SFMl-游戏开发示例-全-
SFMl 游戏开发示例(全)
原文:
zh.annas-archive.org/md5/3045d5ecd28500fab5ace30f6e07b159译者:飞龙
前言
游戏开发是目前最有趣的职业选择之一。除了在这个过程中融入的许多其他领域,它也是一个纯粹想象得以实现的世界。即使在人们可能认为太阳之下无新事的时候,突破性的想法仍然在这个媒介中得以巩固,既作为革命性的里程碑,也是令人兴奋的冒险,将再次让我们感到童真的兴奋。
开始游戏编程比以往任何时候都要容易!除了文档和教程之外,甚至存在一些爱好者,他们实际上整理了代码库,可以用来消除构建不同类型应用程序的冗余或困难部分。碰巧的是,这些库中的一个名为“简单快速多媒体库”,正是本书的重点。
在本书的整个过程中,我们将从头开始构建三个项目,每个项目的复杂度都比前一个项目有所增加。我们将从一个经典的街机游戏克隆版——蛇开始,它介绍了 SFML 的基础和一些将一直持续到最后的框架。当涉及到困难的主题时,我们将开始拼凑第二个项目,将其转变为一个横版平台游戏。本书的剩余章节将专注于构建和打磨一个可以和朋友一起玩在线 RPG 风格的游戏!这些项目的任何细节都不会被遗漏,因为您将全程指导规划并实施这些项目的每一个方面。
如果需要处理的众多功能还没有让您望而却步,那么恭喜您!您即将踏上一次规模巨大的旅程。所以不要让几率吓倒您。我们希望看到您在终点线!
本书涵盖的内容
第一章,它活着!它活着!——设置和第一个程序,涵盖了构建基本 SFML 应用程序所必需的基本知识。
第二章,给它一些结构——构建游戏框架,介绍了本书中将要使用的应用程序的更好框架。它还涵盖了视频游戏中的时间基础。
第三章,动手实践——你需要知道什么,通过完成我们的第一个游戏项目,帮助巩固前几章的所有信息。
第四章,抓起摇杆——输入和事件管理,详细阐述了获取窗口事件和外设信息的过程,以及如何以自动化的方式使用它们。
第五章, 我能暂停吗? – 应用状态,讨论了使用状态机进行状态切换和融合的问题。
第六章, 让它动起来! – 动画和移动你的世界,处理屏幕滚动、资源管理以及精灵表的使用和动画问题。
第七章, 重燃火焰 – 常见游戏设计元素,通过处理实体管理、瓦片地图和碰撞来总结本书的第二个项目。
第八章, 知道的越多 – 常见游戏编程模式,通过介绍几个常见编程模式的基本原理,包括实体组件系统,引入本书的第三个项目。
第九章, 呼吸新鲜空气 – 实体组件系统继续,通过将其分解为其组件和系统来构建常见的游戏功能。
第十章, 我能点击这个吗? – GUI 基础,解释了如何使用基本数据类型实现图形用户界面。
第十一章, 别碰那个红按钮! – 实现 GUI,从上一章结束的地方继续,并总结了 GUI 系统的实现。我们还讨论了三种基本元素类型。
第十二章, 你现在能听到我吗? – 声音和音乐,通过将实体声音和音乐引入其中,使本书的第三个项目更加生动。
第十三章, 我们有了联系! – 网络基础,涵盖了实现最终项目中的网络所需的所有基础知识。
第十四章, 来和我们一起玩! – 多玩家细微差别,通过应用客户端-服务器网络模型和战斗系统,将本书的最终项目转变为一个多人 RPG 风格的死亡匹配。
你需要这本书的内容
由于本书涵盖了 SFML 库,因此有必要下载并设置它。第一章, 它活着!它活着! – 设置和第一个程序,逐步介绍了这个过程。
此外,为了编译我们即将编写的代码,需要一个支持C++11的编译器或 IDE。本书的代码是在Microsoft Visual Studio 2013 IDE 上编写的,并在运行Windows 7的系统上编译的。
本书面向对象
本书旨在为至少对 C++编程语言有相当了解,并且可选地具有游戏设计背景的游戏开发爱好者编写。
术语约定
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"我们可以通过使用include指令来包含其他上下文。"
代码块设置如下:
#include <SFML/Graphics.hpp>
void main(int argc, char** argv[]){
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
#include <SFML/Graphics.hpp>
void main(int argc, char** argv[]){
}
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:"通过右键单击我们的项目并选择属性,导航到配置属性下的VC++目录。"
注意
警告或重要注意事项以如下框的形式出现。
小贴士
小技巧和技巧看起来像这样。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要发送给我们一般反馈,只需发送电子邮件至<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> 联系我们,我们将尽力解决问题。
第一章。它活着!它活着!——设置和第一个程序
建造某物的自豪感是一种强大的感觉。结合探索的刺激,几乎很难缩小我们大多数游戏开发者为何这样做的原因。虽然创造是这个过程的主要力量,但失败支配着它,就像任何其他学科一样。迟早,我们都会处于一个砖墙不仅阻碍了特定项目的开发,甚至可能扼杀工作动机的情况。在这些时候,有一个好的资源可以依靠是至关重要的,尤其是对于刚开始动手的新开发者来说,这就是我们介入的地方。我们的目标是通过对本书过程中开发真实项目来以最实际的方法传授经验。
在本章中,我们将介绍以下内容:
-
在您的机器和 IDE 上设置 SFML
-
平均 SFML 应用程序的流程
-
打开和管理窗口
-
渲染基础
本章的目的是帮助您轻松地进入使用简单快速多媒体库(SFML)开发游戏的过程。让我们先从设置过程开始!
什么是 SFML?
在我们开始向您抛出术语和代码之前,公平起见,我们应该稍微谈谈本书选择的库。正如其标题清楚地表明的那样,SFML 是一个库,它加快并简化了开发依赖于大量使用媒体内容的应用程序的过程,例如视频、文本、静态图像、音频和动画以实现交互性,我们将专注于这些应用程序的特定类别,即视频游戏。它提供了一个易于使用的应用程序****编程接口(API),在 Windows、Linux 和 Mac OS X 上无需安装即可编译和运行,并支持多种语言,如 C、.NET、C++、Java、Ruby、Python 和 Go 等,仅举几例。虽然某些移动设备存在非官方的端口,但官方移动平台的发布仍在进行中。它也是开源的,所以如果有人愿意,总可以去查看源代码。在本书中,我们将专注于使用C++11在Windows平台上进行开发。
为了方便起见,SFML 被分为五个模块,这些模块相互独立,可以根据需要使用:
-
系统:一个核心模块,定义了大多数基本数据结构,提供对线程、时钟、用户数据流和其他基本功能的访问。
-
窗口:此模块提供创建和管理窗口、收集用户输入和事件以及与 OpenGL 一起使用 SFML 的方法。
-
图形:在充分利用窗口模块后,所有剩余的图形需求都由图形模块处理。它处理所有与二维渲染相关的内容。
-
音频:与播放音乐、声音、音频流或录音相关的一切都由这个模块处理。
-
网络:最后一个但绝对是最有趣的模块,它涵盖了向其他计算机发送数据以及使用一些网络协议。
每个这样的模块都编译在一个单独的库(.lib)中,该库具有特定的后缀,表示库是静态链接还是动态链接,以及是否在调试或发布模式下构建。静态链接库意味着它被包含在可执行文件中,与动态链接相反,动态链接需要.dll文件存在于应用程序运行时。后一种情况通过依赖于运行它的机器上存在的库来减少应用程序的整体大小。这也意味着库可以升级而无需修改应用程序,这在修复错误时非常有用。另一方面,静态链接允许你的代码在更受限的环境中执行。
确保你的应用程序以适合情况的模式构建也很重要。调试模式的应用程序包含大量有用的额外信息,这些信息在查找程序中的缺陷时非常有用。这使得应用程序运行速度明显减慢,不应用于除测试以外的任何目的。当以发布模式构建你的项目时,也会开启大量的不同优化,这不仅提供了更小的可执行文件大小,还提供了更快的运行速度。如果应用程序要用于除调试以外的任何目的,则应该以这种模式编译。
每个模块都按照格式sfml-module[-s][-d].lib命名。例如,一个静态链接并编译在调试模式下的图形库的文件名可能看起来像这样:sfml-graphics-s-d.lib。当动态链接或编译在发布模式下时,后缀需要被省略。SFML 还要求在静态链接时定义SFML_STATIC宏,我们将在设置第一个项目时简要介绍。
关于单独的库,需要记住的一个重要事情是它们仍然有依赖关系。窗口、图形、音频和网络库依赖于系统库,任何 SFML 应用程序要编译和运行都必须链接到系统库。图形库也依赖于窗口库,所以如果应用程序进行任何绘图,所有三个库都必须链接。音频和网络库只依赖于系统库。
注意
从版本 2.2 开始,当静态链接 SFML 时,其依赖项也必须链接到项目中。这些依赖项在 2.2 和 2.3 主要版本之间有所不同,因此我们将坚持使用最新版本,即 2.3。图形库需要 opengl32.lib、freetype.lib 和 jpeg.lib 库。窗口库依赖于 opengl32.lib、winmm.lib 和 gdi32.lib。仅链接到系统库只需要 winmm.lib 库,而 sfml-network-s.lib 需要依赖 ws2_32.lib 才能工作。最后,声音库依赖于 openal32.lib、flac.lib、vorbisenc.lib、vorbisfile.lib、vorbis.lib 和 ogg.lib。
这五个模块中的每一个都有一个相应的头文件,必须包含以利用其功能。例如,包含图形头文件将看起来像这样:
#include <SFML/Graphics.hpp>
通过指定模块中所需的实际头文件,也可以避免包含整个模块头文件:
#include <SFML/Graphics/Color.hpp>
这让你有机会只包含绝对必要的部分。
注意
在包含库时使用正斜杠是一种最佳实践。不同的操作系统无法识别包含反斜杠的路径。
SFML 许可协议
无论你为项目使用何种类型的库,了解你可以用它做什么以及不能做什么都很重要。SFML 在 zlib/libpng 许可协议下授权,这远非限制性。它允许任何人出于任何目的使用 SFML,包括商业应用,以及修改和重新分发它,前提是保留原始软件的作者信用不变,并且产品标记为修改过的源代码。对于使用原始软件的信用并不强制要求,但会表示感谢。更多信息,请访问:opensource.org/licenses/Zlib。
资源和安装
你可以在:www.sfml-dev.org/download.php 下载库的最新稳定预构建版本。你也可以从这里获取最新的 Git 修订版并自行编译:github.com/LaurentGomila/SFML。前一种选项更容易,也推荐给初学者。然而,你必须等待主要版本发布,尽管它们更稳定。要自行构建 SFML,你需要使用 CMake,这是一个用于生成解决方案或 g++ Makefiles 的工具,具体取决于编译它的软件。官方 SFML 网站提供了关于如何自行构建的教程:www.sfml-dev.org/tutorials。
在获取到 SFML 的预构建版本或自己编译它之后,将其移动到一个更永久的位置是个好主意,希望路径尽可能短。在本地驱动器上为 SFML 和其他库分配一个目录以供快速链接和随时使用并不罕见。当处理同一库的多个版本时,这也很有用。在本书的其余部分,我们将假设我们的 SFML 库和头文件目录位于C:\libs\SFML-2.3,因此库目录为C:\libs\SFML-2.3\lib,头文件目录为C:\libs\SFML-2.3\include。这些目录必须在您选择的编译器中正确设置,以便项目可以构建。在本书的整个过程中,我们将使用 Microsoft Visual Studio 2013,然而,有关为 Code::Blocks 设置项目的说明可以在 SFML 网站上的教程部分找到。
设置 Microsoft Visual Studio 项目
在您的 IDE 中创建一个新的解决方案。它可以是一个 Win32 应用程序或控制台应用程序,这实际上并不重要,尽管一个漂亮的控制台窗口通常对调试很有用。我总是选择空项目选项以避免任何自动生成的代码。完成这些后,让我们准备我们的项目以使用 SFML:
-
通过右键点击我们的项目并选择属性,导航到配置属性下的VC++ 目录。
-
对于我们来说,只有两个字段是关注的重点,即包含目录和库目录。确保为调试和发布配置都提供了 SFML 库和包含目录的路径。
-
当静态链接 SFML 时,需要在C/C++下的预处理器部分定义
SFML_STATIC宏。 -
接下来是链接器下的通用部分的附加库目录。确保它也指向 SFML 库目录,在调试和发布配置中都要如此。
-
最后,我们需要通过编辑链接器下的输入部分的附加依赖项字段来设置项目依赖项。当使用静态链接库时,调试配置看起来可能如下所示:
sfml-graphics-s-d.lib; sfml-window-s-d.lib; sfml-system-s-d.lib; opengl32.lib; freetype.lib; jpeg.lib; winmm.lib; gdi32.lib;记住,由于库依赖性,我们需要包含系统库。同时注意使用
-s和-d后缀。确保调试和发布配置都已设置,并且发布配置省略了-d后缀。
打开一个窗口
如您所知,在屏幕上绘制东西需要一个窗口存在。幸运的是,SFML 允许我们轻松打开和管理我们自己的窗口!让我们像往常一样,通过向我们的项目添加一个名为Main.cpp的文件开始。这将是我们的应用程序的入口点。一个基本应用程序的骨架如下所示:
#include <SFML/Graphics.hpp>
void main(int argc, char** argv[]){
}
注意,我们已经包含了 SFML 图形头文件。这将为我们提供打开窗口和绘制所需的一切,所以无需多言,让我们看看打开我们窗口的代码:
#include <SFML/Graphics.hpp>
void main(int argc, char** argv[]){
sf::RenderWindow window(sf::VideoMode(640,480), "First window!");
while(window.isOpen()){
sf::Event event;
while(window.pollEvent(event)){
if(event.type == sf::Event::Closed){
// Close window button clicked.
window.close();
}
}
window.clear(sf::Color::Black);
// Draw here.
window.display();
}
}
小贴士
SFML 使用 sf 命名空间,因此我们必须在它的数据类型、枚举和静态类成员前加上前缀"sf::"。
在这里我们首先声明并初始化我们的RenderWindow类型的窗口实例。在这种情况下,我们使用了它的构造函数,然而也可以留空并在稍后通过传递完全相同的参数来利用它的create方法,这些参数中至少需要两个:一个sf::videoMode和一个用于窗口的std::string标题。视频模式的构造函数需要两个参数:内部窗口的宽度和高度。还有一个可选的参数用于设置每像素的位数,默认为 32 位,这对于良好的渲染已经足够,所以现在不必为此烦恼。
在我们的窗口实例创建之后,我们进入一个 while 循环,使用我们的窗口方法之一来检查它是否仍然打开,即isOpen。这实际上创建了我们游戏循环,这是我们代码的核心部分。
让我们看看一个典型游戏的示意图:

游戏循环的目的是检查事件和输入,在帧之间更新我们的游戏世界,这意味着移动玩家、敌人、检查变化等,最后在屏幕上绘制一切。这个过程需要每秒重复多次,直到窗口关闭。次数因应用程序而异,有时每秒高达数千次迭代。第二章,“给它一些结构 - 构建游戏框架”将涵盖管理并限制应用程序的帧率以及使游戏以恒定速度运行。
大多数应用程序都需要一种方法来检查窗口是否已关闭、调整大小或移动。这就是事件处理的作用。SFML 提供了一个事件类,我们可以用它来存储我们的事件信息。在游戏循环的每次迭代中,我们需要通过使用窗口实例的pollEvent方法来检查发生的事件并处理它们。在这种情况下,我们只对当鼠标点击关闭窗口按钮时触发的事件感兴趣。我们可以检查类Event的公共成员type是否与适当的枚举成员匹配,在这种情况下是sf::Event::Closed。如果是,我们可以调用窗口实例的close方法,我们的程序将终止。
小贴士
在所有 SFML 应用程序中必须处理事件。如果没有事件循环轮询事件,窗口将变得无响应,因为它不仅向用户提供事件信息,而且还为窗口本身提供了一种处理其内部事件的方式,这对于它能够对移动或调整大小做出反应是必要的。
在完成所有这些之后,清除前一次迭代的窗口是必要的。如果不这样做,我们将在其上绘制的所有内容都会堆叠并造成混乱。想象一下屏幕是一块白板,你想要在别人在上面乱涂乱画之后在上面绘制新的东西。然而,我们不需要拿橡皮擦,而是需要调用我们的窗口实例的 clear 方法,该方法接受一个 sf::Color 数据类型作为参数,如果没有提供参数,则默认为黑色。屏幕可以被清除为 sf::Color 类提供的任何枚举颜色,该类作为静态成员提供,或者我们可以传递一个 sf::Color 实例,该实例的构造函数接受单个颜色通道的 无符号整数 值:红色、绿色、蓝色,以及可选的 alpha。后者为我们提供了一个明确指定所需颜色范围的方法,如下所示:
window.clear(sf::Color(0,0,0,255));
最后,我们调用 window.display() 方法来显示所有绘制的内容。这利用了一种称为双缓冲的技术,这在当今的游戏中是标准的。基本上,任何绘制的内容都不会立即绘制到屏幕上,而是绘制到一个隐藏的缓冲区中,然后在调用 display 后将缓冲区的内容复制到我们的窗口中。双缓冲用于防止图形伪影,如撕裂,这是由于显卡驱动程序在帧缓冲区仍在写入时从中提取导致的,结果是一个部分绘制的图像被显示出来。调用 display 方法是强制性的,不能避免,否则窗口将显示为一个静态的正方形,没有任何变化发生。
小贴士
请记住,如果应用程序是动态链接的,则必须在可执行文件依赖的同一目录中包含 SFML 库 .dll 文件。
在编译和执行代码后,我们将发现自己有一个空白的控制台窗口和一个黑色的 640x480 px 窗口坐在它上面,少于 20 行代码,并且一个打开的窗口。这并不令人兴奋,但仍然比 Atari 2600 上的 E.T. 要好。让我们在屏幕上画些东西吧!
SFML 绘图基础
就像幼儿园一样,我们将从基本形状开始,逐步过渡到更复杂的类型。让我们通过首先声明它并设置它来绘制一个矩形形状:
sf::RectangleShape rectangle(sf::Vector2f(128.0f,128.0f));
rectangle.setFillColor(sf::Color::Red);
rectangle.setPosition(320,240);
sf::RectangleShape 是 sf::Shape 的一个派生类,它继承自 sf::Drawable,这是一个抽象基类,所有实体都必须继承并实现其虚拟方法才能在屏幕上绘制。它还继承自 sf::Transformable,这提供了移动、缩放和旋转实体的所有必要功能。这种关系允许我们的矩形进行变换,并且可以在屏幕上渲染。在其构造函数中,我们引入了一个新的数据类型:sf::Vector2f。它本质上只是一个包含两个 float 的结构,x 和 y,代表二维宇宙中的一个点,不要与 std::vector 混淆,后者是一个数据容器。
小贴士
SFML 为整数和无符号整数提供了一些其他向量类型:sf::Vector2i 和 sf::Vector2u。实际的 sf::Vector2 类是模板化的,因此可以使用任何原始数据类型与之一起使用,如下所示:
sf::Vector2<long> m_vector;
矩形构造函数接受一个 sf::Vector2f 类型的单个参数,它表示矩形的像素大小,这是可选的。在第二行,我们通过提供 SFML 的预定义颜色之一来设置矩形的填充颜色。最后,我们通过调用 setPosition 方法并传入其位置(以像素为单位)以及 x 和 y 轴来设置我们形状的位置,在这种情况下是窗口的中心。在我们能够绘制矩形之前,只剩下一件事要做:
window.draw(rectangle); // Render our shape.
这行代码就在我们调用 window.display(); 之前,负责将我们的形状带到屏幕上。让我们运行我们的修改后的应用程序并查看结果:

现在我们屏幕上画了一个红色方块,但它并不完全居中。这是因为任何 sf::Transformable 的默认原点,它只是一个代表对象全局位置的二维点,位于局部坐标 (0,0),即左上角。在这种情况下,这意味着这个矩形的左上角被设置为屏幕中心的位置。这可以通过调用 setOrigin 方法并传入我们形状的所需局部坐标来轻松解决,我们希望新的原点正好在中间:
rectangle.setOrigin(64.0f,64.0f);
如果由于某种原因不知道形状的大小,矩形类提供了一个很好的方法 getSize,它返回一个包含大小的 float 向量:
rectangle.setOrigin(rectangle.getSize().x / 2, rectangle.getSize().y / 2);
现在我们的形状正快乐地坐在黑色屏幕的正中间。使这一切成为可能的整个代码段看起来有点像这样:
#include <SFML/Graphics.hpp>
void main(int argc, char** argv[]){
sf::RenderWindow window(sf::VideoMode(640,480),
"Rendering the rectangle.");
// Creating our shape.
sf::RectangleShape rectangle(sf::Vector2f(128.0f,128.0f));
rectangle.setFillColor(sf::Color::Red);
rectangle.setPosition(320,240);
rectangle.setOrigin(rectangle.getSize().x / 2, rectangle.getSize().y / 2);
while(window.isOpen()){
sf::Event event;
while(window.pollEvent(event)){
if(event.type == sf::Event::Closed){
// Close window button clicked.
window.close();
}
}
window.clear(sf::Color::Black);
window.draw(rectangle); // Drawing our shape.
window.display();
}
}
在 SFML 中绘制图像
为了在屏幕上绘制图像,我们需要熟悉两个类:sf::Texture 和 sf::Sprite。纹理本质上只是一个图像,它位于图形卡上,目的是使其绘制更快。任何硬盘上的图片都可以通过加载它来转换成纹理:
sf::Texture texture;
if(!texture.loadFromFile("filename.png"){
// Handle an error.
}
loadFromFile 方法返回一个布尔值,这提供了一种简单的方式来处理加载错误,例如文件未找到。如果你同时打开了控制台窗口和 SFML 窗口,你将注意到在纹理加载失败的情况下会打印出一些信息:
无法加载图像 "filename.png"。原因:无法打开文件
提示
如果在 loadFromFile 方法中未指定完整路径,它将被解释为相对于工作目录。重要的是要注意,虽然当单独启动时,工作目录通常与可执行文件相同,但在 IDE(在我们的例子中是 Microsoft Visual Studio)中编译和运行应用程序时,它通常设置为项目目录而不是调试或发布文件夹。如果你提供了相对路径,请确保将你要加载的资源放在与你的 .vcxproj 项目文件相同的目录中。
还可以从内存、自定义输入流或 sf::Image 工具类中加载你的纹理,这些工具类帮助存储和操作图像数据作为原始像素,这将在后面的章节中更广泛地介绍。
什么是精灵?
精灵,就像我们迄今为止所使用的 sf::Shape 派生类一样,是一个 sf::Drawable 对象,在这种情况下代表一个 sf::Texture,并且支持一系列变换,包括物理变换和图形变换。把它想象成一个简单应用了纹理的矩形:

sf::Sprite 提供了在屏幕上渲染纹理或其一部分的手段,以及变换它的手段,这使得精灵依赖于纹理的使用。由于 sf::Texture 不是一个轻量级对象,sf::Sprite 出于性能原因使用它所绑定纹理的像素数据,这意味着只要精灵在使用它所绑定的纹理,该纹理就必须在内存中保持活跃,并且只能在不再使用时才能被释放。在我们设置好纹理之后,设置精灵并绘制它就变得非常简单:
sf::Sprite sprite(texture);
...
window.draw(sprite);
将纹理通过引用传递给精灵构造函数是可选的。可以通过使用 setTexture 方法在任意时刻更改它所绑定的纹理:
sprite.setTexture(texture);
由于 sf::Sprite,就像 sf::Shape 一样,继承自 sf::Transformable,我们可以访问相同的方法来操作和获取原点、位置、缩放和旋转。
是时候应用我们迄今为止所获得的所有知识,并编写一个利用这些知识的基本应用程序了:
void main(int argc, char** argv[]){
sf::RenderWindow window(sf::VideoMode(640,480),
"Bouncing mushroom.");
sf::Texture mushroomTexture;
mushroomTexture.loadFromFile("Mushroom.png");
sf::Sprite mushroom(mushroomTexture);
sf::Vector2u size = mushroomTexture.getSize();
mushroom.setOrigin(size.x / 2, size.y / 2);
sf::Vector2f increment(0.4f, 0.4f);
while(window.isOpen()){
sf::Event event;
while(window.pollEvent(event)){
if(event.type == sf::Event::Closed){
window.close();
}
}
if((mushroom.getPosition().x + (size.x / 2) >
window.getSize().x && increment.x > 0) ||
(mushroom.getPosition().x - (size.x / 2) < 0 &&
increment.x < 0))
{
// Reverse the direction on X axis.
increment.x = -increment.x;
}
if((mushroom.getPosition().y + (size.y / 2) >
window.getSize().y && increment.y > 0) ||
(mushroom.getPosition().y - (size.y / 2) < 0 &&
increment.y < 0))
{
// Reverse the direction on Y axis.
increment.y = -increment.y;
}
mushroom.setPosition(mushroom.getPosition() + increment);
window.clear(sf::Color(16,16,16,255)); // Dark gray.
window.draw(mushroom); // Drawing our sprite.
window.display();
}
}
上述代码将产生一个在窗口周围弹跳的精灵,每次撞击窗口边界时都会改变方向。为了使代码更短,省略了加载纹理的错误检查。在主循环的事件处理部分之后的两条if语句负责检查精灵的当前位置并更新表示正负号的增量值的方向,因为你可以沿着单轴的正负端移动。记住,形状的默认原点是左上角,如这里所示:

由于这个原因,在检查形状是否超出底部或右侧边界时,我们必须补偿形状的整个宽度和高度,或者确保其原点位于中间。在这种情况下,我们选择后者,并从蘑菇的位置添加或减去纹理大小的一半来检查它是否仍然在我们希望的空间内。如果不是,只需将轴上超出屏幕的增量浮点向量的符号取反,voila!我们就有了反弹效果!

作为额外加分,你可以随意尝试使用sf::Sprite的setColor方法,它可以用来用所需颜色着色精灵,并通过调整sf::Color类型的第四个参数(对应于 alpha 通道)使其透明:
mushroom.setColor(sf::Color(255, 0, 0, 255)); // Red tint.
常见错误
经常,SFML 的新用户会尝试做一些类似这样的事情:
sf::Sprite CreateSprite(std::string l_path){
sf::Texture texture;
texture.loadFromFile(l_path);
. . .
return sf::Sprite(texture);
}
当尝试绘制返回的精灵时,在精灵应该所在的位置出现了一个白色方块。发生了什么?好吧,回顾一下我们讨论纹理的部分。只要精灵在使用纹理,纹理就必须在作用域内,因为它存储了对纹理实例的指针。从上面的例子中,我们可以看到它是静态分配的,所以当函数返回时,在栈上分配的纹理现在超出了作用域,并被弹出。Poof。消失了。现在精灵指向了一个它无法使用的无效资源,并绘制了一个白色矩形。现在这并不是说你可以通过调用新函数在堆上分配内存,但这不是本例的重点。从这个例子中我们可以吸取的教训是,在处理任何应用程序时,适当的资源管理至关重要,因此请注意你资源的生命周期。在第六章中,我们将讨论设计自己的资源管理器并自动处理此类情况。
另一个常见的错误是保留过多的纹理实例。一个纹理可以被尽可能多的精灵使用。sf::Texture 并非一个轻量级对象,你可以使用相同的纹理来创建大量的 sf::Sprite 实例,同时仍然获得良好的性能。对于显卡来说,重新加载纹理也是昂贵的,因此尽可能少地保留纹理是如果你想让应用程序运行得更快的话,你需要真正记住的事情之一。这就是使用瓦片图集的思路,它只是包含在其中的小图像的大纹理。这提供了更好的性能,因为我们不需要保留数百个纹理实例并逐个加载文件,我们只需加载单个纹理,通过指定读取区域来访问任何所需的瓦片。这一点将在后面的章节中进一步讨论。
使用不受支持的图像格式或格式选项是另一个相当常见的问题。始终最好查阅官方网站以获取有关文件格式支持的最新信息。一个简短的列表可以在这里找到:www.sfml-dev.org/documentation/2.2/classsf_1_1Image.php#a9e4f2aa8e36d0cabde5ed5a4ef80290b
最后,需要提一下 LNK2019 错误。无论指南、教程或书籍多少次提到如何正确设置和链接你的项目到任何给定的库,在这个世界上没有什么是完美的,尤其是人类。当你尝试编译项目时,你的 IDE 输出可能会被类似以下的消息淹没:
error LNK2019: unresolved external symbol. . .
不要慌张,并且请,不要在某个论坛上发一个包含数百行代码的新帖子。你只是忘记在链接器输入中包含所有必需的附加依赖项。回顾我们介绍如何使用 SFML 设置项目的部分,并确保那里一切都正确。此外,请记住,你需要包含其他库所依赖的库。例如,系统库始终需要包含,如果使用图形模块,则需要包含窗口库,等等。静态链接库需要链接它们的依赖项。
摘要
本章涵盖了大量的内容。如果你是初学者,其中一些内容可能一开始有点难以理解,但请不要气馁。将这一知识应用到实践中是更好地理解它的关键。在继续到下一章之前,确保你对到目前为止所介绍的一切都感到自信。
如果你真的能通读这一章,并且充满信心地说你已经准备好向前迈进,我们想恭喜你迈出了成为成功的 SFML 游戏开发者的重要第一步!为什么就此止步呢?在下一章中,我们将介绍一种更好的方法来构建我们的第一个游戏项目的代码结构。除此之外,我们还将引入时间管理,并通过构建你第一个完整功能游戏的主要部分来实际应用到目前为止所涵盖的所有内容。我们面前还有大量的工作要做,所以赶快行动起来!你的软件不会自己写出来。
第二章. 给它一些结构 – 构建游戏框架
在一个结构不良的项目上工作就像在没有地基的情况下建造房子:很难维护,极其不稳定,你可能会很快放弃它。虽然我们在第一章中工作的代码,它活着!它活着! – 设置和第一个程序,在非常小的规模上是功能性的,并且可以管理,但在没有首先构建一个坚实的框架的情况下扩展它很可能会导致大量的意大利面代码(不要与意大利面代码或千层面代码混淆)。虽然听起来很美味,但这个贬义词描述了在无结构且以“纠缠”方式执行源代码中实现新特征的指数级困难,这是我们将会着重避免的。
在本章中,我们将涵盖:
-
设计窗口类和主游戏类
-
代码重构和适当的架构
-
在应用程序中适当的时间管理的重要性
-
使用
sf::Clock和sf::Time类 -
固定和可变的时间步长
晋升到意大利面
让我们从简单开始。每个游戏都需要有一个窗口,正如你从第一章,它活着!它活着! – 设置和第一个程序中已经知道的,它需要被创建、销毁,并且需要处理其事件。它还需要能够清除屏幕并更新自身以显示在屏幕清除之后绘制的任何内容。此外,跟踪窗口是否正在关闭以及它是否处于全屏模式,以及有一个方法可以切换后者将会非常有用。最后,我们当然需要将内容绘制到窗口中。了解所有这些,我们的窗口类头文件可能会预见到如下所示:
class Window{
public:
Window();
Window(const std::string& l_title,const sf::Vector2u& l_size);
~Window();
void BeginDraw(); // Clear the window.
void EndDraw(); // Display the changes.
void Update();
bool IsDone();
bool IsFullscreen();
sf::Vector2u GetWindowSize();
void ToggleFullscreen();
void Draw(sf::Drawable& l_drawable);
private:
void Setup(const std::string& l_title, const sf::Vector2u& l_size);
void Destroy();
void Create();
sf::RenderWindow m_window;
sf::Vector2u m_windowSize;
std::string m_windowTitle;
bool m_isDone;
bool m_isFullscreen;
};
由于我们希望在内部处理窗口的设置,设置方法被设置为私有,以及销毁和创建方法。将这些视为用户不需要了解的辅助方法。在设置完成后保留某些信息是个好主意,比如窗口大小或显示在其上的标题。最后,我们保留两个布尔变量来跟踪窗口是否关闭以及其全屏状态。
小贴士
我们窗口类中使用的命名约定被称为匈牙利符号法。当然,使用它不是必需的,但在处理大量代码、尝试追踪错误以及在更大的人群中工作时,它可能很有用。我们将在这本书中利用它。更多关于它的信息可以在这里找到:en.wikipedia.org/wiki/Hungarian_notation
实现窗口类
现在我们有了我们的蓝图,让我们开始实际构建我们的窗口类。入口和出口点似乎是开始的好地方:
Window::Window(){ Setup("Window", sf::Vector2u(640,480)); }
Window::Window(const std::string& l_title, const sf::Vector2u& l_size)
{
Setup(l_title,l_size);
}
Window::~Window(){ Destroy(); }
构造函数和析构函数的实现只是简单地利用我们很快将要实现的辅助方法。还有一个默认构造函数,它不接受任何参数并初始化一些预设的默认值,这不是必需的,但很方便。话虽如此,让我们看看设置方法:
void Window::Setup(const std::string l_title, const sf::Vector2u& l_size)
{
m_windowTitle = l_title;
m_windowSize = l_size;
m_isFullscreen = false;
m_isDone = false;
Create();
}
再次强调,这很简单。如前所述,它初始化并跟踪一些将要传递给构造函数的窗口属性。除此之外,它还调用另一个名为 Create 的方法来进一步分解代码,这是我们将在实现 Destroy 方法之后要实现的内容:
void Window::Create(){
auto style = (m_isFullscreen ? sf::Style::Fullscreen
: sf::Style::Default);
m_window.create({ m_windowSize.x, m_windowSize.y, 32 },
m_windowTitle, style);
}
void Window::Destroy(){
m_window.close();
}
在这里,我们介绍 SFML 提供的一种新数据类型:sf::Uint32。它存储在 style 本地变量中,通过使用 auto 关键字自动推断为该类型。它是一个无符号的固定大小整数类型。在这种情况下,我们使用的是 32 位 整数,尽管 SFML 提供了 8 位、16 位 和 32 位 的有符号和无符号类型。我们使用这个值来保存窗口的当前样式,使用 三元运算符 并将其分配给窗口样式枚举的默认或全屏样式。这是 SFML 中所有可能的窗口样式的完整列表:
| 枚举器 | 描述 | 互斥 |
|---|---|---|
| 无 | 没有边框或标题栏。最简约的样式。 | 是 |
| 全屏 | 全屏模式。 | 是 |
| 标题栏 | 标题栏和固定边框。 | 否 |
| 关闭 | 标题栏和关闭按钮。 | 否 |
| 调整大小 | 标题栏、可调整大小的边框和最大化按钮。 | 否 |
| 默认 | 标题栏、可调整大小的边框、最大化和关闭按钮。 | 否 |
互斥列仅表示所讨论的样式是否可以与其他样式同时使用。例如,通过在 C++ 中使用位或运算符组合两种样式,可以拥有一个带有标题栏、可调整大小的边框、最大化按钮和关闭按钮的窗口:
auto style = sf::Style::Resize | sf::Style::Close;
如果一个样式是互斥的,则不能以这种方式与其他任何样式一起使用。
一旦我们有了我们的样式,我们只需将其传递给窗口的 create 方法,以及构造时得到的 sf::VideoMode 类型,使用统一初始化。就这么简单。
我们 Window 类的 destroy 方法将简单地通过调用其 close 方法来关闭窗口。这里需要注意的是,关闭的窗口将销毁其所有附加的资源,但您仍然可以再次调用其 create 方法来重新创建窗口。如果窗口关闭,轮询事件和调用 display 方法仍然有效,但不会有任何效果。
让我们通过在适当的 update 方法中处理窗口的事件来继续分解我们之前的一块代码:
void Window::Update(){
sf::Event event;
while(m_window.pollEvent(event)){
if(event.type == sf::Event::Closed){
m_isDone = true;
} else if(event.type == sf::Event::KeyPressed &&
event.key.code == sf::Keyboard::F5)
{
ToggleFullscreen();
}
}
}
这和之前一样,我们只是在处理事件。然而,我们不是立即关闭窗口,而是简单地翻转我们用来检查窗口是否已关闭的布尔标志m_isDone。由于我们还在切换窗口的全屏和正常状态之间切换,我们需要注意另一种类型的事件:sf::Event::KeyPressed。每当键盘按键被按下时,都会分发此事件,并且它包含有关该键的信息,存储在event.key结构中。目前,我们只对按下的键的代码感兴趣,然后我们可以将其与sf::Keyboard枚举表进行比较。在接收到按下F5键的事件时,我们调用ToggleFullscreen方法,现在由于我们将代码分解成可管理的部分,这个方法实现起来相当简单:
void Window::ToggleFullscreen(){
m_isFullscreen = !m_isFullscreen;
Destroy();
Create();
}
如您所见,我们在这里做的唯一一件事就是反转我们的布尔类成员m_isFullscreen的值,它跟踪窗口状态。之后,我们需要销毁并重新创建窗口,以便使其遵守我们的更改。让我们看看绘图方法:
void Window::BeginDraw(){ m_window.clear(sf::Color::Black); }
void Window::EndDraw(){ m_window.display(); }
这里没有引入任何新内容。我们只是在BeginDraw和EndDraw方法中封装了清除和显示的功能。现在剩下的只是简单的辅助方法:
bool Window::IsDone(){ return m_isDone; }
bool Window::IsFullscreen(){ return m_isFullscreen; }
sf::Vector2u Window::GetWindowSize(){ return m_windowSize; }
void Window::Draw(sf::Drawable&l_drawable){
m_window.draw(l_drawable);
}
这些基本方法提供了获取窗口信息的方式,同时不会给窗口类外部的东西太多控制。目前,我们的窗口类已经足够。
构建游戏类
我们已经很好地完成了窗口类的基本功能封装,但这并不是唯一需要重构的代码块。在第一章中,“它活着!它活着!——设置和第一个程序”,我们讨论了主游戏循环及其内容,主要是处理输入、更新游戏世界和玩家,最后,在屏幕上渲染一切。将所有这些功能塞进游戏循环中通常会导致代码混乱,既然我们想要摆脱这种状况,让我们考虑一个更好的结构,这将允许这种行为:
#include "Game.h"
void main(int argc, void** argv[]){
// Program entry point.
Game game; // Creating our game object.
while(!game.GetWindow()->IsDone()){
// Game loop.
game.HandleInput();
game.Update();
game.Render();
}
}
上述代码代表了我们的main.cpp文件的全部内容,并完美地说明了正确结构的游戏类的使用,它不会在无限循环中调用适当的顺序直到窗口关闭。为了清晰起见,让我们看看游戏类头文件的简化版本:
class Game{
public:
Game();
~Game();
void HandleInput();
void Update();
void Render();
Window* GetWindow();
...
private:
void MoveMushroom();
Window m_window;
...
};
注意,游戏类持有我们的窗口实例。它可以以不同的方式完成,但就我们当前的需求而言,这已经足够了。
将我们的代码投入实际应用
我们现在准备重新实现来自第一章的弹跳蘑菇演示,“它活着!它活着!——设置和第一个程序”。鉴于它的简单性,我们将向您展示如何将我们之前编写的代码适应到我们的新结构中的整个过程。让我们首先设置我们将要使用的窗口和图形:
Game::Game(): m_window("Chapter 2", sf::Vector2u(800,600)){
// Setting up class members.
m_mushroomTexture.loadFromFile("Mushroom.png");
m_mushroom.setTexture(m_mushroomTexture);
m_increment = sf::Vector2i(4,4);
}
由于我们没有什么要清理的,我们的游戏析构函数现在仍然是空的:
Game::~Game(){}
对于这个例子,我们没有必要检查输入,所以现在让我们暂时忽略这个方法。然而,我们将要做的是更新我们的精灵在每个帧的位置:
void Game::Update(){
m_window.Update(); // Update window events.
MoveMushroom();
}
void Game::MoveMushroom(){
sf::Vector2u l_windSize = m_window.GetWindowSize();
sf::Vector2u l_textSize = m_mushroomTexture.getSize();
if((m_mushroom.getPosition().x >
l_windSize.x - l_textSize.x&&m_increment.x> 0) ||
(m_mushroom.getPosition().x < 0 &&m_increment.x< 0)){
m_increment.x = -m_increment.x;
}
if((m_mushroom.getPosition().y >
l_windSize.y - l_textSize.y&&m_increment.y> 0) ||
(m_mushroom.getPosition().y < 0 &&m_increment.y< 0)){
m_increment.y = -m_increment.y;
}
m_mushroom.setPosition(
m_mushroom.getPosition().x + m_increment.x,
m_mushroom.getPosition().y + m_increment.y);
}
你可能首先注意到的是我们窗口类的更新方法调用。我们已经在 SFML 中讨论了 事件处理 的重要性,但仍然值得再次强调。其余的代码基本上是一样的,只是我们现在有一个单独的方法负责更新蘑菇精灵的位置。我们使用了两个局部变量来保存窗口和纹理的大小,以提高可读性,但这基本上就是所有的事情。现在是时候将我们的精灵绘制到屏幕上了:
void Game::Render(){
m_window.BeginDraw(); // Clear.
m_window.Draw(m_mushroom);
m_window.EndDraw(); // Display.
}
再次,代码相当直接。我们的窗口类做所有的工作,我们只需要调用 Draw 方法,并在清除屏幕和显示更改的包装方法之间传递我们的 sf::Drawable。
将一切组合起来并运行它应该会产生与第一章,“它活着!它活着!——设置和第一个程序”中相同的弹跳蘑菇。然而,你可能已经注意到,精灵的移动方式取决于你的电脑有多忙。在这个观察中,隐藏着一个关于游戏开发的重要教训。
硬件和执行时间
让我们回到 1992 年 5 月 5 日。Apogee 软件开始发布由 id Software 开发的现在已知的经典之作 Wolfenstein 3D:

具有远见卓识的人,约翰·卡马克,在个人电脑上的第一人称射击游戏类型中迈出了巨大的步伐,不仅普及了这一类型,而且还彻底改变了它。它的巨大成功不容小觑,因为即使现在也很难准确预测它被下载了多少次。在那个时代长大的人,有时不禁会感到怀旧,并试图再次玩这款游戏。自从它在个人电脑上的 DOS 操作系统上首次发布以来,它已经被移植到许多其他操作系统和游戏机上。虽然现在仍然可以玩它,但我们已经从使用 DOS 的时代走了很长的路。我们软件运行的环境已经发生了根本性的变化,因此过去的软件不再兼容,这就需要仿真。
注意
模拟器是软件、硬件或两者的组合,它模拟了在主系统(称为主机)上运行的一定系统的功能,通常称为客户机。
用于此目的的每个模拟器不仅需要模仿一个与你要尝试玩的游戏兼容的系统的软件,还需要模仿硬件。为什么这很重要?在 DOS 时代,大多数游戏都依赖于硬件的大致相似性。在《狼人之三》的例子中,它假设它在一个4.77 MHz的系统上运行,这使得开发者可以通过不编写内部定时循环来节省一些时钟周期以提高效率。像《狼人之三》这样的游戏消耗了所有的处理能力,这在当时是一个很好的策略,直到更强大、更快的处理器出现。如今,即使是所有最便宜的消费级处理器,4.77 MHz 的速度也相形见绌,因此,正确模拟特定系统还需要减少 CPU 时钟周期,否则这些游戏会运行得太快,这正是当模拟器设置不正确且没有足够限制速度时发生的情况。
虽然这是一个最极端的例子,但速度管理是任何需要以恒定速度运行的软件的重要组件。抛开硬件和架构的不同选择,你的软件可能仅仅基于系统在那一刻的繁忙程度或代码在渲染图像之前每轮需要完成的任务的不同,运行得更快或更慢。考虑以下说明:

左侧和右侧的变化都在 1 秒的时间间隔内发生。在这两种情况下,代码完全相同。唯一的区别是主循环在那个间隔内完成的迭代次数。可以预见,硬件越慢,执行你的代码所需的时间就越长,因此迭代次数会更少,导致在 1 秒的时间间隔内精灵移动的次数更少,最终看起来像左侧。作为一个游戏开发者,确保你的产品在指定的规范指南内的所有系统上运行相同是很重要的。这就是 SFML 时间管理发挥作用的地方。
控制帧率
SFML 提供了一种为你的应用程序设置帧率上限的方法。这是sf::RenderWindow类中的一个方法,恰当地命名为setFramerateLimit:
m_window.setFramerateLimit(60); // 60 FPS cap.
虽然这个特性并不绝对可靠,但它确保了只要提供的上限不是太高,应用程序的帧率以合理的精度被限制在提供的最大值。记住,限制帧率会降低程序的总体 CPU 消耗,因为它不再需要多次更新和重新绘制相同的场景。然而,这也会为较慢的硬件带来问题。如果帧率低于提供的值,模拟也会运行得更慢。设置限制只解决了我们问题的一半。让我们看看一些更实际的东西。进入sf::Clock!
使用 SFML 时钟
sf::Clock类非常简单且轻量级,因此它只有两个方法:getElapsedTime()和restart()。它的唯一目的是以操作系统能提供的最精确的方式测量自上次时钟重启或创建以来的经过时间。当使用getElapsedTime方法检索经过时间时,它返回一个类型为sf::Time的值。背后的主要原因是增加一层抽象,以提供灵活性和避免强加任何固定数据类型。《sf::Time`类也是轻量级的,并提供三个将经过时间转换为秒的方法,返回一个浮点值,毫秒,返回一个32 位整数值,以及微秒,返回一个64 位整数值,如下所示:
sf::Clock clock;
...
sf::Time time = clock.getElapsedTime();
float seconds = time.asSeconds();
sf::Int32 milliseconds = time.asMilliseconds();
sf::Int64 microseconds = time.asMicroseconds();
...
time = clock.restart();
如您所见,restart方法也返回一个sf::Time值。这是为了防止在调用restart方法之前立即调用getElapsedTime,在这两个调用之间会有一些时间流逝,否则将无法计算。这对我们有什么用呢?好吧,我们处理的问题是相同的代码在其他平台上运行不同,因为我们无法计算它们的速度。我们使用以下代码行在屏幕上移动精灵:
m_mushroom.setPosition(
m_mushroom.getPosition().x + m_increment.x,
m_mushroom.getPosition().y + m_increment.y);
这里使用的m_increment向量是基于假设迭代之间的时间间隔是恒定的,但显然这不是真的。回想一下速度、时间和距离公式的神奇三角形:

要找到精灵在更新之间应该旅行的距离,首先需要定义一个移动速度。这里的时间值只是整个程序周期完成所需的时间。为了准确测量,我们将调整Game类以利用sf::Clock:
class Game{
public:
...
sf::Time GetElapsed();
void RestartClock();
private:
...
sf::Clock m_clock;
sf::Time m_elapsed;
...
};
我们添加的两个新公共方法可以这样实现:
sf::Time Game::GetElapsed(){ return m_elapsed; }
void Game::RestartClock(){ m_elapsed = m_clock.restart(); }
完成这些后,实际上利用这个功能并在每次迭代后重启游戏时钟是很重要的。这可以通过在主游戏循环中在所有工作完成后简单地调用RestartClock方法来实现:
while(!game.GetWindow()->IsDone()){
// Game loop.
game.HandleInput();
game.Update();
game.Render();
game.RestartClock(); // Restarting our clock.
}
循环中的最后一行将确保游戏类中的m_elapsed成员始终具有上一次迭代期间经过的时间值,所以让我们使用这个时间来确定我们的精灵应该移动多远:
float fElapsed = m_elapsed.asSeconds();
m_mushroom.setPosition(
m_mushroom.getPosition().x + (m_increment.x * fElapsed),
m_mushroom.getPosition().y + (m_increment.y * fElapsed));
我们现在使用m_increment作为速度变量,而不是距离。通过查看我们在构造函数中的前一段代码,我们将m_increment向量的x和y值都设置为4。由于我们用秒来表示经过的时间,这实际上意味着精灵需要每秒移动4像素。这真的很慢,所以让我们将其改为更有刺激性的值:
Game::Game(){
...
m_increment = sf::Vector2i(400,400); // 400px a second.
}
编译并运行项目后,你应该看到我们的精灵在屏幕上快乐地弹跳。现在,无论在哪个机器上执行,它都会移动相同的距离,无论帧率有多不稳定。为了加分,你可以通过使用 SFML 提供的sf::sleep函数人工减慢游戏循环来尝试一下,如下所示:
while(!game.GetWindow()->IsDone()){
// Game loop.
game.HandleInput();
game.Update();
game.Render();
sf::sleep(sf::seconds(0.2)); // Sleep for 0.2 seconds.
game.RestartClock();
}
随意调整传递给 sleep 函数的参数。你会发现,无论每次迭代完成需要多长时间,精灵都会移动相同的距离。
固定时间步长
在某些情况下,我们编写的代码对于时间管理并不完全适用。假设我们只想以每秒 60 次的固定速率调用某些方法。这可能是一个需要仅更新一定次数的物理系统,或者如果游戏是网格基础的,它可能很有用。无论情况如何,当更新速率非常重要时,固定时间步长是你的朋友。与可变时间步长不同,其中下一次更新和绘制会在前一次完成后立即发生,固定时间步长方法将确保某些游戏逻辑仅在提供的速率下发生。实现固定时间步长相当简单。首先,我们必须确保不是覆盖前一次迭代的经过时间值,而是像这样将其添加到:
void Game::RestartClock(){
m_elapsed += m_clock.restart();
}
计算一秒内单个更新的时间的基本表达式在这里展示:

假设我们想让我们的游戏每秒更新60次。为了找到帧时间,我们将1除以60,并检查经过的时间是否超过了这个值,如下所示:
float frametime = 1.0f / 60.0f;
if(m_elapsed.asSeconds() >= frametime){
// Do something 60 times a second.
...
m_elapsed -= sf::seconds(frametime); // Subtracting.
}
注意最后的减法。这是我们重置周期并保持模拟以恒定速度运行的方式。根据你的应用,你可能会想在更新之间将其“休眠”,以减轻 CPU 的负担。除了这个细节之外,这些都是固定时间步长的基本框架。这正是我们将在下一章中完成构建的游戏所使用的技术。
常见错误
通常,当使用时钟时,SFML 的新手往往会把它们放在错误的位置,并在错误的时间重新启动它们。这类事情最多只会导致“古怪”的行为。
注意
请记住,每行非空或非注释的代码都需要时间来执行。根据被调用的函数或正在构造的类的实现方式,时间值可能从微不足道到无限。
更新游戏世界中的所有游戏实体、执行计算和渲染等操作,在计算上相当昂贵,所以请确保不要以某种方式排除这些调用在时间测量的范围内。始终确保在主游戏循环结束时重启时钟和获取经过的时间是最后做的事情。
另一个错误是将时钟对象放在了错误的作用域内。考虑以下示例:
void Game::SomeMethod(){
sf::Clock clock;
...
sf::Time time = clock.getElapsedTime();
}
假设这段代码的目的是测量除 sf::Clock 对象启动以来的时间之外的任何东西,这段代码将产生错误的结果。创建时钟实例只是测量它在作用域内存在的时间,而不是其他任何东西。这也是为什么游戏类中的时钟被声明为类成员的原因。由于时钟是在栈上创建的,一旦上述方法结束,时钟就会被再次销毁。
将经过的时间保存在 float 数据类型中,或者任何其他不是 sf::Time 的数据类型,通常也是不被推荐的。以下这样的例子并不是正确使用 SFML 的好例子:
class Game{
...
private:
...
float m_elapsed;
...
};
虽然它可行,但这并不完全类型安全。由于每次时钟重启时都必须调用三种转换方法之一,因此它还需要进行更多的类型转换。代码的可读性也是一个问题。SFML 提供自己的时间类是为了方便,所以除非有充分的理由不使用它,否则请避免使用其他数据类型。
最后,由于我们正在讨论时间,所以值得提一下的是 C++ 中的控制台输出。虽然偶尔打印一些内容是可以接受的,甚至只是为了调试目的,但持续的 console spam 会减慢你的应用程序速度。控制台输出本身相当慢,并且不能期望它以与程序其他部分相同的速度执行。例如,在主游戏循环的每次迭代中打印内容,将会极大地降低应用程序的速度。
摘要
恭喜你完成了这本书的第二章!正如之前提到的,理解本章涵盖的所有内容至关重要,因为接下来的一切都将严重依赖于这里所讨论的内容。
在不同平台和不同条件下获得平滑和一致的结果,与一个应用程序的良好结构一样重要,这又是一个“千层面”的层次。完成本章后,你将再次拥有足够的知识来制作能够利用固定和可变时间步长来创建独立于底层架构的模拟应用程序。
最后,我们将给你留下一条宝贵的建议。前几章的内容是大多数读者相对紧密和字面地遵循的部分。虽然这样做是可以接受的,但我们更希望你能将其视为一个指南而不是食谱。人类知识的最大奇妙之处在于它并非仅仅通过无休止的记忆来吸收。实验和获得实际经验是成功掌握这一技能的另一半关键,所以请继续编写代码。无论代码好坏,编译它,或者遇到一大堆错误,运行它或让它崩溃,两种情况都很好。尝试新事物并失败得惨不忍睹,以便有一天能够取得辉煌的成功。你已经走在“动手实践”的道路上了,因为我们将在下一章实际上开始为这本书实现第一个游戏项目。在那里见!
第三章. 沾满泥土——你需要知道的内容
游戏开发往往是一个令人厌烦的过程。在许多情况下,花费在编写特定代码块、实现特定功能集或修订你或其他人编写的旧代码上的时间很少能立即得到认可;这就是为什么你可能会在某些时候看到游戏开发者的脸上突然露出喜悦的表情,当他们的项目中的一个更炫目的部分得以展示时。看到你的游戏真正地在你眼前开始变化,这是大多数游戏开发者所做事情的原因。那些时刻使得编写大量看似没有刺激结果的代码成为可能。
因此,现在我们已经准备好了游戏结构,是时候专注于有趣、炫目的部分了!
在本章中,我们将涵盖:
-
我们第一个项目的游戏选择及其历史
-
构建我们选择的游戏
-
常见的游戏编程元素
-
完成我们项目所需的额外 SFML 元素
-
为我们所有的游戏项目构建辅助元素
-
游戏中的有效调试和常见问题解决
介绍蛇
如果你现在想象的是用戴着标志性头巾的Solid Snake来构建游戏,我们还没有达到那个阶段,尽管这样做的心情是可以理解的。然而,如果你想象的是以下这样的东西,你就完全正确:

首次由Gremlin于 1976 年以“Blockade”之名发布,蛇的概念是有史以来最著名的游戏类型之一。为这种机制编写了无数移植版本,例如 1978 年由Atari发布的Surround和由Peter Trefonas发布的Worm。几乎任何能想到的平台都有蛇的移植版本,甚至包括早期的单色Nokia手机,如3310和6110。图形从移植到移植,随着时间的推移而改进。然而,自从其简朴的起点以来,主要思想和规则始终保持不变:
-
蛇可以沿四个方向移动:上、下、左和右
-
吃苹果会使蛇变长
-
你不能触碰墙壁或自己的身体,否则游戏结束
根据你玩的游戏版本,其他一些事情可能会有所不同,例如吃苹果获得的分数、你的生命值、蛇移动的速度、游戏场的尺寸、障碍物等等。
游戏设计决策
某些版本的蛇运行方式不同;然而,为了致敬经典方法,我们将实现一个基于网格移动的蛇,如下所示:

采用这种方法使得以后检查蛇段和苹果之间的碰撞变得更容易。网格移动基本上意味着以静态速率更新。这可以通过使用固定的时间步长来实现,这是我们之前在 第二章 中提到的,给它一些结构 - 构建游戏框架。
外部区域象征着游戏的边界,在基于网格的移动中,这将是范围 [1;Width-1] 和 [1;Height-1]。如果蛇头不在这个范围内,那么可以肯定地说,玩家已经撞到了墙壁。这里所有的网格段都是 16px x 16px 大小;然而,这可以在任何时候进行调整。
除非玩家用完生命,否则我们希望在蛇头与身体相交的点切断蛇,并减少剩余的生命值。这给游戏增添了一些多样性,但不会过于失衡。
最后,您可能已经注意到了,我们在游戏中使用的是非常简单的蛇的图形表示。这样做主要是为了保持简单,同时也为了给经典元素增添魅力。然而,使用精灵来做到这一点并不复杂,但让我们先不考虑这一点。
实现蛇结构
现在让我们创建我们将要工作的两个文件:Snake.h 和 Snake.cpp。在真正开发蛇类之前,我们需要定义一些数据类型和结构。我们可以从在蛇头文件中实际定义我们的苹果吞噬蛇的结构开始:
struct SnakeSegment{
SnakeSegment(int x, int y) : position(x,y){}
sf::Vector2i position;
};
如您所见,这是一个非常简单的结构,它包含一个单一成员,即一个表示段在网格上位置的 整数向量。这里的构造函数被用来通过 初始化列表 设置段的位置。
小贴士
在继续前进之前,请确保您熟悉 标准模板库 和它提供的数据容器。我们将特别使用 std::vector 来满足我们的需求。
我们现在已经定义了段类型,所以让我们开始考虑将蛇存储在某个地方。出于初学者的目的,std::vector 将会非常合适!在深入之前,这里有一个小技巧可以帮助我们消除代码中的“长行病”:
using SnakeContainer = std::vector<SnakeSegment>;
正如您从您的 C/C++ 背景中应该已经知道的,using 是一个很棒的小关键字,它允许用户为已知的数据类型定义别名。通过结合使用我们干净的新定义和 auto 关键字,我们防止了以下情况的发生:
std::vector<SnakeSegment>::iterator someIterator = ...
这只是一个方便的问题,使用它是完全可选的,然而,我们将在这个书中一直使用这个有用的工具。
在真正开始编写蛇类之前,我们还需要定义一个类型枚举:
enum class Direction{ None, Up, Down, Left, Right };
再次强调,这并不复杂。蛇有四个可以移动的方向。我们还有一个可能性,即它保持静止,在这种情况下,我们可以将方向设置为NONE。
蛇类
在设计任何对象之前,必须问自己它需要什么。在我们的例子中,蛇需要有一个移动的方向。它还需要有生命值,跟踪分数,速度,是否失败,以及是否失败。最后,我们将存储一个矩形形状,它将代表蛇的每个部分。当所有这些问题都得到解决后,蛇类的头文件将看起来像以下这样:
class Snake{
public:
Snake(int l_blockSize);
~Snake();
// Helper methods.
void SetDirection(Direction l_dir);
Direction GetDirection();
int GetSpeed();
sf::Vector2i GetPosition();
int GetLives();
int GetScore();
void IncreaseScore();
bool HasLost();
void Lose(); // Handle losing here.
void ToggleLost();
void Extend(); // Grow the snake.
void Reset(); // Reset to starting position.
void Move(); // Movement method.
void Tick(); // Update method.
void Cut(int l_segments); // Method for cutting snake.
void Render(sf::RenderWindow& l_window);
private:
void CheckCollision(); // Checking for collisions.
SnakeContainer m_snakeBody; // Segment vector.
int m_size; // Size of the graphics.
Direction m_dir; // Current direction.
int m_speed; // Speed of the snake.
int m_lives; // Lives.
int m_score; // Score.
bool m_lost; // Losing state.
sf::RectangleShape m_bodyRect; // Shape used in rendering.
};
注意,我们正在使用我们为蛇段向量创建的新类型别名。这看起来目前并不那么有用,但它很快就会变得非常有用。
如您所见,我们的类定义了一些方法,旨在分割功能,例如Lose()、Extend()、Reset()和CheckCollision()。这将增加代码的可重用性和可读性。让我们开始实际实现这些方法:
Snake::Snake(int l_blockSize){
m_size = l_blockSize;
m_bodyRect.setSize(sf::Vector2f(m_size - 1, m_size - 1));
Reset();
}
Snake::~Snake(){}
构造函数相当直接。它接受一个参数,即我们图形的大小。这个值将被存储以供以后使用,并且sf::RectangleShape成员的大小将根据它进行调整。从大小中减去一个像素是一个非常简单的方法,以保持蛇段在视觉上略微分离,如图所示:

构造函数也在最后一行调用了Reset()方法。头文件中的注释表明,此方法负责将蛇移动到起始位置。让我们实现这一点:
void Snake::Reset(){
m_snakeBody.clear();
m_snakeBody.push_back(SnakeSegment(5,7));
m_snakeBody.push_back(SnakeSegment(5,6));
m_snakeBody.push_back(SnakeSegment(5,5));
SetDirection(Direction::None); // Start off still.
m_speed = 15;
m_lives = 3;
m_score = 0;
m_lost = false;
}
这段代码将在每次新游戏开始时被调用。首先,它将清除上一局游戏中的蛇段向量。之后,将添加一些蛇段。由于我们的实现方式,向量中的第一个元素始终将是头部。蛇片的坐标目前是硬编码的,只是为了保持简单。
现在我们有一个三段蛇。我们现在要做的第一件事是将它的方向设置为None。我们希望在没有玩家按下键移动蛇之前不发生任何移动。接下来,我们为速度、生命值和起始分数设置一些任意值。这些可以在以后根据您的喜好进行调整。我们还设置了m_lost标志为false,以表示正在进行新的一轮。
在继续到更难实现的方法之前,让我们快速覆盖所有辅助方法:
void Snake::SetDirection(Direction l_dir){ m_dir = l_dir; }
Direction Snake::GetDirection(){ return m_dir; }
int Snake::GetSpeed(){ return m_speed; }
sf::Vector2i Snake::GetPosition(){
return (!m_snakeBody.empty() ?
m_snakeBody.front().position : sf::Vector2i(1,1));
}
int Snake::GetLives(){ return m_lives; }
int Snake::GetScore(){ return m_score; }
void Snake::IncreaseScore(){ m_score += 10; }
bool Snake::HasLost(){ return m_lost; }
void Snake::Lose(){ m_lost = true; }
void Snake::ToggleLost(){ m_lost = !m_lost; }
这些方法相当简单。有描述性的名称非常有帮助。现在让我们看看Extend方法:
void Snake::Extend(){
if (m_snakeBody.empty()){ return; }
SnakeSegment& tail_head =
m_snakeBody[m_snakeBody.size() - 1];
if(m_snakeBody.size() > 1){
SnakeSegment& tail_bone =
m_snakeBody[m_snakeBody.size() - 2];
if(tail_head.position.x == tail_bone.position.x){
if(tail_head.position.y > tail_bone.position.y){
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x, tail_head.position.y + 1));
} else {
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x, tail_head.position.y - 1));
}
} else if(tail_head.position.y == tail_bone.position.y){
if(tail_head.position.x > tail_bone.position.x){
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x + 1, tail_head.position.y));
} else {
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x - 1, tail_head.position.y));
}
}
} else {
if(m_dir == Direction::Up){
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x, tail_head.position.y + 1));
} else if (m_dir == Direction::Down){
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x, tail_head.position.y - 1));
} else if (m_dir == Direction::Left){
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x + 1, tail_head.position.y));
} else if (m_dir == Direction::Right){
m_snakeBody.push_back(SnakeSegment(
tail_head.position.x - 1, tail_head.position.y));
}
}
}
之前的方法是负责在蛇触碰到苹果时实际增长蛇的。我们首先做的事情是创建一个指向该段向量中最后一个元素的引用,称为tail_head。接下来,我们有一个相当大的if-else 语句代码块,并且它的两种情况都需要访问最后一个元素,所以现在创建这个引用是一个好主意,以避免代码重复。
小贴士
std::vector容器重载了方括号运算符以支持通过数字索引进行随机访问。它与数组类似,使我们能够通过使用size() - 1的索引来引用最后一个元素。随机访问速度也是恒定的,无论容器中的元素数量如何,这就是为什么std::vector是此项目的良好选择。
实质上,这归结为两种情况:要么蛇的长度超过一个段,要么不超过。如果蛇有多于一个部分,我们创建另一个引用,称为tail_bone,它指向倒数第二个元素。这是为了确定在扩展蛇时新的一段应该放置的位置,而我们检查这个位置的方法是通过比较tail_head和tail_bone段的位置position.x和position.y值。如果 x 值相同,可以说两个部分之间的差异在 y 轴上,反之亦然。考虑以下插图,其中橙色矩形是tail_bone,红色矩形是tail_head:

让我们以面向左的例子为例进行分析:tail_bone和tail_head具有相同的y坐标,而tail_head的x坐标大于tail_bone,所以下一个段将添加到与tail_head相同的坐标,除了 x 值将增加一。由于SnakeSegment构造函数方便地重载以接受坐标,因此可以在将段推入向量的末尾的同时轻松执行这个简单的数学运算。
在向量中只有一个段的情况下,我们只需检查蛇的方向并执行之前所做的相同数学运算,只是这次是基于头部朝向的方向。前面的插图也适用于这种情况,其中橙色矩形是头部,红色矩形是即将添加的部分。如果它面向左,我们将 x 坐标增加一,而 y 坐标保持不变。如果它面向右,则从 x 坐标中减去,依此类推。花点时间分析这张图片,并将其与之前的代码联系起来。
当然,如果我们的蛇不动,这一切都没有意义。这正是更新方法所处理的内容,在我们的固定时间步长案例中,这被称为“tick”:
void Snake::Tick(){
if (m_snakeBody.empty()){ return; }
if (m_dir == Direction::None){ return; }
Move();
CheckCollision();
}
方法中的前两行用于检查蛇是否应该移动,这取决于其大小和方向。如前所述,Direction::None值专门用于使其保持静止。蛇的移动完全包含在Move方法中:
void Snake::Move(){
for (int i = m_snakeBody.size() - 1; i > 0; --i){
m_snakeBody[i].position = m_snakeBody[i - 1].position;
}
if (m_dir == Direction::Left){
--m_snakeBody[0].position.x;
} else if (m_dir == Direction::Right){
++m_snakeBody[0].position.x;
} else if (m_dir == Direction::Up){
--m_snakeBody[0].position.y;
} else if (m_dir == Direction::Down){
++m_snakeBody[0].position.y;
}
}
我们首先从向量中向后迭代。这样做是为了达到一种类似“ inchworm”的效果。当然,也可以不反向迭代向量来做这件事,然而,这样做简化了过程,并使得理解游戏的工作原理更容易。我们还在使用随机访问运算符,再次使用数字索引而不是向量迭代器,出于同样的原因。考虑以下插图:

在我们调用tick方法之前,我们有一组段的位置,这可以被称为“初始状态”。当我们开始从我们的向量中向后迭代时,我们首先从段#3 开始。在我们的for循环中,我们检查索引是否等于0,以确定当前段是否是蛇的前端。在这种情况下,它不是,所以我们把段#3 的位置设置为与段#2 的相同。前面的插图显示了该部分似乎位于两个位置之间,这样做只是为了能够看到它们。实际上,段#3 正坐在段#2 的上面。
在对蛇的第二部分应用同样的过程之后,我们继续处理它的头部。在这个时候,我们只需将其移动到对应其面向方向的轴上的一个空间。这里的应用思想与之前的插图相同,但符号相反。由于在我们的例子中,蛇面向右,它被移动到坐标(x+1;y)。一旦这样做,我们就成功地移动了我们的蛇一个空间。
我们的小虫子最后要做的一件事是调用CheckCollision()方法。让我们看看它的实现:
void Snake::CheckCollision(){
if (m_snakeBody.size() < 5){ return; }
SnakeSegment& head = m_snakeBody.front();
for(auto itr = m_snakeBody.begin() + 1;
itr != m_snakeBody.end(); ++itr)
{
if(itr->position == head.position){
int segments = m_snakeBody.end() - itr;
Cut(segments);
break;
}
}
}
首先,除非我们有超过四个段,否则没有必要检查碰撞。理解你的游戏中的某些场景,并添加检查以避免资源浪费是游戏开发的重要部分。如果我们有超过四个蛇的段,我们再次创建对头部的引用,因为在任何碰撞的情况下,那将是首先撞到另一个段的部分。没有必要两次检查所有部分之间的碰撞。我们还在蛇的头部跳过一个迭代,因为显然没有必要检查它是否与自己碰撞。
在这个基于网格的游戏中,我们检查碰撞的基本方式是本质上通过比较头部位置和由我们的迭代器表示的当前片段位置。如果两个位置相同,头部与身体相交。我们解决这个问题的方法在本书的游戏设计决策部分有简要介绍。蛇必须在碰撞点被切断,直到玩家用完生命。我们通过首先获取从末端到被击中的片段之间的片段计数整数值来完成此操作。STL 在迭代器方面相当灵活,并且由于使用向量时内存都是连续布局的,我们可以简单地从向量的最后一个元素中减去我们的当前迭代器来获得这个值。这样做是为了知道需要从蛇的尾部移除多少个元素,直到交点。然后我们调用负责切割蛇的方法。此外,由于一次只能有一个碰撞,我们跳出for循环,以避免浪费更多的时钟周期。
让我们来看看Cut方法:
void Snake::Cut(int l_segments){
for (int i = 0; i < l_segments; ++i){
m_snakeBody.pop_back();
}
--m_lives;
if (!m_lives){ Lose(); return; }
}
到目前为止,这就像基于l_segments值循环一定次数,并从向量的末尾弹出元素一样简单。这实际上是在蛇上切割。
其余的代码只是减少剩余的生命值,检查是否为零,如果没有更多生命,则调用Lose()方法。
呼!这相当多的代码。然而,仍然有一件事要做,那就是将我们的方形蛇渲染到屏幕上:
void Snake::Render(sf::RenderWindow& l_window){
if (m_snakeBody.empty()){ return; }
auto head = m_snakeBody.begin();
m_bodyRect.setFillColor(sf::Color::Yellow);
m_bodyRect.setPosition(head->position.x * m_size,
head->position.y * m_size);
l_window.draw(m_bodyRect);
m_bodyRect.setFillColor(sf::Color::Green);
for(auto itr = m_snakeBody.begin() + 1;
itr != m_snakeBody.end(); ++itr)
{
m_bodyRect.setPosition(itr->position.x * m_size,
itr->position.y * m_size);
l_window.draw(m_bodyRect);
}
}
与我们在这里实施的大多数方法非常相似,我们需要遍历每个片段。头部本身是在循环外部绘制的,以避免不必要的检查。我们将代表蛇片段的sf::RectangleShape图形形状的位置设置为网格位置乘以m_size值,以便在屏幕上获得像素坐标。绘制矩形是完整实现蛇类的最后一步!
世界类
我们的蛇现在可以移动并与其自身碰撞。虽然功能齐全,但这并不足以让游戏变得真正有趣。让我们通过引入World类来给它一些边界和可以咀嚼的东西,以增加分数。
虽然我们可以为这里提到的每一件事都创建单独的对象,但这个项目足够简单,足以允许其某些方面被很好地包含在一个类中,这个类可以轻松地管理它们。这个类负责处理与保持游戏边界相关的一切,以及维护玩家将尝试抓取的苹果。
让我们看看类头:
class World{
public:
World(sf::Vector2u l_windSize);
~World();
int GetBlockSize();
void RespawnApple();
void Update(Snake& l_player);
void Render(sf::RenderWindow& l_window);
private:
sf::Vector2u m_windowSize;
sf::Vector2i m_item;
int m_blockSize;
sf::CircleShape m_appleShape;
sf::RectangleShape m_bounds[4];
};
如您从前面的代码中可以看到,这个类还跟踪游戏中对象的大小。除此之外,它仅仅保留四个矩形用于边界图形,一个圆用于绘制苹果,以及一个整数向量用于跟踪苹果的坐标,该向量命名为 m_item。让我们开始实现构造函数:
World::World(sf::Vector2u l_windSize){
m_blockSize = 16;
m_windowSize = l_windSize;
RespawnApple();
m_appleShape.setFillColor(sf::Color::Red);
m_appleShape.setRadius(m_blockSize / 2);
for(int i = 0; i < 4; ++i){
m_bounds[i].setFillColor(sf::Color(150,0,0));
if(!((i + 1) % 2)){
m_bounds[i].setSize(sf::Vector2f(m_windowSize.x,
m_blockSize));
} else {
m_bounds[i].setSize(sf::Vector2f(m_blockSize,
m_windowSize.y));
}
if(i < 2){
m_bounds[i].setPosition(0,0);
} else {
m_bounds[i].setOrigin(m_bounds[i].getSize());
m_bounds[i].setPosition(sf::Vector2f(m_windowSize));
}
}
}
World::~World(){}
在看起来复杂的 for 循环之前,我们只是从局部构造函数变量中初始化一些成员值,设置苹果圆的颜色和半径,并调用 RespawnApple() 方法来将其放置在网格上的某个位置。
第一个 for 循环只是对游戏屏幕的四边各迭代四次,以便在每一边设置一个红色矩形墙。它为矩形填充设置深红色颜色,并继续检查索引值。首先,我们通过以下表达式确定索引是偶数还是奇数:if(!((i + 1) % 2)){...}。这是为了知道在特定轴上每堵墙需要有多大。因为它必须与屏幕尺寸之一一样大,所以我们简单地使另一个与屏幕上的所有其他图形一样大,这由 m_blockSize 值表示。
最后的 if 语句检查索引是否小于 2。如果是,我们正在处理左上角,所以我们只需将矩形的位置设置为(0,0)。由于 SFML 中所有基于矩形的可绘制对象的坐标原点始终是左上角,所以我们在这个情况下不需要担心。然而,如果索引是 2 或更高,我们将原点设置为矩形的尺寸,这实际上使其成为右下角。之后,我们将矩形的位置设置为与屏幕尺寸相同,这样就将形状放置到底部右下角。您可以简单地手动设置所有坐标和原点,但这种方法使基本特征的初始化更加自动化。现在可能很难看到它的用途,但在更复杂的项目中,这种思维方式会很有用,所以为什么不从现在开始呢?
既然我们有我们的墙壁,让我们看看一个人可能会如何重新生成苹果:
void World::RespawnApple(){
int maxX = (m_windowSize.x / m_blockSize) - 2;
int maxY = (m_windowSize.y / m_blockSize) - 2;
m_item = sf::Vector2i(
rand() % maxX + 1, rand() % maxY + 1);
m_appleShape.setPosition(
m_item.x * m_blockSize,
m_item.y * m_blockSize);
}
我们必须做的第一件事是确定苹果可以生成的边界。我们通过定义两个值来完成此操作:maxX 和 maxY。这些值设置为窗口尺寸除以块大小,这给出了网格中的空间数量,然后我们必须从中减去 2。这是因为网格索引从 0 开始,而不是 1,并且我们不希望在右侧或底部墙壁内生成苹果。
下一步是实际生成苹果坐标的随机值。我们在这里使用预先计算好的值,并将最低可能的随机值设置为1,因为我们不希望任何东西在顶部墙壁或左侧墙壁上生成。由于苹果的坐标现在可用,我们可以通过将网格坐标乘以所有图形的大小来设置m_appleShape图形的像素坐标。
让我们通过实现更新方法来让所有这些功能变得生动起来:
void World::Update(Snake& l_player){
if(l_player.GetPosition() == m_item){
l_player.Extend();
l_player.IncreaseScore();
RespawnApple();
}
int gridSize_x = m_windowSize.x / m_blockSize;
int gridSize_y = m_windowSize.y / m_blockSize;
if(l_player.GetPosition().x <= 0 ||
l_player.GetPosition().y <= 0 ||
l_player.GetPosition().x >= gridSize_x – 1 ||
l_player.GetPosition().y >= gridSize_y - 1)
{
l_player.Lose();
}
}
首先,我们检查玩家的位置是否与苹果的位置相同。如果是,我们就有了碰撞,蛇变长,分数增加,苹果重新生成。接下来,我们确定网格大小,并检查玩家坐标是否在任何指定的边界之外。如果是这种情况,我们调用Lose()方法来展示与墙壁的碰撞,并给玩家一个“游戏结束”的提示。
为了不让玩家处于盲目状态,我们必须显示游戏的边界,以及主要兴趣点——苹果。让我们在屏幕上绘制一切:
void World::Render(sf::RenderWindow& l_window){
for(int i = 0; i < 4; ++i){
l_window.draw(m_bounds[i]);
}
l_window.draw(m_appleShape);
}
我们所需要做的就是迭代四次,绘制四个相应的边界。然后我们绘制苹果,这标志着我们对这个方法的兴趣结束。
还有一点需要指出的是,其他类可能需要知道图形需要有多大,因此让我们实现一个简单的方法来获取这个值:
int World::GetBlockSize(){ return m_blockSize; }
这就完成了World类的编写。
是时候整合了
正如没有人在使用它时锤子毫无用处一样,我们的两个类如果没有被Game类正确采用,也是无用的。既然我们写所有这些代码并不是仅仅为了练习打字,那么让我们来把这些碎片拼在一起。首先,我们需要实际上向Game类添加两个新成员,你可能已经猜到了它们是什么:
class Game{
...
private:
...
World m_world;
Snake m_snake;
};
接下来,让我们初始化这些成员。由于它们都有接受参数的构造函数,现在是初始化列表的时间了:
Game::Game(): m_window("Snake", sf::Vector2u(800, 600)),m_snake(m_world.GetBlockSize()),m_world(sf::Vector2u(800,600))
{
...
}
接下来,我们需要处理一些输入。正如你可能从前面的章节中回忆起来的那样,利用事件进行实时输入是非常延迟的,并且绝对不应该用于除检查非时间敏感的按键以外的任何其他目的。幸运的是,SFML 通过sf::Keyboard类提供了获取键盘实时状态的方法。它只包含静态函数,并且从不打算被初始化。其中有一个函数正是我们需要的:isKeyPressed(sf::Keyboard::Key)。它接受的唯一参数是你想要检查状态的键的实际键值,这可以通过使用sf::Keyboard::Key枚举来获得,如下所示:
if(sf::Keyboard::isKeyPressed(sf::Keyboard::Up)
&& m_snake.GetDirection() != Direction::Down)
{
m_snake.SetDirection(Direction::Up);
} else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Down)
&& m_snake.GetDirection() != Direction::Up)
{
m_snake.SetDirection(Direction::Down);
} else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Left)
&& m_snake.GetDirection() != Direction::Right)
{
m_snake.SetDirection(Direction::Left);
} else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Right)
&& m_snake.GetDirection() != Direction::Left)
{
m_snake.SetDirection(Direction::Right);
}
我们不希望蛇做的某件事是朝与当前方向相反的方向移动。在任何给定的时间,它只能朝三个方向移动,使用GetDirection()方法确保我们不会让蛇反向移动,从而吃掉自己。如果我们有适当的输入组合及其当前方向,就可以通过使用SetDirection()方法安全地调整其方向。
让我们通过更新我们的两个类来开始行动:
void Game::Update(){
...
float timestep = 1.0f / m_snake.GetSpeed();
if(m_elapsed >= timestep){
m_snake.Tick();
m_world.Update(m_snake);
m_elapsed -= timestep;
if(m_snake.HasLost()){
m_snake.Reset();
}
}
...
}
如前所述,我们在这里使用的是固定时间步长,它包含了蛇的速度,以便每秒更新适当的次数。这也是我们检查玩家是否输掉游戏并重置蛇的地方,如果玩家输了。
我们现在非常接近了。是时候在屏幕上绘制一切了:
void Game::Render(){
m_window.BeginDraw();
// Render here.
m_world.Render(*m_window.GetRenderWindow());
m_snake.Render(*m_window.GetRenderWindow());
m_window.EndDraw();
}
和之前一样,我们只是简单地调用我们两个类的Render方法,并传入sf::RenderWindow的引用。有了这个,我们的游戏实际上就可以玩了!在项目成功编译和执行后,我们应该得到以下图像所示的内容:

蛇最初是静止的,直到按下四个方向键中的任意一个。一旦开始移动,它就能吃掉苹果并增长一个节段,在与自己的尾巴碰撞并失去两次之前死亡,如果玩家撞到墙壁,游戏结束。我们游戏的核心版本已经完成!给自己鼓掌吧,因为你刚刚创建了你第一个游戏。
捕捉虫子
尽管你对自己的第一个项目感到自豪和满足,但没有任何事情是完美的。如果你花了一些时间实际玩游戏,你可能注意到了当快速按键时发生的奇怪事件,看起来像这样:

这张图片代表了两次连续更新之间的差异。看起来它之前面向正确的方向,然后面向左边,并且没有撞到自己的尾巴。发生了什么?在继续之前,试着自己找出答案,因为它完美地说明了修复游戏缺陷的经历。
再多玩一会儿,可以揭示一些缩小我们问题的细节。让我们分析一下当玩家开始快速按键时会发生什么:
-
蛇面向右。
-
按下除了左键或右键之外的任意箭头键。
-
蛇的方向被设置为其他方向,比如说向上。
-
在游戏有机会更新之前,正确的按键已经被按下。
-
由于蛇的方向不再设置为向右或向左,输入处理程序中的
if语句被满足,并将方向设置为向左。 -
游戏更新蛇的位置,并将其向左移动一个空间。头部与尾巴相撞,蛇被切断。
是的,看起来我们的方向检查有缺陷,导致了这个错误。再次,在继续之前,花些时间想想如何解决这个问题。
修复 bug
让我们讨论在这种情况下可能使用的几种方法。首先,程序员可能会考虑在某个地方放置一个标志,以记住方向是否已经为当前迭代设置,并在之后重置。这将防止我们遇到的错误,但也会锁定玩家与蛇交互的次数。假设蛇每秒移动一次。这意味着如果你在那一秒的开始按下一个键,你就无法改变主意并快速按下另一个键来纠正你的错误决定,因为蛇会移动。这不好。让我们转向一个新的想法。
另一种方法可能是记录在更改任何迭代之前原始方向。然后,一旦调用更新方法,我们可以检查在做出任何更改之前原始方向是否与我们收到的最新方向相反。如果是这样,我们可以简单地忽略它,并将蛇移动到任何更改之前的方向。这将修复错误,而不会给我们带来新的错误,但这也意味着需要跟踪一个额外的变量,可能会变得令人困惑。想象一下,在未来你遇到了一个类似的错误或需要跟踪另一个变量的功能请求。想象这种情况发生一次,然后又一次。很快,你的检查语句可能看起来有点像这样:
if(var1 != something && var2 == something && var3 == true && var4 == !var3 ...)
现在我们称之为混乱。更不用说,想象一下你不得不为四种不同的条件检查相同的变量四次。很快就会很明显,这是一个糟糕的设计,任何打算向他人展示其代码的人都不应该使用它。
你可能会问我们如何纠正我们的问题。好吧,我们可以简单地不依赖蛇类中变量的使用来确定其方向,而是实现一个查看其结构并输出其面对的方向的方法,如下所示:
Direction Snake::GetPhysicalDirection(){
if(m_snakeBody.size() <= 1){
return Direction::None;
}
SnakeSegment& head = m_snakeBody[0];
SnakeSegment& neck = m_snakeBody[1];
if(head.position.x == neck.position.x){
return (head.position.y > neck.position.y ? Direction::Down : Direction::Up);
} else if(head.position.y == neck.position.y){
return (head.position.x > neck.position.x ? Direction::Right : Direction::Left);
}
return Direction::None;
}
首先,我们检查蛇是否只有 1 个或更少的段;在这种情况下,它朝哪个方向不重要,因为它如果只有头部,就不会吃掉自己,如果没有段在向量中,甚至没有方向。假设它比一个段更长,我们获得两个引用:头部和颈部,这是蛇头后面的第二部分。然后,我们简单地检查它们的位置,并使用与之前相同的逻辑确定蛇的方向,就像在实现蛇类时所示,如下面的图像所示:

这将返回一个正确的方向,除非蛇移动,否则不会改变,所以让我们调整我们的输入处理代码以适应这些变化:
if(sf::Keyboard::isKeyPressed(sf::Keyboard::Up) && m_snake.GetPhysicalDirection() != Direction::Down)
{
m_snake.SetDirection(Direction::Up);
} else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Down) && m_snake.GetPhysicalDirection() != Direction::Up)
{
m_snake.SetDirection(Direction::Down);
} else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Left) && m_snake.GetPhysicalDirection() != Direction::Right)
{
m_snake.SetDirection(Direction::Left);
} else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Right) && m_snake.GetPhysicalDirection() != Direction::Left)
{
m_snake.SetDirection(Direction::Right);
}
哇!我们的蛇不再会翻转了。
游戏中还有一个故意没有解决的错误。试着找出它并修复它,以便在将来练习解决这类问题。
小贴士
提示:这与游戏开始时蛇有多少段有关。
如果你想要公平地完成这个任务,尽量不参考这本书附带已完成项目的代码,因为那已经固定了。
走得更远
一个功能性的游戏远非一个完全完成的产品。当然,我们一开始就拥有了所有想要的东西,但它仍然留有遗憾,比如跟踪分数和显示我们有多少条命。起初,你的主要本能可能就是简单地在一个屏幕上的某个地方添加一些文本,简单地打印出你剩余的命数。你甚至可能被诱惑只简单地将其打印到控制窗口中。如果是这样的话,这部分的目的就是通过引入我们将在这本书的整个过程中使用和改进的东西来改变你的思维方式:文本框。
如果这个名字对你来说没有任何意义,只需想象在任何通信应用上的一个聊天窗口,例如 MSN Messenger 或 Skype。每当有新消息添加时,它会被添加到底部,而较旧的消息则被向上移动。窗口中可以同时显示一定数量的消息。这不仅对游戏打印轻松消息很有用,还可以用于调试。让我们先从编写我们的标题开始,就像往常一样:
using MessageContainer = std::vector<std::string>;
class Textbox{
public:
Textbox();
Textbox(int l_visible, int l_charSize, int l_width, sf::Vector2f l_screenPos);
~Textbox();
void Setup(int l_visible, int l_charSize, int l_width, sf::Vector2f l_screenPos);
void Add(std::string l_message);
void Clear();
void Render(sf::RenderWindow& l_wind);
private:
MessageContainer m_messages;
int m_numVisible;
sf::RectangleShape m_backdrop;
sf::Font m_font;
sf::Text m_content;
};
我们首先定义了所有消息容器的数据类型。在这种情况下,我们再次选择了 std::vector,因为这在这个阶段更熟悉。为了使其看起来更好、更易读,我们添加了一个矩形形状作为类的一个成员,该类将用作背景。除此之外,我们还引入了一个新的数据类型:sf::Text。这是一个可绘制的类型,代表任何键入的字符或字符字符串,并且可以调整大小、字体和颜色,就像 SFML 中的任何其他可绘制对象一样。
让我们开始实现我们的新特性:
Textbox::Textbox(){
Setup(5,9,200,sf::Vector2f(0,0));
}
Textbox::Textbox(int l_visible, int l_charSize,
int l_width, sf::Vector2f l_screenPos){
Setup(l_visible, l_charSize, l_width, l_screenPos);
}
Textbox::~Textbox(){ Clear(); }
如你所见,它有两个构造函数,其中一个可以用来初始化一些默认值,另一个允许通过传递一些参数来自定义。第一个参数是文本框中可见的行数。它后面跟着字符大小(以像素为单位)、整个文本框的宽度(以像素为单位),以及一个表示应在屏幕上绘制位置的浮点向量。所有这些构造函数所做的只是调用 Setup 方法并将所有这些参数传递给它,所以让我们来看看它:
void Textbox::Setup(int l_visible, int l_charSize, int l_width, sf::Vector2f l_screenPos)
{
m_numVisible = l_visible;
sf::Vector2f l_offset(2.0f, 2.0f);
m_font.loadFromFile("arial.ttf");
m_content.setFont(m_font);
m_content.setString("");
m_content.setCharacterSize(l_charSize);
m_content.setColor(sf::Color::White);
m_content.setPosition(l_screenPos + l_offset);
m_backdrop.setSize(sf::Vector2f(
l_width, (l_visible * (l_charSize * 1.2f))));
m_backdrop.setFillColor(sf::Color(90,90,90,90));
m_backdrop.setPosition(l_screenPos);
}
除了初始化其成员值之外,此方法定义了一个偏移浮点向量,它将被用来适当地间隔文本并提供从左上角的一些填充。它还通过首先创建一个与之绑定的字体,设置初始字符串为空,设置字符大小和颜色,以及将其屏幕位置设置为提供的位置参数(考虑了适当的偏移量)来设置我们的sf::Text成员。此外,它通过使用提供的宽度并乘以可见行数与字符大小和 1.2 这个常量浮点值的乘积来设置背景的大小,以考虑到行之间的间隔。
小贴士
有时,这仅仅归结为玩代码,看看什么真正有效。找到在所有情况下都起作用的某些数值常数,就是测试以确定正确值的情况之一。不要害怕尝试新事物并看看什么有效。
由于我们正在使用向量来存储我们的消息,添加一个新消息或删除所有消息就像使用push_back和clear方法一样简单:
void Textbox::Add(std::string l_message){
m_messages.push_back(l_message);
if(m_messages.size() < 6){ return; }
m_messages.erase(m_messages.begin());
}
void Textbox::Clear(){ m_messages.clear(); }
在添加新消息的情况下,检查我们是否比我们看到的多,这是一个好主意。如果我们周围有我们永远不会看到或需要的物品,那就是浪费,所以当时肯定看不见的第一条消息就被从消息容器中移除了。
我们实际上已经非常接近完成这个整洁的功能了。现在唯一剩下的事情就是绘制它,这,就像往常一样,由Render方法来处理:
void Textbox::Render(sf::RenderWindow& l_wind){
std::string l_content;
for(auto &itr : m_messages){
l_content.append(itr+"\n");
}
if(l_content != ""){
m_content.setString(l_content);
l_wind.draw(m_backdrop);
l_wind.draw(m_content);
}
}
代码从设置std::string以存储屏幕上所有可见消息开始。之后,它就像遍历消息向量并将每条消息的文本追加到我们的本地std::string变量一样简单,在末尾加上换行符。最后,在检查本地变量并确保它不为空后,我们必须将我们的m_content成员(类型为sf::Text)设置为包含我们一直在推送消息的字符串,并在屏幕上绘制背景和文本。这就是Textbox类的全部内容。
在将Textbox实例作为成员添加到我们的游戏类之后,我们可以开始设置它:
Game::Game() ... {
...
m_textbox.Setup(5,14,350,sf::Vector2f(225,0));
...
m_textbox.Add("Seeded random number generator with: " + std::to_string(time(NULL)));
}
在向我们的m_textbox成员的Setup方法传递一些常量值后,我们立即在构造函数中使用它,实际上输出我们的第一条消息。让我们通过最后调整Game::Render()方法来完全集成它:
void Game::Render(){
m_window.BeginDraw();
// Render here.
m_world.Render(*m_window.GetRenderWindow());
m_snake.Render(*m_window.GetRenderWindow());
m_textbox.Render(*m_window.GetRenderWindow());
m_window.EndDraw();
}
这与我们在之前实现的所有类都一样,只是现在文本框是我们最后绘制的,这意味着它将显示在其他所有内容之上。在向游戏中添加更多要打印的消息并编译我们的项目后,我们最终应该得到如下所示的内容:

这个文本框,在其最基本的形式中,是我们将在本书中涵盖的蛇游戏中的最后一个新增功能。请随意尝试,看看你还能想出什么来让游戏更加有趣!
常见错误
人们经常忘记的一个相当常见的事情是以下这一行:
srand(time(nullptr));
如果你注意到,每次启动游戏时生成的数字都是完全相同的,那么很可能是你没有对随机数生成器进行初始化,或者你没有提供一个合适的种子。建议始终使用 Unix 时间戳,如下所示。
小贴士
应该限制使用这个特定的随机函数,以避免与安全性和密码学相关。与模运算符结合使用时,由于引入的偏差,可能会产生非常不均匀的结果。
另一个相当常见的问题是程序员选择的数据容器来存储他们的结构。以下是一个例子:
using SnakeContainer = std::vector<SnakeSegment>;
这定义了我们的SnakeContainer的类型。如果你已经编译了我们编写的代码,你会注意到它运行得相当顺畅。现在考虑下一行代码:
Using SnakeContainer = std::deque<SnakeSegment>;
由于这两个容器在 STL 中的实现方式,我们的代码中没有任何其他变化,所以请随意尝试将你的SnakeContainer的数据类型从std::vector更改为std::deque。编译并运行项目后,你肯定会注意到性能的提升。为什么会这样呢?好吧,尽管std::vector和std::deque基本上可以以相同的方式使用,但它们在底层是根本不同的。向量提供了其元素在内存中连续的确定性,而双端队列则没有。根据插入和删除操作最频繁的位置,性能上也有差异。如果你不确定使用哪个容器,请确保查阅或自己进行基准测试。永远不要盲目假设,除非性能不是你主要关心的问题。
最后,在更开放的话题上,不要害怕尝试、修改、更改、破解或以其他方式改变你看到的任何代码。你最大的错误可能是没有通过破坏和修复来学习。考虑我们编写的代码只是正确的方向上的一个推动,而不是一个具体的食谱。如果你必须先破坏它才能更好地理解某些东西,那就这么做吧。
摘要
游戏开发是一次伟大的旅程,你之前已经迈出了第一步和第二步,但现在你已经带着你的第一个、功能齐全的游戏登上了飞机。你现在正式成为一名游戏开发者了!这架机会之飞机将带你到何方,它将停留多久?这一切完全取决于你。然而,在你还在空中之前,我们将尽我们所能来激励你,并展示你可以去的不同地方以及在那里可以获得的美好体验。然而,有一件事是肯定的,那就是这并不是终点。如果你的热情让你走到了这一步,那么只有一个方向可以前进,那就是向前。
本章涵盖了大量的内容,现在可以说你在向所有时代街机经典之一致敬时已经动手实践了。在下一章中,我们将处理输入处理和事件管理,以便在提供你与应用程序之间灵活和流畅的交互方式的同时,引入我们为下一章准备的新项目。还有很多东西要学习,还有很多代码要编写,所以不要花太多时间犹豫是否进入下一章。一场全新的冒险正在等待展开。在那里见!
第四章. 捕获摇杆 – 输入和事件管理
毫无疑问,任何游戏最重要的方面实际上就是能够玩它。无论输入的目的如何,从简单地按键到浏览菜单,再到控制角色何时跳跃以及他或她走向哪个方向,如果没有一个应用程序提供一种让你与之交互的方式,那么你可能会得到一个非常花哨的屏保。我们非常简要地查看了一下获取和使用键盘输入的原始方法,然而我们本章的动机与仅仅满足于处理每个按键的庞大 if/else 语句集合是截然不同的。相反,我们想要查看一种更健壮的方法来处理不仅仅是键盘,还包括鼠标和帧之间发生的事件,以及添加处理其他外围设备输入的可能性,例如摇杆。考虑到这一点,让我们看看本章我们将要涵盖的内容:
-
检查键盘和鼠标按钮状态的基本方法
-
理解和处理不同类型的事件
-
理解和利用回调
-
设计和实现事件管理器
让我们不要像你的游戏角色在没有输入的情况下一样静止不动,开始编码吧!
获取外围输入
几个之前的章节已经稍微触及了检索外围输出的主题,并且讽刺的是,整个类的范围都被涵盖了。只是为了回顾一下,sf::Keyboard 是一个提供单个静态方法 isKeyPressed(sf::Keyboard::Key) 的类,用于确定某个键盘键的实时状态,该方法通过 sf::Keyboard::Key 枚举表传递作为参数。因为这个方法是静态的,所以 sf::Keyboard 不需要实例化,可以使用如下方式:
if(sf::Keyboard::isKeyPressed(sf::Keyboard::W)){
// Do something if the W key is pressed.
}
这是我们在之前章节中检查输入的方法,然而,如果我们想要检查更多的按键,这会导致大量的 if/else 语句。
检查鼠标输入
预计之下,SFML 也提供了一个类似于 sf::Keyboard 的类,具有获取鼠标实时状态的同一种想法:sf::Mouse。与它的犯罪伙伴键盘类似,它提供了一种检查鼠标按钮是否被按下的方法,如下所示:
if(sf::Mouse::isButtonPressed(sf::Mouse::Left)){
// Do something if the left mouse button is pressed.
}
sf::Mouse 类提供了任何给定鼠标上可能按钮的枚举,其中我们总共有五个:
sf::Mouse::Left |
左键鼠标按钮 |
|---|---|
sf::Mouse::Right |
右键鼠标按钮 |
sf::Mouse::Middle |
鼠标滚轮被点击 |
sf::Mouse::XButton1 |
第一个额外鼠标按钮 |
sf::Mouse::XButton2 |
第二个额外鼠标按钮 |
此外,sf::Mouse 类提供了一种获取和设置当前鼠标位置的方法:
// Getting the mouse position.
sf::Vector2i mousePos = sf::Mouse::getPosition(); // 1
sf::Vector2i mousePos = sf::Mouse::getPosition(m_window); // 2
// Setting the mouse position.
sf::Mouse::setPosition(sf::Vector2i(0,0)); // 3
sf::Mouse::setPosition(sf::Vector2i(0,0),m_window); // 4
这两种方法都有一个重载版本,它接受一个窗口的引用,以确定是查看相对于窗口的鼠标坐标还是相对于桌面的鼠标坐标。考虑以下插图:

如果没有提供窗口的引用,就像上一个示例中的第 1 行,返回的鼠标位置是桌面原点到鼠标所在点的距离。然而,如果提供了窗口的引用,位置就是窗口原点到鼠标位置的距离。换句话说,示例#2 中的鼠标位置是相对于窗口的。同样的逻辑也适用于第 3 行和第 4 行,除了鼠标位置被设置为提供的 int 向量参数。
插入你的控制器
是的,正如标题所述,SFML 不仅支持来自键盘和鼠标的输入,还支持来自连接到计算机的附加外设的输入。通过利用类sf::Joystick,它只包含静态方法,就像前两个类一样,可以检查控制器是否连接,检查其按钮状态,甚至确定控制器是否支持某些轴的位置。
SFML 支持同时连接多达八个不同的控制器,这些控制器通过[0;7]范围内的数值索引来识别。因此,sf::Joystick提供的每个方法都必须至少有一个参数,即控制器 ID。首先,让我们看看确定控制器是否连接的方法:
if (sf::Joystick::isConnected(0))
{
// We have a controller with an id 0.
}
如果我们有一个 ID 为 0 的控制器,我们可以检查它实际支持多少个按钮,如下所示:
unsigned int n_buttons = sf::Joystick::getButtonCount(0);
因为没有其他方法可以抽象地定义地球上每个控制器的按钮,所以它们简单地通过 0 到 31 之间的数字索引来引用。可以通过调用isButtonPressed()方法来检查按钮是否被按下,如下所示:
if(sf::Joystick::isButtonPressed(0,1)){
// Button 1 on controller 0 is pressed.
}
为了检查一个控制器是否支持特定的轴,我们可以使用hasAxis()方法:
if(sf::Joystick::hasAxis(0,sf::Joystick::X)){
// Controller 0 supports movement on X axis.
}
sf::Joystick::Axis枚举封装了一个控制器可能支持的所有可能的轴,因此可以像前面代码中那样进行检查。假设控制器支持它,可以通过以下方式获取其沿轴的当前位置:
float p_x = sf::Joystick::getAxisPosition(0, sf::Joystick::X);
float p_y = sf::Joystick::getAxisPosition(0, sf::Joystick::Y);
// Do something with p_x and p_y.
前面的方法将返回控制器 0 上 X 和 Y 轴的当前位置。
注意
因为在检查事件时sf::Joystick状态会被更新,所以在事件被轮询之前使用这些方法可能会出现一些问题。如果出现这种情况,最好手动调用sf::Joystick:Update()方法,以确保你拥有最新的外设状态。
理解sf::Event
再次强调,sf::Event 是我们简要提到过的东西,然而,如果我们想要构建一个可以无缝处理所有类型事件而没有任何问题的系统,那么在继续之前,我们必须扩展并更好地理解它。首先,让我们重申一下什么是事件。sf::Event 是一个联合体,在 C++ 术语中意味着它是一个特殊的类,它一次只能持有其非静态数据成员中的一个,它有几个这样的成员,例如 KeyEvent,它包含有关键盘事件的信息,SizeEvent,它包含有关我们窗口大小的信息,以及其他许多事件。由于 sf::Event 的这种性质,如果新手以错误的方式处理事件,例如在 sf::Event 中访问 KeyEvent 结构,而此时它不是活动的数据成员,那么这可能会成为一个陷阱。由于联合体的所有成员共享相同的内存空间,这会导致未定义的行为,并可能导致你的应用程序崩溃,除非你知道你在做什么。
让我们看看处理事件最基本的方法:
sf::Event event;
while(m_window.pollEvent(event)){
switch(event.type){
case sf::Event::Closed:
m_window.close();
break;
case sf::Event::KeyPressed:
if(event.key.code == sf::Keyboard::W){
// Do something when W key gets pressed once.
}
break;
}
}
我们没有看到过的新东西,尽管了解确切发生的事情是很重要的。首先,名为 event 的 sf::Event 实例是通过 pollEvent() 方法填充的。根据其类型,它将选择联合体中的一个结构作为活动结构来携带与事件相关的数据。之后,我们可以检查事件的类型,这是由 sf::Event::Type 枚举表定义的,并确保我们使用正确的数据成员来获取所需的信息。如前所述,如果事件类型是 sf::Event::Closed,尝试访问 event.key.code 将会导致未定义的行为。
小贴士
记住,使用 sf::Event::KeyPressed 事件来处理类似实时角色移动的操作是一个糟糕的想法。这个事件在应用一小段时间延迟之前只会被分发一次,然后再次分发。想象一下文档编辑器。当你按下并保持一个键时,最初它只会显示一个字符,然后才会写入更多内容。这个事件的工作方式与此完全相同。将其用于任何需要持续按下键的动作,甚至都不接近最佳方案,并且应该用 sf::Keyboard::isKeyPressed() 来替换,以便检查键的实际状态。同样的想法也适用于鼠标和控制器输入。使用这些事件对于只需要在每次按键时发生一次的事情是理想的,但除此之外并不多。
虽然这种方法在小项目中是可管理的,与之前的输入示例几乎相同,但在更大规模上可能会迅速失控。让我们面对现实,以我们之前项目中的方式处理所有事件、按键和每个输入设备的所有状态,那是一个噩梦。仍然不相信?想象一下,有一个应用程序,你想要检查同时按下多个键,并在它们同时按下时调用某个函数。这不好吗?好吧,让我们将事件纳入这个场景。你想要检查同时按下两个键和同时发生某个事件,以便调用一个函数。这增加了另一层复杂性,但你完全能够处理,对吧?在其中加入一些布尔标志来跟踪事件状态或按键状态可能不会太难。
一段时间过去了,现在应用程序需要支持从文件中加载键组合,以便使你的方法更加动态和可定制。你现在手头一团糟。你可以构建它,但添加新功能或扩展那堆废话将会非常尴尬,你可能会举手放弃。为什么让自己经历所有这些,而只需一些努力和白板,你就可以想出一个自动化的方法,它不需要任何标志,是灵活的,可以从文件中加载任何键和事件的组合,并且仍然保持你的代码像以前一样整洁和干净,甚至更好?让我们通过开发一个将为我们处理所有这些烦恼的系统来智能地解决这个问题。
介绍事件管理器
确定我们想要从我们的应用程序中获得什么,是设计过程中的第一步也是最关键的部分。有时很难覆盖所有的基础,但忘记一个可能改变所有代码结构的功能,然后在以后尝试实现它,可能会对你的软件投入的所有工作造成严重破坏。话虽如此,让我们列出一个我们希望事件管理器拥有的功能列表:
-
能够将任何组合的键、按钮或事件(从现在起称为绑定)与通过字符串标识的所需功能相耦合
-
将这些功能绑定到当所有条件(例如按键被按下、左鼠标按钮被点击或窗口失去焦点等)满足时会被调用的方法
-
事件管理器处理实际被轮询的 SFML 事件的方式
-
从配置文件中加载绑定
我们有了规格说明,现在让我们开始设计!我们将使用EventManager.h文件来包含所有使这成为可能的小部件,同时还有类的定义。我们需要定义的第一件事是我们将要处理的所有事件类型。这可以在以后扩展,但鉴于这现在将完全满足我们的目的,我们暂时不必担心这一点。让我们编写枚举表:
enum class EventType{
KeyDown = sf::Event::KeyPressed,
KeyUp = sf::Event::KeyReleased,
MButtonDown = sf::Event::MouseButtonPressed,
MButtonUp = sf::Event::MouseButtonReleased,
MouseWheel = sf::Event::MouseWheelMoved,
WindowResized = sf::Event::Resized,
GainedFocus = sf::Event::GainedFocus,
LostFocus = sf::Event::LostFocus,
MouseEntered = sf::Event::MouseEntered,
MouseLeft = sf::Event::MouseLeft,
Closed = sf::Event::Closed,
TextEntered = sf::Event::TextEntered,
Keyboard = sf::Event::Count + 1, Mouse, Joystick
};
大多数这些都是实际事件;然而,请注意在列举结束前的最后一行。我们正在设置自己的事件,称为Keyboard,其值为sf::Event::Count + 1。因为所有的枚举本质上都是指向整数值的关键字,所以最后一行防止了任何类型的标识符冲突,并确保在此之后添加的任何内容都高于绝对最大sf::Event::EventType枚举值。只要在最后一行之前添加的内容是有效的事件类型,就不应该有冲突。
注意
sf::Event枚举值可能因你使用的 SFML 版本不同而不同!
接下来,让我们使能够为每个绑定存储这些事件组成为可能。我们知道,为了绑定到一个键,我们需要事件类型和我们感兴趣的键的代码。我们将处理的一些事件只需要存储类型,在这种情况下,我们可以简单地存储一个与类型相关的 0 整数值。了解这一点后,让我们定义一个新的结构,它将帮助我们存储这些信息:
struct EventInfo{
EventInfo(){ m_code = 0; }
EventInfo(int l_event){ m_code = l_event; }
union{
int m_code;
};
};
为了留出扩展的空间,我们已经在使用联合体来存储事件代码。接下来,我们可以设置我们将要使用来保存事件信息的数据类型:
using Events = std::vector<std::pair<EventType, EventInfo>>;
由于我们需要与使用此类的代码共享事件信息,现在是设置一个有助于我们做到这一点的数据类型的好时机:
struct EventDetails{
EventDetails(const std::string& l_bindName)
: m_name(l_bindName)
{
Clear();
}
std::string m_name;
sf::Vector2i m_size;
sf::Uint32 m_textEntered;
sf::Vector2i m_mouse;
int m_mouseWheelDelta;
int m_keyCode; // Single key code.
void Clear(){
m_size = sf::Vector2i(0, 0);
m_textEntered = 0;
m_mouse = sf::Vector2i(0, 0);
m_mouseWheelDelta = 0;
m_keyCode = -1;
}
};
现在是时候设计绑定结构了,它将保存所有的事件信息。看起来相当简单,所以让我们来实现它:
struct Binding{
Binding(const std::string& l_name)
: m_name(l_name), m_details(l_name), c(0){}
void BindEvent(EventType l_type,
EventInfo l_info = EventInfo())
{
m_events.emplace_back(l_type, l_info);
}
Events m_events;
std::string m_name;
int c; // Count of events that are "happening".
EventDetails m_details;
};
构造函数接受我们想要绑定事件的动作名称,并使用初始化列表来设置类的数据成员。我们还有一个BindEvent()方法,它简单地接受一个事件类型和一个事件信息结构,以便将其添加到事件向量中。我们之前还没有提到的一个额外数据成员是名为c的整数。正如注释所暗示的,它跟踪实际发生的事件数量,这将在稍后确定绑定中的所有键和事件是否“开启”时很有用。最后,这是事件详细数据成员共享的结构。
这些绑定也必须以某种方式存储,所以让我们定义一个数据类型,用于容纳负责这一点的容器:
using Bindings = std::unordered_map<std::string, Binding*>;
使用std::unordered_map作为我们的绑定保证了每个动作只有一个绑定,因为它是一个关联容器,动作名称字符串是该容器的键。
到目前为止,我们进展顺利,然而,如果没有一种方法将这些操作与将被调用的有效方法实际联系起来,这个系统就相当无用。让我们来谈谈如何实现这一点。在计算机科学的世界里,你可能时不时地会听到“回调”这个术语被提及。简单来说,回调就是一些代码块,作为参数传递给其他代码,它将在一个方便的时间执行。在我们的事件管理器中,方便的时间是所有绑定到特定动作的事件发生时,而回调是一个表示正在执行的动作的方法。比如说,我们希望在按下空格键时让角色跳跃。我们会创建一个名为"Jump"的绑定,这是我们的动作名称,并向它添加一个类型为KeyDown和代码sf::Keyboard::Space的单个事件。为了举例,假设角色有一个名为Jump()的方法。这就是我们的回调。我们希望将这个方法绑定到名称"Jump"上,并让事件管理器在按下空格键时调用角色的Jump()方法。简而言之,这就是我们将如何使用这个新系统处理输入的。
到现在为止,你的 C++背景可能让你想到了“函数指针”这个术语。虽然这并不一定是一个坏选择,但如果你对这个领域不是很熟悉,可能会有些混乱。那种方法的主要问题是添加一个类的方法作为回调的场景。指向类成员的指针并不完全等同于常规函数,除非它是静态方法。以下是一个成员函数指针的基本定义:
void(SomeClass::*_callback)();
已经显示出了一些主要限制。首先,我们只能有指向“SomeClass”类的方法的指针。其次,如果没有指向我们想要指向的方法的类的实例,这相当无用。你可能已经想到了一个想法,就是将实例和函数指针一起存储在某种回调结构中。让我们看看:
struct Callback{
std::string m_name;
SomeClass* CallbackInstance; // Pointer to instance.
void(SomeClass::*_callback)();
void Call(){
CallbackInstance->*_callback();
}
};
这有点好。至少我们现在可以调用方法了,尽管我们仍然只限于一个类。我们可以在“SomeClass”类的方法中包装其他类的每个方法调用,但这很繁琐,更重要的是,这是一种不好的做法。现在你可能正在想,一些模板魔法可能可以解决这个问题。虽然这是可能的,但你还得考虑兼容性和它可能造成的混乱。考虑一下这需要的最小工作量:
template<class T>
struct Callback{
...
T* CallbackInstance; // Pointer to instance.
void(T::*_callback)();
...
};
这本身并不能解决问题,反而只会带来更多的问题。首先,你现在必须在事件管理器类中定义这个模板,这很成问题,因为我们需要一个容器来存放所有这些回调函数,这意味着我们必须模板化整个事件管理器类,这将其锁定为一种类类型。我们又回到了起点。使用 typedef 可能是一个聪明的想法,但是大多数 Visual Studio 编译器不支持这种形式的 typedef:
template <class T>
using Function = void (T::*)();
对于非 C++11 编译器,有一些非常规的解决方案,比如在定义模板后,将typedef包裹在struct中。然而,这并不能解决问题。甚至有 Visual Studio 2010 编译器在使用“模板化”成员函数指针类型定义时崩溃的实例。这相当混乱,到这一点你可能正在考虑简单地回到常规函数指针,并将每个成员函数调用包裹在不同的函数中。不用担心,C++11 引入了一个比那更好的方法。
标准函数包装器
C++工具库为我们提供了解决这个难题所需的一切:std::function和std::bind。std::function类型是一个通用多态函数包装器。在它支持的许多其他功能中,它可以存储成员函数指针并调用它们。让我们看看使用它的最小示例:
#include <functional> // Defines std::function & std::bind.
...
std::function<void(void)> foo = std::bind(&Bar::method1, this);
在这种情况下,我们实例化了一个名为"foo"的函数包装器,它包含一个签名为void(void)的函数。在等号的右侧,我们使用std::bind将类"Bar"的成员函数"method1"绑定到foo对象上。第二个参数,因为这是一个成员函数指针,是具有其方法注册为回调的类的实例。在这种情况下,它必须是Bar类的实例,所以让我们想象这一行代码是在它的实现中写下的,并且只传入"this"。现在我们的foo对象被绑定到了Bar类的method1方法上。因为std::function重载了括号操作符,所以调用它就像这样:
foo(); // Equivalent to barInstance->method1();
现在我们可以最终定义回调容器类型:
using Callbacks = std::unordered_map<std::string, std::function<void(EventDetails*)>>;
再次强调,使用std::unordered_map确保每个动作只有一个回调。如果需要,以后可以更改。
构建事件管理器
到目前为止,我们已经有了编写事件管理器类头文件所需的一切。考虑到我们之前做出的所有设计决策,它应该看起来像以下这样:
class EventManager{
public:
EventManager();
~EventManager();
bool AddBinding(Binding *l_binding);
bool RemoveBinding(std::string l_name);
void SetFocus(const bool& l_focus);
// Needs to be defined in the header!
template<class T>
bool AddCallback(const std::string& l_name, void(T::*l_func)(EventDetails*), T* l_instance)
{
auto temp = std::bind(l_func,l_instance, std::placeholders::_1);
return m_callbacks.emplace(l_name, temp).second;
}
void RemoveCallback(const std::string& l_name){
m_callbacks.erase(l_name);
}
void HandleEvent(sf::Event& l_event);
void Update();
sf::Vector2i GetMousePos(sf::RenderWindow* l_wind = nullptr){
return (l_wind ? sf::Mouse::getPosition(*l_wind)
: sf::Mouse::getPosition());
}
private:
void LoadBindings();
Bindings m_bindings;
Callbacks m_callbacks;
bool m_hasFocus;
};
从查看类定义中可以看出,我们仍然需要为AddCallback()方法使用模板成员函数指针参数。然而,std::function的使用将此隔离到单个方法中,这意味着我们不需要对整个类进行模板化,这是一个改进。在将方法指针和类的实例以及一个将来将被参数替换的单个占位符绑定到一个临时函数后,我们将其插入到回调容器中。由于编译器处理模板类的方式,我们需要在头文件中实现我们的模板AddCallback()方法,而不是在.cpp 文件中。只是为了保持一致性,并且因为这个方法非常简单,我们在头文件中也定义了RemoveCallback()。
关于标题的另一件值得指出的事情是用于获取鼠标位置的方法实现:GetMousePos()。它接受一个指向sf::RenderWindow类型的指针,以防我们希望返回的坐标相对于特定窗口。同一个窗口也可以获得或失去焦点,因此有一个标志m_hasFocus被保留以跟踪这一点。
实现事件管理器
让我们开始实际实现所有事件管理器类方法,从构造函数和析构函数开始:
EventManager::EventManager(): m_hasFocus(true){ LoadBindings(); }
EventManager::~EventManager(){
for (auto &itr : m_bindings){
delete itr.second;
itr.second = nullptr;
}
}
在这种情况下,构造函数的工作非常简单。它只需要调用一个私有方法LoadBindings(),该方法用于从文件中加载有关我们绑定的信息。我们将在稍后介绍这一点。
对于此类,析构函数的工作也相当普通。如果你还记得,我们在堆上存储绑定,因此必须释放这块动态内存。
让我们看看AddBinding方法实现:
bool EventManager::AddBinding(Binding *l_binding){
if (m_bindings.find(l_binding->m_name) != m_bindings.end())
return false;
return m_bindings.emplace(l_binding->m_name,
l_binding).second;
}
如你所见,它接受一个指向绑定的指针。然后它检查绑定容器是否已经有一个具有相同名称的绑定。如果有,该方法返回false,这对于错误检查很有用。如果没有名称冲突,新的绑定将被插入到容器中。
我们有添加绑定的一种方法,但关于移除它们怎么办?这就是RemoveBinding方法的作用所在:
bool EventManager::RemoveBinding(std::string l_name){
auto itr = m_bindings.find(l_name);
if (itr == m_bindings.end()){ return false; }
delete itr->second;
m_bindings.erase(itr);
return true;
}
它接受一个字符串参数,并在容器中搜索匹配项以存储到迭代器中。如果找到匹配项,它首先通过删除键值对中的第二个元素(即为绑定对象分配的动态内存)来释放内存,然后在实际返回true表示成功之前从容器中删除条目。很简单。
如在设计此类规格说明中提到的,我们需要一种方法来处理在每次迭代中轮询的 SFML 事件,以便查看它们并确定是否有我们感兴趣的内容。这就是HandleEvent的作用所在:
void EventManager::HandleEvent(sf::Event& l_event){
// Handling SFML events.
for (auto &b_itr : m_bindings){
Binding* bind = b_itr.second;
for (auto &e_itr : bind->m_events){
EventType sfmlEvent = (EventType)l_event.type;
if (e_itr.first != sfmlEvent){ continue; }
if (sfmlEvent == EventType::KeyDown ||
sfmlEvent == EventType::KeyUp)
{
if (e_itr.second.m_code == l_event.key.code){
// Matching event/keystroke.
// Increase count.
if (bind->m_details.m_keyCode != -1){
bind->m_details.m_keyCode = e_itr.second.m_code;
}
++(bind->c);
break;
}
} else if (sfmlEvent == EventType::MButtonDown ||
sfmlEvent == EventType::MButtonUp)
{
if (e_itr.second.m_code == l_event.mouseButton.button){
// Matching event/keystroke.
// Increase count.
bind->m_details.m_mouse.x = l_event.mouseButton.x;
bind->m_details.m_mouse.y = l_event.mouseButton.y;
if (bind->m_details.m_keyCode != -1){
bind->m_details.m_keyCode = e_itr.second.m_code;
}
++(bind->c);
break;
}
} else {
// No need for additional checking.
if (sfmlEvent == EventType::MouseWheel){
bind->m_details.m_mouseWheelDelta = l_event.mouseWheel.delta;
} else if (sfmlEvent == EventType::WindowResized){
bind->m_details.m_size.x = l_event.size.width;
bind->m_details.m_size.y = l_event.size.height;
} else if (sfmlEvent == EventType::TextEntered){
bind->m_details.m_textEntered = l_event.text.unicode;
}
++(bind->c);
}
}
}
}
它接受一个类型为 sf::Event 的参数,这是相当合适的。然后这个方法必须遍历所有的绑定以及绑定内的每个事件,检查 l_event 参数的类型是否与当前正在处理的事件绑定类型匹配。如果匹配,我们检查它是否是键盘事件或鼠标事件,因为这涉及到进一步检查键盘键或鼠标按钮是否与我们的绑定匹配。如果是其中之一,最后一步是检查键盘键码或鼠标按钮码,分别存储在 l_event.key 和 l_event.mouseButton 结构体中,是否与我们的绑定事件码匹配。如果是这种情况,或者如果它是一种不需要进一步处理的不同类型的事件,如几行代码所示,我们就在将相关事件信息存储在绑定的事件详情结构体后不久,增加绑定实例的成员 c 以表示匹配。
最后,对于输入处理,我们需要有一个更新方法,这个方法可以处理实时输入检查以及验证和重置绑定状态。让我们来写一下:
void EventManager::Update(){
if (!m_hasFocus){ return; }
for (auto &b_itr : m_bindings){
Binding* bind = b_itr.second;
for (auto &e_itr : bind->m_events){
switch (e_itr.first){
case(EventType::Keyboard) :
if (sf::Keyboard::isKeyPressed(
sf::Keyboard::Key(e_itr.second.m_code)))
{
if (bind->m_details.m_keyCode != -1){
bind->m_details.m_keyCode = e_itr.second.m_code;
}
++(bind->c);
}
break;
case(EventType::Mouse) :
if (sf::Mouse::isButtonPressed(
sf::Mouse::Button(e_itr.second.m_code)))
{
if (bind->m_details.m_keyCode != -1){
bind->m_details.m_keyCode = e_itr.second.m_code;
}
++(bind->c);
}
break;
case(EventType::Joystick) :
// Up for expansion.
break;
}
}
if (bind->m_events.size() == bind->c){
auto callItr = m_callbacks.find(bind->m_name);
if(callItr != m_callbacks.end()){
callItr->second(&bind->m_details);
}
}
bind->c = 0;
bind->m_details.Clear();
}
}
再次遍历所有的绑定及其事件。然而,在这种情况下,我们只对 Keyboard、Mouse 和 Joystick 感兴趣,因为这是我们唯一可以检查实时输入的设备。就像之前一样,我们检查我们处理的事件类型,并使用适当的类来检查输入。像往常一样,增加绑定类的 c 成员是我们注册匹配的方式。
最后一步是检查事件容器中的事件数量是否与“开启”的事件数量匹配。如果是这样,我们在 m_callbacks 容器中定位我们的回调,并使用括号操作符调用 second 数据成员,因为它是一个 std::function 方法包装器,从而正式实现回调。我们向它传递包含所有事件信息的 EventDetails 结构体的地址。之后,重要的是将活动事件计数器 c 重置为 0 以进行下一次迭代,因为之前检查的任何事件的状态都可能已经改变,它们都需要重新评估。
最后,如果你从头到尾看了代码,你可能已经注意到控制器输入的情况并没有做任何事情。事实上,我们甚至没有处理任何与控制器相关的事件。这是可以稍后扩展的内容,并且对我们任何项目都不是至关重要的。如果你渴望添加对游戏手柄的支持并且有机会使用一个,那么在完成本章后,将其视为作业。
现在我们有了所有这些功能,为什么不实际从文件中读取一些绑定信息呢?让我们看看我们将要加载的示例配置文件,名为 keys.cfg:
Window_close 0:0
Fullscreen_toggle 5:89
Move 9:0 24:38
这可以按照您想要的任何方式格式化,然而,为了简单起见,这里的布局将保持相当基础。每一行都是一个新绑定。它以绑定名称开始,后面跟着事件类型的数值表示和事件代码,由冒号分隔。每个不同的事件键:值对由空格分隔,以及绑定名称和事件的开始。让我们读取这个:
void EventManager::LoadBindings(){
std::string delimiter = ":";
std::ifstream bindings;
bindings.open("keys.cfg");
if (!bindings.is_open()){
std::cout << "! Failed loading keys.cfg." << std::endl;
return;
}
std::string line;
while (std::getline(bindings, line)){
std::stringstream keystream(line);
std::string callbackName;
keystream >> callbackName;
Binding* bind = new Binding(callbackName);
while (!keystream.eof()){
std::string keyval;
keystream >> keyval;
int start = 0;
int end = keyval.find(delimiter);
if (end == std::string::npos){
delete bind;
bind = nullptr;
break;
}
EventType type = EventType(
stoi(keyval.substr(start, end - start)));
int code = stoi(keyval.substr(end + delimiter.length(),
keyval.find(delimiter, end + delimiter.length())));
EventInfo eventInfo;
eventInfo.m_code = code;
bind->BindEvent(type, eventInfo);
}
if (!AddBinding(bind)){ delete bind; }
bind = nullptr;
}
bindings.close();
}
我们首先尝试打开keys.cfg文件。如果失败,此方法会输出一个控制台消息通知我们。接下来,我们进入一个while循环,以便读取文件中的每一行。我们定义一个std::stringstream对象,它允许我们使用>>运算符逐个“流”我们的字符串。它使用默认的分隔符,即空格,这就是我们为配置文件做出那个决定的原因。在获取我们的绑定名称后,我们创建一个新的Binding实例,并在构造函数中传递该名称。之后,通过进入while循环并使用!keystream.eof()作为参数,我们确保它循环直到std::stringstream对象达到它正在读取的行的末尾。这个循环对每个键:值对运行一次,再次感谢std::stringstream及其默认使用空格作为分隔符的重载>>运算符。
在流式传输事件类型和代码后,我们必须确保将其从字符串转换为两个整数值,然后分别存储在各自的局部变量中。它读取之前读取的字符串的一部分,以便通过分隔符字符将键:值对分开,在这个例子中,该分隔符字符是在此方法的顶部定义的,即":"。如果该字符在字符串中未找到,则绑定实例将被删除,该行将被跳过,因为它很可能格式不正确。如果不是这种情况,则事件将成功绑定,代码继续到下一对。
一旦读取了所有值并达到行尾,我们尝试将绑定添加到事件管理器中。这是在 if 语句中完成的,以便捕获我们之前提到的与绑定名称冲突的错误。如果有冲突,绑定实例将被删除。
如您可能已经知道,在使用文件后关闭它也同样重要,因此这是此方法结束前我们做的最后一件事。完成这些后,我们的事件管理器终于完成了,现在是时候真正投入使用。
集成事件管理器类
因为事件管理器需要检查所有被处理的事件,所以将其保留在我们的Window类中是有意义的,因为我们实际上在这里进行事件轮询。毕竟,我们处理的所有事件都源自打开的窗口,所以在这里保留事件管理器的一个实例是合理的。让我们通过向Window类中添加一个数据成员来对其进行轻微调整:
class Window{
public:
...
bool IsFocused();
EventManager* GetEventManager();
void ToggleFullscreen(EventDetails* l_details);
void Close(EventDetails* l_details = nullptr);
...
private:
...
EventManager m_eventManager;
bool m_isFocused;
};
除了添加一个额外的获取事件管理器的方法外,全屏切换方法已经被修改为接受EventDetails结构作为参数。我们还为我们的Window类添加了一个Close方法,以及一个标志来跟踪窗口是否处于焦点状态。关闭窗口的方法本身非常简单,只需将一个标志设置为true:
void Window::Close(){ m_isDone = true; }
现在是时候调整Window::Update方法并将所有被轮询的事件传递给事件管理器:
void Window::Update(){
sf::Event event;
while(m_window.pollEvent(event)){
if (event.type == sf::Event::LostFocus){
m_isFocused = false;
m_eventManager.SetFocus(false);
}
else if (event.type == sf::Event::GainedFocus){
m_isFocused = true;
m_eventManager.SetFocus(true);
}
m_eventManager.HandleEvent(event);
}
m_eventManager.Update();
}
这确保了窗口中发出的每个事件都将得到适当的处理。它还通知事件管理器窗口的焦点是否发生变化。
现在是真正使用事件管理器的时候了!让我们在Window::Setup中通过注册两个回调到一些成员函数来实现,在创建事件管理器的新实例之后:
void Window::Setup(...){
...
m_isFocused = true; // Default value for focused flag.
m_eventManager->AddCallback("Fullscreen_toggle", &Window::ToggleFullscreen,this);
m_eventManager->AddCallback("Window_close", &Window::Close,this);
...
}
让我们回顾一下keys.cfg文件。我们定义了Fullscreen_toggle操作,并设置了一个键值对 5:89,这实际上被分解为事件类型KeyDown(数字 5)和键盘上F5键的代码(数字 89)。这两个值都是我们使用的枚举的整数表示。
另一个被设置的回调是针对Window_close操作的,在配置文件中它被绑定到 0:0。事件类型 0 对应枚举表中的Closed,代码无关紧要,所以我们也将它设置为 0。
这两个操作都被绑定到Window类的函数上。注意AddCallback方法中的最后一个参数,它是一个指向当前窗口实例的this指针。在成功编译和启动后,你应该会发现按下键盘上的F5键可以切换窗口的全屏模式,而点击关闭按钮实际上会关闭窗口。它真的工作了!现在让我们用这个来做点更有趣的事情。
重新审视移动精灵
现在我们有了这个花哨的事件管理器,让我们通过在按下左 shift 键并按下左鼠标按钮时将精灵移动到鼠标位置来完全测试它。在你的Game类中添加两个新的数据成员:m_texture和m_sprite。按照前几章的讨论来设置它们。对于我们的目的,我们只需重新使用第一、二章节中的蘑菇图形。现在在你的游戏类中添加并实现一个新的方法MoveSprite:
void Game::MoveSprite(EventDetails* l_details){
sf::Vector2i mousepos = m_window->GetEventManager()->GetMousePos(m_window->GetRenderWindow());
m_sprite.setPosition(mousepos.x, mousepos.y);
std::cout << "Moving sprite to: " << mousepos.x << ":" << mousepos.y << std::endl;
}
我们在这里做的是从事件管理器获取相对于当前窗口的鼠标位置,并将其存储在一个名为 mousepos 的局部整数向量中。然后我们将精灵的位置设置为当前鼠标位置,并在控制台窗口中打印出一个小句子。这非常基础,但将很好地作为测试。让我们设置我们的回调:
Game::Game(){
...
// Texture and sprite setup.
...
m_window->GetEventManager()->AddCallback("Move", &Game::MoveSprite,this);
}
我们将动作名称 Move 绑定到 Game 类的 MoveSprite 方法,并传入当前实例的指针,就像之前一样。在运行之前,让我们看看 keys.cfg 文件中定义的移动动作的方式:
Move 9:0 24:38
第一个事件类型对应于 MButtonDown,这是左鼠标按钮被按下的事件。第二个事件类型对应于 Keyboard 事件,它通过 sf::Keyboard 类检查实时输入。数字 38 是左移位键码,对应于 sf::Keyboard::LShift。
在编译和执行我们的应用程序后,我们应该在屏幕上渲染一个精灵。如果我们按住左移位键并在屏幕上的任何位置左击,它将神奇地移动到那个位置!

使用原则
在这个设计中,知道何时使用哪种类型的事件同样重要。比如说,如果你只想在涉及左移位和 R 键的绑定中调用一次回调,你不会将这两个事件类型都定义为 Keyboard,因为这样只要这些键被按下,回调方法就会一直被调用。你也不想将它们都定义为 KeyDown 事件,因为这意味着这两个事件必须同时注册,而在同时按下多个键的情况下,由于屏幕刷新率的原因,这种情况不太可能发生。正确使用的方法是混合使用 Keyboard 和 KeyDown 事件,使得最后一个被按下的键是 KeyDown 类型,其余的键将是 Keyboard 类型。在我们的例子中,这意味着我们将左移位键通过 sf::Keyboard 类进行检查,而 R 键将默认为事件分发。这听起来可能有些奇怪,然而,考虑一下你电脑上著名的 Ctrl + Alt + Del 键组合。它就是这样工作的,但如果以相反的顺序按这些键,它将不会做任何事情。如果我们正在实现这个功能,我们很可能会确保 Ctrl 和 Alt 键总是通过 sf::Keyboard 类进行检查,而 Del 键将通过事件轮询进行注册。
关于这个类使用的一个需要注意的最后一点是,一些事件尚未得到支持,例如sf::Event::TextEntered事件,因为为了完全利用它们,需要额外的信息,这些信息来自sf::Event类。在处理需要这些事件的问题时,将在后面的章节中介绍如何正确扩展事件管理器以支持这些功能。
常见错误
当涉及到 SFML 输入时,新用户犯的最常见的错误之一是使用某些方法来检查用户输入,但这些方法并不适用于正确的任务,例如使用窗口事件进行实时字符移动或捕获文本输入。了解你所使用的一切的限制是培养任何良好性能的关键。确保坚持我们讨论过的所有不同机制的本意用途,以实现最佳结果。
另一个相当常见的错误是,人们在.cpp 文件中定义模板而不是在头文件中。如果你遇到了与使用模板的方法(例如EventManager::AddCallback()方法)相关的链接错误,确保将方法的实现和模板的定义直接移动到你的类头文件中,否则编译器无法实例化模板,方法在链接过程中将不可访问。
最后,许多新用户在使用 SFML 时犯的一个相当简单但极其普遍的错误是不知道如何正确获取相对于窗口的鼠标坐标。这从简单地使用错误的坐标并经历奇怪的行为,到获取相对于桌面的坐标以及窗口的位置,然后从另一个坐标中减去以获得局部鼠标位置。虽然后者可行,但有点过度,尤其是考虑到 SFML 已经为你提供了一种无需重新发明轮子的方法。只需将你的窗口引用传递给sf::Mouse::getPosition()方法即可。这就是你所需要的一切。
摘要
正如良好的代码组织一样,健壮的输入管理是许多可以意味着你快乐地开发应用程序和应用程序在众多失败项目中淹没之间的区别之一。随着适当和灵活的设计,代码的可重用性得到了极大的提高,所以恭喜你,又迈出了构建一个不会因为其狭隘的结构而难以工作而变得一文不值的应用程序的一步。
这个世界上没有绝对完美的设计,然而,随着本章的完成,我们现在又向我们在这次体验一开始就为自己设定的目标迈进了一步。这个目标因人而异。也许自从我们开始以来它已经有所增长;甚至可能已经变成了与之前完全不同的东西。对我们其他人来说,这些都并不确定,但这并不重要。重要的是,我们完全掌控着我们将这些目标引向何方,即使我们无法控制它们将我们带向何方。而且,随着我们朝着目标前进的旅程持续进行,甚至当新的目标开始出现时,我们现在可以说,我们有了更强的手段来掌控整个过程,就像我们建立了自己更强的手段来掌控我们的应用程序一样。所以,请继续前进到下一章,并继续你的旅程,通过了解应用程序状态。我们那里见!
第五章. 我能暂停吗? – 应用程序状态
一款软件,如视频游戏,很少像术语所暗示的那样简单。大多数时候,你不仅要处理游戏机制和渲染,还要处理这种应用程序。如今,行业标准的产品在游戏开始之前还包括一个很好的开场动画。它还有一个菜单,玩家可以用来开始游戏,管理它提供的不同设置,查看版权信息或退出应用程序。除此之外,本章标题还暗示了暂停游戏一会儿的可能性。事后看来,这样的简单便利性正是区分早期游戏(操作尴尬,可能令人困惑)和提供与市场上大多数游戏相同控制水平的产品之间的界限。为了为这样的想法提供支撑,在本章中,我们将涵盖以下内容:
-
实现状态管理器
-
升级事件管理器以处理不同状态
-
为我们的游戏介绍、主菜单和游戏玩法部分创建不同的状态
-
提供暂停游戏的方法
-
实现状态混合
-
将状态串联起来以创建连贯的应用程序流程
什么是状态?
在我们开始任何类型的实现之前,理解我们所处理的内容是必要的。如果你之前阅读过任何类型的游戏开发材料,你可能已经遇到了术语状态。它可以根据上下文有不同的含义。在这种情况下,状态是游戏中的许多不同层次之一,比如主菜单、在显示菜单之前播放的介绍,或者实际的游戏玩法。自然地,这些层次中的每一个都有自己的方式来更新自己并将内容渲染到屏幕上。当利用这个系统时,游戏开发者的工作是将给定的问题分解成单独的、可管理的状态以及它们之间的转换。这本质上意味着,如果你面临在游戏中添加菜单的问题,解决方案将是创建两个状态,一个用于菜单,一个用于你的游戏玩法,并在适当的时候在这两个状态之间进行转换。
最简单的方法
让我们先从新手解决这个问题的最常见方法开始说明。它首先列举了游戏可能具有的所有可能状态:
enum class StateType{
Intro = 1, MainMenu, Game, Paused, GameOver, Credits
};
良好的开始。现在让我们通过简单地使用switch语句来使用它:
void Game::Update(){
switch(m_state){
case(StateType::Intro):
UpdateIntro();
break;
case(StateType::Game):
UpdateGame();
break;
case(StateType::MainMenu):
UpdateMenu();
break;
...
}
}
同样,在屏幕上绘制它:
void Game::Render(){
switch(m_state){
case(StateType::Intro):
DrawIntro();
break;
case(StateType::Game):
DrawGame();
break;
case(StateType::MainMenu):
DrawMenu();
break;
...
}
}
虽然这种方法对于非常小的游戏来说是可行的,但可扩展性在这里完全是个问题。首先,随着状态的增加,switch 语句将继续增长。假设我们保持更新和渲染特定状态的功能仅本地化到一种方法,那么这些方法的数量也将随着每个状态至少增加两种方法而增长,其中一种用于更新,另一种用于渲染。记住,这是为了支持额外状态所需的最小扩展量。如果我们还为每个状态单独处理事件或执行某种类型的附加逻辑,如延迟更新,那么就是四个 switch 语句,每个状态一个额外的 switch 分支,以及四个必须实现并添加到分支中的额外方法。
接下来,考虑状态转换。如果你出于某种原因想在短时间内同时渲染两个状态,整个方法就会崩溃。仍然可以通过绑定一串标志或创建如下组合状态来以某种方式将那种功能组合在一起:
enum StateType{
Intro = 1, Intro_MainMenu, MainMenu, Game, MainMenu_Game
Paused, GameOver, MainMenu_GameOver, Credits, MainMenu_Credits
...
// Crying in the corner.
};
这种混乱的情况正变得越来越严重,我们甚至还没有开始扩展我们已有的庞大的 switch 语句,更不用说实现我们想要的全部状态了!
如果你到现在还没有考虑过迁移到不同的策略,请考虑以下最后一个观点:资源。如果你同时加载了游戏可能具有的所有可能状态的所有数据,那么从效率的角度来看,你可能会遇到相当大的问题。你可以动态分配代表某些状态的类,并检查它们何时不再使用,从而以某种方式释放它们,然而,这将在你已经几乎无法阅读的代码库中增加额外的混乱,而且既然你已经考虑使用类,为什么不做得更好呢?
介绍状态模式
在经过一些仔细的白板讨论和考虑之后,之前提到的问题都可以避免。之前已经提出过,不同的游戏状态可以简单地本地化到它们自己的类中。所有这些类都将共享相同的更新和渲染方法,这使得继承成为了当务之急。让我们看看我们的基础状态头文件:
class StateManager;
class BaseState{
friend class StateManager;
public:
BaseState(StateManager* l_stateManager)
:m_stateMgr(l_stateManager),m_transparent(false),
m_transcendent(false){}
virtual ~BaseState(){}
virtual void OnCreate() = 0;
virtual void OnDestroy() = 0;
virtual void Activate() = 0;
virtual void Deactivate() = 0;
virtual void Update(const sf::Time& l_time) = 0;
virtual void Draw() = 0;
void SetTransparent(const bool& l_transparent){
m_transparent = l_transparent;
}
bool IsTransparent()const{ return m_transparent; }
void SetTranscendent(const bool& l_transcendence){
m_transcendent = l_transcendence;
}
bool IsTranscendent()const{ return m_transcendent; }
StateManager* GetStateManager(){ return m_stateMgr; }
protected:
StateManager* m_stateMgr;
bool m_transparent;
bool m_transcendent;
};
首先,你会注意到我们正在使用StateManager类的前向声明。基类实际上不需要了解我们的状态管理器将如何实现,只需要知道它需要保持对其的指针。这样做也是为了避免递归定义,因为StateManager类头文件需要包含BaseState类头文件。
由于我们希望在整个状态中强制使用相同的方法,我们将它们定义为纯虚的,这意味着从 BaseState 继承的类必须实现每一个,以便项目可以编译。任何派生类必须实现的方 法包括 OnCreate 和 OnDestroy,这些方法在状态被创建并推入栈中时调用,稍后从栈中移除,Activate 和 Deactivate,这些方法在状态被移动到栈顶以及从栈顶位置移除时调用,最后是 Update 和 Draw,这些方法用于更新状态和绘制其内容。
关于这个类的一个需要注意的最后一点是,它有一对标志:m_transparent 和 m_transcendent。这些标志表示这个状态是否也需要渲染或更新其之前的状态。这消除了对状态之间不同转换的无数枚举的需求,并且可以自动完成,无需任何额外的扩展。
定义常见类型
我们肯定要保留从上一个示例中的状态类型枚举表:
enum class StateType{
Intro = 1, MainMenu, Game, Paused, GameOver, Credits
};
将状态类型枚举出来既方便又有助于自动化状态创建,您稍后将会看到。
我们还需要保留的另一个常见类型是我们将与状态一起使用的设备上下文。不要被这个名字迷惑,它仅仅意味着有一个指向我们最常用的一些类或“设备”的指针。因为不止一个,定义一个简单的结构来保留指向主窗口类和事件管理器的指针非常有用:
struct SharedContext{
SharedContext():m_wind(nullptr),m_eventManager(nullptr){}
Window* m_wind;
EventManager* m_eventManager;
};
这可以在需要时进行扩展,以保存有关玩家和其他处理资源分配、声音和网络辅助类的信息。
状态管理类
现在我们已经设置了辅助结构,让我们实际定义将在状态管理类中使用的类型,以保存信息。像往常一样,我们将使用类型定义,其美妙之处在于它们减少了在修改类型定义时需要更改的代码量。让我们首先看看状态容器类型:
using StateContainer = std::vector<std::pair<StateType, BaseState*>>;
再次强调,我们正在使用一个向量。元素类型是我们状态类型和指向 BaseState 类型对象的指针的配对。你可能想知道为什么映射不是更好的选择,答案取决于你的实现想法,然而,一个主要因素是映射在容器中不保持类似栈的顺序,这对于我们希望状态管理器正确工作来说非常重要。
状态管理类中的一个设计决策也需要一个状态类型的容器,让我们定义一下:
using TypeContainer = std::vector<StateType>;
如您所见,它只是一个 StateType 枚举类型的向量。
我们需要定义的最后一个类型是用于存储自定义函数的容器,这些函数将作为自动生成从 BaseState 类派生的不同类型对象的手段:
using StateFactory = std::unordered_map<StateType, std::function<BaseState*(void)>>;
我们在这里使用无序映射来将特定的状态类型映射到将生成该类型的特定函数。如果现在听起来很困惑,请耐心等待。当我们实际使用它时,将会更详细地介绍。
定义状态管理器类
我们需要的所有单个部分现在都已齐备,因此让我们编写它:
class StateManager{
public:
StateManager(SharedContext* l_shared);
~StateManager();
void Update(const sf::Time& l_time);
void Draw();
void ProcessRequests();
SharedContext* GetContext();
bool HasState(const StateType& l_type);
void SwitchTo(const StateType& l_type);
void Remove(const StateType& l_type);
private:
// Methods.
void CreateState(const StateType& l_type);
void RemoveState(const StateType& l_type);
template<class T>
void RegisterState(const StateType& l_type){...}
// Members.
SharedContext* m_shared;
StateContainer m_states;
TypeContainer m_toRemove;
StateFactory m_stateFactory;
};
构造函数接受一个指向我们之前提到的 SharedContext 类型的指针,它将在我们的主 Game 类中创建。不出所料,状态管理器也使用了 Update 和 Draw 方法,因为它将由 Game 类操作,并且保持接口熟悉是件好事。为了方便起见,它还提供了获取上下文以及确定当前是否具有某个状态在栈上的辅助方法。
在公共方法结束时,我们有 SwitchTo,它接受一个状态类型并将当前状态更改为与该类型相对应的状态,以及 Remove,用于通过其类型从状态栈中删除状态。
如果你从上到下查看类定义,你可能已经注意到我们有一个名为 m_toRemove 的 TypeContainer 成员。为了确保平稳且无错误的转换,我们不能在任何时候随意从状态容器中删除任何状态。这里的简单解决方案是跟踪我们想要删除的状态类型,并且只有在它们不再被使用时才删除它们,这就是 ProcessRequests 方法所做的工作。它在游戏循环的最后被调用,这确保了 m_toRemove 容器中的状态不再被使用。
让我们在下一节继续介绍更高级的私有方法和状态管理器类的实现。
实现状态管理器
为了保持我们在堆上自动创建状态的自动化方法,我们必须有一种定义它们如何创建的方式。m_stateFactory 成员是一个将状态类型映射到 std::function 类型的映射,我们可以通过使用 lambda 表达式来设置它以包含函数体:
template<class T>
void RegisterState(const StateType& l_type){
m_stateFactory[l_type] = [this]() -> BaseState*
{
return new T(this);
};
}
上面的代码将 m_stateFactory 映射中的 l_type 类型映射到一个简单的函数,该函数返回新分配的内存的指针。我们在这里使用模板来减少代码量。因为每个状态在其构造函数中都需要指向 StateManager 类的指针,所以我们传递了 this 指针。我们现在可以像这样注册不同的状态:
StateManager::StateManager(SharedContext* l_shared): m_shared(l_shared)
{
RegisterState<State_Intro>(StateType::Intro);
RegisterState<State_MainMenu>(StateType::MainMenu);
RegisterState<State_Game>(StateType::Game);
RegisterState<State_Paused>(StateType::Paused);
}
现在是时候开始实现类的其余部分了。让我们看看析构函数:
StateManager::~StateManager(){
for (auto &itr : m_states){
itr.second->OnDestroy();
delete itr.second;
}
}
由于我们将任何状态的动态内存分配都本地化到这个类中,因此我们也有必要适当地释放内存。遍历所有状态并删除组成元素的配对的第二个值正是这样做的。
接下来,让我们看看如何实现绘制方法:
void StateManager::Draw(){
if (m_states.empty()){ return; }
if (m_states.back().second->IsTransparent() && m_states.size() > 1)
{
auto itr = m_states.end();
while (itr != m_states.begin()){
if (itr != m_states.end()){
if (!itr->second->IsTransparent()){
break;
}
}
--itr;
}
for (; itr != m_states.end(); ++itr){
itr->second->Draw();
}
} else {
m_states.back().second->Draw();
}
}
首先,就像 Update 方法一样,我们检查状态容器是否至少有一个状态。如果有,我们检查最近添加的一个的透明度标志,以及栈上是否只有一个状态,否则透明度将没有用。如果栈上只有一个状态或者当前状态不是透明的,我们只需调用它的 Draw 方法。否则,事情会变得有点更有趣。
为了正确渲染透明状态,我们必须以正确的顺序调用它们的相应 Draw 方法,其中栈上最新的状态最后被绘制到屏幕上。为此,需要从状态向量中向后迭代,直到找到一个既不是透明的状态,或者是栈上的第一个状态,这正是 while 循环所做的事情。找到这样的状态后,for 循环将调用从找到的状态开始,包括最后一个状态的所有状态的 Draw 方法。这有效地以正确的顺序一次渲染多个状态。
更新状态时遵循相当类似的程序:
void StateManager::Update(const sf::Time& l_time){
if (m_states.empty()){ return; }
if (m_states.back().second->IsTranscendent() && m_states.size() > 1)
{
auto itr = m_states.end();
while (itr != m_states.begin()){
if (itr != m_states.end()){
if (!itr->second->IsTranscendent()){
break;
}
}
--itr;
}
for (; itr != m_states.end(); ++itr){
itr->second->Update(l_time);
}
} else {
m_states.back().second->Update(l_time);
}
}
首先检查状态的单超越标志,以确定顶部状态是否允许其他状态更新。然后需要更新的状态或状态的 Update 方法被调用,传入的参数是经过的时间,通常称为delta time。
总是如此,我们需要为类定义一些辅助方法,使其真正灵活和有用:
SharedContext* StateManager::GetContext(){ return m_shared; }
bool StateManager::HasState(const StateType& l_type){
for (auto itr = m_states.begin();
itr != m_states.end(); ++itr)
{
if (itr->first == l_type){
auto removed = std::find(m_toRemove.begin(),
m_toRemove.end(), l_type);
if (removed == m_toRemove.end()){ return true; }
return false;
}
}
return false;
}
获取上下文的第一种方法相当直接。它只是返回对 m_shared 成员的指针。第二种方法简单地遍历 m_states 容器,直到找到一个类型为 l_type 的状态并返回 true。如果没有找到这样的状态,或者找到了但即将被移除,它返回 false。这为我们提供了一种检查特定状态是否在栈上的方法。
有一种方法可以移除状态,就像有方法添加状态一样必要。让我们实现公共方法 Remove:
void StateManager::Remove(const StateType& l_type){
m_toRemove.push_back(l_type);
}
这个方法将状态类型推入 m_toRemove 向量中,稍后由该方法处理:
void StateManager::ProcessRequests(){
while (m_toRemove.begin() != m_toRemove.end()){
RemoveState(*m_toRemove.begin());
m_toRemove.erase(m_toRemove.begin());
}
}
这个类中最后被调用的方法 ProcessRequests 简单地遍历 m_toRemove 向量,并调用一个私有方法 RemoveState,该方法负责实际的资源释放。然后它移除元素,确保容器被清空。
能够更改当前状态至关重要,这正是 SwitchTo 方法所处理的:
void StateManager::SwitchTo(const StateType& l_type){
m_shared->m_eventManager->SetCurrentState(l_type);
for (auto itr = m_states.begin();
itr != m_states.end(); ++itr)
{
if (itr->first == l_type){
m_states.back().second->Deactivate();
StateType tmp_type = itr->first;
BaseState* tmp_state = itr->second;
m_states.erase(itr);
m_states.emplace_back(tmp_type, tmp_state);
tmp_state->Activate();
return;
}
}
// State with l_type wasn't found.
if (!m_states.empty()){ m_states.back().second->Deactivate(); }
CreateState(l_type);
m_states.back().second->Activate();
}
首先,你会注意到我们通过共享上下文访问事件管理器并调用一个名为 SetCurrentState 的方法。我们还没有添加它,但是很快就会涉及到。它的作用是简单地修改事件管理器类的一个内部数据成员,这个成员跟踪游戏处于哪个状态。
接下来,我们必须找到我们想要切换到的类型的状态,因此我们遍历状态向量。如果我们找到一个匹配项,即将被推回的当前状态将调用其 Deactivate 方法以执行任何必要的功能,以防状态关心它被移动下来的时刻。然后,我们创建两个临时变量来保存状态类型和状态对象的指针,这样在通过调用 erase 从向量中移除我们感兴趣的元素时,我们不会丢失这些信息。完成这些后,所有指向状态容器的迭代器都无效了,但在我们的情况下,这并不重要,因为我们不再需要任何。移动所需状态现在就像将另一个元素推回向量并传入我们的临时变量一样简单。然后,我们调用刚刚移动进来的状态的 Activate 方法,以防它在那时需要执行任何逻辑。
如果找不到具有 l_type 的状态,则需要创建一个。然而,首先,重要的是检查是否至少有一个状态可以调用 Deactivate 方法,如果有,就调用它。在调用私有方法 CreateState 并传入状态类型之后,我们从状态向量中获取最近由 CreateState 添加的元素,并调用 Activate。
是时候看看创建一个状态究竟需要包含哪些内容了:
void StateManager::CreateState(const StateType& l_type){
auto newState = m_stateFactory.find(l_type);
if (newState == m_stateFactory.end()){ return; }
BaseState* state = newState->second();
m_states.emplace_back(l_type, state);
state->OnCreate();
}
创建一个状态工厂迭代器并检查它是否与 std::unordered_map 的 end() 方法返回的迭代器匹配,这样我们可以确保可以创建具有该类型的状态。如果可以,创建一个类型为 BaseState 的指针,称为 state。它捕获了我们的迭代器第二个值作为函数调用的返回结果,如果你记得,那是 std::function 类型,并返回一个指向新创建的状态类的指针。这就是我们如何使用之前提到的“工厂”。在检索到状态的新分配内存的指针后,我们只需将其推回状态向量并调用 OnCreate 以执行状态关于刚刚创建的内部逻辑。
我们如何移除一个状态?让我们看看:
void StateManager::RemoveState(const StateType& l_type){
for (auto itr = m_states.begin();itr != m_states.end(); ++itr)
{
if (itr->first == l_type){
itr->second->OnDestroy();
delete itr->second;
m_states.erase(itr);
return;
}
}
}
当处理 std::vector 类型时,我们遍历它直到找到匹配项。移除实际状态的过程是从调用该状态的 OnDestroy 方法开始的,再次强调,这是为了让它执行任何必要的逻辑以便准备好被移除。然后我们简单地使用 delete 关键字来释放内存。最后,我们从状态向量中删除元素并从方法中返回。
改进事件管理器类
在游戏中存在不同的状态无疑会创造需要相同键或事件的场景,至少有两个状态会需要。假设我们有一个菜单,通过按箭头键进行导航。这都很好,但如果游戏状态也注册了箭头键的使用并设置了它自己的回调呢?最好的情况是所有状态的回调将同时被调用并产生奇怪的行为。然而,当你有指向不再内存中的方法的函数指针时,事情变得更糟,特别是没有人喜欢应用程序崩溃。处理这个问题的简单方法是将回调按状态分组,并且只有在当前状态是回调状态时才调用它们。这显然意味着需要对正在处理的数据类型进行一些重新定义:
using CallbackContainer = std::unordered_map<std::string, std::function<void(EventDetails*)>>;
enum class StateType;
using Callbacks = std::unordered_map<StateType, CallbackContainer>;
现在事情变得有点复杂了。之前是Callback定义的地方现在被重命名为CallbackContainer。我们每个状态只想有一个这样的,这意味着我们需要使用另一个映射,这就是新的Callback定义出现的地方。它将状态类型映射到CallbackContainer类型,这样我们就可以在每个状态中只有一个CallbackContainer,同时每个名称也只有一个回调函数。
尽管有这些变化,事件管理器头文件中m_callbacks的声明保持不变:
Callbacks m_callbacks;
类数据成员列表中增加了一个小的内容,那就是当前状态:
StateType m_currentState;
然而,改变的是添加、移除和利用回调的方法。让我们将AddCallback方法适应这些变化:
template<class T>
bool AddCallback(StateType l_state, const std::string& l_name,void(T::*l_func)(EventDetails*), T* l_instance)
{
auto itr = m_callbacks.emplace(l_state, CallbackContainer()).first;
auto temp = std::bind(l_func, l_instance,std::placeholders::_1);
return itr->second.emplace(l_name, temp).second;
}
首先要注意的是,我们在方法签名中有一个新的参数l_state。接下来,我们尝试向m_callbacks映射中插入一个新元素,将状态参数和一个新的CallbackContainer配对。由于映射只能有一个具有特定索引的元素,在这种情况下是状态类型,emplace方法总是返回一个元素对,其中第一个元素是一个迭代器。如果插入成功,迭代器指向新创建的元素。另一方面,如果已经存在具有指定索引的元素,迭代器将指向该元素。这是一个很好的策略,因为我们无论如何都需要那个迭代器,如果没有我们指定的索引的元素,我们想要插入一个。
函数绑定之后,它保持不变,我们需要将实际的回调插入到CallbackContainer类型中,这是构成m_callbacks元素的配对中的第二个值。映射插入方法返回的配对的第二个值是一个布尔值,表示插入的成功,这就是用于错误检查返回的内容。
现在让我们来看看修改回调移除的方法:
bool RemoveCallback(StateType l_state, const std::string& l_name){
auto itr = m_callbacks.find(l_state);
if (itr == m_callbacks.end()){ return false; }
auto itr2 = itr->second.find(l_name);
if (itr2 == itr->second.end()){ return false; }
itr->second.erase(l_name);
return true;
}
这个相当简单。我们只是使用find方法两次而不是一次。首先,我们在第一个映射中find到状态对,然后就像之前一样,通过名称在第二个映射中erase实际的回调。
使其按我们想要的方式工作的最后一部分是修复回调函数实际调用的方式。由于类型定义发生了变化,我们调用回调的方式也略有不同:
void EventManager::Update(){
...
if (bind->m_events.size() == bind->c){
auto stateCallbacks = m_callbacks.find(m_currentState);
auto otherCallbacks = m_callbacks.find(StateType(0));
if (stateCallbacks != m_callbacks.end()){
auto callItr = stateCallbacks->second.find(bind->m_name);
if (callItr != stateCallbacks->second.end()){
// Pass in information about events.
callItr->second(&bind->m_details);
}
}
if (otherCallbacks != m_callbacks.end()){
auto callItr = otherCallbacks->second.find(bind->m_name);
if (callItr != otherCallbacks->second.end()){
// Pass in information about events.
callItr->second(&bind->m_details);
}
}
}
...
}
这里的主要区别是现在有两个状态需要检查回调,而不仅仅是其中一个:stateCallbacks和otherCallbacks。前者相当明显,我们只是使用find来获取当前状态的回调映射。然而,后者传递了一个状态类型值0,这不是一个有效的状态类型,因为枚举从1开始。这样做是因为即使在游戏中存在多个状态的情况下,我们仍然希望处理Window类的全局回调,以及其他超出简单状态范围并持续整个应用程序生命周期的类。任何状态类型为0的都将被调用,无论我们处于哪个状态。
其余部分相当直接。就像之前一样,我们使用从第一次搜索返回的迭代器的第二个值中的find方法,这实际上是我们真正的回调映射。如果找到匹配项,函数将被调用。
我们在这里想要做的最后一件事是修改keys.cfg文件,以便为我们保留一些额外的键,以便以后使用:
Window_close 0:0
Fullscreen_toggle 5:89
Intro_Continue 5:57
Mouse_Left 9:0
Key_Escape 5:36
Key_P 5:15
Intro_Continue绑定代表空格键的“按下”事件,Mouse_Left是鼠标左键点击事件,Key_Escape绑定到ESC“按下”事件,最后,Key_P代表字母P“按下”事件。
集成状态管理器
虽然现在还不是欢庆的时候,但兴奋之情确实在所难免,因为我们可以最终开始使用我们全新的StateManager类了!对Game类头文件的修改是一个良好的开端:
...
#include "StateManager.h"
...
class Game{
public:
...
void LateUpdate();
private:
...
StateManager m_stateManager;
};
将新的数据成员附加到Game类并添加一个新的方法以进行后期更新,这些都是需要在头文件中进行的调整。让我们调整Game构造函数以初始化状态管理器:
Game::Game(): m_window("Chapter 5", sf::Vector2u(800, 600)), m_stateManager(&m_context)
{
...
m_context.m_wind = &m_window;
m_context.m_eventManager = m_window.GetEventManager();
m_stateManager.SwitchTo(StateType::Intro);
}
自然地,我们首先创建将被所有状态使用的上下文,并将其传递给状态管理器的构造函数。然后我们开始“多米诺效应”,切换到介绍状态,这将最终切换到其他状态并强制应用程序的流程。
最后,让我们调整Game类中最重要三种方法:
void Game::Update(){
m_window.Update();
m_stateManager.Update(m_elapsed);
}
void Game::Render(){
m_window.BeginDraw();
m_stateManager.Draw();
m_window.EndDraw();
}
void Game::LateUpdate(){
m_stateManager.ProcessRequests();
RestartClock();
}
这已经很直接了。需要注意的是,RestartClock方法现在由LateUpdate调用,这意味着我们必须按照以下方式调整main.cpp文件:
#include "Game.h"
void main(int argc, void** argv[]){
// Program entry point.
Game game;
while(!game.GetWindow()->IsDone()){
game.Update();
game.Render();
game.LateUpdate();
}
}
现在一切似乎都井然有序。编译并启动应用程序应该会给你一个非常令人印象深刻的黑色屏幕。太棒了!让我们实际上为游戏创建一些状态,以表彰为此付出的努力。
创建介绍状态
从介绍状态开始似乎很合适,这样同时也给状态管理器一个介绍。一如既往,一个好的开始是从头文件开始,让我们开始吧:
class State_Intro : public BaseState{
public:
...
void Continue(EventDetails* l_details);
private:
sf::Texture m_introTexture;
sf::Sprite m_introSprite;
sf::Text m_text;
float m_timePassed;
};
State_Intro 类,就像我们将要构建的所有其他状态类一样,继承自 BaseState 类。基类中的所有纯虚方法都必须在这里实现。除此之外,我们还有一个名为 Continue 的独特方法以及一些将在该状态下使用的私有数据成员。不出所料,我们将在屏幕上渲染一个精灵以及一些文本。位于最底部的浮点数据成员将用于跟踪我们在该状态下花费的时间,以便在经过一定间隔后,用户能够按下空格键进入主菜单。Continue 方法负责处理这个转换。
实现介绍状态
我们即将完成我们的第一个功能状态!现在需要完成的是在头文件中声明的实际方法的实现,这样我们就可以大功告成了。让我们首先在 State_Intro.cpp 中包含我们类的头文件:
#include "State_Intro.h"
#include "StateManager.h"
注意第二行。因为 StateManager 类在 BaseState 头文件中是前向声明的,所以我们必须在实现文件中包含状态管理器头文件。这对于我们将要构建的任何状态都适用,包括这个。
我们永远不会使用我们状态的结构体和析构函数来初始化或分配任何东西,而是依赖于 OnCreate 和 OnDestroy 方法来保持对资源分配和释放实际发生时间的最大控制:
void State_Intro::OnCreate(){
m_timePassed = 0.0f;
sf::Vector2u windowSize = m_stateMgr->GetContext()->
m_wind->GetRenderWindow()->getSize();
m_introTexture.loadFromFile("intro.png");
m_introSprite.setTexture(m_introTexture);
m_introSprite.setOrigin(m_introTexture.getSize().x / 2.0f,
m_introTexture.getSize().y / 2.0f);
m_introSprite.setPosition(windowSize.x / 2.0f, 0);
m_font.loadFromFile("arial.ttf");
m_text.setFont(m_font);
m_text.setString({ "Press SPACE to continue" });
m_text.setCharacterSize(15);
sf::FloatRect textRect = m_text.getLocalBounds();
m_text.setOrigin(textRect.left + textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
m_text.setPosition(windowSize.x / 2.0f, windowSize.y / 2.0f);
EventManager* evMgr = m_stateMgr->
GetContext()->m_eventManager;
evMgr->AddCallback(StateType::Intro,"Intro_Continue",
&State_Intro::Continue,this);
}
然而,代码量相当大,但到目前为止,其中只有一小部分对我们来说是新的。首先,我们必须将我们的数据成员 m_timePassed 初始化为零。接下来,我们通过从基类使用状态管理器指针来获取共享上下文,并使用它来获取当前窗口大小。
为了将 m_text 正好放置在屏幕中间,我们首先将其原点设置为绝对中心,这是通过首先调用我们的 sf::text 对象的 getLocalBounds 方法来获取一个 sf::FloatRect 数据类型来完成的。sf::FloatRect 的左和上值代表文本的左上角,可以通过向其添加矩形大小的一半来计算中心。
提示
如果对字符大小、字符串或 sf::text 对象使用的字体进行了任何更改,则必须重新计算原点,因为局部边界矩形的物理尺寸也发生了变化。
这个介绍状态的基本想法是让一个精灵从屏幕顶部下降到中间。经过五秒钟后,一些文本将出现在精灵下方,通知用户可以按空格键进入主菜单。这是我们将会使用的下降精灵纹理:

我们最后需要做的是将空格键绑定到我们介绍类的Continue方法。我们通过共享上下文获取事件管理器实例并设置回调来实现,这与上一章的做法几乎相同,只是这次我们需要一个额外的参数:状态类型。
即使这个类没有分配任何内存,但在移除时仍然很重要,它需要移除其回调,这可以在下面这样做:
void State_Intro::OnDestroy(){
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->RemoveCallback(StateType::Intro,"Intro_Continue");
}
就像AddCallback方法一样,移除回调也要求第一个参数为状态类型。
由于我们在这里处理的是时间和移动,因此更新这个状态将是必要的:
void State_Intro::Update(const sf::Time& l_time){
if(m_timePassed < 5.0f){ // Less than five seconds.
m_timePassed += l_time.asSeconds();
m_introSprite.setPosition(m_introSprite.getPosition().x,
m_introSprite.getPosition().y + (48 * l_time.asSeconds()));
}
}
由于只希望精灵移动到中间,因此定义了一个五秒钟的窗口。如果总时间少于这个值,我们将 delta time 参数添加到下一次迭代中,并以每秒一定像素数在 y 方向上移动精灵,同时保持 x 方向不变。这保证了垂直移动,当然,除非我们绘制一切,否则这是完全无用的:
void State_Intro::Draw(){
sf::RenderWindow* window = m_stateMgr->GetContext()->m_wind->GetRenderWindow();
window->draw(m_introSprite);
if(m_timePassed >= 5.0f){
window->draw(m_text);
}
}
在通过共享上下文获得窗口指针后,我们在屏幕上绘制精灵。如果已经过去五秒钟以上,我们还会绘制文本,通知玩家可以继续通过介绍状态,这是最后一部分的拼图:
void State_Intro::Continue(){
if(m_timePassed >= 5.0f){
m_stateMgr->SwitchTo(StateType::MainMenu);
m_stateMgr->Remove(StateType::Intro);
}
}
再次检查是否已经过去足够的时间以继续到下一个状态。实际的切换发生在调用SwitchTo方法时。由于我们不再需要在栈中保留介绍状态,它将在下一行自行移除。
尽管我们不需要最后两种方法,但我们仍然需要实现它们的空版本,如下所示:
void State_Intro::Activate(){}
void State_Intro::Deactivate(){}
现在是时候吹响号角了!我们的第一个状态已经完成,并准备好使用。构建和启动你的应用程序应该会得到类似这样的结果:

如上图所示,精灵下降到屏幕中间,并在五秒后显示继续的提示信息。按下空格键后,你将发现自己在一个黑色窗口中,因为我们还没有实现主菜单状态。
从这一点开始,所有重复的代码都将被省略。对于完整的源代码,请查看本章的源文件。
主菜单状态
任何游戏的主菜单在应用程序流程中都是一个重要的部分,尽管它通常被忽视。是我们尝试构建一个的时候了,尽管是一个非常简化的版本,就像往常一样,从头文件开始:
class State_MainMenu : public BaseState{
public:
...
void MouseClick(EventDetails* l_details);
private:
sf::Text m_text;
sf::Vector2f m_buttonSize;
sf::Vector2f m_buttonPos;
unsigned int m_buttonPadding;
sf::RectangleShape m_rects[3];
sf::Text m_labels[3];
};
这个类独特的功能是MouseClick方法。由于我们在这里处理的是一个菜单,可以预见它将用于处理鼠标输入。对于私有数据成员,我们有一个用于标题的文本变量,按钮的大小、位置和填充大小变量,按钮的可绘制矩形以及按钮标签的文本变量。让我们把它们放在一起:
void State_MainMenu::OnCreate(){
m_font.loadFromFile("arial.ttf");
m_text.setFont(m_font);
m_text.setString(sf::String("MAIN MENU:"));
m_text.setCharacterSize(18);
sf::FloatRect textRect = m_text.getLocalBounds();
m_text.setOrigin(textRect.left + textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
m_text.setPosition(400,100);
m_buttonSize = sf::Vector2f(300.0f,32.0f);
m_buttonPos = sf::Vector2f(400,200);
m_buttonPadding = 4; // 4px.
std::string str[3];
str[0] = "PLAY";
str[1] = "CREDITS";
str[2] = "EXIT";
for(int i = 0; i < 3; ++i){
sf::Vector2f buttonPosition(m_buttonPos.x,m_buttonPos.y +
(i * (m_buttonSize.y + m_buttonPadding)));
m_rects[i].setSize(m_buttonSize);
m_rects[i].setFillColor(sf::Color::Red);
m_rects[i].setOrigin(m_buttonSize.x / 2.0f,
m_buttonSize.y / 2.0f);
m_rects[i].setPosition(buttonPosition);
m_labels[i].setFont(m_font);
m_labels[i].setString(sf::String(str[i]));
m_labels[i].setCharacterSize(12);
sf::FloatRect rect = m_labels[i].getLocalBounds();
m_labels[i].setOrigin(rect.left + rect.width / 2.0f,
rect.top + rect.height / 2.0f);
m_labels[i].setPosition(buttonPosition);
}
EventManager* evMgr = m_stateMgr->
GetContext()->m_eventManager;
evMgr->AddCallback(StateType::MainMenu,"Mouse_Left",
&State_MainMenu::MouseClick,this);
}
在上述方法中,所有图形元素都得到了设置。定义了文本数据成员,设置了原点,并为单个按钮命名了标签。最后,设置了鼠标左键点击的回调。这绝对不是一个复杂的 GUI 系统。然而,在后面的章节中,我们将介绍一种更健壮的设计方法,但就目前而言,这将满足我们的需求。
当状态被销毁时,我们需要移除其回调,如前所述:
void State_MainMenu::OnDestroy(){
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->RemoveCallback(StateType::MainMenu,"Mouse_Left");
}
当状态被激活时,我们需要检查主游戏状态是否存在于状态堆栈中,以便调整“play”按钮以显示“继续”:
void State_MainMenu::Activate(){
if(m_stateMgr->HasState(StateType::Game) && m_labels[0].getString() == "PLAY")
{
m_labels[0].setString(sf::String("RESUME"));
sf::FloatRect rect = m_labels[0].getLocalBounds();
m_labels[0].setOrigin(rect.left + rect.width / 2.0f,rect.top + rect.height / 2.0f);
}
}
注意
文本原点需要重新计算,因为sf::drawable对象的尺寸现在不同了。
MouseClick方法可以按以下方式实现:
void State_MainMenu::MouseClick(EventDetails* l_details){
sf::Vector2i mousePos = l_details->m_mouse;
float halfX = m_buttonSize.x / 2.0f;
float halfY = m_buttonSize.y / 2.0f;
for(int i = 0; i < 3; ++i){
if(mousePos.x >= m_rects[i].getPosition().x - halfX &&
mousePos.x <= m_rects[i].getPosition().x + halfX &&
mousePos.y >= m_rects[i].getPosition().y - halfY &&
mousePos.y <= m_rects[i].getPosition().y + halfY)
{
if(i == 0){
m_stateMgr->SwitchTo(StateType::Game);
} else if(i == 1){
// Credits state.
} else if(i == 2){
m_stateMgr->GetContext()->m_wind->Close();
}
}
}
}
首先,我们从事件信息结构中获取鼠标位置,该结构作为参数传递。然后我们设置一些局部浮点类型变量,这些变量将被用来检查按钮的边界,并开始遍历所有按钮。由于每个按钮的原点都设置为绝对中间,我们必须在检查鼠标位置是否在矩形内时根据这个进行调整。如果我们有一个鼠标到按钮的碰撞,一个 if-else 语句会检查哪个 ID 发生了碰撞,并相应地执行操作。在“play”按钮被按下的情况下,我们切换到游戏状态。如果退出按钮被按下,我们通过共享上下文调用Window::Close方法。
最后,让我们绘制主菜单:
void State_MainMenu::Draw(){
sf::RenderWindow* window = m_stateMgr->GetContext()->m_wind->GetRenderWindow();
window->draw(m_text);
for(int i = 0; i < 3; ++i){
window->draw(m_rects[i]);
window->draw(m_labels[i]);
}
}
通过共享上下文获取渲染窗口指针后,绘制整个菜单就像迭代几次来绘制一个按钮和一个标签一样简单。
在成功编译和执行后,我们再次看到主界面。当按下空格键时,主菜单打开,看起来像这样:

这并不是世界上最漂亮的样子,但它完成了工作。点击PLAY按钮一次会留下一个黑屏,而点击EXIT会关闭应用程序。整洁!
一个示例游戏状态
为了展示我们系统的完整使用,让我们在屏幕上得到一些弹跳的东西,这将展示在菜单、游戏和暂停状态之间的切换。为了测试目的,前几章中的弹跳蘑菇就足够了。我们还需要切换到菜单状态和暂停状态的方法。知道了这一点,让我们为游戏状态编写头文件:
class State_Game : public BaseState{
public:
...
void MainMenu(EventDetails* l_details);
void Pause(EventDetails* l_details);
private:
sf::Texture m_texture;
sf::Sprite m_sprite;
sf::Vector2f m_increment;
};
我们开始,就像许多其他时候一样,在OnCreate方法中进行资源分配和数据成员的设置:
void State_Game::OnCreate(){
m_texture.loadFromFile("Mushroom.png");
m_sprite.setTexture(m_texture);
m_sprite.setPosition(0,0);
m_increment = sf::Vector2f(400.0f,400.0f);
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->AddCallback(StateType::Game,"Key_Escape",&State_Game::MainMenu,this);
evMgr->AddCallback(StateType::Game,"Key_P",&State_Game::Pause,this);
}
在加载纹理并将精灵绑定到它之后,我们设置其位置,定义增量向量,就像之前一样,并添加回调到我们额外的两个方法以切换到不同的状态。当然,我们需要在状态销毁时移除它们,如下所示:
void State_Game::OnDestroy(){
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->RemoveCallback(StateType::GAME,"Key_Escape");
evMgr->RemoveCallback(StateType::GAME,"Key_P");
}
更新方法将保持我们之前使用的相同代码:
void State_Game::Update(const sf::Time& l_time){
sf::Vector2u l_windSize = m_stateMgr->GetContext()->
m_wind->GetWindowSize();
sf::Vector2u l_textSize = m_texture.getSize();
if((m_sprite.getPosition().x > l_windSize.x -
l_textSize.x && m_increment.x > 0) ||
(m_sprite.getPosition().x < 0 && m_increment.x < 0))
{
m_increment.x = -m_increment.x;
}
if((m_sprite.getPosition().y > l_windSize.y -
l_textSize.y && m_increment.y > 0) ||
(m_sprite.getPosition().y < 0 && m_increment.y < 0))
{
m_increment.y = -m_increment.y;
}
m_sprite.setPosition(m_sprite.getPosition().x +
(m_increment.x * l_time.asSeconds()),
m_sprite.getPosition().y +
(m_increment.y * l_time.asSeconds()));
}
检查精灵位置,如果它在窗口边界之外,则适当的轴上的增量向量被反转。然后,更新精灵位置,考虑到帧之间的时间差。就像时钟一样规律。让我们在屏幕上绘制精灵:
void State_Game::Draw(){
m_stateMgr->GetContext()->m_wind->GetRenderWindow()->draw(m_sprite);
}
现在让我们实现切换状态的方法:
void State_Game::MainMenu(EventDetails* l_details){
m_stateMgr->SwitchTo(StateType::MAIN_MENU);
}
void State_Game::Pause(EventDetails* l_details){
m_stateMgr->SwitchTo(StateType::PAUSED);
}
注意,游戏状态在这里并没有删除自己,就像主菜单状态一样。这意味着它仍然在内存中,并等待被推回到向量前面以更新和重新渲染。这使用户能够在任何时间点返回主菜单并恢复游戏状态,而不会丢失进度。
现在运行应用程序将带我们通过介绍状态进入主菜单。按下PLAY按钮将出现一个弹跳的蘑菇,就像之前一样:

现在按下 Esc 键将带您回到主菜单,此时您可以点击RESUME按钮返回游戏状态,或者点击EXIT按钮退出应用程序。只剩下一个状态需要实现,才能完全展示这个系统的能力!
暂停的方式
有些人可能会简单地认为从游戏状态导航到主菜单是将游戏暂停的一种方式。虽然这在技术上是真的,但为什么不探索第二种选项,它看起来比简单地弹出主菜单更时尚?在写了这么多代码之后,我们值得一个看起来很棒的暂停状态:
class State_Paused : public BaseState{
public:
...
void Unpause(EventDetails* l_details);
private:
sf::Text m_text;
sf::RectangleShape m_rect;
};
这个很简单。再次定义一个额外的方法,在这种情况下是Unpause,以切换到不同的状态。同时,也只使用了两个数据成员来在屏幕上绘制“PAUSED”文本,以及一个漂亮的半透明背景,由sf::RectangleShape表示。让我们在这个章节中最后一次实现OnCreate方法:
void State_Paused::OnCreate(){
SetTransparent(true); // Set our transparency flag.
m_font.loadFromFile("arial.ttf");
m_text.setFont(m_font);
m_text.setString(sf::String("PAUSED"));
m_text.setCharacterSize(14);
m_text.setStyle(sf::Text::Bold);
sf::Vector2u windowSize = m_stateMgr->GetContext()->m_wind->GetRenderWindow()->getSize();
sf::FloatRect textRect = m_text.getLocalBounds();
m_text.setOrigin(textRect.left + textRect.width / 2.0f,textRect.top + textRect.height / 2.0f);
m_text.setPosition(windowSize.x / 2.0f, windowSize.y / 2.0f);
m_rect.setSize(sf::Vector2f(windowSize));
m_rect.setPosition(0,0);
m_rect.setFillColor(sf::Color(0,0,0,150));
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->AddCallback(StateType::Paused,"Key_P",&State_Paused::Unpause,this);
}
这里的一个显著区别是使用了m_transparent标志,它是BaseState类的一个受保护的数据成员。将其设置为 true 意味着我们允许状态管理器直接在状态堆栈中渲染此状态之后的状态。
除了这些,我们创建一个与整个窗口大小相同的矩形,并将其填充颜色设置为黑色,alpha 通道值为 255 中的 150。这使得它既透明又暗淡,使其背后的所有东西都变暗。
上文所述方法的最后一部分,与其他所有方法一样,是将回调函数添加到Unpause方法中。当此状态被销毁时,需要像这样将其移除:
void State_Paused::OnDestroy(){
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->RemoveCallback(StateType::Paused,"Key_P");
}
现在让我们绘制我们创建的矩形和文字:
void State_Paused::Draw(){
sf::RenderWindow* wind = m_stateMgr->GetContext()->m_wind->GetRenderWindow();
wind->draw(m_rect);
wind->draw(m_text);
}
此外,让我们通过简单地切换到游戏状态来实现Unpause方法:
void State_Paused::Unpause(EventDetails* l_details){
m_stateMgr->SwitchTo(StateType::Game);
}
由于主游戏状态是目前唯一可以暂停的状态,因此只需简单地切换回它就足够了。
现在,深呼吸并再次编译应用程序。通过跳过初始状态,点击主菜单中的PLAY按钮,以及按下键盘上的P键,将有效地暂停游戏状态并微妙地变暗屏幕,同时在中间显示PAUSED文字,如图所示:

如果你已经走到这一步,恭喜你!虽然这绝对不是一个成品,但它已经从几乎无法控制的静态、不可移动的类走了很长的路。
常见错误
在使用此系统时可能犯的一个常见错误是未注册新添加的状态。如果你构建了一个状态,当你切换到它时它只是简单地显示一个黑色屏幕,那么很可能是它从未在StateManager的构造函数中注册。
窗口对按下F5键或点击关闭按钮没有反应是全局回调没有正确设置的标志。为了确保无论处于何种状态都会调用回调,它必须设置为状态类型 0,如下所示:
m_eventManager->AddCallback(StateType(0),"Fullscreen_toggle",
&Window::ToggleFullscreen,this);
m_eventManager->AddCallback(StateType(0),"Window_close",
&Window::Close,this);
最后,记住当在主菜单状态中检索鼠标位置时,事件内部存储的坐标是自动相对于窗口的。通过sf::Mouse::GetPosition获取坐标不会做同样的事情,除非提供一个sf::Window类的引用作为参数。
摘要
当本章结束时,你应该已经拥有了制作可以透明、批量更新并由我们代码库的其他部分支持的状态的工具。没有必要就此止步。再次构建它,让它变得更好、更快,并实现本章未涉及的不同功能。扩展它,崩溃它,修复它并从中学习。永远没有足够好的东西,所以在这里构建你获得的知识。
一句著名的中国谚语说:“人生如棋,每一步都在变”。
虽然这个类比是正确的,但生活也可以像是一个具有不同状态的游戏。将其分解成更小、更易于管理的部分,会使处理起来容易得多。无论是生活模仿代码还是代码模仿生活,这都不重要。伟大的想法来自于不同背景的融合。希望到这一章结束时,你不仅掌握了如何构建另一个管理者的知识,而且也拥有了从每一个可用资源和想法中寻求灵感的智慧。没有专属的知识,只有包容性的思维。下一章见!
第六章:启动! – 在你的世界中动画和移动
我们的第一款游戏虽然功能齐全,但视觉上肯定不够吸引人,至少在这个世纪是这样。首先,图形几乎不能代表它们应有的样子。将一串方块称为蛇是唯一能让玩家了解他们控制的是什么的方法。较老的设计的第二个基本点是静态摄像机位置。虽然在像 Snake 这样的游戏中这是一个设计选择,但更复杂的游戏类型会受到这种限制的阻碍。像 Super Mario Bros 这样的游戏依赖于游戏世界超出屏幕边界的事实,这不仅因为视觉吸引力,也因为能够构建一个更大的游戏世界,而这个世界不需要适应某个预先指定的矩形。简单地用图像而不是基本形状来表示游戏角色,以及提供屏幕移动的手段,打开了很多可能性。
在本章中,我们将涵盖:
-
SFML 视图和屏幕滚动
-
自动资源管理和处理
-
精灵表单的创建和应用
-
精灵表单动画
有很多东西要学习,所以我们不要浪费时间,直接深入吧!
版权资源的使用
在继续前进之前,我们想要向那些为我们游戏创建纹理、精灵和其他艺术作品的艺术家表示敬意。这些资产包括:
-
richtaur 的 Lemcraft,根据 CC0 1.0 许可证:
opengameart.org/content/lemcraft -
由
www.robotality.com提供的 Prototyping 2D Pixelart Tilesets,根据 CC-BY-SA 3.0 许可证:opengameart.org/content/prototyping-2d-pixelart-tilesets -
etqws3 的 Generic Platformer Tileset (16x16) + Background,根据 CC0 1.0 许可证:
opengameart.org/content/generic-platformer-tileset-16x16-background -
backyardninja 的 Knight 和 Rat 精灵:
www.dumbmanex.com/bynd_freestuff.html
以上所有资源的许可允许任何形式的材料使用,包括商业用途。有关这两个具体许可的更多信息,请访问以下链接:
查找和使用当前目录
毫无疑问,编程一段时间后,读取或写入文件的麻烦开始迅速积累。当在编译器外运行程序时,这也许不是那么糟糕,但在调试时使用相对路径可能会很痛苦,因为它们不再相对于可执行文件的目录,而是相对于.obj文件的位置。在这本书的其余部分,我们将使用一个函数来获取可执行文件的完整路径,无论它在哪里或如何启动。让我们看看一个将包含此函数的新头文件,称为Utilities.h:
#pragma once
#define RUNNING_WINDOWS
#include <iostream>
#include <string>
#include <algorithm>
namespace Utils{
#ifdef RUNNING_WINDOWS
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <Shlwapi.h>
inline std::string GetWorkingDirectory(){
HMODULE hModule = GetModuleHandle(nullptr);
if(hModule){
char path[256];
GetModuleFileName(hModule,path,sizeof(path));
PathRemoveFileSpec(path);
strcat_s(path,"\\"); // new
return std::string(path); // new
}
return "";
}
#elif defined RUNNING_LINUX
#include <unistd.h>
inline std::string GetWorkingDirectory(){
char cwd[1024];
if(getcwd(cwd, sizeof(cwd)) != nullptr){
return std::string(cwd) + std::string("/");
}
return "";
}
#endif
}
小贴士
#pragma once被广泛支持,但不是标准。如果代码是在较旧的编译器中处理的,它可以被典型的包含保护符所替代。
如果设置了RUNNING_WINDOWS宏,它定义了一个方法,该方法首先获取包括可执行文件及其扩展名的完整路径,然后仅获取可执行文件的名字和扩展名,最后在返回字符串之前从完整路径中删除它,现在这个字符串包含了可执行文件所在目录的完整“地址”。这些函数是针对 Windows 的特定函数,在其他操作系统上无法工作,因此这个头文件需要为每个操作系统定义不同的相同方法。
注意
使用这些函数获取当前目录需要包含Shlwapi.h头文件,以及将shlwapi.lib文件列在链接器的附加依赖项中,在所有配置中。忘记满足这些要求将导致链接器错误。
如您所见,我们在这里涵盖了 Windows 和 Linux 操作系统。如果您希望应用程序能够正常运行,您需要为其他平台实现相同函数的版本。
使用 SFML 视图
到目前为止,我们只处理过在打开的窗口边界内渲染内容的代码。还没有需要屏幕移动的实例,如果我们生活在 80 年代初期,那倒也无可厚非,但即使是十年后的游戏也更为先进。以超级马里奥兄弟为例,这是一款经典的横版卷轴游戏。仅从其类型就可以看出,我们的第一款游戏没有的东西:滚动。如果需要滚动效果或任何屏幕的移动、调整大小或旋转,使用sf::View是必要的。
sf::View是什么?它是一个矩形。仅此而已。如果您曾经用手指做出矩形形状来“框”您观察的世界,您就用双手创建了一个视图。通过移动它,您实际上是在通过窗口的截止点移动场景。如果您仍然“不理解”,这里有一个插图来引导您走向正确的方向:

sf::View是一个非常轻量级的对象,本质上只包含一些浮点变量和一些用于检索其值的方法。它的构造函数可以接受一个sf::FloatRect类型,该类型定义了屏幕上的矩形区域,或者它可以接受两个sf::Vector2f类型,第一个是视图的中心,第二个是大小:
// Top-left corner at 500:500, bottom-right at 1000:1000.
sf::View first(sf::FloatRect(500,500,1000,1000));
// Center at 250:250, size is 800:600.
sf::View second(sf::Vector2f(250,250), sf::Vector2f(800,600));
如你所见,视图主要是由其中心而不是其左上角来操纵的,这与大多数其他形状不同。
通过操纵其中心,可以移动视图,如下所示:
// Top-left corner at 0:0, bottom-right at 800:600.
sf::View view(sf::FloatRect(0,0,800,600));
view.setCenter(100,100); // Move center to 100:100.
它也可以通过move方法移动偏移量:
view.move(100,100); // Move by 100x100 offset.
通过使用setSize方法或通过zoom方法按比例缩放,可以调整视图的大小:
view.setSize(640, 480); // Creates a smaller view space.
view.zoom(0.5f); // Also creates a smaller view space.
在第一种情况下,它将 800x600 像素的视图大小调整为 640x480 像素。在第二种情况下,将0.5f的因子应用于其当前大小,将其减半,使屏幕上的所有内容都变大。
为了使用特定的视图,你必须调用你正在使用的窗口实例的setView方法:
window.setView(view); // Applies view to window.
小贴士
setView方法不通过引用接收值。它只是从视图中复制值并将它们存储在窗口对象中。如果在你的代码中的任何一点改变了视图,你必须再次调用setView方法,以便应用这些更改并使其反映出来。
值得一提的是,还可以从窗口对象中获取两个视图。第一种视图是当前正在使用的视图,第二种视图是窗口启动时的默认视图。它与窗口大小相同,其左上角位于坐标(0;0)。获取这些视图的方法如下:
sf::View view = window.getView();
sf::View defaultView = window.getDefaultView();
准备应用程序状态以供视图使用
为了支持在世界上进行平移,我们必须调整状态系统。这里的主要思想是每个状态都有自己的视图。例如,主菜单很可能永远不会需要移动到窗口提供的默认视图之外,而游戏状态则会在每一帧都聚焦于角色。一个简单而优雅的方法是将视图与每个状态一起存储,以便在需要时调整屏幕视图空间。让我们从修改BaseState.h文件开始:
class BaseState{
public:
...
sf::View& GetView(){ return m_view; }
protected:
...
sf::View m_view;
};
与我们即将修改的大多数类一样,这里我们只展示相关的部分,在这个例子中,这些部分在尺寸上相当保守。我们添加的只是一个视图数据成员和获取它的方法。让我们继续将这个视图应用到我们的状态管理器中:
void StateManager::CreateState(const StateType& l_type){
...
BaseState* state = newState->second();
state->m_view = m_shared->m_wind->
GetRenderWindow()->getDefaultView();
...
}
由于我们不希望sf::View的默认构造函数将我们的视图成员初始化为其默认值,因此在创建状态时必须设置视图。我们到目前为止的大多数状态都依赖于视图从不移动的事实,这就是为什么它首先被设置为默认值。如果一个状态希望定义自己的视图,它可以在OnCreate方法中这样做,正如你很快就会看到的。让我们继续到状态切换:
void StateManager::SwitchTo(const StateType& l_type){
...
for(...)
{
if(itr->first == l_type){
...
m_shared->m_wind->GetRenderWindow()->
setView(tmp_state->GetView());
return;
}
}
...
m_states.back().second->Activate();
m_shared->m_wind->GetRenderWindow()->setView( m_states.back().second->GetView());
}
这相当直接。当切换到不同的状态时,我们希望将窗口的视图空间更改为与我们要切换到的状态相匹配。如果没有完成这一点,并且游戏状态移动了视图,切换到另一个状态将简单地留下一个空白屏幕,因为新状态的内容被渲染在窗口的视图空间之外。
现在引入了不同的视图后,同时绘制多个状态可能会引起一些问题。这可能会有些难以理解,所以让我们用一个例子来说明这个问题。假设游戏处于暂停状态。因为暂停状态是透明的,它需要首先绘制它前面的状态,以便将它们混合在一起。立刻就出现了问题,因为暂停状态将其元素定位在窗口坐标中,并且它永远不需要视图移动。如果窗口的视图移动了,顶部状态所绘制的任何内容都将位于其视图空间之外,因此可能只会部分可见或根本不可见。我们可以将窗口坐标转换为世界坐标,并每帧更新这些元素的坐标以“跟随”屏幕,但这不是一个优雅或高效的解决方案。因此,我们必须在渲染之前将窗口视图设置为状态视图,如下所示:
void StateManager::Draw(){
...
for(; itr != m_states.end(); ++itr){
m_shared->m_wind->GetRenderWindow()->
setView(itr->second->GetView());
itr->second->Draw();
}
...
}
由于视图和渲染的方式,上述问题得到了解决。考虑以下说明:

首先,将窗口视图设置为游戏视图以渲染场景。假设它的左上角位于位置(600;700)。然后应用窗口的默认视图。这将窗口视图空间的左上角移动回(0;0),这与本地窗口坐标相匹配。因为即将绘制的元素是基于这些坐标定位的,所以它们现在又回到了窗口的视图空间,并绘制在帧缓冲区上。透明部分被混合,不透明像素被覆盖。最后,调用window.display();并在屏幕上绘制帧缓冲区。结果是场景和元素都被混合在一起。
在继续前进之前,我们想在现有的代码库中添加一个新方法到 Window 类中,用于获取一个定义窗口视图空间的sf::FloatRect类型:
sf::FloatRect Window::GetViewSpace(){
sf::Vector2f viewCenter = m_window.getView().getCenter();
sf::Vector2f viewSize = m_window.getView().getSize();
sf::Vector2f viewSizeHalf(viewSize.x / 2, viewSize.y / 2);
sf::FloatRect viewSpace(viewCenter - viewSizeHalf, viewSize);
return viewSpace;
}
首先,这种方法获取当前视图的中心和大小。然后它计算大小的一半并从视图中心的坐标中减去,以获得其左上角。最后,它通过传入视图的左上角和大小来构建视图空间的矩形。这将在以后很有用。
自动资源管理
让我们谈谈纹理以及我们迄今为止使用它们的方式。在 SFML 中,纹理是一种你只想有一个的东西,因为它在内存方面并不便宜。我们迄今为止的方法只是简单地将纹理存储为相关类的数据成员。以下是一个说明这种策略多么糟糕的场景:你需要在其他地方使用相同的纹理。就是这样。这根本不像是你能轻易放下的事情,因为它毕竟不是经常发生。创建多个包含相同数据的纹理是资源的巨大浪费,为使用它们的类添加获取纹理的方法也是一场灾难。这不仅会弄乱类的轮廓,还意味着其他类必须能够访问持有这个纹理的类。没有人应该让自己承受这样的折磨。
那么,我们如何解决这个问题呢?通过创建一个类,它将所有我们的纹理放在同一个地方,并跟踪它们的使用次数,以便以智能的方式管理其资源。我们还希望给它们唯一的标识符,以便快速引用,这可以通过从将名称映射到路径的文件中加载它们来实现。我们可以将其命名为Textures.cfg,它看起来可能像这样:
Intro media/Textures/intro.png
PlayerSprite media/Textures/PlayerSheet.png
RatSprite media/Textures/RatSheet.png
TileSheet media/Textures/tilesheet.png
Bg1 media/Textures/bg1.png
Bg2 media/Textures/bg2.png
Bg3 media/Textures/bg3.png
当然,这种方法可以用于其他类型的资源,而不仅仅是纹理。稍后,我们还将处理字体和声音文件,所以让我们设计一个抽象基类,首先处理所有常见的任务,然后再专门处理纹理。
设计资源管理器
我们将要处理的所有资源都将被计数和记录,换句话说。每当我们要使用一个纹理,例如,它需要被请求。如果它不再需要,资源就会被释放。这听起来很简单,所以让我们写下来:
template<typename Derived, typename T>
class ResourceManager{
public:
ResourceManager(const std::string& l_pathsFile){
LoadPaths(l_pathsFile);
}
virtual ~ResourceManager(){ PurgeResources(); }
...
private:
std::unordered_map<std::string, std::pair<T*, unsigned int>> m_resources;
std::unordered_map<std::string, std::string> m_paths;
};
当处理使用模板的类时,方法的实现必须在头文件中,因为编译器需要访问实现才能使用模板参数实例化方法。话虽如此,让我们谈谈m_resources数据成员。它使用一个映射,将字符串句柄绑定到一对元素,其中第一个是资源的模板参数,第二个是用于计数当前使用此特定资源的位置的未签名整数类型。
我们还有一个数据成员,是一个用于资源路径的两个字符串的映射。构造函数调用一个内部方法来从特定位置加载路径,而析构函数则调用另一个内部方法来清除和重新分配其所有资源。
让我们开始实现我们将需要使用此类的其他方法,从公共方法开始:
T* GetResource(const std::string& l_id){
auto res = Find(l_id);
return(res ? res->first : nullptr);
}
这是一个获取由管理器保存的资源的方法。它使用字符串参数作为句柄,并在映射中查找它,使用我们稍后定义的内部Find方法。如果找到,它返回映射中元素对的指针,如果没有找到,则返回nullptr。
我们可能还感兴趣的是检索特定资源的路径之一:
std::string GetPath(const std::string& l_id){
auto path = m_paths.find(l_id);
return(path != m_paths.end() ? path->second : "");
}
如果没有某种方式来保证资源在使用过程中不会被重新分配,这个系统将毫无用处。为了防止这种情况,让我们实现一种方法来注册资源的使用:
bool RequireResource(const std::string& l_id){
auto res = Find(l_id);
if(res){
++res->second;
return true;
}
auto path = m_paths.find(l_id);
if (path == m_paths.end()){ return false; }
T* resource = Load(path->second);
if (!resource){ return false; }
m_resources.emplace(l_id, std::make_pair(resource, 1));
return true;
}
此方法有两个目的。一个是在需要时简单地增加资源使用实例计数器。它的第二个目的是在资源容器中找不到句柄时创建资源。它首先检查路径容器,以确认资源句柄有效。如果找到匹配项,它尝试获取新分配内存的指针,该指针由Load方法返回。如果没有返回nullptr值,则将资源插入,计数器设置为1。
正如阴阳必须相互对应一样,对于每个所需资源,都必须有一个不再需要它的时刻:
bool ReleaseResource(const std::string& l_id){
auto res = Find(l_id);
if (!res){ return false; }
--res->second;
if (!res->second){ Unload(l_id); }
return true;
}
此方法尝试使用字符串句柄在容器中查找资源。如果找到,则减少其使用计数器。如果计数器现在是0,则此资源不再需要,可以通过调用Unload方法来释放其内存。
在某个时刻,一切都必须结束。这是清除方法:
void PurgeResources(){
while(m_resources.begin() != m_resources.end()){
delete m_resources.begin()->second.first;
m_resources.erase(m_resources.begin());
}
}
这是一个相当直接的方法。它循环直到容器中没有更多元素。每次它通过传递迭代器删除资源内存并擦除容器条目。
由于某些资源的独特性质,某些方法并不是通用的。为了将这个基类扩展到支持我们想要的任何资源,每个派生管理器都将使用Load方法。为了避免运行时多态,可以使用Curiously Recurring Template Pattern(奇特重复模板模式)如下:
T* Load(const std::string& l_path){
return static_cast<Derived*>(this)->Load(l_path);
}
派生类将实现自己的Load版本,但不会在运行时解析虚拟指针到函数。
现在我们已经完成了表面的工作,让我们深入探讨使这种功能成为可能的私有方法,从Find方法开始:
std::pair<T*,unsigned int>* Find(const std::string& l_id){
auto itr = m_resources.find(l_id);
return (itr != m_resources.end() ? &itr->second : nullptr);
}
此方法返回一个指向包含实际资源和正在使用它的实例数的 pair 结构的指针。如果提供的字符串句柄在资源容器中未找到,则返回nullptr。
卸载资源并没有带来任何新的东西:
bool Unload(const std::string& l_id){
auto itr = m_resources.find(l_id);
if (itr == m_resources.end()){ return false; }
delete itr->second.first;
m_resources.erase(itr);
return true;
}
和往常一样,我们首先通过字符串句柄在容器中查找元素。如果找到,我们释放分配的内存,从容器中擦除元素并从方法中返回。
最后,如果没有映射到路径的字符串句柄,我们无法使用它们。让我们将它们加载进来:
void LoadPaths(const std::string& l_pathFile){
std::ifstream paths;
paths.open(Utils::GetWorkingDirectory() + l_pathFile);
if(paths.is_open()){
std::string line;
while(std::getline(paths,line)){
std::stringstream keystream(line);
std::string pathName;
std::string path;
keystream >> pathName;
keystream >> path;
m_paths.emplace(pathName,path);
}
paths.close();
return;
}
std::cerr <<
"! Failed loading the path file: "
<< l_pathFile << std::endl;
}
如果你了解 C++中的文件加载,这应该不会让你感到惊讶。它所做的只是设置一个名为paths的输入流。然后它尝试打开它,通过传递文件的完整路径,归功于之前提到的GetWorkingDirectory函数。如果文件已打开,这意味着它已被找到并且可以读取。定义了一个字符串类型,用作在读取它们时按顺序存储当前文件行的手段。该方法在解析的文件中仍有新行时循环,并将该新行传递给line变量。然后设置了一个stringstream变量,它专为字符串操作设计。定义了两个string变量,一个用于路径标识符,一个用于实际路径。它们通过使用其重载的>>操作符从keystream变量中填充,该操作符本质上只是抓取直到遇到空格分隔符的所有内容。然后我们将此信息插入路径容器中,并在循环结束后关闭文件。
实现纹理管理器
在完成资源管理部分后,我们现在可以在自己的类中实现实际的纹理加载。因为我们只想实现一个方法,所以它也可以在头文件中完成:
class TextureManager:
public ResourceManager<TextureManager, sf::Texture>
{
public:
TextureManager(): ResourceManager("textures.cfg"){}
sf::Texture* Load(const std::string& l_path){
sf::Texture* texture = new sf::Texture();
if(!texture->loadFromFile(
Utils::GetWorkingDirectory() + l_path))
{
delete texture;
texture = nullptr;
std::cerr << "! Failed to load texture: "
<< l_path << std::endl;
}
return texture;
}
};
我们创建了TextureManager类,并从ResourceManager继承,同时指定了该管理类处理的数据类型以及模板所使用的资源,当然,这是一个sf::Texture。纹理管理器的构造函数仅用于在初始化列表中调用基类构造函数,以传递包含句柄和路径绑定的文件名。
在Load方法中,我们为纹理分配新的内存,并尝试从提供的路径加载它。如果加载失败,我们将删除分配的内存,并打印出控制台消息来通知用户失败。这就是纹理管理器类的全部内容。现在是时候将其投入使用!
介绍精灵图集
首先,让我们通过查看使用精灵图集的未来来激发你的兴趣,这允许你创建看起来像这样的动画:

从我们之前使用 SFML 的经验中,我们知道精灵基本上是一个可以移动、裁剪、缩放和旋转的图像,仅举几个选项。另一方面,精灵图集是一个包含多个精灵的纹理。从上面的图像中,你可以看到玩家正在向左移动,他的动画正在进展。玩家动画的每一帧都存储在精灵图集中,该图集被访问和裁剪以表示单个精灵。这就是它作为纹理看起来的一部分:

精灵的布局方式可能因游戏而异。它取决于特定项目的尺寸限制以及游戏玩法的具体情况。上面精灵图的格式只是在这里效果最好,绝对不是“完美的设计”。
我们为什么要使用精灵图呢?最大的优势是它使得访问精灵更加容易和快速,而且更节省内存。在精灵图上实现动画也更加容易。加载多个不同精灵的纹理比加载单个可裁剪的纹理要昂贵得多。在某些情况下,精心打包的精灵图可以节省大量资源。如果您追求效率,使用精灵图而不是为每个精灵加载不同的纹理无疑是一个明智的选择。
实现精灵图类
因为我们希望能够即时修改与精灵图相关的任何内容,从文件中加载它们是最有意义的。让我们先通过创建一个 Player.sheet 文件来看看玩家精灵图的样貌:
Texture PlayerSprite
Size 32 32
Scale 1.0 1.0
|Type|Name|StartFrame|EndFrame|Row|FrameTime|FrameActionStart|End|
AnimationType Directional
Animation Idle 0 7 0 0.2 -1 -1
Animation Walk 0 5 2 0.1 -1 -1
Animation Jump 0 3 4 0.2 -1 -1
Animation Attack 0 4 6 0.08 2 3
Animation Hurt 0 2 8 0.2 -1 -1
Animation Death 0 8 10 0.15 -1 -1
它首先指定将要使用的纹理句柄。还定义了一些关于精灵本身的附加数据,例如单个精灵的大小和缩放。然后跳转到一条注释掉的行。它描述了文件其余部分中值的顺序和含义,这部分用于在精灵图中定义动画序列。在定义动画类型之后,它继续定义所有这些关于动画的不同参数。现在不需要关注这部分,因为稍后会有更深入的讲解。
文件格式的问题解决之后,让我们开始编写精灵图类!首先,定义一个容器类型来保存动画。使用无序映射是因为它比其有序对应物提供更快的查找速度:
using Animations = std::unordered_map<std::string,Anim_Base*>;
再次提醒,尽量不要过多地纠结于此,因为稍后会有更深入的讲解。现在我们来为精灵图类编写标题:
class SpriteSheet{
public:
SpriteSheet(TextureManager* l_textMgr);
~SpriteSheet();
void CropSprite(const sf::IntRect& l_rect);
... // Basic setters/getters.
bool LoadSheet(const std::string& l_file);
void ReleaseSheet();
Anim_Base* GetCurrentAnim();
bool SetAnimation(const std::string& l_name,
const bool& l_play = false,
const bool& l_loop = false);
void Update(const float& l_dT);
void Draw(sf::RenderWindow* l_wnd);
private:
std::string m_texture;
sf::Sprite m_sprite;
sf::Vector2i m_spriteSize;
sf::Vector2f m_spriteScale;
Direction m_direction;
std::string m_animType;
Animations m_animations;
Anim_Base* m_animationCurrent;
TextureManager* m_textureManager;
};
如您所见,它提供了裁剪纹理、更新和绘制精灵图的方法。该类保留对纹理管理器的指针,以便获取和释放资源。关于这个类还有一点需要说明,那就是它持有一个 Direction 类型的数据成员。它只是一个枚举,定义在 Directions.h 文件中:
enum class Direction{ Right = 0, Left };
它甚至不值得有自己的标题。然而,相当多的类实际上依赖于这个方法,所以需要一个单独的标题来放置它。
让我们开始实现精灵图类的实际方法,从构造函数和析构函数开始:
SpriteSheet::SpriteSheet(TextureManager* l_textMgr)
:m_textureManager(l_textMgr), m_animationCurrent(nullptr),
m_spriteScale(1.f, 1.f), m_direction(Direction::Right){}
除了将数据成员初始化为默认值之外,这里没有其他有趣的内容。析构函数简单地调用另一个方法来清理,就像很多其他类一样:
SpriteSheet::~SpriteSheet(){ ReleaseSheet(); }
void SpriteSheet::ReleaseSheet(){
m_textureManager->ReleaseResource(m_texture);
m_animationCurrent = nullptr;
while(m_animations.begin() != m_animations.end()){
delete m_animations.begin()->second;
m_animations.erase(m_animations.begin());
}
}
ReleaseSheet 方法使用纹理管理器来释放它所使用的资源,以及删除它当前分配的所有动画。
当设置精灵大小时,重要的是也要重置原点,使其始终位于精灵的x轴中间和y轴的底部:
void SpriteSheet::SetSpriteSize(const sf::Vector2i& l_size){
m_spriteSize = l_size;
m_sprite.setOrigin(m_spriteSize.x / 2, m_spriteSize.y);
}
自然地,我们还需要一个设置精灵位置的方法:
void SpriteSheet::SetSpritePosition(const sf::Vector2f& l_pos){
m_sprite.setPosition(l_pos);
}
设置精灵的不同方向将改变其精灵,因此我们需要在之后重新裁剪它:
void SpriteSheet::SetDirection(const Direction& l_dir){
if (l_dir == m_direction){ return; }
m_direction = l_dir;
m_animationCurrent->CropSprite();
}
实际的裁剪是通过精灵类的setTextureRect方法完成的:
void SpriteSheet::CropSprite(const sf::IntRect& l_rect){
m_sprite.setTextureRect(l_rect);
}
它接受一个sf::IntRect类型,该类型定义了其左上角的位置以及矩形的尺寸。左上角坐标是裁剪纹理的局部坐标。假设我们想要获取精灵图中的第一个精灵。如果我们知道每个精灵的大小是 32px x 32px,我们只需要传递左上角的位置(0;0)以及尺寸(32;32)来获取精灵。
尽管我们还没有涉及动画,但让我们先了解一下SetAnimation方法,因为它即使不了解即将使用的动画类的具体细节,也不难理解:
bool SpriteSheet::SetAnimation(const std::string& l_name,
const bool& l_play, const bool& l_loop)
{
auto itr = m_animations.find(l_name);
if (itr == m_animations.end()){ return false; }
if (itr->second == m_animationCurrent){ return false; }
if (m_animationCurrent){ m_animationCurrent->Stop(); }
m_animationCurrent = itr->second;
m_animationCurrent->SetLooping(l_loop);
if(l_play){ m_animationCurrent->Play(); }
m_animationCurrent->CropSprite();
return true;
}
它接受三个参数:一个字符串句柄和两个布尔标志,用于立即播放动画以及是否循环。该方法本身会遍历动画容器以查找与字符串句柄匹配的动画。如果找到了,它会检查是否有任何当前动画被设置,因为需要停止它。一旦完成,它只需将当前动画的指针更改为容器中找到的动画,并调用相应的方法以循环和播放动画。并没有什么太复杂的。
然后,我们用最普通的更新和绘制方法来完善这个类:
void SpriteSheet::Update(const float& l_dT){
m_animationCurrent->Update(l_dT);
}
void SpriteSheet::Draw(sf::RenderWindow* l_wnd){
l_wnd->draw(m_sprite);
}
这是最简单不过了。然而,它确实留下了一个未解释的方法:LoadSheet。在我们能够实现它之前,我们需要更多地了解我们将要使用的动画类。
基础动画类
就像资源管理器一样,我们希望将所有非特定于更具体类的功能卸载到基类中。这就是基础动画类发挥作用的地方。让我们看看Anim_Base.h头文件:
class SpriteSheet;
using Frame = unsigned int;
class Anim_Base{
friend class SpriteSheet;
public:
Anim_Base();
virtual ~Anim_Base();
... // Setters/getters.
void Play();
void Pause();
void Stop();
void Reset();
virtual void Update(const float& l_dT);
friend std::stringstream& operator >>(
std::stringstream& l_stream, Anim_Base& a)
{
a.ReadIn(l_stream);
return l_stream;
}
protected:
virtual void FrameStep() = 0;
virtual void CropSprite() = 0;
virtual void ReadIn(std::stringstream& l_stream) = 0;
Frame m_frameCurrent;
Frame m_frameStart;
Frame m_frameEnd;
Frame m_frameRow;
int m_frameActionStart; // Frame when a specific "action" begins
int m_frameActionEnd; // Frame when a specific "action" ends
float m_frameTime;
float m_elapsedTime;
bool m_loop;
bool m_playing;
std::string m_name;
SpriteSheet* m_spriteSheet;
};
首先,注意类SpriteSheet的前向声明。因为这个类需要包含SpriteSheet,而SpriteSheet也需要包含这个类,所以需要前向声明以防止交叉包含。我们还将使用无符号整型的一个别名,简单地命名为Frame。
大多数数据成员以及方法名称都是相当直观的。一些术语可能令人困惑,例如帧时间和动作。帧时间是每个帧完成所需的时间。动作定义了一个帧的范围,在这个范围内可以执行特定于该动画的行为。如果设置为负一,则在整个动画中都可以执行这种行为。这些都是我们想要跟踪以使游戏更具交互性和响应性的东西。注意,我们正在重载 >> 操作符,以便简化从文件中加载动画。关于这一点,稍后会有更多说明。
最后要指出的是三个纯虚方法:FrameStep、CropSprite 和 ReadIn。FrameStep 是不同类型动画特有的更新部分。CropSprite 是不同类型动画从精灵表中获取精灵的独特方式。最后,ReadIn 是定义在从文件加载数据时如何使用 stringstream 对象的方法。这三个方法只会在派生类中定义。
实现基本动画类
由于前向声明,我们需要包含在 .cpp 文件中声明的类的实际头文件:
#include "Anim_Base.h"
#include "SpriteSheet.h"
现在我们不再有交叉包含,我们可以使用 SpriteSheet 类了。是时候实现实际的类了:
Anim_Base::Anim_Base(): m_frameCurrent(0), m_frameStart(0),
m_frameEnd(0), m_frameRow(0), m_frameTime(0.f),
m_elapsedTime(0.f), m_frameActionStart(-1),
m_frameActionEnd(-1), m_loop(false), m_playing(false){}
Anim_Base::~Anim_Base(){}
构造函数正在执行其预期的初始化默认值的工作,而析构函数在这个类中根本不会被使用。
当然,我们需要一种方法来设置我们的精灵表数据成员:
void Anim_Base::SetSpriteSheet(SpriteSheet* l_sheet){
m_spriteSheet = l_sheet;
}
设置动画帧的情况也是一样,尽管这个方法稍微复杂一些:
void Anim_Base::SetFrame(const unsigned int& l_frame){
if((l_frame >= m_frameStart && l_frame <= m_frameEnd)||(l_frame >= m_frameEnd && l_frame <= m_frameStart))
{
m_frameCurrent = l_frame;
}
}
传递给此方法的参数将被检查是否在两个特定的范围内,这是为了支持未来可以向后播放的动画类型。
这是一个检查此动画当前是否能够执行其自定义行为的方法:
bool Anim_Base::IsInAction(){
if(m_frameActionStart == -1 || m_frameActionEnd == -1){
return true;
}
return (m_frameCurrent >= m_frameActionStart && m_frameCurrent <= m_frameActionEnd);
}
如果任何值是 -1,则“动作”总是执行。否则,当前帧将检查是否在从精灵表文件加载的指定范围内。
如果不控制这些动画,我们将无法走得更远。提供一个简单的接口来做这件事是个好主意:
void Anim_Base::Play(){ m_playing = true; }
void Anim_Base::Pause(){ m_playing = false; }
void Anim_Base::Stop(){ m_playing = false; Reset(); }
Play 和 Pause 方法只是简单地操作一个布尔标志,而 Stop 方法也会重置动画:
void Anim_Base::Reset(){
m_frameCurrent = m_frameStart;
m_elapsedTime = 0.0f;
CropSprite();
}
在将帧移回开始并重置计时器后,由于帧的变化,它裁剪了精灵。我们几乎完成了这个类的实现。现在唯一缺少的是更新它的方法:
void Anim_Base::Update(const float& l_dT){
if (!m_playing){ return; }
m_elapsedTime += l_dT;
if (m_elapsedTime < m_frameTime){ return; }
FrameStep();
CropSprite();
m_elapsedTime = 0;
}
Update 方法,像往常一样,接收一个参数,表示帧之间的经过时间。然后它简单地将其添加到动画的经过时间中,如果动画正在播放,并检查是否超过了帧时间。如果超过了,我们调用两个虚拟方法,并将计时器重置回 0。
方向动画
根据实现细节,不同类型的动画之间并不总是存在清晰的二分法。为了使本章内容不因特定主题而拖延,我们将只实现一种类型的动画,即方向动画。这种类型的动画通常用于任何具有特定方向动画的移动实体。与其他类型的动画不同,增加帧数可能导致行数跳跃,而方向动画将始终保持在表示正确方向正确类型的动画所在的行上。考虑以下插图:

在我们的情况下,每一行包含特定动画的左侧或右侧版本。了解这一点后,让我们创建方向动画类的标题:
class Anim_Directional : public Anim_Base{
protected:
void FrameStep();
void CropSprite();
void ReadIn(std::stringstream& l_stream);
};
这个类甚至不需要构造函数或析构函数,只需要实现基类中的三个方法。再次注意,由于Anim_Base类的头文件中存在前向声明,因此包含了SpriteSheet.h文件:
#include "Anim_Directional.h"
#include "SpriteSheet.h"
现在让我们将纹理切割成我们的精灵:
void Anim_Directional::CropSprite(){
sf::IntRect rect(m_spriteSheet->GetSpriteSize().x * m_frameCurrent,m_spriteSheet->GetSpriteSize().y * (m_frameRow + (short)m_spriteSheet->GetDirection()),m_spriteSheet->GetSpriteSize().x,m_spriteSheet->GetSpriteSize().y);
m_spriteSheet->CropSprite(rect);
}
首先,我们构造一个矩形。其左上角位置是精灵大小乘以当前帧在x轴上的值,以及精灵大小乘以当前动画行和精灵表方向在y轴上的总和。由于方向枚举将方向映射到0或1的数值,这使得获取正确方向的行变得非常容易,如上图所示。设置好左上角后,我们传入像素单位的精灵大小,并根据构造的矩形裁剪精灵表。这样就得到了一个精灵!
动画领域的最后一部分是实现FrameStep方法:
void Anim_Directional::FrameStep(){
if (m_frameStart < m_frameEnd){ ++m_frameCurrent; }
else { --m_frameCurrent; }
if ((m_frameStart < m_frameEnd && m_frameCurrent > m_frameEnd)||
(m_frameStart > m_frameEnd && m_frameCurrent < m_frameEnd))
{
if (m_loop){ m_frameCurrent = m_frameStart; return; }
m_frameCurrent = m_frameEnd;
Pause();
}
}
首先,我们检查应该滚动帧的方向,因为将来可能需要定义反向移动的动画。如果起始帧号低于结束帧号,我们正在向正方向移动。然后我们检查帧是否超出范围,并根据它是循环还是非循环,我们将当前帧重置为起始帧,或者将其设置为动画的末尾并暂停它。如果动画是反向播放的,则应用相同的逻辑,只是方向相反。
最后,负责从文件中读取数据的readFile方法:
void Anim_Directional::ReadIn(std::stringstream& l_stream){
l_stream >> m_frameStart >> m_frameEnd >> m_frameRow
>> m_frameTime >> m_frameActionStart >> m_frameActionEnd;
}
随着最后一段代码的完成,动画部分就结束了!为了实现加载精灵表文件,我们现在已经拥有了所有需要的东西。
加载精灵表文件
加载方法通常从设置文件、读取文件和获取当前行开始。从该行中加载的第一个标识符被存入type变量。其余部分相当典型:
bool SpriteSheet::LoadSheet(const std::string& l_file){
std::ifstream sheet;
sheet.open(Utils::GetWorkingDirectory() + l_file);
if(sheet.is_open()){
ReleaseSheet(); // Release current sheet resources.
std::string line;
while(std::getline(sheet,line)){
if (line[0] == '|'){ continue; }
std::stringstream keystream(line);
std::string type;
keystream >> type;
...
}
sheet.close();
return true;
}
std::cerr << "! Failed loading spritesheet: "
<< l_file << std::endl;
return false;
}
为了避免混淆,该文件中不同类型条目的解析已被分成单独的部分。让我们从纹理加载开始:
if(type == "Texture"){
if (m_texture != ""){
std::cerr << "! Duplicate texture entries in: "
<< l_file << std::endl;
continue;
}
std::string texture;
keystream >> texture;
if (!m_textureManager->RequireResource(texture)){
std::cerr << "! Could not set up the texture: "
<< texture << std::endl;
continue;
}
m_texture = texture;
m_sprite.setTexture(*m_textureManager->GetResource(m_texture));
} else if ...
首先,我们检查纹理是否已经被初始化,以避免重复条目。如果没有,keystream变量输出纹理句柄,该句柄在if语句中传递给纹理管理器。这样做是为了捕获无效句柄的错误。如果句柄有效,纹理名称被保留以供稍后释放资源,我们将用于绘制的精灵设置为指向纹理。
现在是阅读更小的信息块的时候了:
} else if(type == "Size"){
keystream >> m_spriteSize.x >> m_spriteSize.y;
SetSpriteSize(m_spriteSize);
} else if(type == "Scale"){
keystream >> m_spriteScale.x >> m_spriteScale.y;
m_sprite.setScale(m_spriteScale);
} else if(type == "AnimationType"){
keystream >> m_animType;
} else if ...
最引人注目的条目留到最后。此刻,我们解析动画:
} else if(type == "Animation"){
std::string name;
keystream >> name;
if (m_animations.find(name) != m_animations.end()){
std::cerr << "! Duplicate animation(" << name
<< ") in: " << l_file << std::endl;
continue;
}
Anim_Base* anim = nullptr;
if(m_animType == "Directional"){
anim = new Anim_Directional();
} else {
std::cerr << "! Unknown animation type: "
<< m_animType << std::endl;
continue;
}
keystream >> *anim;
anim->SetSpriteSheet(this);
anim->SetName(name);
anim->Reset();
m_animations.emplace(name,anim);
if (m_animationCurrent){ continue; }
m_animationCurrent = anim;
m_animationCurrent->Play();
}
首先,加载动画名称并检查动画容器以避免重复。然后检查之前加载的动画类型,以便构建正确的动画类型。我们可以使用工厂方法来做这件事,但由于我们目前只有一种类型的动画,所以现在似乎没有必要。然后,动画结构从我们的stringstream对象中接收数据流,初始化它。此外,动画被重置以将其值归零。一旦它被插入到动画容器中,我们最后检查的是当前动画成员是否已经分配了值。如果没有,这是精灵图文件中的第一个动画,我们假设它是默认的。它被分配给当前动画成员并设置为播放。
摘要
虽然良好的图形不是游戏最重要的方面,但从基本形状到屏幕上实际动画的精灵可以给玩家带来巨大的差异。当然,美化产品并不能修复它可能存在的任何缺陷,这似乎是现在的流行心态。然而,将玩家沉浸到游戏世界中,以及让看起来像是一堆方块的东西栩栩如生,这是我们追求的效果,而随着本章的完成,你现在可以使用一些基本技巧实现这一点。
在下一章中,我们将介绍常见的游戏开发元素,这些元素将把我们在游戏中构建的所有图形元素统一成一个具有平台元素、敌人和多个级别的完整游戏。那里见!
第七章. 重拾火焰 – 常见游戏设计元素
视频游戏每天都在变得越来越复杂。似乎创新的想法正在兴起,尤其是在独立游戏(如 Minecraft 和 Super Meat Boy)越来越受欢迎的背景下。尽管游戏想法本身变得越来越抽象,至少在外观上,支撑着美丽皮肤并帮助其保持形状的刚性骨架仍然是游戏开发者眼中最低的共同点。即使游戏的焦点围绕着两只在空闲时间吸食仙女粉末并帮助德古拉制作松饼,以免海王星爆炸的独角兽,这个概念能否实现将极大地取决于游戏背后的底层逻辑。如果没有实体,就没有独角兽。如果实体只是在黑色屏幕上弹跳,游戏就不会吸引人。这些都是任何项目都必须能够依赖的常见游戏设计元素,否则它注定会失败。
在本章中,我们将涵盖以下内容:
-
设计和实现游戏地图类
-
通过创建和管理实体来填充地图
-
检查和处理碰撞
-
将所有代码合并成一个完整游戏
游戏地图
玩家实际探索的环境和周围环境与游戏的其他部分一样重要。如果没有世界存在,玩家就只能在一个空白的屏幕颜色中空转。设计一个良好的界面来展示游戏的不同部分,从关卡背景到玩家必须面对的众多危险,可能会很棘手。现在,让我们为这个坚实的基础打下基础,从定义我们的地图格式开始,同时展望我们想要实现的目标:

首先,我们想要指定一个纹理句柄作为背景。然后,我们想要明确定义地图大小并设置重力,这决定了实体落地的速度。此外,我们还需要存储默认的摩擦力,这决定了平均地砖有多滑。最后,我们想要存储的是当当前地图结束时将加载的下一个地图的名称。以下是我们将要工作的其中一个地图的片段,Map1.map:
|type|~id|x|y|
BACKGROUND Bg1
SIZE 63 32
GRAVITY 512
DEFAULT_FRICTION 0.8 0
NEXTMAP map2.map
|PLAYER 0 512
|ENEMY Rat 128 512
TILE 0 0 25
TILE 1 0 26 WARP
...
如您所见,除了定义所讨论的所有内容外,地图文件还存储了玩家位置,以及不同的敌人和它们的出生位置。其中最后但绝对不是最不重要的部分是地砖存储以及指示哪个地砖在被触摸时会将玩家“传送”到下一个阶段。
地砖是什么?
“瓦片”这个词经常被提及,但还没有被定义。简单来说,瓦片是构成世界众多部分之一。瓦片是创建游戏环境的块,无论是你站立的草地还是你掉落的刺。地图使用瓦片图集,这与精灵图集非常相似,因为它一次可以持有许多不同的精灵。主要区别在于如何从瓦片图集中获取这些精灵。在我们的案例中,将要用作瓦片图集的纹理如下所示:

每个瓦片还具有独特的属性,我们希望从Tiles.cfg文件中加载这些属性:
|id|name|friction x|friction y|deadly
0 Grass 0.8 0 0
1 Dirt 0.8 0 0
2 Stone 0.8 0 0
3 Brick 0.8 0 0
4 Brick_Red 0.8 0 0
5 Rock 0.8 0 0
6 Icy_Rock 0.6 0 0
7 Spikes 1.0 0 1
8 Ice 0.25 0 0
它非常简单,只包含瓦片 ID、名称、两个摩擦轴和一个表示瓦片是否致命的二元标志。
构建游戏世界
由于瓦片在我们的游戏设计中将扮演如此重要的角色,因此拥有一个独立的数据结构,其中所有瓦片信息都可以本地化,将非常有帮助。一个不错的起点是定义一些瓦片大小的常量,以及将要使用的瓦片图集的尺寸。在存储此类信息时,一个简单的枚举可以非常有帮助:
enum Sheet{Tile_Size = 32, Sheet_Width = 256, Sheet_Height = 256};
在这里,我们使所有瓦片都宽 32 px,高 32 px,并且每个瓦片图集都宽 256 px,高 256 px。显然,这些常量可以更改,但这里的想法是在运行时保持它们相同。
为了使我们的代码更短,我们还可以从类型别名中受益,用于瓦片 ID:
using TileID = unsigned int;
飞行员模式
显然,每个瓦片都必须有一个代表其类型的精灵。从图形上讲,为了绘制草瓦片,我们希望调整精灵以仅裁剪到瓦片图集中的草瓦片。然后,我们设置其在屏幕上的位置并绘制它。看起来很简单,但考虑以下情况:你有一个大小为 1000x1000 瓦片的地图,其中可能有 25%的地图大小是实际瓦片,而不是空气,这让你有总共 62,500 个瓦片需要绘制。现在想象一下,你为每个瓦片存储一个精灵。当然,精灵是轻量级对象,但这仍然是一种巨大的资源浪费。这就是飞行员模式发挥作用的地方。
存储大量冗余数据显然是浪费,为什么不只存储每种类型的一个实例,并在瓦片中简单地存储对类型的指针呢?简而言之,这就是飞行员模式。让我们通过实现瓦片信息结构来观察它的实际应用:
struct TileInfo{
TileInfo(SharedContext* l_context,
const std::string& l_texture = "", TileID l_id = 0)
: m_context(l_context), m_id(0), m_deadly(false)
{
TextureManager* tmgr = l_context->m_textureManager;
if (l_texture == ""){ m_id = l_id; return; }
if (!tmgr->RequireResource(l_texture)){ return; }
m_texture = l_texture;
m_id = l_id;
m_sprite.setTexture(*tmgr->GetResource(m_texture));
sf::IntRect tileBoundaries(m_id %
(Sheet::Sheet_Width / Sheet::Tile_Size) * Sheet::Tile_Size,
m_id/(Sheet::Sheet_Height/Sheet::Tile_Size)*Sheet::Tile_Size,
Sheet::Tile_Size,Sheet::Tile_Size);
m_sprite.setTextureRect(tileBoundaries);
}
~TileInfo(){
if (m_texture == ""){ return; }
m_context->m_textureManager->ReleaseResource(m_texture);
}
sf::Sprite m_sprite;
TileID m_id;
std::string m_name;
sf::Vector2f m_friction;
bool m_deadly;
SharedContext* m_context;
std::string m_texture;
};
这个 struct 实际上包含了关于每种瓦片类型所有非唯一信息。它存储了它所使用的纹理,以及将代表瓦片的精灵。正如你所见,在这个结构的构造函数中,我们将精灵设置为指向瓦片图纹理,然后根据其瓦片 ID 进行裁剪。这种裁剪与精灵图类中的裁剪略有不同,因为我们现在只有瓦片 ID 可用,并不知道精灵位于哪一行。使用一些基本的数学知识,我们可以首先通过将我们的图尺寸除以瓦片大小来计算出瓦片图有多少列和行。在这种情况下,一个 256x256 像素的精灵图,瓦片大小为 32x32 像素,每行和每列将有 8 个瓦片。通过使用取模运算符 % 可以获得瓦片 ID 在 x 轴上的坐标。在每行有 8 个瓦片的情况下,它将返回从 0 到 7 的值,基于 ID。确定 y 坐标是通过将 ID 除以每列的瓦片数来完成的。这给了我们瓦片精灵在瓦片图中的左上角坐标,所以我们通过传递 Sheet::Tile_Size 来完成裁剪。
TileInfo 析构函数仅释放用于瓦片图的纹理。在这个结构体中存储的其他值将在地图加载时初始化。现在让我们定义我们的瓦片结构:
struct Tile{
TileInfo* m_properties;
bool m_warp; // Is the tile a warp.
// Other flags unique to each tile.
};
这就是为什么享乐模式如此强大的原因。如果瓦片对象只存储每个瓦片唯一的信息,而不是瓦片类型,那么它们将非常轻量级。到目前为止,我们唯一感兴趣的是瓦片是否是传送门,这意味着当玩家站在上面时,它会加载下一级。
设计地图类
在处理完瓦片之后,我们可以继续处理更高级的结构,例如游戏地图。让我们首先创建一些合适的容器类型,这些容器将包含地图信息以及瓦片类型信息:
using TileMap = std::unordered_map<TileID,Tile*>;
using TileSet = std::unordered_map<TileID,TileInfo*>;
TileMap 类型是一个 unordered_map 容器,它包含指向 Tile 对象的指针,这些对象通过无符号整数进行寻址。
注意
在已知瓦片数量预定的情形下,使用不会改变大小的容器(例如 std::array 或预分配的 std::vector)是明智的,以实现连续存储,从而实现更快的访问。
但等等!我们不是在二维空间中工作吗?如果坐标由两个数字表示,我们如何将瓦片映射到只有一个整数上呢?好吧,通过一点数学知识,完全可以将两个维度的索引表示为一个单一的数字。这将在稍后进行说明。
TileSet 数据类型代表所有不同类型瓦片的容器,这些瓦片与一个由无符号整数表示的瓦片 ID 相关联。这为我们编写地图头文件提供了所有需要的信息,这个头文件可能看起来像这样:
class Map{
public:
Map(SharedContext* l_context, BaseState* l_currentState);
~Map();
Tile* GetTile(unsigned int l_x, unsigned int l_y);
TileInfo* GetDefaultTile();
float GetGravity()const;
unsigned int GetTileSize()const;
const sf::Vector2u& GetMapSize()const;
const sf::Vector2f& GetPlayerStart()const;
void LoadMap(const std::string& l_path);
void LoadNext();
void Update(float l_dT);
void Draw();
private:
// Method for converting 2D coordinates to 1D ints.
unsigned int ConvertCoords(unsigned int l_x, unsigned int l_y);
void LoadTiles(const std::string& l_path);
void PurgeMap();
void PurgeTileSet();
TileSet m_tileSet;
TileMap m_tileMap;
sf::Sprite m_background;
TileInfo m_defaultTile;
sf::Vector2u m_maxMapSize;
sf::Vector2f m_playerStart;
unsigned int m_tileCount;
unsigned int m_tileSetCount;
float m_mapGravity;
std::string m_nextMap;
bool m_loadNextMap;
std::string m_backgroundTexture;
BaseState* m_currentState;
SharedContext* m_context;
};
首先,我们定义所有可预测的方法,例如在特定坐标获取瓦片、从类中获取各种信息,以及当然,更新和绘制地图的方法。让我们继续实现这些方法,以便更深入地讨论它们:
Map::Map(SharedContext* l_context, BaseState* l_currentState)
:m_context(l_context), m_defaultTile(l_context), m_maxMapSize(32, 32), m_tileCount(0), m_tileSetCount(0),m_mapGravity(512.f), m_loadNextMap(false),m_currentState(l_currentState)
{
m_context->m_gameMap = this;
LoadTiles("tiles.cfg");
}
地图构造函数将其数据成员初始化为一些默认值,并调用一个私有方法以从tiles.cfg文件加载不同类型的瓦片。相当标准。足够可预测,这个类的析构函数也没有做任何特别的事情:
Map::~Map(){
PurgeMap();
PurgeTileSet();
m_context->m_gameMap = nullptr;
}
从地图中获取瓦片是通过首先将此方法提供的作为参数的 2D 坐标转换为单个数字,然后在无序映射中定位特定的瓦片:
Tile* Map::GetTile(unsigned int l_x, unsigned int l_y){
auto itr = m_tileMap.find(ConvertCoords(l_x,l_y));
return(itr != m_tileMap.end() ? itr->second : nullptr);
}
坐标转换看起来是这样的:
unsigned int Map::ConvertCoords(const unsigned int& l_x, const unsigned int& l_y)
{
return (l_x * m_maxMapSize.x) + l_y; // Row-major.
}
为了使这个方法工作,我们必须定义地图的最大尺寸,否则它会产生错误的结果。
更新地图是另一个关键部分:
void Map::Update(float l_dT){
if(m_loadNextMap){
PurgeMap();
m_loadNextMap = false;
if(m_nextMap != ""){
LoadMap("media/maps/"+m_nextMap);
} else {
m_currentState->GetStateManager()->
SwitchTo(StateType::GameOver);
}
m_nextMap = "";
}
sf::FloatRect viewSpace = m_context->m_wind->GetViewSpace();
m_background.setPosition(viewSpace.left, viewSpace.top);
}
这里,它检查m_loadNextMap标志。如果它设置为true,则清除地图信息并加载下一个地图,如果设置了持有其句柄的数据成员。如果没有设置,则将应用程序状态设置为GameOver,这将在稍后创建。这将模拟玩家通关游戏。最后,我们获取窗口的视图空间并将地图背景的左上角设置为视图空间的左角,以便背景跟随相机。让我们在屏幕上绘制这些更改:
void Map::Draw(){
sf::RenderWindow* l_wind = m_context->m_wind->GetRenderWindow();
l_wind->draw(m_background);
sf::FloatRect viewSpace = m_context->m_wind->GetViewSpace();
sf::Vector2i tileBegin(
floor(viewSpace.left / Sheet::Tile_Size),
floor(viewSpace.top / Sheet::Tile_Size));
sf::Vector2i tileEnd(
ceil((viewSpace.left + viewSpace.width) / Sheet::Tile_Size),
ceil((viewSpace.top + viewSpace.height) / Sheet::Tile_Size));
unsigned int count = 0;
for(int x = tileBegin.x; x <= tileEnd.x; ++x){
for(int y = tileBegin.y; y <= tileEnd.y; ++y){
if(x < 0 || y < 0){ continue; }
Tile* tile = GetTile(x,y);
if (!tile){ continue; }
sf::Sprite& sprite = tile->m_properties->m_sprite;
sprite.setPosition(x * Sheet::Tile_Size,
y * Sheet::Tile_Size);
l_wind->draw(sprite);
++count;
}
}
}
通过共享上下文获取渲染窗口的指针,并在前两行中绘制背景。接下来的三行有一个简单的名字,称为剔除。这是一种任何优秀的游戏程序员都应该利用的技术,其中任何当前不在屏幕视图空间内的东西都应该不被绘制。再次考虑这种情况,你有一个 1000x1000 大小的巨大地图。尽管现代硬件现在可以非常快地绘制,但仍然没有必要浪费这些时钟周期,当它们可以用来执行更好的任务时,而不是将一些甚至不可见的东西带到屏幕上。如果你在游戏中没有进行任何剔除,它最终将开始遭受严重的性能打击。
从视图空间的左上角到右下角的瓦片坐标被输入到一个循环中。首先,它们被评估为正数。如果它们是负数,我们计算地图容器 1D 索引的方式会产生一些镜像伪影,如果你向上或向左走得太远,你将看到相同的地图反复出现。
通过传递循环中的x和y坐标,我们获得一个瓦片的指针。如果它是一个有效的瓦片,我们就从TileInfo结构的指针中获取其精灵。精灵的位置被设置为与瓦片的坐标匹配,并在屏幕上绘制精灵。
现在有一个方法可以擦除整个地图:
void Map::PurgeMap(){
m_tileCount = 0;
for (auto &itr : m_tileMap){
delete itr.second;
}
m_tileMap.clear();
m_context->m_entityManager->Purge();
if (m_backgroundTexture == ""){ return; }
m_context->m_textureManager->ReleaseResource(m_backgroundTexture);
m_backgroundTexture = "";
}
除了清除地图容器外,你还会注意到我们在调用实体管理器的Purge方法。现在先忽略那行。实体将在稍后讨论。我们也不应忘记在擦除地图时释放背景纹理。
清空不同瓦片类型的容器也是必要的部分:
void Map::PurgeTileSet(){
for (auto &itr : m_tileSet){
delete itr.second;
}
m_tileSet.clear();
m_tileSetCount = 0;
}
这部分很可能会在析构函数中被调用,但有一个单独的方法还是不错的。说到不同的瓦片类型,我们需要从文件中加载它们:
void Map::LoadTiles(const std::string& l_path){
std::ifstream file;
file.open(Utils::GetWorkingDirectory() + l_path);
if (!file.is_open()){
std::cout << "! Failed loading tile set file: "<< l_path << std::endl;
return;
}
std::string line;
while(std::getline(file,line)){
if (line[0] == '|'){ continue; }
std::stringstream keystream(line);
int tileId;
keystream >> tileId;
if (tileId < 0){ continue; }
TileInfo* tile = new TileInfo(m_context,"TileSheet",tileId);
keystream >> tile->m_name >> tile->m_friction.x >> tile->m_friction.y >> tile->m_deadly;
if(!m_tileSet.emplace(tileId,tile).second){
// Duplicate tile detected!
std::cout << "! Duplicate tile type: "<< tile->m_name << std::endl;
delete tile;
}
}
file.close();
}
首先加载瓦片 ID,正如tiles.cfg格式所建议的。它被检查是否越界,如果不是,就会为瓦片类型分配动态内存,此时所有内部数据成员都被初始化为字符串流中的值。如果瓦片信息对象无法插入到瓦片集容器中,那么必须有重复条目,此时动态内存将被释放。
现在是地图的压轴大戏——加载方法。由于实际的文件加载代码基本上保持不变,让我们直接跳到读取地图文件的内容,从瓦片条目开始:
if(type == "TILE"){
int tileId = 0;
keystream >> tileId;
if (tileId < 0){ std::cout << "! Bad tile id: " << tileId << std::endl;
continue;
}
auto itr = m_tileSet.find(tileId);
if (itr == m_tileSet.end()){
std::cout << "! Tile id(" << tileId<< ") was not found in tileset." << std::endl;
continue;
}
sf::Vector2i tileCoords;
keystream >> tileCoords.x >> tileCoords.y;
if (tileCoords.x>m_maxMapSize.x || tileCoords.y>m_maxMapSize.y)
{
std::cout << "! Tile is out of range: " <<tileCoords.x << " " << tileCoords.y << std::endl;
continue;
}
Tile* tile = new Tile();
// Bind properties of a tile from a set.
tile->m_properties = itr->second;
if(!m_tileMap.emplace(ConvertCoords(
tileCoords.x,tileCoords.y),tile).second)
{
// Duplicate tile detected!
std::cout << "! Duplicate tile! : " << tileCoords.x << "" << tileCoords.y << std::endl;
delete tile;
tile = nullptr;
continue;
}
std::string warp;
keystream >> warp;
tile->m_warp = false;
if(warp == "WARP"){ tile->m_warp = true; }
} else if ...
TILE行的第一部分被加载进来,这是瓦片 ID。按照惯例,它被检查是否在正数和0的范围内。如果是,就会在瓦片集中查找该特定瓦片 ID 的瓦片信息。因为我们不希望地图周围有空白的瓦片,所以我们只有在找到特定 ID 的瓦片信息时才会继续进行。接下来,读取瓦片坐标并检查它们是否在地图大小的范围内。如果是,就会为瓦片分配内存,并将其瓦片信息数据成员设置为指向瓦片集中的那个。最后,我们尝试读取TILE行末尾的字符串并检查它是否说“WARP”。这是指接触特定瓦片应该加载下一级。
现在来说说地图的背景:
} else if(type == "BACKGROUND"){
if (m_backgroundTexture != ""){ continue; }
keystream >> m_backgroundTexture;
if (!m_context->m_textureManager->RequireResource(m_backgroundTexture))
{
m_backgroundTexture = "";
continue;
}
sf::Texture* texture = m_context->m_textureManager->GetResource(m_backgroundTexture);
m_background.setTexture(*texture);
sf::Vector2f viewSize = m_currentState->GetView().getSize();
sf::Vector2u textureSize = texture->getSize();
sf::Vector2f scaleFactors;
scaleFactors.x = viewSize.x / textureSize.x;
scaleFactors.y = viewSize.y / textureSize.y;
m_background.setScale(scaleFactors);
} else if ...
这部分相当直接。从BACKGROUND行加载一个纹理句柄。如果句柄有效,背景精灵就会与纹理绑定。但是有一个问题。假设我们窗口的视图比背景纹理大。这会导致背景周围出现空白区域,看起来非常糟糕。重复纹理可能会解决空白区域的问题,但我们将要处理的特定背景并不适合平铺,所以最好的解决方案是将精灵缩放到足够大,以完全适应视图空间,无论它更大还是更小。缩放因子的值可以通过将视图大小乘以纹理大小来获得。例如,如果我们有一个 800x600 像素大小的视图和一个 400x300 像素大小的纹理,两个轴的缩放因子都是 2,背景被放大到原来的两倍大小。
接下来是简单地从文件中读取一些数据成员的部分:
} else if(type == "SIZE"){
keystream >> m_maxMapSize.x >> m_maxMapSize.y;
} else if(type == "GRAVITY"){
keystream >> m_mapGravity;
} else if(type == "DEFAULT_FRICTION"){
keystream >> m_defaultTile->m_friction.x >> m_defaultTile->m_friction.y;
} else if(type == "NEXTMAP"){
keystream >> m_nextMap;
}
让我们用一个小助手方法来结束这个类,这个方法将帮助我们跟踪下一个地图何时应该被加载:
void Map::LoadNext(){ m_loadNextMap = true; }
这就完成了地图类(map class)的实现。现在世界已经存在,但没有人去占据它。真是荒谬!我们不要贬低我们的工作,让我们创建一些实体来探索我们创造的环境。
所有世界对象的父类
实体实际上只是游戏对象(game object)的另一种说法。它是一个抽象类,作为所有其派生类的父类,包括玩家、敌人和可能的项目,具体取决于你如何实现。让这些完全不同的概念共享相同的根源,允许程序员定义适用于所有这些的共同行为类型。此外,它还允许游戏引擎以相同的方式对它们进行操作,因为它们都共享相同的接口。例如,敌人可以被推动,玩家也可以。所有敌人、项目和玩家都必须受到重力的影响。这些不同类型之间的共同血统使我们能够卸载大量冗余代码,并专注于每个实体的独特方面,而不是一遍又一遍地重写相同的代码。
让我们先定义我们将要处理哪些实体类型:
enum class EntityType{ Base, Enemy, Player };
基类实体类型只是一个抽象类,实际上并不会被实例化。这让我们有了敌人和玩家。现在,让我们设置实体可能拥有的所有可能状态:
enum class EntityState{
Idle, Walking, Jumping, Attacking, Hurt, Dying
};
你可能已经注意到,这些状态与玩家精灵图(sprite sheet)中的动画大致相符。所有角色实体都将以此方式建模。
创建基类实体
在使用继承构建实体的情况下,编写这样一个基本父类(parent class)相当常见。它必须提供任何给定游戏内实体应有的所有功能。
在完成所有设置之后,我们终于可以开始这样塑造它:
class EntityManager;
class EntityBase{
friend class EntityManager;
public:
EntityBase(EntityManager* l_entityMgr);
virtual ~EntityBase();
... // Getters and setters.
void Move(float l_x, float l_y);
void AddVelocity(float l_x, float l_y);
void Accelerate(float l_x, float l_y);
void SetAcceleration(float l_x, float l_y);
void ApplyFriction(float l_x, float l_y);
virtual void Update(float l_dT);
virtual void Draw(sf::RenderWindow* l_wind) = 0;
protected:
// Methods.
void UpdateAABB();
void CheckCollisions();
void ResolveCollisions();
// Method for what THIS entity does TO the l_collider entity.
virtual void OnEntityCollision(EntityBase* l_collider,bool l_attack) = 0;
// Data members.
std::string m_name;
EntityType m_type;
unsigned int m_id; // Entity id in the entity manager.
sf::Vector2f m_position; // Current position.
sf::Vector2f m_positionOld; // Position before entity moved.
sf::Vector2f m_velocity; // Current velocity.
sf::Vector2f m_maxVelocity; // Maximum velocity.
sf::Vector2f m_speed; // Value of acceleration.
sf::Vector2f m_acceleration; // Current acceleration.
sf::Vector2f m_friction; // Default friction value.
TileInfo* m_referenceTile; // Tile underneath entity.
sf::Vector2f m_size; // Size of the collision box.
sf::FloatRect m_AABB; // The bounding box for collisions.
EntityState m_state; // Current entity state.
// Flags for remembering axis collisions.
bool m_collidingOnX;
bool m_collidingOnY;
Collisions m_collisions;
EntityManager* m_entityManager;
};
一开始,我们就将尚未编写的EntityManager类设置为基类实体(base entities)的朋友类(friend class)。因为代码可能有点令人困惑,所以添加了一大堆注释来解释类的每个数据成员,所以我们不会过多地涉及这些内容,直到我们在类的实现过程中遇到它们。
实体的三个主要属性包括其位置、速度和加速度。实体的位置是自解释的。速度表示实体移动的速度。由于我们应用程序中的所有更新方法都接受以秒为单位的 delta 时间,所以速度将表示实体每秒移动的像素数。三个主要属性中的最后一个元素是加速度,它负责实体速度增加的速度。它也被定义为每秒添加到实体速度的像素数。这里的事件序列如下:
-
实体被加速,并且它的加速度调整其速度。
-
实体的位置是根据其速度重新计算的。
-
实体的速度会受到摩擦系数的阻尼。
碰撞和边界框
在深入实现之前,让我们谈谈所有游戏中最常用的元素之一——碰撞。检测和解决碰撞是防止玩家穿过地图或屏幕外部的关键。它还决定了玩家在受到敌人触碰时是否会受伤。以一种间接的方式,我们使用了一种基本的碰撞检测方法来确定在地图类中应该渲染哪些瓦片。如何检测和解决碰撞呢?有很多方法可以做到这一点,但就我们的目的而言,最基本的边界框碰撞就足够了。还可以使用其他类型的碰撞,例如圆形,但这可能取决于正在构建的游戏类型,可能不是最有效或最合适的方法。
边界框,正如其名,是一个盒子或矩形,代表实体的实体部分。以下是一个边界框的好例子:

它不会像那样可见,除非我们创建一个与边界框具有相同位置和大小的实际 sf::RectangleShape 并进行渲染,这是一种调试应用程序的有用方法。在我们的基础实体类中,名为 m_AABB 的边界框只是一个 sf::FloatRect 类型。名称 "AABB" 代表它持有的两个不同值对:位置和大小。边界框碰撞,也称为 AABB 碰撞,简单来说就是两个边界框相互相交的情况。SFML 中的矩形数据类型为我们提供了一个检查交集的方法:
if(m_AABB.intersects(SomeRectangle){...}
冲突解决这个术语简单来说就是执行一系列动作来通知并移动发生碰撞的实体。例如,在碰撞到瓦片的情况下,冲突解决意味着将实体推回足够远,使其不再与瓦片相交。
小贴士
本项目的代码文件包含一个额外的类,允许进行调试信息渲染,以及已经设置好的所有这些信息。按下 O 键可以切换其可见性。
实现基础实体类
在处理完所有这些信息之后,我们终于可以回到实现基础实体类的工作上了。一如既往,还有什么地方比构造函数更好的起点呢?让我们来看看:
EntityBase::EntityBase(EntityManager* l_entityMgr)
:m_entityManager(l_entityMgr), m_name("BaseEntity"),
m_type(EntityType::Base), m_referenceTile(nullptr),
m_state(EntityState::Idle), m_id(0),
m_collidingOnX(false), m_collidingOnY(false){}
它只是将所有数据成员初始化为默认值。请注意,在它设置为零的所有成员中,摩擦系数实际上被设置为 x 轴为 0.8。这是因为我们不想实体的默认行为与冰上的牛一样,坦白说。摩擦系数定义了实体速度中有多少会损失到环境中。如果现在不太理解,不用担心。我们很快就会更详细地介绍它。
这里包含了修改实体基类数据成员的所有方法:
void EntityBase::SetPosition(const float& l_x, const float& l_y){
m_position = sf::Vector2f(l_x,l_y);
UpdateAABB();
}
void EntityBase::SetPosition(const sf::Vector2f& l_pos){
m_position = l_pos;
UpdateAABB();
}
void EntityBase::SetSize(const float& l_x, const float& l_y){
m_size = sf::Vector2f(l_x,l_y);
UpdateAABB();
}
void EntityBase::SetState(const EntityState& l_state){
if(m_state == EntityState::Dying){ return; }
m_state = l_state;
}
如您所见,修改实体位置或大小都会调用内部方法 UpdateAABB。简单来说,它负责更新边界框的位置。更多相关信息即将揭晓。
有一个有趣的事情要注意的是在 SetState 方法中。如果当前状态是 Dying,则不允许状态改变。这样做是为了防止游戏中的某些其他事件神奇地将实体从死亡状态中拉出来。
现在我们有一个更有趣的代码块,负责移动实体:
void EntityBase::Move(float l_x, float l_y){
m_positionOld = m_position;
m_position += sf::Vector2f(l_x,l_y);
sf::Vector2u mapSize = m_entityManager->GetContext()->m_gameMap->GetMapSize();
if(m_position.x < 0){
m_position.x = 0;
} else if(m_position.x > (mapSize.x + 1) * Sheet::Tile_Size){
m_position.x = (mapSize.x + 1) * Sheet::Tile_Size;
}
if(m_position.y < 0){
m_position.y = 0;
} else if(m_position.y > (mapSize.y + 1) * Sheet::Tile_Size){
m_position.y = (mapSize.y + 1) * Sheet::Tile_Size;
SetState(EntityState::Dying);
}
UpdateAABB();
}
首先,我们将当前位置复制到另一个数据成员:m_positionOld。保留这些信息总是好的,以防以后需要。然后,位置通过提供的偏移量进行调整。之后,获取地图的大小,以便检查当前位置是否超出地图范围。如果它在任一轴上,我们只需将其位置重置为边界外的边缘。如果实体在 y 轴上超出地图范围,其状态被设置为 Dying。在所有这些之后,边界框被更新以反映实体精灵位置的变化。
现在我们来添加和管理实体的速度:
void EntityBase::AddVelocity(float l_x, float l_y){
m_velocity += sf::Vector2f(l_x,l_y);
if(abs(m_velocity.x) > m_maxVelocity.x){
if(m_velocity.x < 0){ m_velocity.x = -m_maxVelocity.x; }
else { m_velocity.x = m_maxVelocity.x; }
}
if(abs(m_velocity.y) > m_maxVelocity.y){
if(m_velocity.y < 0){ m_velocity.y = -m_maxVelocity.y; }
else { m_velocity.y = m_maxVelocity.y; }
}
}
如您所见,这相当简单。将速度成员添加到一起,然后检查它是否超出允许的最大速度范围。在第一次检查中,我们使用绝对值,因为速度可以是正的也可以是负的,这表示实体移动的方向。如果速度超出范围,则将其重置为允许的最大值。
说到加速实体,可以说,就像向另一个向量中添加一个向量一样简单:
void EntityBase::Accelerate(float l_x, float l_y){
m_acceleration += sf::Vector2f(l_x,l_y);
}
应用摩擦并不比管理我们的速度更复杂:
void EntityBase::ApplyFriction(float l_x, float l_y){
if(m_velocity.x != 0){
if(abs(m_velocity.x) - abs(l_x) < 0){ m_velocity.x = 0; }
else {
if(m_velocity.x < 0){ m_velocity.x += l_x; }
else { m_velocity.x -= l_x; }
}
}
if(m_velocity.y != 0){
if (abs(m_velocity.y) - abs(l_y) < 0){ m_velocity.y = 0; }
else {
if(m_velocity.y < 0){ m_velocity.y += l_y; }
else { m_velocity.y -= l_y; }
}
}
}
它需要检查该轴上速度和摩擦系数绝对值之差是否不小于零,以防止通过摩擦改变实体运动方向,这会显得很奇怪。如果是小于零,则将速度设置回零。如果不是,则检查速度的符号并应用适当方向的摩擦。
为了使实体不是背景的静态部分,它需要被更新:
void EntityBase::Update(float l_dT){
Map* map = m_entityManager->GetContext()->m_gameMap;
float gravity = map->GetGravity();
Accelerate(0,gravity);
AddVelocity(m_acceleration.x * l_dT, m_acceleration.y * l_dT);
SetAcceleration(0.0f, 0.0f);
sf::Vector2f frictionValue;
if(m_referenceTile){
frictionValue = m_referenceTile->m_friction;
if(m_referenceTile->m_deadly){ SetState(EntityState::Dying); }
} else if(map->GetDefaultTile()){
frictionValue = map->GetDefaultTile()->m_friction;
} else {
frictionValue = m_friction;
}
float friction_x = (m_speed.x * frictionValue.x) * l_dT;
float friction_y = (m_speed.y * frictionValue.y) * l_dT;
ApplyFriction(friction_x, friction_y);
sf::Vector2f deltaPos = m_velocity * l_dT;
Move(deltaPos.x, deltaPos.y);
m_collidingOnX = false;
m_collidingOnY = false;
CheckCollisions();
ResolveCollisions();
}
这里发生了很多事情。让我们一步一步来。首先,通过共享上下文获取游戏地图的一个实例。然后使用它来获取地图的重力,该重力是从地图文件中加载的。然后,实体的加速度通过 y 轴的重力增加。通过使用 AddVelocity 方法并传递加速度乘以时间增量,调整速度并将加速度设置回零。接下来,我们必须获取速度将被阻尼的摩擦系数。如果 m_referenceTile 数据成员没有被设置为 nullptr,则首先使用它,以从实体所站的瓦片中获取摩擦力。如果它被设置为 nullptr,则实体必须在空中,因此从地图中获取默认瓦片以获取从地图文件中加载的摩擦值。如果由于任何原因,这也没有设置,则默认为在 EntityBase 构造函数中设置的值。
在计算摩擦力之前,重要的是要明确,除了被设置为默认值之外,m_speed 数据成员在这个类中既没有设置也没有初始化。速度是实体移动时加速的量,它将在 EntityBase 的一个派生类中实现。
如果你从该类的构造函数中回忆起来,我们设置了默认摩擦为 0.8f。这不仅仅是一个非常小的值。我们正在使用摩擦作为一个因素,以确定实体速度应该损失多少。话虽如此,将速度乘以摩擦系数,然后乘以时间增量,我们得到的是在此帧中损失的速度,然后将其传递到 ApplyFriction 方法中以操纵速度。
最后,位置的变化,称为 deltaPos,是通过将速度乘以时间增量来计算的,并将其传递到 Move 方法中以调整实体在世界中的位置。两个轴上的碰撞标志被重置为 false,实体调用其自己的私有成员以首先获取然后解决碰撞。
让我们看看负责更新边界框的方法:
void EntityBase::UpdateAABB(){
m_AABB = sf::FloatRect(m_position.x - (m_size.x / 2),m_position.y - m_size.y, m_size.x, m_size.y);
}
由于边界框的起点被留在左上角,并且实体的位置被设置为 (width / 2, height),如果我们想要精确的碰撞,那么考虑这一点是必要的。代表边界框的矩形被重置以匹配精灵的新位置。
实体与瓦片碰撞
在跳入碰撞检测和解决之前,让我们回顾一下 SFML 提供的用于检查两个矩形是否相交的方法:
sf::FloatRect r1;
sf::FloatRect r2;
if(r1.intersects(r2)){ ... }
无论我们检查哪个矩形,如果它们相交,交点方法仍然会返回 true。然而,此方法确实接受一个可选的第二个参数,它是一个矩形类的引用,该引用将填充有关交点本身的信息。考虑以下插图:

我们有两个相交的矩形。对角条纹区域表示交集的矩形,可以通过以下方式获得:
...
sf::FloatRect intersection;
if(r1.intersects(r2,intersection)){ ... }
这对我们来说很重要,因为一个实体可能同时与多个瓦片发生碰撞。了解碰撞的深度也是解决碰撞的关键部分。考虑到这一点,让我们定义一个结构来临时存储碰撞信息,在它被解决之前:
struct CollisionElement{
CollisionElement(float l_area, TileInfo* l_info,const sf::FloatRect& l_bounds):m_area(l_area), m_tile(l_info), m_tileBounds(l_bounds){}
float m_area;
TileInfo* m_tile;
sf::FloatRect m_tileBounds;
};
using Collisions = std::vector<CollisionElement>;
首先,我们创建一个结构,它包含一个表示碰撞面积的浮点数,一个包含实体碰撞的瓦片的边界信息的矩形,以及一个指向TileInfo实例的指针。你总是希望先解决最大的碰撞,这些信息将帮助我们做到这一点。这次,碰撞元素将存储在一个向量中。
接下来,我们需要一个函数来比较我们自定义容器中的两个元素以便对其进行排序,其蓝图在EntityBase类的头文件中看起来像这样:
bool SortCollisions(const CollisionElement& l_1,const CollisionElement& l_2);
实现这个函数非常简单。向量容器简单地使用布尔检查来确定它正在比较的两个元素中哪一个更大。我们只需根据哪个元素更大返回 true 或 false。因为我们按面积大小对容器进行排序,所以比较是在第一对的第一元素之间进行的:
bool SortCollisions(const CollisionElement& l_1,const CollisionElement& l_2)
{ return l_1.m_area > l_2.m_area; }
接下来是有趣的部分,检测碰撞:
void EntityBase::CheckCollisions(){
Map* gameMap = m_entityManager->GetContext()->m_gameMap;
unsigned int tileSize = gameMap->GetTileSize();
int fromX = floor(m_AABB.left / tileSize);
int toX = floor((m_AABB.left + m_AABB.width) / tileSize);
int fromY = floor(m_AABB.top / tileSize);
int toY = floor((m_AABB.top + m_AABB.height) / tileSize);
for(int x = fromX; x <= toX; ++x){
for(int y = fromY; y <= toY; ++y){
Tile* tile = gameMap->GetTile(x,y);
if (!tile){ continue; }
sf::FloatRect tileBounds(x * tileSize, y * tileSize,
tileSize,tileSize);
sf::FloatRect intersection;
m_AABB.intersects(tileBounds,intersection);
float area = intersection.width * intersection.height;
CollisionElement e(area, tile->m_properties, tileBounds);
m_collisions.emplace_back(e);
if(tile->m_warp && m_type == EntityType::Player){
gameMap->LoadNext();
}
}
}
}
我们首先使用边界框的坐标和大小来获得它可能相交的瓦片的坐标。以下图像更好地说明了这一点:

然后将由四个整数表示的瓦片坐标范围输入到一个双重循环中,该循环检查是否有瓦片占据了我们所感兴趣的空间。如果GetTile方法返回一个瓦片,那么实体的边界框肯定与一个瓦片相交,因此创建一个表示瓦片边界框的浮点矩形。我们还准备另一个浮点矩形来保存交集的数据,并调用intersects方法以获取这些信息。交集的面积通过乘以其宽度和高度来计算,碰撞信息以及表示实体碰撞的瓦片类型的TileInfo对象的指针被推入碰撞容器中。
在结束这个方法之前,我们最后要检查实体正在碰撞的当前瓦片是否是扭曲瓦片,以及实体是否是玩家。如果这两个条件都满足,则加载下一个地图。
现在已经获得了一个实体的碰撞列表,下一步是解决它们:
void EntityBase::ResolveCollisions(){
if(!m_collisions.empty()){
std::sort(m_collisions.begin(),m_collisions.end(), SortCollisions);
Map* gameMap = m_entityManager->GetContext()->m_gameMap;
unsigned int tileSize = gameMap->GetTileSize();
for (auto &itr : m_collisions){
if (!m_AABB.intersects(itr.m_tileBounds)){ continue; }
float xDiff = (m_AABB.left + (m_AABB.width / 2)) -(itr.m_tileBounds.left + (itr.m_tileBounds.width / 2));
float yDiff = (m_AABB.top + (m_AABB.height / 2)) -(itr.m_tileBounds.top + (itr.m_tileBounds.height / 2));
float resolve = 0;
if(abs(xDiff) > abs(yDiff)){
if(xDiff > 0){
resolve = (itr.m_tileBounds.left + tileSize) –m_AABB.left;
} else {
resolve = -((m_AABB.left + m_AABB.width) –itr.m_tileBounds.left);
}
Move(resolve, 0);
m_velocity.x = 0;
m_collidingOnX = true;
} else {
if(yDiff > 0){
resolve = (itr.m_tileBounds.top + tileSize) –
m_AABB.top;
} else {
resolve = - ((m_AABB.top + m_AABB.height) –itr.m_tileBounds.top);
}
Move(0,resolve);
m_velocity.y = 0;
if (m_collidingOnY){ continue; }
m_referenceTile = itr.m_tile;
m_collidingOnY = true;
}
}
m_collisions.clear();
}
if(!m_collidingOnY){ m_referenceTile = nullptr; }
}
首先,我们检查容器中是否有任何碰撞。接下来是对所有元素进行排序。调用std::sort函数,并传入容器的开始和结束迭代器,以及将用于元素之间比较的函数名称。
代码接着遍历容器中存储的所有碰撞。这里在实体的边界框和瓦片的边界框之间还有一个交点检查。这样做是因为解决之前的碰撞可能会以某种方式移动实体,使其不再与容器中下一个瓦片发生碰撞。如果仍然存在碰撞,则计算实体边界框中心到瓦片边界框中心的距离。这些距离的第一个用途在下一条线中体现,其中它们的绝对值被比较。如果 x 轴上的距离大于 y 轴上的距离,则解决发生在 x 轴上。否则,它在 y 轴上解决。
距离计算的第二个目的是确定实体位于瓦片的哪一侧。如果距离为正,则实体位于瓦片的右侧,因此它向正 x 方向移动。否则,它向负 x 方向移动。resolve变量接受瓦片和实体之间的穿透量,这取决于轴和碰撞的侧面。
在两个轴的情况下,通过调用其实体的Move方法并传入穿透深度来移动实体。在模拟实体撞击固体时,停止该轴上的实体速度也很重要。最后,将特定轴上的碰撞标志设置为 true。
如果碰撞在 y 轴上解决,除了在 x 轴碰撞解决情况下采取的所有相同步骤外,我们还会检查是否设置了 y 轴碰撞标志。如果尚未设置,我们将m_referenceTile数据成员更改为指向实体正在与之碰撞的当前瓦片的瓦片类型,然后设置该标志为 true,以保持引用不变,直到下一次检查碰撞。这段小代码片段使任何实体根据其站立在哪个瓦片上而表现出不同的行为。例如,实体在冰瓦片上可以比在简单的草瓦片上滑动更多,如图所示:

如箭头所示,这些瓦片的摩擦系数不同,这意味着我们实际上是从直接下面的瓦片获取信息。
实体存储和管理
没有适当的管理,这些实体只是散布在内存中的随机类,没有任何规律。为了产生一种稳健的方式来创建实体之间的交互,它们需要由一个管理类来监护。在我们开始设计它之前,让我们定义一些数据类型来包含我们将要处理的信息:
using EntityContainer = std::unordered_map<unsigned int,EntityBase*>;
using EntityFactory = std::unordered_map<EntityType, std::function<EntityBase*(void)>>;
using EnemyTypes = std::unordered_map<std::string,std::string>;
EntityContainer类型,正如其名所示,是一个实体容器。它再次由一个unordered_map提供支持,将实体实例与作为标识符的无符号整数关联起来。下一个类型是 lambda 函数的容器,它将实体类型与可以分配内存并返回从基实体类继承的类实例的代码链接起来,充当工厂。这种行为对我们来说并不陌生,所以让我们继续定义实体管理器类:
class EntityManager{
public:
EntityManager(SharedContext* l_context,unsigned int l_maxEntities);
~EntityManager();
int Add(const EntityType& l_type,const std::string& l_name = "");
EntityBase* Find(unsigned int l_id);
EntityBase* Find(const std::string& l_name);
void Remove(unsigned int l_id);
void Update(float l_dT);
void Draw();
void Purge();
SharedContext* GetContext();
private:
template<class T>
void RegisterEntity(const EntityType& l_type){
m_entityFactory[l_type] = [this]() -> EntityBase*
{
return new T(this);
};
}
void ProcessRemovals();
void LoadEnemyTypes(const std::string& l_name);
void EntityCollisionCheck();
EntityContainer m_entities;
EnemyTypes m_enemyTypes;
EntityFactory m_entityFactory;
SharedContext* m_context;
unsigned int m_idCounter;
unsigned int m_maxEntities;
std::vector<unsigned int> m_entitiesToRemove;
};
除了将 lambda 函数插入实体工厂容器的私有模板方法之外,这个类看起来相对典型。我们有更新和绘制实体、添加、查找和删除它们以及清除所有数据的方法,就像我们通常做的那样。存在名为ProcessRemovals的私有方法表明我们正在使用延迟删除实体,就像我们在状态管理器类中所做的那样。让我们通过实现它来更详细地了解这个类的运作方式。
实体管理器的实现
和往常一样,一个好的开始是构造函数:
EntityManager::EntityManager(SharedContext* l_context,unsigned int l_maxEntities):m_context(l_context),m_maxEntities(l_maxEntities), m_idCounter(0)
{
LoadEnemyTypes("EnemyList.list");
RegisterEntity<Player>(EntityType::Player);
RegisterEntity<Enemy>(EntityType::Enemy);
}
EntityManager::~EntityManager(){ Purge(); }
其中一些数据成员通过初始化列表进行初始化。m_idCounter变量将用于跟踪分配给实体的最高 ID。接下来,调用一个私有方法来加载敌人名称和它们的角色定义文件对,这将在稍后进行解释。
最后,注册了两种实体类型:玩家和敌人。我们还没有设置它们的类,但很快就会完成,所以现在我们可以先注册它们。
实体管理器的析构函数简单地调用Purge方法。
通过将实体类型及其名称传递给实体管理器的Add方法来向游戏中添加新实体:
int EntityManager::Add(const EntityType& l_type,const std::string& l_name)
{
auto itr = m_entityFactory.find(l_type);
if (itr == m_entityFactory.end()){ return -1; }
EntityBase* entity = itr->second();
entity->m_id = m_idCounter;
if (l_name != ""){ entity->m_name = l_name; }
m_entities.emplace(m_idCounter,entity);
if(l_type == EntityType::Enemy){
auto itr = m_enemyTypes.find(l_name);
if(itr != m_enemyTypes.end()){
Enemy* enemy = (Enemy*)entity;
enemy->Load(itr->second);
}
}
++m_idCounter;
return m_idCounter - 1;
}
实体工厂容器会搜索提供的参数类型。如果该类型已注册,则会调用 lambda 函数来为实体分配动态内存,并通过指向EntityBase类的指针变量entity捕获内存地址。然后,新创建的实体被插入到实体容器中,并使用m_idCounter数据成员设置其 ID。如果用户为实体名称提供了参数,它也会被设置。
接下来检查实体类型。如果是敌人,则会在敌人类型容器中搜索以找到角色定义文件的路径。如果找到,实体会被类型转换为敌人实例,并调用Load方法,将角色文件路径传递给它。
最后,ID 计数器递增,并返回刚刚使用的实体 ID 以表示成功。如果在任何点上方法失败,它将返回-1,表示失败。
如果你不能获取实体,拥有实体管理器是没有意义的。这就是Find方法的作用:
EntityBase* EntityManager::Find(const std::string& l_name){
for(auto &itr : m_entities){
if(itr.second->GetName() == l_name){
return itr.second;
}
}
return nullptr;
}
我们的实体管理器提供了这个方法的两个版本。第一个版本接受一个实体名称,并在容器中搜索直到找到一个具有该名称的实体,此时它被返回。第二个版本根据数值标识符查找实体:
EntityBase* EntityManager::Find(unsigned int l_id){
auto itr = m_entities.find(l_id);
if (itr == m_entities.end()){ return nullptr; }
return itr->second;
}
由于我们将实体实例映射到数值,这更容易,因为我们只需调用我们的容器中的Find方法来找到我们正在寻找的元素。
现在我们来处理移除实体:
void EntityManager::Remove(unsigned int l_id){
m_entitiesToRemove.emplace_back(l_id);
}
这是一个公共方法,它接受一个实体 ID 并将其插入到容器中,该容器将用于稍后移除实体。
更新所有实体可以通过以下方式实现:
void EntityManager::Update(float l_dT){
for(auto &itr : m_entities){
itr.second->Update(l_dT);
}
EntityCollisionCheck();
ProcessRemovals();
}
管理器遍历其所有元素,并通过传递作为参数接收的 delta 时间调用它们各自的Update方法。在所有实体更新完毕后,将调用一个私有方法EntityCollisionCheck来检查和解决实体之间的碰撞。然后,我们处理由之前实现的Remove方法添加的实体移除。
让我们看看我们如何绘制所有这些实体:
void EntityManager::Draw(){
sf::RenderWindow* wnd = m_context->m_wind->GetRenderWindow();
sf::FloatRect viewSpace = m_context->m_wind->GetViewSpace();
for(auto &itr : m_entities){
if (!viewSpace.intersects(itr.second->m_AABB)){ continue; }
itr.second->Draw(wnd);
}
}
在获取到渲染窗口的指针后,我们也得到了它的视图空间,以便出于效率原因剪裁实体。因为实体的视图空间和边界框都是矩形,我们可以简单地检查它们是否相交,以确定实体是否在视图空间内,如果是的话,它就会被绘制。
实体管理器需要有一种方式来分配其所有资源。这就是Purge方法发挥作用的地方:
void EntityManager::Purge(){
for (auto &itr : m_entities){
delete itr.second;
}
m_entities.clear();
m_idCounter = 0;
}
实体被迭代,它们的动态内存被释放——就像时钟一样规律。现在来处理需要被移除的实体:
void EntityManager::ProcessRemovals(){
while(m_entitiesToRemove.begin() != m_entitiesToRemove.end()){
unsigned int id = m_entitiesToRemove.back();
auto itr = m_entities.find(id);
if(itr != m_entities.end()){
std::cout << "Discarding entity: "<< itr->second->GetId() << std::endl;
delete itr->second;
m_entities.erase(itr);
}
m_entitiesToRemove.pop_back();
}
}
当我们遍历包含需要移除的实体 ID 的容器时,会检查实体容器中是否存在每个添加的 ID。如果确实存在具有该 ID 的实体,其内存将被释放,并且元素将从实体容器中弹出。
现在是更有趣的部分——检测实体之间的碰撞:
void EntityManager::EntityCollisionCheck(){
if (m_entities.empty()){ return; }
for(auto itr = m_entities.begin();
std::next(itr) != m_entities.end(); ++itr)
{
for(auto itr2 = std::next(itr);
itr2 != m_entities.end(); ++itr2)
{
if(itr->first == itr2->first){ continue; }
// Regular AABB bounding box collision.
if(itr->second->m_AABB.intersects(itr2->second->m_AABB)){
itr->second->OnEntityCollision(itr2->second, false);
itr2->second->OnEntityCollision(itr->second, false);
}
EntityType t1 = itr->second->GetType();
EntityType t2 = itr2->second->GetType();
if (t1 == EntityType::Player || t1 == EntityType::Enemy){
Character* c1 = (Character*)itr->second;
if (c1->m_attackAABB.intersects(itr2->second->m_AABB)){
c1->OnEntityCollision(itr2->second, true);
}
}
if (t2 == EntityType::Player || t2 == EntityType::Enemy){
Character* c2 = (Character*)itr2->second;
if (c2->m_attackAABB.intersects(itr->second->m_AABB)){
c2->OnEntityCollision(itr->second, true);
}
}
}
}
}
首先,我们需要解决的是我们如何检查每个实体与每个其他实体的问题。当然,有更好的、更有效的方法来确定要检查哪些实体,而不仅仅是遍历所有实体,例如二叉空间划分。然而,鉴于我们项目的范围,那将是过度设计:
| "过早优化是编程中所有邪恶(至少是大多数邪恶)的根源。" | ||
|---|---|---|
| --唐纳德·克努特 |
话虽如此,我们将变得更聪明一些,而不仅仅是简单地迭代所有实体两次。因为检查实体 0 与实体 1 相同于检查实体 1 与 0,我们可以通过使用std::next来实现一个更高效的算法,它创建一个比提供的迭代器前移一个空间的迭代器,并在第二个循环中使用它。这创建了一个看起来像这样的检查模式:

这就是我们在游戏早期制作阶段需要的优化。
在遍历实体时,碰撞检查方法首先确保两个迭代器不共享相同的实体 ID,出于某种奇怪的原因。然后,它只是简单地检查我们感兴趣的两个实体的边界框之间的交集。如果有碰撞,则在两个实例中调用处理碰撞的方法,传递被碰撞的实体作为参数,以及作为第二个参数的false,以让实体知道这是一个简单的 AABB 碰撞。那是什么意思呢?嗯,一般来说,实体之间会有两种类型的碰撞:常规边界框碰撞和攻击碰撞。EntityBase类的子类,主要是Character实例,将必须保持另一个边界框以执行攻击,如图所示:

由于这并不复杂,我们可以继续实现实体管理器,直到我们不久后实现Character类。
由于只有Character类及其任何继承类将具有攻击边界框,因此有必要首先通过验证实体类型来检查我们是否正在处理一个Character实例。如果一个实体是Enemy或Player类型,则调用Character实例的OnEntityCollision方法,并传递与之碰撞的实体以及这次作为参数的布尔常量true,以指示攻击碰撞。
我们基本上已经完成了。让我们编写加载不同敌人类型的方法的代码,这些敌人类型可以解析像这样的文件:
|Name|CharFile|
Rat Rat.char
这是一个相当简单的格式。让我们读取它:
void EntityManager::LoadEnemyTypes(const std::string& l_name){
std::ifstream file;
... // Opening the file.
while(std::getline(file,line)){
if (line[0] == '|'){ continue; }
std::stringstream keystream(line);
std::string name;
std::string charFile;
keystream >> name >> charFile;
m_enemyTypes.emplace(name,charFile);
}
file.close();
}
这里没有什么是你以前没有见过的。两个字符串值被读取并存储在敌人类型容器中。这段简单的代码结束了我们对实体管理器类的兴趣。
使用实体构建角色
到目前为止,我们只有定义了一些抽象方法并提供操作它们的手段的实体,但没有可以在游戏世界中出现、渲染并四处走动的实体。同时,我们也不想在玩家或敌人类中重新实现所有这些功能,这意味着我们需要一个中间级别的抽象类:Character。这个类将提供所有需要在世界中移动并被渲染的实体之间共享的功能。让我们继续设计:
class Character : public EntityBase{
friend class EntityManager;
public:
Character(EntityManager* l_entityMgr);
virtual ~Character();
void Move(const Direction& l_dir);
void Jump();
void Attack();
void GetHurt(const int& l_damage);
void Load(const std::string& l_path);
virtual void OnEntityCollision(
EntityBase* l_collider, bool l_attack) = 0;
virtual void Update(float l_dT);
void Draw(sf::RenderWindow* l_wind);
protected:
void UpdateAttackAABB();
void Animate();
SpriteSheet m_spriteSheet;
float m_jumpVelocity;
int m_hitpoints;
sf::FloatRect m_attackAABB;
sf::Vector2f m_attackAABBoffset;
};
首先,让我们谈谈公共方法。移动、跳跃、攻击和受到伤害是游戏中每个角色-实体常见的动作。角色还必须被加载,以便提供正确的图形和属性,这些属性在每种敌人类型和玩家之间是不同的。所有从它派生的类都必须实现它们自己的处理与其他实体碰撞的版本。此外,角色类的Update方法被设置为虚拟的,这允许任何从该类继承的类定义自己的更新方法或扩展现有的方法。
所有角色都将使用我们之前设计的精灵图集类来支持动画。
实现角色类
你现在应该知道了。这是构造函数:
Character::Character(EntityManager* l_entityMgr)
:EntityBase(l_entityMgr),
m_spriteSheet(m_entityManager->GetContext()->m_textureManager),
m_jumpVelocity(250), m_hitpoints(5)
{ m_name = "Character"; }
精灵图集是通过在构造函数中传递指向纹理管理器的指针来创建和设置的。我们还有一个名为m_jumpVelocity的数据成员,它指定了玩家可以跳多远。最后,我们给m_hitpoints变量设置了一个任意值,它代表了实体在被击中多少次后才会死亡。
让我们继续到Move方法:
void Character::Move(const Direction& l_dir){
if (GetState() == EntityState::Dying){ return; }
m_spriteSheet.SetDirection(l_dir);
if (l_dir == Direction::Left){ Accelerate(-m_speed.x, 0); }
else { Accelerate(m_speed.x, 0); }
if (GetState() == EntityState::Idle){
SetState(EntityState::Walking);
}
}
无论实体的方向如何,都会检查实体的状态,以确保实体没有死亡。如果没有死亡,就会设置精灵图集的方向,并且角色开始在相关轴上加速。最后,如果实体目前处于空闲状态,它会被设置为行走状态,以便播放行走动画:
void Character::Jump(){
if (GetState() == EntityState::Dying || GetState() == EntityState::Jumping || GetState() == EntityState::Hurt)
{
return;
}
SetState(EntityState::Jumping);
AddVelocity(0, -m_jumpVelocity);
}
一个角色只有在没有死亡、受到伤害或正在跳跃的情况下才能跳跃。当这些条件满足,并且角色被指示跳跃时,其状态被设置为Jumping,并在 y 轴上获得负速度,使其对抗重力并向上移动。速度必须足够高,才能打破该级别的重力。
攻击相当直接。因为实体管理器已经为我们做了碰撞检测,所以剩下的只是设置状态,如果实体没有死亡、跳跃、受到伤害或正在攻击的话:
void Character::Attack(){
if (GetState() == EntityState::Dying ||
GetState() == EntityState::Jumping ||
GetState() == EntityState::Hurt ||
GetState() == EntityState::Attacking)
{
return;
}
SetState(EntityState::Attacking);
}
为了赋予我们的实体生命,它们需要有受伤的方式:
void Character::GetHurt(const int& l_damage){
if (GetState() == EntityState::Dying ||
GetState() == EntityState::Hurt)
{
return;
}
m_hitpoints = (m_hitpoints - l_damage > 0 ?
m_hitpoints - l_damage : 0);
if (m_hitpoints){ SetState(EntityState::Hurt); }
else { SetState(EntityState::Dying); }
}
如果角色尚未受到伤害或死亡,此方法会对角色造成伤害。伤害值要么从生命值中减去,要么将生命值变量设置为0,以防止其达到负值。如果减去伤害后实体仍有生命,其状态将被设置为HURT,以便播放正确的动画。否则,程序员将实体判处死刑。
如前所述,我们希望能够从像这样的文件中加载我们的角色(Player.char):
Name Player
Spritesheet Player.sheet
Hitpoints 5
BoundingBox 20 26
DamageBox -5 0 26 26
Speed 1024 128
JumpVelocity 250
MaxVelocity 200 1024
它包含了构成角色的所有基本组成部分,如精灵表句柄以及在前几节中讨论的所有其他信息。此类文件的加载方法与我们已实现的那些方法不会有太大差异:
void Character::Load(const std::string& l_path){
std::ifstream file;
...
while(std::getline(file,line)){
...
std::string type;
keystream >> type;
if(type == "Name"){
keystream >> m_name;
} else if(type == "Spritesheet"){
std::string path;
keystream >> path;
m_spriteSheet.LoadSheet("media/SpriteSheets/" + path);
} else if(type == "Hitpoints"){
keystream >> m_hitpoints;
} else if(type == "BoundingBox"){
sf::Vector2f boundingSize;
keystream >> boundingSize.x >> boundingSize.y;
SetSize(boundingSize.x, boundingSize.y);
} else if(type == "DamageBox"){
keystream >> m_attackAABBoffset.x >> m_attackAABBoffset.y
>> m_attackAABB.width >> m_attackAABB.height;
} else if(type == "Speed"){
keystream >> m_speed.x >> m_speed.y;
} else if(type == "JumpVelocity"){
keystream >> m_jumpVelocity;
} else if(type == "MaxVelocity"){
keystream >> m_maxVelocity.x >> m_maxVelocity.y;
} else {
std::cout << "! Unknown type in character file: "
<< type << std::endl;
}
}
file.close();
}
除了精灵表需要调用一个加载方法外,其余的只是从字符串流中加载数据成员。
就像基础实体及其边界框一样,角色必须有一种方法来更新其攻击区域的位置:
void Character::UpdateAttackAABB(){
m_attackAABB.left =
(m_spriteSheet.GetDirection() == Direction::Left ?
(m_AABB.left - m_attackAABB.width) - m_attackAABBoffset.x
: (m_AABB.left + m_AABB.width) + m_attackAABBoffset.x);
m_attackAABB.top = m_AABB.top + m_attackAABBoffset.y;
}
这里的一个细微差别是,攻击边界框使用的是实体边界框的位置,而不是其精灵位置。此外,其定位方式根据实体面对的方向而不同,因为边界框的位置代表其左上角。
现在是时候介绍将带来最大视觉差异的方法了:
void Character::Animate(){
EntityState state = GetState();
if(state == EntityState::Walking && m_spriteSheet.
GetCurrentAnim()->GetName() != "Walk")
{
m_spriteSheet.SetAnimation("Walk",true,true);
}
else if(state == EntityState::Jumping && m_spriteSheet.
GetCurrentAnim()->GetName() != "Jump")
{
m_spriteSheet.SetAnimation("Jump",true,false);
}
else if(state == EntityState::Attacking && m_spriteSheet.
GetCurrentAnim()->GetName() != "Attack")
{
m_spriteSheet.SetAnimation("Attack",true,false);
} else if(state == EntityState::Hurt && m_spriteSheet.
GetCurrentAnim()->GetName() != "Hurt")
{
m_spriteSheet.SetAnimation("Hurt",true,false);
}
else if(state == EntityState::Dying && m_spriteSheet.
GetCurrentAnim()->GetName() != "Death")
{
m_spriteSheet.SetAnimation("Death",true,false);
}
else if(state == EntityState::Idle && m_spriteSheet.
GetCurrentAnim()->GetName() != "Idle")
{
m_spriteSheet.SetAnimation("Idle",true,true);
}
}
它所做的只是简单地检查当前状态和当前动画。如果当前动画与当前状态不匹配,它将被设置为其他内容。注意SetAnimation方法中的第三个参数的使用,它是一个布尔常量,代表动画循环。某些动画不需要循环,如攻击或受伤动画。它们不循环并在达到最后一帧时停止,这为我们提供了一个钩子,可以根据特定动画的进度来操纵游戏中的发生的事情。以Update方法为例:
void Character::Update(float l_dT){
EntityBase::Update(l_dT);
if(m_attackAABB.width != 0 && m_attackAABB.height != 0){
UpdateAttackAABB();
}
if(GetState() != EntityState::Dying && GetState() !=
EntityState::Attacking && GetState() != EntityState::Hurt)
{
if(abs(m_velocity.y) >= 0.001f){
SetState(EntityState::Jumping);
} else if(abs(m_velocity.x) >= 0.1f){
SetState(EntityState::Walking);
} else {
SetState(EntityState::Idle);
}
} else if(GetState() == EntityState::Attacking ||
GetState() == EntityState::Hurt)
{
if(!m_spriteSheet.GetCurrentAnim()->IsPlaying()){
SetState(EntityState::Idle);
}
} else if(GetState() == EntityState::Dying){
if(!m_spriteSheet.GetCurrentAnim()->IsPlaying()){
m_entityManager->Remove(m_id);
}
}
Animate();
m_spriteSheet.Update(l_dT);
m_spriteSheet.SetSpritePosition(m_position);
}
首先,我们调用实体基类的更新方法,因为角色的状态依赖于它。然后,我们检查攻击边界框的宽度和高度是否仍然为 0,这是它们的默认值。如果不是,这意味着攻击边界框已经设置好,可以更新。更新方法的其余部分基本上只是处理状态转换。如果实体没有死亡、攻击某物或受到伤害,其当前状态将由其速度决定。为了准确地描绘实体下落,我们必须使 y 轴上的速度优先于其他所有因素。如果实体没有垂直速度,则检查水平速度,如果速度高于指定的最小值,则将状态设置为Walking。使用小值而不是绝对零值可以解决动画有时会抖动的问题。
由于攻击和受到伤害的状态没有设置为循环,因此会检查精灵表动画,以查看它是否仍在播放。如果没有播放,状态会切换回空闲状态。最后,如果实体正在死亡并且死亡动画播放完毕,我们会调用实体管理器的 Remove 方法,以便从世界中移除这个实体。
Animate 方法在更新接近结束时被调用,以便反映可能发生的状态变化。此外,这也是精灵表更新并设置其位置以匹配实体位置的地方。
在所有这些代码之后,让我们以一个真正简单的东西结束——Draw 方法:
void Character::Draw(sf::RenderWindow* l_wind){
m_spriteSheet.Draw(l_wind);
}
由于我们的精灵表类负责绘制,我们只需要传递渲染窗口的指针到其 Draw 方法。
创建玩家
现在我们已经为在屏幕上可视化的实体创建了一个坚实的基础。让我们充分利用它,并从开始构建玩家类,从头文件开始:
class Player : public Character{
public:
Player(EntityManager* l_entityMgr);
~Player();
void OnEntityCollision(EntityBase* l_collider, bool l_attack);
void React(EventDetails* l_details);
};
这里事情变得简单起来。因为我们基本上将大部分通用功能外包给了基类,现在我们只剩下玩家特定的逻辑。注意 React 方法。根据其参数列表,很明显我们将将其用作处理玩家输入的回调。然而,在我们这样做之前,我们必须将此方法注册为:
Player::Player(EntityManager* l_entityMgr)
: Character(l_entityMgr)
{
Load("Player.char");
m_type = EntityType::Player;
EventManager* events = m_entityManager->
GetContext()->m_eventManager;
events->AddCallback<Player>(StateType::Game,
"Player_MoveLeft", &Player::React, this);
events->AddCallback<Player>(StateType::Game,
"Player_MoveRight", &Player::React, this);
events->AddCallback<Player>(StateType::Game,
"Player_Jump", &Player::React, this);
events->AddCallback<Player>(StateType::Game,
"Player_Attack", &Player::React, this);
}
我们在这里所做的只是调用 Load 方法来设置玩家的角色值,并向将用于处理键盘输入的同一 React 方法添加多个回调。实体的类型也被设置为 Player:
Player::~Player(){
EventManager* events = m_entityManager->GetContext()->m_eventManager;
events->RemoveCallback(GAME,"Player_MoveLeft");
events->RemoveCallback(GAME,"Player_MoveRight");
events->RemoveCallback(GAME,"Player_Jump");
events->RemoveCallback(GAME,"Player_Attack");
}
析构函数,不出所料,只是简单地移除了我们用来移动玩家的回调函数。
我们需要通过 Character 类实现的最后一个方法是负责实体之间的碰撞:
void Player::OnEntityCollision(EntityBase* l_collider,
bool l_attack)
{
if (m_state == EntityState::Dying){ return; }
if(l_attack){
if (m_state != EntityState::Attacking){ return; }
if (!m_spriteSheet.GetCurrentAnim()->IsInAction()){ return; }
if (l_collider->GetType() != EntityType::Enemy &&
l_collider->GetType() != EntityType::Player)
{
return;
}
Character* opponent = (Character*)l_collider;
opponent->GetHurt(1);
if(m_position.x > opponent->GetPosition().x){
opponent->AddVelocity(-32,0);
} else {
opponent->AddVelocity(32,0);
}
} else {
// Other behavior.
}
}
这个方法,正如你从本章的实体管理部分所记得的,当某个实体与这个特定实体发生碰撞时会被调用。在碰撞的情况下,另一个发生碰撞的实体作为参数传递给这个方法,同时还有一个标志来确定实体是否与你的边界框或攻击区域发生碰撞。
首先,我们确保玩家实体没有死亡。然后,我们检查是否是攻击区域与另一个实体发生碰撞。如果是,并且玩家处于攻击状态,我们检查精灵表中的攻击动画是否目前正在“进行中”。如果当前帧在动作应该发生时的开始和结束帧的范围内,最后的检查是确定实体是玩家还是敌人。最后,如果是其中之一,对手会受到预先确定的伤害值,并根据其位置添加一些速度以产生击退效果。这就是最基本的游戏设计。
添加敌人
为了让我们的玩家不会在世界中孤独地行走且不受攻击,我们必须在游戏中添加敌人。再次,让我们从头文件开始:
#pragma once
#include "Character.h"
class Enemy : public Character{
public:
Enemy(EntityManager* l_entityMgr);
~Enemy();
void OnEntityCollision(EntityBase* l_collider, bool l_attack);
void Update(float l_dT);
private:
sf::Vector2f m_destination;
bool m_hasDestination;
};
这里的基本想法与玩家类中的相同。然而,这次敌人类需要指定它自己的Update方法版本。它还有两个私有数据成员,其中一个是一个目标向量。这是一个非常简单的尝试,为游戏添加基本的人工智能。它只会跟踪一个目标位置,而Update方法会不时随机化这个位置以模拟游荡的实体。让我们来实现它:
Enemy::Enemy(EntityManager* l_entityMgr)
:Character(l_entityMgr), m_hasDestination(false)
{
m_type = EntityType::Enemy;
}
Enemy::~Enemy(){}
构造函数只是将一些数据成员初始化为其默认值,而析构函数仍然未使用。到目前为止,一切顺利!
void Enemy::OnEntityCollision(EntityBase* l_collider,
bool l_attack)
{
if (m_state == EntityState::Dying){ return; }
if (l_attack){ return; }
if (l_collider->GetType() != EntityType::Player){ return; }
Character* player = (Character*)l_collider;
SetState(EntityState::Attacking);
player->GetHurt(1);
if(m_position.x > player->GetPosition().x){
player->AddVelocity(-m_speed.x,0);
m_spriteSheet.SetDirection(Direction::Left);
} else {
player->AddVelocity(m_speed.y,0);
m_spriteSheet.SetDirection(Direction::Right);
}
}
实体碰撞方法也非常相似,但这次我们确保只有在敌人的边界框与另一个实体碰撞时才采取行动,而不是它的攻击区域。此外,我们忽略每一次碰撞,除非它与玩家实体碰撞,在这种情况下,敌人的状态被设置为Attacking以显示攻击动画。它对玩家造成1点的伤害,并根据实体的位置将其击退一小点。精灵图的方向也根据敌人实体相对于其攻击的位置来设置。
现在,来更新我们的敌人:
void Enemy::Update(float l_dT){
Character::Update(l_dT);
if (m_hasDestination){
if (abs(m_destination.x - m_position.x) < 16){
m_hasDestination = false;
return;
}
if (m_destination.x - m_position.x > 0){
Move(Direction::Right);
} else { Move(Direction::Left); }
if (m_collidingOnX){ m_hasDestination = false; }
return;
}
int random = rand() % 1000 + 1;
if (random != 1000){ return; }
int newX = rand() % 65 + 0;
if (rand() % 2){ newX = -newX; }
m_destination.x = m_position.x + newX;
if (m_destination.x < 0){ m_destination.x = 0; }
m_hasDestination = true;
}
因为这依赖于Character类的功能,我们在做任何事情之前首先调用它的更新方法。然后,最基础的 AI 模拟首先检查实体是否有目标。如果没有,则在 1 到 1000 之间生成一个随机数。它有 1/1000 的机会将其目标位置设置为当前位置 128 像素内的任何地方。方向由另一个随机数生成决定,但这次要小得多。目标最终被设置并检查是否超出世界边界。
另一方面,如果实体确实有一个目标,则检查它与当前位置之间的距离。如果它大于 16,则根据目标点所在的方向调用适当的移动方法。我们还必须检查水平碰撞,因为敌人实体可能会被分配一个它无法跨越的瓦片之外的目标。如果发生这种情况,目标就会被简单地移除。
完成这些后,我们现在有了游荡的实体和可以在世界中移动的玩家!现在要真正将这些实体引入游戏,唯一剩下的事情就是加载它们。
从地图文件加载实体
如果您还记得本章中关于创建地图类的问题部分,我们还没有完全实现加载方法,因为我们当时还没有实体。既然这种情况已经不再存在,让我们来看看如何扩展它:
} else if(type == "PLAYER"){
if (playerId != -1){ continue; }
// Set up the player position here.
playerId = entityMgr->Add(EntityType::Player);
if (playerId < 0){ continue; }
float playerX = 0; float playerY = 0;
keystream >> playerX >> playerY;
entityMgr->Find(playerId)->SetPosition(playerX,playerY);
m_playerStart = sf::Vector2f(playerX, playerY);
} else if(type == "ENEMY"){
std::string enemyName;
keystream >> enemyName;
int enemyId = entityMgr->Add(EntityType::Enemy, enemyName);
if (enemyId < 0){ continue; }
float enemyX = 0; float enemyY = 0;
keystream >> enemyX >> enemyY;
entityMgr->Find(enemyId)->SetPosition(enemyX, enemyY);
} ...
如果地图遇到PLAYER行,它将尝试添加一个类型为Player的实体并获取其 ID。如果它大于或等于 0,则实体创建成功,这意味着我们可以从地图文件中读取其余的数据,这恰好是玩家位置。获取后,我们设置玩家的位置并确保在地图类本身中也跟踪起始位置。
所有上述内容对于ENEMY行也是正确的,只是它还加载了实体的名称,这是从文件中加载其角色信息所必需的。
现在游戏能够从地图文件中加载实体并将它们放入游戏世界,如下所示:

对代码库的最终修订
在本章的最后部分,我们将介绍为了实现这一点而做出的所有小改动和添加/修订,从共享上下文开始,现在它已经移动到了自己的头文件中。
共享上下文的变化
在我们定义的所有额外类中,其中一些需要可供代码库的其余部分访问。这就是现在共享上下文结构的样子:
class Map;
struct SharedContext{
SharedContext():
m_wind(nullptr),
m_eventManager(nullptr),
m_textureManager(nullptr),
m_entityManager(nullptr),
m_gameMap(nullptr){}
Window* m_wind;
EventManager* m_eventManager;
TextureManager* m_textureManager;
EntityManager* m_entityManager;
Map* m_gameMap;
DebugOverlay m_debugOverlay;
};
其中的最后一个对象是我们之前在处理基础实体类时简要讨论的调试覆盖层,它通过为实体碰撞的瓦片、扭曲瓦片和尖刺瓦片提供覆盖图形,帮助我们通过实体边界框等的视觉表示来查看游戏中的情况。由于调试代码对于本章不是必需的,因此没有将其片段包含在这里,但它们存在于附带的代码中。
将所有部件组合在一起
接下来,我们需要将我们辛苦工作的代码实例放在正确的位置,首先是实体管理器类,它直接作为数据成员进入游戏类:
class Game{
public:
...
private:
...
EntityManager m_entityManager;
};
地图类实例保留在游戏状态类中:
class State_Game : public BaseState{
public:
...
private:
...
Map* m_gameMap;
};
主游戏状态还负责设置自己的视图并放大到足以使游戏看起来更有吸引力且不太可能导致眯眼,更不用说初始化和加载地图:
void State_Game::OnCreate(){
...
sf::Vector2u size = m_stateMgr->GetContext()->m_wind->GetWindowSize();
m_view.setSize(size.x,size.y);
m_view.setCenter(size.x/2,size.y/2);
m_view.zoom(0.6f);
m_stateMgr->GetContext()->m_wind->GetRenderWindow()->setView(m_view);
m_gameMap = new Map(m_stateMgr->GetContext(), this);
m_gameMap->LoadMap("media/Maps/map1.map");
}
由于地图是动态分配的,必须在游戏状态的OnDestroy方法中删除它:
void State_Game::OnDestroy(){
...
delete m_gameMap;
m_gameMap = nullptr;
}
接下来是拼图中最后一块——游戏状态更新方法:
void State_Game::Update(const sf::Time& l_time){
SharedContext* context = m_stateMgr->GetContext();
EntityBase* player = context->m_entityManager->Find("Player");
if(!player){
std::cout << "Respawning player..." << std::endl;
context->m_entityManager->Add(EntityType::Player,"Player");
player = context->m_entityManager->Find("Player");
player->SetPosition(m_gameMap->GetPlayerStart());
} else {
m_view.setCenter(player->GetPosition());
context->m_wind->GetRenderWindow()->setView(m_view);
}
sf::FloatRect viewSpace = context->m_wind->GetViewSpace();
if(viewSpace.left <= 0){
m_view.setCenter(viewSpace.width / 2,m_view.getCenter().y);
context->m_wind->GetRenderWindow()->setView(m_view);
} else if (viewSpace.left + viewSpace.width >
(m_gameMap->GetMapSize().x + 1) * Sheet::Tile_Size)
{
m_view.setCenter(((m_gameMap->GetMapSize().x + 1) *
Sheet::Tile_Size) - (viewSpace.width / 2),
m_view.getCenter().y);
context->m_wind->GetRenderWindow()->setView(m_view);
}
m_gameMap->Update(l_time.asSeconds());
m_stateMgr->GetContext()->
m_entityManager->Update(l_time.asSeconds());
}
首先,我们通过按名称搜索玩家来确定玩家是否仍然存活于游戏中。如果找不到玩家,他们肯定已经死亡,因此需要重生。创建一个新的玩家实体并将地图的起始坐标传递给其SetPosition方法。
现在是管理视图滚动方式的部分。如果玩家实体存在,我们将视图的中心设置为与玩家位置完全匹配,并使用共享上下文获取渲染窗口,该窗口将使用更新的视图。现在,我们遇到了屏幕离开地图边界的问题,这可以通过检查视图空间左上角来解决。如果它低于或等于零,我们将视图中心的 x 轴位置设置为将其左上角放置在屏幕边缘的位置,以防止无限地向左滚动。然而,如果视图在地图的相反方向外,视图中心的 x 坐标被设置为使其右侧也位于地图边界的最边缘。
最后,游戏地图以及实体管理器在这里进行了更新,因为我们不希望地图更新或实体移动,如果当前状态不同的话。
摘要
恭喜你完成了本书的一半!所有编写的代码、设计决策、考虑效率以及反复试验都把你带到了这个阶段。虽然我们构建的游戏相当基础,但其架构也非常稳健和可扩展,这可不是一件小事。尽管其中的一些事情可能并不完美,但你也已经遵循了先让它工作起来,然后再进行优化的黄金法则,现在你已经有了一些游戏设计模式,可以开始构建更复杂的应用程序,以及一个坚实的代码库来扩展和改进。
随着本章的结束,本书的第二个项目正式完成。我们解决了一些相当棘手的问题,编写了数千行代码,并将我们对游戏开发过程的理解扩展到了狭隘、幼稚的幻想阶段之外。但真正的冒险还在前方等着我们。我们可能不知道它最终会引导我们走向何方,但有一点是肯定的:现在绝不是停下脚步的时候。下一章见。
第八章. 知识越多越好 – 常见游戏编程模式
随着我们这本书过半,我们游戏中的功能和装饰将越来越复杂。为了正确展示它们,我们最终项目的类型将是一个经典的二维 角色扮演游戏,采用正交投影。随着我们的代码库以快速的速度增长,糟糕的设计很快就会变得难以维护,甚至难以管理。随着新功能的添加,我们希望代码的扩展变得容易,而不是减慢整体过程。这正是游戏编程模式最闪耀的地方。
在本章中,我们将涵盖:
-
实体组件系统的设计和实现
-
使用观察者模式进行跨系统通信
-
渲染顺序
-
地图层的实现
让我们不要浪费时间,直接进入使我们的代码库更健壮的步骤!
版权资源的利用
再次提醒,在开始本章之前,我们希望对应该给予的赞誉给予应有的认可。本书第三个项目的图形资源包括但不限于:
由 wulax 根据 CC-BY-SA 3.0 和 GPL 3.0 许可证提供的 [LPC] 中世纪幻想角色精灵:
opengameart.org/content/lpc-medieval-fantasy-character-sprites
由 Hyptosis 提供 大量免费的 2D 瓦片和精灵,根据 CC-BY 3.0 许可证:
opengameart.org/content/lots-of-free-2d-tiles-and-sprites-by-hyptosis
适用于这些资源使用的所有许可证都可以在这里找到:
什么是编程模式?
编程模式,或称为设计模式,是针对特定问题的可重用和广泛实施的解决方案。这并不是说这些模式作为某种库存在,尽管基于它们的库是存在的。相反,编程模式更多的是一种想法或策略。它是对解决某个问题的精心设计的计划,是对给定问题情境的最佳可能答案,这是经过时间和经验证明的,这也是它们应该被使用的一个最好的理由。
现在有很多设计模式,以及书籍、教程甚至专门用于理解和实现它们的课程。为了我们的目的,我们将介绍四种:实体组件系统、事件队列、观察者和工厂模式。我们将分别讨论每一个,尽管它们在功能上不重叠,但它们可以一起工作。
实体组件系统
实体组件系统是一种编程模式,它允许实体通过组合的方式拥有属性和功能,而不是通过继承。使用这种模式的最大好处包括逻辑更强的解耦、更容易的实体序列化和反序列化、更好的代码重用性以及创建新实体的简便性。然而,它确实会给你的代码库增加相当多的复杂性。
这种模式的典型实现包括三个部分:
-
实体:在大多数情况下,实体几乎只是标识符,被贴在一系列组件上
-
组件是实体的构建块,它们不过是数据的集合
-
系统:这些是专门处理非常特定任务的类,并负责持有这个范式中的所有逻辑
除了处理这三种不同的元素类型外,我们的实体组件系统还需要一个实体管理器来保存和管理所有实体和组件数据,以及系统管理器,它将负责更新每个系统,此外还有一些我们很快会介绍的功能。
为了区分不同类型的组件和系统,我们将创建一个新的头文件,ECS_Types.h,该文件将用于存储这些信息:
using ComponentType = unsigned int;
#define N_COMPONENT_TYPES 32
enum class Component{
Position = 0, SpriteSheet, State, Movable, Controller, Collidable
};
enum class System{
Renderer = 0, Movement, Collision, Control, State, SheetAnimation
};
除了组件和系统枚举之外,我们还别名一个无符号整数作为组件类型,并定义了一个宏N_COMPONENT_TYPES,它代表我们可以拥有的最大组件类型数。
什么是组件?
在实体组件系统范式内,组件是实体的最小、非重叠的部分,例如其位置、速度或精灵。然而,从编程的角度来看,它不过是一个简单的数据结构,其中没有任何真正的逻辑。它的唯一任务是存储它所代表的实体特征信息,如下所示:

为了轻松存储组件,它们必须依赖于继承原则。让我们看看一个基组件类的定义:
class C_Base{
public:
C_Base(const Component& l_type): m_type(l_type){}
virtual ~C_Base(){}
Component GetType(){ return m_type; }
friend std::stringstream& operator >>(
std::stringstream& l_stream, C_Base& b)
{
b.ReadIn(l_stream);
return l_stream;
}
virtual void ReadIn(std::stringstream& l_stream) = 0;
protected:
Component m_type;
};
我们组件基类的构造函数将接受它所代表的组件类型。需要注意的是重载的>>运算符,它调用一个纯虚函数ReadIn。这提供了一种快速从文件中读取组件数据的方法。因为每个组件都是唯一的,它定义了自己的ReadIn方法版本,以便正确地加载数据。
位置组件
将基组件类投入实际应用的一个好例子是实现第一种也是最具普遍性的组件类型:位置。
class C_Position : public C_Base{
public:
C_Position(): C_Base(Component::Position), m_elevation(0){}
~C_Position(){}
void ReadIn(std::stringstream& l_stream){
l_stream >> m_position.x >> m_position.y >> m_elevation;
}
const sf::Vector2f& GetPosition(){ return m_position; }
const sf::Vector2f& GetOldPosition(){ return m_positionOld; }
unsigned int GetElevation(){ return m_elevation; }
void SetPosition(float l_x, float l_y){
m_positionOld = m_position;
m_position = sf::Vector2f(l_x,l_y);
}
void SetPosition(const sf::Vector2f& l_vec){
m_positionOld = m_position;
m_position = l_vec;
}
void SetElevation(unsigned int l_elevation){
m_elevation = l_elevation;
}
void MoveBy(float l_x, float l_y){
m_positionOld = m_position;
m_position += sf::Vector2f(l_x,l_y);
}
void MoveBy(const sf::Vector2f& l_vec){
m_positionOld = m_position;
m_position += l_vec;
}
private:
sf::Vector2f m_position;
sf::Vector2f m_positionOld;
unsigned int m_elevation;
};
我们组件基类的构造函数在初始化列表中被调用,组件类型作为唯一参数传递。虽然还有更好的方法为单个组件类型分配它们自己的唯一标识符,但为了清晰起见,最好从简单开始。
这个组件跟踪三份数据:其实际位置、前一个周期时的位置以及实体的当前高度,这是一个表示实体相对于地图高度的值。
就像本章后面将要介绍的其他任何组件一样,它提供了一系列修改和获取其数据成员的方法。虽然公开其数据成员是完全可以接受的,但提供辅助方法可以减少代码冗余并提供一个熟悉的接口。
最后,注意ReadIn方法的实现。它使用一个stringstream对象作为参数,并从中加载相关的数据。
位掩码
拥有一个轻量级、易于使用且易于扩展的数据结构,它可以表示任何给定实体的组成,以及系统强加的一组要求,这可以节省很多麻烦。对我们来说,这个数据结构是一个位掩码。
提示
标准模板库提供了一个自己的位掩码版本:std::bitset。出于教育目的,我们将实现这个类的自己的版本。
如您可能已经知道,在二进制中,任何和所有数字都可以表示为零和一的组合。然而,谁又能说这两个值只能用来表示一个数字呢?通过一些快速的位运算符魔法,任何简单的整数都可以转换成一系列连续的标志,这些标志代表实体的不同方面,例如它具有哪些组件,或者它需要具有哪些类型的组件才能属于一个系统。
考虑以下插图:

实际上的唯一真正区别是可用的标志比八个多得多。让我们开始编码:
#include <stdint.h>
using Bitset = uint32_t;
class Bitmask{
public:
Bitmask() : bits(0){}
Bitmask(const Bitset& l_bits) : bits(l_bits){}
Bitset GetMask() const{ return bits; }
void SetMask(const Bitset& l_value){ bits = l_value; }
bool Matches(const Bitmask& l_bits,
const Bitset& l_relevant = 0)const
{
return(l_relevant ?
((l_bits.GetMask() & l_relevant) == (bits & l_relevant))
:(l_bits.GetMask() == bits));
}
bool GetBit(const unsigned int& l_pos)const{
return ((bits&(1 << l_pos)) != 0);
}
void TurnOnBit(const unsigned int& l_pos){
bits |= 1 << l_pos;
}
void TurnOnBits(const Bitset& l_bits){
bits |= l_bits;
}
void ClearBit(const unsigned int& l_pos){
bits &= ~(1 << l_pos);
}
void ToggleBit(const unsigned int& l_pos){
bits ^= 1 << l_pos;
}
void Clear(){ bits = 0; }
private:
Bitset bits;
};
我们首先定义了 bitset 的数据类型,这个数据类型由stdint.h头文件友好地提供。正如其名所示,uint32_t类型正好是 32 位宽。使用这种类型,而不是,比如说,一个典型的整数,消除了跨平台差异的可能性。一个常规整数可能根据我们的代码在哪个平台上执行而占用更少或更多的内存。使用stdint.h头文件中的专用类型确保无论平台差异如何,都能得到相同的结果。
Bitmask类的大部分内容都是位运算,这是 C/C++背景的一个基本组成部分。如果您还不熟悉它们,那也不是世界末日,然而,在继续前进之前至少了解它们是如何工作的会更有益。
管理实体
现在我们已经定义了实体的构建块,是时候讨论存储和管理它们了。如前所述,目前实体只是一个单一的标识符。了解这一点后,我们可以开始塑造这种数据存储的方式,一如既往地,从定义要使用的数据类型开始:
using EntityId = unsigned int;
using ComponentContainer = std::vector<C_Base*>;
using EntityData = std::pair<Bitmask,ComponentContainer>;
using EntityContainer = std::unordered_map<EntityId,EntityData>;
using ComponentFactory = std::unordered_map<
Component,std::function<C_Base*(void)>>;
我们将要处理的第一种数据类型是实体标识符,它再次由一个无符号整数表示。接下来,需要一个容器来存储实体的所有组件。向量非常适合这个目的。随后,我们定义了一对,一个位掩码和组件容器,它们将存储有关实体的所有信息。位掩码在这里被用来减轻在容器中迭代以查找组件的需求,当可以快速查询以实现相同目的时。实体拼图的最后一部分是将实体标识符映射到所有其数据上,我们将使用unordered_map来完成这项工作。
为了尽可能少地编写代码来生成不同的组件类型,我们在这里也将使用我们信任的 lambda 表达式工厂方法。这里类型定义的最后四行使得这一点成为可能。
定义了所有数据类型后,我们终于可以查看实体管理器类的声明了:
class SystemManager;
class EntityManager{
public:
EntityManager(SystemManager* l_sysMgr,
TextureManager* l_textureMgr);
~EntityManager();
int AddEntity(const Bitmask& l_mask);
int AddEntity(const std::string& l_entityFile);
bool RemoveEntity(const EntityId& l_id);
bool AddComponent(const EntityId& l_entity,
const Component& l_component);
template<class T>
T* GetComponent(const EntityId& l_entity,
const Component& l_component){ ... }
bool RemoveComponent(const EntityId& l_entity,
const Component& l_component);
bool HasComponent(const EntityId& l_entity,
const Component& l_component);
void Purge();
private:
template<class T>
void AddComponentType(const Component& l_id){
m_cFactory[l_id] = []()->C_Base* { return new T(); };
}
// Data members
unsigned int m_idCounter;
EntityContainer m_entities;
ComponentFactory m_cFactory;
SystemManager* m_systems;
TextureManager* m_textureManager;
};
以相当可预测的方式,我们拥有任何其他作为容器存在的类中可能存在的所有方法。提供了两种不同的添加实体版本,一种基于作为参数传递的位掩码,另一种是从文件中加载实体配置。从特定实体获取组件的方法是模板化的,这减少了在类外编写以获取所需组件类型代码的数量。让我们看看它是如何实现的:
template<class T>
T* GetComponent(const EntityId& l_entity,
const Component& l_component)
{
auto itr = m_entities.find(l_entity);
if (itr == m_entities.end()){ return nullptr; }
// Found the entity.
if (!itr->second.first.GetBit((unsigned int)l_component))
{
return nullptr;
}
// Component exists.
auto& container = itr->second.second;
auto component = std::find_if(container.begin(),container.end(),
&l_component{
return c->GetType() == l_component;
});
return (component != container.end() ?
dynamic_cast<T*>(*component) : nullptr);
}
首先评估传递给方法中的实体参数,以确定是否存在具有提供标识符的实体。如果存在,则检查该实体的位掩码以验证请求类型的组件是否是其一部分。然后在该向量中定位组件,并以模板的动态类型返回。
实现实体管理器
在处理完类定义后,我们可以开始实现其方法。按照惯例,让我们首先处理实体管理器类的构造函数和析构函数:
EntityManager::EntityManager(SystemManager* l_sysMgr,
TextureManager* l_textureMgr): m_idCounter(0),
m_systems(l_sysMgr), m_textureManager(l_textureMgr)
{
AddComponentType<C_Position>(Component::Position);
AddComponentType<C_SpriteSheet>(Component::SpriteSheet);
AddComponentType<C_State>(Component::State);
AddComponentType<C_Movable>(Component::Movable);
AddComponentType<C_Controller>(Component::Controller);
AddComponentType<C_Collidable>(Component::Collidable);
}
EntityManager::~EntityManager(){ Purge(); }
构造函数接收一个指向SystemManager类的指针,我们将在不久后实现它,以及一个指向TextureManager的指针。在其初始化列表中,idCounter数据成员被设置为零。这是一个变量,将用于跟踪分配给实体的最后一个标识符。此外,系统管理器和纹理管理器指针被存储以供以后参考。构造函数的最后一个目的是将所有不同类型的组件添加到组件工厂中。
析构函数简单地调用一个Purge方法,该方法将用于清理所有动态分配的内存并清除此类中所有可能的容器。
int EntityManager::AddEntity(const Bitmask& l_mask){
unsigned int entity = m_idCounter;
if (!m_entities.emplace(entity,
EntityData(0,ComponentContainer())).second)
{ return -1; }
++m_idCounter;
for(unsigned int i = 0; i < N_COMPONENT_TYPES; ++i){
if(l_mask.GetBit(i)){ AddComponent(entity,(Component)i); }
}
// Notifying the system manager of a modified entity.
m_systems->EntityModified(entity,l_mask);
m_systems->AddEvent(entity,(EventID)EntityEvent::Spawned);
return entity;
}
在根据提供的位掩码添加实体的情况下,首先将新的实体对插入到实体容器中。如果插入成功,则for循环遍历所有可能的组件类型,并检查该类型的掩码。如果位掩码启用了该类型,则调用AddComponent方法。
在组件插入后,系统管理员被通知实体已被修改,或者在这种情况下,被插入。实体标识符以及该实体的位掩码被传递到系统管理员的EntityModified方法中。同时创建一个事件来提醒系统这个实体刚刚生成。
然后返回新创建的实体的标识符。如果方法未能添加实体,则返回-1,以表示错误。
删除实体同样简单,甚至可能更简单:
bool EntityManager::RemoveEntity(const EntityId& l_id){
auto itr = m_entities.find(l_id);
if (itr == m_entities.end()){ return false; }
// Removing all components.
while(itr->second.second.begin() != itr->second.second.end()){
delete itr->second.second.back();
itr->second.second.pop_back();
}
m_entities.erase(itr);
m_systems->RemoveEntity(l_id);
return true;
}
在实体容器中成功定位实体后,首先释放它所拥有的每个组件的动态分配的内存,然后从向量中删除组件。然后实体本身从实体容器中被删除,并且系统管理员被通知其删除。
bool EntityManager::AddComponent(const EntityId& l_entity,
const Component& l_component)
{
auto itr = m_entities.find(l_entity);
if (itr == m_entities.end()){ return false; }
if (itr->second.first.GetBit((unsigned int)l_component))
{
return false;
}
// Component doesn't exist.
auto itr2 = m_cFactory.find(l_component);
if (itr2 == m_cFactory.end()){ return false; }
// Component type does exist.
C_Base* component = itr2->second();
itr->second.second.emplace_back(component);
itr->second.first.TurnOnBit((unsigned int)l_component);
// Notifying the system manager of a modified entity.
m_systems->EntityModified(l_entity,itr->second.first);
return true;
}
向实体添加组件的过程首先是通过提供的标识符验证实体是否存在。如果存在,并且该实体尚未添加该类型的组件,则查询 lambda 函数容器以获取所需类型的组件。一旦为组件分配了内存,它就被推入组件向量中。然后修改位掩码以反映对实体的更改。系统管理员也会被通知这些更改。
预计在从实体中删除组件时,发生的过程非常相似:
bool EntityManager::RemoveComponent(const EntityId& l_entity,
const Component& l_component)
{
auto itr = m_entities.find(l_entity);
if (itr == m_entities.end()){ return false; }
// Found the entity.
if (!itr->second.first.GetBit((unsigned int)l_component))
{
return false;
}
// Component exists.
auto& container = itr->second.second;
auto component = std::find_if(container.begin(),container.end(),
&l_component{
return c->GetType() == l_component;
});
if (component == container.end()){ return false; }
delete (*component);
container.erase(component);
itr->second.first.ClearBit((unsigned int)l_component);
m_systems->EntityModified(l_entity, itr->second.first);
return true;
}
确认实体和组件都存在后,为组件分配的内存被释放,组件本身也被删除。位掩码也相应地修改以反映这些变化。就像之前一样,系统管理员需要知道实体是否被修改,因此调用EntityModified方法。
对于课外活动来说,有一个非常有用的方法是检查一个实体是否具有某种类型的组件:
bool EntityManager::HasComponent(const EntityId& l_entity,
const Component& l_component)
{
auto itr = m_entities.find(l_entity);
if (itr == m_entities.end()){ return false; }
return itr->second.first.GetBit((unsigned int)l_component);
}
它遵循之前的模式,首先检查实体是否存在,然后检查其位掩码以确定是否存在特定类型的组件。
清理时间到了。正确处理所有分配的资源留给Purge方法:
void EntityManager::Purge(){
m_systems->PurgeEntities();
for(auto& entity : m_entities){
for(auto &component : entity.second.second){delete component;}
entity.second.second.clear();
entity.second.first.Clear();
}
m_entities.clear();
m_idCounter = 0;
}
系统管理员首先被通知删除所有实体。在遍历存储中的所有实体时,它会释放每个组件的内存。然后清除组件容器。最后,清除实体容器本身,并将标识符计数器重置为 0。
工厂模式
对于像实体这样的复杂数据结构,程序员可能不会手动设置和初始化每个组件。快速设置具有任何组件排列的实体,并尽可能减少重复代码,这是这里的主要目标。幸运的是,存在一种编程模式可以解决这个特定问题。它简单地被称为工厂模式。
对于这种整洁模式的用法哲学相当简单。存在一个类,它包含一些抽象方法,这些方法接受一个或两个参数,这些参数与一些模糊的识别特性相关。然后,根据它所提供的信息,这个类生成一个或多个类,并返回它们的句柄,实际上消除了手动进行数据分配或成员初始化的部分。换句话说,它提供了一个蓝图,并根据它生成产品,因此得名“工厂”。这种功能已经通过基于位掩码创建实体来实现,然而,实际上并没有初始化任何数据,只是设置了默认值。要更纯净地设置这些实体,需要一个更详细的蓝图,所以为什么不使用文本文件呢?例如:
Name Player
Attributes 63
|Component|ID|Individual attributes|
Component 0 0 0 1
...
这种格式允许将实体的所有内容以纯文本形式存储为蓝图,并在任何时间加载以生成具有完全相同特性的任意数量的实体。让我们看看如何实现处理实体文件:
int EntityManager::AddEntity(const std::string& l_entityFile){
int EntityId = -1;
std::ifstream file;
file.open(Utils::GetWorkingDirectory() +
"media/Entities/" + l_entityFile + ".entity");
if (!file.is_open()){
std::cout << "! Failed to load entity: "
<< l_entityFile << std::endl;
return -1;
}
std::string line;
while(std::getline(file,line)){
if (line[0] == '|'){ continue; }
std::stringstream keystream(line);
std::string type;
keystream >> type;
if(type == "Name"){
} else if(type == "Attributes"){
if (EntityId != -1){ continue; }
Bitset set = 0;
Bitmask mask;
keystream >> set;
mask.SetMask(set);
EntityId = AddEntity(mask);
if(EntityId == -1){ return -1; }
} else if(type == "Component"){
if (EntityId == -1){ continue; }
unsigned int c_id = 0;
keystream >> c_id;
C_Base* component = GetComponent<C_Base>
(EntityId,(Component)c_id);
if (!component){ continue; }
keystream >> *component;
if(component->GetType() == Component::SpriteSheet){
C_SpriteSheet* sheet = (C_SpriteSheet*)component;
sheet->Create(m_textureManager);
}
}
}
file.close();
return EntityId;
}
加载实体文件与我们过去处理的其他文件没有太大区别。目前,读取实体名称尚未实现。属性行只是位掩码与已启用的所需组件的数值。一旦读取了这个值,我们就将其传递给AddEntity的其他版本,以创建它并确保所有组件都正确分配。
读取实际组件稍微复杂一些。首先,我们必须确保实体已经被创建。这意味着“属性”行必须在实体文件中各个组件数据之前。如果实体 ID 大于-1,我们继续读取组件 ID 并根据它获取实际对象。重载的>>运算符在这里很有用,因为它极大地简化了实际流式传输组件数据。
最后,由于资源管理的性质,必须检查组件类型,以便为其实例提供一个指向纹理管理器类的指针,如果需要的话。我们还没有创建这样的组件,然而其中之一将是代表某些实体的精灵图组件。
设计系统
在考虑了这一范式的数据方面之后,最后一个剩余的组件就是系统。正如其名称所暗示的,系统负责处理组件内部和之间的所有逻辑。从精灵渲染到碰撞检测等所有事情都是由各自的系统处理的,以确保游戏非重叠部分之间的完全分离。至少,在理想世界中应该是这样。然而,在现实中,尽管人们尽力解耦和分类逻辑或数据,但某些事物仍然保持松散的联系,这正是事物的本质。信息仍然需要在系统之间进行交换。某些功能也需要作为完全无关的系统动作的结果而被调用。简单来说,系统之间需要有一种方式来进行交流,而无需了解彼此的工作方式。
实体事件
处理系统间关系的一种相当简单且必要的做法是派发事件。每个其他系统都可以监听这些事件,并在发生特定事件时执行自己的逻辑,完全独立于其他一切。让我们看看可能的实体事件列表:
enum class EntityEvent{
Spawned, Despawned, Colliding_X, Colliding_Y,
Moving_Left, Moving_Right, Moving_Up, Moving_Down,
Elevation_Change, Became_Idle, Began_Moving
};
这应该能给你一个很好的概念,了解系统通信将如何进行。假设有一个实体正在向左移动。"移动系统"开始派发事件,表示它在运动。"动画系统"监听这些事件,并在接收到事件后,继续增加实体精灵表中的帧数。记住,所有这些逻辑块之间仍然是完全独立的。"移动系统"并没有增加实体精灵表中的帧数。它只是对所有其他系统说:“嗨,我正在将实体 x 向左移动”,而它们在监听并做出反应。听起来我们可以从"事件队列"中受益。
实体事件队列
事件队列是一种编程模式,用于解耦事件触发和实际处理的时间。下面的插图应该能够捕捉到它的本质:

队列被称为先进先出(First-In-First-Out)的数据容器。最早推入的数据最先被移除。这很好地满足了我们的需求。正如事件队列的定义所述,其事件是在与它们添加时完全不同的时间被处理的。考虑到这一点,让我们开始设计EventQueue类:
using EventID = unsigned int;
class EventQueue{
public:
void AddEvent(const EventID& l_event){m_queue.push(l_event);}
bool ProcessEvents(EventID& l_id){
if (m_queue.empty()){ return false; }
l_id = m_queue.front();
m_queue.pop();
return true;
}
void Clear(){ while(!m_queue.empty()){ m_queue.pop(); }}
private:
std::queue<EventID> m_queue;
};
事件标识符用一个无符号整数表示。为了存储实际的事件,我们将使用一个合适的队列容器。向其中添加一个事件就像向任何其他 STL 容器添加一样简单。这个类提供了一个方法,可以在 while 循环中使用,以简化事件处理。它返回一个布尔值,以便在事件队列空时退出循环,并且它的唯一参数是通过引用传递的,以便每次调用该方法时都可以修改它。这与 SFML 处理事件的方式类似。
基类
为了开始实现我们的系统,它们必须首先有一个共同的基类,这个基类不仅提供了一个必须实现的共同接口,还消除了代码冗余。就像我们构建的大多数其他类一样,它将有自己的数据类型定义:
using EntityList = std::vector<EntityId>;
using Requirements = std::vector<Bitmask>;
系统标识符,就像组件标识符一样,由一个无符号整数表示。所有实体标识符都将存储在一个向量容器中,就像要求位掩码一样。我们之所以想要超过一个要求位掩码,是为了能够定义属于同一系统的不同类型组件的组合。一个很好的例子是不同可绘制类型属于同一渲染系统。
让我们看看我们的系统基类的头文件:
class SystemManager;
class S_Base{
public:
S_Base(const System& l_id, SystemManager* l_systemMgr);
virtual ~S_Base();
bool AddEntity(const EntityId& l_entity);
bool HasEntity(const EntityId& l_entity);
bool RemoveEntity(const EntityId& l_entity);
System GetId();
bool FitsRequirements(const Bitmask& l_bits);
void Purge();
virtual void Update(float l_dT) = 0;
virtual void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event) = 0;
protected:
System m_id;
Requirements m_requiredComponents;
EntityList m_entities;
SystemManager* m_systemManager;
};
我们希望每个系统都有自己的更新方法,以及自己的事件处理版本。此外,希望系统可以访问它们自己的管理器。所有其他非系统特定的东西,比如检查要求位掩码,都由基类处理。
实现基类
因为所有系统都需要指向系统管理器的指针,所以存在交叉包含的问题。在类头文件之前进行前向声明,并在实现文件中包含系统管理器头文件,可以解决这个问题:
#include "System_Manager.h"
现在是时候开始实现方法的列表了,从构造函数和析构函数开始:
S_Base::S_Base(const System& l_id, SystemManager* l_systemMgr)
: m_id(l_id), m_systemManager(l_systemMgr){}
S_Base::~S_Base(){ Purge(); }
每个系统都必须有自己的标识符,就像所有组件一样。这个标识符通过构造函数的参数列表传递,同时传递一个指向系统管理器的指针。除了在初始化列表中将适当的数据成员设置为这些值之外,基类构造函数不做其他任何事情。
析构函数,遵循典型模式,调用Purge方法来进行清理。
bool S_Base::AddEntity(const EntityId& l_entity){
if (HasEntity(l_entity)){ return false; }
m_entities.emplace_back(l_entity);
return true;
}
向系统中添加实体相当简单。如果提供的标识符在该系统中不存在,它就直接被推入向量中。系统是如何确定它是否有具有该标识符的实体呢?让我们来看看:
bool S_Base::HasEntity(const EntityId& l_entity){
return std::find(m_entities.begin(),
m_entities.end(), l_entity) != m_entities.end();
}
利用std::find函数,我们可以将此方法总结为单行。移除实体也利用了类似的功能:
bool S_Base::RemoveEntity(const EntityId& l_entity){
auto entity = std::find_if(m_entities.begin(), m_entities.end(),
&l_entity{ return id = l_entity; });
if (entity == m_entities.end()){ return false; }
m_entities.erase(entity);
return true;
}
在这种情况下,我们使用std::find_if函数,它将谓词作为第三个参数。谓词只是另一个函数,它比较两个元素以找到匹配项。在这种情况下,我们简单地构造一个 lambda 函数,它接受一个EntityId并返回一个布尔值,这将告诉查找函数是否找到了匹配项。如果是,则移除该实体。
每个系统都必须确保一个实体拥有所有必需的组件才能将其添加进去。这正是这个方法发挥作用的地方:
bool S_Base::FitsRequirements(const Bitmask& l_bits){
return std::find_if(m_requiredComponents.begin(),
m_requiredComponents.end(), &l_bits{
return b.Matches(l_bits, b.GetMask());
}) != m_requiredComponents.end();
}
它接受一个位掩码作为参数,并利用相同的std::find_if函数与 lambda 一起定位匹配项。虽然很少的系统需要为其实际组件定义超过一个位掩码,但是当需要时,拥有这种功能总是很好的。
最后,这是清理的方法:
void S_Base::Purge(){ m_entities.clear(); }
由于这里没有实际分配动态内存,所以可以安全地清空容器中的所有实体标识符。
处理消息
实体事件,虽然对许多情况很有用,但并不是所有情况都适用。例如,使用事件队列在系统之间传递数据是不可能的。事件还被发送到每个系统,这可能是浪费的。为什么不有一个额外的通信方法,不仅能够携带数据,还允许系统选择他们想要接收的内容?实体组件系统消息正好满足这个目的,而且恰好还有一个编程模式,它允许轻松实现消息订阅方法。
观察者模式
如其名所示,观察者模式允许用户选择他们希望被通知的内容。换句话说,观察者在订阅希望接收的信息类型后会处于休眠状态,并且只有在遇到这些类型时才会被通知。让我们看看Observer基类的非常基本的实现:
class Observer{
public:
virtual ~Observer(){}
virtual void Notify(const Message& l_message) = 0;
};
Observer类只是一个接口,其继承者必须定义一个方法才能使用它。尽管看起来很简单,但如果没有它,我们游戏中许多期望的功能将无法实现。让我们看看这些观察者将收到什么通知:
using MessageType = unsigned int;
struct TwoFloats{ float m_x; float m_y; };
struct Message{
Message(const MessageType& l_type) : m_type(l_type){}
MessageType m_type;
int m_sender;
int m_receiver;
union{
TwoFloats m_2f;
bool m_bool;
int m_int;
};
};
除了包含发送者和接收者实体以及消息类型的信息外,它还使用了一个union来避免继承。这本质上意味着这个union中的所有数据成员将在内存中共享相同的空间,并且一次只能有一个有效。
拼图的最后一块是将所有可能的观察者包含在一个Communicator类中。为此,我们将使用一个向量:
using ObserverContainer = std::vector<Observer*>;
由于这个类具有相对简单的仅处理管理向量容器的函数,让我们从头到尾查看完整的类定义:
class Communicator{
public:
~Communicator(){ m_observers.clear(); }
bool AddObserver(Observer* l_observer){
if (HasObserver(l_observer)){ return false; }
m_observers.emplace_back(l_observer);
return true;
}
bool RemoveObserver(Observer* l_observer){
auto observer = std::find_if(m_observers.begin(),
m_observers.end(), &l_observer{
return o == l_observer; });
if (observer == m_observers.end()){ return false; }
m_observers.erase(observer);
return true;
}
bool HasObserver(const Observer* l_observer){
return (std::find_if(m_observers.begin(), m_observers.end(),
&l_observer{
return o == l_observer;
}) != m_observers.end());
}
void Broadcast(const Message& l_msg){
for(auto& itr : m_observers){ itr->Notify(l_msg); }
}
private:
ObserverContainer m_observers;
};
添加、删除和查找观察者的基本方法都是典型的。然而,有一点需要注意,那就是Broadcast方法,它只是调用了观察者的Notify方法,并传递了一个要发送的消息。
最后,真正使用观察者方法的代码量最少:
class S_Base : public Observer{ ... }
由于基本系统类有虚方法,它不需要实现自己的Notify版本。这将是所有从该类继承的系统的工作。
消息处理器类
我们已经拥有了构建统一消息系统的所有组件。让我们看看将用于存储消息订阅信息的数据类型:
using Subscribtions = std::unordered_map<
EntityMessage,Communicator>;
每种可能的消息类型都将有自己的通信者,用于将消息广播给所有观察者。使用unordered_map是表达这种关系的完美选择。
消息处理器是一个非常简单的类,让我们看看它的完整实现:
class MessageHandler{
public:
bool Subscribe(const EntityMessage& l_type
Observer* l_observer)
{
return m_communicators[l_type].AddObserver(l_observer);
}
bool Unsubscribe(const EntityMessage& l_type,
Observer* l_observer)
{
return m_communicators[l_type].RemoveObserver(l_observer);
}
void Dispatch(const Message& l_msg){
auto itr = m_communicators.find(
(EntityMessage)l_msg.m_type);
if (itr == m_communicators.end()){ return; }
itr->second.Broadcast(l_msg);
}
private:
Subscriptions m_communicators;
};
订阅和取消订阅消息类型只需通过操作无序映射数据容器即可完成。当消息被分发时,会在订阅容器中查询消息类型。如果找到,则使用传递的消息作为参数调用通信者的Broadcast方法。
到目前为止,你可能想知道我们将处理哪种类型的消息。让我们看看EntityMessages.h文件:
enum class EntityMessage{
Move, Is_Moving, State_Changed, Direction_Changed,
Switch_State, Attack_Action, Dead
};
通过简单地阅读消息类型的名称,消息系统的目的很快就会变得清晰。每一个都适合需要包含额外数据或仅适用于单个系统。
管理系统
最后,我们已经到达了实体组件系统路线的最后一站:处理系统本身。让我们快速回顾一下这个类的自定义数据类型:
using SystemContainer = std::unordered_map<System,S_Base*>;
using EntityEventContainer = std::unordered_map<
EntityId,EventQueue>;
第一个数据类型SystemContainer非常难以误解。使用无序映射将系统标识符链接到实际系统。这里第二个类型定义负责存储实体事件。它也使用无序映射并将实体标识符链接到EventQueue实例,这些实例都持有特定实体的所有事件,直到它们被处理。
是时候设计系统管理器类了:
class EntityManager;
class SystemManager{
public:
SystemManager();
~SystemManager();
void SetEntityManager(EntityManager* l_entityMgr);
EntityManager* GetEntityManager();
MessageHandler* GetMessageHandler();
template<class T>
T* GetSystem(const System& l_system){...}
void AddEvent(const EntityId& l_entity, const EventID& l_event);
void Update(float l_dT);
void HandleEvents();
void Draw(Window* l_wind, unsigned int l_elevation);
void EntityModified(const EntityId& l_entity,
const Bitmask& l_bits);
void RemoveEntity(const EntityId& l_entity);
void PurgeEntities();
void PurgeSystems();
private:
SystemContainer m_systems;
EntityManager* m_entityManager;
EntityEventContainer m_events;
MessageHandler m_messages;
};
如预期的那样,它需要添加和处理事件、更新和绘制系统、通知实体更改和删除请求的方法,以及获取它们的方法。获取特定系统的方式如下实现模板方法:
template<class T>
T* GetSystem(const System& l_system){
auto itr = m_systems.find(l_system);
return(itr != m_systems.end() ?
dynamic_cast<T*>(itr->second) : nullptr);
}
就像实体管理器获取组件的方法一样,这个方法依赖于模板和动态类型转换的使用,以获取正确形式的系统。
实现系统管理器
交叉包含问题再次浮现,因此我们必须通过在实现文件中使用前向声明和头文件包含来对抗它:
#include "Entity_Manager.h"
在完成这些之后,我们现在可以开始实现构造函数和析构函数:
SystemManager::SystemManager(): m_entityManager(nullptr){
m_systems[System::State] = new S_State(this);
m_systems[System::Control] = new S_Control(this);
m_systems[System::Movement] = new S_Movement(this);
m_systems[System::Collision] = new S_Collision(this);
m_systems[System::SheetAnimation] = new S_SheetAnimation(this);
m_systems[System::Renderer] = new S_Renderer(this);
}
SystemManager::~SystemManager(){
PurgeSystems();
}
构造函数在初始化它所持有的所有系统之前,设置了一个指向实体管理器类的指针。析构函数执行其通常的清理工作,这被委托给PurgeSystems方法。
因为系统管理器需要指向实体管理器的指针,反之亦然,所以首先实例化的那个不能简单地在其构造函数中获取其他类的指针,因此需要SetEntityManager方法:
void SystemManager::SetEntityManager(EntityManager* l_entityMgr){
if(!m_entityManager){ m_entityManager = l_entityMgr; }
}
对于这样一个应用广泛的类,它需要为其数据成员提供 getter 方法:
EntityManager* SystemManager::GetEntityManager(){
return m_entityManager;
}
MessageHandler* SystemManager::GetMessageHandler(){
return &m_messages;
}
这确保了所有系统都可以访问消息处理器以及实体处理器。
说到系统访问,它们还必须能够向任何实体添加事件:
void SystemManager::AddEvent(const EntityId& l_entity, const EventID& l_event)
{
m_events[l_entity].AddEvent(l_event);
}
在这里使用unordered_map结构真的让这个方法变得简单而整洁。实体标识符作为键,可以轻松访问其单独的事件队列并向其中添加内容。
如果我们想让那些系统运行,就需要一个更新循环:
void SystemManager::Update(float l_dT){
for(auto &itr : m_systems){
itr.second->Update(l_dT);
}
HandleEvents();
}
在这里,调用每个系统的更新方法,并传入经过的时间。在所有系统更新完毕后进行事件处理。现在是时候剖析那个方法了:
void SystemManager::HandleEvents(){
for(auto &event : m_events){
EventID id = 0;
while(event.second.ProcessEvents(id)){
for(auto &system : m_systems)
{
if(system.second->HasEntity(event.first)){
system.second->HandleEvent(event.first,(EntityEvent)id);
}
}
}
}
}
我们首先遍历不同实体的事件队列。设置了一个事件标识符变量,并在while循环中通过引用使用它,以从队列中获取信息。遍历管理器中的每个系统,并检查是否有感兴趣的实体。如果有,就调用系统的HandleEvent方法,并传入相关信息。总结来说,这就是在更大规模上完成事件管理。现在每个系统只需要担心它想要处理哪些事件以及如何响应它们。
为了在屏幕的黑暗空间中填充实体,我们需要一个Draw方法:
void SystemManager::Draw(Window* l_wind,
unsigned int l_elevation)
{
auto itr = m_systems.find(System::Renderer);
if (itr == m_systems.end()){ return; }
S_Renderer* system = (S_Renderer*)itr->second;
system->Render(l_wind, l_elevation);
}
对于大多数需求,有一个专门用于渲染实体的系统就足够了。因此,渲染系统位于系统容器中,并从基类向上进行类型转换。然后使用相关参数调用其Render方法,其中一个参数是当前正在渲染的高度。以这种方式绘制可以让我们在游戏中实现“深度”感。
由于实体的组成不是静态的,系统必须意识到这些变化,并根据情况适当接收或处理它们。这个特定方法已经在实体管理器类的实现过程中多次被提及,所以让我们看看它是如何工作的:
void SystemManager::EntityModified(const EntityId& l_entity,
const Bitmask& l_bits)
{
for(auto &s_itr : m_systems){
S_Base* system = s_itr.second;
if(system->FitsRequirements(l_bits)){
if(!system->HasEntity(l_entity)){
system->AddEntity(l_entity);
}
} else {
if(system->HasEntity(l_entity)){
system->RemoveEntity(l_entity);
}
}
}
}
当有关实体的任何变化发生时,必须使用实体的标识符及其新的位掩码作为参数调用EntityModified方法。然后遍历每个系统。它们的相应FitsRequirements方法使用新的位掩码作为参数被调用。如果一个实体符合系统的要求,但它不属于该系统,它将被添加。然而,如果一个实体不符合这些要求,但系统仍然有这个实体,它将被移除。这个简单概念的使用使得实体在结构上具有动态性。任何给定的实体都可以失去或获得一个组件,并立即“转变”成其他东西。
实体的移除相当简单:
void SystemManager::RemoveEntity(const EntityId& l_entity){
for(auto &system : m_systems){
system.second->RemoveEntity(l_entity);
}
}
这里需要发生的事情只是调用每个系统的RemoveEntity方法,这与清除所有实体非常相似:
void SystemManager::PurgeEntities(){
for(auto &system : m_systems){
system.second->Purge();
}
}
从系统管理器中移除所有系统也是轻而易举的事情:
void SystemManager::PurgeSystems(){
for (auto &system : m_systems){
delete system.second;
}
m_systems.clear();
}
由于系统是动态分配的,因此必须为每个系统释放内存。然后系统容器被简单地清空。
最后一种方法标志着我们的系统管理器的完成,以及实体组件系统范例的核心结构。现在,我们塑造游戏的基本工具都已经准备好了,所以让我们实现游戏中的第一个也是最重要的系统:渲染器。
实现渲染系统
为了让实体能够在屏幕上绘制,它们必须有一个代表其视觉外观的组件。经过一些仔细的计划,可以推断出实体可能不会只有一个可能的图形表示选择。例如,一个实体可以是一个简单的形状,具有单色填充,而不是精灵表。为了实现这一点,我们需要一个可绘制组件的通用接口。让我们看看我们能想出什么:
class C_Drawable : public C_Base{
public:
C_Drawable(const Component& l_type) : C_Base(l_type){}
virtual ~C_Drawable(){}
virtual void UpdatePosition(const sf::Vector2f& l_vec) = 0;
virtual const sf::Vector2u& GetSize() = 0;
virtual void Draw(sf::RenderWindow* l_wind) = 0;
private:
};
这里首先要注意的是,这个类的构造函数也接受一个组件类型,并将其简单地传递给基类。由于C_Drawable只有纯虚方法,它永远不能被实例化,而只能用作塑造其他可绘制组件的模板。它要求所有派生类实现一个用于更新可绘制位置、获取其大小并在屏幕上绘制的方法。
精灵表组件
基类设置好后,是时候看看如何创建精灵表组件了:
class C_SpriteSheet : public C_Drawable{
public:
...
private:
SpriteSheet* m_spriteSheet;
std::string m_sheetName;
};
当然,这个组件将利用我们之前构建的SpriteSheet类作为其数据成员之一。我们还想保留精灵表名称,以便在反序列化之后正确分配资源。让我们开始实现精灵表组件:
C_SpriteSheet(): C_Drawable(Component::SpriteSheet),
m_spriteSheet(nullptr){}
~C_SpriteSheet(){
if(m_spriteSheet){ delete m_spriteSheet; }
}
到目前为止,一切都很正常。构造函数使用初始化列表来设置组件类型并将精灵表指针设置为NULL,而析构函数则负责释放由该精灵表占用的内存。
接下来,让我们处理读取组件数据,这仅包括精灵表名称:
void ReadIn(std::stringstream& l_stream){
l_stream >> m_sheetName;
}
由于这个特定的可绘制组件的性质,它需要访问纹理管理器。为了正确设置精灵表,引入了Create方法:
void Create(TextureManager* l_textureMgr,
const std::string& l_name = "")
{
if (m_spriteSheet){ return; }
m_spriteSheet = new SpriteSheet(l_textureMgr);
m_spriteSheet->LoadSheet("media/Spritesheets/" +
(l_name != "" ? l_name : m_sheetName) + ".sheet");
}
如前所述,这个特定方法用于在实体加载期间设置精灵表组件。它首先检查m_spriteSheet数据成员的内存是否已经被分配。如果没有,就使用传入的纹理管理器指针作为唯一参数创建一个新的SpriteSheet对象。其余的代码处理第二个可选参数。纹理的名称可以直接传递给Create方法,或者可以使用从实体文件中读取的m_sheetName数据成员。
最后,所有C_Drawable类的虚拟方法都必须在这里实现:
SpriteSheet* GetSpriteSheet(){ return m_spriteSheet; }
void UpdatePosition(const sf::Vector2f& l_vec){
m_spriteSheet->SetSpritePosition(l_vec);
}
const sf::Vector2u& GetSize(){
return m_spriteSheet->GetSpriteSize();
}
void Draw(sf::RenderWindow* l_wind){
if (!m_spriteSheet){ return; }
m_spriteSheet->Draw(l_wind);
}
过去在SpriteSheet类上所做的所有工作使得这变得相当简单。需要注意的是,由于加载精灵表组件的性质,在尝试绘制之前检查它是否实际上已经被分配可能是个明智的选择。
渲染器
简单的部分已经处理完毕,让我们专注于创建我们构建的第一个系统,即渲染器:
class S_Renderer : public S_Base{
public:
S_Renderer(SystemManager* l_systemMgr);
~S_Renderer();
void Update(float l_dT);
void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event);
void Notify(const Message& l_message);
void Render(Window* l_wind, unsigned int l_layer);
private:
void SetSheetDirection(const EntityId& l_entity,
const Direction& l_dir);
void SortDrawables();
};
所有其他系统的头文件看起来将非常类似于这个,除了私有方法,这些方法针对每个系统执行的功能是特定的。每个系统都必须实现自己的Update和HandleEvent方法。此外,作为一个观察者,还需要实现一个独特的Notify方法。现在是时候尝试实现渲染器系统了:
S_Renderer::S_Renderer(SystemManager* l_systemMgr)
:S_Base(System::Renderer, l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::SpriteSheet);
m_requiredComponents.push_back(req);
req.Clear();
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Direction_Changed,this);
}
S_Renderer::~S_Renderer(){}
在调用基类构造函数并传入适当的类型以及指向系统管理器的指针后,渲染器设置了一个位掩码,表示实体必须满足的要求才能属于这个系统。正如你所见,它只需要具有位置和精灵表组件。一旦将要求位掩码添加到系统中,它还会订阅Direction_Changed消息类型。这利用了之前讨论过的观察者模式。
让我们看看更新方法:
void S_Renderer::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities)
{
C_Position* position = entities->
GetComponent<C_Position>(entity, Component::Position);
C_Drawable* drawable = nullptr;
if (entities->HasComponent(entity, Component::SpriteSheet)){
drawable = entities->
GetComponent<C_Drawable>(entity, Component::SpriteSheet);
} else { continue; }
drawable->UpdatePosition(position->GetPosition());
}
}
在迭代属于这个系统的所有实体时,通过实体管理器获取位置和可绘制组件。然后,通过使用其UpdatePosition方法更新可绘制组件的位置。显然,如果将来添加了额外的可绘制类型,这个方法可以扩展。
接下来,让我们处理适当的事件:
void S_Renderer::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
if (l_event == EntityEvent::Moving_Left ||
l_event == EntityEvent::Moving_Right ||
l_event == EntityEvent::Moving_Up ||
l_event == EntityEvent::Moving_Down ||
l_event == EntityEvent::Elevation_Change ||
l_event == EntityEvent::Spawned)
{
SortDrawables();
}
}
如果系统遇到实体生成、改变位置或海拔高度的事件,它们的可绘制表示必须重新排序,以确保正确的分层。这个结果相当值得麻烦:

消息处理的相关代码如下:
void S_Renderer::Notify(const Message& l_message){
if(HasEntity(l_message.m_receiver)){
EntityMessage m = (EntityMessage)l_message.m_type;
switch(m){
case EntityMessage::Direction_Changed:
SetSheetDirection(l_message.m_receiver,
(Direction)l_message.m_int);
break;
}
}
}
由于消息是全球广播到每个系统,无论它们包含哪些实体,并且渲染器只处理与特定实体相关的单一消息类型,因此会进行一次检查,以确保实体存在于渲染器系统中。到目前为止,我们关注的唯一消息类型是方向被改变,在这种情况下,将调用一个私有方法来调整它。
现在,让我们来谈谈渲染器系统存在的主要目的:
void S_Renderer::Render(Window* l_wind, unsigned int l_layer)
{
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
C_Position* position = entities->
GetComponent<C_Position>(entity, Component::Position);
if(position->GetElevation() < l_layer){ continue; }
if(position->GetElevation() > l_layer){ break; }
C_Drawable* drawable = nullptr;
if (!entities->HasComponent(entity,
Component::SpriteSheet))
{
continue;
}
drawable = entities->
GetComponent<C_Drawable>(entity, Component::SpriteSheet);
sf::FloatRect drawableBounds;
drawableBounds.left = position->GetPosition().x –
(drawable->GetSize().x / 2);
drawableBounds.top = position->GetPosition().y –
drawable->GetSize().y;
drawableBounds.width = drawable->GetSize().x;
drawableBounds.height = drawable->GetSize().y;
if (!l_wind->GetViewSpace().intersects(
drawableBounds))
{
continue;
}
drawable->Draw(l_wind->GetRenderWindow());
}
}
每个实体都会被迭代,就像任何其他系统一样。这里的主要区别是方法接受的层参数。因为我们想要有一个地图,许多不同的层实体可以“夹”在其中,按高度渲染是必要的,以保持正确的绘制顺序并提供深度感,如图所示:

Render方法的第二部分致力于实体剔除。首先,创建一个矩形结构来表示可渲染对象的边界。在精灵图的情况下,我们知道其原点设置在其宽度的一半和其完整高度的位置。使用这些信息,矩形结构被正确设置并检查与视图空间的交集,这本质上意味着精灵在屏幕上,应该被绘制。
尽可能地重用代码,从长远来看会使生活变得更轻松,这就是为什么在多个系统中存在许多私有方法,这些方法涉及实体组件的修改。例如:
void S_Renderer::SetSheetDirection(const EntityId& l_entity,
const Direction& l_dir)
{
EntityManager* entities = m_systemManager->GetEntityManager();
if (!entities->HasComponent(l_entity,
Component::SpriteSheet))
{
return;
}
C_SpriteSheet* sheet = entities->
GetComponent<C_SpriteSheet>(l_entity,Component::SpriteSheet);
sheet->GetSpriteSheet()->SetDirection(l_dir);
}
SetSheetDirection方法简单地获取精灵图组件并更改其方向。
我们想要实现的功能的最后一项是可渲染对象的渲染顺序,以便模拟深度。正确顺序绘制实体需要它们被排序。这就是SortDrawables方法发挥作用的地方:
void S_Renderer::SortDrawables(){
EntityManager* e_mgr = m_systemManager->GetEntityManager();
std::sort(m_entities.begin(), m_entities.end(),
e_mgr
{
auto pos1 = e_mgr->
GetComponent<C_Position>(l_1, Component::Position);
auto pos2 = e_mgr->
GetComponent<C_Position>(l_2, Component::Position);
if (pos1->GetElevation() == pos2->GetElevation()){
return pos1->GetPosition().y < pos2->GetPosition().y;
}
return pos1->GetElevation() < pos2->GetElevation();
});
}
在这里,我们只是调用std::sort函数,最后一个参数是我们之前已经见过的谓词 lambda。在实体精灵排序时,高度优先考虑。任何高度更高的对象都将绘制在顶部,而处于相同高度的精灵将根据它们的Y坐标进行排序。
有了这个,渲染系统现在就完成了!将所有这些部件组合在一起,是我们游戏中采用实体组件系统模式的最后一步。
将 ECS 投入工作
由于这种范式在我们应用程序的整体结构中扮演的角色,我们希望系统管理器和实体管理类对大多数代码库都是可访问的。将这些对象作为共享上下文的一部分是做到这一点的最佳方式:
struct SharedContext{
SharedContext():
...
m_systemManager(nullptr),
m_entityManager(nullptr),
...{}
...
SystemManager* m_systemManager;
EntityManager* m_entityManager;
...
};
调整共享上下文意味着我们在Game.h中需要跟踪两个额外的类:
class Game{
...
private:
...
SystemManager m_systemManager;
EntityManager m_entityManager;
...
};
这些类必须被正确初始化,这将在Game.cpp中完成:
Game::Game(): m_window("Chapter 8", sf::Vector2u(800,600)),
m_entityManager(&m_systemManager, &m_textureManager),
m_stateManager(&m_context)
{
...
m_systemManager.SetEntityManager(&m_entityManager);
m_context.m_systemManager = &m_systemManager;
m_context.m_entityManager = &m_entityManager;
...
}
注意到实体管理器在初始化列表中被初始化。然后系统管理器被赋予实体管理器的指针,这两个类都被添加到共享上下文中。
接下来,需要对游戏状态进行一些修改:
class State_Game : public BaseState{
public:
...
void PlayerMove(EventDetails* l_details);
...
private:
...
Void UpdateCamera();
int m_player;
};
当前游戏状态除了提供更新摄像头的新方法外,还跟踪玩家的实体标识符,并将这些设置为一个回调,如下所示:
void State_Game::OnCreate(){
...
evMgr->AddCallback(StateType::Game, "Player_MoveLeft",
&State_Game::PlayerMove, this);
evMgr->AddCallback(StateType::Game, "Player_MoveRight",
&State_Game::PlayerMove, this);
evMgr->AddCallback(StateType::Game, "Player_MoveUp",
&State_Game::PlayerMove, this);
evMgr->AddCallback(StateType::Game, "Player_MoveDown",
&State_Game::PlayerMove, this);
...
m_player = m_gameMap->GetPlayerId();
}
在加载游戏地图后,通过Map类获取玩家实体标识符,该类在地图加载期间存储此信息。
下一个任务是让摄像头跟随我们的英雄。这可以通过首先在我们的游戏状态Update方法中调用我们的UpdateCamera方法来实现:
void State_Game::Update(const sf::Time& l_time){
SharedContext* context = m_stateMgr->GetContext();
UpdateCamera();
m_gameMap->Update(l_time.asSeconds());
context->m_systemManager->Update(l_time.asSeconds());
}
实际的UpdateCamera方法实现如下:
void State_Game::UpdateCamera(){
if (m_player == -1){ return; }
SharedContext* context = m_stateMgr->GetContext();
C_Position* pos = m_stateMgr->GetContext()->m_entityManager->
GetComponent<C_Position>(m_player, Component::Position);
m_view.setCenter(pos->GetPosition());
context->m_wind->GetRenderWindow()->setView(m_view);
sf::FloatRect viewSpace = context->m_wind->GetViewSpace();
if (viewSpace.left <= 0){
m_view.setCenter(viewSpace.width / 2, m_view.getCenter().y);
context->m_wind->GetRenderWindow()->setView(m_view);
} else if (viewSpace.left + viewSpace.width >
(m_gameMap->GetMapSize().x) * Sheet::Tile_Size)
{
m_view.setCenter(
((m_gameMap->GetMapSize().x) * Sheet::Tile_Size) -
(viewSpace.width / 2), m_view.getCenter().y);
context->m_wind->GetRenderWindow()->setView(m_view);
}
if (viewSpace.top <= 0){
m_view.setCenter(m_view.getCenter().x, viewSpace.height / 2);
context->m_wind->GetRenderWindow()->setView(m_view);
} else if (viewSpace.top + viewSpace.height >
(m_gameMap->GetMapSize().y) * Sheet::Tile_Size)
{
m_view.setCenter(m_view.getCenter().x,
((m_gameMap->GetMapSize().y) * Sheet::Tile_Size) -
(viewSpace.height / 2));
context->m_wind->GetRenderWindow()->setView(m_view);
}
}
首先验证玩家标识符是否为非负值,这会表示一个错误。然后获取玩家实体的位置组件,并用于更新当前视图的位置。其余的代码处理调整视图以适应地图边界,如果它游出边界之外。这也是必须调用系统管理器更新方法的地方。
绘制我们的游戏世界也需要进行修订:
void State_Game::Draw(){
for(unsigned int i = 0; i < Sheet::Num_Layers; ++i){
m_gameMap->Draw(i);
m_stateMgr->GetContext()->m_systemManager->Draw(
m_stateMgr->GetContext()->m_wind, i);
}
}
首先,一个for循环遍历可能用于游戏的每一层。Num_Layers值是Sheet枚举的一部分,该枚举在Map类头文件中定义。我们将在稍后介绍这一点。现在,地图Draw方法需要知道要绘制哪一层,因为它们不再同时绘制。在渲染适当的层之后,所有占据相同高度的实体也会在屏幕上渲染,从而产生游戏中的深度感,如下所示:

最后,我们需要定义移动玩家的回调方法:
void State_Game::PlayerMove(EventDetails* l_details){
Message msg((MessageType)EntityMessage::Move);
if (l_details->m_name == "Player_MoveLeft"){
msg.m_int = (int)Direction::Left;
} else if (l_details->m_name == "Player_MoveRight"){
msg.m_int = (int)Direction::Right;
} else if (l_details->m_name == "Player_MoveUp"){
msg.m_int = (int)Direction::Up;
} else if (l_details->m_name == "Player_MoveDown"){
msg.m_int = (int)Direction::Down;
}
msg.m_receiver = m_player;
m_stateMgr->GetContext()->m_systemManager->
GetMessageHandler()->Dispatch(msg);
}
创建并设置了一个类型为Move的消息,以便在它的m_int数据成员中保存方向。消息的接收者也被设置为玩家,并且消息通过系统管理器的消息处理器分发。这个消息将由我们在后续章节中构建的系统中之一处理。
我们之前项目中的最后一个变化是实体可以移动的方向数量。鉴于我们新的实体精灵表格式,让我们修改Directions.h:
enum class Direction{ Up = 0, Left, Down, Right };
由于方向被用作偏移精灵表中的行数以获取正确动画的方式,因此这里设置的值很重要。这个小改动标志着我们组件实体系统的构建和设置完成!现在剩下的只是调整Map类,以满足并补充我们游戏的新特性。
新的改进版地图
尽管这本书的第二个项目看起来很好,但它有很多方面相当原始。在其其他缺点中,由于无法支持瓦片层,地图设计缺乏复杂性。要有一个更复杂的场景,需要瓦片能够相互叠加,如图所示:

添加层支持,以及重新设计处理实体方式后加载实体信息,需要对地图文件格式进行一些修改。让我们看看一个例子:
SIZE 32 32
DEFAULT_FRICTION 1.0 1.0
|ENTITY|Name|x|y|elevation|
ENTITY Player 256.0 256.0 1
...
|TILE|ID|x|y|layer|solid|
TILE 3 0 0 0 0
...
虽然一些地图属性保持不变,但像重力或背景图像这样的东西已经被移除,因为它们不再适合我们正在制作的游戏的类型。这里的主要变化是实体和瓦片行。
加载实体就像提供其实体文件名称和一些与地图相关的数据一样简单,例如其位置和海拔。
瓦片加载现在也有所不同。除了其标识符和位置外,瓦片现在还需要有一个层,以及一个表示固实的标志,这些将在下一章中更深入地介绍。
在一些较大的变化中,枚举Sheet中定义了一个新值。它代表任何给定地图中可能的最大层数:
enum Sheet{
Tile_Size = 32, Sheet_Width = 256,
Sheet_Height = 256, Num_Layers = 4
};
此外,为了允许单独的固实选项,每个瓦片现在都带有一个可以开启或关闭的固实标志:
struct Tile{
...
bool m_solid; // Is the tile a solid.
};
与额外的信息,如瓦片层一起工作,需要对GetTile和ConvertCoords方法进行某些修改:
class Map{
public:
...
Tile* GetTile(unsigned int l_x, unsigned int l_y, unsigned int l_layer);
...
void Draw(unsigned int l_layer);
private:
unsigned int ConvertCoords(unsigned int l_x, unsigned int l_y,unsigned int l_layer)const;
...
int m_playerId;
...
};
注意m_playerId数据成员。它在加载地图文件后跟踪玩家被分配的实体 ID。
调整地图类
是时候开始实施所有这些更改了!首先,让我们看看用于获取地图瓦片的方法:
Tile* Map::GetTile(unsigned int l_x, unsigned int l_y,
unsigned int l_layer)
{
if(l_x < 0 || l_y < 0 || l_x >= m_maxMapSize.x ||
l_y >= m_maxMapSize.y || l_layer < 0 ||
l_layer >= Sheet::Num_Layers)
{
return nullptr;
}
auto itr = m_tileMap.find(ConvertCoords(l_x,l_y,l_layer));
if (itr == m_tileMap.end()){ return nullptr; }
return itr->second;
}
这里最大的不同之处在于检查访问超出地图边界的瓦片。该方法本身接受一个额外的参数,代表瓦片层,然后将其传递到ConvertCoords方法。与瓦片层一起工作需要将一个三维添加到二维瓦片数组中。由于我们正在将所有这些信息存储在一个一维数组中,因此必须进行一些额外的数学运算才能执行转换:
unsigned int Map::ConvertCoords(unsigned int l_x,
unsigned int l_y, unsigned int l_layer)const
{
return ((l_layer*m_maxMapSize.y+l_y) * m_maxMapSize.x + l_x);
}
如果你之前将地图视为一个二维网格,现在它正变成一个三维立方体,层值代表其深度。
更新后的Draw方法的功能在游戏状态Draw方法中已经非常清晰地概述了。让我们来实现它:
void Map::Draw(unsigned int l_layer){
if (l_layer >= Sheet::Num_Layers){ return; }
sf::RenderWindow* l_wind = m_context->m_wind->GetRenderWindow();
sf::FloatRect viewSpace = m_context->m_wind->GetViewSpace();
sf::Vector2i tileBegin(
floor(viewSpace.left / Sheet::Tile_Size),
floor(viewSpace.top / Sheet::Tile_Size));
sf::Vector2i tileEnd(
ceil((viewSpace.left + viewSpace.width) / Sheet::Tile_Size),
ceil((viewSpace.top + viewSpace.height) / Sheet::Tile_Size));
unsigned int count = 0;
for(int x = tileBegin.x; x <= tileEnd.x; ++x){
for(int y = tileBegin.y; y <= tileEnd.y; ++y){
Tile* tile = GetTile(x,y,l_layer);
if (!tile){ continue; }
sf::Sprite& sprite = tile->m_properties->m_sprite;
sprite.setPosition(x * Sheet::Tile_Size,
y * Sheet::Tile_Size);
l_wind->draw(sprite);
++count;
}
}
}
在我们开始任何实际的渲染之前,我们必须确保提供的层参数不超过定义的最大值。除此之外,唯一的真正区别现在是我们现在将层参数传递到GetTile方法。这是一个相当简单的调整。
最后,需要修复加载瓦片和实体的方式。让我们看看LoadMap方法的片段:
if(type == "TILE"){
...
sf::Vector2i tileCoords;
unsigned int tileLayer = 0;
unsigned int tileSolidity = 0;
keystream >> tileCoords.x >> tileCoords.y >>
tileLayer >> tileSolidity;
if (tileCoords.x > m_maxMapSize.x ||
tileCoords.y > m_maxMapSize.y ||
tileLayer >= Sheet::Num_Layers)
{
std::cout << "! Tile is out of range: " <<
tileCoords.x << " " << tileCoords.y << std::endl;
continue;
}
Tile* tile = new Tile();
// Bind properties of a tile from a set.
tile->m_properties = itr->second;
tile->m_solid = (bool)tileSolidity;
if(!m_tileMap.emplace(ConvertCoords(
tileCoords.x,tileCoords.y,tileLayer),tile).second)
{
...
}
...
} else if ...
这段代码的大部分保持不变。增加了读取图层和实体数据,以及检查图层值是否有效,以及坐标值。然而,实体方面的事情却相当不同:
} else if(type == "ENTITY"){
// Set up entity here.
std::string name;
keystream >> name;
if (name == "Player" && m_playerId != -1){ continue; }
int entityId = m_context->m_entityManager->AddEntity(name);
if (entityId < 0){ continue; }
if(name == "Player"){ m_playerId = entityId; }
C_Base* position = m_context->m_entityManager->
GetComponent<C_Position>(entityId,Component::Position);
if(position){ keystream >> *position; }
} else ...
首先读取实体的名称。如果它是一个玩家实体,并且尚未根据m_playerId数据成员设置,或者它只是任何其他实体,将尝试添加它。成功添加后,会再次检查其名称,以确保捕获并存储玩家实体标识符。然后获取位置组件,并直接从地图文件中读取其数据。
一旦Map类完成,编译和渲染我们的项目以及加载一个有效的地图,我们应该会剩下一些实体安静地站立着:

摘要
随着我们所需的所有工具的发明,我们接下来将致力于将最常见的游戏元素添加到我们的最终项目中,并使其栩栩如生,更不用说实际上运用我们构建的后端功能了。尽管这一章节已经结束,但这绝不是我们探索和应用新编程模式的终结,如果将来再次需要使用它们的话。
一个好的代码库应该能够轻松处理新功能的添加和旧功能的扩展。这一章节的实现标志着我们制作的游戏不再受限于设计限制或不便扩展。在这个阶段,问题不再是“如何”,而是“为什么不?”既然你已经走到了这一步,为什么不继续前进呢?下一章见!
第九章. 一股清新的空气 – 实体组件系统继续
在上一章中,我们讨论了使用聚合而非简单继承的优点。虽然一开始可能不太直观,但由多个组件组成并由系统操作的实体无疑提高了代码的灵活性和可重用性,更不用说为未来的增长提供了一个更方便的环境。正如流行表达所说,“未来已经到来!”一座房子没有好的基础是无用的,就像一个好的基础如果没有在上面建造房子也是无用的。既然我们已经有了坚实的基础,那么接下来就是砌砖直到出现一个合适的结构。
在本章中,我们将:
-
实现基本移动
-
开发一个更新精灵图的系统
-
重新审视并实现实体状态
-
在实体组件系统范式下研究碰撞
添加实体移动
在实体组件系统范式下,特定身体的移动是通过作用在其上的所有力来量化的。这些力的集合可以表示为一个可移动组件:
class C_Movable : public C_Base{
public:
...
private:
sf::Vector2f m_velocity;
float m_velocityMax;
sf::Vector2f m_speed;
sf::Vector2f m_acceleration;
Direction m_direction;
};
此组件从本书的第二项目中移除了物理元素,即速度、速度和加速度属性。为了简化代码,这次将速度限制表示为一个单一的浮点数,因为我们不太可能需要根据其轴来不同地限制速度。
让我们看看可移动组件类的其余部分:
C_Movable() : C_Base(Component::Movable),
m_velocityMax(0.f), m_direction((Direction)0)
{}
此处的构造函数将数据成员初始化为一些默认值,这些值随后将由反序列化中的值替换:
void ReadIn(std::stringstream& l_stream){
l_stream >> m_velocityMax >> m_speed.x >> m_speed.y;
unsigned int dir = 0;
l_stream >> dir;
m_direction = (Direction)dir;
}
为了方便地在一定范围内操纵速度,我们提供了AddVelocity方法:
void AddVelocity(const sf::Vector2f& l_vec){
m_velocity += l_vec;
if(std::abs(m_velocity.x) > m_velocityMax){
m_velocity.x = m_velocityMax *
(m_velocity.x / std::abs(m_velocity.x));
}
if(std::abs(m_velocity.y) > m_velocityMax){
m_velocity.y = m_velocityMax *
(m_velocity.y / std::abs(m_velocity.y));
}
}
在添加提供的速度参数后,检查最终结果是否高于每个轴上允许的最大值。如果是,则将速度限制在允许的最大值,并保留适当的符号。
void ApplyFriction(const sf::Vector2f& l_vec){
if(m_velocity.x != 0 && l_vec.x != 0){
if(std::abs(m_velocity.x) - std::abs(l_vec.x) < 0){
m_velocity.x = 0;
} else {
m_velocity.x += (m_velocity.x > 0 ? l_vec.x * -1 : l_vec.x);
}
}
if(m_velocity.y != 0 && l_vec.y != 0){
if(std::abs(m_velocity.y) - std::abs(l_vec.y) < 0){
m_velocity.y = 0;
} else {
m_velocity.y += (m_velocity.y > 0 ? l_vec.y * -1 : l_vec.y);
}
}
}
将摩擦应用于当前速度也是受控的。为了避免摩擦使速度改变符号,它被检查是否不等于零,以及当前速度的绝对值与提供的摩擦之间的差异不会是负数。如果是,则将速度设置为零。否则,将摩擦值以适当的符号添加到当前速度。
为了使实体能够移动,它必须被加速。让我们提供一个方法:
void Accelerate(const sf::Vector2f& l_vec){
m_acceleration += l_vec;
}
void Accelerate(float l_x, float l_y){
m_acceleration += sf::Vector2f(l_x,l_y);
}
为了方便起见,我们提供了相同的方法,重载以接受两种类型的参数:一个浮点向量和两个单独的浮点值。它所做的只是简单地将参数值添加到当前加速度。
最后,实体也可以根据提供的方向移动,而不是手动调用Accelerate方法:
void Move(const Direction& l_dir){
if(l_dir == Direction::Up){
m_acceleration.y -= m_speed.y;
} else if (l_dir == Direction::Down){
m_acceleration.y += m_speed.y;
} else if (l_dir == Direction::Left){
m_acceleration.x -= m_speed.x;
} else if (l_dir == Direction::Right){
m_acceleration.x += m_speed.x;
}
}
根据提供的方向参数,实体的速度被添加到加速度向量中。
移动系统
在设计好移动组件后,让我们尝试实现实际移动我们实体的系统:
enum class Axis{ x, y };
class Map;
class S_Movement : public S_Base{
public:
...
void SetMap(Map* l_gameMap);
private:
void StopEntity(const EntityId& l_entity,
const Axis& l_axis);
void SetDirection(const EntityId& l_entity,
const Direction& l_dir);
const sf::Vector2f& GetTileFriction(unsigned int l_elevation,
unsigned int l_x, unsigned int l_y);
void MovementStep(float l_dT, C_Movable* l_movable,
C_Position* l_position);
Map* m_gameMap;
};
首先,创建一个Axis枚举,以便简化此类私有辅助方法中的代码。然后,我们提前声明一个Map类,以便能够在头文件中使用它。这样,就有一个Map数据成员,以及一个公共方法,用于向移动系统提供一个Map实例。还需要一些私有辅助方法来使代码更易于阅读。让我们从设置构造函数开始:
S_Movement::S_Movement(SystemManager* l_systemMgr)
: S_Base(System::Movement,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::Movable);
m_requiredComponents.push_back(req);
req.Clear();
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Is_Moving,this);
m_gameMap = nullptr;
}
该系统的要求包括两个组件:位置和可移动。除此之外,该系统还订阅了Is_Moving消息类型,以便对其做出响应。
接下来,让我们更新我们的实体信息:
void S_Movement::Update(float l_dT){
if (!m_gameMap){ return; }
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
C_Position* position = entities->
GetComponent<C_Position>(entity, Component::Position);
C_Movable* movable = entities->
GetComponent<C_Movable>(entity, Component::Movable);
MovementStep(l_dT, movable, position);
position->MoveBy(movable->GetVelocity() * l_dT);
}
}
如该系统的要求所示,它将在位置组件和可移动组件上运行。对于属于此系统的每个实体,我们希望更新其物理属性并根据其速度和帧间经过的时间调整其位置,从而产生基于力的移动。
让我们看看移动步骤方法:
void S_Movement::MovementStep(float l_dT, C_Movable* l_movable,
C_Position* l_position)
{
sf::Vector2f f_coefficient =
GetTileFriction(l_position->GetElevation(),
floor(l_position->GetPosition().x / Sheet::Tile_Size),
floor(l_position->GetPosition().y / Sheet::Tile_Size));
sf::Vector2f friction(l_movable->GetSpeed().x * f_coefficient.x,
l_movable->GetSpeed().y * f_coefficient.y);
l_movable->AddVelocity(l_movable->GetAcceleration() * l_dT);
l_movable->SetAcceleration(sf::Vector2f(0.0f, 0.0f));
l_movable->ApplyFriction(friction * l_dT);
float magnitude = sqrt(
(l_movable->GetVelocity().x * l_movable->GetVelocity().x) +
(l_movable->GetVelocity().y * l_movable->GetVelocity().y));
if (magnitude <= l_movable->GetMaxVelocity()){ return; }
float max_V = l_movable->GetMaxVelocity();
l_movable->SetVelocity(sf::Vector2f(
(l_movable->GetVelocity().x / magnitude) * max_V,
(l_movable->GetVelocity().y / magnitude) * max_V));
}
首先获取实体站立的地砖的摩擦值。在根据加速度值更新速度后,立即将其应用于实体的可移动组件。
接下来,我们必须确保对角移动被正确处理。考虑以下插图:

根据勾股定理,表示对角移动的直角三角形的斜边平方等于其两边的平方和。换句话说,斜边比两边的和要短。例如,向右下移动的角色看起来会比单方向移动得更快,除非我们根据速度向量的幅度(也称为我们插图中的三角形的斜边)限制它们的速度。一旦计算出幅度,就会检查它是否超过了实体可能的最大速度。如果超过了,它会被归一化并乘以最大速度的值,以强制对角移动变慢。
获取地砖摩擦力的方法如下:
const sf::Vector2f& S_Movement::GetTileFriction(
unsigned int l_elevation, unsigned int l_x, unsigned int l_y)
{
Tile* t = nullptr;
while (!t && l_elevation >= 0){
t = m_gameMap->GetTile(l_x, l_y, l_elevation);
--l_elevation;
}
return(t ? t->m_properties->m_friction :
m_gameMap->GetDefaultTile()->m_friction);
}
在启动while循环之前设置一个地砖指针。它将不断尝试在提供的位置获取地砖,同时每次减少海拔。这意味着地砖摩擦力实际上是从玩家所在的最顶层地砖中获得的。如果没有找到地砖,则返回默认摩擦值。
如你现在可能猜到的,由于其重要性,移动系统需要响应相当多的事件:
void S_Movement::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
switch(l_event){
case EntityEvent::Colliding_X:
StopEntity(l_entity,Axis::x); break;
case EntityEvent::Colliding_Y:
StopEntity(l_entity, Axis::y); break;
case EntityEvent::Moving_Left:
SetDirection(l_entity, Direction::Left); break;
case EntityEvent::Moving_Right:
SetDirection(l_entity, Direction::Right); break;
case EntityEvent::Moving_Up:
{
C_Movable* mov = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
if(mov->GetVelocity().x == 0){
SetDirection(l_entity, Direction::Up);
}
}
break;
case EntityEvent::Moving_Down:
{
C_Movable* mov = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
if(mov->GetVelocity().x == 0){
SetDirection(l_entity, Direction::Down);
}
}
break;
}
}
首先,它处理两个碰撞事件,通过调用私有的StopEntity方法来在指定轴上停止实体。接下来,我们有四个移动事件。在Moving_Left和Moving_Right的情况下,调用私有的SetDirection方法来更新实体的方向。然而,上下移动则略有不同。我们希望实体的方向只有在它没有x轴上的速度时才改变。否则,它最终会以一种相当滑稽的方式移动。
接下来是消息处理:
void S_Movement::Notify(const Message& l_message){
EntityManager* eMgr = m_systemManager->GetEntityManager();
EntityMessage m = (EntityMessage)l_message.m_type;
switch(m){
case EntityMessage::Is_Moving:
{
if (!HasEntity(l_message.m_receiver)){ return; }
C_Movable* movable = eMgr->GetComponent<C_Movable>
(l_message.m_receiver, Component::Movable);
if (movable->GetVelocity() != sf::Vector2f(0.0f, 0.0f))
{
return;
}
m_systemManager->AddEvent(l_message.m_receiver,
(EventID)EntityEvent::Became_Idle);
}
break;
}
}
在这里,我们只关心一种消息类型:Is_Moving。这是一个消息,当实体变得空闲时,会触发发送另一个消息。首先,检查系统是否包含相关的实体。然后获取其实体的可移动组件,检查其速度是否为零。既然是这样,就创建一个事件来表示实体变得空闲。
现在我们只剩下私有辅助方法。这些都是冗余逻辑,其存在避免了代码重复。我们将首先检查负责停止实体的第一个方法:
void S_Movement::StopEntity(const EntityId& l_entity,
const Axis& l_axis)
{
C_Movable* movable = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
if(l_axis == Axis::x){
movable->SetVelocity(sf::Vector2f(0.f, movable->GetVelocity().y));
} else if(l_axis == Axis::y){
movable->SetVelocity(sf::Vector2f(movable->GetVelocity().x, 0.f));
}
}
在获得其可移动组件后,实体在其轴上将其速度设置为零,该轴作为此方法的参数提供。
void S_Movement::SetDirection(const EntityId& l_entity,
const Direction& l_dir)
{
C_Movable* movable = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
movable->SetDirection(l_dir);
Message msg((MessageType)EntityMessage::Direction_Changed);
msg.m_receiver = l_entity;
msg.m_int = (int)l_dir;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
SetDirection方法更新可移动组件的方向。然后发送一条消息来通知所有其他系统这一变化。
最后,我们只剩下一个Map类的设置器方法:
void S_Movement::SetMap(Map* l_gameMap){ m_gameMap = l_gameMap; }
为了使实体具有动态摩擦,移动系统必须能够访问Map类,因此它在游戏状态中设置:
void State_Game::OnCreate(){
...
m_stateMgr->GetContext()->m_systemManager->
GetSystem<S_Movement>(SYSTEM_MOVEMENT)->SetMap(m_gameMap);
}
最后这段代码片段完成了移动系统的实现。我们的实体现在可以根据施加在它们身上的力进行移动。然而,有了移动支持实际上并不产生移动。这就是实体状态系统发挥作用的地方。
实现状态
移动,就像许多与实体相关的其他动作和事件一样,取决于它们当前状态是否令人满意。一个垂死的玩家不应该能够四处移动。应根据其实际状态播放相关的动画。强制执行这些规则需要实体具有状态组件:
enum class EntityState{ Idle, Walking, Attacking, Hurt, Dying };
class C_State : public C_Base{
public:
C_State(): C_Base(Component::State){}
void ReadIn(std::stringstream& l_stream){
unsigned int state = 0;
l_stream >> state;
m_state = (EntityState)state;
}
EntityState GetState(){ return m_state; }
void SetState(const EntityState& l_state){
m_state = l_state;
}
private:
EntityState m_state;
};
如您所知,这是一段非常简单的代码。它定义了自己的实体可能状态枚举。组件类本身仅提供设置器和获取器,以及所需的反序列化方法。其余的,像往常一样,留给系统自行处理。
状态系统
由于从现在开始的大多数系统头文件看起来几乎相同,因此将省略它们。话虽如此,让我们首先实现我们的状态系统的构造函数和析构函数:
S_State::S_State(SystemManager* l_systemMgr)
: S_Base(System::State,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::State);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Move,this);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Switch_State,this);
}
这个系统所需的所有东西只是一个状态组件。它还订阅了两种消息类型:Move 和 Switch_State。后者是显而易见的,而 Move 消息是由游戏状态中的方法发送的,以移动玩家。因为移动完全依赖于实体状态,所以这是唯一处理这种类型消息并确定状态是否适合运动的系统。
接下来,让我们看看 Update 方法:
void S_State::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
C_State* state = entities->
GetComponent<C_State>(entity, Component::State);
if(state->GetState() == EntityState::Walking){
Message msg((MessageType)EntityMessage::Is_Moving);
msg.m_receiver = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
}
这里发生的一切只是一个简单的检查实体当前的状态。如果它在运动中,就会分发一个 Is_Moving 消息。如果你还记得,这种类型的消息是由运动系统处理的,当实体变为空闲时,它会触发一个事件。这个事件由我们的状态系统处理:
void S_State::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
switch(l_event){
case EntityEvent::Became_Idle:
ChangeState(l_entity,EntityState::Idle,false);
break;
}
}
它所做的只是调用一个私有方法 ChangeState,该方法将实体的当前状态更改为 Idle。这里的第三个参数只是一个标志,用于指示状态更改是否应该被强制执行。
我们在这里将要处理的最后一个公共方法是 Notify:
void S_State::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver)){ return; }
EntityMessage m = (EntityMessage)l_message.m_type;
switch(m){
case EntityMessage::Move:
{
C_State* state = m_systemManager->GetEntityManager()->
GetComponent<C_State>(l_message.m_receiver,
Component::State);
if (state->GetState() == EntityState::Dying){ return; }
EntityEvent e;
if (l_message.m_int == (int)Direction::Up){
e = EntityEvent::Moving_Up;
} else if (l_message.m_int == (int)Direction::Down){
e = EntityEvent::Moving_Down;
} else if(l_message.m_int == (int)Direction::Left){
e = EntityEvent::Moving_Left;
} else if (l_message.m_int == (int)Direction::Right){
e = EntityEvent::Moving_Right;
}
m_systemManager->AddEvent(l_message.m_receiver, (EventID)e);
ChangeState(l_message.m_receiver,
EntityState::Walking,false);
}
break;
case EntityMessage::Switch_State:
ChangeState(l_message.m_receiver,
(EntityState)l_message.m_int,false);
break;
}
}
Move 消息通过获取目标实体的状态来处理。如果实体没有死亡,就会根据消息包含的方向构建一个 Moving_X 事件。一旦事件被分发,实体的状态就会更改为 Walking。
Switch_State 消息只是通过调用这个私有方法来更改实体的当前状态,而不进行强制更改:
void S_State::ChangeState(const EntityId& l_entity,
const EntityState& l_state, const bool& l_force)
{
EntityManager* entities = m_systemManager->GetEntityManager();
C_State* state = entities->
GetComponent<C_State>(l_entity, Component::State);
if (!l_force && state->GetState() == EntityState::Dying){
return;
}
state->SetState(l_state);
Message msg((MessageType)EntityMessage::State_Changed);
msg.m_receiver = l_entity;
msg.m_int = (int)l_state;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
在获得状态后,检查 l_force 标志。如果它设置为 false,只有当实体当前不是 DYING 时,状态才会被更改。我们不希望任何东西随机地将实体从死亡中拉出来。如果 l_force 标志设置为 true,则无论是否更改状态。
现在我们可以根据实体的当前状态控制可能发生的事情。有了这个,实体现在就可以被控制了。
实体控制器
让一个独立的系统负责移动实体的想法不仅在于我们可以决定哪些实体可以被移动,而且还进一步分离了逻辑,并为未来的 A.I. 实现提供了钩子。让我们看看控制器组件:
class C_Controller : public C_Base{
public:
C_Controller() : C_Base(COMPONENT_CONTROLLER){}
void ReadIn(std::stringstream& l_stream){}
};
是的,它只是一个空组件,它只是用作告诉控制系统它所属的实体可以被控制的一种方式。它可能需要存储一些额外的信息,但到目前为止,它只是一个“标志”。
实际的控制系统非常简单易实现。让我们从构造函数开始:
S_Control::S_Control(SystemManager* l_systemMgr)
:S_Base(System::Control,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::Movable);
req.TurnOnBit((unsigned int)Component::Controller);
m_requiredComponents.push_back(req);
req.Clear();
}
它对位置、可移动和控制器组件提出了要求,以便能够移动实体,这正是这个系统的唯一目的。实际的移动由处理实体事件来处理,如下所示:
void S_Control::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
switch(l_event){
case EntityEvent::Moving_Left:
MoveEntity(l_entity,Direction::Left); break;
case EntityEvent::Moving_Right:
MoveEntity(l_entity, Direction::Right); break;
case EntityEvent::Moving_Up:
MoveEntity(l_entity, Direction::Up); break;
case EntityEvent::Moving_Down:
MoveEntity(l_entity, Direction::Down); break;
}
}
所有四个事件都会调用同一个私有方法,该方法只是调用可移动组件的 Move 方法,并传入适当的方向:
void S_Control::MoveEntity(const EntityId& l_entity,
const Direction& l_dir)
{
C_Movable* mov = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity, Component::Movable);
mov->Move(l_dir);
}
在向我们的代码库添加了这个谦虚的补充之后,我们终于可以用键盘移动玩家了:

现在唯一的问题是实体看起来像是在冰上滑行,这是由于完全缺乏动画。为了解决这个问题,必须引入动画系统。
动画实体
如果您回忆起前面的章节,我们构建的 SpriteSheet 类已经对动画有很好的支持。在这个阶段没有必要添加这个功能,尤其是我们只处理基于精灵图的图形。这为我们节省了大量时间,并允许精灵图动画由一个单独的系统处理,无需额外的组件开销。
让我们开始实现精灵图动画系统,就像往常一样,先处理构造函数:
S_SheetAnimation::S_SheetAnimation(SystemManager* l_systemMgr)
: S_Base(System::SheetAnimation,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::SpriteSheet);
req.TurnOnBit((unsigned int)Component::State);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::State_Changed,this);
}
由于实体动画到目前为止完全是基于状态的,因此这个系统需要一个状态组件,除了精灵图组件之外。它还订阅了 State_Changed 消息类型,以便通过播放适当的动画来响应状态变化。更新所有实体是这个系统逻辑最多的区域,所以让我们看看 Update 方法:
void S_SheetAnimation::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
C_SpriteSheet* sheet = entities->
GetComponent<C_SpriteSheet>(entity, Component::SpriteSheet);
C_State* state = entities->
GetComponent<C_State>(entity, Component::State);
sheet->GetSpriteSheet()->Update(l_dT);
const std::string& animName = sheet->
GetSpriteSheet()->GetCurrentAnim()->GetName();
if(animName == "Attack"){
if(!sheet->GetSpriteSheet()->GetCurrentAnim()->IsPlaying())
{
Message msg((MessageType)EntityMessage::Switch_State);
msg.m_receiver = entity;
msg.m_int = (int)EntityState::Idle;
m_systemManager->GetMessageHandler()->Dispatch(msg);
} else if(sheet->GetSpriteSheet()->GetCurrentAnim()->IsInAction())
{
Message msg((MessageType)EntityMessage::Attack_Action);
msg.m_sender = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
} else if(animName == "Death" &&
!sheet->GetSpriteSheet()->GetCurrentAnim()->IsPlaying())
{
Message msg((MessageType)EntityMessage::Dead);
msg.m_receiver = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
}
首先,获取精灵图和状态组件。然后更新精灵图并检索当前动画的名称。如果攻击动画不再播放,则发送一个 Switch_State 类型的消息,以便将实体放回 Idle 状态。否则,检查动画是否当前处于精灵图文件中指定的 "action" 帧范围内。如果是,向当前实体发送一个 Attack_Action 消息,稍后不同的系统可以使用它来实现战斗。另一方面,如果死亡动画已经结束,则发送一个 Dead 消息。
接下来,让我们处理消息:
void S_SheetAnimation::Notify(const Message& l_message){
if(HasEntity(l_message.m_receiver)){
EntityMessage m = (EntityMessage)l_message.m_type;
switch(m){
case EntityMessage::State_Changed:
{
EntityState s = (EntityState)l_message.m_int;
switch(s){
case EntityState::Idle:
ChangeAnimation(l_message.m_receiver,"Idle",true,true);
break;
case EntityState::Walking:
ChangeAnimation(l_message.m_receiver,"Walk",true,true);
break;
case EntityState::Attacking:
ChangeAnimation(l_message.m_receiver,
"Attack",true,false);
break;
case EntityState::Hurt: break;
case EntityState::Dying:
ChangeAnimation(l_message.m_receiver,
"Death",true,false);
break;
}
}
break;
}
}
}
这个系统可能感兴趣的任何消息都与特定实体有关,所以首先进行这个检查。目前,我们只处理一种消息类型:State_Changed。每次状态改变时,我们都会改变实体的动画。唯一的可能例外是 Hurt 状态,稍后我们会处理它。
我们需要的最后一段代码是私有的 ChangeAnimation 方法:
void S_SheetAnimation::ChangeAnimation(const EntityId& l_entity,
const std::string& l_anim, bool l_play, bool l_loop)
{
C_SpriteSheet* sheet = m_systemManager->GetEntityManager()->
GetComponent<C_SpriteSheet>(l_entity,Component::SpriteSheet);
sheet->GetSpriteSheet()->SetAnimation(l_anim,l_play,l_loop);
}
获取实体的精灵图组件后,它简单地调用其 SetAnimation 方法来更改正在播放的当前动画。这段代码足够冗余,值得有一个单独的方法。
编译成功后,我们可以看到我们的实体现在已经开始动画了:

处理碰撞
让实体相互碰撞,以及进入我们将要构建的所有茂密环境,是一种机制,没有这种机制,大多数游戏将无法运行。为了实现这一点,这些在屏幕上四处移动的动画图像必须有一个表示其固体的组件。边界框在过去为我们工作得很好,所以让我们坚持使用它们,并开始构建可碰撞体组件:
enum class Origin{ Top_Left, Abs_Centre, Mid_Bottom };
class C_Collidable : public C_Base{
public:
...
private:
sf::FloatRect m_AABB;
sf::Vector2f m_offset;
Origin m_origin;
bool m_collidingOnX;
bool m_collidingOnY;
};
每个可碰撞实体都必须有一个表示其实体固体部分的边界框。这正是m_AABB矩形发挥作用的地方。除此之外,边界框本身可以根据实体的类型偏移一定数量的像素,并且可以有不同的起点。最后,我们想要跟踪实体是否在给定的轴上发生碰撞,这需要使用m_collidingOnX和m_collidingOnY标志。
这个组件的构造函数可能看起来有点像这样:
C_Collidable(): C_Base(Component::Collidable),
m_origin(Origin::Mid_Bottom), m_collidingOnX(false),
m_collidingOnY(false)
{}
在将默认值初始化到其一些数据成员之后,这个组件,就像许多其他组件一样,需要有一种反序列化的方式:
void ReadIn(std::stringstream& l_stream){
unsigned int origin = 0;
l_stream >> m_AABB.width >> m_AABB.height >> m_offset.x>> m_offset.y >> origin;
m_origin = (Origin)origin;
}
这里有一些独特的设置器和获取器方法,我们将使用它们:
void CollideOnX(){ m_collidingOnX = true; }
void CollideOnY(){ m_collidingOnY = true; }
void ResetCollisionFlags(){
m_collidingOnX = false;
m_collidingOnY = false;
}
void SetSize(const sf::Vector2f& l_vec){
m_AABB.width = l_vec.x;
m_AABB.height = l_vec.y;
}
最后,我们来到了这个组件的关键方法,SetPosition:
void SetPosition(const sf::Vector2f& l_vec){
switch(m_origin){
case(Origin::Top_Left):
m_AABB.left = l_vec.x + m_offset.x;
m_AABB.top = l_vec.y + m_offset.y;
break;
case(Origin::Abs_Centre):
m_AABB.left = l_vec.x - (m_AABB.width / 2) + m_offset.x;
m_AABB.top = l_vec.y - (m_AABB.height / 2) + m_offset.y;
break;
case(Origin::Mid_Bottom):
m_AABB.left = l_vec.x - (m_AABB.width / 2) + m_offset.x;
m_AABB.top = l_vec.y - m_AABB.height + m_offset.y;
break;
}
}
为了支持不同类型的起点,边界框矩形的定位必须不同。考虑以下插图:

实际的边界框矩形的起点始终是左上角。为了正确定位它,我们使用其宽度和高度来补偿几种可能的起点类型之间的差异。
碰撞系统
实际的碰撞魔法只有在有了负责计算游戏中每个可碰撞体系统的系统之后才会开始。让我们首先看看在这个系统中将要使用的数据类型:
struct CollisionElement{
CollisionElement(float l_area, TileInfo* l_info,
const sf::FloatRect& l_bounds):m_area(l_area),
m_tile(l_info), m_tileBounds(l_bounds){}
float m_area;
TileInfo* m_tile;
sf::FloatRect m_tileBounds;
};
using Collisions = std::vector<CollisionElement>;
为了进行适当的碰撞检测和响应,我们还需要一个能够存储碰撞信息的数据结构,这些信息可以稍后进行排序和处理。为此,我们将使用CollisionElement数据类型的向量。它是一个结构,由一个表示碰撞面积的浮点数、一个指向TileInfo实例的指针(该实例携带有关瓦片的所有信息)和一个简单的浮点矩形组成,该矩形包含地图瓦片的边界框信息。
为了检测实体和瓦片之间的碰撞,碰撞系统需要能够访问一个Map实例。了解所有这些后,让我们开始实现这个类!
实现碰撞系统
和往常一样,我们将在类的构造函数中设置组件要求:
S_Collision::S_Collision(SystemManager* l_systemMgr)
:S_Base(System::Collision,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::Collidable);
m_requiredComponents.push_back(req);
req.Clear();
m_gameMap = nullptr;
}
如您所见,该系统对实体施加了位置和可碰撞组件的要求。其m_gameMap数据成员也被初始化为nullptr,直到通过使用此方法进行设置:
void S_Collision::SetMap(Map* l_map){ m_gameMap = l_map; }
接下来是那个非常常见的更新方法,它使一切行为如预期:
void S_Collision::Update(float l_dT){
if (!m_gameMap){ return; }
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
C_Position* position = entities->
GetComponent<C_Position>(entity, Component::Position);
C_Collidable* collidable = entities->
GetComponent<C_Collidable>(entity, Component::Collidable);
collidable->SetPosition(position->GetPosition());
collidable->ResetCollisionFlags();
CheckOutOfBounds(position, collidable);
MapCollisions(entity, position, collidable);
}
EntityCollisions();
}
为了清晰起见,更新方法使用了另外两个辅助方法:CheckOutOfBounds 和 MapCollisions。在遍历所有可碰撞实体时,该系统获取其实体位置和可碰撞组件。后者使用实体的最新位置进行更新。同时,它的布尔碰撞标志也被重置。在所有实体都被更新后,私有的 EntityCollisions 方法被调用以处理实体与实体之间的交点测试。注意这个方法的开始部分。如果地图实例没有正确设置,它将立即返回。
首先,检查实体是否位于我们地图的边界之外:
void S_Collision::CheckOutOfBounds(C_Position* l_pos,
C_Collidable* l_col)
{
unsigned int TileSize = m_gameMap->GetTileSize();
if (l_pos->GetPosition().x < 0){
l_pos->SetPosition(0.0f, l_pos->GetPosition().y);
l_col->SetPosition(l_pos->GetPosition());
} else if (l_pos->GetPosition().x >
m_gameMap->GetMapSize().x * TileSize)
{
l_pos->SetPosition(m_gameMap->GetMapSize().x * TileSize,
l_pos->GetPosition().y);
l_col->SetPosition(l_pos->GetPosition());
}
if (l_pos->GetPosition().y < 0){
l_pos->SetPosition(l_pos->GetPosition().x, 0.0f);
l_col->SetPosition(l_pos->GetPosition());
} else if (l_pos->GetPosition().y >
m_gameMap->GetMapSize().y * TileSize)
{
l_pos->SetPosition(l_pos->GetPosition().x,
m_gameMap->GetMapSize().y * TileSize);
l_col->SetPosition(l_pos->GetPosition());
}
}
如果实体意外地位于地图之外,其位置将被重置。
在这个阶段,我们开始运行实体与地砖的碰撞测试:
void S_Collision::MapCollisions(const EntityId& l_entity,
C_Position* l_pos, C_Collidable* l_col)
{
unsigned int TileSize = m_gameMap->GetTileSize();
Collisions c;
sf::FloatRect EntityAABB = l_col->GetCollidable();
int FromX = floor(EntityAABB.left / TileSize);
int ToX = floor((EntityAABB.left + EntityAABB.width)/TileSize);
int FromY = floor(EntityAABB.top / TileSize);
int ToY = floor((EntityAABB.top + EntityAABB.height)/TileSize);
...
}
设置了一个名为 c 的碰撞信息向量。它将包含实体碰撞的所有重要信息,碰撞区域的尺寸以及它所碰撞的地砖的属性。然后从可碰撞组件中获取实体的边界框。根据该边界框计算出一个要检查的坐标范围,如下所示:

这些坐标立即被使用,因为我们开始遍历计算出的地砖范围,检查碰撞:
for (int x = FromX; x <= ToX; ++x){
for (int y = FromY; y <= ToY; ++y){
for (int l = 0; l < Sheet::Num_Layers; ++l){
Tile* t = m_gameMap->GetTile(x, y, l);
if (!t){ continue; }
if (!t->m_solid){ continue; }
sf::FloatRect TileAABB(x*TileSize, y*TileSize,TileSize, TileSize);
sf::FloatRect Intersection;
EntityAABB.intersects(TileAABB, Intersection);
float S = Intersection.width * Intersection.height;
c.emplace_back(S, t->m_properties, TileAABB);
break;
}
}
}
一旦遇到一个固体地砖,就收集其边界框、地砖信息和交点区域细节,并将它们插入到向量 c 中。如果检测到固体地砖,则必须停止层循环,否则碰撞检测可能无法正常工作。
在找到计算范围内实体碰撞的所有固体之后,它们都必须进行排序:
if (c.empty()){ return; }
std::sort(c.begin(), c.end(),
[](CollisionElement& l_1, CollisionElement& l_2){
return l_1.m_area > l_2.m_area;
});
排序后,我们最终可以开始解决碰撞:
for (auto &col : c){
EntityAABB = l_col->GetCollidable();
if (!EntityAABB.intersects(col.m_tileBounds)){ continue; }
float xDiff = (EntityAABB.left + (EntityAABB.width / 2)) -
(col.m_tileBounds.left + (col.m_tileBounds.width / 2));
float yDiff = (EntityAABB.top + (EntityAABB.height / 2)) -
(col.m_tileBounds.top + (col.m_tileBounds.height / 2));
float resolve = 0;
if (std::abs(xDiff) > std::abs(yDiff)){
if (xDiff > 0){
resolve = (col.m_tileBounds.left + TileSize) -
EntityAABB.left;
} else {
resolve = -((EntityAABB.left + EntityAABB.width) -
col.m_tileBounds.left);
}
l_pos->MoveBy(resolve, 0);
l_col->SetPosition(l_pos->GetPosition());
m_systemManager->AddEvent(l_entity,
(EventID)EntityEvent::Colliding_X);
l_col->CollideOnX();
} else {
if (yDiff > 0){
resolve = (col.m_tileBounds.top + TileSize) -
EntityAABB.top;
} else {
resolve = -((EntityAABB.top + EntityAABB.height) -
col.m_tileBounds.top);
}
l_pos->MoveBy(0, resolve);
l_col->SetPosition(l_pos->GetPosition());
m_systemManager->AddEvent(l_entity,
(EventID)EntityEvent::Colliding_Y);
l_col->CollideOnY();
}
}
由于解决一个碰撞可能会解决另一个碰撞,因此在承诺解决碰撞之前,必须检查实体的边界框是否存在交点。实际的解决方法与第七章中描述的几乎相同,即重新发现火焰 – 常见游戏设计元素。
一旦计算了解决细节,位置组件就会根据它移动。可碰撞组件也必须在这里更新,否则它可能会被多次解决并错误地移动。最后需要关注的是向实体的事件队列中添加碰撞事件,并在可碰撞组件中调用 CollideOnX 或 CollideOnY 方法来更新其标志。
接下来是实体与实体之间的碰撞:
void S_Collision::EntityCollisions(){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto itr = m_entities.begin();
itr != m_entities.end(); ++itr)
{
for(auto itr2 = std::next(itr);
itr2 != m_entities.end(); ++itr2){
C_Collidable* collidable1 = entities->
GetComponent<C_Collidable>(*itr, Component::Collidable);
C_Collidable* collidable2 = entities->
GetComponent<C_Collidable>(*itr2, Component::Collidable);
if(collidable1->GetCollidable().intersects(
collidable2->GetCollidable()))
{
// Entity-on-entity collision!
}
}
}
}
此方法通过使用 SFML 矩形类提供的intersects方法,将所有实体与其余所有实体的边界框进行碰撞检查。目前,我们不必担心对这些类型的碰撞做出响应,然而,我们将在未来的章节中使用这个功能。
最后,就像其移动对应物一样,碰撞系统需要一个指向Map类的指针,所以让我们在游戏状态的OnCreate方法中给它一个:
void State_Game::OnCreate(){
...
m_stateMgr->GetContext()->m_systemManager->
GetSystem<S_Collision>(SYSTEM_COLLISION)->SetMap(m_gameMap);
...
}
以下代码片段为碰撞系统提供了所有所需的权力,以防止实体穿过固体瓷砖,如下所示:

摘要
在完成本章内容后,我们成功摆脱了基于继承的实体设计,并通过一种更加模块化的方法强化了我们的代码库,从而避免了组合留下的许多陷阱。链条的强度仅取决于其最薄弱的环节,而现在我们可以放心,实体部分将稳固。
在接下来的两章中,我们将讨论如何通过添加 GUI 系统以及添加一些不同类型的元素,管理它们的事件,并为它们提供图形自定义的空间,来使游戏更加互动和用户友好。那里见!
第十章.我能点击这个吗?——GUI 基础
在非图灵意义上,人类和机器真正有什么共同之处?如今,普通人的日常生活几乎与操作我们物种创造的大量装置同义,然而,我们中的大多数人甚至不说我们使用的设备的语言,这产生了对某种翻译的需求。现在并不是我们不能学会如何直接与机器交流,但鉴于我们的大脑与通用处理器完全不同的工作方式,这太繁琐、太耗时了。存在一个灰色区域,其中人类执行的相对直观的动作也可以被机器理解和解释,而无需涉及任何底层复杂性——即接口的方式。
在本章中,我们将涵盖以下主题:
-
实现所有 GUI 元素的核心数据类型
-
利用 SFML 的渲染纹理实现 GUI 分层
-
通过使用风格属性来奠定平滑和响应式 GUI 交互的基础
我们有很多内容要覆盖,让我们开始吧!
版权资源的使用
在我们开始之前,公正起见,我们应该感谢下一两章中使用的字体和图像的真正创作者:
由Ravenmore在dycha.net/提供的Fantasy UI Elements,根据 CC-BY 3.0 许可:
opengameart.org/content/fantasy-ui-elements-by-ravenmore
由Arro设计的Vegur 字体,根据 CC0 许可(公共领域):
关于这些资源所适用的所有许可证的更多信息,请在此处查找:
creativecommons.org/publicdomain/zero/1.0/
creativecommons.org/licenses/by/3.0/
什么是 GUI?
GUI,即图形用户界面,是用户与软件之间的视觉中介,它作为数字设备或计算机程序的控制系统。使用此类界面比依赖基于文本的控制(如输入命令)更快、更简单。
在编写任何代码之前,我们需要概述我们 GUI 系统的预期功能,该系统将包括三个主要组件:
-
元素:绘制到屏幕上的每个 GUI 表面
-
接口:一种特殊类型的元素,用作其他元素的容器,并且可以移动以及滚动
-
管理器:负责保持 GUI 界面一致性和行为的类
系统中的所有元素都需要能够适应不同的状态,当鼠标悬停或点击时。样式集也需要应用于不同的状态,从而使界面变得响应。最后,你必须能够在运行时从文件中加载界面,并根据它们内部发生的事件或一系列事件将它们与代码绑定。
GUI 样式
统一在 GUI 表面上应用和使用的样式方式对于需要定制和灵活性至关重要。简单来说,手动修改和应用每种可能类型元素的所有样式属性将是一场噩梦,任何代码重用都将变得不可能。这需要一种可以在整个系统中使用的自定义数据类型:GUI_Style结构。
首先,任何和所有的 GUI 元素都应该能够支持以下三种状态:
enum class GUI_ElementState{ Neutral, Focused, Clicked };
虽然这些状态不仅用于图形目的,但每个状态也被定义为一系列视觉属性,以模拟交互和流畅性,这些属性由一系列样式属性表示:
struct GUI_Style{
...
sf::Vector2f m_size; // Element size.
// Background properties.
sf::Color m_backgroundColor;
sf::Color m_elementColor;
std::string m_backgroundImage;
sf::Color m_backgroundImageColor;
// Text properties.
sf::Color m_textColor;
std::string m_textFont;
sf::Vector2f m_textPadding;
unsigned int m_textSize;
bool m_textCenterOrigin;
// Glyph properties.
std::string m_glyph;
sf::Vector2f m_glyphPadding;
};
一个元素或界面可以改变这些属性中的每一个,并根据其状态调整自身以看起来完全不同。如果没有定义,构造函数中设置的默认值将优先,如下所示:
GUI_Style(): m_textSize(12), m_textCenterOrigin(false),
m_backgroundImageColor(255,255,255,255)
{
sf::Color none = sf::Color(0, 0, 0, 0);
m_backgroundColor = none;
m_elementColor = none;
m_textColor = none;
}
如果我们没有可绘制对象来修改,所有这一切都是无用的,所以让我们解决这个问题:
struct GUI_Visual{
sf::RectangleShape m_backgroundSolid;
sf::Sprite m_backgroundImage;
sf::Sprite m_glyph;
sf::Text m_text;
};
这种基本结构将成为每个单独的元素和界面的组成部分,使它们能够通过这四种可绘制元素的任意组合来表示。
扩展实用函数
为了使事情简单易读,总是从将被频繁使用的任何代码中创建实用类型函数是一个好主意。在处理界面反序列化时,许多元素必须读取包含空格的参数。我们解决这个问题的方法是将字符串放在双引号中并定义一个内联函数来读取数据。这个完美的位置是在Utilities.h文件中:
inline void ReadQuotedString(std::stringstream& l_stream,
std::string& l_string)
{
l_stream >> l_string;
if (l_string.at(0) == '"'){
while (l_string.at(l_string.length() - 1) != '"' ||
!l_stream.eof())
{
std::string str;
l_stream >> str;
l_string.append(" " + str);
}
}
l_string.erase(std::remove(l_string.begin(),
l_string.end(), '"'), l_string.end());
}
一个单词从字符串流对象加载到提供的字符串参数中。检查其第一个字符是否为双引号。如果是,则使用while循环继续读取单词并将它们追加到参数字符串中,直到其最后一个字符是双引号或达到流末尾。
之后,字符串中的所有双引号都被删除。
字体管理
在我们开始构建图形用户界面的结构之前,我们需要一种方法来自动管理和处理字体的加载和卸载,就像我们处理纹理一样。我们在第六章中编写的资源管理器的努力,启动! – 动画和移动你的世界,即将得到回报。为了管理字体,我们只需要创建一个FontManager.h文件并编写以下代码:
class FontManager : public ResourceManager<FontManager, sf::Font>{
public:
FontManager() : ResourceManager("fonts.cfg"){}
sf::Font* Load(const std::string& l_path){
sf::Font* font = new sf::Font();
if (!font->loadFromFile(
Utils::GetWorkingDirectory() + l_path))
{
delete font;
font = nullptr;
std::cerr << "! Failed to load font: "
<< l_path << std::endl;
}
return font;
}
};
这在构造函数中定义了字体资源配置文件,以及使用Load方法加载字体文件的具体方式。我们之前实现的资源管理器使这个过程非常简单,所以让我们继续吧!
所有元素的核心
GUI_Element类是每个元素和界面的核心。它提供了高层对象所依赖的关键功能,并强制实现必要的方法,这导致了几个独特的元素类型。
定义不同元素类型的好地方是:
enum class GUI_ElementType{ Window, Label, Button, Scrollbar,
Textfield };
每个元素必须持有它可以根据其状态切换的不同样式。unordered_map数据结构非常适合我们的目的:
using ElementStyles = std::unordered_map<
GUI_ElementState, GUI_Style>;
为了防止交叉包含,对拥有者类的向前声明也是必要的:
class GUI_Interface;
接下来,我们可以开始塑造GUI_Element类:
class GUI_Element{
friend class GUI_Interface;
public:
GUI_Element(const std::string& l_name,
const GUI_ElementType& l_type, GUI_Interface* l_owner);
virtual ~GUI_Element();
// Event methods.
virtual void ReadIn(std::stringstream& l_stream) = 0;
virtual void OnClick(const sf::Vector2f& l_mousePos) = 0;
virtual void OnRelease() = 0;
virtual void OnHover(const sf::Vector2f& l_mousePos) = 0;
virtual void OnLeave() = 0;
virtual void Update(float l_dT) = 0;
virtual void Draw(sf::RenderTarget* l_target) = 0;
virtual void UpdateStyle(const GUI_ElementState& l_state,
const GUI_Style& l_style);
virtual void ApplyStyle();
... // Getters/setters
friend std::stringstream& operator >>(
std::stringstream& l_stream, GUI_Element& b)
{
b.ReadIn(l_stream);
return l_stream;
}
protected:
void ApplyTextStyle();
void ApplyBgStyle();
void ApplyGlyphStyle();
void RequireTexture(const std::string& l_name);
void RequireFont(const std::string& l_name);
void ReleaseTexture(const std::string& l_name);
void ReleaseFont(const std::string& l_name);
void ReleaseResources();
std::string m_name;
sf::Vector2f m_position;
ElementStyles m_style; // Style of drawables.
GUI_Visual m_visual; // Drawable bits.
GUI_ElementType m_type;
GUI_ElementState m_state;
GUI_Interface* m_owner;
bool m_needsRedraw;
bool m_active;
bool m_isControl;
};
任何 GUI 元素最基本的部分是它如何响应用件。这就是纯虚方法的魔力所在。然而,样式应用方法并不是纯虚的。元素在处理其样式时并不比默认元素有所不同。
每个元素也需要一个名称、一个位置、每个可能状态的一组样式、一个可以绘制的视觉组件、一个类型和状态标识符,以及指向一个拥有者类的指针。它还需要跟踪是否需要重新绘制、其活动状态以及一个表示它是否是控件的标志。这些属性由GUI_Element类的私有数据成员集合表示。
在这个结构的大致想法已经确定之后,让我们塑造元素类的更详细细节。
实现 GUI 元素类
我们即将开始实现的类是每个界面和元素的基础。它将定义我们的 GUI 系统如何行为。考虑到这一点,让我们首先看看构造函数,因为我们有很多东西需要初始化:
GUI_Element::GUI_Element(const std::string& l_name,
const GUI_ElementType& l_type, GUI_Interface* l_owner)
: m_name(l_name), m_type(l_type), m_owner(l_owner),
m_state(GUI_ElementState::Neutral), m_needsRedraw(false),
m_active(true), m_isControl(false){}
元素名称、类型以及指向拥有者类的指针被接收并传递到适当的数据成员。其他附加标志也被初始化为默认值。到目前为止,没有什么异常之处。让我们看看这个类是如何被销毁的:
GUI_Element::~GUI_Element(){ ReleaseResources(); }
由于这个类中没有任何动态内存分配,因此释放资源也很简单。为此目的的方法只是在这里简单调用。它看起来有点像这样:
void GUI_Element::ReleaseResources(){
for (auto &itr : m_style){
ReleaseTexture(itr.second.m_backgroundImage);
ReleaseTexture(itr.second.m_glyph);
ReleaseFont(itr.second.m_textFont);
}
}
我们只需要关注元素本身所需的那些纹理和字体,因此每个样式都会被迭代,并且通过相应的方法释放其资源,这些方法看起来都与展示的类似:
void GUI_Element::ReleaseTexture(const std::string& l_name){
if (l_name == ""){ return; }
m_owner->GetManager()->GetContext()->
m_textureManager->ReleaseResource(l_name);
}
如果释放了字体,唯一的不同之处在于所使用的管理器。
说到样式,我们需要有一种规范的方式来修改它们。UpdateStyle方法负责这项工作:
void GUI_Element::UpdateStyle(const GUI_ElementState& l_state,
const GUI_Style& l_style)
{
// Resource management.
if (l_style.m_backgroundImage !=
m_style[l_state].m_backgroundImage)
{
ReleaseTexture(m_style[l_state].m_backgroundImage);
RequireTexture(l_style.m_backgroundImage);
}
if (l_style.m_glyph != m_style[l_state].m_glyph){
ReleaseTexture(m_style[l_state].m_glyph);
RequireTexture(l_style.m_glyph);
}
if (l_style.m_textFont != m_style[l_state].m_textFont){
ReleaseFont(m_style[l_state].m_textFont);
RequireFont(l_style.m_textFont);
}
// Style application.
m_style[l_state] = l_style;
if (l_state == m_state){ SetRedraw(true); ApplyStyle(); }
}
此方法期望两个参数:正在修改的状态和一个将用于替换现有结构的样式结构。虽然使用赋值运算符覆盖相关样式很简单,但在那之前必须进行一些资源管理。我们需要知道被替换的样式是否需要与其他样式不同的资源。如果是,则释放旧的纹理和字体,而新的则通过使用两个看起来类似的辅助方法进行预留:
void GUI_Element::RequireTexture(const std::string& l_name){
if (l_name == ""){ return; }
m_owner->GetManager()->GetContext()->
m_textureManager->RequireResource(l_name);
}
此方法的字体等效使用不同的管理器,但其他方面相同。
一旦样式被覆盖,我们会检查正在修改的状态是否与元素的状态相同。如果是,这个特定的元素将通过SetRedraw方法标记为需要重新绘制,并且其样式将通过ApplyStyle方法应用,这是我们接下来要查看的内容:
void GUI_Element::ApplyStyle(){
ApplyTextStyle();
ApplyBgStyle();
ApplyGlyphStyle();
if (m_owner != this && !IsControl()){
m_owner->AdjustContentSize(this);
}
}
这段代码负责将元素的样式与其视觉表示连接起来。它首先调用几个辅助方法,帮助我们将代码分解成更小、更易于管理的块。之后,需要通知所有者接口,因为任何元素样式的修改可能会导致尺寸变化。如果元素不是接口控件并且不是它自己的所有者,则调用GUI_Interface类的AdjustContentSize方法,并将this关键字作为参数传递。我们很快就会实现它。
让我们看看第一个辅助方法,它处理文本样式:
void GUI_Element::ApplyTextStyle(){
FontManager* fonts = m_owner->GetManager()->
GetContext()->m_fontManager;
const GUI_Style& CurrentStyle = m_style[m_state];
if (CurrentStyle.m_textFont != ""){
m_visual.m_text.setFont(
*fonts->GetResource(CurrentStyle.m_textFont));
m_visual.m_text.setColor(CurrentStyle.m_textColor);
m_visual.m_text.setCharacterSize(CurrentStyle.m_textSize);
if (CurrentStyle.m_textCenterOrigin){
sf::FloatRect rect = m_visual.m_text.getLocalBounds();
m_visual.m_text.setOrigin(rect.left + rect.width / 2.0f,
rect.top + rect.height / 2.0f);
} else {
m_visual.m_text.setOrigin(0.f, 0.f);
}
}
m_visual.m_text.setPosition(m_position +
CurrentStyle.m_textPadding);
}
可以为元素具有的每种不同样式应用不同的字体、颜色和字符大小。每次这些属性被操作时,文本的原始位置也需要重新计算,因为这些属性可以在任何点上被操作。然后,文本的位置会根据当前样式的填充值进行更新。
背景样式应用遵循相同的基本思想:
void GUI_Element::ApplyBgStyle(){
TextureManager* textures = m_owner->GetManager()->
GetContext()->m_textureManager;
const GUI_Style& CurrentStyle = m_style[m_state];
if (CurrentStyle.m_backgroundImage != ""){
m_visual.m_backgroundImage.setTexture(
*textures->GetResource(CurrentStyle.m_backgroundImage));
m_visual.m_backgroundImage.setColor(
CurrentStyle.m_backgroundImageColor);
}
m_visual.m_backgroundImage.setPosition(m_position);
m_visual.m_backgroundSolid.setSize(
sf::Vector2f(CurrentStyle.m_size));
m_visual.m_backgroundSolid.setFillColor(
CurrentStyle.m_backgroundColor);
m_visual.m_backgroundSolid.setPosition(m_position);
}
这展示了我们如何添加对背景图像和实心元素的支持。这两个元素都通过应用当前样式的视觉属性并重新设置其位置进行调整。
最后,元素的符号以相同的方式被改变:
void GUI_Element::ApplyGlyphStyle(){
const GUI_Style& CurrentStyle = m_style[m_state];
TextureManager* textures = m_owner->GetManager()->
GetContext()->m_textureManager;
if (CurrentStyle.m_glyph != ""){
m_visual.m_glyph.setTexture(
*textures->GetResource(CurrentStyle.m_glyph));
}
m_visual.m_glyph.setPosition(m_position +
CurrentStyle.m_glyphPadding);
}
接下来,让我们看看元素状态的变化:
void GUI_Element::SetState(const GUI_ElementState& l_state){
if (m_state == l_state){ return; }
m_state = l_state;
SetRedraw(true);
}
如果元素的状态发生变化,必须将其标记为重新绘制,因为不同的状态可能具有不同的样式元素。然而,这只有在提供的状态参数与当前状态不匹配时才会进行,这样做是为了节省资源。
设置元素位置也值得注意:
void GUI_Element::SetPosition(const sf::Vector2f& l_pos){
m_position = l_pos;
if (m_owner == nullptr || m_owner == this){ return; }
const auto& padding = m_owner->GetPadding();
if (m_position.x < padding.x){ m_position.x = padding.x; }
if (m_position.y < padding.y){ m_position.y = padding.y; }
}
由于所有元素都属于一个容器结构,因此它们的定位也必须尊重这些容器的填充。一旦元素的定位被设置,容器接口的填充就会被获取。如果元素在任一轴上的定位小于该填充,则定位将被设置为至少与边缘一样远,这是接口允许的最小距离。
这里有一段重要的代码,它可以决定与任何 GUI 表面的交互是否成功:
bool GUI_Element::IsInside(const sf::Vector2f& l_point) const{
sf::Vector2f position = GetGlobalPosition();
return(l_point.x >= position.x &&
l_point.y >= position.y &&
l_point.x <= position.x + m_style.at(m_state).m_size.x &&
l_point.y <= position.y + m_style.at(m_state).m_size.y);
}
IsInside 方法用于确定空间中的某个点是否在元素内部。由于它与所有者的相对位置,使用其正常位置计算交集会产生错误的结果。相反,它使用 GetGlobalPosition 方法从所有者接口的渲染纹理中获取元素的位置,而不是局部空间。然后,通过一点基本的边界框碰撞魔法,它根据元素当前样式的尺寸确定提供的点是否在元素内部。
获取元素的全局位置可以这样做:
sf::Vector2f GUI_Element::GetGlobalPosition() const{
sf::Vector2f position = GetPosition();
if (m_owner == nullptr || m_owner == this){ return position; }
position += m_owner->GetGlobalPosition();
if (IsControl()){ return position; }
position.x -= m_owner->m_scrollHorizontal;
position.y -= m_owner->m_scrollVertical;
return position;
}
首先,获取元素的局部位置。然后,该方法确定该元素是否有所有者以及它是否不属于自己。如果有,获取的位置就是最终结果并返回。否则,通过使用此方法获取所有者的全局位置,并将其添加到局部位置。此外,如果元素不是控件类型,则从其位置中减去水平和垂直滚动值,以尊重接口滚动。
最后,这里有一些不是那么直接的设置器和获取器:
Const sf::Vector2f& GUI_Element::GetSize() const{
return m_style.at(m_state).m_size;
}
void GUI_Element::SetActive(const bool& l_active){
if (l_active != m_active){
m_active = l_active;
SetRedraw(true);
}
}
std::string GUI_Element::GetText() const{
return m_visual.m_text.getString();
}
void GUI_Element::SetText(const std::string& l_text){
m_visual.m_text.setString(l_text);
SetRedraw(true);
}
注意
注意 SetActive 和 SetText 方法。每当元素被修改时,我们必须将其重绘标志设置为 true,否则它将不会更新,直到另一个事件需要它。
定义 GUI 事件
与接口提供流畅的交互以及将更改与应用程序内部的操作关联起来的简便方式可能是区分良好 GUI 系统和不良 GUI 系统的最重要的标准。既然我们已经在学习 SFML,我们可以使用 SFML 方法并省略事件。
首先,我们必须定义在接口中可能发生的所有可能的事件。创建一个 GUI_Event.h 文件并构建一个枚举,如下所示:
enum class GUI_EventType{ None, Click, Release, Hover, Leave };
我们还必须在同一个文件中定义一个自定义结构,用于存储事件信息:
struct ClickCoordinates{
float x, y;
};
struct GUI_Event{
GUI_EventType m_type;
const char* m_element;
const char* m_interface;
union{
ClickCoordinates m_clickCoords;
};
};
这里首先要讨论的是结构。在这里仅仅使用sf::Vector2f应该是可能的。在大多数情况下,这会工作得很好,但在那几行下面,你会看到ClickCoordinates的重要性。根据我们将要处理的事件类型,它需要在GUI_Event结构中存储不同的数据。通过在这个结构内部使用一个联合,我们将避免分配额外的内存,但这也有代价。联合不能有成员函数、虚函数或派生自其他类的成员。正是因为这个限制,我们被迫定义自己的struct,它包含两个浮点数并代表一个点。
提示
在这种情况下,boost 库可能是有用的,因为它提供了boost::variant,这是一个类型安全的联合容器,没有这些限制。它还有很少或没有开销。
实际的事件结构包含一个事件类型,用于确定联合中哪个成员是活动的,以及事件起源的元素和接口的名称。如果你对细节有很好的洞察力,你现在可能已经问过自己为什么我们使用const char*数据类型而不是std::string。简化数据成员的数据类型是另一个迹象,表明这个结构将被纳入联合。不幸的是,std::string陷入了与sf::Vector2f相同的陷阱,不能在没有额外工作的前提下用于联合。
接口类
接口,在最简单的意义上,是一个元素的容器。它是一个可以移动和滚动的窗口,具有与常规元素相同的特性和事件钩子。效率也是一个很大的关注点,因为在单个窗口中处理大量元素是肯定可能发生的。这些问题可以通过精心设计在适当时间绘制元素的方式来解决。
我们希望接口绘制内容的方式是使用三个不同的纹理,用于不同的目的,如下所示:

-
背景层用于绘制背景元素
-
内容层是绘制接口所有元素的地方
-
控件层托管如滚动条等元素,这些元素操作内容层且不需要滚动
设计细节确定后,元素存储值得注意。碰巧的是,std::unordered_map结构很好地服务于这个目的:
using Elements = std::unordered_map<std::string,GUI_Element*>;
接下来,需要一个所有者类的声明来防止交叉包含:
class GUI_Manager;
所有这些都带我们来到了GUI_Interface类:
class GUI_Interface : public GUI_Element{
friend class GUI_Element;
friend class GUI_Manager;
public:
...
private:
void DefocusTextfields();
Elements m_elements;
sf::Vector2f m_elementPadding;
GUI_Interface* m_parent;
GUI_Manager* m_guiManager;
sf::RenderTexture* m_backdropTexture;
sf::Sprite m_backdrop;
// Movement.
sf::RectangleShape m_titleBar;
sf::Vector2f m_moveMouseLast;
bool m_showTitleBar;
bool m_movable;
bool m_beingMoved;
bool m_focused;
// Variable size.
void AdjustContentSize(const GUI_Element* l_reference= nullptr);
void SetContentSize(const sf::Vector2f& l_vec);
sf::RenderTexture* m_contentTexture;
sf::Sprite m_content;
sf::Vector2f m_contentSize;
int m_scrollHorizontal;
int m_scrollVertical;
bool m_contentRedraw;
// Control layer.
sf::RenderTexture* m_controlTexture;
sf::Sprite m_control;
bool m_controlRedraw;
};
注意
注意friend类的声明。GUI_Element和GUI_Manager都需要访问这个类的私有和受保护成员。
现在,让我们只关注私有成员,并将公共成员留给本章的实现部分。
除了拥有元素容器外,界面还定义了元素必须遵守的填充量,如果有的话,指向其父类的一个指针,以及管理类和一组代表其不同层的纹理。除非我们讨论实现细节,否则无法完全理解其余的数据成员以及省略的方法,所以让我们直接进入正题!
实现界面类
像往常一样,一个不错的起点是类构造函数:
GUI_Interface::GUI_Interface(const std::string& l_name,
GUI_Manager* l_guiManager)
: GUI_Element(l_name, GUI_ElementType::Window, this),
m_parent(nullptr), m_guiManager(l_guiManager), m_movable(false),
m_beingMoved(false), m_showTitleBar(false), m_focused(false),
m_scrollHorizontal(0),m_scrollVertical(0),m_contentRedraw(true),
m_controlRedraw(true)
{
m_backdropTexture = new sf::RenderTexture();
m_contentTexture = new sf::RenderTexture();
m_controlTexture = new sf::RenderTexture();
}
在这里,通过初始化列表初始化了很多数据成员。首先,父类 GUI_Element 需要知道界面的名字、类型和所有者。GUI_Interface 的一个参数是其名字,它被传递给 GUI_Element 构造函数。类型当然设置为 Window,而 this 关键字作为界面的所有者传递。此外,界面的父类被初始化为其默认值 nullptr,并将指向 GUI_Manager 类的指针存储在 m_guiManager 数据成员中。
在数据成员初始化之后,我们进入构造函数的主体,其中动态分配了三个 sf::RenderTexture 对象。这些是用于渲染界面背景、内容和控制层的纹理。
接下来,让我们看看在析构函数中释放所有这些资源:
GUI_Interface::~GUI_Interface(){
delete m_backdropTexture;
delete m_contentTexture;
delete m_controlTexture;
for (auto &itr : m_elements){
delete itr.second;
}
}
当然,这三个纹理实例也必须被删除,以及所有在销毁时仍然存在于元素容器中的单个元素。之后,元素容器被清空。
设置界面的位置稍微复杂一些,所以让我们来看看:
void GUI_Interface::SetPosition(const sf::Vector2f& l_pos){
GUI_Element::SetPosition(l_pos);
m_backdrop.setPosition(l_pos);
m_content.setPosition(l_pos);
m_control.setPosition(l_pos);
m_titleBar.setPosition(m_position.x, m_position.y - m_titleBar.getSize().y);
m_visual.m_text.setPosition(m_titleBar.getPosition() + m_style[m_state].m_textPadding);
}
首先,调用父类的 SetPosition 方法来调整实际位置。没有必要修复没有损坏的东西。接下来,调整代表背景、内容和控制层的三个精灵的位置。最后,你设置标题栏。实心背景形状的位置被设置为在界面之上,而视觉组件的文本用作标题,并调整到与标题栏背景相同的位置,只是增加了文本填充。
空的窗口并不很有用或有趣,所以让我们提供一种方法,通过这种方法可以将元素添加到它们中:
bool GUI_Interface::AddElement(const GUI_ElementType& l_type,
const std::string& l_name)
{
if (m_elements.find(l_name) != m_elements.end()){return false;}
GUI_Element* element = nullptr;
element = m_guiManager->CreateElement(l_type, this);
if (!element){ return false; }
element->SetName(l_name);
element->SetOwner(this);
m_elements.emplace(l_name, element);
m_contentRedraw = true;
m_controlRedraw = true;
return true;
}
避免名称冲突很重要,所以需要将提供的第二个参数中的名称与元素容器进行核对,以防止重复。如果没有找到任何重复项,就使用 GUI_Manager 类的 CreateElement 方法在堆上创建相关类型的元素,并返回其内存地址。在确认确实创建之后,设置元素的名字和所有者属性,然后将其插入到元素容器中。然后界面设置两个标志以重新绘制内容和控制层。
任何界面都需要一种提供对其元素访问的方法。这就是 GetElement 方法的作用所在:
GUI_Element* GUI_Interface::GetElement(const std::string& l_name)
const{
auto itr = m_elements.find(l_name);
return(itr != m_elements.end() ? itr->second : nullptr);
}
它只是使用其查找方法在 std::unordered_map 中定位元素并返回它。如果找不到元素,则返回 nullptr。很简单。
接下来,我们需要一种方法从界面中移除元素:
bool GUI_Interface::RemoveElement(const std::string& l_name){
auto itr = m_elements.find(l_name);
if (itr == m_elements.end()){ return false; }
delete itr->second;
m_elements.erase(itr);
m_contentRedraw = true;
m_controlRedraw = true;
AdjustContentSize();
return true;
}
按照与 GetElement 方法相同的示例,首先在容器内定位元素。然后使用 delete 操作符释放动态内存,并将元素本身从容器中移除。界面被标记为重新绘制其内容和控制层,如果需要,调用 AdjustContentSize 方法来调整内容纹理的大小。
我们需要覆盖原始的 IsInside 方法,因为界面由于标题栏的存在而占用额外的空间,如下所示:
bool GUI_Interface::IsInside(const sf::Vector2f& l_point) const{
if (GUI_Element::IsInside(l_point)){ return true; }
return m_titleBar.getGlobalBounds().contains(l_point);
}
首先调用父类方法以确定 l_point 是否在界面占用的空间内。如果不是,则返回标题栏边界框的 contains 方法的结果以确定 l_point 是否在该区域内。
接下来展示的是代码的反序列化部分:
void GUI_Interface::ReadIn(std::stringstream& l_stream){
std::string movableState;
std::string titleShow;
std::string title;
l_stream >> m_elementPadding.x >> m_elementPadding.y >> movableState >> titleShow;
Utils::ReadQuotedString(l_stream, title);
m_visual.m_text.setString(title);
if (movableState == "Movable"){ m_movable = true; }
if (titleShow == "Title"){ m_showTitleBar = true; }
}
所有界面首先读取元素的填充 x 和 y 值,以及状态和标题参数。然后使用我们之前定义的 ReadQuotedText 工具函数来读取界面的实际标题。根据读取的字符串,它随后设置 m_movable 和 m_showTitleBar 标志以反映这些值。
现在是有趣的部分。让我们定义当界面被点击时会发生什么:
void GUI_Interface::OnClick(const sf::Vector2f& l_mousePos){
DefocusTextfields();
if (m_titleBar.getGlobalBounds().contains(l_mousePos) &&
m_movable && m_showTitleBar)
{
m_beingMoved = true;
} else {
GUI_Event event;
event.m_type = GUI_EventType::Click;
event.m_interface = m_name.c_str();
event.m_element = "";
event.m_clickCoords.x = l_mousePos.x;
event.m_clickCoords.y = l_mousePos.y;
m_guiManager->AddEvent(event);
for (auto &itr : m_elements){
if (!itr.second->IsInside(l_mousePos)){ continue; }
itr.second->OnClick(l_mousePos);
event.m_element = itr.second->m_name.c_str();
m_guiManager->AddEvent(event);
}
SetState(GUI_ElementState::Clicked);
}
}
首先,我们调用负责从所有 Textfield GUI 元素中移除焦点的私有辅助方法之一。这将在稍后进行更深入的讨论。另一个问题是当在界面中检测到点击时发生的拖动。如果鼠标位置在标题栏区域,并且界面本身是可移动的,我们将 m_beingMoved 标志设置为 true 以指示界面拖动。
如果只是界面边界内的常规点击,我们首先设置一个将要分发的事件,表示发生了点击。类型设置为 Click,界面名称被复制为 c 字符串,并且也设置了鼠标坐标。使用我们新创建的事件作为参数调用 GUI_Manager 类的 AddEvent 方法。这个第一个事件表明点击发生在界面本身,而不是任何特定的元素中。
这很快就会跟着一个循环,该循环遍历界面中的每一个单独的元素。它们的IsInside方法被调用以确定发生的点击是否也位于任何元素内部。如果是这样,那个特定元素的OnClick方法就会调用,并将鼠标位置作为参数传入。然后,在循环之前设置的同一次事件被稍微修改,包含元素的名称,并再次触发,表示点击也影响了它。随后,界面的状态被更改为CLICKED。这种结果看起来相当吸引人:

接下来,让我们看看点击的相反面——OnRelease方法:
void GUI_Interface::OnRelease(){
GUI_Event event;
event.m_type = GUI_EventType::Release;
event.m_interface = m_name.c_str();
event.m_element = "";
m_guiManager->AddEvent(event);
for (auto &itr : m_elements){
if (itr.second->GetState() != GUI_ElementState::Clicked)
{
continue;
}
itr.second->OnRelease();
event.m_element = itr.second->m_name.c_str();
m_guiManager->AddEvent(event);
}
SetState(GUI_ElementState::Neutral);
}
就像之前一样,设置了一个事件并触发,表示在这个特定界面内发生了释放。然后,每个元素都会被迭代,并检查它们的状态。如果元素处于Clicked状态,它的OnRelease方法会被调用,并再次触发一个事件,表示在该元素内释放了左鼠标按钮。随后,界面的状态被设置为Neutral。
界面还需要处理输入的文本:
void GUI_Interface::OnTextEntered(const char& l_char){
for (auto &itr : m_elements){
if (itr.second->GetType() != GUI_ElementType::Textfield){
continue;
}
if (itr.second->GetState() != GUI_ElementState::Clicked){
continue;
}
if (l_char == 8){
// Backspace.
const auto& text = itr.second->GetText();
itr.second->SetText(text.substr(0, text.length() -1));
return;
}
if (l_char < 32 || l_char > 126){ return; }
std::string text = itr.second->GetText();
text.push_back(l_char);
itr.second->SetText(text);
return;
}
}
当我们的窗口接收到 SFML 事件sf::Event::TextEntered时,这个方法将会被调用。每个元素都会被迭代,直到我们找到一个类型为Textfield且当前处于Clicked状态的元素。按下的退格键通过从我们元素文本属性中剪除最后一个字符来处理。请注意,我们在方法中的多个地方返回,以避免多个Textfield元素接收到相同的输入文本。
最后,我们需要检查接收到的字符值的边界。任何低于 ID 32或高于126的字符都保留用于其他目的,我们对此不感兴趣。如果输入的是常规字母或数字,我们希望通过将这个字符添加到它来更新我们的文本属性。
提示
ASCII 字符的完整表可以在这里找到:www.asciitable.com/
既然我们正在处理文本字段元素的处理,让我们看看在处理Click事件时我们之前使用过的一个方法:
void GUI_Interface::DefocusTextfields(){
GUI_Event event;
event.m_type = GUI_EventType::Release;
event.m_interface = m_name.c_str();
event.m_element = "";
for (auto &itr : m_elements){
if (itr.second->GetType() != GUI_ElementType::Textfield){
continue;
}
itr.second->SetState(GUI_ElementState::Neutral);
event.m_element = itr.second->m_name.c_str();
m_guiManager->AddEvent(event);
}
}
在处理文本字段时,重要的是要记住,每次鼠标左键被点击时,它们都会失去焦点。如果不是这样,我们最终会在多个文本框中输入文本,这是不可取的。使文本字段失去焦点就像构造一个Release事件并将其发送到界面拥有的每个Textfield元素一样简单。
下两个方法因为它们的相似性而被组合在一起:
void GUI_Interface::OnHover(const sf::Vector2f& l_mousePos){
GUI_Event event;
event.m_type = GUI_EventType::Hover;
event.m_interface = m_name.c_str();
event.m_element = "";
event.m_clickCoords.x = l_mousePos.x;
event.m_clickCoords.y = l_mousePos.y;
m_guiManager->AddEvent(event);
SetState(GUI_ElementState::Focused);
}
void GUI_Interface::OnLeave(){
GUI_Event event;
event.m_type = GUI_EventType::Leave;
event.m_interface = m_name.c_str();
event.m_element = "";
m_guiManager->AddEvent(event);
SetState(GUI_ElementState::Neutral);
}
当鼠标悬停在界面上时,构建一个包含鼠标坐标的事件,而不是在OnLeave方法中鼠标离开界面区域时。OnHover和OnLeave在每个事件中只调用一次,因为它们不处理元素。这项工作留给了Update方法:
void GUI_Interface::Update(float l_dT){
sf::Vector2f mousePos = sf::Vector2f(
m_guiManager->GetContext()->m_eventManager->GetMousePos(
m_guiManager->GetContext()->m_wind->GetRenderWindow()));
if (m_beingMoved && m_moveMouseLast != mousePos){
sf::Vector2f difference = mousePos - m_moveMouseLast;
m_moveMouseLast = mousePos;
sf::Vector2f newPosition = m_position + difference;
SetPosition(newPosition);
}
...
}
在获得鼠标位置后,检查m_beingMoved标志以确定界面是否当前正在拖动。如果是,并且保存的鼠标位置与当前鼠标位置不同,则计算这个差异并根据它调整界面的位置。处理完这些后,让我们看看省略的代码块:
for (auto &itr : m_elements){
if (itr.second->NeedsRedraw()){
if (itr.second->IsControl()){ m_controlRedraw = true; }
else { m_contentRedraw = true; }
}
if (!itr.second->IsActive()){ continue; }
itr.second->Update(l_dT);
if (m_beingMoved){ continue; }
GUI_Event event;
event.m_interface = m_name.c_str();
event.m_element = itr.second->m_name.c_str();
event.m_clickCoords.x = mousePos.x;
event.m_clickCoords.y = mousePos.y;
if (IsInside(mousePos) && itr.second->IsInside(mousePos)
&& !m_titleBar.getGlobalBounds().contains(mousePos))
{
if (itr.second->GetState() != GUI_ElementState::Neutral){
continue;
}
itr.second->OnHover(mousePos);
event.m_type = GUI_EventType::Hover;
} else if (itr.second->GetState() == GUI_ElementState::Focused){
itr.second->OnLeave();
event.m_type = GUI_EventType::Leave;
}
m_guiManager->AddEvent(event);
}
我们首先检查当前元素是否需要重新绘制。如果遇到一个需要重新绘制整个界面的相关标志被设置为true,同时考虑到它是否是一个控制元素。
当遍历所有元素的列表时,会检查它们的活动状态。如果一个元素处于活动状态,它将被更新。如果当前界面没有被移动,并且鼠标在界面和元素内部,但不在标题栏上,则检查元素当前的状态。如果元素当前状态为Neutral,则需要分发一个Hover事件并调用OnHover方法。然而,如果鼠标不在元素上,或者当前界面的状态是Focused,则创建一个Leave事件并将其提交,同时调用OnLeave方法。
现在,让我们将所有这些辛勤工作带到屏幕上并渲染界面:
void GUI_Interface::Draw(sf::RenderTarget* l_target){
l_target->draw(m_backdrop);
l_target->draw(m_content);
l_target->draw(m_control);
if (!m_showTitleBar){ return; }
l_target->draw(m_titleBar);
l_target->draw(m_visual.m_text);
}
这相当简单,多亏了我们的设计涉及三个不同的渲染纹理。为了成功绘制界面,背景、内容和控制层的精灵必须按照特定的顺序绘制。如果m_showTitleBar标志设置为true,则标题背景也必须与文本一起绘制。
虽然Update方法做了大部分工作,但移动界面需要更多的准备。让我们从定义两个用于移动的辅助方法开始,首先是用于启动过程的那个:
void GUI_Interface::BeginMoving(){
if (!m_showTitleBar || !m_movable){ return; }
m_beingMoved = true;
SharedContext* context = m_guiManager->GetContext();
m_moveMouseLast = sf::Vector2f(context->m_eventManager->
GetMousePos(context->m_wind->GetRenderWindow()));
}
如果满足移动界面的条件,则调用此方法以在拖动开始时保存鼠标位置。
我们还有一个简单的代码行来停止界面移动:
void GUI_Interface::StopMoving(){ m_beingMoved = false; }
由于界面与普通 GUI 元素有很大不同,它们必须定义自己的方式来获取它们的全局位置,如下所示:
sf::Vector2f GUI_Interface::GetGlobalPosition() const{
sf::Vector2f pos = m_position;
GUI_Interface* i = m_parent;
while (i){
pos += i->GetPosition();
i = i->m_parent;
}
return pos;
}
当它获得其实际位置时,它需要遍历父界面的链并求和它们的所有位置。一个while循环是一个很好的做法;当循环结束时返回最终位置。
界面的样式应用也不同于常规元素类型。让我们看看:
void GUI_Interface::ApplyStyle(){
GUI_Element::ApplyStyle(); // Call base method.
m_visual.m_backgroundSolid.setPosition(0.f,0.f);
m_visual.m_backgroundImage.setPosition(0.f,0.f);
m_titleBar.setSize(sf::Vector2f(m_style[m_state].m_size.x, 16.f));
m_titleBar.setPosition(m_position.x,m_position.y - m_titleBar.getSize().y);
m_titleBar.setFillColor(m_style[m_state].m_elementColor);
m_visual.m_text.setPosition(m_titleBar.getPosition() + m_style[m_state].m_textPadding);
m_visual.m_glyph.setPosition(m_titleBar.getPosition() + m_style[m_state].m_glyphPadding);
}
首先调用ApplyStyle方法,因为父类在正确设置大多数视觉组件方面做得很好。然后需要将背景元素的位置更改为绝对零值,因为界面将这些可绘制元素渲染到纹理上而不是屏幕上。无论界面的位置如何,这些元素的位置都不会改变。
接下来,设置标题栏背景以匹配界面在x轴上的大小,并且在y轴上应有 16 像素的高度。这个硬编码的值可以在任何时候进行调整。其位置被设置为正好位于界面之上。标题背景的填充颜色由其样式的元素颜色属性定义。
最后四行设置了标题栏文本和图标的定位。标题栏背景的定位与相关填充量相加,以获得这两个属性的最终位置。
渲染时间!让我们将这些视觉元素绘制到它们各自的纹理上,从背景层开始:
void GUI_Interface::Redraw(){
if (m_backdropTexture->getSize().x!=m_style[m_state].m_size.x ||
m_backdropTexture->getSize().y != m_style[m_state].m_size.y)
{
m_backdropTexture->create(m_style[m_state].m_size.x,
m_style[m_state].m_size.y);
}
m_backdropTexture->clear(sf::Color(0, 0, 0, 0));
ApplyStyle();
m_backdropTexture->draw(m_visual.m_backgroundSolid);
if (m_style[m_state].m_backgroundImage != ""){
m_backdropTexture->draw(m_visual.m_backgroundImage);
}
m_backdropTexture->display();
m_backdrop.setTexture(m_backdropTexture->getTexture());
m_backdrop.setTextureRect(sf::IntRect(0, 0,
m_style[m_state].m_size.x, m_style[m_state].m_size.y));
SetRedraw(false);
}
首先,进行一次检查以确保背景纹理的大小与当前样式规定的大小相同。如果不相同,则使用正确的大小重新创建纹理。
下一行对于获得良好外观的结果至关重要。乍一看,它只是将纹理清除为黑色。然而,如果你仔细观察,你会发现它有四个参数而不是三个。最后一个参数是alpha 通道,或颜色的透明度值。清除为黑色的纹理看起来像一个大黑方块,这并不是我们想要的。相反,我们希望它在绘制元素之前完全为空,这正是0的 alpha 值所做到的。
接下来,调用ApplyStyle方法以调整界面的视觉部分以匹配当前样式。然后,将背景固体和背景图像绘制到背景纹理上。为了显示对其所做的所有更改,必须调用纹理的display方法,就像渲染窗口一样。
最后,将背景精灵绑定到背景纹理上,并将其可视区域裁剪到界面大小,以防止溢出。重绘标志被设置为false以指示此过程已完成。
对于内容层也需要发生一个非常类似的过程:
void GUI_Interface::RedrawContent(){
if (m_contentTexture->getSize().x != m_contentSize.x ||
m_contentTexture->getSize().y != m_contentSize.y)
{
m_contentTexture->create(m_contentSize.x, m_contentSize.y);
}
m_contentTexture->clear(sf::Color(0, 0, 0, 0));
for (auto &itr : m_elements){
GUI_Element* element = itr.second;
if (!element->IsActive() || element->IsControl()){ continue; }
element->ApplyStyle();
element->Draw(m_contentTexture);
element->SetRedraw(false);
}
m_contentTexture->display();
m_content.setTexture(m_contentTexture->getTexture());
m_content.setTextureRect(sf::IntRect(
m_scrollHorizontal, m_scrollVertical,
m_style[m_state].m_size.x, m_style[m_state].m_size.y));
m_contentRedraw = false;
}
检查内容纹理的尺寸。这里唯一的区别是我们正在手动跟踪其大小在m_contentSize浮点向量中,这将在稍后介绍。
在清除纹理之后,我们遍历界面内的所有元素,检查它们是否处于活动状态或是一个控件元素。如果所有这些条件都满足,元素的样式就会被应用,并将其渲染到传入Draw方法的内容纹理上,其重绘标志随后被设置为false。
在显示纹理并将其绑定到相关精灵之后,它也会被裁剪,但这次,我们使用m_scrollHorizontal和m_scrollVertical数据成员作为前两个参数,以便考虑滚动。请考虑以下插图:

滚动界面意味着将裁剪的矩形在内容纹理上移动。然后m_contentRedraw标志被设置为false,以表示重绘过程已结束。这让我们得到了如下结果:

界面的最后一层遵循几乎相同的路径:
void GUI_Interface::RedrawControls(){
if (m_controlTexture->getSize().x!=m_style[m_state].m_size.x ||
m_controlTexture->getSize().y != m_style[m_state].m_size.y)
{
m_controlTexture->create(m_style[m_state].m_size.x,
m_style[m_state].m_size.y);
}
m_controlTexture->clear(sf::Color(0, 0, 0, 0));
for (auto &itr : m_elements){
GUI_Element* element = itr.second;
if (!element->IsActive() || !element->IsControl()){ continue; }
element->ApplyStyle();
element->Draw(m_controlTexture);
element->SetRedraw(false);
}
m_controlTexture->display();
m_control.setTexture(m_controlTexture->getTexture());
m_control.setTextureRect(sf::IntRect(0, 0,
m_style[m_state].m_size.x, m_style[m_state].m_size.y));
m_controlRedraw = false;
}
这里的主要区别在于纹理旨在匹配当前样式的大小,就像背景层一样。这次只绘制了控制元素。
界面滚动的主题不断出现,所以让我们看看它是如何实现的:
void GUI_Interface::UpdateScrollHorizontal(
unsigned int l_percent)
{
if (l_percent > 100){ return; }
m_scrollHorizontal = ((m_contentSize.x - GetSize().x) / 100) *
l_percent;
sf::IntRect rect = m_content.getTextureRect();
m_content.setTextureRect(sf::IntRect(
m_scrollHorizontal, m_scrollVertical,rect.width,rect.height));
}
void GUI_Interface::UpdateScrollVertical(unsigned int l_percent){
if (l_percent > 100){ return; }
m_scrollVertical = ((m_contentSize.y - GetSize().y) / 100) *
l_percent;
sf::IntRect rect = m_content.getTextureRect();
m_content.setTextureRect(sf::IntRect(
m_scrollHorizontal, m_scrollVertical,rect.width,rect.height));
}
水平和垂直调整方法都接受一个百分比值,告诉界面它应该滚动多少。界面应该偏移的实际像素量是通过首先将相关轴上内容大小与其自身大小的差值除以一百,然后将结果乘以百分比参数来计算的。然后获取纹理矩形以保持内容区域的适当宽度和高度,然后使用滚动值作为前两个参数重新设置。这有效地模拟了界面的滚动感觉。
在界面内部添加、删除或操作不同的元素可能会改变其大小。这里有一个解决这些问题的方法:
void GUI_Interface::AdjustContentSize(
const GUI_Element* l_reference)
{
if (l_reference){
sf::Vector2f bottomRight =
l_reference->GetPosition() + l_reference->GetSize();
if (bottomRight.x > m_contentSize.x){
m_contentSize.x = bottomRight.x;
m_controlRedraw = true;
}
if (bottomRight.y > m_contentSize.y){
m_contentSize.y = bottomRight.y;
m_controlRedraw = true;
}
return;
}
sf::Vector2f farthest = GetSize();
for (auto &itr : m_elements){
GUI_Element* element = itr.second;
if (!element->IsActive() || element->IsControl()){ continue; }
sf::Vector2f bottomRight =
element->GetPosition() + element->GetSize();
if (bottomRight.x > farthest.x){
farthest.x = bottomRight.x;
m_controlRedraw = true;
}
if (bottomRight.y > farthest.y){
farthest.y = bottomRight.y;
m_controlRedraw = true;
}
}
SetContentSize(farthest);
}
在深入探讨它之前,我可以向你展示,在类定义内部,这个方法看起来是这样的:
void AdjustContentSize(const GUI_Element* l_reference = nullptr);
它的唯一参数有一个默认值nullptr,这使得方法能够在有或没有引用元素的情况下检测大小变化。
如果提供了一个元素作为参数,这通常发生在将其添加到界面中时,其右下角坐标将使用其位置和大小来计算。如果这些坐标位于内容大小边界之外,则内容大小将被调整以更大,并且控制重绘标志被设置为true,因为滑动条的物理尺寸将会改变。然后方法返回,以防止执行其余逻辑。
没有参考元素,浮点向量被设置来跟踪界面纹理内的最远点,其原始值是界面大小。然后,遍历每个活动的非控件元素,检查它是否超过了纹理中的最远点,这只是在相关轴上简单地被覆盖。如果找到一个超出这些边界的元素,它的右下角位置将被存储,并且控件层将被标记为需要重绘。在检查所有元素之后,内容大小被设置为界面的最远角。
这个最后的代码片段结束了界面类的编写。
摘要
正如没有装订的书只是一摞纸张一样,我们编写的代码如果不被正确地整合和管理,就不会成为它需要成为的样子。我们在本章中打下的基础将极大地帮助我们实现一个完全功能的 GUI 系统,但它只代表了所有部件的布局。
到目前为止,我们已经涵盖了 GUI 元素和窗口的基本设计,以及实现了许多不同类型元素可以使用的有用功能。虽然代码量已经不少,但我们还没有完成。在下一章中,我们将把所有我们已经工作的部分整合在一起,以及创建实际的 GUI 元素。那里见!
第十一章。别碰那个红按钮! – 实现 GUI
在上一章中,我们介绍了基础知识并创建了图形用户界面组装所需的构建块。尽管这可能看起来代码很多,但要让其运行还需要更多的工作。正确管理接口、代码库的其他部分的良好支持以及 GUI 系统本身的用户友好语义都是至关重要的。让我们完成在第十章中设定的目标,我能点击这个吗? – GUI 基础知识,并最终为用户提供一种接口方式。
在本章中,我们将涵盖以下主题:
-
接口及其事件的管理
-
扩展事件管理类以支持额外的 GUI 功能
-
创建我们的第一个元素类型
-
我们 GUI 系统的集成和使用
在所有部件就位后,让我们让我们的接口活跃起来!
GUI 管理器
在幕后负责整个表演的“木偶大师”,在这种情况下,必须是GUI_Manager类。它负责存储应用程序中的所有接口以及维护它们的状态。所有鼠标输入处理都源自这个类,并传递到所有权树中。让我们先解决一些类型定义问题:
using GUI_Interfaces = std::unordered_map<std::string,
GUI_Interface*>;
using GUI_Container = std::unordered_map<StateType,
GUI_Interfaces>;
using GUI_Events = std::unordered_map<StateType,
std::vector<GUI_Event>>;
using GUI_Factory = std::unordered_map<GUI_ElementType,
std::function<GUI_Element*(GUI_Interface*)>>;
using GUI_ElemTypes = std::unordered_map<std::string,
GUI_ElementType>;
我们将使用std::unordered_map数据结构,通过名称索引它们来存储接口数据。接口数据容器还需要按游戏状态分组,这正是下一个类型定义的目的。同样,GUI 事件需要根据相关的游戏状态进行索引。事件本身存储在std::vector中。
此外,由于我们将以类似之前的方式以工厂模式创建元素,因此创建了一个工厂类型定义。这里的主要区别在于我们将存储的lambda函数需要接收一个指向所有者接口的指针,以便正确构造。
最后,我们将映射元素类型字符串到实际的枚举值。再次,std::unordered_map类型再次发挥作用。
现在,这里是类定义本身:
struct SharedContext; // Forward declaration.
class GUI_Manager{
friend class GUI_Interface;
public:
GUI_Manager(EventManager* l_evMgr, SharedContext* l_context);
~GUI_Manager();
...
template<class T>
void RegisterElement(const GUI_ElementType& l_id){
m_factory[l_id] = [](GUI_Interface* l_owner) -> GUI_Element*
{ return new T("",l_owner); };
}
private:
GUI_Element* CreateElement(const GUI_ElementType& l_id,
GUI_Interface* l_owner);
GUI_ElementType StringToType(const std::string& l_string);
bool LoadStyle(const std::string& l_file,
GUI_Element* l_element);
GUI_Container m_interfaces;
GUI_Events m_events;
SharedContext* m_context;
StateType m_currentState;
GUI_Factory m_factory;
GUI_ElemTypes m_elemTypes;
};
一开始,我们可以看出元素将使用工厂方法,因为存在RegisterElement方法。它存储一个以所有者接口指针作为唯一参数的lambda函数,该函数返回一个带有空白名称的GUI_Element类型,由l_id参数指定的给定类型构造而成。它的私有方法友元CreateElement将使用存储的lambda函数并返回指向新创建内存的指针。
在深入实现这个类之前,还有一个需要注意的事情,那就是存在一个接受GUI_Element类型的LoadStyle方法。管理类负责反序列化样式文件并根据它们正确设置元素,以避免在元素和接口类中造成混乱。
实现 GUI 管理器
在处理完类头文件之后,我们可以直接进入实现我们的 GUI 管理器。GUI_Manager类的构造函数定义如下:
GUI_Manager::GUI_Manager(EventManager* l_evMgr,
SharedContext* l_shared): m_eventMgr(l_evMgr),
m_context(l_shared), m_currentState(StateType(0))
{
RegisterElement<GUI_Label>(GUI_ElementType::Label);
RegisterElement<GUI_Scrollbar>(GUI_ElementType::Scrollbar);
RegisterElement<GUI_Textfield>(GUI_ElementType::Textfield);
m_elemTypes.emplace("Label", GUI_ElementType::Label);
m_elemTypes.emplace("Button", GUI_ElementType::Button);
m_elemTypes.emplace("Scrollbar", GUI_ElementType::Scrollbar);
m_elemTypes.emplace("TextField", GUI_ElementType::Textfield);
m_elemTypes.emplace("Interface", GUI_ElementType::Window);
m_eventMgr->AddCallback(StateType(0),
"Mouse_Left", &GUI_Manager::HandleClick, this);
m_eventMgr->AddCallback(StateType(0),
"Mouse_Left_Release", &GUI_Manager::HandleRelease, this);
m_eventMgr->AddCallback(StateType(0),
"Text_Entered", &GUI_Manager::HandleTextEntered, this);
}
它需要一个指向事件管理器的指针和共享上下文结构作为参数,并通过初始化列表设置它们,同时为当前状态设置一个默认值。在函数体内部,我们可以看到这个类首先注册了我们将要使用的三个元素类型。它还填充了元素类型映射,这将用于后续的检查。最后,它注册了三个回调:两个用于左鼠标按钮的按下和释放,一个用于文本输入。请注意,这些回调被注册为无论应用程序处于何种状态都会被调用。
GUI_Manager::~GUI_Manager(){
m_eventMgr->RemoveCallback(StateType(0), "Mouse_Left");
m_eventMgr->RemoveCallback(StateType(0), "Mouse_Left_Release");
m_eventMgr->RemoveCallback(StateType(0), "Text_Entered");
for (auto &itr : m_interfaces){
for (auto &itr2 : itr.second){
delete itr2.second;
}
}
}
析构函数移除了在构造函数中注册的所有回调,并对每个界面进行迭代以正确释放动态分配的内存。然后清除界面和事件容器。
让我们看看如何将一个界面添加到 GUI 管理器中:
bool GUI_Manager::AddInterface(const StateType& l_state,
const std::string& l_name)
{
auto s = m_interfaces.emplace(l_state, GUI_Interfaces()).first;
GUI_Interface* temp = new GUI_Interface(l_name, this);
if (s->second.emplace(l_name, temp).second){ return true; }
delete temp;
return false;
}
当提供有效的应用程序状态和未使用的界面名称时,为界面分配动态内存并尝试插入。在插入过程中遇到任何问题都会被emplace方法的返回值捕获,该值存储在i变量中。如果失败,将释放内存并返回false以表示失败。否则,返回true。
获取一个界面就像这样简单:
GUI_Interface* GUI_Manager::GetInterface(const StateType& l_state,
const std::string& l_name)
{
auto s = m_interfaces.find(l_state);
if (s == m_interfaces.end()){ return nullptr; }
auto i = s->second.find(l_name);
return (i != s->second.end() ? i->second : nullptr);
}
如果找到作为参数提供的状态,并且还找到了提供的名称的界面,则返回它。找不到有效状态或正确界面的情况由返回值nullptr表示。
通过操作容器结构来移除界面:
bool GUI_Manager::RemoveInterface(const StateType& l_state,
const std::string& l_name)
{
auto s = m_interfaces.find(l_state);
if (s == m_interfaces.end()){ return false; }
auto i = s->second.find(l_name);
if (i == s->second.end()){ return false; }
delete i->second;
return s->second.erase(l_name);
}
注意
注意,如果找到状态和界面,delete关键字就会出现。有时,很容易忘记在堆上释放不再使用的内存,这会导致内存泄漏。
由于 GUI 管理器需要跟踪当前应用程序状态,因此需要以下方法:
void GUI_Manager::SetCurrentState(const StateType& l_state){
if (m_currentState == l_state){ return; }
HandleRelease(nullptr);
m_currentState = l_state;
}
除了更改当前状态的数据成员外,它还调用了HandleRelease方法,以防止界面和元素状态粘滞。如果一个元素被点击并且状态突然改变,该元素将保持在CLICKED状态,直到它被悬停,除非调用HandleRelease。
现在,让我们处理鼠标输入以提供与我们的界面交互:
void GUI_Manager::HandleClick(EventDetails* l_details){
auto state = m_interfaces.find(m_currentState);
if (state == m_interfaces.end()){ return; }
sf::Vector2i mousePos = m_eventMgr->
GetMousePos(m_context->m_wind->GetRenderWindow());
for (auto itr = state->second.rbegin();
itr != state->second.rend(); ++itr)
{
if (!itr->second->IsInside(sf::Vector2f(mousePos))){continue;}
if (!itr->second->IsActive()){ return; }
itr->second->OnClick(sf::Vector2f(mousePos));
itr->second->Focus();
if (itr->second->IsBeingMoved()){itr->second->BeginMoving();}
return;
}
}
这个方法,就像它的HandleRelease兄弟方法一样,只接受一个类型为EventDetails的参数。目前,只需忽略它,因为它对GUI_Manager没有任何影响,将在本章的后面处理。
首先,它获取相对于窗口的当前鼠标位置。接下来,获取接口容器的迭代器并检查其有效性。然后,以相反的顺序遍历属于当前状态的每个接口,这给新添加的接口提供了优先权。如果它处于活动状态,并且鼠标位置在其边界内,则调用其OnClick方法,并将鼠标位置作为参数传递。然后检查接口的m_beingMoved标志,因为点击可能发生在其标题栏的边界内。如果是这样,则调用BeginMoving方法来完成拖动操作。在这个点上,我们简单地从方法中返回,以防止一次点击影响多个接口。
处理左鼠标按钮释放遵循相同的约定:
void GUI_Manager::HandleRelease(EventDetails* l_details){
auto state = m_interfaces.find(m_currentState);
if (state == m_interfaces.end()){ return; }
for (auto &itr : state->second){
GUI_Interface* i = itr.second;
if (!i->IsActive()){ continue; }
if (i->GetState() == GUI_ElementState::Clicked)
{
i->OnRelease();
}
if (i->IsBeingMoved()){ i->StopMoving(); }
}
}
唯一的不同之处在于,每个处于Clicked状态的接口都会调用其OnRelease方法,如果它处于被拖动的状态,还会调用StopMoving方法。
最后,别忘了我们的文本字段元素,因为它们需要在输入文本时得到通知:
void GUI_Manager::HandleTextEntered(EventDetails* l_details){
auto state = m_interfaces.find(m_currentState);
if (state == m_interfaces.end()){ return; }
for (auto &itr : state->second){
if (!itr.second->IsActive()){ continue; }
if (!itr.second->IsFocused()){ continue; }
itr.second->OnTextEntered(l_details->m_textEntered);
return;
}
}
这是一段相当简单的代码片段。每当输入文本时,我们尝试找到一个活动且聚焦的元素。一旦找到,就调用其OnTextEntered方法,并将文本信息作为参数传递。
添加 GUI 事件就像将它们推回到std::vector数据结构中一样简单:
void GUI_Manager::AddEvent(GUI_Event l_event){
m_events[m_currentState].push_back(l_event);
}
为了正确处理这些事件,我们必须有一种方法来获取它们:
bool GUI_Manager::PollEvent(GUI_Event& l_event){
if (m_events[m_currentState].empty()){ return false; }
l_event = m_events[m_currentState].back();
m_events[m_currentState].pop_back();
return true;
}
这与 SFML 处理事件的方式类似,因为它接收一个GUI_Event数据类型的引用,并在弹出之前用事件向量中的最后一个事件覆盖它。它还返回一个布尔值,以便在while循环中方便地使用。
接下来,让我们来更新接口:
void GUI_Manager::Update(float l_dT){
sf::Vector2i mousePos = m_eventMgr->
GetMousePos(m_context->m_wind->GetRenderWindow());
auto state = m_interfaces.find(m_currentState);
if (state == m_interfaces.end()){ return; }
for (auto itr = state->second.rbegin();
itr != state->second.rend(); ++itr)
{
GUI_Interface* i = itr->second;
if (!i->IsActive()){ continue; }
i->Update(l_dT);
if (i->IsBeingMoved()){ continue; }
if (i->IsInside(sf::Vector2f(mousePos)))
{
if (i->GetState() == GUI_ElementState::Neutral){
i->OnHover(sf::Vector2f(mousePos));
}
return;
} else if (i->GetState() == GUI_ElementState::Focused){
i->OnLeave();
}
}
}
获取当前鼠标位置后,遍历属于当前应用程序状态的所有接口。如果接口当前处于活动状态,则对其进行更新。只有当问题接口当前没有被拖动时,才会考虑Hover和Leave事件,就像我们在接口内部的小型 GUI 元素中所做的那样。
现在是时候将这些接口绘制到屏幕上了:
void GUI_Manager::Render(sf::RenderWindow* l_wind){
auto state = m_interfaces.find(m_currentState);
if (state == m_interfaces.end()){ return; }
for (auto &itr : state->second){
GUI_Interface* i = itr.second;
if (!i->IsActive()){ continue; }
if (i->NeedsRedraw()){ i->Redraw(); }
if (i->NeedsContentRedraw()){ i->RedrawContent(); }
if (i->NeedsControlRedraw()){ i->RedrawControls(); }
i->Draw(l_wind);
}
}
再次强调,此方法会遍历属于当前应用程序状态的所有接口。如果它们处于活动状态,则会检查每个重绘标志并调用相应的重绘方法。最后,将sf::RenderWindow的指针传递给接口的Draw方法,以便它能够绘制自身。
如果有一个方法可以自动创建这些类型,那就太好了,因为我们正在使用工厂生产的元素类型:
GUI_Element* GUI_Manager::CreateElement(
const GUI_ElementType& l_id, GUI_Interface* l_owner)
{
if (l_id == GUI_ElementType::Window){
return new GUI_Interface("", this);
}
auto f = m_factory.find(l_id);
return (f != m_factory.end() ? f->second(l_owner) : nullptr);
}
如果提供的元素类型是Window,则创建一个新的接口,并将GUI_Manager的指针传递为其第二个参数。在传递任何其他元素类型的情况下,搜索工厂容器,并调用存储的lambda函数,并将l_owner参数传递给它。
最后,让我们讨论接口的反序列化。需要一个方法来加载这种格式的文件:
Interface name Style.style 0 0 Immovable NoTitle "Title"
Element Label name 100 0 Style.style "Label text"
...
接下来,让我们从文件中加载我们的接口。我们不会介绍如何读取文件本身,因为它基本上与我们的常规做法相同:
bool GUI_Manager::LoadInterface(const StateType& l_state,const std::string& l_interface, const std::string& l_name)
{
...
}
让我们从创建一个接口开始:
if (key == "Interface"){
std::string style;
keystream >> InterfaceName >> style;
if (!AddInterface(l_state, l_name)){
std::cout << "Failed adding interface: "
<< l_name << std::endl;
return false;
}
GUI_Interface* i = GetInterface(l_state, l_name);
keystream >> *i;
if (!LoadStyle(style, i)){
std::cout << "Failed loading style file: "
<< style << " for interface " << l_name << std::endl;
}
i->SetContentSize(i->GetSize());
} else if ...
如文件格式所建议,首先需要读取其名称和样式文件名称。如果添加带有加载名称的接口失败,则打印错误信息并停止文件读取。否则,获取新添加的窗口的指针,并使用其重载的>>运算符从流中读取附加信息,这是我们在本章接口部分讨论过的。
接下来,尝试通过调用LoadStyle方法加载之前读取的样式文件,我们将在稍后介绍该方法。如果失败,则打印错误信息。最后,根据其当前样式调整其内容大小。
处理元素反序列化,在其最基本的形式下,相当相似:
} else if (key == "Element"){
if (InterfaceName == ""){
std::cout << "Error: 'Element' outside or before
declaration of 'Interface'!" << std::endl;
continue;
}
std::string type;
std::string name;
sf::Vector2f position;
std::string style;
keystream >> type >> name >> position.x >> position.y >> style;
GUI_ElementType eType = StringToType(type);
if (eType == GUI_ElementType::None){
std::cout << "Unknown element('" << name
<< "') type: '" << type << "'" << std::endl;
continue;
}
GUI_Interface* i = GetInterface(l_state, l_name);
if (!i){ continue; }
if (!i->AddElement(eType, name)){ continue; }
GUI_Element* e = i->GetElement(name);
keystream >> *e;
e->SetPosition(position);
if (!LoadStyle(style, e)){
std::cout << "Failed loading style file: " << style
<< " for element " << name << std::endl;
continue;
}
}
元素类型、名称、位置和样式值都是从文件中读取的。元素类型是在将读取到的文本运行到type变量中并通过我们的辅助方法StringToType后获得的。需要添加到元素中的接口是通过使用传递给LoadInterface方法的参数名称获得的。通过调用获取到的接口的AddElement方法,以在堆上创建适当的元素类型。如果成功,通过名称获取元素,并通过利用其重载的>>运算符读取其附加信息。再次调用LoadStyle方法,以从文件中读取元素的样式。让我们看看这看起来是什么样子:
State Neutral
Size 64 32
TextColor 0 0 0 255
TextSize 12
Font Main
TextPadding 0 0
/State
State Hover
TextColor 255 255 255 255
/State
State Clicked
TextColor 255 0 0 255
/State
以此为例,现在是时候尝试读取它了。再次提醒,我们将跳过读取文件的代码,因为它与通常的做法重复。考虑到这一点,让我们看一下:
bool GUI_Manager::LoadStyle(const std::string& l_file,
GUI_Element* l_element)
{
...
std::string currentState;
GUI_Style ParentStyle;
GUI_Style TemporaryStyle;
...
}
注意这里设置的两种GUI_Style结构:它们跟踪作为父级的主要样式和当前正在读取的临时样式。让我们继续向下看这个方法,在实际的while循环中:
if (type == "State"){
if (currentState != ""){
std::cout << "Error: 'State' keyword found
inside another state!" << std::endl;
continue;
}
keystream >> currentState;
} else if ...
如果遇到State关键字且currentState未设置,则读取状态名称。否则,我们打印出错误信息:
} else if (type == "/State"){
if (currentState == ""){
std::cout << "Error: '/State' keyword found
prior to 'State'!" << std::endl;
continue;
}
GUI_ElementState state = GUI_ElementState::Neutral;
if (currentState == "Hover"){state = GUI_ElementState::Focused;}
else if (currentState == "Clicked"){
state = GUI_ElementState::Clicked;
}
if (state == GUI_ElementState::Neutral){
ParentStyle = TemporaryStyle;
l_element->UpdateStyle(
GUI_ElementState::Neutral, TemporaryStyle);
l_element->UpdateStyle(
GUI_ElementState::Focused, TemporaryStyle);
l_element->UpdateStyle(
GUI_ElementState::Clicked, TemporaryStyle);
} else {
l_element->UpdateStyle(state, TemporaryStyle);
}
TemporaryStyle = ParentStyle;
currentState = "";
} else { ...
当遇到/State关键字时,我们可以安全地假设当前正在处理的样式已经结束。然后,根据读取的表示该状态的字符串确定状态。
如果状态是Neutral,我们需要将其设置为父样式,这意味着其他样式的所有未设置属性也将从这一种继承过来。然后对三个支持的状态中的每一个调用UpdateStyle方法,以覆盖默认值。如果状态不是Neutral,则只为该状态调用一次UpdateStyle方法。然后,TemporaryStyle变量被ParentStyle覆盖,以模拟继承。
最后,让我们看看如何支持每个不同的样式特性:
} else {
// Handling style information.
if (currentState == ""){
std::cout << "Error: '" << type
<< "' keyword found outside of a state!" << std::endl;
continue;
}
if (type == "Size"){
keystream >>TemporaryStyle.m_size.x >>TemporaryStyle.m_size.y;
} else if (type == "BgColor"){
int r, g, b, a = 0;
keystream >> r >> g >> b >> a;
TemporaryStyle.m_backgroundColor = sf::Color(r,g,b,a);
} else if (type == "BgImage"){
keystream >> TemporaryStyle.m_backgroundImage;
} else if (type == "BgImageColor"){
int r, g, b, a = 0;
keystream >> r >> g >> b >> a;
TemporaryStyle.m_backgroundImageColor = sf::Color(r, g, b, a);
} else if (type == "TextColor"){
int r, g, b, a = 0;
keystream >> r >> g >> b >> a;
TemporaryStyle.m_textColor = sf::Color(r, g, b, a);
} else if (type == "TextSize"){
keystream >> TemporaryStyle.m_textSize;
} else if (type == "TextOriginCenter"){
TemporaryStyle.m_textCenterOrigin = true;
} else if (type == "Font"){
keystream >> TemporaryStyle.m_textFont;
} else if (type == "TextPadding"){
keystream >> TemporaryStyle.m_textPadding.x
>> TemporaryStyle.m_textPadding.y;
} else if (type == "ElementColor"){
int r, g, b, a = 0;
keystream >> r >> g >> b >> a;
TemporaryStyle.m_elementColor = sf::Color(r, g, b, a);
} else if (type == "Glyph"){
keystream >> TemporaryStyle.m_glyph;
} else if (type == "GlyphPadding"){
Keystream >> TemporaryStyle.m_glyphPadding.x
>> TemporaryStyle.m_glyphPadding.y;
} else {
std::cout << "Error: style tag '" << type
<< "' is unknown!" << std::endl;
}
}
每个颜色值首先被读取为四个单独的整数,然后存储在sf::Color结构中,该结构被分配给样式结构中的适当数据成员。填充和文本值只是简单地流进。有一个例外是TextOriginCenter标签。它不包含任何附加信息,它的存在仅仅意味着文本元素的起点应该始终居中。
标签元素
标签元素是最简单的 GUI 类型。它支持所有默认的样式特性,但除了包含一个可以在运行时加载或设置的特定字符串值之外,没有做太多其他的事情。
让我们看看它的构造函数和析构函数:
GUI_Label::GUI_Label(const std::string& l_name,
GUI_Interface* l_owner)
: GUI_Element(l_name, GUI_ElementType::Label, l_owner){}
与我们之前编写的代码相比,这简直是小菜一碟。它的名称、类型和所有者都在初始化列表中设置,除此之外没有其他内容。
这种类型元素的反序列化过程也相当简单。回想一下接口文件中的以下行:
Element Label TestLabel 0 0 Default.style "Some text"
由于GUI_Manager类处理了所有这些信息(除了最后一部分),这个元素的ReadIn方法可能看起来像这样:
void GUI_Label::ReadIn(std::stringstream& l_stream){
std::string content;
Utils::ReadQuotedString(l_stream, content);
m_visual.m_text.setString(content);
}
现在,我们必须实现这个元素的的事件方法。在这种情况下,这不过是简单地调整标签的状态:
void GUI_Label::OnClick(const sf::Vector2f& l_mousePos){
SetState(GUI_ElementState::Clicked);
}
void GUI_Label::OnRelease(){
SetState(GUI_ElementState::Neutral);
}
void GUI_Label::OnHover(const sf::Vector2f& l_mousePos){
SetState(GUI_ElementState::Focused);
}
void GUI_Label::OnLeave(){
SetState(GUI_ElementState::Neutral);
}
最后一部分代码负责如何绘制这个元素:
void GUI_Label::Draw(sf::RenderTarget* l_target){
l_target->draw(m_visual.m_backgroundSolid);
if (m_style[m_state].m_glyph != ""){
l_target->draw(m_visual.m_glyph);
}
l_target->draw(m_visual.m_text);
}
在绘制背景矩形之后,检查符号是否需要绘制。最后,文本直接渲染在最后两个视觉属性之上。
文本字段元素
为了成功实现文本字段元素,我们需要定义它如何正确地响应输入。首先,让我们通过创建文本字段元素类和实现构造函数来设置一个新的元素类型,如下所示:
GUI_Textfield::GUI_Textfield(const std::string& l_name,
GUI_Interface* l_owner)
: GUI_Element(l_name, GUI_ElementType::Textfield , l_owner){}
这个元素在加载时也可以有一个默认的文本值,所以让我们通过提供一个自定义的ReadIn方法来表达这一点:
void GUI_Textfield::ReadIn(std::stringstream& l_stream){
std::string content;
Utils::ReadQuotedString(l_stream, content);
m_visual.m_text.setString(content);
}
如你所知,文本字段在鼠标按钮释放时不会改变状态。这允许它们保持聚焦状态,直到在其他地方注册鼠标点击。我们已经在GUI_Interface类中实现了该功能,作为DefocusTextfields方法。现在剩下的只是忽略释放事件:
void GUI_Textfield::OnRelease(){}
最后,让我们看看如何绘制这个元素:
void GUI_Textfield::Draw(sf::RenderTarget* l_target){
l_target->draw(m_visual.m_backgroundSolid);
if (m_style[m_state].m_glyph != ""){
l_target->draw(m_visual.m_glyph);
}
l_target->draw(m_visual.m_text);
}
它的本质相当简单。到目前为止,我们只关心绘制这个元素所包含文本背后的背景实体。符号也在这里得到支持,但我们不会使用它。
滑块元素
所有这些对界面滚动和控制元素的支持意味着滑块元素的存在。其目的是在内容纹理的可见区域内移动,以揭示位置超出其尺寸允许的元素,这可以沿任何轴进行。有了这些知识,让我们尝试制定滑块元素的基本类定义:
enum class SliderType{ Horizontal, Vertical };
class GUI_Scrollbar : public GUI_Element{
public:
...
void SetPosition(const sf::Vector2f& l_pos);
void ApplyStyle();
void UpdateStyle(const GUI_ElementState& l_state,
const GUI_Style& l_style);
private:
SliderType m_sliderType;
sf::RectangleShape m_slider;
sf::Vector2f m_moveMouseLast;
int m_percentage;
};
首先,我们列举两种可能的滑块类型:水平和垂直。实际的 GUI_Scrollbar 类覆盖了父类提供的三个原始方法,并实现了所有纯虚拟方法。
在其私有数据成员中,滑块跟踪其自身类型,该类型包含另一个可绘制对象来表示滑块,并维护有关最后已知鼠标坐标以及当前滚动百分比值的信息。
让我们从简单的部分开始——构造函数:
GUI_Scrollbar::GUI_Scrollbar(const std::string& l_name,
GUI_Interface* l_owner)
: GUI_Element(l_name, GUI_ElementType::Scrollbar, l_owner)
{
m_isControl = true;
}
到目前为止,这相当直接。元素类型设置为 Scrollbar,并将 m_isControl 标志设置为 true 以告诉所有者界面在哪个层上绘制它。
接下来,需要覆盖 SetPosition 方法以确保滑块定位正确:
void GUI_Scrollbar::SetPosition(const sf::Vector2f& l_pos){
GUI_Element::SetPosition(l_pos);
if (m_sliderType == SliderType::Horizontal){ m_position.x = 0; }
else { m_position.y = 0; }
}
由于这个特定元素的特性,必须始终将一个轴设置为 0 以保持其在右侧边缘的位置。
目前,滑块类型将从界面文件中读取。为了实现这一点,我们可能需要像这样处理反序列化:
void GUI_Scrollbar::ReadIn(std::stringstream& l_stream){
std::string type;
l_stream >> type;
if (type == "Horizontal"){m_sliderType =SliderType::Horizontal;}
else { m_sliderType = SliderType::Vertical; }
if (m_sliderType == SliderType::Horizontal){
m_slider.setPosition(0, GetPosition().y);
}
else { m_slider.setPosition(GetPosition().x, 0); }
}
接下来,我们处理事件,从 OnClick 开始:
void GUI_Scrollbar::OnClick(const sf::Vector2f& l_mousePos){
if (!m_slider.getGlobalBounds().contains(
l_mousePos - m_owner->GetPosition()))
{
return;
}
SetState(GUI_ElementState::Clicked);
m_moveMouseLast = l_mousePos;
}
由于我们只想在拖动滑块部分时进行滚动,因此只有当鼠标坐标在滑块内时,此元素的状态才设置为 Clicked。然后它们被存储在 m_moveMouseLast 数据成员中,以防止滑块跳动。
剩下的三个事件除了调整状态外不需要做任何事情:
void GUI_Scrollbar::OnRelease(){
SetState(GUI_ElementState::Neutral);
}
void GUI_Scrollbar::OnHover(const sf::Vector2f& l_mousePos){
SetState(GUI_ElementState::Focused);
}
void GUI_Scrollbar::OnLeave(){
SetState(GUI_ElementState::Neutral);
}
样式更新也必须更改以保持滑块的期望功能:
void GUI_Scrollbar::UpdateStyle(const GUI_ElementState& l_state,
const GUI_Style& l_style)
{
GUI_Element::UpdateStyle(l_state, l_style);
if (m_sliderType == SliderType::Horizontal){
m_style[l_state].m_size.x = m_owner->GetSize().x;
}
else { m_style[l_state].m_size.y = m_owner->GetSize().y; }
}
在调用父类 UpdateStyle 之后,滑块的大小设置为与所有者界面在相关轴上的大小相匹配。
接下来,我们必须定义一种自定义方式来应用样式属性到滑块元素,因为它们的独特性质:
void GUI_Scrollbar::ApplyStyle(){
GUI_Element::ApplyStyle();
m_slider.setFillColor(m_style[m_state].m_elementColor);
bool horizontal = m_sliderType == SliderType::Horizontal;
auto& bgSolid = m_visual.m_backgroundSolid;
SetPosition((horizontal ?
sf::Vector2f(0, m_owner->GetSize().y - bgSolid.getSize().y) :
sf::Vector2f(m_owner->GetSize().x - bgSolid.getSize().x, 0)));
bgSolid.setSize((horizontal ?
sf::Vector2f(m_owner->GetSize().x,m_style[m_state].m_size.y) :
sf::Vector2f(m_style[m_state].m_size.x,m_owner->GetSize().y)));
m_slider.setPosition(
(horizontal ? m_slider.getPosition().x : GetPosition().x),
(horizontal ? GetPosition().y : m_slider.getPosition().y));
float SizeFactor = (horizontal ?
m_owner->GetContentSize().x / m_owner->GetSize().x :
m_owner->GetContentSize().y / m_owner->GetSize().y);
if (SizeFactor < 1.f){ SizeFactor = 1.f; }
float SliderSize = (horizontal ?
m_owner->GetSize().x : m_owner->GetSize().y) / SizeFactor;
m_slider.setSize((horizontal ?
sf::Vector2f(SliderSize,bgSolid.getSize().y):
sf::Vector2f(bgSolid.getSize().x, SliderSize)));
bgSolid.setPosition(GetPosition());
}
在调用父类 ApplyStyle 并设置滑块颜色后,元素的定位被覆盖,以保持在动作轴上的位置为 0 并且在垂直轴的边缘附近。背景实体的尺寸由滚动轴上的界面尺寸决定。其样式属性决定了其他尺寸值。
滚动条的位置在非操作轴上被修改,以确保始终与元素本身的位置相匹配。沿着滚动轴计算其大小就像将拥有窗口的大小除以其内容大小除以相同窗口大小的结果一样简单。
在完成这个元素的样式部分后,让我们来移动它并影响其所有者界面:
void GUI_Scrollbar::Update(float l_dT){
// Mouse-drag code.
if (GetState() != GUI_ElementState::Clicked){ return; }
SharedContext* context = m_owner->GetManager()->GetContext();
sf::Vector2f mousePos =
sf::Vector2f(context->m_eventManager->GetMousePos(
context->m_wind->GetRenderWindow()));
if (m_moveMouseLast == mousePos){ return; }
sf::Vector2f difference = mousePos - m_moveMouseLast;
m_moveMouseLast = mousePos;
bool horizontal = m_sliderType == SliderType::Horizontal;
m_slider.move((horizontal ? difference.x : 0),
(horizontal ? 0 : difference.y));
if (horizontal && m_slider.getPosition().x < 0){
m_slider.setPosition(0, m_slider.getPosition().y);
} else if (m_slider.getPosition().y < 0){
m_slider.setPosition(m_slider.getPosition().x, 0);
}
if (horizontal&&(m_slider.getPosition().x+m_slider.getSize().x >
m_owner->GetSize().x))
{
m_slider.setPosition(
m_owner->GetSize().x - m_slider.getSize().x,
m_slider.getPosition().y);
} else if (m_slider.getPosition().y + m_slider.getSize().y >
m_owner->GetSize().y)
{
m_slider.setPosition(m_slider.getPosition().x,
m_owner->GetSize().y - m_slider.getSize().y);
}
float WorkArea = (horizontal ?
m_owner->GetSize().x - m_slider.getSize().x :
m_owner->GetSize().y - m_slider.getSize().y);
int percentage = ((horizontal ?
m_slider.getPosition().x : m_slider.getPosition().y) /
WorkArea) * 100;
if (horizontal){ m_owner->UpdateScrollHorizontal(percentage); }
else { m_owner->UpdateScrollVertical(percentage); }
SetRedraw(true);
}
上述所有代码只有在元素的当前状态为Clicked时才需要执行。很明显,滚动条的滑块正在上下拖动。如果当前鼠标位置与上一次迭代的最后位置不同,则计算它们之间的差异,并将当前鼠标位置存储以供以后参考。
首先,通过两次迭代之间鼠标位置的差异来移动滑块。然后检查它是否超出界面的边界,如果是,则将其位置重置为最近的边缘。
最后,通过将滑块在相关轴上的位置除以窗口大小和滑块大小的差值来计算滚动百分比值。然后调用相应的滚动更新方法,并将此元素标记为需要重新绘制以反映其更改。
我们需要做的最后一件事是定义滚动条元素是如何绘制的:
void GUI_Scrollbar::Draw(sf::RenderTarget* l_target){
l_target->draw(m_visual.m_backgroundSolid);
l_target->draw(m_slider);
}
目前,它只使用两个矩形形状,但是很容易扩展以支持纹理。
集成 GUI 系统
为了使用 GUI 系统,它首先需要存在。就像在之前的章节中一样,我们需要实例化和更新我们构建的 GUI 类。让我们首先将 GUI 管理器和字体管理器添加到SharedContext.h文件中:
struct SharedContext{
SharedContext():
...
m_fontManager(nullptr),
...
m_guiManager(nullptr){}
...
FontManager* m_fontManager;
GUI_Manager* m_guiManager;
};
我们需要在Game类中保留对 GUI 管理器和字体管理器的指针,就像所有通过SharedContext结构共享的其他类一样,从头文件开始:
class Game{
public:
...
private:
...
FontManager m_fontManager;
...
GUI_Manager m_guiManager;
};
这些指针当然是没有意义的,除非它们实际上指向内存中的有效对象。让我们在Game.cpp文件中处理资源的分配和释放:
Game::Game() : m_window("Chapter 11", sf::Vector2u(800, 600)),
m_entityManager(&m_systemManager, &m_textureManager),
m_stateManager(&m_context),
m_guiManager(m_window.GetEventManager(),&m_context)
{
...
m_context.m_guiManager = &m_guiManager;
...
m_fontManager.RequireResource("Main");
}
Game::~Game(){
m_fontManager.ReleaseResource("Main");
}
接下来,我们可以查看更新应用程序中的所有界面和处理 GUI 事件:
void Game::Update(){
...
m_context.m_guiManager->Update(m_elapsed.asSeconds());
GUI_Event guiEvent;
while (m_context,m_guiManager->PollEvent(guiEvent)){
m_window.GetEventManager()->HandleEvent(guiEvent);
}
}
注意,GUI_Event实例被转发到EventManager类。我们很快就会对其进行扩展。
最后,让我们处理绘制我们的界面:
void Game::Render(){
...
m_stateManager.Draw();
sf::View CurrentView = m_window.GetRenderWindow()->getView();
m_window.GetRenderWindow()->setView(m_window.GetRenderWindow()->getDefaultView());
m_context.m_guiManager->Render(m_window->GetRenderWindow());
m_window.GetRenderWindow()->setView(CurrentView);
m_window.EndDraw();
}
为了使 GUI 始终绘制在场景的其他部分之上,在绘制界面之前必须将窗口视图设置为默认值。然后需要将其设置回以保持一致的相机位置,这可能看起来像这样:

扩展事件管理器
为了防止堆积,需要为应用程序的每个可能状态处理 GUI 事件,就像 SFML 事件一样。为了避免编写所有额外的代码,我们将使用专门为处理它们而构建的东西:事件管理器。
让我们从扩展 EventType 枚举以支持 GUI 事件开始:
enum class EventType{
...
Keyboard = sf::Event::Count + 1, Mouse, Joystick,
GUI_Click, GUI_Release, GUI_Hover, GUI_Leave
};
由于我们过去编写的代码的方式,出于这个原因,重要的是将这些自定义事件类型放在结构的底部。
我们之前对 EventManager 类的原始实现依赖于这样一个事实:任何给定的事件都可以简单地用一个数值来表示。大多数 SFML 事件,如按键绑定,都属于这一类,但许多其他事件类型,尤其是自定义事件,需要额外的信息才能正确处理。
而不是使用数字,我们需要切换到一个轻量级的数据结构,如下所示:
struct EventInfo{
EventInfo(){ l_code = 0; }
EventInfo(int l_event){ l_code = l_event; }
EventInfo(GUI_Event l_guiEvent){ l_gui = l_guiEvent; }
union{
int l_code;
GUI_Event l_gui;
};
};
联合确保没有浪费内存,我们仍然可以使用事件类型的数值表示,以及自定义数据类型,如 GUI_Event 结构。GUI_Event 属于联合,这就是为什么它不能使用 std::string 类型的数据成员。
提示
如果使用 boost 库,所有这些代码都可以简化为 boost::variant<int, GUI_Event>。
一个额外的变化是我们希望能够将 GUI 事件信息传递给已注册的回调方法。这些信息也将由我们的 EventDetails 结构持有:
struct EventDetails{
EventDetails(const std::string& l_bindName)
: m_name(l_bindName){ Clear(); }
...
std::string m_guiInterface; // GUI interface name.
std::string m_guiElement; // GUI element name.
GUI_EventType m_guiEvent; // GUI event type.
void Clear(){
...
m_guiInterface = "";
m_guiElement = "";
m_guiEvent = GUI_EventType::None;
}
};
现在,让我们调整 Binding 结构:
struct Binding{
Binding(const std::string& l_name): m_name(l_name),
m_details(l_name), c(0){}
~Binding(){
// GUI portion.
for (auto itr = m_events.begin();
itr != m_events.end(); ++itr)
{
if (itr->first == EventType::GUI_Click ||
itr->first == EventType::GUI_Release ||
itr->first == EventType::GUI_Hover ||
itr->first == EventType::GUI_Leave)
{
delete [] itr->second.m_gui.m_interface;
delete [] itr->second.m_gui.m_element;
}
}
}
...
};
由于联合的限制,我们不得不使用 const char* 数据类型来存储元素和接口名称。虽然这仅适用于与 GUI 相关的事件,但这些内存仍然需要释放。当一个绑定被销毁时,所有的事件信息都会被迭代并检查是否属于四种 GUI 事件类型之一,如果是,则安全地释放内存。
接下来,我们需要一个单独的方法来处理仅 GUI 事件。在这里,使用不同的参数类型重载 HandleEvent 方法似乎是一个不错的选择:
void HandleEvent(sf::Event& l_event);
void HandleEvent(GUI_Event& l_event);
我们需要确保在原始的 HandleEvent 方法中不处理任何 GUI 事件:
void EventManager::HandleEvent(sf::Event& l_event){
...
for(auto &e_itr : bind->m_events){
EventType sfmlEvent = (EventType)l_event.type;
if (e_itr.first == EventType::GUI_Click ||
e_itr.first == EventType::GUI_Release ||
e_itr.first == EventType::GUI_Hover ||
e_itr.first == EventType::GUI_Leave)
{
continue;
}
...
}
...
}
如果事件是四种 GUI 类型之一,则跳过迭代。处理 GUI 事件本身相当简单,可以按以下方式完成:
void EventManager::HandleEvent(GUI_Event& l_event){
for (auto &b_itr : m_bindings){
Binding* bind = b_itr.second;
for (auto &e_itr : bind->m_events)
{
if (e_itr.first != EventType::GUI_Click &&
e_itr.first != EventType::GUI_Release &&
e_itr.first != EventType::GUI_Hover &&
e_itr.first != EventType::GUI_Leave)
{ continue; }
if ((e_itr.first == EventType::GUI_Click &&
l_event.m_type != GUI_EventType::Click) ||
(e_itr.first == EventType::GUI_Release &&
l_event.m_type != GUI_EventType::Release) ||
(e_itr.first == EventType::GUI_Hover &&
l_event.m_type != GUI_EventType::Hover) ||
(e_itr.first == EventType::GUI_Leave &&
l_event.m_type != GUI_EventType::Leave))
{ continue; }
if (strcmp(e_itr.second.m_gui.m_interface,
l_event.m_interface) ||
strcmp(e_itr.second.m_gui.m_element, l_event.m_element))
{ continue; }
bind->m_details.m_guiInterface = l_event.m_interface;
bind->m_details.m_guiElement = l_event.m_element;
++(bind->c);
}
}
}
在迭代绑定内的事件时,会检查它们的类型。任何不是 GUI 事件的都会被跳过。如果处理事件的类型与绑定内的类型匹配,则会在 EventInfo 结构中检查额外的信息,即接口和元素名称。如果这些也匹配,它们将被记录为事件细节,并且事件计数会增加。
需要关注的最后一部分代码是 LoadBindings 方法。我们需要调整它以支持从 keys.cfg 文件中加载接口和元素名称,其格式可能如下所示:
Key_X 5:23
MainMenu_Play 27:MainMenu:Play
第一行代表一种普通类型的事件,而第二行是 GUI 事件,它需要加载两个标识符而不是一个。让我们调整它:
void EventManager::LoadBindings(){
...
while(!keystream.eof()){
std::string keyval;
keystream >> keyval;
int start = 0;
int end = keyval.find(delimiter);
if (end == std::string::npos){
delete bind;
bind = nullptr;
break;
}
EventType type = EventType(
stoi(keyval.substr(start, end-start)));
EventInfo eventInfo;
if (type==EventType::GUI_Click ||
type==EventType::GUI_Release ||
type == EventType::GUI_Hover ||
type == EventType::GUI_Leave)
{
start = end + delimiter.length();
end = keyval.find(delimiter, start);
std::string window = keyval.substr(start, end - start);
std::string element;
if (end != std::string::npos){
start = end + delimiter.length();
end = keyval.length();
element = keyval.substr(start, end);
}
char* w = new char[window.length() + 1]; // +1 for \0
char* e = new char[element.length() + 1];
// Size in bytes is the same as character length.1 char = 1B.
strcpy_s(w, window.length() + 1, window.c_str());
strcpy_s(e, element.length() + 1, element.c_str());
eventInfo.m_gui.m_interface = w;
eventInfo.m_gui.m_element = e;
} else {
int code = stoi(keyval.substr(end + delimiter.length(),
keyval.find(delimiter,end + delimiter.length())));
eventInfo.m_code = code;
}
bind->BindEvent(type, eventInfo);
}
...
}
在像往常一样加载事件类型后,它会检查它是否与四个 GUI 事件中的任何一个匹配。然后读取窗口和元素字符串,并通过std::strcpy方法将它们复制到新分配的char*内存中。
注意
请记住,当为char*类型的内存分配以匹配给定字符串时,它还需要额外的空间来存储末尾的空终止字符。
重新实现主菜单
为了展示以这种方式构建交互性有多容易,让我们重新构建主菜单,首先创建其.interface文件:
Interface MainMenu MainMenu.style 0 0 Immovable NoTitle "Main menu"
Element Label Title 100 0 MainMenuTitle.style "Main menu:"
Element Label Play 0 32 MainMenuLabel.style "PLAY"
Element Label Credits 0 68 MainMenuLabel.style "CREDITS"
Element Label Quit 0 104 MainMenuLabel.style "EXIT"
该界面在两个轴向上都设置了零填充,不可移动,并且没有标题栏。此界面中的所有三个按钮以及其标题都可以用不同样式的标签表示。说到这里,让我们看看我们主菜单界面的样式:
State Neutral
Size 300 150
TextSize 12
Font Main
/State
如您所见,它只定义了最基本的属性,并且本身并不旨在具有视觉响应性。然而,按钮标签样式略有不同:
State Neutral
Size 300 32
BgColor 255 0 0 255
TextColor 255 255 255 255
TextSize 14
Font Main
TextPadding 150 16
TextOriginCenter
/State
State Hover
BgColor 255 100 0 255
/State
State Clicked
BgColor 255 150 0 255
/State
当状态改变时,标签的背景颜色也会调整,这与代表主菜单标题的标签不同:
State Neutral
Size 118 32
TextColor 255 255 255 255
TextSize 24
Font Main
/State
在所有视觉元素都处理完毕后,让我们调整主菜单状态以加载并维护此界面:
class State_MainMenu : public BaseState{
public:
...
void Play(EventDetails* l_details); // Callback.
void Quit(EventDetails* l_details); // Callback.
};
除了状态必须实现的所有必需方法之外,我们只需要两个回调函数来处理 GUI 点击。这一切都在主菜单状态中的OnCreate方法中设置:
void State_MainMenu::OnCreate(){
GUI_Manager* gui = m_stateMgr->GetContext()->m_guiManager;
gui->LoadInterface(StateType::MainMenu,
"MainMenu.interface", "MainMenu");
gui->GetInterface(StateType::MainMenu,
"MainMenu")->SetPosition(sf::Vector2f(250.f, 168.f));
EventManager* eMgr = m_stateMgr->GetContext()->m_eventManager;
eMgr->AddCallback(StateType::MainMenu,
"MainMenu_Play", &State_MainMenu::Play, this);
eMgr->AddCallback(StateType::MainMenu,
"MainMenu_Quit", &State_MainMenu::Quit, this);
}
首先,主菜单界面从文件加载并放置在屏幕上。然后使用事件管理器设置播放和退出按钮动作的回调。这已经比以前的方法干净得多。
一旦状态被销毁,必须移除界面和两个回调函数,如下所示:
void State_MainMenu::OnDestroy(){
m_stateMgr->GetContext()->m_guiManager->
RemoveInterface(StateType::MainMenu, "MainMenu");
EventManager* eMgr = m_stateMgr->GetContext()->m_eventManager;
eMgr->RemoveCallback(StateType::MainMenu, "MainMenu_Play");
eMgr->RemoveCallback(StateType::MainMenu, "MainMenu_Quit");
}
如果存在GAME状态,则必须更改播放按钮的文本:
void State_MainMenu::Activate(){
auto& play = *m_stateMgr->GetContext()->m_guiManager->
GetInterface(StateType::MainMenu, "MainMenu")->
GetElement("Play");
if (m_stateMgr->HasState(StateType::Game)){
// Resume
play.SetText("Resume");
} else {
// Play
play.SetText("Play");
}
}
这就留下了我们两个回调函数,它们看起来像这样:
void State_MainMenu::Play(EventDetails* l_details){
m_stateMgr->SwitchTo(StateType::Game);
}
void State_MainMenu::Quit(EventDetails* l_details){
m_stateMgr->GetContext()->m_wind->Close();
}
这完美地说明了使用我们新的 GUI 以及改进的事件管理器如何容易实现快速和响应的结果。主菜单是用大约 20 行代码或更少的代码创建的,看起来像这样:

摘要
在第十章的开始部分 第十章,“我能点击这个吗? – GUI 基础”,我们的主要目标是实现一种简单而强大的方式来与我们的应用程序进行交互。在本章中,我们深入探讨了诸如界面和事件管理、新元素类型的创建和集成以及现有代码的扩展等附加主题。投入 GUI 的所有工作的有效性无法用其他方式衡量,只能用成功来衡量。我们现在拥有一个系统,它能够在最少的努力和代码下产生高效、响应快和快速的结果。此外,你现在应该具备构建更多元素类型的技能,这将使这个系统能够完成令人惊叹的事情。
在下一章中,我们将介绍 SFML 中声音和音乐元素的管理和使用。那里见!
第十二章.你现在能听到我吗?——声音和音乐
沉浸在虚拟环境中的享受是独一无二的。从我们观看的电影到我们玩的游戏,尽可能多地吸引和利用人类感官可以决定一种媒体能否吸引人。创造一个生机勃勃的氛围很少,如果不是永远,仅仅依靠视觉效果。在本章中,我们将暂时闭上眼睛,通过涵盖以下主题来参与这个项目的听觉方面:
-
SFML 中声音和音乐的基本知识
-
3D 空间中声音和听者的位置
-
正确管理和回收声音实例
-
扩展实体组件系统以允许声音
我们离第一次声爆还有很长的路要走,所以让我们直接进入正题!
使用受版权保护的资源
在我们进入声音管理之前,让我们给予应有的赞誉。在本章中,我们将使用以下资源:
-
由Fantozzi创作的、受 CC0 许可(公共领域)的Fantozzi 的脚步声(草地/沙石与石头):
opengameart.org/content/fantozzis-footsteps-grasssand-stone -
由Snabisch创作的、受 CC-BY 3.0 许可的NES 版本 Electrix:
opengameart.org/content/electrix-nes-version -
由cynicmusic创作的、受 CC-BY 3.0 许可的城镇主题 RPG:
opengameart.org/content/town-theme-rpg
为项目准备声音
为了成功编译使用 SFML 音频的项目,我们需要确保包含这些额外的依赖.lib文件:
-
sfml-audio-s.lib -
openal32.lib -
flac.lib -
ogg.lib -
vorbis.lib -
vorbisenc.lib -
vorbisfile.lib
此外,可执行文件必须始终附带 SFML 提供的openal32.dll文件,该文件位于库的bin文件夹中。
SFML 声音的基本知识
任何与音频相关的内容在 SFML 中都属于以下两个类别之一:代表短声音效果的sf::Sound,或用于播放较长时间音频片段的sf::Music。在继续之前,了解这两个类如何使用是明智的。让我们分别讨论每一个。
播放声音
sf::Sound类非常轻量级,应该仅用于播放短小的声音效果,不应占用大量内存。它存储和利用实际音频文件的方式是通过使用sf::SoundBuffer实例。这与sf::Sprite及其使用sf::Texture实例进行绘制的方式类似。sf::SoundBuffer用于在内存中存储音频数据,然后sf::Sound类从这些数据中读取并播放。它可以如下使用:
sf::SoundBuffer buffer;
buffer.loadFromFile("SomeSound.ogg");
sf::Sound sound(buffer);
sound.setBuffer(buffer); // Alternative.
如您所见,可以通过将声音缓冲区传递给声音的构造函数或使用声音实例的setBuffer方法,将声音缓冲区附加到sf::Sound实例。
小贴士
只要预期声音正在播放,就不应该销毁 sf::SoundBuffer 实例!
在声音缓冲区加载声音文件并将其附加到 sf::Sound 实例之后,可以通过调用 play() 方法来播放:
sound.play(); // Play the sound!
也可以通过使用相应命名的 pause() 和 stop() 方法来暂停和停止:
sound.pause(); // Pause the sound.
sound.stop(); // Stop the sound.
要确定声音是否正在播放、暂停或停止,以获取当前状态,可以这样做:
sf::SoundSource::Status status = sound.getStatus();
返回的状态是三个值的简单枚举:stopped、paused 和 playing。
最后,我们可以通过使用这些方法分别调整声音的音量、音高、是否循环以及声音已播放的进度:
sound.setVolume(100.f); // Takes in a float.
sound.setPitch(1.f); // Takes in a float.
sound.setLoop(true); // Takes in a Boolean.
sound.setPlayingOffset(sf::seconds(5.f)); // Takes in sf::Time.
音频音高是一个表示声音频率的数值。大于 1 的值会导致声音以更高的音高播放,而小于 1 的值则产生相反的效果。如果改变音高,也会改变声音的播放速度。
播放音乐
任何 sf::Music 实例都支持之前讨论的所有方法,除了 setBuffer。正如你所知道的那样,sf::Sound 使用它从中读取的 sf::SoundBuffer 实例。这意味着整个声音文件必须加载到内存中才能播放。对于较大的文件,这很快就会变得低效,这就是 sf::Music 存在的原因。它不是使用缓冲区对象,而是在音乐播放时从文件本身流式传输数据,只加载当前所需的数据。
让我们看看一个例子:
sf::Music music;
music.openFromFile("SomeMusic.ogg");
music.play();
...
music.stop();
注意方法名称 openFromFile。相比之下,当声音缓冲区加载文件时,sf::Music 只打开它并从中读取。
这里要提到的一个重要的事情是 sf::Music 是一个不可复制的类!这意味着任何形式的值赋值都会自动导致错误:
sf::Music music;
sf::Music music2 = music; // ERROR!
通过值传递音乐实例到函数或方法也会产生相同的结果。
声音空间化
sf::Sound 和 sf::Music 也支持空间定位。它利用左右音频通道,让人感觉声音实际上是在你周围播放。不过有一个问题。任何希望实现空间化的声音或音乐实例都必须只有一个通道。这更常见地被称为单声道或单声,与已经决定了扬声器如何使用的立体声相对。
在三维空间中感知声音的方式是通过一个单一、静态的类:sf::Listener。它是静态的,因为每个应用程序只能有一个监听器。我们感兴趣的该类的主要两个方面是监听器的位置和方向。记住,尽管我们可能在制作 2D 游戏,SFML 声音存在于 3D 空间中。让我们看看一个例子:
sf::Listener::setPosition(5.f, 0.f, 5.f);
sf::Listener::setDirection(1.f, 0.f, 0.f);
首先,让我们谈谈三维坐标。在 SFML 中,默认的向上向量位于正 Y 轴上。看看下面的图:

角色所在的每个轴代表三维空间中的一个方向向量
这种轴的排列被称为右手笛卡尔坐标系,是 OpenGL 的标准,而 SFML 就是基于 OpenGL 的。这意味着我们所说的二维空间的Y轴实际上是三维空间中的Z轴。如果我们想在空间移动声音时得到正确的结果,这一点很重要要记住。
听众的方向由一个称为单位向量的东西表示,也称为归一化向量,这意味着它只能有一个最大幅度为 1。当设置听众的方向时,提供的向量将被再次归一化,因此这两行代码将产生等效的东南方向结果:
sf::Listener::setDirection(1.f, 0.f, 1.f);
sf::Listener::setDirection(0.5f, 0.f, 0.5f);
然而,对于我们来说,我们不需要使用对角方向,因为我们的主要角色,显然将是唯一的听众,只能面向四个可能的方向。
在空间中放置声音
就像精灵在二维空间中的位置一样,声音也可以通过同名的方法进行定位:
sf::Sound sound;
sound.setPosition(5.f, 0.f, 5.f);
假设听众面向正X方向 (1.f, 0.f, 0.f)。我们刚刚放置在坐标 (5.f, 0.f, 5.f) 的声音将位于听众前方五单位,右侧五单位,将通过右扬声器听到。然而,它需要多大声呢?这就是最小声音距离和衰减发挥作用的地方:
sound.setMinDistance(6.f);
sound.setAttenuation(2.f);
声音最小距离是声音开始失去音量并变弱的下限。在先前的例子中,如果听众距离声音源更近或正好六单位距离,将听到声音的全音量。否则,声音开始减弱。减弱的速度由衰减因子决定。考虑这个图示:

半径为 Min_Distance 的圆代表一个区域,在这个区域内可以听到最大音量的声音。超过最小距离后,将应用衰减因子到音量上。
衰减是一个乘法因子。它越高,声音随距离的衰减越快。将衰减设置为 0 会导致声音无处不在都能听到,而像 100 这样的值则意味着只有在听众非常接近时才能听到。
记住,尽管我们不会利用它,但只要它只有一个通道,SFML 中的音乐在空间化方面遵循与声音相同的规则。
音频管理器
与我们对纹理和字体所做的一样,我们需要一种方法来轻松管理sf::SoundBuffer实例。幸运的是,我们的ResourceManager类可以使其变得极其方便,因此让我们创建AudioManager.h文件并定义声音缓冲区的设置方式:
class AudioManager : public ResourceManager<
AudioManager, sf::SoundBuffer>
{
public:
AudioManager() : ResourceManager("audio.cfg"){}
sf::SoundBuffer* Load(const std::string& l_path){
sf::SoundBuffer* sound = new sf::SoundBuffer();
if (!sound->loadFromFile(
Utils::GetWorkingDirectory() + l_path))
{
delete sound;
sound = nullptr;
std::cerr << "! Failed to load sound: "
<< l_path << std::endl;
}
return sound;
}
};
如您所知,声音接口几乎与纹理或字体完全相同。类似于之前的资源管理器,我们也提供了一个文件,路径是从该文件加载的。在这种情况下,它是 audio.cfg 文件:
Footstep media/Audio/footstep.ogg
TownTheme media/Audio/TownTheme.ogg
再次强调,这就像处理纹理或字体一样。到目前为止,一切顺利!
定义声音属性
声音,就像任何其他媒介一样,有几个有趣的属性可以调整。我们将在游戏中播放的效果不仅有不同的来源,还有不同的音量、音调值、声音可以覆盖的距离以及表示声音衰减速度的系数。我们将如何存储这些信息在 SoundProps.h 中定义:
struct SoundProps{
SoundProps(const std::string& l_name): m_audioName(l_name), m_volume(100), m_pitch(1.f), m_minDistance(10.f), m_attenuation(10.f){}
std::string m_audioName;
float m_volume;
float m_pitch;
float m_minDistance;
float m_attenuation;
};
除了之前描述的品质之外,还需要存储声音将要使用的音频文件的标识符。我们应用程序的典型声音文件可能看起来像 footstep.sound:
Audio Footstep
Volume 25
Pitch 1.0
Distance 150
Attenuation 2
在这个问题解决之后,我们实际上可以直接进入管理 sf::Sound 实例的阶段!
管理声音
由于应用程序中我们可以拥有的声音数量有限制,最好有一个集中处理和回收声音的方法。这就是 SoundManager 类发挥作用的地方。让我们开始为声音 ID 赋予别名数据类型:
using SoundID = int;
一个简单的整数类型完全能够胜任保持声音识别的工作。
此外,我们还想与声音实例存储一些信息:
struct SoundInfo{
SoundInfo(const std::string& l_name): m_name(l_name), m_manualPaused(false){}
std::string m_name;
bool m_manualPaused;
};
为了在关键时刻正确地释放资源,我们需要存储声音使用的音频文件的字符串标识符。跟踪声音是否被自动暂停对于一致性很重要。这就是 m_manualPaused 布尔标志的作用。
最后,在我们深入探讨声音管理器之前,查看这里使用的几个类型定义是至关重要的:
using SoundProperties = std::unordered_map<std::string,
SoundProps>;
using SoundContainer = std::unordered_map<SoundID,
std::pair<SoundInfo, sf::Sound*>>;
using Sounds = std::unordered_map<StateType, SoundContainer>;
using RecycledSounds = std::vector<std::pair<
std::pair<SoundID, std::string>, sf::Sound*>>;
using MusicContainer = std::unordered_map<StateType,
std::pair<SoundInfo, sf::Music*>>;
SoundProperties 类型只是一个将声音名称与其属性包含的结构相关联的映射。SoundContainer 是另一个映射,它将 SoundID 与包含 SoundInfo 结构以及实际的 sf::Sound 对象实例的配对绑定。Sounds 数据类型负责根据 State 对这些声音容器进行分组。
在声音被回收的过程中,它们需要被移动到不同类型的 RecycledSounds 容器中。它存储了声音 ID 和名称,以及 sf::Sound 实例。
我们将要处理的最后一个类型定义是一个用于 sf::Music 实例的容器。就像声音一样,它们按状态分组。这里的一个主要区别是我们只允许每个状态有一个 sf::Music 实例,它与 SoundInfo 结构一起存储。
现在我们已经拥有了所有需要的东西,让我们来看看声音管理器的头文件:
class SoundManager{
public:
SoundManager(AudioManager* l_audioMgr);
~SoundManager();
void ChangeState(const StateType& l_state);
void RemoveState(const StateType& l_state);
void Update(float l_dT);
SoundID Play(const std::string& l_sound,
const sf::Vector3f& l_position,
bool l_loop = false,
bool l_relative = false);
bool Play(const SoundID& l_id);
bool Stop(const SoundID& l_id);
bool Pause(const SoundID& l_id);
bool PlayMusic(const std::string& l_musicId,
float l_volume = 100.f, bool l_loop = false);
bool PlayMusic(const StateType& l_state);
bool StopMusic(const StateType& l_state);
bool PauseMusic(const StateType& l_state);
bool SetPosition(const SoundID& l_id, const sf::Vector3f& l_pos);
bool IsPlaying(const SoundID& l_id);
SoundProps* GetSoundProperties(const std::string& l_soundName);
static const int Max_Sounds = 150;
static const int Sound_Cache = 75;
private:
bool LoadProperties(const std::string& l_file);
void PauseAll(const StateType& l_state);
void UnpauseAll(const StateType& l_state);
sf::Sound* CreateSound(SoundID& l_id,
const std::string& l_audioName);
void SetUpSound(sf::Sound* l_snd, const SoundProps* l_props,
bool l_loop = false, bool l_relative = false);
bool RecycleSound(const SoundID& l_id, sf::Sound* l_snd,
const std::string& l_name);
void Cleanup();
Sounds m_audio;
MusicContainer m_music;
RecycledSounds m_recycled;
SoundProperties m_properties;
StateType m_currentState;
SoundID m_lastID;
unsigned int m_numSounds;
float m_elapsed;
AudioManager* m_audioManager;
};
如前所述,将应用程序中sf::Sound和sf::Music实例的数量限制在不超过 256 个的指定限制是一个好主意。在这种情况下,我们通过使用静态数据成员来设置同时加载到内存中的声音限制为 150 个,这样做相当安全。此外,我们还设置了一个限制,即在使用之前可以回收多少个声音实例,这个数字是 75。这些值显然可以根据您的喜好进行调整。
在我们深入了解实现细节之前,让我们谈谈这个类的私有数据成员。正如预期的那样,声音和音乐容器存储在这个类中,分别命名为m_audio和m_music。此外,我们还将所有声音属性存储在这个类中,以及回收的声音容器。由于声音功能是基于状态的,因此m_currentState数据成员对于跟踪应用程序运行在哪个状态是必要的。
为了正确分配声音 ID,跟踪最后一个 ID 是一个好主意,因此有m_lastID。此外,由于强制限制sf::Sound和sf::Music实例同时“存活”的数量至关重要,m_numSounds用于跟踪这两个类的每个实例。我们还需要在我们的应用程序中检查时间流逝,这将是m_elapsed所用的。
最后,保留对音频管理器的指针,用于资源管理和检索。
实现声音管理器
就像往常一样,让我们从查看这个类的构造函数和析构函数开始:
SoundManager::SoundManager(AudioManager* l_audioMgr)
: m_lastID(0), m_audioManager(l_audioMgr),
m_elapsed(0.f), m_numSounds(0){}
SoundManager::~SoundManager(){ Cleanup(); }
通过构造函数的参数列表获取指向AudioManager实例的指针,并在初始化列表中初始化,与其他数据成员及其默认值一起。析构函数简单地调用另一个名为Cleanup()的方法,该方法负责释放内存。这将在稍后进行介绍。
我们已经讨论了应用状态在良好管理中所扮演的角色。现在,让我们来看看当状态改变时,声音行为的实际定义:
void SoundManager::ChangeState(const StateType& l_state){
PauseAll(m_currentState);
UnpauseAll(l_state);
m_currentState = l_state;
if (m_music.find(m_currentState) != m_music.end()){ return; }
SoundInfo info("");
sf::Music* music = nullptr;
m_music.emplace(m_currentState, std::make_pair(info, music));
}
当应用状态被改变时,会调用PauseAll方法,并传递m_currentState作为参数。它负责有效地静音当前正在播放的所有声音。在我们处于主菜单时,我们不想听到游戏中的战斗和爆炸声。接下来调用UnpauseAll方法,将状态改变的状态标识符作为参数传递。显然,如果我们处于主菜单并切换回游戏状态,我们希望所有动作都恢复,这包括所有声音效果。然后修改保存当前状态信息的数据成员。
在这个方法的最后几行代码中,负责确保音乐容器有关于新状态的信息。如果没有找到任何内容,就会在m_music容器中插入一些空白信息,以表示当前状态目前没有播放音乐。
接下来,让我们谈谈当从应用程序中移除状态时会发生什么:
void SoundManager::RemoveState(const StateType& l_state){
auto& StateSounds = m_audio.find(l_state)->second;
for (auto &itr : StateSounds){
RecycleSound(itr.first, itr.second.second,
itr.second.first.m_name);
}
m_audio.erase(l_state);
auto music = m_music.find(l_state);
if (music == m_music.end()){ return; }
if (music->second.second){
delete music->second.second;
--m_numSounds;
}
m_music.erase(l_state);
}
首先获取要移除的状态的声音容器。然后遍历该状态中的每个声音,并通过RecycleSound方法进行回收,该方法接受声音 ID、指向sf::Sound实例的指针和声音名称。完成这些后,所有状态信息都会从m_audio容器中删除。此外,如果在那个状态中找到了sf::Music实例,它的内存将被释放,并且当前内存中存在的声音数量会减少。
在应用程序中,良好的内存管理非常重要,这也是我们使用管理类而不是简单地将资源散布各处的主要原因之一。负责清理这种混乱的方法可能看起来像这样:
void SoundManager::Cleanup(){
for (auto &state : m_audio){
for (auto &sound : state.second){
m_audioManager->ReleaseResource(sound.second.first.m_name);
delete sound.second.second;
}
}
m_audio.clear();
for (auto &recycled : m_recycled){
m_audioManager->ReleaseResource(recycled.first.second);
delete recycled.second;
}
m_recycled.clear();
for (auto &music : m_music){
if (music.second.second){
delete music.second.second;
}
}
m_music.clear();
m_properties.clear();
m_numSounds = 0;
m_lastID = 0;
}
首先,我们遍历当前播放的声音容器,释放正在使用的音频资源。然后安全地删除声音的动态内存,而不是进行回收。对于m_recycled容器中存在的所有声音,重复上述过程。最后,删除所有音乐实例。一旦所有容器都得到适当清理,声音的数量将重置为 0,以及最后一个声音 ID。
现在我们已经涵盖了所有的“家务”细节,让我们看看我们如何通过Update方法使这样的系统运行起来:
void SoundManager::Update(float l_dT){
m_elapsed += l_dT;
if (m_elapsed < 0.33f){ return; }
// Run once every third of a second.
m_elapsed = 0;
auto& container = m_audio[m_currentState];
for (auto itr = container.begin(); itr != container.end();){
if (!itr->second.second->getStatus()){
RecycleSound(itr->first, itr->second.second,
itr->second.first.m_name);
itr = container.erase(itr); // Remove sound.
continue;
}
++itr;
}
auto music = m_music.find(m_currentState);
if (music == m_music.end()){ return; }
if (!music->second.second){ return; }
if (music->second.second->getStatus()){ return; }
delete music->second.second;
music->second.second = nullptr;
--m_numSounds;
}
这里需要记住的一个重要事项是,我们真的不需要在应用程序的每个 tick 都运行这段代码。相反,我们跟踪时间的流逝,并在每个周期检查m_elapsed数据成员,看是否是运行我们代码的时候了。在这个情况下,0.33f的值是任意的,可以设置在合理的范围内。
如果已经过去了足够的时间,我们就遍历当前状态中的每个声音并检查其状态。如果声音已经停止,我们可以通过调用RecycleSound方法安全地回收它,然后从我们的主要声音容器中移除它。
小贴士
当一个 STL 容器中的元素被移除时,该容器的所有迭代器都变为无效。如果置之不理,可能会导致元素被跳过或越界访问。可以通过将迭代器设置为erase方法的返回值来解决,因为它返回一个指向被删除元素之后元素的合法迭代器。只有当循环的当前周期中没有元素被删除时,迭代器才会递增。
在这个系统中,音乐遵循相同的处理方式,如果不再播放,就会被移除。
接下来,让我们看看为这个类的用户提供播放声音的方法:
SoundID SoundManager::Play(const std::string& l_sound,
const sf::Vector3f& l_position, bool l_loop, bool l_relative)
{
SoundProps* props = GetSoundProperties(l_sound);
if (!props){ return -1; } // Failed to load sound properties.
SoundID id;
sf::Sound* sound = CreateSound(id, props->m_audioName);
if (!sound){ return -1; }
// Sound created successfully.
SetUpSound(sound, props, l_loop, l_relative);
sound->setPosition(l_position);
SoundInfo info(props->m_audioName);
m_audio[m_currentState].emplace(id,std::make_pair(info, sound));
sound->play();
return id;
}
我们首先通过使用GetSoundProperties方法获取声音属性结构的指针,该方法我们将在后面介绍。如果它返回nullptr值,则Play方法返回-1 以表示加载错误。否则,我们继续创建一个将要传递给CreateSound方法的声声音 ID 实例,并附带音频声音缓冲区的标识符。如果声音创建成功,它将返回一个指向sf::Sound实例的指针,该实例已准备好使用。
然后调用SetUpSound方法,将sf::Sound实例和属性作为参数传递,以及两个布尔标志,表示声音是否应该循环和相对于听者。后两个作为参数传递给当前正在实现的Play方法。然后,声音在空间中被定位并存储在m_audio容器中,以及设置在上一行并包含音频标识符的SoundInfo结构。
最后一步是调用我们的声音实例的play()方法,并返回该声音的 ID 以供后续操作。
如头文件所示,Play方法有两个版本。现在让我们来看看另一个版本:
bool SoundManager::Play(const SoundID& l_id){
auto& container = m_audio[m_currentState];
auto sound = container.find(l_id);
if (sound == container.end()){ return false; }
sound->second.second->play();
sound->second.first.m_manualPaused = false;
return true;
}
这个版本的Play方法只接受一个声音 ID 参数并返回一个布尔标志。它的目的是启动一个已经存在的声音,该声音首先位于声音容器中。如果找到了声音,它的play方法将被调用,并将m_manualPaused标志设置为false,表示它不再处于暂停状态。
停止声音的工作方式非常相似:
bool SoundManager::Stop(const SoundID& l_id){
auto& container = m_audio[m_currentState];
auto sound = container.find(l_id);
if (sound == container.end()){ return false; }
sound->second.second->stop();
sound->second.first.m_manualPaused = true;
return true;
}
这里的唯一区别是调用stop方法,并将m_manualPaused标志设置为true,以表示它以非自动方式被暂停。
另一个遵循完全相同模式的函数是Pause方法:
bool SoundManager::Pause(const SoundID& l_id){
auto& container = m_audio[m_currentState];
auto sound = container.find(l_id);
if (sound == container.end()){ return false; }
sound->second.second->pause();
sound->second.first.m_manualPaused = true;
return true;
}
现在是时候从声音转向音乐,特别是如何播放它:
bool SoundManager::PlayMusic(const std::string& l_musicId,
float l_volume, bool l_loop)
{
auto s = m_music.find(m_currentState);
if (s == m_music.end()){ return false; }
std::string path = m_audioManager->GetPath(l_musicId);
if (path == ""){ return false; }
if (!s->second.second){
s->second.second = new sf::Music();
++m_numSounds;
}
sf::Music* music = s->second.second;
if (!music->openFromFile(Utils::GetWorkingDirectory() + path)){
delete music;
--m_numSounds;
s->second.second = nullptr;
std::cerr << "[SoundManager] Failed to load music from file: "
<< l_musicId << std::endl;
return false;
}
music->setLoop(l_loop);
music->setVolume(l_volume);
music->setRelativeToListener(true); // Always relative.
music->play();
s->second.first.m_name = l_musicId;
return true;
}
首先,定位当前状态的音元素。然后,通过使用我们新添加的GetPath方法获取实际音频文件的路径,并检查其是否为空。如果不为空,我们检查当前状态是否存在sf::Music的实际实例,如果不存在则创建一个。随后,在if语句中调用sf::Music实例的openFromFile方法,以检查其是否成功。如果不成功,则删除sf::Music实例并减少声音的数量。否则,将音乐实例设置为提供的音量和循环偏好,并播放。请注意,我们正在将每个音乐实例设置为相对于听者的相对位置。虽然可以制作位置音乐,但我们目前不需要它。
由于我们希望音乐具有与任何给定声音相同的功能,因此我们也有类似的函数列表来操作音乐:
bool SoundManager::PlayMusic(const StateType& l_state){
auto music = m_music.find(m_currentState);
if (music == m_music.end()){ return false; }
if (!music->second.second){ return false; }
music->second.second->play();
music->second.first.m_manualPaused = false;
return true;
}
bool SoundManager::StopMusic(const StateType& l_state){
auto music = m_music.find(m_currentState);
if (music == m_music.end()){ return false; }
if (!music->second.second){ return false; }
music->second.second->stop();
delete music->second.second;
music->second.second = nullptr;
--m_numSounds;
return true;
}
bool SoundManager::PauseMusic(const StateType& l_state){
auto music = m_music.find(m_currentState);
if (music == m_music.end()){ return false; }
if (!music->second.second){ return false; }
music->second.second->pause();
music->second.first.m_manualPaused = true;
return true;
}
让我们回到声音上来。由于我们将利用其空间特性,因此有一个可以用来设置其在空间中位置的方法是个好主意:
bool SoundManager::SetPosition(const SoundID& l_id,
const sf::Vector3f& l_pos)
{
auto& container = m_audio[m_currentState];
auto sound = container.find(l_id);
if (sound == container.end()){ return false; }
sound->second.second->setPosition(l_pos);
return true;
}
此方法只是在其容器中定位声音实例,并将其位置设置为提供的参数。
如果我们想检查一个声音是否仍在播放?没问题!这正是IsPlaying方法的作用:
bool SoundManager::IsPlaying(const SoundID& l_id){
auto& container = m_audio[m_currentState];
auto sound = container.find(l_id);
return (sound != container.end() ?
sound->second.second->getStatus() : false);
}
由于声音状态是一个简单的枚举表,它可以被强制转换为布尔值。由于我们不关心“暂停”状态,将状态作为布尔值返回就可以正常工作。
接下来,我们有一种方法可以获取声音属性:
SoundProps* SoundManager::GetSoundProperties(
const std::string& l_soundName)
{
auto& properties = m_properties.find(l_soundName);
if (properties == m_properties.end()){
if (!LoadProperties(l_soundName)){ return nullptr; }
properties = m_properties.find(l_soundName);
}
return &properties->second;
}
由于声音属性在启动时没有加载,简单地找不到正确的信息可能仅仅意味着它从未被加载。如果是这种情况,将调用LoadProperties方法。它返回一个布尔值,告诉我们是否失败,在这种情况下返回nullptr值。否则,将再次在属性结构中搜索,并在该方法结束时返回。
在讨论加载属性的过程中,我们实际上可以看看它们是如何从.sound文件中加载的:
bool SoundManager::LoadProperties(const std::string& l_name){
std::ifstream file;
file.open(Utils::GetWorkingDirectory() +
"media/Sounds/" + l_name + ".sound");
if (!file.is_open()){
std::cerr << "Failed to load sound: " << l_name << std::endl;
return false;
}
SoundProps props("");
std::string line;
while (std::getline(file, line)){
if (line[0] == '|'){ continue; }
std::stringstream keystream(line);
std::string type;
keystream >> type;
if (type == "Audio"){
keystream >> props.m_audioName;
} else if (type == "Volume"){
keystream >> props.m_volume;
} else if (type == "Pitch"){
keystream >> props.m_pitch;
} else if (type == "Distance"){
keystream >> props.m_minDistance;
} else if (type == "Attenuation"){
keystream >> props.m_attenuation;
} else {
// ?
}
}
file.close();
if (props.m_audioName == ""){ return false; }
m_properties.emplace(l_name, props);
return true;
}
在过去加载了许多文件之后,这应该没有什么新奇的。所以,我们就直接快速浏览一下。在栈上创建了一个临时的SoundProps实例,名为props,默认音频名称为空。然后逐行处理并检查文件中的相关关键词。信息随后使用>>运算符直接加载到临时的属性实例中。
小贴士
为了获得额外加分,可以将if else链替换为某种类型的关联 lambda 函数容器,但为了简单起见,我们还是保持原有的逻辑。
一旦文件全部读取完毕,它将被关闭,并检查属性实例的音频名称是否不为空,因为它应该在过程中被加载。如果名称实际上不是空,则将SoundProps实例插入到属性容器中,并返回成功为true。
在我们讨论状态变化时,介绍了一些暂停和启动所有声音的方法。现在让我们看看其中之一:
void SoundManager::PauseAll(const StateType& l_state){
auto& container = m_audio[l_state];
for (auto itr = container.begin(); itr != container.end();){
if (!itr->second.second->getStatus()){
RecycleSound(itr->first, itr->second.second,
itr->second.first.m_name);
itr = container.erase(itr);
continue;
}
itr->second.second->pause();
++itr;
}
auto music = m_music.find(l_state);
if (music == m_music.end()){ return; }
if (!music->second.second){ return; }
music->second.second->pause();
}
PauseAll方法首先获取提供状态的所有声音的容器。它遍历每一个,检查声音是否实际上已经停止。如果是,声音将被简单地回收,元素被删除。否则,将调用声音的pause方法。如果存在提供状态的音乐,也会暂停。
UnpauseAll方法比较简单,因为它没有理由回收声音:
void SoundManager::UnpauseAll(const StateType& l_state){
auto& container = m_audio[l_state];
for (auto &itr : container){
if (itr.second.first.m_manualPaused){ continue; }
itr.second.second->play();
}
auto music = m_music.find(l_state);
if (music == m_music.end()){ return; }
if (!music->second.second ||music->second.first.m_manualPaused){
return;
}
music->second.second->play();
}
这里的问题是,只有当声音没有被相应的Pause方法手动暂停时,声音和音乐才会再次播放。
现在,让我们实现这个类中可能最重要的部分,它负责实际的sf::Sound实例的创建和循环使用:
sf::Sound* SoundManager::CreateSound(SoundID& l_id,
const std::string& l_audioName)
{
sf::Sound* sound = nullptr;
if (!m_recycled.empty() && (m_numSounds >= Max_Sounds ||
m_recycled.size() >= Sound_Cache))
{
auto itr = m_recycled.begin();
while (itr != m_recycled.end()){
if (itr->first.second == l_audioName){ break; }
++itr;
}
if (itr == m_recycled.end()){
// If a sound with the same name hasn't been found!
auto element = m_recycled.begin();
l_id = element->first.first;
m_audioManager->ReleaseResource(element->first.second);
m_audioManager->RequireResource(l_audioName);
sound = element->second;
sound->setBuffer(*m_audioManager->GetResource(l_audioName));
m_recycled.erase(element);
} else {
l_id = itr->first.first;
sound = itr->second;
m_recycled.erase(itr);
}
return sound;
}
if (m_numSounds < Max_Sounds){
if (m_audioManager->RequireResource(l_audioName)){
sound = new sf::Sound();
l_id = m_lastID;
++m_lastID;
++m_numSounds;
sound->setBuffer(*m_audioManager->GetResource(l_audioName));
return sound;
}
}
std::cerr << "[SoundManager] Failed to create sound."
<< std::endl;
return nullptr;
}
首先设置一个名为sound的局部变量,其值为nullptr,并在整个方法中对其进行操作。然后检查循环声音容器的尺寸,以及整体最大声音数量或最大缓存声音数量是否已超过。
如果声音的数量过高,无论是总数还是循环容器不为空,我们知道我们将要循环使用已经存在的声音。这个过程首先尝试找到一个已经使用相同sf::SoundBuffer实例的声音。如果这样的声音不存在,我们就简单地从循环容器中弹出第一个元素,将其 ID 存储在变量l_id中,并释放被循环声音使用的资源。l_id参数接受一个对SoundID的引用,它修改该引用,这作为让外部代码知道分配给声音实例的 ID 的一种方式。然后为新资源预留空间,并将我们的声音变量设置为指向循环声音实例,然后将其设置为使用新的声音缓冲区。我们的翻新声音从循环容器中移除。另一方面,如果找到了使用相同sf::SoundBuffer实例的声音,它不需要任何额外的设置,只需在存储其 ID 并将其从m_recycled容器中删除后即可返回。
如果没有可用的循环声音或者我们有额外的空间可以用来创建新的声音,那么就会创建一个新的声音而不是使用循环声音。声音的 ID 被设置为匹配m_lastID,然后递增(与m_numSounds相同)。在声音的缓冲区设置完成后,它可以安全地返回以进行进一步处理,例如在SetUpSound方法中:
void SoundManager::SetUpSound(sf::Sound* l_snd,
const SoundProps* l_props, bool& l_loop, bool& l_relative)
{
l_snd->setVolume(l_props->m_volume);
l_snd->setPitch(l_props->m_pitch);
l_snd->setMinDistance(l_props->m_minDistance);
l_snd->setAttenuation(l_props->m_attenuation);
l_snd->setLoop(l_loop);
l_snd->setRelativeToListener(l_relative);
}
此方法的主要思想是尽可能减少代码。它根据提供的参数设置声音的音量、音调、最小距离、衰减、循环和相对性。
让我们用一段相对简单但常用的代码来结束这个类:
void SoundManager::RecycleSound(const SoundID& l_id,
sf::Sound* l_snd, const std::string& l_name)
{
m_recycled.emplace_back(std::make_pair(l_id, l_name), l_snd);
}
此方法仅负责将作为参数提供的信息推送到循环容器中,以供以后使用。
添加声音支持
为了让我们的实体发出声音,必须做一些准备工作。目前,我们只关心在角色行走时简单地添加脚步声。这样做需要对EntityMessages.h中的EntityMessage枚举进行轻微修改:
enum class EntityMessage{
Move, Is_Moving, Frame_Change, State_Changed, Direction_Changed,
Switch_State, Attack_Action, Dead
};
我们将重点关注突出显示的部分。Frame_Change是本章中添加的新类型消息,而Direction_Changed将用于操纵声音监听器的方向。然而,为了检测动画过程中帧的变化,我们还需要对我们的代码库进行一些调整。
动画系统钩子
为了能够发送我们刚刚创建的Frame_Change消息,我们的动画系统将需要一些小的补充,从Anim_Base.h开始:
class Anim_Base{
public:
...
bool CheckMoved();
...
protected:
...
bool m_hasMoved;
...
};
在这里,我们添加了一个新的数据成员和一个方法来检查动画的当前帧是否最近已被更改。让我们实际上在Anim_Base.cpp中集成这段代码:
Anim_Base::Anim_Base()...,m_hasMoved(false){ ... }
bool Anim_Base::CheckMoved(){
bool result = m_hasMoved;
m_hasMoved = false;
return result;
}
在构造函数中,重要的是要记住将新添加的数据成员设置为默认值,在这个例子中是false。实际的CheckMoved方法是一段非常基础的代码块,它返回m_hasMoved的值,但同时也将其设置为false,以避免出现误报。
现在我们有一个将被用来检查帧变化的激活标志,所缺少的只是简单地在SetFrame方法中将它设置为true:
bool Anim_Base::SetFrame(const unsigned int& l_frame){
if((l_frame >= m_frameStart && l_frame <= m_frameEnd)||
(l_frame >= m_frameEnd && l_frame <= m_frameStart))
{
m_frameCurrent = l_frame;
m_hasMoved = true;
return true;
}
return false;
}
注意,返回值现在是一个布尔值而不是void。这个额外的变化使得进行错误检查变得非常容易,这对于我们在Anim_Directional.cpp中进行的最后修改非常重要:
void Anim_Directional::FrameStep(){
bool b = SetFrame(m_frameCurrent +
(m_frameStart <= m_frameEnd ? 1 : -1));
if (b){ return; }
if (m_loop){ SetFrame(m_frameStart); }
else { SetFrame(m_frameEnd); Pause(); }
}
这里的区别微妙但相关。我们实际上是从手动通过m_frameCurrent递增当前帧,转变为仅使用SetFrame方法。
实体组件系统扩展
经过之前的调整,我们现在可以通过在S_SheetAnimation.cpp中发送Frame_Change消息来放下最后一部分拼图,使其工作:
void S_SheetAnimation::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
...
if (sheet->GetSpriteSheet()->GetCurrentAnim()->CheckMoved()){
int frame = sheet->GetSpriteSheet()->
GetCurrentAnim()->GetFrame();
Message msg((MessageType)EntityMessage::Frame_Change);
msg.m_receiver = entity;
msg.m_int = frame;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
}
如您所知,Update方法已经处理了与实体攻击和死亡相关的其他类型的消息,所以这已经为我们准备好了。我们之前添加的CheckMoved方法现在派上了用场,并帮助我们检查变化。如果有变化,当前帧将被获取并存储在消息中,随后很快就会调用Dispatch。
声音发射组件
在实体组件系统范式内,每一个可能的实体参数或特性都表示为一个组件。发出声音无疑是这些特性之一。为了实现这一点,我们确实需要进行一些设置,从在C_SoundEmitter.h头文件中创建和实现它开始。然而,在此之前,让我们定义实体可以拥有的声音类型:
enum class EntitySound{ None = -1, Footstep, Attack, Hurt,Death };
如您所见,我们只将与四种类型的音效打交道,其中之一将在本章中实现。还设置了一个None值,以便于进行错误检查。
一个实体可以发出的每一个声音很可能会在不同的帧中播放,这需要一个新的数据结构来封装此类信息:
struct SoundParameters{
static const int Max_SoundFrames = 5;
SoundParameters(){
for (int i = 0; i < Max_SoundFrames; ++i){ m_frames[i] = -1; }
}
std::string m_sound;
std::array<int, Max_SoundFrames> m_frames;
};
由于声音将被绑定到动画的特定帧上,我们需要定义可以附加声音的最大帧数。名为Max_SoundFrames的静态常量在这里用于此目的。
SoundParameters结构的构造函数将整个帧数组初始化为-1 的值。这将允许我们以稍微高效一些的方式检查这个信息,因为检查可以在遇到第一个-1 值时停止。除了帧数字组之外,这个结构还存储了要发出的声音的名称。
现在,我们终于可以开始实现声音发射组件了:
class C_SoundEmitter : public C_Base{
public:
static const int Max_EntitySounds = 4;
...
private:
SoundID m_soundID;
std::array<SoundParameters, Max_EntitySounds> m_params;
};
首先,创建了一个新的静态常量,用来表示将要存在的实体声音的数量。该组件本身只有两个数据成员。第一个是一个声音 ID,它将被用来发射不应重复播放且必须等待前一个声音播放完毕的声音。第二个数据成员是每个可能的实体声音类型的参数数组。
让我们从组件的构造函数开始实现组件:
C_SoundEmitter(): C_Base(Component::SoundEmitter), m_soundID(-1){}
除了使用传递的组件类型调用C_Base构造函数之外,声音 ID 数据成员也被初始化为-1,以表示该组件当前没有播放任何声音。
为了让未来的声音系统知道播放哪些声音,我们将提供一种从该组件中提取声音信息的方法:
const std::string& GetSound(const EntitySound& l_snd){
static std::string empty = "";
return((int)l_snd < Max_EntitySounds ?
m_params[(int)l_snd].m_sound : empty);
}
通过简单地提供一个EntitySound枚举值作为参数,外部类可以获取在特定情况下应该播放哪个声音的信息。
此外,为了知道是否应该播放声音,声音系统将需要一个方法来判断当前动画帧是否应该发出声音。这就是IsSoundFrame方法的作用所在:
bool IsSoundFrame(const EntitySound& l_snd, int l_frame){
if ((int)l_snd >= Max_EntitySounds){ return false; }
for (int i = 0; i < SoundParameters::Max_SoundFrames; ++i){
if (m_params[(int)l_snd].m_frames[i] == -1){ return false; }
if (m_params[(int)l_snd].m_frames[i] == l_frame){return true;}
}
return false;
}
如果提供的声音参数大于最高支持的实体声音 ID,则返回false。否则,将迭代给定声音的所有帧。如果遇到-1 值,则立即返回false。然而,如果提供的帧参数与数组中的声音帧匹配,则此方法返回true。
接下来,我们需要一些辅助方法来设置和获取某些信息:
SoundID GetSoundID(){ return m_soundID; }
void SetSoundID(const SoundID& l_id){ m_soundID = l_id; }
SoundParameters* GetParameters(){ return &m_params[0]; }
在我们从实体文件中读取此组件的信息之前,让我们看看它可能的样子。这个片段可以在Player.entity文件中找到:
Name Player
...
Component 6 footstep:1,4
在组件 ID 之后,我们将读取要播放的声音效果的名称,然后是一系列由逗号分隔的帧。声音本身的名称与帧信息之间由冒号分隔。让我们写下这个:
void ReadIn(std::stringstream& l_stream){
std::string main_delimiter = ":";
std::string frame_delimiter = ",";
for (int i = 0; i < Max_EntitySounds; ++i){
std::string chunk;
l_stream >> chunk;
if (chunk == ""){ break; }
std::string sound = chunk.substr(0,
chunk.find(main_delimiter));
std::string frames = chunk.substr(
chunk.find(main_delimiter) + main_delimiter.length());
m_params[i].m_sound = sound;
size_t pos = 0;
unsigned int frameNum = 0;
while (frameNum < SoundParameters::Max_SoundFrames){
pos = frames.find(frame_delimiter);
int frame = -1;
if (pos != std::string::npos){
frame = stoi(frames.substr(0, pos));
frames.erase(0, pos + frame_delimiter.length());
} else {
frame = stoi(frames);
m_params[i].m_frames[frameNum] = frame;
break;
}
m_params[i].m_frames[frameNum] = frame;
++frameNum;
}
}
}
在设置分隔符信息之后,我们针对每个可能的实体声音迭代一次,并将行中的下一个段的内容读入一个名为chunk的字符串。如果该字符串实际上是空的,则退出循环,因为显然没有更多的信息需要加载。否则,将块在冒号分隔符处分成两部分:sound和frames。然后,实体声音存储在参数结构体中。
最后,有必要处理帧信息,这些信息由逗号分隔。为此设置了两个局部变量来帮助我们:pos用于存储逗号分隔符的位置(如果找到的话),而frameNum用于确保遵守Max_SoundFrames限制。在while循环内部,首先使用std::string类的find方法定位帧分隔符。如果找到了分隔符,则从字符串中提取帧并将其转换为整数,存储在变量frame中。然后,包括分隔符在内的整个段被从字符串frames中删除,提取的信息存储在参数结构体中。然而,如果没有找到分隔符,循环将在提取帧信息后立即停止。
声音监听器组件
为了正确实现空间声音,我们的游戏世界中必须有一个监听器。当然,这个监听器是游戏玩家。幸运的是,在创建音频监听器组件时,我们不需要处理或存储大量的信息:
class C_SoundListener : public C_Base{
public:
C_SoundListener() : C_Base(Component::SoundListener){}
void ReadIn(std::stringstream& l_stream){}
private:
};
是的,就是这样!在其最基本的形式中,这个类仅仅表示一个标志,表示其所有者实体应在听觉世界中被视为监听器。
实现声音系统
在处理完声音发射器和声音监听器组件之后,我们可以开始实现将所有这些代码激活的声音系统。让我们开始吧!
class S_Sound : public S_Base{
public:
S_Sound(SystemManager* l_systemMgr);
~S_Sound();
void Update(float l_dT);
void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event);
void Notify(const Message& l_message);
void SetUp(AudioManager* l_audioManager,
SoundManager* l_soundManager);
private:
sf::Vector3f MakeSoundPosition(const sf::Vector2f& l_entityPos,
unsigned int l_elevation);
void EmitSound(const EntityId& l_entity,
const EntitySound& l_sound, bool l_useId, bool l_relative,
int l_checkFrame = -1);
AudioManager* m_audioManager;
SoundManager* m_soundManager;
};
除了系统需要实现的典型方法和一些自定义方法之外,我们还有两个数据成员,它们指向AudioManager和SoundManager类的实例。让我们开始实际实现声音系统:
S_Sound::S_Sound(SystemManager* l_systemMgr)
: S_Base(System::Sound, l_systemMgr), m_audioManager(nullptr),
m_soundManager(nullptr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::SoundEmitter);
m_requiredComponents.push_back(req);
req.ClearBit((unsigned int)Component::SoundEmitter);
req.TurnOnBit((unsigned int)Component::SoundListener);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Direction_Changed, this);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Frame_Change, this);
}
构造函数,不出所料,设置了两个可能的要求掩码版本,这两个版本都需要位置组件存在。然后,它订阅了我们之前讨论的两个消息类型。
由于我们需要访问音频管理器和声音管理器,这样的方法肯定很有用:
void S_Sound::SetUp(AudioManager* l_audioManager,
SoundManager* l_soundManager)
{
m_audioManager = l_audioManager;
m_soundManager = l_soundManager;
}
接下来,让我们尝试实现Update方法:
void S_Sound::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto &entity : m_entities){
C_Position* c_pos = entities->
GetComponent<C_Position>(entity, Component::Position);
sf::Vector2f position = c_pos->GetPosition();
unsigned int elevation = c_pos->GetElevation();
bool IsListener = entities->
HasComponent(entity, Component::SoundListener);
if (IsListener){
sf::Listener::setPosition(
MakeSoundPosition(position, elevation));
}
if (!entities->HasComponent(
entity, Component::SoundEmitter))
{
continue;
}
C_SoundEmitter* c_snd = entities->GetComponent<C_SoundEmitter>
(entity,Component::SoundEmitter);
if (c_snd->GetSoundID() == -1){ continue; }
if (!IsListener){
if (!m_soundManager->SetPosition(c_snd->GetSoundID(),
MakeSoundPosition(position, elevation)))
{
c_snd->SetSoundID(-1);
}
} else {
if (!m_soundManager->IsPlaying(c_snd->GetSoundID())){
c_snd->SetSoundID(-1);
}
}
}
}
在这个系统中,每个实体首先获取其位置和高度,并将这些信息存储在几个局部变量中。它还确定当前实体是否是声音监听器,并将该信息存储在布尔变量中。
如果当前实体具有声音发射器组件且其声音 ID 不等于-1,可以安全地推断声音目前正在播放。如果当前实体不是声音监听者,我们尝试更新声音的位置并在if语句中捕获结果。如果位置更新失败,则将声音 ID 设置回-1,因为这表示声音不再活跃。如果实体实际上是监听者,我们根本不需要更新声音的位置。相反,我们通过调用IsPlaying方法来确定声音是否仍在播放。
之后,如果当前实体具有监听器组件,有必要更新sf::Listener类的位置。注意这里以及之前代码块中MakeSoundPosition方法的使用。它根据实体的位置和高度返回一个sf::Vector3f。我们将在稍后介绍这个方法。
让我们处理之前讨论的两种消息类型:
void S_Sound::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver)){ return; }
EntityManager* entities = m_systemManager->GetEntityManager();
bool IsListener = entities->
HasComponent(l_message.m_receiver, Component::SoundListener);
EntityMessage m = (EntityMessage)l_message.m_type;
switch (m){
case EntityMessage::Direction_Changed:
{
if (!IsListener){ return; }
Direction dir = (Direction)l_message.m_int;
switch (dir){
case Direction::Up: sf::Listener::setDirection(0, 0, -1);
break;
case Direction::Down: sf::Listener::setDirection(0, 0, 1);
break;
case Direction::Left: sf::Listener::setDirection(-1, 0, 0);
break;
case Direction::Right: sf::Listener::setDirection(1, 0, 0);
break;
}
}
break;
case EntityMessage::Frame_Change:
if (!entities->HasComponent(l_message.m_receiver,
Component::SoundEmitter))
{
return;
}
EntityState state = entities->GetComponent<C_State>
(l_message.m_receiver, Component::State)->GetState();
EntitySound sound = EntitySound::None;
if (state==EntityState::Walking){sound=EntitySound::Footstep;}
else if(state == EntityState::Attacking){
sound = EntitySound::Attack;
} else if (state==EntityState::Hurt){sound=EntitySound::Hurt;}
else if (state==EntityState::Dying){sound=EntitySound::Death;}
if (sound == EntitySound::None){ return; }
EmitSound(l_message.m_receiver, sound, false,
IsListener, l_message.m_int);
break;
}
}
如果实体的方向已改变且它是声音监听者,我们显然需要改变sf::Listener的方向以匹配消息中携带的方向。另一方面,如果我们收到有关帧改变的消息,将使用实体 ID、声音类型、两个布尔标志(指示声音是否应该循环以及是否相对于监听者)和动画当前帧作为参数调用EmitSound方法。场景中声音相对于监听者的相对性简单由当前实体本身是否是监听者来决定。
在空间中定位声音也是整个系统正确工作的重要组成部分。让我们看看MakeSoundPosition方法:
sf::Vector3f S_Sound::MakeSoundPosition(
const sf::Vector2f& l_entityPos, unsigned int l_elevation)
{
return sf::Vector3f(l_entityPos.x,
l_elevation * Sheet::Tile_Size, l_entityPos.y);
}
由于 SFML 中的默认向上向量是正Y轴,因此将实体位置的二维坐标作为 X 和 Z 参数传入。同时,Y参数简单地是实体高度乘以Tile_Size值,该值在Map.h头文件中找到,这导致实体高度模拟高度。
最后但绝对重要的是,我们有一段负责实体发射所有声音的代码,我们需要看看:
void S_Sound::EmitSound(const EntityId& l_entity,
const EntitySound& l_sound, bool l_useId, bool l_relative,
int l_checkFrame)
{
if (!HasEntity(l_entity)){ return; }
if (!m_systemManager->GetEntityManager()->
HasComponent(l_entity, Component::SoundEmitter))
{
return;
}
// Is a sound emitter.
EntityManager* entities = m_systemManager->GetEntityManager();
C_SoundEmitter* c_snd = entities->GetComponent<C_SoundEmitter>
(l_entity, Component::SoundEmitter);
if (c_snd->GetSoundID() != -1 && l_useId){ return; }
// If sound is free or use of ID isn't required.
if (l_checkFrame != -1 &&
!c_snd->IsSoundFrame(l_sound, l_checkFrame))
{
return;
}
// Frame is irrelevant or correct.
C_Position* c_pos = entities->
GetComponent<C_Position>(l_entity, Component::Position);
sf::Vector3f pos = (l_relative ?
sf::Vector3f(0.f, 0.f, 0.f) :
MakeSoundPosition(c_pos->GetPosition(),
c_pos->GetElevation()));
if (l_useId){
c_snd->SetSoundID(m_soundManager->
Play(c_snd->GetSound(l_sound), pos));
} else {
m_soundManager->Play(c_snd->GetSound(l_sound),
pos, false, l_relative);
}
}
第一项任务是显然检查声音系统是否有具有提供 ID 的实体,并且该实体是否是声音发射器。如果是,就获取声音发射器组件并检查它存储的声音 ID 是否等于-1。然而,如果实体已经发射了另一个声音但l_useId参数设置为false,这告诉我们应该发出声音。接下来,检查传入的帧参数是否等于-1,这意味着应该播放声音,或者检查它是否是声音发射器组件内部定义的声音帧之一。
一旦我们决定播放声音,就会获取实体的位置组件并用于计算声音的位置。如果它应该是相对于听者的,则位置简单地设置为所有轴的绝对零坐标。
如果我们只想保留特定声音的单个实例,声音管理器的Play方法将在声音发射组件的SetSoundID参数列表中调用,以捕获返回的 ID。它只传递了两个参数,因为其他两个布尔标志持有false的默认值。否则,如果这个特定的声音应该播放,无论实体是否已经发出另一个声音,我们的声音管理器的Play方法将自行调用,并将声音相对于听者的布尔标志作为最后一个参数传递。
整合我们的代码
为了防止在不适当时播放声音或音乐,我们的状态管理器必须通知声音管理器任何状态变化:
void StateManager::SwitchTo(const StateType& l_type){
...
m_shared->m_soundManager->ChangeState(l_type);
...
}
由于声音管理器也关心状态的移除,让我们在发生这种情况时通知它:
void StateManager::RemoveState(const StateType& l_type){
for (auto itr = m_states.begin();
itr != m_states.end(); ++itr)
{
if (itr->first == l_type){
...
m_shared->m_soundManager->RemoveState(l_type);
return;
}
}
}
我们现在唯一要做的就是将我们所有的工作整合到我们的代码库中,从SharedContext.h开始:
...
#include "AudioManager.h"
#include "SoundManager.h"
...
struct SharedContext{
SharedContext():
...
m_audioManager(nullptr),
m_soundManager(nullptr),
...
{}
...
AudioManager* m_audioManager;
SoundManager* m_soundManager;
...
};
接下来,在共享上下文中实例化和管理这两个新类至关重要。让我们先从修改Game.h头文件开始:
class Game{
public:
...
private:
...
AudioManager m_audioManager;
SoundManager m_soundManager;
...
};
和往常一样,我们将这些管理类放在Game中,以便正确管理它们的生命周期。然而,对于其中的一些,仅仅存在是不够的。它们需要像这样设置:
Game::Game(): ..., m_soundManager(&m_audioManager)
{
...
m_context.m_audioManager = &m_audioManager;
m_context.m_soundManager = &m_soundManager;
...
m_systemManager.GetSystem<S_Sound>(System::Sound)->
SetUp(&m_audioManager, &m_soundManager);
...
}
在创建这两个类之后,它们的地址被传递到共享上下文中。一个容易被忽视的重要细节是在这一点上实际上设置声音系统。它需要访问音频和声音管理器。
让我们别忘了在整个应用程序流程中正确更新声音管理器:
void Game::Update(){
...
m_soundManager.Update(m_elapsed.asSeconds());
...
}
随着新组件和系统的创建,我们也有责任确保它们可以自动创建,通过将组件类型添加到实体管理器:
EntityManager::EntityManager(SystemManager* l_sysMgr,
TextureManager* l_textureMgr): ...
{
...
AddComponentType<C_SoundEmitter>(Component::SoundEmitter);
AddComponentType<C_SoundListener>(Component::SoundListener);
}
我们的声音系统也需要在系统管理器内部创建:
SystemManager::SystemManager():...{
...
m_systems[System::Sound] = new S_Sound(this);
}
完成所有这些后,我们终于可以为我们的游戏添加音乐了!让我们先通过修改State_Intro.cpp来确保我们有一个开场音乐:
void State_Intro::OnCreate(){
...
m_stateMgr->GetContext()->m_soundManager->
PlayMusic("Electrix", 100.f, true);
}
此外,在游戏实际进行时有一些背景音乐会很好,所以让我们按照以下方式修改State_Game.cpp:
void State_Game::OnCreate(){
...
m_stateMgr->GetContext()->m_soundManager->
PlayMusic("TownTheme", 50.f, true);
}
哇!就这样,我们现在在我们的 RPG 中有了音乐和动态音效!
摘要
游戏世界开始发展出一种角色感和存在感,从简单的氛围到复杂的音乐曲目牵动着玩家的心弦。我们投入的所有努力,确保我们的项目不会沉默无声,累积起来又是一次向质量方向的重大飞跃。然而,随着我们开始接近这本书的结尾,只剩下两章,最具挑战性的部分仍然还在前方。
在下一章中,我们将探索广阔的网络世界以及它是如何帮助我们把我们孤独、安静的角色扮演游戏转变为多个玩家战斗区的。那里见!
第十三章. 我们有联系! – 网络基础
在当今这个人人互联、事事相连的世界里,和朋友一起玩游戏已经不再是什么新鲜事了。它已经成为许多群体中的标准。像“击杀”或“露营”这样的表达已经成为游戏玩家的流行语。无论是 2-4 人的局域网聚会还是大型多人在线游戏,网络显然在游戏圈中扮演着巨大的角色。引入其他玩家的元素增加了游戏内容,同时也让游戏的宇宙看起来更加生动和繁荣。在许多情况下,这种现象实际上将人们聚集在一起,并提供了一种非常愉快的体验,只要它不卡顿。现在是时候利用多人游戏的核心,也许甚至传播六度分隔理论了。
在本章中,我们将介绍以下内容:
-
网络应用程序的基本原理
-
利用线程并确保数据安全
-
实现我们自己的基本通信协议
-
构建一个简单的聊天客户端和服务器
让我们打破系统的孤立状态,将其对外开放!
网络基础
首先,让我们来解释一个在此时与网络几乎同义的术语:套接字。什么是套接字?最简单的说法,套接字就是一个用于网络通信的接口。当两个应用程序进行通信时,至少涉及两个套接字,并且它们之间交换数据。当数据从应用程序 A 发送到应用程序 B 时,它首先从应用程序 A 的套接字出发,穿越整个互联网,并希望最终到达应用程序 B 的套接字:

每个套接字都必须绑定到一个称为端口的实体上,这可以想象成通向系统的门户。每个门户用于不同的目的,并且一次只能由一个套接字使用。最简单的说法,端口就是一个 16 位的数值,这意味着端口号可以高达 65535。当某个服务正在使用特定的端口时,另一个套接字无法绑定到它,直到它被释放。最常用的端口范围在 20-1024 之间。例如,端口 80 始终用于 HTTP 流量,这是大多数网站托管服务器运行的基础。
SFML 网络
为了在 SFML 中访问网络结构,我们首先需要包含网络头文件:
#include <SFML/Network.hpp>
构建具有网络功能的项目还需要更多的库文件来正确链接,具体为sfml-network.lib、ws2_32.lib和winmm.lib。包含这些库将确保项目能够正确编译。
现在有几种类型的套接字可供选择,每种都有特定的功能、优点和缺点。SFML 为我们提供了两种基本类型:TCP 和 UDP。TCP 代表 传输控制协议,而 UDP 代表 用户数据报协议。这两个协议都能够发送和接收数据,但在底层它们是根本不同的。公平地说,虽然同一类型的两个套接字不能绑定到同一个端口,但它们仍然可以被两种不同的协议绑定。
TCP 套接字
TCP 是一种基于连接的协议,这意味着在交换数据之前,必须通过一个尝试发起连接的应用程序(客户端)连接到另一个积极等待连接的应用程序(服务器)来建立连接。让我们看看一个基本的客户端应用程序连接尝试:
sf::TcpSocket socket;
sf::Socket::Status status =
socket.connect("192.168.1.2", 5600, sf::seconds(5.f));
if (status != sf::Socket::Done){
// Connection failed.
}
首先,我们创建一个 TCP 套接字实例。接下来,会调用其 connect 方法,并传递三个参数:
-
第一个参数是
sf::IpAddress类型,正如其名称所示:我们试图连接到的 IP 地址,它必须是开放的并且有一个服务器正在接受连接。 -
第二个参数是端口号。
-
最后,我们有第三个参数,它是完全可选的。这是套接字在超时后放弃并抛出错误的超时值。如果没有提供此参数,则使用默认的操作系统超时值。
connect 方法的返回值被捕获并存储在 sf::Socket::Status 类型中,它只是一个包含一些有用值的枚举表,例如 Done、NotReady、Partial、Disconnected 和 Error。与发送或接收数据、连接或断开连接相关的两种套接字类型的每个方法都会返回一个我们可以用于错误检查的状态。
当使用 TCP 在服务器端接受连接时,会使用一个特殊的类:sf::TcpListener。它必须绑定到特定的端口,并且不能发送或接收任何数据:
sf::TcpListener listener;
if (listener.listen(5600) != sf::Socket::Done)
{
// Unable to bind to port.
}
sf::TcpSocket incoming;
if (listener.accept(incoming) != sf::Socket::Done)
{
// Failed accepting an incoming connection.
}
在套接字设置完成后,会调用监听器的 accept 方法。与 connect 以及我们稍后将要讨论的几个其他方法一样,它实际上会阻止应用程序继续执行,直到建立连接。这被称为 阻塞。一个来自 STL 库的阻塞函数的好例子是 std::cin。为什么这很重要呢?简单来说,网络操作相当不可预测。我们无法确切知道连接尝试可能需要多长时间,因为另一端的宿主可能无法到达。在这段时间里,你的应用程序将停滞不前,什么也不做。
最终,当连接建立后,传入的套接字可以用来与客户端通信:
char data[100];
// ...
if (socket.send(data, 100) != sf::Socket::Done){
// Sending data failed.
}
发送方法有两种变体:一种是低级版本,允许用户发送原始字节数组,另一种是高级版本,它使用我们将很快介绍的专业化类。低级版本接受一个void指针和它应该发送的字节数量。
小贴士
请记住,由于多种原因,发送数据也可能失败。务必始终检查返回的状态以检查错误!
为了在另一端接收数据,套接字需要监听:
char data[100];
std::size_t received;
if (socket.receive(data, 100, received) != sf::Socket::Done)
{
// Failed receiving data.
}
当发送原始数据时,必须提供一个足够大的缓冲区以及它可以包含的最大大小,这是接收方法中的第二个参数。第三个参数是接收到的字节数,当数据到来时会被写入。默认情况下,接收方法也是阻塞的。这意味着它将使整个程序停止,直到有数据通过。
处理多个连接
你可能已经注意到,上述所有示例都只关注一个客户端连接并发送数据。在当今高度互联的世界里,这种情况几乎从未发生过,所以让我们看看我们如何同时处理多个 TCP 套接字:
sf::TcpSocket socket;
// ...
sf::SocketSelector selector;
selector.add(socket);
sf::SocketSelector类提供了一种让我们能够阻塞在多个套接字上的方法,而不是仅仅一个。它监视添加到其中的每个套接字,以寻找传入的数据,与之前的示例不同,之前的示例只处理了一个套接字。
注意
需要牢记的一个重要事情是,套接字选择器实际上并不存储添加到其中的套接字,它只是指向它们。这意味着尽管套接字已被添加到选择器中,但它仍然必须存储在你选择的数据容器中。
要处理来自多个套接字的数据,可以使用套接字选择器的wait方法:
sf::SocketSelector selector;
std::vector<sf::TcpSocket> container;
// ...
sf::TcpSocket socket;
selector.add(socket);
container.push_back(socket);
if (selector.wait(sf::seconds(10))){
for (auto &itr : container){
if (selector.isReady(itr)){
// Socket received data.
char data[100];
std::size_t received;
sf::Socket::Status status= itr.receive(data, 100, received);
if (status != sf::Socket::Done){
// Failed receiving data...
}
}
}
} else {
// Timed out...
}
wait方法提供的参数再次是可选的。如果选择器内的某个套接字接收到了数据,则返回true,我们可以通过使用isRead方法遍历我们的数据容器来找到接收数据的套接字。
TCP 协议细节
TCP 和 UDP 之间一个主要的不同点在于传输可靠性。TCP 协议在建立连接时使用了一种称为三次握手的机制。它看起来有点像这样:

尝试建立连接的一方首先发送一个SYN(同步)数据包。服务器响应一个SYN/ACK(同步确认)数据包,客户端随后响应一个ACK(确认)数据包。这三个数据交换发生在每个连接的开始阶段。之后,当实际数据发送时,它以 SYN 数据包的形式发送,接收方总是回复一个 ACK 数据包。如果发送数据的方没有收到 ACK 响应,那么在特定的时间间隔后,会再次发送相同的数据。所有这些来回发送的数据都会被标记上序列号,这使得 TCP 协议也能确保数据按顺序到达。这提供了可靠性,但代价是速度较慢且体积较大。如果某个数据包丢失,接收方必须等待直到相同的数据被重传才能继续。对于大多数应用程序甚至某些类型的游戏,这种速度差异是可以忽略不计的。然而,一些需要最高效率且不关心数据包丢失的快节奏游戏最终会使用 UDP。
用户数据报协议
在 SFML 中,TCP 和 UDP 套接字实际上都继承自同一个基类,这意味着我们之前看到的 TCP 的大部分功能都得到了继承。然而,一个主要的区别是 UDP 是无连接的。这意味着 UDP 没有三次握手这样的东西,也没有确认包。UDP 专注于数据的发送。没有顺序检查,没有序列号,没有膨胀的包,也没有保证发送出去的数据会到达目的地。这种完全消除错误检查将数据包开销从使用 TCP 时的 20 字节降低到 8 字节。
说了这么多,UDP 也有一些限制,比如发送数据的最大大小。UDP 中的数据是以数据报的形式发送的,而不是像 TCP 那样以流的形式,这是 TCP 处理数据的方式。规定的最大数据报大小略小于 65536 字节,不能超过。
因为 UDP 是无连接的,所以没有可以用来接收传入流量的sf::TcpListener等价物。不过,在可以使用之前,套接字必须绑定到特定的端口:
sf::UdpSocket socket;
// Bind to port 5600
if (socket.bind(5600) != sf::Socket::Done)
{
// Binding failed.
}
通过sf::Socket::AnyPort,可以将随机端口绑定到套接字,而不是使用数值常量。稍后可以像这样检索它:
sf::UdpSocket socket;
// ...
unsigned int port = socket.getLocalPort();
发送和接收的基本原理相同,只是由于 UDP 是无连接的,所以需要提供额外的参数,即数据发送到或接收自的 IP 地址和端口:
sf::UdpSocket socket;
// ...
char data[100];
if (socket.send(data, 100, "192.168.1.2", 5600)
!= sf::Socket::Done)
{
// Sending failed.
}
// ...
sf::IpAddress ipAddr;
unsigned short port;
std::size_t received;
if (socket.receive(data, 100, received, ipAddr, port)
!= sf::Socket::Done)
{
// Receiving failed.
}
最后,UDP 套接字确实与sf::SocketSelector类一起工作,但鉴于 UDP 的特性,真正有用的情况并不多,因为所有数据最多可以通过一个或两个套接字发送和接收。
发送原始数据的替代方案
在网络上简单地发送原始字节可能会变得相当复杂,更不用说有问题了。第一个也许是最大的问题是机器的字节序。一些处理器以不同于其他处理器的顺序解释数据。在大端字节序的家族中,最重要的字节首先存储,而在小端字节序的机器中则会相反。从大端机器发送到小端机器的原始数据会被不同地解释,并导致奇怪的结果。
除了所有类型的机器存储数据的方式不同之外,C++中基本变量的大小可能在不同的机器和编译器之间有所不同。如果这还不够,TCP 协议由于不保留消息边界而引入了额外的麻烦。发送出去的数据块可能会被分割和组合,如果接收者没有正确地重新构造它们,这可能会引起问题。
虽然所有这些都听起来相当可怕,但所有这些问题都有解决方案。可以通过使用 SFML 的固定大小类型来解决数据类型大小变化的问题,例如sf::Int8、sf::Uint16等。它们是简单的类型定义,映射到平台上的预期大小确定的数据类型。通过网络交换这些类型可以再次确保数据安全。
SFML 数据包
字节序和消息边界问题需要稍微更多的努力来解决。这时就出现了sf::Packet!它是一个专门且轻量级的类,可以用来打包/提取数据。SFML 数据包使用与标准流完全相同的接口,通过使用<<和>>操作符进行数据插入和提取,如下所示:
sf::Int16 n = 16;
float f = 32.f;
std::string str = "Aloha";
sf::Packet packet;
packet << n << f << str;
// ...
packet >> n >> f >> str;
虽然打包数据始终可以保证工作,但提取数据实际上可能会失败。如果发生这种情况,数据包错误标志会被设置。检查标志是否设置与测试布尔值类似,这又与标准流类似:
if(!(packet >> n)){
// Failed extraction.
}
TCP 和 UDP 数据包都提供了与sf::Packet实例一起工作的重载发送和接收方法:
sf::Packet packet;
// TCP
tcpSocket.send(packet);
tcpSocket.receive(packet);
// UDP
udpSocket.send(packet, "192.168.1.2", 5600);
sf::IpAddress senderIP;
unsigned short senderPort;
udpSocket.receive(packet, senderIP, senderPort);
如果提供了<<和>>操作符的重载,自定义数据类型也可以被输入到或从sf::Packet结构中提取,如下所示:
struct SomeStructure{
sf::Int32 m_int;
std::string m_str;
};
sf::Packet& operator <<(sf::Packet& l_packet,
const SomeStructure& l_struct)
{
return l_packet << l_struct.m_int << l_struct.m_str;
}
sf::Packet& operator >>(sf::Packet& l_packet,
SomeStructure& l_struct)
{
return l_packet >> l_struct.m_int >> l_struct.m_str;
}
这使得插入和提取自定义数据类型变得容易:
SomeStructure s;
sf::Packet packet;
packet << s;
packet >> s;
使用 SFML 数据包与 TCP 套接字存在一个小限制。由于必须保留消息边界,数据包中会发送一些额外的数据。这意味着以 SFML 数据包形式发送的数据必须使用 SFML 数据包接收。UDP 不对此有限制,因为该协议本身保留了消息边界。
非阻塞套接字
默认情况下,TCP 和 UDP 套接字以及 TCP 监听器都是阻塞的。它们的阻塞模式可以更改为立即返回:
sf::TcpSocket tcp;
tcp.setBlocking(false);
sf::TcpListener tcpListener;
tcpListener.setBlocking(false);
sf::UdpSocket udp;
udp.setBlocking(false);
在一个没有数据传入的非阻塞套接字上接收数据会返回 sf::Socket::NotReady,同样,如果没有挂起的 TCP 连接,尝试接受 TCP 连接也会返回 sf::Socket::NotReady。将你的套接字置于非阻塞模式是避免程序执行因检查数据或连接的可用性而中断的最简单方法。
非阻塞 TCP 套接字不能保证发送你传递给它的所有数据,即使使用 sf::Packet 实例。如果返回 sf::Socket::Partial 状态,必须在 send 上一次调用停止的确切字节偏移量处再次发送数据。如果发送原始数据,请确保使用此 send 重载:
send(const void* data, std::size_t size, std::size_t& sent)
它会覆盖第三个提供的参数,以发送的字节数的准确值。
发送 sf::Packet 实例不需要你跟踪字节数偏移量,因为它存储在数据包本身中。这意味着你必须在数据包成功发送之前不能销毁数据包实例。创建一个新的数据包并用完全相同的数据填充它将不起作用,因为数据包内部存储的数据偏移量丢失了。
让流量流动
在互联网上进行通信比使用正确的代码有更多细微之处。正如我们之前讨论的,应用程序用于发送或接收数据的端口号可以想象成是通向你的系统的门户,其中有成千上万个。这个门户可以是开启的,也可以是关闭的。默认情况下,你选择的程序使用的端口在你的系统上更有可能被关闭,这对于本地连接来说无关紧要,但任何通过该特定端口从外部世界传入的数据都无法通过。你可以通过访问你的路由器设置页面来管理你的端口。所需的步骤因每个路由器而异。幸运的是,portforward.com 提供了帮助!通过访问它并在该网站上查找你的路由器的制造商和型号,你可以找到有关如何打开或关闭任何端口的详细说明。
小贴士
绑定到 sf::Socket::AnyPort 的套接字很可能会绑定到 49152 和 65535 之间的某个端口。端口转发适用于范围,以及单个端口。打开这个特定的端口范围将确保你的 SFML 网络应用程序在通过万维网进行通信时按预期工作。
防火墙也通常会默认阻止此类流量。例如,Windows 防火墙会提示用户允许流量通过首次启动的应用程序。然而,根据您的应用程序,由于 Windows 防火墙并非最可靠的应用软件,该提示可能永远不会出现。如果您的所有关键端口都开放,而某个特定程序似乎仍然没有发送或接收任何内容,请确保将您的客户端或服务器程序添加到 Windows 防火墙的“允许列表”中,方法是转到控制面板,点击Windows 防火墙,在左侧选择通过 Windows 防火墙允许程序或功能,点击更改设置,最后点击允许另一个程序按钮。这将打开另一个窗口,您可以通过浏览并点击添加来添加您的客户端/服务器应用程序。
多线程
在您的代码中包含阻塞函数可能会带来真正的麻烦。监听传入的网络连接或数据、要求用户在控制台中输入某些内容,或者甚至加载游戏数据,如纹理、地图或声音,都可能阻塞程序执行直到完成。您是否曾想过某些游戏在加载数据时加载条会实际移动?如何用顺序执行的代码实现这一点?答案是多线程。您的应用程序从上到下按顺序在主线程中运行所有代码。它不是一个程序,因为它不能独立存在。相反,线程只在您的应用程序中运行。这种美妙的特性是,可以同时存在并运行多个线程,这实现了并行代码执行。考虑以下图示:

假设整个应用程序空间是主线程,我们在这里所做的一切就是更新和渲染游戏。上面的例子中除了主线程外还运行了三个线程。第一个线程可以用来监听传入的网络连接。线程 #2 负责在新关卡打开或关闭时加载数据或卸载数据。最后,第三个线程可能正在等待控制台输入。即使这三个线程都阻塞了,应用程序仍然会继续渲染!真酷!
SFML 为我们提供了一些基本类型,可以用来创建和控制线程。让我们首先给线程分配一些工作:
void Test(){
for (int i = 0; i < 10; ++i){
std::cout << i << std::endl;
}
}
这只是一个我们希望与主线程并行执行的基本函数。如何实现这一点?通过使用 sf::Thread!
小贴士
C++ 也提供了它自己的线程类 std::thread,以及它自己的锁和互斥量。它还提供了一个 std::future 类模板,这在访问异步操作的结果时非常有用。
首先,必须通过向其构造函数提供函数或成员函数指针来正确设置它:
sf::Thread thread1(Test);
线程构造函数实际上提供了四个重载,甚至可以接受std::bind和 lambda 表达式的返回值,这使得我们可以向这些函数提供任意数量的参数。一旦线程设置好,就必须启动它才能执行代码:
thread1.launch();
一旦执行函数返回,其线程将自动停止。sf::Thread类提供了一个终止方法,但除非您知道自己在做什么,否则不应使用它。它可能会产生不受欢迎的行为,包括在某些操作系统上局部变量不会被销毁。相反,您的代码应该设计成允许线程在不再需要时自行停止。手动终止它是不安全的!警告您了。
线程提供的最后一个方法是wait方法:
thread1.wait();
被调用的线程将一直挂起,直到thread1完成。这可能会非常危险。如果thread1中存在无限循环或被调用的阻塞函数永远不会解除阻塞,程序将完全挂起。
注意
在sf::Thread完成之前,永远不要销毁其实例!这将导致主线程挂起,因为线程的析构函数会调用其wait方法。您的应用程序将陷入停滞。
共享数据保护
顺便说一下,使用线程的原因也是用户可能遇到的大多数问题的原因。并行运行代码块是很好的,但如果两个线程试图读取或修改相同的数据会发生什么呢?在这种场景下,崩溃和数据损坏是明确的可能性。想象一下这样一个场景:主线程持有要更新和渲染的实体列表。到目前为止,一切顺利!接下来,让我们引入一个新的线程,该线程将运行特定于网络的代码,并有权访问我们所有的实体。如果这个线程出于任何原因决定删除一个实体,那么它很可能发生在主线程的更新或渲染周期中。此时,我们都知道当您正在使用的迭代器突然变得无效时会发生什么。幸运的是,有方法可以确保您的代码中的所有操作都是线程安全的,通过同步它们来实现。
SFML 为我们提供了一个有趣的名为sf::Mutex的小类。它代表互斥,基于一个非常基本的原理:允许只有一个线程执行某些代码,同时使其他线程等待它完成。让我们通过一个基本的代码示例来帮助您更好地理解这个概念:
sf::Mutex mutex;
void Test(){
mutex.lock();
for (int i = 0; i < 10; ++i){
std::cout << i << std::endl;
}
mutex.unlock();
}
int main(){
sf::Thread thread1(Test);
thread1.launch();
mutex.lock();
// Do something data-sensitive here.
mutex.unlock();
return 0;
}
mutex类为我们提供了两个方法:lock和unlock。当一个互斥锁第一次被锁定时,锁定它的线程被赋予优先权,并允许继续执行代码。如果另一个线程在互斥锁仍然锁定的情况下调用lock方法,则不允许它进一步移动,直到互斥锁被解锁。一旦解锁,等待结束,第二个线程被允许继续。
让我们分析一下上面代码示例中发生的情况:thread1 被绑定到 Test 函数并立即启动。测试函数锁定互斥锁,因为在此之前它还没有被锁定,所以打印数字的循环开始迭代。与此同时,我们的主线程到达了 mutex.lock(); 这一行。此时可能已经打印出了一些数字。由于互斥锁已经被锁定,主线程立即停止。一旦 Test 函数打印出最后一个数字,就会到达 mutex.unlock(); 这一行。这允许主线程为自己锁定互斥锁并继续执行。如果其他任何线程试图调用共享互斥锁的锁定方法,它必须等待主线程完成。最后,互斥锁被解锁,任何在后台等待的可能线程现在可以继续执行。
存在一个潜在的边缘情况,这可能会变得危险。互斥锁必须被解锁,主线程才能继续执行。如果绑定到线程的函数突然抛出异常怎么办?如果它返回一个值,或者有返回不同值的 if/else 语句分支怎么办?unlock 方法可以在每个分支中调用,但这只会使代码变得混乱,更不用说它并没有解决异常问题。幸运的是,有一个非常优雅的解决方案:sf::Lock 类。它所做的只是在其构造函数中接收一个互斥锁的引用,此时它被锁定,并在析构函数中解锁它。在栈上创建这样的对象可以解决所有这些问题,因为互斥锁会在锁对象超出作用域时自动解锁。让我们看看它是如何使用的:
sf::Mutex mutex;
void Test(){
sf::Lock lock(mutex);
for (int i = 0; i < 10; ++i){
std::cout << i << std::endl;
if (i == 5){ return; } // mutex.unlock() called.
}
} // mutex.unlock() called.
这是一段更安全的代码。即使有可能抛出异常,共享互斥锁也会被解锁,允许程序的其余部分继续执行。
创建一个简单的通信协议
在涵盖了所有基础知识之后,我们终于准备好开始设计了!我们需要做的第一个选择是选择哪种协议更适合我们的需求。在像这样的实时应用程序中丢失数据包并不是悲剧。更重要的是,数据要尽可能快地发送和接收,以便更新玩家和游戏中的所有实体。由于 TCP 是一个较慢的协议,我们不会从它采取的额外措施中受益,因此选择很明确。用户数据报协议是更好的选择。
通过首先定义一些将在服务器和客户端之间交换的包类型,以及决定包标识符的类型,让我们详细说明一下我们将要构建的系统的一些细节。这些信息将保存在 PacketTypes.h 头文件中:
using PacketID = sf::Int8;
enum class PacketType{
Disconnect = -1, Connect, Heartbeat, Snapshot,
Player_Update, Message, Hurt, OutOfBounds
};
void StampPacket(const PacketType& l_type, sf::Packet& l_packet);
注意PacketType枚举中的最后一个元素。它并不是一个实际要发送或接收的数据包类型。相反,它仅仅是为了方便检查数据包类型是否有效而存在,这一点我们很快就会讨论。除了枚举之外,我们还提供了一个将类型附加到数据包的函数。该函数在PacketTypes.cpp文件中实现:
void StampPacket(const PacketType& l_type, sf::Packet& l_packet){
l_packet << PacketID(l_type);
}
这个函数只是将提供的类型参数转换为 SFML 提供的特定整数数据类型,然后再将其输入到数据包实例中。使用函数来做这件事,从长远来看是有好处的,如果我们决定通过在数据包中添加额外的数据来更改通信协议。
接下来,让我们创建一个头文件,其中包含客户端和服务器之间共享的最常见信息位。我们可以简单地称它为NetworkDefinitions.h:
enum class Network{
HighestTimestamp = 2147483647, ClientTimeout = 10000,
ServerPort = 5600, NullID = -1
};
using ClientID = int;
using PortNumber = unsigned short;
这些只是通信双方将要使用到的所有类型和常量。
保持 UDP 连接活跃
由于 UDP 套接字是无连接的,我们需要有一种方法来检查服务器端的任何一个客户端,或者客户端端的任何一个服务器,是否已经停止响应,因此被认为是超时了。这种机制的常见术语是心跳。它的实现可能因应用程序而异,以及你的个人感受。在这种情况下,我们将讨论一种相当基本的策略,不仅维护一个活跃的连接,而且还测量两边的网络延迟。
为了这个目的,最好由服务器启动心跳。它有两个主要好处:交换的数据更少,作弊的风险降低。让我们看看服务器-客户端心跳的最保守实现:

服务器将跟踪最后一次向客户端发送心跳的时间。结合预定义的心跳间隔,我们可以以恒定的速率发送它们。当需要发送时,构建一个心跳包并将其本地服务器时间附加到它上。然后将其发送到客户端。
这个操作的客户端部分要简单得多。它总是等待心跳,当收到一个心跳时,更新客户端的本地时间并保留下来以供稍后检查超时。然后向服务器发送一个响应,确认确实收到了心跳。
当我们的服务器从客户端收到心跳响应时,通过从当前时间减去最后一次心跳包发送的时间来测量这两台机器之间的延迟。这个延迟,也称为延迟,是数据在两个主机之间往返所需的时间。
在服务器上实现心跳机制确保我们不会保留任何可能已经断开连接的客户端,从而通过向不可达的主机发送数据而浪费带宽。
设计客户端类
在客户端发生的一切,无论是渲染精灵、播放声音还是处理用户输入,都只意味着将所有网络代码本地化在单个类中。这将使我们能够快速轻松地与服务器通信。让我们首先查看Client.h头文件中的一些必要定义,开始设计这个类:
#define CONNECT_TIMEOUT 5000 // Milliseconds.
class Client;
using PacketHandler = std::function<
void(const PacketID&, sf::Packet&, Client*)>;
第一个定义是客户端意识到它不再连接到服务器所需的时间(以毫秒为单位)。这个值显然可以在任何时候进行调整。接下来是一个函数类型的定义,它将用于处理客户端上的数据包。我们将为客户端类提供一个指向函数的指针,该函数负责处理大部分传入的信息。
在处理完这些之后,我们可以开始塑造客户端类:
class Client{
public:
Client();
~Client();
bool Connect();
bool Disconnect();
void Listen();
bool Send(sf::Packet& l_packet);
const sf::Time& GetTime() const;
const sf::Time& GetLastHeartbeat() const;
void SetTime(const sf::Time& l_time);
void SetServerInformation(const sf::IpAddress& l_ip,
const PortNumber& l_port);
template<class T>
void Setup(void(T::*l_handler)
(const PacketID&, sf::Packet&, Client*), T* l_instance)
{
m_packetHandler = std::bind(l_handler, l_instance,
std::placeholders::_1, std::placeholders::_2,
std::placeholders::_3);
}
void Setup(void(*l_handler)(const PacketID&,
sf::Packet&, Client*));
void UnregisterPacketHandler();
void Update(const sf::Time& l_time);
bool IsConnected() const;
void SetPlayerName(const std::string& l_name);
sf::Mutex& GetMutex();
private:
std::string m_playerName;
sf::UdpSocket m_socket;
sf::IpAddress m_serverIp;
PortNumber m_serverPort;
PacketHandler m_packetHandler;
bool m_connected;
sf::Time m_serverTime;
sf::Time m_lastHeartbeat;
sf::Thread m_listenThread;
sf::Mutex m_mutex;
};
这里有一些需要注意的事情。首先,我们希望支持常规函数和成员函数作为数据包处理器,因此有两个Setup方法。显然,第一个方法由于有模板参数,必须在头文件中实现。
第二,这个类保留并管理自己的sf::Mutex和sf::Thread实例。这样,我们可以向外部代码提供一个通用的线程同步接口。
实现客户端
在处理完类定义后,现在是时候让它真正发挥作用了,从构造函数和析构函数开始:
Client::Client():m_listenThread(&Client::Listen, this){}
Client::~Client(){ m_socket.unbind(); }
在客户端构造函数的初始化列表中,我们将监听线程绑定到这个类的Listen方法。线程没有默认的空构造函数,这就是为什么这是必要的。析构函数只是用来解绑我们使用的套接字。
现在,让我们尝试实现连接协议:
bool Client::Connect(){
if (m_connected){ return false; }
m_socket.bind(sf::Socket::AnyPort);
sf::Packet p;
StampPacket(PacketType::Connect, p);
p << m_playerName;
if (m_socket.send(p, m_serverIp, m_serverPort) !=
sf::Socket::Done)
{
m_socket.unbind();
return false;
}
m_socket.setBlocking(false);
p.clear();
sf::IpAddress recvIP;
PortNumber recvPORT;
sf::Clock timer;
timer.restart();
while (timer.getElapsedTime().asMilliseconds()<CONNECT_TIMEOUT){
sf::Socket::Status s = m_socket.receive(p, recvIP, recvPORT);
if (s != sf::Socket::Done){ continue; }
if (recvIP != m_serverIp){ continue; }
PacketID id;
if (!(p >> id)){ break; }
if ((PacketType)id != PacketType::Connect){ continue; }
m_packetHandler(id, p, this);
m_connected = true;
m_socket.setBlocking(true);
m_lastHeartbeat = m_serverTime;
m_listenThread.launch();
return true;
}
std::cout << "Connection attempt failed! Server info: "
<< m_serverIp << ":" << m_serverPort << std::endl;
m_socket.unbind();
m_socket.setBlocking(true);
return false;
}
第一步也是最明显的一步是检查我们是否已经连接到服务器,通过检查m_connected数据成员。
接下来,我们使用的套接字必须绑定到一个端口。这里可以使用特定的端口号,但这将限制同一台计算机在同时可以拥有的连接数量。你不能在相同的协议上两次绑定同一个端口的套接字。通过使用sf::Socket::AnyPort,我们让 SFML 选择一个未被使用的随机端口。
为了建立连接,客户端必须首先向服务器发送一些信息。由于 SFML 已经提供了一个用于轻松数据传输的优秀辅助类sf::Packet,我们将充分利用它。
在将类型Connect分配给我们的数据包后,我们也写入玩家名称并将数据包发送到服务器。
代码的其余部分负责正确处理超时。首先,我们将套接字设置为非阻塞模式,因为我们将在单个线程中处理它。在清除我们刚刚发送的数据包以便再次使用后,设置一些局部变量以捕获响应的 IP 地址和端口号。此外,设置一个时钟,以帮助我们确定我们是否等待响应时间过长。
接下来,代码会在计时器保持在预定义的超时值CONNECT_TIMEOUT之下时循环。在每次迭代中,我们调用套接字的接收方法并捕获其状态。如果返回的状态不表示成功或接收到的 IP 地址不匹配我们服务器的 IP 地址,我们简单地跳过当前迭代。没有人想从未知来源接收数据!
在验证数据包包含 ID 并且它与Connect匹配后,我们将接收到的信息传递给数据包处理器,将m_connected标志设置为true,将套接字放回阻塞模式,将最后的心跳值设置为当前时间,启动监听线程,并返回true以表示成功。然而,如果成功连接的时间耗尽,循环结束,打印错误消息,并将套接字解绑并再次设置为阻塞模式。
一旦客户端连接到服务器,就会启动监听线程。让我们看看是什么让它运转:
void Client::Listen(){
sf::Packet packet;
sf::IpAddress recvIP;
PortNumber recvPORT;
while (m_connected){
packet.clear();
sf::Socket::Status status =
m_socket.receive(packet, recvIP, recvPORT);
if (status != sf::Socket::Done){
if (m_connected){
std::cout << "Failed receiving a packet from "
<< recvIP << ":" << recvPORT << ". Status: "
<< status << std::endl;
continue;
} else {
std::cout << "Socket unbound." << std::endl;
break;
}
}
if (recvIP != m_serverIp){
// Ignore packets not sent from the server.
continue;
}
PacketID p_id;
if (!(packet >> p_id)){
// Non-conventional packet.
continue;
}
PacketType id = (PacketType)p_id;
if (id<PacketType::Disconnect||id >=PacketType::OutOfBounds){
// Invalid packet type.
continue;
}
if (id == PacketType::Heartbeat){
sf::Packet p;
StampPacket(PacketType::Heartbeat, p);
if (m_socket.send(p, m_serverIp, m_serverPort) !=
sf::Socket::Done)
{
std::cout << "Failed sending a heartbeat!" << std::endl;
}
sf::Int32 timestamp;
packet >> timestamp;
SetTime(sf::milliseconds(timestamp));
m_lastHeartbeat = m_serverTime;
} else if(m_packetHandler){
m_packetHandler((PacketID)id, packet, this); // Handle.
}
}
}
在设置一些局部变量以保存数据包、IP 和端口号信息后,进入监听线程循环。只要客户端连接到服务器,它就会运行。每次循环迭代时,都会清除数据包实例以接收新数据。套接字接收方法的状态存储在局部变量status中,并检查是否成功。因为套接字处于阻塞模式,监听线程将在m_socket.receive(...)行处停止,直到有数据到来。
如果返回的状态表示某种失败,打印适当的错误消息,假设客户端仍然连接到服务器。如果不是,套接字将被解绑,循环立即停止,以便线程可以安全地终止。
假设已经正确接收了一些数据,检查原始 IP 地址。如果它不匹配我们服务器的 IP 地址,数据将通过跳过当前循环迭代被丢弃。同样,如果我们无法提取数据包 ID,或者它不在我们预定的边界内,结果相同。我们不希望有任何格式不正确或不欢迎的数据包。
接下来,我们检查刚刚接收到的数据包的 ID。在这个特定的类中,我们只关心一种类型的数据包:PACKET_HEARTBEAT。这些是小消息,服务器为了两个原因发送给所有客户端:时间同步和保持有效的连接。由于不可预见的情况,服务器端和客户端的时间可能会开始不同步,这最终可能导致严重的问题。每隔一段时间用来自服务器的时间戳覆盖客户端的时间可以消除这个问题。除此之外,这也是客户端和服务器跟踪连接是否仍然存活的方法。在我们的客户端中,m_lastHeartbeat保存了从服务器接收到的最新时间戳,稍后可以用来检查超时。
如果数据包 ID 是其他东西,它就直接传递给数据包处理函数,由不同的类进行处理。
现在我们有了所有这些打开和维护服务器连接的方法,让我们看看它是如何被终止的:
bool Client::Disconnect(){
if (!m_connected){ return false; }
sf::Packet p;
StampPacket(PacketType::Disconnect, p);
sf::Socket::Status s =
m_socket.send(p, m_serverIp, m_serverPort);
m_connected = false;
m_socket.unbind(); // Unbind to close the listening thread.
if (s != sf::Socket::Done){ return false; }
return true;
}
首先,检查客户端的状态。如果没有连接,我们不需要断开连接。然后,使用类型为Disconnect的数据包实例发送到服务器。在将m_connected标志设置为false之后,我们解绑我们的套接字,并根据发送数据包是否成功返回true或false。
注意
当套接字处于阻塞模式时,其receive方法会在有数据到达之前等待,然后继续。如果在单独的线程中发生类似的事情,它就会继续运行,因此会阻止我们的程序退出。防止这种情况的一种方法是通过解绑正在使用的套接字。这会使receive方法返回一个错误,我们在客户端类的Listen方法中已经处理了这个错误。
向服务器发送数据非常简单,就像下一个方法所展示的那样:
bool Client::Send(sf::Packet& l_packet){
if (!m_connected){ return false; }
if (m_socket.send(l_packet, m_serverIp, m_serverPort) !=
sf::Socket::Done)
{
return false;
}
return true;
}
我们接收一个需要发送出去的现有数据包的引用。如果我们没有连接到服务器,或者我们的套接字的send方法返回的不是sf::Socket::Done,该方法立即返回false。
我们还需要一种方法来提供自定义的数据包处理函数,该函数将由这个类使用。这个方法的成员函数版本已经在头文件中实现,剩下要处理的是函数指针版本:
void Client::Setup(void(*l_handler)
(const PacketID&, sf::Packet&, Client*))
{
m_packetHandler = std::bind(l_handler,
std::placeholders::_1, std::placeholders::_2,
std::placeholders::_3);
}
为了平衡,每个正面都需要一个反面。让我们提供一种方法来移除对可能不再存在的函数的任何关联,一旦代码开始封装:
void Client::UnregisterPacketHandler(){
m_packetHandler = nullptr;
}
最后但同样重要的是,更新方法:
void Client::Update(const sf::Time& l_time){
if (!m_connected){ return; }
m_serverTime += l_time;
if (m_serverTime.asMilliseconds() < 0){
m_serverTime -= sf::milliseconds(
sf::Int32(Network::HighestTimestamp));
m_lastHeartbeat = m_serverTime;
return;
}
if (m_serverTime.asMilliseconds() -
m_lastHeartbeat.asMilliseconds() >=
sf::Int32(Network::ClientTimeout))
{
// Timeout.
std::cout << "Server connection timed out!" << std::endl;
Disconnect();
}
}
这个方法的主要目的是通过将更新之间的时间添加到服务器时间来跟踪流逝的时间。
现在,你可能会在接下来的几行中注意到一些奇怪的地方。为什么我们要检查服务器时间是否低于零?嗯,自开始以来经过的毫秒数由一个有符号的 32 位整数表示。它的最大正值是 2,147,483,647,之后它直接进入负数,确切地说是-2,147,483,648。诚然,这种情况并不常见。事实上,服务器需要连续运行近 25 天才能使时间戳达到我们讨论的值。尽管如此,一个边缘情况场景不应该因为不太可能发生而被忽视。从 32 位整数的最大可能值中减去服务器时间戳“将时间绕回”到正数领域,并允许它继续像什么都没发生一样。
更新方法也是我们检查连接是否超时的地方。如果当前时间和从服务器接收到的最后心跳之间的差异大于或等于超时值(以毫秒为单位),则调用Disconnect方法。
服务器类
现在,是时候看看另一端是如何处理这些事情的了。让我们先定义一些常量:
#define HEARTBEAT_INTERVAL 1000 // Milliseconds.
#define HEARTBEAT_RETRIES 5
我们的服务器应用程序将每秒发送一次心跳,并在超时前重试五次,然后才会将客户端标记为超时。说到客户端,我们还需要跟踪一些额外的信息,这需要一个好的数据结构来存储所有这些信息:
struct ClientInfo{
sf::IpAddress m_clientIP;
PortNumber m_clientPORT;
sf::Time m_lastHeartbeat;
sf::Time m_heartbeatSent;
bool m_heartbeatWaiting;
unsigned short m_heartbeatRetry;
unsigned int m_latency;
ClientInfo(const sf::IpAddress& l_ip, const PortNumber& l_port,
const sf::Time& l_heartbeat): m_clientIP(l_ip),
m_clientPORT(l_port), m_lastHeartbeat(l_heartbeat),
m_heartbeatWaiting(false), m_heartbeatRetry(0), m_latency(0)
{}
ClientInfo& operator=(const ClientInfo& l_rhs){
m_clientIP = l_rhs.m_clientIP;
m_clientPORT = l_rhs.m_clientPORT;
m_lastHeartbeat = l_rhs.m_lastHeartbeat;
m_heartbeatSent = l_rhs.m_heartbeatSent;
m_heartbeatWaiting = l_rhs.m_heartbeatWaiting;
m_heartbeatRetry = l_rhs.m_heartbeatRetry;
m_latency = l_rhs.m_latency;
return *this;
}
};
除了简单地跟踪客户端的 IP 和端口号外,我们还需要知道上次向他们发送心跳的时间,服务器是否正在等待心跳响应,已进行的心跳重试次数,以及客户端当前的延迟。
小贴士
跟踪延迟提供了大量的潜在好处,从评估服务质量、精确匹配到最大化网络模拟的准确性。
接下来,我们将要使用的数据类型在整个Server类中值得一看:
using Clients = std::unordered_map<ClientID, ClientInfo>;
class Server;
using PacketHandler = std::function<void(sf::IpAddress&,
const PortNumber&, const PacketID&, sf::Packet&, Server*)>;
using TimeoutHandler = std::function<void(const ClientID&)>;
如您所见,这个类还使用了一个自定义函数,该函数将处理传入的数据包。除此之外,我们还需要能够在类外处理客户端超时,这也可以通过使用函数指针来完成。
我们已经有了所有需要的东西,所以让我们编写Server类的头文件:
class Server{
public:
template <class T>
Server(void(T::*l_handler)(sf::IpAddress&, const PortNumber&,
const PacketID&, sf::Packet&, Server*),
T* l_instance): m_listenThread(&Server::Listen, this)
{
m_packetHandler = std::bind(l_handler, l_instance,
std::placeholders::_1, std::placeholders::_2,
std::placeholders::_3, std::placeholders::_4,
std::placeholders::_5);
}
Server(void(*l_handler)(sf::IpAddress&, const PortNumber&,
const PacketID&, sf::Packet&, Server*));
~Server();
template<class T>
void BindTimeoutHandler(void(T::*l_handler)
(const ClientID&), T* l_instance)
{
m_timeoutHandler = std::bind(l_handler, l_instance,
std::placeholders::_1);
}
void BindTimeoutHandler(void(*l_handler)(const ClientID&));
bool Send(const ClientID& l_id, sf::Packet& l_packet);
bool Send(sf::IpAddress& l_ip, const PortNumber& l_port,
sf::Packet& l_packet);
void Broadcast(sf::Packet& l_packet,
const ClientID& l_ignore = ClientID(Network::NullID));
void Listen();
void Update(const sf::Time& l_time);
ClientID AddClient(const sf::IpAddress& l_ip,
const PortNumber& l_port);
ClientID GetClientID(const sf::IpAddress& l_ip,
const PortNumber& l_port);
bool HasClient(const ClientID& l_id);
bool HasClient(const sf::IpAddress& l_ip,
const PortNumber& l_port);
bool GetClientInfo(const ClientID& l_id, ClientInfo& l_info);
bool RemoveClient(const ClientID& l_id);
bool RemoveClient(const sf::IpAddress& l_ip,
const PortNumber& l_port);
void DisconnectAll();
bool Start();
bool Stop();
bool IsRunning();
unsigned int GetClientCount();
std::string GetClientList();
sf::Mutex& GetMutex();
private:
void Setup();
ClientID m_lastID;
sf::UdpSocket m_incoming;
sf::UdpSocket m_outgoing;
PacketHandler m_packetHandler;
TimeoutHandler m_timeoutHandler;
Clients m_clients;
sf::Time m_serverTime;
bool m_running;
sf::Thread m_listenThread;
sf::Mutex m_mutex;
size_t m_totalSent;
size_t m_totalReceived;
};
就像Client类一样,我们希望支持将成员函数和常规函数绑定作为数据包和超时处理程序。此外,我们还需要一个互斥锁的实例和两个套接字:一个用于监听,一个用于发送数据。在服务器端有两个套接字提供了不同操作之间的分离,这有时可能会导致运行时错误和数据损坏。作为额外的奖励,我们还在跟踪所有发送和接收的数据。
实现服务器
让我们先看看这个类的第二个构造函数和析构函数:
Server::Server(void(*l_handler)(sf::IpAddress&, const PortNumber&,
const PacketID&, sf::Packet&, Server*))
: m_listenThread(&Server::Listen, this)
{
// Bind a packet handler function.
m_packetHandler = std::bind(l_handler,
std::placeholders::_1, std::placeholders::_2,
std::placeholders::_3, std::placeholders::_4,
std::placeholders::_5);
}
Server::~Server(){ Stop(); }
这里没有发生什么有趣的事情。构造函数只是绑定提供的包处理函数,而析构函数只是调用我们即将介绍的Stop方法。说到绑定,我们还需要一个处理客户端超时的函数:
void Server::BindTimeoutHandler(void(*l_handler)
(const ClientID&))
{
m_timeoutHandler = std::bind(l_handler, std::placeholders::_1);
}
仅让客户端断开连接并不总是足够的,这取决于你的应用程序。我们将利用这个功能的主要方式之一是在游戏世界中销毁实体。
现在,让我们看看我们是如何启动服务器的:
bool Server::Start(){
if (m_running){ return false; }
if(m_incoming.bind(SERVER_PORT) != sf::Socket::Done){
return false;
}
m_outgoing.bind(sf::Socket::AnyPort);
Setup();
std::cout << "Incoming port: " <<
m_incoming.getLocalPort() << ". Outgoing port: "
<< m_outgoing.getLocalPort() << std::endl;
m_listenThread.launch();
m_running = true;
return true;
}
如果服务器已经运行,或者如果我们未能将传入的套接字绑定到预指定的端口号,则返回false。否则,传出套接字绑定到一个随机端口,调用Setup方法来设置一些数据成员,启动监听线程,并将m_running标志设置为true。你可以这样停止服务器:
bool Server::Stop(){
if (!m_running){ return false; }
DisconnectAll();
m_running = false;
m_incoming.unbind(); // Stops the listening thread.
return true;
}
如果服务器实际上正在运行,则会调用DisconnectAll方法来断开所有客户端。然后运行标志设置为false,并将传入的套接字解绑,这反过来又停止了监听线程的运行,因为套接字处于阻塞模式。
这里有一个小助手方法,它将一些数据成员初始化为它们的默认状态:
void Server::Setup(){
m_lastID = 0;
m_running = false;
m_totalSent = 0;
m_totalReceived = 0;
}
这在之前演示的每次启动服务器时都会被调用。
向客户端发送数据相当直接,正如你所看到的:
bool Server::Send(const ClientID& l_id, sf::Packet& l_packet){
sf::Lock lock(m_mutex);
auto itr = m_clients.find(l_id);
if (itr == m_clients.end()){ return false; }
if (m_outgoing.send(l_packet, itr->second.m_clientIP,
itr->second.m_clientPORT) != sf::Socket::Done)
{
std::cout << "Error sending a packet..." << std::endl;
return false;
}
m_totalSent += l_packet.getDataSize();
return true;
}
通过一点 STL 查找魔法,我们从存储它的容器中检索客户端信息,并发送数据包。
注意
注意第一行中的互斥锁。这样做是为了确保在发送操作过程中不会从容器中移除客户端。
作为一项额外的好处,跟踪发送和接收的数据量也是一件好事。在这里,我们利用sf::Packet提供的getDataSize方法来完成这项工作。
为了方便,我们还可以编写一个重载的Send方法,它不需要客户端:
bool Server::Send(sf::IpAddress& l_ip,
const PortNumber& l_port, sf::Packet& l_packet)
{
if (m_outgoing.send(l_packet, l_ip, l_port) != sf::Socket::Done)
{
return false;
}
m_totalSent += l_packet.getDataSize();
return true;
}
在许多情况下,仅向单个客户端发送数据是不够的。将数据广播给所有当前连接的客户端可以用于发送从聊天消息到实体状态的各种内容。让我们来写一下:
void Server::Broadcast(sf::Packet& l_packet,
const ClientID& l_ignore)
{
sf::Lock lock(m_mutex);
for (auto &itr : m_clients)
{
if (itr.first != l_ignore){
if (m_outgoing.send(l_packet, itr.second.m_clientIP,
itr.second.m_clientPORT) != sf::Socket::Done)
{
std::cout << "Error broadcasting a packet to client: "
<< itr.first << std::endl;
continue;
}
m_totalSent += l_packet.getDataSize();
}
}
}
再次强调,这相当基础。遍历客户端容器,并检查每个客户端的 ID 是否与l_ignore参数匹配,该参数可以用来指定不应接收广播数据包的客户端 ID。如果数据成功发送出去,其大小会被添加到已发送数据计数器中。
就像客户端一样,我们的服务器也需要一个单独的线程来处理传入的数据。让我们看看Listen方法:
void Server::Listen(){
sf::IpAddress ip;
PortNumber port;
sf::Packet packet;
while (m_running){
packet.clear();
sf::Socket::Status status =
m_incoming.receive(packet, ip, port);
if (status != sf::Socket::Done){
if (m_running){
std::cout << "Error receiving a packet from: "
<< ip << ":" << port << ". Code: " <<
status << std::endl;
continue;
} else {
std::cout << "Socket unbound." << std::endl;
break;
}
}
m_totalReceived += packet.getDataSize();
PacketID p_id;
if (!(packet >> p_id)){
continue;
} // Non-conventional packet.
PacketType id = (PacketType)p_id;
if (id<PacketType::Disconnect || id>=PacketType::OutOfBounds){
continue;
} // Invalid packet type.
if (id == PacketType::Heartbeat){
sf::Lock lock(m_mutex);
for (auto &itr : m_clients){
if (itr.second.m_clientIP != ip ||
itr.second.m_clientPORT != port)
{
continue;
}
if (!itr.second.m_heartbeatWaiting){
std::cout << "Invalid heartbeat packet received!"
<< std::endl;
break;
}
itr.second.m_ping = m_serverTime.asMilliseconds() -
itr.second.m_heartbeatSent.asMilliseconds();
itr.second.m_lastHeartbeat = m_serverTime;
itr.second.m_heartbeatWaiting = false;
itr.second.m_heartbeatRetry = 0;
break;
}
} else if (m_packetHandler){
m_packetHandler(ip, port, (PacketID)id, packet, this);
}
}
}
如您所见,这与客户端监听器实现的方式非常相似。在设置了一些用于捕获传入数据的局部变量之后,我们进入一个while循环,在这个循环中,数据包被清除,并调用我们传入套接字的阻塞接收方法,同时捕获状态。就像之前一样,如果服务器不再运行,并且receive方法的返回状态不是sf::Socket::Done,我们就从循环中跳出。
在所有数据包 ID 检查之后,我们到达代码的心跳部分。设置了一个标志来指示是否找到了发送心跳的客户端。
注意
注意,我们在这里锁定互斥锁,因为我们即将开始遍历客户端列表以找到发送心跳响应的那个客户端。
如果找到了匹配信息的客户端,我们还会检查服务器是否正在等待从他们那里收到心跳响应。我们只希望客户端能够发送心跳响应,以便准确测量延迟并防止潜在的作弊尝试。
由于这是一个有效的心跳响应,通过从当前服务器时间减去发送到特定客户端的心跳时间来计算延迟。当前的时间戳也存储在m_lastHeartbeat中,我们将使用它来确定何时发送下一个心跳。之后,将心跳等待标志设置为false,并将重试计数器重置为 0。
接下来,让我们实现添加客户端:
ClientID Server::AddClient(const sf::IpAddress& l_ip,
const PortNumber& l_port)
{
sf::Lock lock(m_mutex);
for (auto &itr : m_clients){
if (itr.second.m_clientIP == l_ip &&
itr.second.m_clientPORT == l_port)
{
return ClientID(Network::NullID);
}
}
ClientID id = m_lastID;
ClientInfo info(l_ip, l_port, m_serverTime);
m_clients.insert(std::make_pair(id, info));
++m_lastID;
return id;
}
再次强调,由于我们正在修改客户端数据,我们想要锁定互斥锁以确保没有其他在另一个线程中运行的代码尝试读取或修改数据。随后,我们在客户端容器中进行快速搜索,如果指定的 IP 和端口号组合已经存在,则返回-1。否则,分配一个新的客户端 ID,并将客户端信息插入到容器中,然后对m_lastID进行增量操作。
有时,我们可能需要通过提供 IP 地址和端口号来获取客户端的 ID。让我们编写一种方法来实现这一点:
ClientID Server::GetClientID(const sf::IpAddress& l_ip,
const PortNumber& l_port)
{
sf::Lock lock(m_mutex);
for (auto &itr : m_clients){
if (itr.second.m_clientIP == l_ip &&
itr.second.m_clientPORT == l_port)
{
return itr.first;
}
}
return ClientID(Network::NullID);
}
和往常一样,它只是遍历每个客户端并检查它们的信息是否与提供的参数匹配。这又是一个需要锁定互斥锁以安全访问这些数据的例子。
接下来,我们需要一些设置器和获取器:
bool Server::HasClient(const ClientID& l_id){
return (m_clients.find(l_id) != m_clients.end());
}
bool Server::HasClient(const sf::IpAddress& l_ip,
const PortNumber& l_port)
{
return(GetClientID(l_ip, l_port) >= 0);
}
bool Server::IsRunning(){ return m_running; }
sf::Mutex& Server::GetMutex(){ return m_mutex; }
从客户端 ID 获取客户信息的方法也是必要的:
bool Server::GetClientInfo(const ClientID& l_id,
ClientInfo& l_info)
{
sf::Lock lock(m_mutex);
for (auto &itr : m_clients){
if (itr.first == l_id){
l_info = itr.second;
return true;
}
}
return false;
}
在这种情况下,提供的ClientInfo结构引用被简单地覆盖为找到的信息。这可以通过ClientInfo提供的重载赋值运算符在单行代码中完成。再次强调,我们锁定互斥锁,因为我们正在访问可能在搜索过程中被删除或覆盖的数据。
当不再需要客户端时,它必须被移除。为了方便,我们提供了两种相同方法的变体:
bool Server::RemoveClient(const ClientID& l_id){
sf::Lock lock(m_mutex);
auto itr = m_clients.find(l_id);
if (itr == m_clients.end()){ return false; }
sf::Packet p;
StampPacket(PacketType::Disconnect, p);
Send(l_id, p);
m_clients.erase(itr);
return true;
}
第一种方法简单地通过使用存储它的容器中的find方法来定位客户端信息。如果找到了一个,则在它被删除之前,会创建一个断开连接的数据包并发送给客户端。第二种变化在于其搜索方法,但执行的是相同的基本想法:
bool Server::RemoveClient(const sf::IpAddress& l_ip,
const PortNumber& l_port)
{
sf::Lock lock(m_mutex);
for (auto itr = m_clients.begin();
itr != m_clients.end(); ++itr)
{
if (itr->second.m_clientIP == l_ip &&
itr->second.m_clientPORT == l_port)
{
sf::Packet p;
StampPacket(PacketType::Disconnect , p);
Send(itr->first, p);
m_clients.erase(itr);
return true;
}
}
return false;
}
再次,由于数据正在读取和修改,所以在这两个地方都锁定了互斥锁。说到移除客户端,我们是否可以有一个一次性将所有客户端踢出的方法?
void Server::DisconnectAll(){
if (!m_running){ return; }
sf::Packet p;
StampPacket(PacketType::Disconnect, p);
Broadcast(p);
sf::Lock lock(m_mutex);
m_clients.clear();
}
这段代码相当简单。如果服务器正在运行,就会创建一个断开连接的数据包,就像之前一样,只不过它是广播给所有客户端而不是一个。然后锁定互斥锁,在客户端容器被完全清除之前。
最后但绝对不是最不重要的,这里是有更新方法:
void Server::Update(const sf::Time& l_time){
m_serverTime += l_time;
if (m_serverTime.asMilliseconds() < 0){
m_serverTime -= sf::milliseconds(HIGHEST_TIMESTAMP);
sf::Lock lock(m_mutex);
for (auto &itr : m_clients)
{
Itr.second.m_lastHeartbeat =
sf::milliseconds(std::abs(
itr.second.m_lastHeartbeat.asMilliseconds() -
HIGHEST_TIMESTAMP));
}
}
sf::Lock lock(m_mutex);
for (auto itr = m_clients.begin(); itr != m_clients.end();){
sf::Int32 elapsed =
m_serverTime.asMilliseconds() -
itr->second.m_lastHeartbeat.asMilliseconds();
if (elapsed >= HEARTBEAT_INTERVAL){
if (elapsed >= CLIENT_TIMEOUT
|| itr->second.m_heartbeatRetry > HEARTBEAT_RETRIES)
{
// Remove client.
std::cout << "Client " <<
itr->first << " has timed out." << std::endl;
if (m_timeoutHandler){ m_timeoutHandler(itr->first); }
itr = m_clients.erase(itr);
continue;
}
if (!itr->second.m_heartbeatWaiting || (elapsed >=
HEARTBEAT_INTERVAL * (itr->second.m_heartbeatRetry + 1)))
{
// Heartbeat
if (itr->second.m_heartbeatRetry >= 3){
std::cout << "Re-try(" << itr->second.m_heartbeatRetry
<< ") heartbeat for client "
<< itr->first << std::endl;
}
sf::Packet Heartbeat;
StampPacket(PACKET_HEARTBEAT, Heartbeat);
Heartbeat << m_serverTime.asMilliseconds();
Send(itr->first, Heartbeat);
if (itr->second.m_heartbeatRetry == 0){
itr->second.m_heartbeatSent = m_serverTime;
}
itr->second.m_heartbeatWaiting = true;
++itr->second.m_heartbeatRetry;
m_totalSent += Heartbeat.getDataSize();
}
}
++itr;
}
}
与客户端类似,服务器也必须担心时间戳超出范围的问题。然而,与客户端不同的是,我们需要重置服务器拥有的每个客户端的心跳,因此需要互斥锁。说到这里,我们还需要在所有更新代码之前锁定互斥锁,因为它可以像任何其他在不同线程中运行的代码片段一样修改任何客户端。
在互斥锁之后,我们开始遍历客户端并测量现在和上次心跳之间经过的时间。如果这个时间超过了我们想要发送心跳的间隔,我们首先检查它是否也超过了超时间隔,或者心跳重试次数是否超过了指定的值。如果是这样,就会调用超时处理程序,并将客户端从容器中删除。
心跳代码本身相当简单。如果服务器没有等待来自客户端的回复,或者如果到了重试发送另一个心跳的时间,就会构建一个数据包,将服务器时间附加到它上面,然后发送出去。如果是第一次尝试发送它,服务器时间也会存储在客户端条目的m_heartbeatSent数据成员中。
一个简单的聊天应用程序
我们已经有了处理连接的基本框架,所以让我们用它来构建一些东西!比如一个整洁的基于控制台的聊天程序?让我们从创建一个名为Server_Main.cpp的新文件开始,并创建一个单独的项目。我们首先需要的是一个数据包处理器:
void Handler(sf::IpAddress& l_ip, const PortNumber& l_port,
const PacketID& l_id, sf::Packet& l_packet, Server* l_server)
{
ClientID id = l_server->GetClientID(l_ip, l_port);
if (id >= 0){
if ((PacketType)l_id == PacketType::Disconnect){
l_server->RemoveClient(l_ip, l_port);
sf::Packet p;
StampPacket(PacketType::Message, p);
std::string message;
message = "Client left! " + l_ip.toString() +
":" + std::to_string(l_port);
p << message;
l_server->Broadcast(p, id);
} else if ((PacketType)l_id == PacketType::Message){
std::string receivedMessage;
l_packet >> receivedMessage;
std::string message = l_ip.toString() + ":" +
std::to_string(l_port) + " :" + receivedMessage;
sf::Packet p;
StampPacket(PacketType::Message, p);
p << message;
l_server->Broadcast(p, id);
}
} else {
if ((PacketType)l_id == PacketType::Connect){
ClientID id = l_server->AddClient(l_ip, l_port);
sf::Packet packet;
StampPacket(PacketType::Connect, packet);
l_server->Send(id, packet);
}
}
}
由于我们打算向Server类提供这个函数的指针,因此指纹必须完全匹配。函数本身首先确定提供的 IP 地址和端口号的客户端 ID 是否存在。如果它确实大于或等于零,我们只对两种类型的数据包感兴趣:Disconnect和Message。
在客户端断开连接的情况下,我们创建一个广播给所有客户端(不包括断开连接的那个)的消息数据包。另一方面,如果收到来自某个客户端的消息,它首先被提取并附加到一个包含客户端 IP 地址和端口号的字符串上。这次我们不会使用昵称。然后将完整的消息字符串附加到消息数据包上,并广播给每个客户端,除了最初发送消息的那个客户端。
然而,如果找不到客户端,我们唯一关心的是接收连接数据包。当收到一个时,IP 地址和端口号被添加,并发送一个连接数据包回客户端。
如果服务器没有处理命令的能力,那会是什么样的服务器?让我们编写一个将在单独的线程中运行的函数,用于处理用户输入:
void CommandProcess(Server* l_server){
while (l_server->IsRunning()){
std::string str;
std::getline(std::cin, str);
if (str == "!quit"){
l_server->Stop();
break;
} else if (str == "dc"){
l_server->DisconnectAll();
std::cout << "DC..." << std::endl;
} else if (str == "list"){
std::cout << l_server->GetClientCount()
<< " clients online:" << std::endl;
std::cout << l_server->GetClientList() << std::endl;
}
}
}
注意,std::getline 是一个阻塞函数。如果程序停止运行,运行此函数的线程将仍然阻塞,直到有用户输入。让它终止的一种方法是实现一个停止服务器的命令,这就是 "!quit" 所做的。一旦调用服务器的 Stop 方法,它也会打破循环以确保安全。
另外两个命令相当标准。一个简单地断开所有用户,而另一个打印出所有已连接客户端的列表。我们没有涵盖 GetClientCount 或 GetClientList,因为它们相当基础,并且对于服务器运行不是必需的。您可以在本书的源代码中找到这两个方法的实现。
现在是组装和运行我们代码的时候了:
int main(){
Server server(Handler);
if (server.Start()){
sf::Thread c(&CommandProcess, &server);
c.launch();
sf::Clock clock;
clock.restart();
while (server.IsRunning()){
server.Update(clock.restart());
}
std::cout << "Stopping server..." << std::endl;
}
system("PAUSE");
return 0;
}
这是对此类应用程序的入口点的相当基本的设置。首先,我们创建 Server 类的一个实例,并在其构造函数中提供一个将处理数据包的函数指针。然后我们尝试启动服务器,并在 if 语句中捕获其返回值。如果成功启动,就设置并启动一个命令线程。在进入主循环之前,创建并重新启动 sf::Clock 实例,主循环简单地执行,只要服务器在运行,就更新它之间的迭代时间值。这就是我们需要的所有内容,一个聊天服务器!
聊天客户端
如果我们没有连接到服务器并来回发送消息的手段,我们的服务器就毫无用处。在另一个项目中,让我们创建一个名为 Client_Main.cpp 的文件,并开始编写客户端部分的代码,从数据包处理器开始:
void HandlePacket(const PacketID& l_id,
sf::Packet& l_packet, Client* l_client)
{
if ((PacketType)l_id == PacketType::Message){
std::string message;
l_packet >> message;
std::cout << message << std::endl;
} else if ((PacketType)l_id == PacketType::Disconnect){
l_client->Disconnect();
}
}
如您所见,当我们有一个合适的支持类可以依赖时,这实际上是一个非常简单的设计。客户端响应两种类型的数据包:消息和断开连接。如果消息突然出现,它会被提取并在控制台窗口中简单地打印出来。如果从服务器收到断开连接的数据包,客户端的 Disconnect 方法将被调用。
接下来,将在命令线程中运行的函数:
void CommandProcess(Client* l_client){
while (l_client->IsConnected()){
std::string str;
std::getline(std::cin, str);
if (str != ""){
if (str == "!quit"){
l_client->Disconnect();
break;
}
sf::Packet p;
StampPacket(PacketType::Message, p);
p << str;
l_client->Send(p);
}
}
}
我们使用与std::getline函数捕获控制台输入相同的基本原理,但在这个案例中,我们只处理退出命令。输入的其他任何内容都被视为消息并发送到服务器。请注意,由于std::getline函数是一个阻塞函数,如果客户端被服务器断开连接,用户将需要按一次回车键,以便提供一些输入并使线程关闭,从而使事情再次运转起来。
最后,让我们将所有这些代码付诸实践,并实现聊天客户端的主循环:
void main(int argc, char** argv){
sf::IpAddress ip;
PortNumber port;
if (argc == 1){
std::cout << "Enter Server IP: ";
std::cin >> ip;
std::cout << "Enter Server Port: ";
std::cin >> port;
} else if (argc == 3){
ip = argv[1];
port = atoi(argv[2]);
} else {
return;
}
Client client;
client.SetServerInformation(ip, port);
client.Setup(&HandlePacket);
sf::Thread c(&CommandProcess, &client);
if (client.Connect()){
c.launch();
sf::Clock clock;
clock.restart();
while (client.IsConnected()){
client.Update(clock.restart());
}
} else {
std::cout << "Failed to connect." << std::endl;
}
std::cout << "Quitting..." << std::endl;
sf::sleep(sf::seconds(1.f));
}
我们首先设置几个变量来保存服务器的 IP 地址和端口号。作为额外加分项,让我们添加对命令行参数的支持。如果没有提供任何参数,用户将被提示在控制台窗口中输入服务器信息。否则,将读取命令行参数并用于相同的目的。
进一步来说,我们看到创建了一个Client实例,并使用提供的服务器信息进行了设置,同时注册了数据包处理函数,并准备了一个命令线程。然后客户端尝试连接到服务器,如果连接成功,将启动命令线程,创建一个sf::Clock实例,并进入程序的主循环,其中客户端会得到更新。
有了这个,我们就拥有了一个相当简单但功能齐全的聊天应用程序:

摘要
恭喜你走到了这一步!在涵盖了最重要的基础知识之后,包括套接字编程的基础、线程的使用以及构建客户端-服务器通信的底层层,我们终于准备好处理实际的网络游戏了!在本书的最后一章中,我们将把这段网络代码集成到现有的代码库中,将孤独的单人 RPG 游戏转变为激动人心的玩家对战竞技场!那里见!
第十四章.一起来玩吧! – 多玩家细微差别
这个世界上有很多伟大的事物有着极其谦逊的起点。这本书从封面到封底,讲述了一个从兴趣和创造意志开始的旅程。现在我们已经到达故事的顶点,为什么不来个高潮呢?让我们将我们开发的框架与网络功能结合起来,为这本书的第三个项目带来新的视角!让我们通过游戏玩法将我们的玩家连接起来,而不仅仅是通过简单的信息交换。
在本章中,我们将涵盖:
-
构建支持先前实现机制的游戏服务器
-
在网络上交换实体数据
-
将现有游戏代码转换为客户端应用程序
-
实现玩家对战玩家战斗
-
通过平滑实体移动来隐藏网络延迟
有很多代码需要覆盖,让我们开始吧!
版权资源的利用
和往常一样,我们应该感谢那些为我们最终项目制作了惊人的图形和音效的艺术家:
-
由C.Nilsson创作的简单的像素心形,根据 CC-BY-SA 3.0 许可:
opengameart.org/content/simple-small-pixel-hearts -
由n3b创作的Grunt,根据 CC-BY 3.0 许可:
opengameart.org/content/grunt -
由artisticdude创作的Swishes 声音包,根据 CC0 许可(公共领域):
opengameart.org/content/swishes-sound-pack -
由Michel Baradari创作的3 个物品声音,根据 CC-BY 3.0 许可:
opengameart.org/content/3-item-sounds
共享代码
由于我们编写的代码将存在于客户端和服务器端,让我们首先讨论这一点,从双方数据交换的方式开始。
我们信息交换最重要的部分是更新所有连接客户端上的实体。我们通过发送包含相关实体信息的专业结构来做到这一点。从现在开始,这些结构将被称为快照。让我们看看它们是如何实现的,通过查看EntitySnapshot.h文件:
struct EntitySnapshot{
std::string m_type;
sf::Vector2f m_position;
sf::Int32 m_elevation;
sf::Vector2f m_velocity;
sf::Vector2f m_acceleration;
sf::Uint8 m_direction;
sf::Uint8 m_state;
sf::Uint8 m_health;
std::string m_name;
};
我们将不断更新任何给定实体的信息,包括其实体位置和海拔,速度,加速度,其面向的方向以及其实体的状态,以及实体的健康和名称。实体的类型也会在快照中发送,并在客户端创建实体时使用。
提示
在这个例子中,EntitySnapshot结构中数据成员的顺序可能不是最有效的。从大到小对结构中的数据进行排序可以帮助减小它们的大小,从而减少带宽开销。结构对齐和打包在这里不会讨论,但它是一个值得研究的主题。
在使我们的代码更易读方面,重载sf::Packet的位运算符以支持自定义数据类型,如EntitySnapshot,非常有帮助:
sf::Packet& operator <<(sf::Packet& l_packet,
const EntitySnapshot& l_snapshot);
sf::Packet& operator >>(sf::Packet& l_packet,
EntitySnapshot& l_snapshot);
这些重载的实际实现位于EntitySnapshot.cpp文件中:
sf::Packet& operator <<(sf::Packet& l_packet,
const EntitySnapshot& l_snapshot)
{
return l_packet << l_snapshot.m_type << l_snapshot.m_name
<< l_snapshot.m_position.x << l_snapshot.m_position.y
<< l_snapshot.m_elevation << l_snapshot.m_velocity.x
<< l_snapshot.m_velocity.y << l_snapshot.m_acceleration.x
<< l_snapshot.m_acceleration.y << l_snapshot.m_direction
<< l_snapshot.m_state << l_snapshot.m_health;
}
sf::Packet& operator >>(sf::Packet& l_packet,
EntitySnapshot& l_snapshot)
{
return l_packet >> l_snapshot.m_type >> l_snapshot.m_name
>> l_snapshot.m_position.x >> l_snapshot.m_position.y
>> l_snapshot.m_elevation >> l_snapshot.m_velocity.x
>> l_snapshot.m_velocity.y >> l_snapshot.m_acceleration.x
>> l_snapshot.m_acceleration.y >> l_snapshot.m_direction
>> l_snapshot.m_state >> l_snapshot.m_health;
}
其他数据交换将更具体地针对特定情况,所以我们将在稍后讨论它们。然而,现在我们可以做的一件事是,更新NetworkDefinitions.h文件中的Network枚举,添加一个新值,该值将用作特定数据包中不同类型数据之间的分隔符:
enum class Network{
HighestTimestamp = 2147483647, ClientTimeout = 10000,
ServerPort = 5600, NullID = -1, PlayerUpdateDelim = -1
};
由于我们将在客户端和服务器上使用使用此分隔符的特定数据包类型,所以它的位置在共享代码空间中。
额外组件
首先且最重要的是,需要在服务器和客户端之间同步的实体需要被标记并分配一个唯一的标识符。这就是C_Client组件发挥作用的地方:
class C_Client : public C_Base{
public:
C_Client(): C_Base(Component::Client),
m_clientID((ClientID)Network::NullID){}
void ReadIn(std::stringstream& l_stream){}
ClientID GetClientID()const{ return m_clientID; }
void SetClientID(const ClientID& l_id){ m_clientID = l_id; }
private:
ClientID m_clientID;
};
支持实体名称也会很方便,以便能够存储玩家昵称。这可以通过实现一个名称组件来完成:
class C_Name : public C_Base{
public:
C_Name() : C_Base(Component::Name){}
void ReadIn(std::stringstream& l_stream){ l_stream >> m_name; }
const std::string& GetName()const{ return m_name; }
void SetName(const std::string& l_name){ m_name = l_name; }
private:
std::string m_name;
};
在游戏中,有一个小冷却期,在这个期间实体不能被攻击,可能还会定义其受伤/死亡动画应该持续多长时间。允许并定义此类功能的组件理想情况下需要从基类继承,这样可以简化此类事件的时间处理过程:
class C_TimedComponentBase : public C_Base{
public:
C_TimedComponentBase(const Component& l_type)
: C_Base(l_type), m_duration(sf::milliseconds(0)){}
virtual ~C_TimedComponentBase(){}
const sf::Time& GetTimer()const{ return m_duration; }
void SetTimer(const sf::Time& l_time){ m_duration = l_time; }
void AddToTimer(const sf::Time& l_time){ m_duration += l_time; }
void Reset(){ m_duration = sf::milliseconds(0); }
protected:
sf::Time m_duration;
};
将使用基定时类的C_Health组件:
using Health = unsigned int;
class C_Health : public C_TimedComponentBase{
public:
C_Health(): C_TimedComponentBase(Component::Health),
m_hurtDuration(0), m_deathDuration(0){}
void ReadIn(std::stringstream& l_stream){
l_stream >> m_maxHealth >> m_hurtDuration >> m_deathDuration;
m_health = m_maxHealth;
}
Health GetHealth()const{ return m_health; }
Health GetMaxHealth()const{ return m_maxHealth; }
void SetHealth(const Health& l_health){ m_health = l_health; }
void ResetHealth(){ m_health = m_maxHealth; }
sf::Uint32 GetHurtDuration(){ return m_hurtDuration; }
sf::Uint32 GetDeathDuration(){ return m_deathDuration; }
private:
Health m_health;
Health m_maxHealth;
sf::Uint32 m_hurtDuration;
sf::Uint32 m_deathDuration;
};
如您所见,它包含当前实体的健康值、其最大健康值以及一些数据成员,这些成员持有预期受伤和死亡的时间长度。
自然地,我们需要更多的实体消息和事件类型来表示战斗过程。以下代码片段中突出显示了新添加的类型:
enum class EntityMessage{
Move, Is_Moving, Frame_Change, State_Changed,
Direction_Changed, Switch_State, Attack,
Being_Attacked, Hurt, Die, Respawn, Removed_Entity
};
enum class EntityEvent{
Spawned, Despawned, Colliding_X, Colliding_Y,
Moving_Left, Moving_Right, Moving_Up, Moving_Down,
Elevation_Change, Became_Idle, Began_Moving, Began_Attacking
};
EntityManager类也将被双方共享。为了让实体组件系统知道何时添加或删除实体,需要对它的AddEntity和RemoveEntity方法进行一些调整:
int EntityManager::AddEntity(const Bitmask& l_mask, int l_id){
...
m_systems->EntityModified(entity,l_mask);
m_systems->AddEvent(entity, (EventID)EntityEvent::Spawned);
return entity;
}
bool EntityManager::RemoveEntity(const EntityId& l_id){
...
Message msg((MessageType)EntityMessage::Removed_Entity);
msg.m_receiver = l_id;
msg.m_int = l_id;
m_systems->GetMessageHandler()->Dispatch(msg);
... // Removing all components.
}
我们在前几章中编写的很多代码实际上也需要共享。例如,一些类,如实体管理器,已经稍作修改,作为客户端和服务器实现派生类的父类。我们在这里不会详细讨论这个问题,因为本章的代码文件应该足以帮助你熟悉代码结构。
构建我们的游戏服务器
在第十三章《我们有了联系!——网络基础》中,我们查看了一个由服务器应用程序支持并由多个客户端连接的基本聊天服务。构建游戏服务器与此非常相似。我们有一块软件,它通过执行所有计算并将结果发送回客户端,作为所有客户端的中心兴趣点,以确保整个系统的正确和一致的模拟。自然地,由于我们不仅仅是交换文本消息,因此将会有更多的数据在客户端和服务器之间来回发送,以及服务器端更多的计算。
首先,我们需要决定发送实体快照的时间间隔值。它必须足够频繁以保持平滑更新,但发送尽可能少的信息以保持效率。经过一些测试和调整,可以很容易地找到一个最佳点。对于这个特定的项目,让我们假设实体快照将每 100 毫秒发送一次,这将在NetSettings.h中定义:
#define SNAPSHOT_INTERVAL 100
每秒发送 10 个快照就足以让客户满意,同时服务器也能保持相对较低的带宽。
实体组件系统的补充
大多数战斗逻辑将在服务器端进行。为了支持实体相互攻击,我们需要一个新的组件与C_Attacker一起工作:
class C_Attacker : public C_TimedComponentBase{
public:
C_Attacker(): C_TimedComponentBase(Component::Attacker),
m_attacked(false), m_knockback(0.f), m_attackDuration(0){}
void ReadIn(std::stringstream& l_stream){
l_stream >> m_offset.x >> m_offset.y
>> m_attackArea.width >> m_attackArea.height
>> m_knockback >> m_attackDuration;
}
void SetAreaPosition(const sf::Vector2f& l_pos){
m_attackArea.left = l_pos.x;
m_attackArea.top = l_pos.y;
}
const sf::FloatRect& GetAreaOfAttack(){ return m_attackArea; }
const sf::Vector2f& GetOffset(){ return m_offset; }
bool HasAttacked(){ return m_attacked; }
void SetAttacked(bool l_attacked){ m_attacked = l_attacked; }
float GetKnockback(){ return m_knockback; }
sf::Uint32 GetAttackDuration(){ return m_attackDuration; }
private:
sf::FloatRect m_attackArea;
sf::Vector2f m_offset;
bool m_attacked;
float m_knockback;
sf::Uint32 m_attackDuration;
};
攻击组件包含有关实体攻击区域的大小和位置以及可能的偏移量的信息,一个标志用于检查实体在攻击时是否击中了某个东西,对另一个被攻击实体的击退力,以及攻击的持续时间。
实现战斗
实体间的战斗将是一个相当简单的补充,因为我们已经有一个很好的碰撞系统。它只需要在EntityCollisions方法内部添加几行代码:
void S_Collision::EntityCollisions(){
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto itr = m_entities.begin();
itr != m_entities.end(); ++itr)
{
for (auto itr2 = std::next(itr);
itr2 != m_entities.end(); ++itr2)
{
...
C_Attacker* attacker1 = entities->
GetComponent<C_Attacker>(*itr, Component::Attacker);
C_Attacker* attacker2 = entities->
GetComponent<C_Attacker>(*itr2, Component::Attacker);
if (!attacker1 && !attacker2){ continue; }
Message msg((MessageType)EntityMessage::Being_Attacked);
if (attacker1){
if (attacker1->GetAreaOfAttack().intersects(
collidable2->GetCollidable()))
{
// Attacker-on-entity collision!
msg.m_receiver = *itr2;
msg.m_sender = *itr;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
if (attacker2){
if (attacker2->GetAreaOfAttack().intersects(
collidable1->GetCollidable()))
{
// Attacker-on-entity collision!
msg.m_receiver = *itr;
msg.m_sender = *itr2;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
}
}
}
首先,被检查的两个实体都会获取其攻击组件。如果它们中没有一个有,则跳过迭代。否则,会构建一个新的类型为Being_Attacked的消息。如果攻击实体的攻击区域实际上与另一个实体的边界框相交,则该消息会填充接收者和发送者信息并发送出去。
为了正确处理和反应这些碰撞,以及更新所有可能处于战斗状态的实体,我们需要一个新的系统:S_Combat!它除了需要实现基本系统类所需的方法外,没有其他额外的方法,所以我们实际上没有必要检查其头文件。让我们看看它的构造函数和析构函数:
S_Combat::S_Combat(SystemManager* l_systemMgr)
: S_Base(System::Combat, l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::Movable);
req.TurnOnBit((unsigned int)Component::State);
req.TurnOnBit((unsigned int)Component::Health);
m_requiredComponents.push_back(req);
req.ClearBit((unsigned int)Component::Health);
req.TurnOnBit((unsigned int)Component::Attacker);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Being_Attacked, this);
}
该系统将保留任何具有位置、是具有状态的移动实体,并且具有健康组件或攻击组件或两者都有的实体。它还订阅了Being_Attacked消息,以便处理攻击区域碰撞。
自然地,不能为实体面向的四个方向中的任何一个以相同的方式定位相同的攻击区域。考虑以下示例:

根据每个实体的当前方向重新定位攻击区域是在该系统的Update方法中完成的:
void S_Combat::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto &entity : m_entities){
C_Attacker* attack = entities->
GetComponent<C_Attacker>(entity, Component::Attacker);
if (!attack){ continue; }
sf::Vector2f offset = attack->GetOffset();
sf::FloatRect AoA = attack->GetAreaOfAttack();
Direction dir = entities->GetComponent<C_Movable>
(entity, Component::Movable)->GetDirection();
sf::Vector2f position = entities->GetComponent<C_Position>
(entity, Component::Position)->GetPosition();
if (dir == Direction::Left){ offset.x -= AoA.width / 2; }
else if (dir == Direction::Right){offset.x += AoA.width / 2; }
else if (dir == Direction::Up){offset.y -= AoA.height / 2; }
else if (dir == Direction::Down){offset.y += AoA.height / 2; }
position -= sf::Vector2f(AoA.width / 2, AoA.height / 2);
attack->SetAreaPosition(position + offset);
}
}
如果当前正在检查的实体没有C_Attacker组件,则简单地跳过迭代。否则,除了当前方向和位置外,还获取实体的攻击区域和偏移量。为了首先使攻击区域居中,从实体的位置中减去其宽度和高度的一半。然后根据实体面向的方向调整偏移量,并将攻击区域移动到应用偏移量的最新位置。
让我们看看对碰撞系统发出的消息的可能响应:
void S_Combat::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver) ||
!HasEntity(l_message.m_sender))
{
return;
}
EntityManager* entities = m_systemManager->GetEntityManager();
EntityMessage m = (EntityMessage)l_message.m_type;
switch (m){
case EntityMessage::Being_Attacked:
C_Health* victim = entities->GetComponent<C_Health>
(l_message.m_receiver, Component::Health);
C_Attacker* attacker = entities->GetComponent<C_Attacker>
(l_message.m_sender, Component::Attacker);
if (!victim || !attacker){ return; }
S_State* StateSystem = m_systemManager->
GetSystem<S_State>(System::State);
if (StateSystem->GetState(l_message.m_sender) !=
EntityState::Attacking)
{
return;
}
if (attacker->HasAttacked()){ return; }
// Begin attacking.
victim->SetHealth((victim->GetHealth() > 1 ?
victim->GetHealth() - 1 : 0));
attacker->SetAttacked(true);
if (!victim->GetHealth()){
StateSystem->ChangeState(l_message.m_receiver,
EntityState::Dying, true);
} else {
Message msg((MessageType)EntityMessage::Hurt);
msg.m_receiver = l_message.m_receiver;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
// Knockback.
Direction attackerDirection =entities->GetComponent<C_Movable>
(l_message.m_sender, Component::Movable)->GetDirection();
float Knockback = attacker->GetKnockback();
sf::Vector2f KnockbackVelocity;
if (attackerDirection == Direction::Left ||
attackerDirection == Direction::Up)
{
Knockback = -Knockback;
}
if (attackerDirection == Direction::Left ||
attackerDirection == Direction::Right)
{
KnockbackVelocity.x = Knockback;
}
else{ KnockbackVelocity.y = Knockback; }
entities->GetComponent<C_Movable>
(l_message.m_receiver, Component::Movable)->
SetVelocity(KnockbackVelocity);
break;
}
}
首先,我们检查战斗系统是否具有此消息的发送者和接收者。如果是这样,首先获取被攻击实体的健康组件以及攻击者的攻击组件。然后检查攻击实体的状态。如果它目前没有攻击或实体已经攻击了其他东西,方法通过返回来终止。否则,通过首先减少受害者的健康值1来开始攻击。然后标记攻击者已经攻击了一个实体。如果受害者的健康值为 0,其状态将更改为Dying。否则,发送一个Hurt消息。
剩下的几行代码处理被攻击实体被轻微击退的情况。同时获取攻击者的方向和击退力,并创建一个表示施加力的sf::Vector2f变量。如果攻击者面向左或上,击退值将被反转。此外,如果攻击实体面向左或右,击退力将应用于X轴。否则,使用Y轴。然后,通过受害者的C_Movable组件将力简单地应用于速度。
服务器动作时间
在服务器上运行相同的代码与在客户端运行之间的一大区别是某些动作和事件的计时方式。由于我们没有动画发生,因此无法简单地检查何时达到最后一帧并终止攻击或死亡,例如。这就是手动设置某些时间值的作用。为此,我们需要S_Timers系统。由于它也没有任何除必需方法之外的其他方法,因此不需要类定义。
让我们先看看这个系统的构造函数和析构函数:
S_Timers::S_Timers(SystemManager* l_systemMgr)
: S_Base(System::Timers, l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::State);
req.TurnOnBit((unsigned int)Component::Attacker);
m_requiredComponents.push_back(req);
req.ClearBit((unsigned int)Component::Attacker);
req.TurnOnBit((unsigned int)Component::Health);
m_requiredComponents.push_back(req);
}
再次,我们只是订阅了状态组件以及攻击者组件、健康组件或两者。这里没有发生什么有趣的事情,所以让我们继续到Update方法,它使得服务器端的计时成为可能:
void S_Timers::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto &entity : m_entities){
EntityState state = entities->GetComponent<C_State>
(entity, Component::State)->GetState();
if (state == EntityState::Attacking){
C_Attacker* attack = entities->GetComponent<C_Attacker>
(entity, Component::Attacker);
attack->AddToTimer(sf::seconds(l_dT));
if (attack->GetTimer().asMilliseconds() <
attack->GetAttackDuration())
{
continue;
}
attack->Reset();
attack->SetAttacked(false);
} else if (state == EntityState::Hurt ||
state == EntityState::Dying)
{
C_Health* health = entities->
GetComponent<C_Health>(entity, Component::Health);
health->AddToTimer(sf::seconds(l_dT));
if ((state == EntityState::Hurt &&
health->GetTimer().asMilliseconds() <
health->GetHurtDuration()) ||
(state == EntityState::Dying &&
health->GetTimer().asMilliseconds() <
health->GetDeathDuration()))
{
continue;
}
health->Reset();
if (state == EntityState::Dying){
Message msg((MessageType)EntityMessage::Respawn);
msg.m_receiver = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
health->ResetHealth();
}
} else { continue; }
m_systemManager->GetSystem<S_State>(System::State)->
ChangeState(entity, EntityState::Idle, true);
}
}
在这个系统中,攻击者和健康组件都会被检查,看它们是否达到了实体文件中提供的特定时间值。如果实体处于攻击状态,则获取攻击者组件并将经过的时间添加到其中。如果攻击持续时间已过,计时器将被重置,并将“攻击”标志设置回 false,从而允许进行另一次攻击。
如果实体处于受伤或死亡状态,将检查相应的计时器值是否与预定的持续时间相符,并在必要时重置计时器。如果实体实际上处于死亡状态,还会发送一个Respawn消息,以便重置其动画、生命值并将实体移动到特定的复活位置。
服务器网络系统
通过简单地构建一个专门系统,该系统设计上就已经可以访问实体数据,可以大大简化服务器端实体网络的处理。这就是服务器网络系统的作用所在。
让我们从玩家如何控制实体开始。在前面的章节中,我们只是简单地使用消息在客户端移动实体。显然,由于网络延迟和带宽限制,每当客户端移动时发送大量消息将会很成问题。简单地保持跟踪玩家的输入状态会更有效率,如下面的简单结构所示:
struct PlayerInput{
int m_movedX;
int m_movedY;
bool m_attacking;
PlayerInput() : m_movedX(0), m_movedY(0), m_attacking(false){}
};
using PlayerInputContainer = std::unordered_map<EntityId,
PlayerInput>;
前两个数据成员将包含玩家在任一轴上移动的次数。在客户端,我们将以特定间隔向服务器发送输入状态,这意味着我们可以将消息组合成整洁的数据包,并处理掉冗余的移动,例如左右移动相同的距离。此外,客户端还将发送他们的攻击状态。所有这些信息都将保存在一个容器中,并将其与特定的实体 ID 绑定。
现在,让我们看看我们将要实现的网络系统的头文件:
class S_Network : public S_Base{
public:
S_Network(SystemManager* l_systemMgr);
~S_Network();
void Update(float l_dT);
void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event);
void Notify(const Message& l_message);
bool RegisterClientID(const EntityId& l_entity,
const ClientID& l_client);
void RegisterServer(Server* l_server);
ClientID GetClientID(const EntityId& l_entity);
EntityId GetEntityID(const ClientID& l_client);
void CreateSnapshot(sf::Packet& l_packet);
void UpdatePlayer(sf::Packet& l_packet, const ClientID& l_cid);
private:
PlayerInputContainer m_playerInput;
Server* m_server;
};
如同往常,我们已经实现了所需的方法,以及一些额外的辅助方法。由于我们将在客户端和实体之间链接行为,因此有几个方法帮助我们注册和获取这种关系信息。除此之外,还有一些辅助方法用于创建实体状态的快照以及从接收到的数据包中更新特定客户端的信息。
实现网络系统
让我们从网络系统的构造函数和析构函数开始:
S_Network::S_Network(SystemManager* l_systemMgr)
: S_Base(System::Network, l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Client);
m_requiredComponents.push_back(req);
MessageHandler* messageHandler =
m_systemManager->GetMessageHandler();
messageHandler->Subscribe(EntityMessage::Removed_Entity, this);
messageHandler->Subscribe(EntityMessage::Hurt, this);
messageHandler->Subscribe(EntityMessage::Respawn, this);
}
这个特定的系统只需要一个组件:C_Client。它还订阅了实体移除、受伤和复活消息。
接下来是Update方法:
void S_Network::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto &entity : m_entities){
auto& player = m_playerInput[entity];
if (player.m_movedX || player.m_movedY){
if (player.m_movedX){
Message msg((MessageType)EntityMessage::Move);
msg.m_receiver = entity;
if (player.m_movedX > 0){msg.m_int=(int)Direction::Right;}
else { msg.m_int = (int)Direction::Left; }
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
if (player.m_movedY){
Message msg((MessageType)EntityMessage::Move);
msg.m_receiver = entity;
if (player.m_movedY > 0){msg.m_int=(int)Direction::Down;}
else { msg.m_int = (int)Direction::Up; }
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
if (player.m_attacking){
Message msg((MessageType)EntityMessage::Attack);
msg.m_receiver = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
}
这是我们处理客户端当前控制状态并将其应用于实体的地方。根据客户端输入的状态构建并发送相关消息。
接下来,让我们处理这个系统订阅的三个消息类型:
void S_Network::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver)){ return; }
EntityMessage m = EntityMessage(l_message.m_type);
if (m == EntityMessage::Removed_Entity){
m_playerInput.erase(l_message.m_receiver);
return;
}
if (m == EntityMessage::Hurt){
sf::Packet packet;
StampPacket(PacketType::Hurt, packet);
packet << l_message.m_receiver;
m_server->Broadcast(packet);
return;
}
if (m == EntityMessage::Respawn){
C_Position* position = m_systemManager->GetEntityManager()->
GetComponent<C_Position>(l_message.m_receiver,
Component::Position);
if (!position){ return; }
position->SetPosition(64.f, 64.f);
position->SetElevation(1);
}
}
首先,如果实体正在被移除,那么控制该实体的相应客户端的玩家输入信息将被删除。如果收到有关实体受伤的消息,将构建一个受伤包并发送给所有客户端,以通知它们实体正在受到伤害。最后,通过重置其位置和高度到一些预定义的值来处理实体重生消息。这些坐标可以很容易地随机化或从地图文件中读取,但为了演示目的,这样做就足够了。
当一个客户端连接到我们的服务器并为它创建一个实体时,我们需要一个方法来允许我们通过绑定这两个值来表达这种关系,如下所示:
bool S_Network::RegisterClientID(const EntityId& l_entity,
const ClientID& l_client)
{
if (!HasEntity(l_entity)){ return false; }
m_systemManager->GetEntityManager()->GetComponent<C_Client>
(l_entity, Component::Client)->SetClientID(l_client);
return true;
}
由于我们将在客户端组件中存储客户端 ID,因此通过实体管理器获取它,并按这种方式使用。
网络类也需要访问Server类的一个实例,因此有如下方法:
void S_Network::RegisterServer(Server* l_server){
m_server = l_server;
}
接下来,一些方便获取客户端和实体 ID 的方法:
ClientID S_Network::GetClientID(const EntityId& l_entity){
if (!HasEntity(l_entity)){ return (ClientID)Network::NullID; }
return m_systemManager->GetEntityManager()->
GetComponent<C_Client>(l_entity, Component::Client)->
GetClientID();
}
EntityId S_Network::GetEntityID(const ClientID& l_client){
EntityManager* e = m_systemManager->GetEntityManager();
auto entity = std::find_if(m_entities.begin(), m_entities.end(),
&e, &l_client{
return e->GetComponent<C_Client>
(id, Component::Client)->GetClientID() == l_client;
});
return(entity != m_entities.end() ?
*entity : (EntityId)Network::NullID);
}
快照的创建本身也值得有一个自己的方法:
void S_Network::CreateSnapshot(sf::Packet& l_packet){
sf::Lock lock(m_server->GetMutex());
ServerEntityManager* e =
(ServerEntityManager*)m_systemManager->GetEntityManager();
StampPacket(PacketType::Snapshot, l_packet);
l_packet << sf::Int32(e->GetEntityCount());
if (e->GetEntityCount()){
e->DumpEntityInfo(l_packet);
}
}
因为我们要访问可能会被更改的实体信息,所以在访问之前必须锁定服务器互斥锁。在将快照类型分配给作为参数提供的包之后,我们还将实体数量写入其中。如果这个数字大于零,就会调用DumpEntityInfo方法。这个方法定义在我们的ServerEntityManager类中,稍后将会介绍。
最后,让我们处理传入的玩家更新包:
void S_Network::UpdatePlayer(sf::Packet& l_packet,
const ClientID& l_cid)
{
sf::Lock lock(m_server->GetMutex());
EntityId eid = GetEntityID(l_cid);
if (eid == -1){ return; }
if (!HasEntity(eid)){ return; }
sf::Int8 entity_message;
m_playerInput[eid].m_attacking = false;
while (l_packet >> entity_message){
switch (entity_message){
case sf::Int8(EntityMessage::Move):
{
sf::Int32 x = 0, y = 0;
l_packet >> x >> y;
m_playerInput[eid].m_movedX = x;
m_playerInput[eid].m_movedY = y;
break;
}
case sf::Int8(EntityMessage::Attack):
{
sf::Int8 attackState;
l_packet >> attackState;
if (attackState){ m_playerInput[eid].m_attacking = true; }
break;
}
}
sf::Int8 delim = 0;
if (!(l_packet >> delim) || delim !=
(sf::Int8)Network::PlayerUpdateDelim)
{
std::cout << "Faulty update!" << std::endl;
break;
}
}
}
在进行任何操作之前,我们必须确保服务器互斥锁被锁定,并且发送此包的客户端附有一个有效的实体。这是通过获取实体 ID 并在接下来的两行中检查其有效性来完成的。然后创建一个名为entity_message的局部变量,以便保存客户端将要发送给我们的消息类型。然后默认将实体的攻击状态设置为false,并开始遍历包的信息。
遇到Move消息时,通过从包中提取 X 和 Y 值,并用它们覆盖给定实体的玩家移动信息来处理。Attack消息有一个更少的值需要关注。如果传入的玩家状态包含的不是零,则玩家的m_attacking标志被设置为true。
服务器实体和系统管理
服务器端支持的组件和系统显然将与客户端的不同。除此之外,两端的自定义方法通过允许基类保持不变,而派生类处理特定逻辑,大大帮助了这一点。让我们看看我们在服务器端运行的简单扩展EntityManager类:
ServerEntityManager::ServerEntityManager(SystemManager* l_sysMgr)
: EntityManager(l_sysMgr)
{
AddComponentType<C_Position>(Component::Position);
AddComponentType<C_State>(Component::State);
AddComponentType<C_Movable>(Component::Movable);
AddComponentType<C_Controller>(Component::Controller);
AddComponentType<C_Collidable>(Component::Collidable);
AddComponentType<C_Client>(Component::Client);
AddComponentType<C_Health>(Component::Health);
AddComponentType<C_Name>(Component::Name);
AddComponentType<C_Attacker>(Component::Attacker);
}
显然,我们这里不需要任何与图形或声音相关的组件类型。处理这些是客户端的工作。
这个类在创建实体快照时也非常有用。所有实体信息都通过这个方法被倒入提供的sf::Packet实例中:
void ServerEntityManager::DumpEntityInfo(sf::Packet& l_packet){
for (auto &entity : m_entities){
l_packet << sf::Int32(entity.first);
EntitySnapshot snapshot;
snapshot.m_type = entity.second.m_type;
const auto& mask = entity.second.m_bitmask;
if (mask.GetBit((unsigned int)Component::Position)){
C_Position* p = GetComponent<C_Position>(entity.first,
Component::Position);
snapshot.m_position = p->GetPosition();
snapshot.m_elevation = p->GetElevation();
}
if (mask.GetBit((unsigned int)Component::Movable)){
C_Movable* m = GetComponent<C_Movable>(entity.first,
Component::Movable);
snapshot.m_velocity = m->GetVelocity();
snapshot.m_acceleration = m->GetAcceleration();
snapshot.m_direction = sf::Uint8(m->GetDirection());
}
if (mask.GetBit((unsigned int)Component::State)){
C_State* s = GetComponent<C_State>(entity.first,
Component::State);
snapshot.m_state = sf::Uint8(s->GetState());
}
if (mask.GetBit((unsigned int)Component::Health)){
C_Health* h = GetComponent<C_Health>(entity.first,
Component::Health);
snapshot.m_health = h->GetHealth();
}
if (mask.GetBit((unsigned int)Component::Name)){
C_Name* n = GetComponent<C_Name>(entity.first,
Component::Name);
snapshot.m_name = n->GetName();
}
l_packet << snapshot;
}
}
实体 ID 首先写入数据包实例。之后创建一个EntitySnapshot变量,并填充相关的组件信息,前提是这些组件确实存在。完成这些后,快照实例被写入数据包,这得益于其重载的<<和>>运算符,变得非常简单。
对于服务器端的管理系统,我们只需要处理添加的系统:
ServerSystemManager::ServerSystemManager(){
AddSystem<S_Network>(System::Network);
AddSystem<S_State>(System::State);
AddSystem<S_Control>(System::Control);
AddSystem<S_Movement>(System::Movement);
AddSystem<S_Timers>(System::Timers);
AddSystem<S_Collision>(System::Collision);
AddSystem<S_Combat>(System::Combat);
}
与我们对组件所做的一样,我们简单地排除了所有与图形或声音相关的任何内容。
主要服务器类
与客户端的Game类类似,服务器端也需要一个管理对象。我们将保持游戏地图、实体、服务器管理器和Server类本身的实例在一个新类中,简单地称为World。让我们先看看头文件:
class World{
public:
World();
~World();
void Update(const sf::Time& l_time);
void HandlePacket(sf::IpAddress& l_ip,
const PortNumber& l_port, const PacketID& l_id,
sf::Packet& l_packet, Server* l_server);
void ClientLeave(const ClientID& l_client);
void CommandLine();
bool IsRunning();
private:
sf::Time m_tpsTime;
sf::Time m_serverTime;
sf::Time m_snapshotTimer;
sf::Thread m_commandThread;
Server m_server;
ServerSystemManager m_systems;
ServerEntityManager m_entities;
bool m_running;
Map m_map;
unsigned int m_tick;
unsigned int m_tps;
};
与Game类似,它有一个Update方法,所有与时间相关的魔法都将在这里发生。它还有处理自定义数据包类型、处理客户端离开和处理命令行输入的方法。
在数据成员方面,我们关注几个sf::Time实例,用于跟踪当前服务器时间和快照的交付时间。还有一个用于命令行的sf::Thread实例也非常方便。
最后但同样重要的是,m_tpsTime、m_tick和m_tps数据成员存在是为了简单方便地测量服务器上的更新率。每秒的更新次数,也称为 tick,对于追踪和解决性能问题非常有用。
实现世界级
让我们启动这个类,从构造函数和析构函数开始:
World::World(): m_server(&World::HandlePacket, this),
m_commandThread(&World::CommandLine, this), m_entities(nullptr),
m_map(&m_entities), m_tick(0), m_tps(0), m_running(false)
{
if (!m_server.Start()){ return; }
m_running = true;
m_systems.SetEntityManager(&m_entities);
m_entities.SetSystemManager(&m_systems);
m_map.LoadMap("media/Maps/map1.map");
m_systems.GetSystem<S_Collision>(System::Collision)->
SetMap(&m_map);
m_systems.GetSystem<S_Movement>(System::Movement)->
SetMap(&m_map);
m_systems.GetSystem<S_Network>(System::Network)->
RegisterServer(&m_server);
m_server.BindTimeoutHandler(&World::ClientLeave, this);
m_commandThread.launch();
}
World::~World(){ m_entities.SetSystemManager(nullptr); }
我们通过在初始化列表中提供一个有效的数据包处理程序来设置Server实例,其中也设置了命令线程。在构造函数的实际主体中,我们首先尝试启动服务器,并在if语句中捕获可能的失败。启动成功后,将m_running标志设置为true,并将实体管理器和系统管理器相互提供指针。然后加载游戏地图,并将其实例提供给相关系统。在我们的网络系统通过Server实例可用后,将ClientLeave方法作为超时处理程序传入,并启动命令行线程。
在World类被销毁时,我们真正需要担心的是移除实体管理器对系统管理器的访问权限。
接下来,让我们通过更新一切来保持动作的连续性:
void World::Update(const sf::Time& l_time){
if (!m_server.IsRunning()){ m_running = false; return; }
m_serverTime += l_time;
m_snapshotTimer += l_time;
m_tpsTime += l_time;
m_server.Update(l_time);
m_server.GetMutex().lock();
m_systems.Update(l_time.asSeconds());
m_server.GetMutex().unlock();
if (m_snapshotTimer.asMilliseconds() >= SNAPSHOT_INTERVAL){
sf::Packet snapshot;
m_systems.GetSystem<S_Network>(System::Network)->
CreateSnapshot(snapshot);
m_server.Broadcast(snapshot);
m_snapshotTimer = sf::milliseconds(0);
}
if (m_tpsTime >= sf::milliseconds(1000)){
m_tps = m_tick;
m_tick = 0;
m_tpsTime = sf::milliseconds(0);
} else {
++m_tick;
}
}
首先检查服务器实例是否已停止。如果是这样,则World类本身停止,并返回Update方法。否则,所有的时间值都会与服务器类一起更新。在此期间,必须锁定服务器互斥锁,因为实体信息可能会被更改。
一切更新完毕后,检查快照计时器以查看它是否超过了快照间隔。如果是这样,使用S_Network的CreateSnapshot方法创建并填充快照数据包。然后,将数据包广播到每个客户端,并将快照计时器重置为零。
注意
每秒滴答数(TPS)通过在每次更新时增加m_tick数据成员来测量,前提是 TPS 计时器没有超过一秒。如果是这样,m_tps被分配m_tick的值,然后m_tick及其 TPS 计时器都会被重置为零。
处理传入的数据包是下一个难题:
void World::HandlePacket(sf::IpAddress& l_ip,
const PortNumber& l_port, const PacketID& l_id,
sf::Packet& l_packet, Server* l_server)
{
ClientID id = l_server->GetClientID(l_ip, l_port);
PacketType type = (PacketType)l_id;
if (id >= 0){
if (type == PacketType::Disconnect){
ClientLeave(id);
l_server->RemoveClient(l_ip, l_port);
} else if (type == PacketType::Message){
// ...
} else if (type == PacketType::PlayerUpdate){
m_systems.GetSystem<S_Network>(System::Network)->
UpdatePlayer(l_packet, id);
}
} else {
if (type != PacketType::Connect){ return; }
std::string nickname;
if (!(l_packet >> nickname)){ return; }
ClientID cid = l_server->AddClient(l_ip, l_port);
if (cid == -1){
sf::Packet packet;
StampPacket(PacketType::Disconnect, packet);
l_server->Send(l_ip, l_port, packet);
return;
}
sf::Lock lock(m_server.GetMutex());
sf::Int32 eid = m_entities.AddEntity("Player");
if (eid == -1){ return; }
m_systems.GetSystem<S_Network>(System::Network)->
RegisterClientID(eid, cid);
C_Position* pos = m_entities.GetComponent<C_Position>
(eid, Component::Position);
pos->SetPosition(64.f, 64.f);
m_entities.GetComponent<C_Name>(eid, Component::Name)->
SetName(nickname);
sf::Packet packet;
StampPacket(PacketType::Connect, packet);
packet << eid;
packet << pos->GetPosition().x << pos->GetPosition().y;
if (!l_server->Send(cid, packet)){
std::cout << "Unable to respond to connect packet!"
<< std::endl;
return;
}
}
}
客户端 ID 首先从源 IP 地址和端口号中获取。如果存在具有该信息的客户端,我们对其可以接收的三个数据包类型感兴趣。首先,客户端断开连接数据包通过调用ClientLeave方法处理,该客户端 ID 作为唯一参数传入。接下来,实际的客户端从服务器类中移除。
下一个数据包类型,Message,目前尚未实现。我们目前不会在客户端之间发送聊天消息,但这是未来实现的地方。接下来是玩家更新数据包类型,在这种情况下,数据包只是简单地传递到网络系统中进行处理。我们已经讨论了这一点。
如果传入数据的源信息没有为我们提供一个有效的客户端 ID,我们只对尝试连接的通信感兴趣。首先,我们尝试从数据包中提取一个字符串,这将作为玩家的昵称。如果失败,则从这个方法返回。接下来,添加客户端信息,并通过分析返回的客户端 ID 来检查其成功。如果失败,则向原始源发送一个Disconnect数据包,并从这个方法返回。否则,服务器互斥锁被锁定,我们尝试添加一个新的玩家实体。如果无法做到这一点,则再次返回此方法。然后将客户端 ID 注册到网络系统中,并将我们新添加的玩家实体的位置设置为一些预定义的值。玩家实体的名称组件也被调整,以反映输入的昵称。此时,构建一个连接数据包作为响应。它包含玩家的实体 ID 以及其出生位置。然后,将数据包发送到我们的新客户端。
相比之下,离开服务器的过程要简单得多。让我们看看:
void World::ClientLeave(const ClientID& l_client){
sf::Lock lock(m_server.GetMutex());
S_Network* network = m_systems.
GetSystem<S_Network>(System::Network);
m_entities.RemoveEntity(network->GetEntityID(l_client));
}
在执行此操作之前,服务器互斥锁被锁定。然后获取网络系统,并调用我们实体管理器的RemoveEntity方法,该方法使用网络系统GetEntityID方法的返回值。这实际上移除了实体。
在服务器端实现一些基本命令证明是非常有用的。让我们看看一个命令行线程的非常基本的设置:
void World::CommandLine(){
while (m_server.IsRunning()){
std::string str;
std::getline(std::cin, str);
if (str == "terminate"){
m_server.Stop();
m_running = false;
break;
} else if (str == "disconnectall"){
std::cout << "Disconnecting all clients..." << std::endl;
m_server.DisconnectAll();
sf::Lock lock(m_server.GetMutex());
m_entities.Purge();
} else if (str.find("tps") != std::string::npos){
std::cout << "TPS: " << m_tps << std::endl;
} else if (str == "clients"){
std::cout << m_server.GetClientCount()
<< " clients online:" << std::endl;
std::cout << m_server.GetClientList()
<< std::endl;
} else if (str == "entities"){
std::cout << "Current entity count: "
<< m_entities.GetEntityCount() << std::endl;
}
}
}
首先,进入一个循环,只要服务器正在运行,就保持其活跃。接下来,提示命令行以获取一行输入。我们处理的第一个命令是"terminate"。这停止了服务器,并从命令行循环中跳出,这很有帮助。接下来的命令断开所有客户端的连接,并清除当前存在的所有实体。注意,在清除之前,服务器互斥锁被锁定。下一个命令简单地显示当前的每秒滴答率。输入"clients"将显示当前连接的客户端列表,包括它们的 IP 地址、端口号和延迟值。最后,"entities"命令简单地打印出当前世界中实体的数量。
最后一个,当然也是最不有趣的方法,用于获取世界的当前状态:
bool World::IsRunning(){ return m_running; }
服务器入口点
现在让我们把所有的努力都付诸实践。以下是我们Server_Main.cpp文件的内容:
#include "World.h"
int main(){
World world;
sf::Clock clock;
clock.restart();
while (world.IsRunning()){
world.Update(clock.restart());
}
return 0;
}
这已经不能再简单了。创建了一个新的World类实例,并立即重新启动了时钟。我们进入主要的while循环,条件是必须让世界实例持续运行。它每次迭代都会使用clock.restart()的返回值进行更新。循环结束后,返回零以成功结束程序。
所有这些为我们带来一个非常漂亮且功能强大的控制台窗口,准备好处理一些传入的连接:

当然,如果没有客户端通过与服务器的通信绘制所有漂亮的图像,这本身是完全无用的。这是我们列表中的下一个主要任务。
开发游戏客户端
在服务器提供适当的后端支持后,我们现在可以完全专注于客户端的细节,并享受一些漂亮的视觉效果,这些视觉效果总是能比后台运行的内容更快地带来成就感。让我们首先创建客户端自己的NetSettings.h版本:
#define NET_RENDER_DELAY 100 // ms.
#define PLAYER_UPDATE_INTERVAL 50 // ms
我们有几个宏可以在这里使用。第一个是屏幕上渲染的内容与真实时间之间的预期延迟。这意味着技术上我们将大约 100 毫秒前渲染所有动作。第二个宏是我们将发送到服务器的更新间隔。50 毫秒给我们足够的时间收集一些输入状态,并让服务器知道发生了什么。
实体组件系统扩展
就像服务器的情况一样,如果我们想实现我们的任何目标,就需要额外的组件和系统。然而,与服务器不同的是,这些添加到客户端实体组件系统中的内容将服务于一个完全不同的目的。对我们来说,看到游戏中所有玩家的名称和健康值将非常重要。我们将努力实现如下:

为了轻松维护这些漂浮在实体上方的符号,我们需要一种新的组件类型,它精确描述了它们应该渲染的位置:
class C_UI_Element : public C_Base{
public:
C_UI_Element() : C_Base(Component::UI_Element),
m_showHealth(false), m_showName(false){}
void ReadIn(std::stringstream& l_stream){
l_stream >> m_offset.x >> m_offset.y;
}
const sf::Vector2f& GetOffset(){ return m_offset; }
void SetOffset(const sf::Vector2f& l_offset){ m_offset = l_offset; }
void SetShowHealth(bool l_show){ m_showHealth = l_show; }
void SetShowName(bool l_show){ m_showName = l_show; }
bool ShowHealth(){ return m_showHealth; }
bool ShowName(){ return m_showName; }
private:
sf::Vector2f m_offset;
bool m_showHealth;
bool m_showName;
};
C_UI_Element组件将从实体文件中读取两个偏移值,一个用于 X 轴,一个用于 Y 轴。这样,不同大小的角色可以定义自己的规则,确定这些信息将出现在哪里。我们还包含了一些布尔标志,以防健康或名称信息因某些原因需要被禁用。
单独的组件本身不会做任何复杂的事情,所以让我们创建一个新的系统,真正让事情发生:
class S_CharacterUI : public S_Base{
public:
S_CharacterUI(SystemManager* l_systemMgr);
~S_CharacterUI();
void Update(float l_dT);
void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event);
void Notify(const Message& l_message);
void Render(Window* l_wind);
private:
sf::Sprite m_heartBar;
sf::Text m_nickname;
sf::RectangleShape m_nickbg;
sf::Vector2u m_heartBarSize;
};
注意,这个系统有一个Render方法。我们不仅要更新图形元素的位置,还要在屏幕上绘制它们。这包括一个将绑定到表示健康的任何纹理上的精灵,一个将包含实体名称的sf::Text实例,一个将在名称后面渲染的矩形背景,以及一个包含健康条纹理大小的数据成员。
在处理完这些之后,让我们开始实现这个系统!
S_CharacterUI::S_CharacterUI(SystemManager* l_systemMgr)
: S_Base(System::Character_UI, l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::UI_Element);
req.TurnOnBit((unsigned int)Component::Health);
m_requiredComponents.push_back(req);
req.ClearBit((unsigned int)Component::Health);
req.TurnOnBit((unsigned int)Component::Name);
m_requiredComponents.push_back(req);
ClientSystemManager* mgr =(ClientSystemManager*)m_systemManager;
mgr->GetTextureManager()->RequireResource("HeartBar");
mgr->GetFontManager()->RequireResource("Main");
sf::Texture* txtr = mgr->GetTextureManager()->
GetResource("HeartBar");
txtr->setRepeated(true);
m_heartBarSize = txtr->getSize();
m_heartBar.setTexture(*txtr);
m_heartBar.setScale(0.5f, 0.5f);
m_heartBar.setOrigin(m_heartBarSize.x / 2, m_heartBarSize.y);
m_nickname.setFont(*mgr->GetFontManager()->GetResource("Main"));
m_nickname.setCharacterSize(9);
m_nickname.setColor(sf::Color::White);
m_nickbg.setFillColor(sf::Color(100, 100, 100, 100));
}
当然,这里的首要任务是设置组件需求。一个实体必须有一个位置组件和一个 UI 元素组件,此外还需要一些健康和名称组件的组合。构造函数的其余部分用于设置我们图形的纹理和字体资源。我们的健康条纹理被设置为可重复的,这样我们就可以表示任何健康值。实际的纹理只有单个心形的大小。
显然,当这些元素不再需要时,必须释放它们的资源。这就是析构函数的作用所在:
S_CharacterUI::~S_CharacterUI(){
ClientSystemManager* mgr =
(ClientSystemManager*)m_systemManager;
mgr->GetTextureManager()->ReleaseResource("HeartBar");
mgr->GetFontManager()->ReleaseResource("Main");
}
最后,这个系统的最重要部分包含在Render方法中:
void S_CharacterUI::Render(Window* l_wind){
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto &entity : m_entities){
C_Health* health = entities->
GetComponent<C_Health>(entity, Component::Health);
C_Name* name = entities->
GetComponent<C_Name>(entity, Component::Name);
C_Position* pos = entities->
GetComponent<C_Position>(entity, Component::Position);
C_UI_Element* ui = entities->
GetComponent<C_UI_Element>(entity, Component::UI_Element);
if (health){
m_heartBar.setTextureRect(sf::IntRect(0, 0,
m_heartBarSize.x * health->GetHealth(),
m_heartBarSize.y));
m_heartBar.setOrigin((
m_heartBarSize.x * health->GetHealth())/2,
m_heartBarSize.y);
m_heartBar.setPosition(pos->GetPosition() +ui->GetOffset());
l_wind->GetRenderWindow()->draw(m_heartBar);
}
if (name){
m_nickname.setString(name->GetName());
m_nickname.setOrigin(m_nickname.getLocalBounds().width / 2,
m_nickname.getLocalBounds().height / 2);
if (health){
m_nickname.setPosition(m_heartBar.getPosition().x,
m_heartBar.getPosition().y - (m_heartBarSize.y));
} else {
m_nickname.setPosition(pos->GetPosition() +
ui->GetOffset());
}
m_nickbg.setSize(sf::Vector2f(
m_nickname.getGlobalBounds().width + 2,
m_nickname.getCharacterSize() + 1));
m_nickbg.setOrigin(m_nickbg.getSize().x / 2,
m_nickbg.getSize().y / 2);
m_nickbg.setPosition(m_nickname.getPosition().x + 1,
m_nickname.getPosition().y + 1);
l_wind->GetRenderWindow()->draw(m_nickbg);
l_wind->GetRenderWindow()->draw(m_nickname);
}
}
}
对于每个实体,我们获取我们将要使用的所有四个组件。由于可能存在名称或健康组件存在而另一个不存在的实例,因此在我们承诺渲染它们之前,必须检查这两个组件。
健康条部分是通过首先重置精灵的纹理矩形来绘制的。其宽度被更改为纹理中单个心形宽度的健康值。Y 值保持不变。然后,精灵的原点被更改为它在 X 轴上的中间位置和 Y 轴的底部。其位置被设置为实体的位置,但考虑了 UI 元素的偏移。因为纹理被设置为重复,这使我们能够表示大量的健康值:

当一个实体的名称被渲染时,sf::Text实例首先通过更改字符串来设置,然后其原点被操作以正好位于中间。由于我们希望信息整齐堆叠,而不是相互覆盖,因此检查是否渲染了健康值是必要的。
如果存在健康组件,名称的位置是从m_heartBar数据成员中获得的。该位置 Y 值通过减去健康条的宽度来修改,以便在顶部渲染玩家名称。否则,名称的位置被设置为与包含偏移的实体相匹配。名称背景被设置为比将要绘制在其后面的文本稍大,其原点设置为精确的中心。名称背景的位置稍微偏离实际名称的位置。这里使用的值可以通过简单地尝试不同的事情并找到最佳结果来完善。
最后,背景和实体的名称按照这个顺序在屏幕上绘制。
网络类和插值
简单地显示我们的实体出现在屏幕上根本不能令人满意。即使我们让它们移动,你也会很快注意到,由于服务器和客户端之间的延迟,玩家看起来更像是跳过屏幕,而不是行走。客户端需要做更多的工作来使其平滑。为此,我们将依赖于一种称为插值的方法。考虑以下插图:

什么是插值?它是在两个已知数据点之间的估计。有许多不同类型的插值,它们都有不同的使用哲学。就我们的目的而言,插值数据简单来说就是找到给定时间两个值之间的加权平均值。在前面的图中,我们有两个快照代表不同时间点。插值帮助我们找到两个快照之间的某个中间位置的状态,进而通过根据估计调整位置、速度和加速度等属性来平滑它们的移动,而不是实际快照数据。
在两个快照之间在特定时间点找到值可以表示为:

我们想在给定时间 tx 找到的值,简单地说,是两个快照之间值的差除以时间差,乘以自第一个快照以来经过的时间,然后加到第一个快照的值上。在代码中,它可以表示如下:
template<class T>
inline T Interpolate(const sf::Int32& T1, const sf::Int32& T2,
const T& T1_val, const T& T2_val, const sf::Int32& T_X)
{
return (((T2_val - T1_val) / (T2 - T1)) * (T_X - T1)) + T1_val;
}
对于实际处理快照和时间类型,以及比较两个快照,有一些额外的方法将是有用的:
void InterpolateSnapshot(const EntitySnapshot& l_s1,
const sf::Int32& T1, const EntitySnapshot& l_s2,
const sf::Int32& T2, EntitySnapshot& l_target,
const sf::Int32& T_X);
bool CompareSnapshots(const EntitySnapshot& l_s1,
const EntitySnapshot& l_s2, bool l_position = true,
bool l_physics = true, bool l_state = true);
我们需要一种方式来包含这些快照,因此是时候定义我们的数据类型了:
using SnapshotMap = std::unordered_map<EntityId, EntitySnapshot>;
struct SnapshotDetails{
SnapshotMap m_snapshots;
};
using SnapshotContainer = std::map<sf::Int32, SnapshotDetails>;
using OutgoingMessages = std::unordered_map<EntityMessage,
std::vector<Message>>;
所有快照首先以实体 ID 作为键进行存储。实际的映射本身由一个SnapshotDetails结构体持有,这可能在以后如果我们决定添加任何额外的快照信息时变得有用。然后,所有实体数据都存储在一个映射结构中,其中快照的时间戳是键值。请注意,我们在这里使用的是常规映射,而不是无序映射。你可能会问,这有什么好处。常规映射类型可能稍微慢一点,但它会自动按键对条目进行排序。这意味着新的快照总是会映射到映射的末尾。为什么这很重要,当我们进行实体插值时会变得明显。
我们将为网络类需要的最后一个数据类型是某种容器,用于存储我们将发送到服务器的出站消息。在这种情况下,无序映射就足够了。
那么,我们的网络系统类将是什么样子呢?让我们看看:
class S_Network : public S_Base{
public:
S_Network(SystemManager* l_systemMgr);
~S_Network();
void Update(float l_dT);
void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event);
void Notify(const Message& l_message);
void SetClient(Client* m_client);
void SetPlayerID(const EntityId& l_entity);
void AddSnapshot(const EntityId& l_entity,
const sf::Int32& l_timestamp,
EntitySnapshot& l_snapshot);
void SendPlayerOutgoing();
void ClearSnapshots();
private:
void ApplyEntitySnapshot(const EntityId& l_entity,
const EntitySnapshot& l_snapshot,
bool l_applyPhysics);
void PerformInterpolation();
SnapshotContainer m_entitySnapshots;
EntityId m_player;
OutgoingMessages m_outgoing;
Client* m_client;
sf::Time m_playerUpdateTimer;
};
除了系统必须实现的常规方法外,我们还有一些用于注册Client实例的 setter,以及跟踪我们的客户端作为玩家将要控制的实体 ID。还有一些辅助方法用于添加接收到的实体快照,以及向服务器发送玩家消息,以使生活变得稍微容易一些。对于我们的私有方法选择,我们总共有两个:一个用于将特定快照应用于实体,另一个用于执行插值。这需要一定数量的数据成员,它们负责包含接收到的快照,跟踪玩家 ID,在发送之前包含要发送到服务器的输出消息,以及访问Client实例。为了锦上添花,我们还将使用另一个sf::Time数据类型来跟踪时间流逝,以便向服务器发送玩家更新。
实现客户端网络类
在实际实现网络系统之前,让我们完成与插值和实体快照比较相关的最后两个函数:
void InterpolateSnapshot(const EntitySnapshot& l_s1,
const sf::Int32& T1, const EntitySnapshot& l_s2,
const sf::Int32& T2, EntitySnapshot& l_target,
const sf::Int32& T_X)
{
l_target.m_direction = l_s2.m_direction;
l_target.m_health = l_s2.m_health;
l_target.m_name = l_s2.m_name;
l_target.m_state = l_s1.m_state;
l_target.m_elevation = l_s1.m_elevation;
l_target.m_position.x = Interpolate<float>(
T1, T2, l_s1.m_position.x, l_s2.m_position.x, T_X);
l_target.m_position.y = Interpolate<float>(
T1, T2, l_s1.m_position.y, l_s2.m_position.y, T_X);
l_target.m_velocity.x = Interpolate<float>(
T1, T2, l_s1.m_velocity.x, l_s2.m_velocity.x, T_X);
l_target.m_velocity.y = Interpolate<float>(
T1, T2, l_s1.m_velocity.y, l_s2.m_velocity.y, T_X);
l_target.m_acceleration.x = Interpolate<float>(
T1, T2, l_s1.m_acceleration.x, l_s2.m_acceleration.x, T_X);
l_target.m_acceleration.y = Interpolate<float>(
T1, T2, l_s1.m_acceleration.y, l_s2.m_acceleration.y, T_X);
}
我们首先覆盖了一些不需要插值的数据。请注意,方向、健康和名称值是用来自第二个实体快照的最新信息覆盖的,而不是第一个。这为实体移动和交互提供了整体更平滑的感觉。对于其余的快照数据,我们使用我们方便的Interpolate函数,它提供了两个更新之间的平滑过渡。
还有一个能够比较两个快照的功能是非常有用的,这样我们就可以知道是否有任何数据已更改。"CompareSnapshots"在这里提供了帮助:
bool CompareSnapshots(const EntitySnapshot& l_s1,
const EntitySnapshot& l_s2, bool l_position,
bool l_physics, bool l_state)
{
if (l_position && (l_s1.m_position != l_s2.m_position ||
l_s1.m_elevation != l_s2.m_elevation))
{ return false; }
if (l_physics && (l_s1.m_velocity != l_s2.m_velocity ||
l_s1.m_acceleration != l_s2.m_acceleration ||
l_s1.m_direction != l_s2.m_direction))
{ return false; }
if (l_state && (l_s1.m_state != l_s2.m_state))
{ return false; }
return true;
}
在这里检查快照的每个方面并不是真的有必要。我们真正关心的是实体的位置、运动学和状态信息。还可以提供三个额外的布尔参数,告诉此函数哪些数据是相关的。
在完成这些之后,我们最终可以开始实现网络系统类,当然是从构造函数和析构函数开始:
S_Network::S_Network(SystemManager* l_systemMgr)
: S_Base(System::Network, l_systemMgr), m_client(nullptr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Client);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Move, this);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Attack, this);
m_playerUpdateTimer = sf::milliseconds(0);
}
与服务器类类似,我们只关心这个系统中具有客户端组件的实体。我们还订阅了实体移动和攻击的消息,以便正确存储它们并在稍后更新服务器。
接下来,我们有Update方法:
void S_Network::Update(float l_dT){
if (!m_client){ return; }
sf::Lock lock(m_client->GetMutex());
m_playerUpdateTimer += sf::seconds(l_dT);
if (m_playerUpdateTimer.asMilliseconds() >=
PLAYER_UPDATE_INTERVAL)
{
SendPlayerOutgoing();
m_playerUpdateTimer = sf::milliseconds(0);
}
PerformInterpolation();
}
首先,进行一次检查以确保我们有一个指向客户端类的有效指针。如果是这样,我们锁定客户端互斥锁并给玩家更新计时器添加时间。如果经过足够的时间来更新服务器,则调用SendPlayerOutgoing方法并重置计时器。最后,我们调用这个类的私有辅助方法,该方法负责在快照之间进行插值。将此功能与实际的更新循环分开,使我们拥有更美观的代码,并允许在插值时提前返回。
处理这个系统订阅的消息相当简单,正如你在这里看到的:
void S_Network::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver) ||
l_message.m_receiver != m_player)
{
return;
}
if (l_message.m_type == (MessageType)EntityMessage::Attack &&
m_outgoing.find(EntityMessage::Attack) != m_outgoing.end())
{
return;
}
m_outgoing[(EntityMessage)l_message.m_type].
emplace_back(l_message);
}
在这个阶段,我们只关心将消息添加到我们的输出容器中,因为我们目前还没有处理更复杂类型。如果收到攻击消息,还会进行额外的检查。在这个容器中同时存在多个攻击消息实际上没有意义,所以Notify方法会在容器中已经存在攻击消息的情况下简单地返回。
接下来,我们有一些辅助方法:
void S_Network::SetClient(Client* l_client){m_client = l_client;}
void S_Network::SetPlayerID(const EntityId& l_entity){
m_player = l_entity;
}
void S_Network::AddSnapshot(const EntityId& l_entity,
const sf::Int32& l_timestamp, EntitySnapshot& l_snapshot)
{
sf::Lock lock(m_client->GetMutex());
auto i = m_entitySnapshots.emplace(l_timestamp,
SnapshotDetails());
i.first->second.m_snapshots.emplace(l_entity, l_snapshot);
}
这里并没有什么特别的事情发生。需要注意的是,当添加新的快照时,客户端互斥锁可能应该被锁定。说到快照,让我们看看如何将一个快照应用到实体上:
void S_Network::ApplyEntitySnapshot(const EntityId& l_entity,
const EntitySnapshot& l_snapshot, bool l_applyPhysics)
{
ClientEntityManager* entities =
(ClientEntityManager*)m_systemManager->GetEntityManager();
C_Position* position = nullptr;
C_Movable* movable = nullptr;
S_Movement* movement_s = nullptr;
S_State* state_s = nullptr;
C_Health* health = nullptr;
C_Name* name = nullptr;
sf::Lock lock(m_client->GetMutex());
if (position = entities->GetComponent<C_Position>(l_entity,
Component::Position))
{
position->SetPosition(l_snapshot.m_position);
position->SetElevation(l_snapshot.m_elevation);
}
if (l_applyPhysics){
if (movable = entities->GetComponent<C_Movable>(l_entity,
Component::Movable))
{
movable->SetVelocity(l_snapshot.m_velocity);
movable->SetAcceleration(l_snapshot.m_acceleration);
}
}
if (movement_s = m_systemManager->
GetSystem<S_Movement>(System::Movement))
{
movement_s->SetDirection(l_entity,
(Direction)l_snapshot.m_direction);
}
if (state_s = m_systemManager->
GetSystem<S_State>(System::State))
{
state_s->ChangeState(l_entity,
(EntityState)l_snapshot.m_state,true);
}
if (health = entities->GetComponent<C_Health>(l_entity,
Component::Health))
{
health->SetHealth(l_snapshot.m_health);
}
if (name = entities->GetComponent<C_Name>(l_entity,
Component::Name))
{
name->SetName(l_snapshot.m_name);
}
}
在我们获得实体管理器的指针并设置指向实体快照可能包含信息的各种组件的空指针之后,客户端互斥锁被锁定,我们开始仔细操作组件信息,首先尝试在if语句中检索一个有效的组件地址。此方法还接受一个标志,以告知它是否应该应用物理信息,如加速度或速度,这可能很有用。
以下方法在更新网络系统类时执行,它负责将玩家更新发送到服务器:
void S_Network::SendPlayerOutgoing(){
sf::Int32 p_x = 0, p_y = 0;
sf::Int8 p_a = 0;
for (auto &itr : m_outgoing){
if (itr.first == EntityMessage::Move){
sf::Int32 x = 0, y = 0;
for (auto &message : itr.second){
if (message.m_int == (int)Direction::Up){ --y; }
else if (message.m_int == (int)Direction::Down){ ++y; }
else if (message.m_int == (int)Direction::Left){ --x; }
else if (message.m_int == (int)Direction::Right){ ++x; }
}
if (!x && !y){ continue; }
p_x = x; p_y = y;
} else if (itr.first == EntityMessage::Attack){ p_a = 1; }
}
sf::Packet packet;
StampPacket(PacketType::PlayerUpdate, packet);
packet << sf::Int8(EntityMessage::Move)
<< p_x << p_y << sf::Int8(Network::PlayerUpdateDelim);
packet << sf::Int8(EntityMessage::Attack)
<< p_a << sf::Int8(Network::PlayerUpdateDelim);
m_client->Send(packet);
m_outgoing.clear();
}
我们首先设置一些局部变量,这些变量将用于存储玩家在 X 和 Y 方向上移动的次数。同时,还设置了一个较小的变量用于攻击状态。下一步是遍历所有发出的消息,并逐个处理每种类型。在Move类型的情况下,每个消息都会被计数。如果发现Attack消息,攻击状态就简单地设置为 1。
最后一步当然是发送这些信息。然后构建一个新的数据包,并将其标记为玩家更新。然后将移动和攻击状态信息输入到数据包中。注意,我们在每个更新类型的末尾添加了PlayerUpdateDelim值。通过执行此类特定的通信规则,可以降低服务器处理无效或损坏数据的机会。一旦发送了更新数据包,输出消息容器就会被清空,以便下一次使用。
最后,我们到达确保实体平滑移动的关键方法:
void S_Network::PerformInterpolation(){
if (m_entitySnapshots.empty()){ return; }
ClientEntityManager* entities =
(ClientEntityManager*)m_systemManager->GetEntityManager();
sf::Time t = m_client->GetTime();
auto itr = ++m_entitySnapshots.begin();
while (itr != m_entitySnapshots.end()){
if (m_entitySnapshots.begin()->first <=
t.asMilliseconds() - NET_RENDER_DELAY &&
itr->first >= t.asMilliseconds() - NET_RENDER_DELAY)
{
auto Snapshot1 = m_entitySnapshots.begin();
auto Snapshot2 = itr;
bool SortDrawables = false;
for (auto snap = Snapshot1->second.m_snapshots.begin();
snap != Snapshot1->second.m_snapshots.end();)
{
if (!entities->HasEntity(snap->first)){
if (entities->AddEntity(snap->second.m_type,
snap->first) == (int)Network::NullID)
{
std::cout << "Failed adding entity type: "
<< snap->second.m_type << std::endl;
continue;
}
ApplyEntitySnapshot(snap->first, snap->second, true);
++snap;
continue;
}
auto snap2 =Snapshot2->second.m_snapshots.find(
snap->first);
if (snap2 == Snapshot2->second.m_snapshots.end()){
sf::Lock lock(m_client->GetMutex());
entities->RemoveEntity(snap->first);
snap = Snapshot1->second.m_snapshots.erase(snap);
continue;
}
EntitySnapshot i_snapshot;
InterpolateSnapshot(snap->second, Snapshot1->first,
snap2->second, Snapshot2->first,
i_snapshot, t.asMilliseconds() - NET_RENDER_DELAY);
ApplyEntitySnapshot(snap->first, i_snapshot, true);
if (!CompareSnapshots(snap->second, snap2->second,
true, false, false))
{
SortDrawables = true;
}
++snap;
}
if (SortDrawables){
m_systemManager->GetSystem<S_Renderer>
(System::Renderer)->SortDrawables();
}
return;
}
m_entitySnapshots.erase(m_entitySnapshots.begin());
itr = ++m_entitySnapshots.begin();
}
}
首先最重要的是处理客户端没有任何快照的可能性。如果发生这种情况,这个方法会立即返回。如果我们有可用的快照,下一步是遍历快照容器,找到我们目前处于其中的两个快照(从时间上讲)。通常情况下,这不太可能发生,但请记住,我们渲染的是稍早之前的事情:

在渲染时稍微滞后带来的好处是,我们实际上将拥有更多来自服务器的数据,这反过来又允许我们平滑处理并提供更佳的实体移动效果。如果我们只是实时渲染一切,这是不可能实现的。这种延迟由NET_RENDER_DELAY宏表示。
一旦我们找到了我们正在寻找的快照对,就设置一个名为SortDrawables的局部变量来跟踪我们是否需要担心重新排序可绘制组件以正确表示深度。然后迭代第一个(较早的)快照中的所有实体。我们的首要任务是确保快照中存在的实体也在我们的客户端存在。如果不存在,就根据快照提供的类型创建一个新的实体。然后将其所有信息应用于新创建的实体,并跳过当前快照循环的迭代,因为没有必要进行任何插值。
下一步是确保在较早的快照中存在的实体也在较后的快照中存在,因此尝试在第二个快照容器中找到它的尝试被做出。如果实体尚未找到,客户端互斥锁被锁定,实体从我们的客户端移除,在实际上从快照容器中删除之前。然后跳过当前迭代,因为我们没有理由再次进行插值。
如果所有这些检查都没有给出我们跳过迭代的理由,就会创建一个新的EntitySnapshot实例。这将是我们用来保存插值数据的对象。然后,使用两个快照及其时间值,以及考虑了插值延迟的目标快照和当前时间作为参数调用InterpolateSnapshot。在目标快照用插值数据填充后,它被应用于当前实体。我们还想比较我们正在插值之间的两个快照,如果它们的位置不同,则将SortDrawables变量设置为true。在所有实体插值代码之后,检查这个变量,并指示系统渲染器如果它确实在某处被设置为true,则重新排序可绘制组件。
从这一点我们可以得到的最后一点是,如果第一个循环中的时间检查条件最终没有得到满足,快照容器中的第一个元素将被删除,迭代器将被重置,指向其中的第二个值,确保无关快照得到适当的处理。
客户端实体和系统管理
很容易预测,我们在客户端侧将拥有与服务器侧不同的组件和系统类型,首先是组件类型:
ClientEntityManager::ClientEntityManager(SystemManager* l_sysMgr,
TextureManager* l_textureMgr): EntityManager(l_sysMgr),
m_textureManager(l_textureMgr)
{
AddComponentType<C_Position>(Component::Position);
AddComponentType<C_State>(Component::State);
AddComponentType<C_Movable>(Component::Movable);
AddComponentType<C_Controller>(Component::Controller);
AddComponentType<C_Collidable>(Component::Collidable);
AddComponentType<C_SpriteSheet>(Component::SpriteSheet);
AddComponentType<C_SoundEmitter>(Component::SoundEmitter);
AddComponentType<C_SoundListener>(Component::SoundListener);
AddComponentType<C_Client>(Component::Client);
AddComponentType<C_Health>(Component::Health);
AddComponentType<C_Name>(Component::Name);
AddComponentType<C_UI_Element>(Component::UI_Element);
}
在确保所有与客户端相关的组件类型都已注册后,让我们在这里实现我们自己的加载实体版本,因为它涉及到操作它可能具有的可渲染组件:
int ClientEntityManager::AddEntity(
const std::string& l_entityFile, int l_id)
{
...
while (std::getline(file, line)){
...
} else if (type == "Component"){
...
keystream >> *component;
if (component->GetType() == Component::SpriteSheet){
C_SpriteSheet* sheet = (C_SpriteSheet*)component;
sheet->Create(m_textureManager);
}
}
}
...
}
我们已经在之前的章节中看到了这段代码,但仍然有理由强调,高亮显示的代码片段在服务器端根本不存在,但在这里是必要的。
接下来,客户端的系统管理器版本:
class ClientSystemManager : public SystemManager{
public:
ClientSystemManager(TextureManager* l_textureMgr,
FontManager* l_fontMgr);
~ClientSystemManager();
TextureManager* GetTextureManager();
FontManager* GetFontManager();
void Draw(Window* l_wind, unsigned int l_elevation);
private:
TextureManager* m_textureMgr;
FontManager* m_fontMgr;
};
自然地,我们在这里所做的唯一添加,再次强调,与图形相关。我们不需要在服务器端绘制任何东西,但在这里是必要的。
我们客户端系统管理器的构造函数处理添加与客户端预期表现相关的系统:
ClientSystemManager::ClientSystemManager(
TextureManager* l_textureMgr, FontManager* l_fontMgr)
: m_textureMgr(l_textureMgr), m_fontMgr(l_fontMgr)
{
AddSystem<S_State>(System::State);
AddSystem<S_Control>(System::Control);
AddSystem<S_Movement>(System::Movement);
AddSystem<S_Collision>(System::Collision);
AddSystem<S_SheetAnimation>(System::SheetAnimation);
AddSystem<S_Network>(System::Network);
AddSystem<S_Sound>(System::Sound);
AddSystem<S_Renderer>(System::Renderer);
AddSystem<S_CharacterUI>(System::Character_UI);
}
注意网络系统在这里的放置。添加这些系统的顺序直接决定了它们更新的顺序。我们不希望我们的网络系统在我们有机会处理我们自己的数据之前发送或接收任何数据。
自然地,在这个方面,纹理和字体管理器的获取器将是有用的:
TextureManager* ClientSystemManager::GetTextureManager(){
return m_textureMgr;
}
FontManager* ClientSystemManager::GetFontManager(){
return m_fontMgr;
}
最后,我们有一些需要在屏幕上渲染的系统:
void ClientSystemManager::Draw(Window* l_wind,
unsigned int l_elevation)
{
auto itr = m_systems.find(System::Renderer);
if(itr != m_systems.end()){
S_Renderer* system = (S_Renderer*)itr->second;
system->Render(l_wind, l_elevation);
}
itr = m_systems.find(System::Character_UI);
if (itr != m_systems.end()){
S_CharacterUI* ui = (S_CharacterUI*)itr->second;
ui->Render(l_wind);
}
}
在渲染系统绘制屏幕上的所有实体之后,我们希望在它们上面叠加它们的名称和健康图形。
将各个部分放在一起
因为所有的网络和动作都将仅在网络状态范围内发生,所以我们将调整的主要类是,从头文件开始:
class State_Game : public BaseState{
...
private:
Map* m_gameMap;
int m_player;
Client* m_client;
};
在确保游戏状态有一个指向Client实例的指针之后,我们必须为游戏提供处理传入数据包的方法:
void State_Game::HandlePacket(const PacketID& l_id,
sf::Packet& l_packet, Client* l_client)
{
ClientEntityManager* emgr = m_stateMgr->
GetContext()->m_entityManager;
PacketType type = (PacketType)l_id;
if (type == PacketType::Connect){
sf::Int32 eid;
sf::Vector2f pos;
if (!(l_packet >> eid) || !(l_packet >> pos.x) ||
!(l_packet >> pos.y))
{
std::cout << "Faulty CONNECT response!" << std::endl;
return;
}
std::cout << "Adding entity: " << eid << std::endl;
m_client->GetMutex().lock();
emgr->AddEntity("Player", eid);
emgr->GetComponent<C_Position>
(eid, Component::Position)->SetPosition(pos);
m_client->GetMutex().unlock();
m_player = eid;
m_stateMgr->GetContext()->m_systemManager->
GetSystem<S_Network>(System::Network)->SetPlayerID(m_player);
emgr->AddComponent(eid, Component::SoundListener);
return;
}
if (!m_client->IsConnected()){ return; }
switch (type){
case PacketType::Snapshot:
{
sf::Int32 entityCount = 0;
if (!(l_packet >> entityCount)){
std::cout << "Snapshot extraction failed."
<< std::endl;
return;
}
sf::Lock lock(m_client->GetMutex());
sf::Int32 t = m_client->GetTime().asMilliseconds();
for (unsigned int i = 0; i < entityCount; ++i){
sf::Int32 eid;
EntitySnapshot snapshot;
if (!(l_packet >> eid) || !(l_packet >> snapshot)){
std::cout << "Snapshot extraction failed."
<< std::endl;
return;
}
m_stateMgr->GetContext()->m_systemManager->
GetSystem<S_Network>(System::Network)->
AddSnapshot(eid, t, snapshot);
}
break;
}
case PacketType::Disconnect:
{
m_stateMgr->Remove(StateType::Game);
m_stateMgr->SwitchTo(StateType::MainMenu);
std::cout << "Disconnected by server!" << std::endl;
break;
}
case PacketType::Hurt:
{
EntityId id;
if (!(l_packet >> id)){ return; }
Message msg((MessageType)EntityMessage::Hurt);
msg.m_receiver = id;
m_stateMgr->GetContext()->m_systemManager->
GetMessageHandler()->Dispatch(msg);
break;
}
}
}
首先,我们处理服务器在客户端尝试连接后发送回我们的连接数据包。如果成功从数据包中提取了实体 ID 和位置,则在添加玩家实体并更新其位置时锁定客户端互斥锁。然后,我们的玩家实体的实体 ID 存储在m_player数据成员中,并传递给需要它的网络系统。注意在这个段落的最后几行代码,在我们返回之前。在实体成功构建后,我们向其添加一个声音监听器组件。自然地,客户端只能有一个声音监听器,那就是我们的玩家。这意味着player.entity文件不再有自己的声音监听器组件。相反,它必须在这里添加,以便正确进行音频定位。
接下来,如果我们的客户端已经连接到服务器,我们就准备好处理快照、伤害和断开连接的数据包。如果收到快照,我们首先尝试读取其中包含的实体数量,如果读取失败则返回。然后锁定客户端互斥锁并获取当前时间,以保持实体快照的连续性。接着构建一个新的for循环,为数据包中的每个单独实体运行,提取其 ID 和快照数据,这些数据随后被添加到网络系统中以供后续处理。
如果从服务器接收到断开连接的数据包,我们只需移除游戏状态并切换回主菜单。此外,在接收到伤害数据包时,从中提取实体 ID,并创建一个要由该实体接收的Hurt消息,然后发送出去。
现在,是时候调整我们游戏状态中的现有方法,以便它在创建时尝试连接到服务器:
void State_Game::OnCreate(){
m_client->Setup(&State_Game::HandlePacket, this);
if (m_client->Connect()){
m_stateMgr->GetContext()->m_systemManager->
GetSystem<S_Network>(System::Network)->SetClient(m_client);
...
evMgr->AddCallback(StateType::Game, "Player_Attack",
&State_Game::PlayerAttack, this);
...
} else {
std::cout << "Failed to connect to the game server!"
<< std::endl;
m_stateMgr->Remove(StateType::Game);
m_stateMgr->SwitchTo(StateType::MainMenu);
}
}
首先,分配客户端的数据包处理器。然后,我们尝试使用客户端类中现有的 IP 和端口信息连接到服务器。如果连接尝试成功,我们可以开始初始化我们的数据成员并添加回调,其中一个回调是处理玩家攻击按钮被按下的新方法。如果连接尝试失败,则移除游戏状态并切换回主菜单状态。
如果移除游戏状态,需要进行一些清理工作:
void State_Game::OnDestroy(){
m_client->Disconnect();
m_client->UnregisterPacketHandler();
S_Network* net = m_stateMgr->GetContext()->
m_systemManager->GetSystem<S_Network>(System::Network);
net->ClearSnapshots();
net->SetClient(nullptr);
net->SetPlayerID((int)Network::NullID);
...
evMgr->RemoveCallback(StateType::Game, "Player_Attack");
...
}
除了清理游戏状态的其余代码之外,我们现在还必须断开与服务器的连接并注销客户端类使用的包处理器。网络系统也被清除了它可能持有的所有快照,以及任何玩家信息和指向客户端类的指针。玩家攻击回调也在这里被移除。
自然地,我们还想稍微调整游戏状态的Update方法:
void State_Game::Update(const sf::Time& l_time){
if (!m_client->IsConnected()){
m_stateMgr->Remove(StateType::Game);
m_stateMgr->SwitchTo(StateType::MainMenu);
return;
}
SharedContext* context = m_stateMgr->GetContext();
UpdateCamera();
m_gameMap->Update(l_time.asSeconds());
{
sf::Lock lock(m_client->GetMutex());
context->m_systemManager->Update(l_time.asSeconds());
}
}
首先检查客户端的连接状态。未连接意味着我们可以退出游戏状态并再次切换回主菜单。否则,我们继续更新。注意系统管理器更新调用周围的括号。它们为内部定义的任何变量创建了一个作用域,这对于使用sf::Lock实例锁定客户端互斥锁很有用,因为一旦我们离开括号,它就会超出作用域,从而解锁。
在屏幕上绘制东西也需要稍作调整:
void State_Game::Draw(){
if (!m_gameMap){ return; }
sf::Lock lock(m_client->GetMutex());
for (int i = 0; i < Sheet::Num_Layers; ++i){
m_gameMap->Draw(i);
m_stateMgr->GetContext()->m_systemManager->
Draw(m_stateMgr->GetContext()->m_wind, i);
}
}
这里唯一的添加是在for循环中绘制不同高度的实体之前,添加客户端互斥锁。我们不希望另一个线程操作我们可能正在访问的任何数据。
最后,需要像这样处理玩家攻击按钮被按下的事件:
void State_Game::PlayerAttack(EventDetails* l_details){
Message msg((MessageType)EntityMessage::Attack);
msg.m_receiver = m_player;
m_stateMgr->GetContext()->m_systemManager->
GetMessageHandler()->Dispatch(msg);
}
这相当简单。当按下攻击键时,实体组件系统会收到一个新的攻击消息。我们的网络系统订阅了这种消息类型,并将其添加到玩家更新容器中,该容器将在特定的时间间隔内发送到服务器。
主菜单调整
我们现在的客户端-服务器设置已经可以正常工作,但我们还需要添加一个小功能才能使其真正运作。我们无法输入服务器信息!让我们通过修改主菜单界面文件来解决这个问题:
Interface MainMenu MainMenu.style 0 0 Immovable NoTitle "Main menu"
Element Label Title 100 0 MainMenuTitle.style "Main menu:"
Element Label IpLabel 0 32 DefaultLabel.style "IP:"
Element TextField IP 18 32 MainMenuTextfield.style "127.0.0.1"
Element Label PortLabel 150 32 DefaultLabel.style "Port:"
Element TextField PORT 175 32 MainMenuTextfield.style "5600"
Element Label NameLabel 50 56 DefaultLabel.style "Nickname:"
Element TextField Nickname 105 56 MainMenuTextfield.style "Player"
Element Label Play 0 80 MainMenuLabel.style "CONNECT"
Element Label Disconnect 0 116 MainMenuLabel.style "DISCONNECT"
Element Label Credits 0 152 MainMenuLabel.style "CREDITS"
Element Label Quit 0 188 MainMenuLabel.style "EXIT"
在这里,主菜单中添加了许多新元素。我们有三个新的文本字段和一些与之相邻的文本标签,以便让用户知道它们的作用。这就是服务器信息和玩家昵称将被输入的方式。让我们通过为这些新按钮添加一些回调来实现这一点:
void State_MainMenu::OnCreate(){
SetTransparent(true); // Transparent for rendering.
SetTranscendent(true); // Transcendent for updating.
...
eMgr->AddCallback(StateType::MainMenu, "MainMenu_Play",
&State_MainMenu::Play, this);
eMgr->AddCallback(StateType::MainMenu, "MainMenu_Disconnect",
&State_MainMenu::Disconnect, this);
eMgr->AddCallback(StateType::MainMenu, "MainMenu_Quit",
&State_MainMenu::Quit, this);
}
void State_MainMenu::OnDestroy(){
...
gui->RemoveInterface(StateType::MainMenu, "MainMenu");
eMgr->RemoveCallback(StateType::MainMenu, "MainMenu_Play");
eMgr->RemoveCallback(StateType::MainMenu,"MainMenu_Disconnect");
eMgr->RemoveCallback(StateType::MainMenu, "MainMenu_Quit");
}
为了让主菜单感觉更具互动性,我们希望在每次激活菜单状态时更新这个界面:
void State_MainMenu::Activate(){
GUI_Interface* menu = m_stateMgr->GetContext()->
m_guiManager->GetInterface(StateType::MainMenu, "MainMenu");
if(m_stateMgr->HasState(StateType::Game)){
// Resume
menu->GetElement("Play")->SetText("Resume");
menu->GetElement("Disconnect")->SetActive(true);
menu->GetElement("IP")->SetActive(false);
menu->GetElement("PORT")->SetActive(false);
menu->GetElement("IpLabel")->SetActive(false);
menu->GetElement("PortLabel")->SetActive(false);
menu->GetElement("NameLabel")->SetActive(false);
menu->GetElement("Nickname")->SetActive(false);
} else {
// Play
menu->GetElement("Play")->SetText("CONNECT");
menu->GetElement("Disconnect")->SetActive(false);
menu->GetElement("IP")->SetActive(true);
menu->GetElement("PORT")->SetActive(true);
menu->GetElement("IpLabel")->SetActive(true);
menu->GetElement("PortLabel")->SetActive(true);
menu->GetElement("NameLabel")->SetActive(true);
menu->GetElement("Nickname")->SetActive(true);
}
}
根据是否存在游戏状态,我们在界面中设置元素以反映我们当前连接的状态。
最后,让我们看看连接和断开连接按钮的回调方法:
void State_MainMenu::Play(EventDetails* l_details){
if (!m_stateMgr->HasState(StateType::Game)){
GUI_Interface* menu = m_stateMgr->GetContext()->
m_guiManager->GetInterface(StateType::MainMenu, "MainMenu");
std::string ip = menu->GetElement("IP")->GetText();
PortNumber port = std::atoi(
menu->GetElement("PORT")->GetText().c_str());
std::string name = menu->GetElement("Nickname")->GetText();
m_stateMgr->GetContext()->m_client->
SetServerInformation(ip, port);
m_stateMgr->GetContext()->m_client->SetPlayerName(name);
}
m_stateMgr->SwitchTo(StateType::Game);
}
void State_MainMenu::Disconnect(EventDetails* l_details){
m_stateMgr->GetContext()->m_client->Disconnect();
}
在Play方法中的第一次检查是为了确保文本字段信息被正确传递到需要的地方。因为我们有一个相同的按钮,既可以用来连接服务器,也可以在存在游戏状态时切换回游戏状态,确保客户端实例的服务器和玩家名称信息得到更新是很重要的。然后我们切换到游戏状态,这可能意味着需要创建它,这时我们会使用刚刚传递的信息,或者它只是简单地恢复为占主导地位的应用程序状态。
断开连接按钮的回调函数仅调用客户端的Disconnect方法,这反过来会导致游戏状态自行终止。
有了这个,我们就拥有了一个功能齐全的 2D 多人游戏,玩家可以互相攻击了!

摘要
恭喜!您已经到达了终点!这是一段相当漫长的旅程。我们凭借一些基本工具和集中精力,成功地创造了一个小世界。它可能内容并不多,但这就是您的用武之地。仅仅因为您读完了这本书,并不意味着我们讨论的三个项目中的任何一个已经完成。实际上,这只是一个开始。尽管我们已经覆盖了很多内容,但仍有大量功能等待您自己实现,例如不同类型的敌人、可选的玩家皮肤、最后项目的魔法和远程攻击、动画地图块、最后项目的地图过渡、聊天系统、我们的 RPG 的等级和经验值,以及更多。毫无疑问,您一定有自己的想法和机制,这些想法和机制应该被提出并在您的游戏中实现。现在不要停下来;保持流畅并开始编码!
非常感谢您阅读,请记住,最终我们创造的这个世界的走向掌握在您的手中,所以请让它变得美好。再见!


浙公网安备 33010602011771号