C---游戏开发的程序化内容生成-全-

C++ 游戏开发的程序化内容生成(全)

原文:zh.annas-archive.org/md5/78a00fe20d9b720cedc79b3376ba4721

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

电脑游戏是一个庞大的媒介,已经发展了三到四十年。游戏比以往任何时候都更大、更沉浸,玩家的期望也从未如此之高。虽然线性游戏,即具有固定故事和固定进度的游戏,仍然很常见,但越来越多的动态和开放式的游戏正在被开发。

计算机硬件和视频游戏技术的进步正在给“游戏世界”这个词带来更加直接的意义。游戏地图不断增加,变得更加灵活,这要归功于过程生成等技术的发展。由于内容是动态生成的,所以购买同一款游戏的两名玩家可能会有非常不同的体验。

在本书中,我们将介绍过程生成,学习生成内容以创建动态和不可预测的游戏系统和机制所需的技能。

本书提供了一个流氓式 C++游戏的游戏模板。当我们在第二章“项目设置和拆分”中编译和设置项目时,您会发现它目前只是一个空壳。然而,随着我们在书中的学习,您将通过真实的例子了解到程序生成内容背后的概念。然后我们将在空项目中实现这些例子。

本书涵盖的内容

第一章,“过程生成简介”,向我们介绍了过程生成的广阔主题。我一直觉得真正学会某事的关键部分是理解为什么要以这种方式完成。了解如何完成某事固然很重要,但了解其起源以及为什么会以这种方式完成则会创造出更完整的画面和更深刻的理解。在本章中,我们将回到过程生成的诞生以及它进入现代电脑游戏的历程。

第二章,“项目设置和拆分”,解释了如何在您选择的 IDE 中设置提供的流氓式游戏项目,并为 Visual Studio 和 Code::Blocks 提供了详细的说明。它是用 C++/SFML 编写的,我们将在整本书中进行扩展。我们还将介绍您可能遇到的常见问题,并首次运行该项目。

第三章,“使用 C++数据类型进行 RNG”,探讨了随机数生成(RNG),包括围绕它的问题以及我们如何在运行时使用它与 C++数据类型来实现随机结果。RNG 是过程生成的核心,是我们模拟计算机随机行为并通过算法实现动态结果的方式。

第四章,“过程填充游戏环境”,帮助我们通过在地图周围的随机位置生成物品和敌人来进一步开发我们的关卡。在过程生成的游戏中,生成环境是一个基本的部分,而在随机位置生成游戏对象是实现这一目标的重要一步。

第五章,“创建独特和随机的游戏对象”,探讨了我们如何创建独特和随机的游戏对象。在运行时,某些物品将被过程生成,这意味着可能会有大量的可能组合。我们将介绍在前几章中用于实现这一点的技能和技术。我们将把所有这些内容整合在一起,构建一个过程系统!

第六章,“程序生成艺术”,通过摆脱简单地随机设置成员变量,转而创建程序生成的艺术和图形,进一步提升了我们的程序生成工作。我们将为我们的敌人程序生成纹理,并修改关卡精灵,使我们的地牢每一层都具有独特的感觉。

第七章,“程序修改音频”,研究了艺术的近亲音频,使用类似的技术来为我们的声音创建差异。我们还将使用 SFML 的音频功能来创建专门的 3D 声音,为我们的关卡带来更多深度。

第八章,“程序行为和机制”,利用我们迄今为止学到的一切知识,创建复杂的程序行为和机制,如寻路和独特的关卡目标。我们将赋予我们的敌人智能,让他们穿越关卡并追逐玩家。我们还将创建独特的关卡目标,并为玩家执行带来独特的奖励。

第九章,“程序地牢生成”,完成了我们对游戏项目的工作。我们将实现也许是 roguelike 游戏最具代表性的特征:程序生成的关卡。在整本书中,我们一直在使用相同的固定关卡。所以,是时候开始程序生成它们了!我们还将在关卡之间创建一些差异,并实现我们在上一章中创建的目标生成器。

第十章,“基于组件的架构”,介绍了基于组件的设计,因为我们的模板项目的工作现在已经完成。程序生成的关键在于灵活性。因此,我们希望使用最灵活的架构进行工作。基于组件的架构可以实现这一点,对这种设计方法有很好的理解将有助于您未来的进步和构建更大的系统。

第十一章,“结语”,回顾了项目和我们在完成程序生成之旅时涉及的主题。对于我们使用的程序生成的每个领域,我们还将确定一些跳板,以便您希望深入探讨该主题。

您需要什么

在撰写本书的过程中,我使用了适用于 Windows 桌面的 Visual Studio Community 2015。这是一个很棒的 IDE,具有我们创建 Windows 的 C++游戏所需的所有工具。它可以免费从微软获得,因此我强烈建议您下载并在整本书的过程中使用它。

如果您以前从未使用过它,不要担心;我们将详细介绍项目设置,以便您熟悉我们将使用的 IDE 的各个部分。我还将提供 Code::Blocks 的设置说明。如果您选择不使用 IDE,您将需要访问 C++编译器,以便您可以运行我们在书中将要使用的项目。

这本书适合谁

这本书面向那些具有 C++游戏开发知识并希望将程序生成融入其游戏中的人。它将假定对编程基础有相当扎实的理解,如数据类型、返回类型、方法调用等。还假定对游戏开发背后的概念有一定了解,因为我们不会深入研究底层引擎。

提供了一个游戏模板,并且我们将在整本书的过程中使用 SFML 来扩展它。不需要有关 SFML 的先前经验。完成本书后,您将对程序生成的内容是什么,它在游戏中如何使用以及将应用于真实游戏的一系列实用技能有扎实的理解。

惯例

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"我们调用了std::srand()并设置了一个新的种子,但每次运行程序时,我们都再次设置相同的种子"

代码块设置如下:

Stirng myStringLiteral = "hello";
string myString = { 'h', 'e', 'l', 'l', 'o', '\0' };

当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:

// If the enemy is dead remove it.
if (enemy.IsDead())
{
    enemyIterator = m_enemies.erase(enemyIterator);

    // If we have an active goal decrement killGoal.
 if (m_activeGoal)
 {
 --m_killGoal;
 }
}

新术语重要单词以粗体显示。例如,屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的方式出现在文本中:"在 Code::Blocks 中,将以下内容添加到项目的构建选项搜索目录选项卡中"。

注意

警告或重要提示会以这样的方式出现在框中。

提示

提示和技巧会以这种方式出现。

额外练习

每章结束时,都有一些复习问题和进一步的练习可以完成。虽然这对书籍并不是至关重要,但建议您完成它们,以便您可以衡量对所涵盖主题的理解,并获得更多经验。

第一章:程序生成简介

当你在 PC 上加载一张图片、iPod 上的一首歌曲,或者 Kindle 上的一本书时,你是从存储中加载它。那张图片、歌曲和书已经作为一个整体存在,每当你想要访问它时,你就会获取整个之前创建的东西。在音乐或视频的情况下,你可以分块流式传输,但它仍然作为一个整体存在于存储中。让我们将这与从家具店购买现成的桌子进行比较。你得到整个桌子作为一个单一的东西,就是这样;你有了一张桌子。

现在,让我们想象一下,你不是买一个成品桌子,而是买了一个平装的桌子。你得到的不是一个预制的桌子,而是你需要建造一个桌子的所有零件,以及如何做的说明。当你回家后,你可以按照这些说明来建造桌子。如果你愿意,你甚至可以偏离说明,创造出与其他人不同的独特桌子。

让我们在游戏开发的背景下使用这个类比,将购买桌子替换为加载关卡。在第一种情况下,我们加载了整个关卡,因为它是预先构建好的。然而,在第二个例子中,我们得到了所有需要建造关卡的零件,并按照自己选择的顺序将它们组合在一起。

通过算法或程序创建某物的过程,而不是已经存在的东西,被称为程序生成。桌子是通过按照算法将其零件组合而成的。游戏关卡也是如此。这几乎可以扩展到任何东西。例如,音乐、图像、游戏和文本都可以通过程序生成。

在本章中,我们将涵盖以下主题:

  • 程序生成与随机生成

  • 在 C++中生成伪随机数

  • 种子

  • 程序生成的利与弊

  • 罗格式游戏的简史

  • 如何实现程序生成

程序生成与随机生成

在我们继续之前,我想先做一个区分。在这本书中,我们将大量讨论程序生成和随机生成。这些术语经常被互换使用,但它们并不是同一回事。因此,让我们花一点时间来定义它们。

程序生成

程序生成是使用算法创建内容的过程。这本身没有随机元素。如果用于生成内容的函数、表达式、算法和输入保持不变,那么你总是会得到相同的结果。这是因为计算机是确定性的,这是我们很快会讨论的内容。程序生成本身并不具有随机性。

随机生成

当我们给这些算法不同的输入或改变它们的表达时,就会引入随机性。这种变化是导致输出多样性的原因。当有人说某物是程序生成时,他们通常是指利用随机性进行程序生成。

引入随机性

计算机是确定性的机器。这意味着如果你给它们相同的输入,并执行相同的操作,每次都会得到相同的输出。就桌子的例子而言,每个人都得到相同的零件,遵循相同的说明,因此建造出相同的桌子。

再次以游戏的背景来说,如果每个人都得到相同的资产和算法来组合它们,我们都会得到相同的游戏和体验。有时,这是目标。然而,在我们的情况下,我们希望创建不可预测和动态的游戏系统。因此,我们需要在程序生成中引入一定的随机元素。

伪随机数生成

随机数生成只是随机选择一个数字的过程。对我们来说这很简单,但对计算机来说是一项更艰巨的任务。事实上,计算机要生成一个真正的随机数是不可能的,除非有特殊的硬件。你马上就会明白为什么会这样。

下一个最好的选择是伪随机数生成。单词pseudo的字面意思是不真实。因此,伪随机数生成可以被认为是假随机数生成。这些数字看起来是随机的,但实际上是复杂方程和算法的结果,事实上可以提前计算出来。

请记住,并非所有的伪随机数生成器都是一样的。对于诸如普通模拟和游戏之类的应用程序,可以使用相当线性的算法,并且非常适用。然而,伪随机数生成也用于诸如密码学之类的应用程序,将使用更复杂的算法,以便无法通过先前输出创建的模式来确定结果。

我们作为开发者使用的伪随机数生成器属于第一类,并且非常适用。幸运的是,C++提供了多种生成普通伪随机数的方法。在本书的过程中,我们将使用std::rand()std::srand(),它们都是标准 C++函数,包含在<cstdlib>库中。

提示

学习如何阅读和从文档中提取信息是一项我认为经常被忽视的技能。有了众多优秀的论坛,很容易直接去谷歌寻找解决方案,但首先,一定要阅读文档。www.cplusplus.com是一个很好的 C++参考,SFML 在www.sfml-dev.org/documentation/上有完整的文档。

为什么计算机不能生成真正的随机数

我们现在知道计算机不能生成随机数,而是生成伪随机数。让我们看看为什么会这样。

这样做的原因与两台计算机在给定相同输入和操作的情况下会达到相同输出的原因相同;计算机是确定性的。计算机产生的一切都是算法或方程的结果。它们只不过是高度复杂的计算器。因此,你不能要求它们表现得不可预测。

真正的随机数可以生成,但你需要利用机器外部的系统。例如,在www.random.org/ 你可以使用大气噪音生成真正的随机数。还有其他类似的系统,但除非你为安全目的生成随机数,否则普通伪随机数生成就足够了。

在 C++中生成随机数

让我们通过编写一个小程序来生成一些伪随机数来开始编码。为此,我们将使用std::rand()函数。它在0RAND_MAX之间生成一个伪随机整数。RAND_MAX变量是在<cstdlib>中定义的常量。它的值将取决于你使用的库。在标准库实现中,它的值至少为 32767。

提示

如果你已经熟悉这个主题,可以直接跳到名为种子的子章节。

你可以从 Packt 网站www.packtpub.com/support下载这个程序的代码。它将出现在Examples文件夹中,项目名称是random_numbers

// Random number generation
// This program will generate a random number each time we press enter.

#include <iostream>

using namespace std;

int main()
{
  while (true)
  {
    cout << "Press enter to generate a random number:";
    cin.get();

    // Generate a random integer.
    int randomInteger = rand();

    cout << randomInteger << endl << endl;
  }

  return 0;
}

提示

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

这是一个非常简单的控制台应用程序,每次按 Enter 键时都会调用std::rand()。这会返回伪随机数,并将其传递给std::cout以显示它。就是这么简单!

在 C++中生成随机数

在范围内生成随机数

先前的代码生成了介于0RAND_MAX之间的数字。这很好,但通常我们希望更多地控制这一点,以便在特定范围内生成数字。为此,我们将使用模运算符

提示

在 C++中,模运算符是%符号。这在不同的语言之间有所不同,但通常是%Mod

取模运算符返回两个数字之间的除法余数。因此,9 mod 2 是 1,因为 2 可以整除 9 四次,剩下 1。我们可以利用这个来创建伪随机数生成的范围。让我们生成一个介于 0 和 249 之间的数字。

为此,我们需要进行以下更改:

// Generate a random integer.
//int randomInteger = rand();
int randomInteger = rand() % 250;

现在运行程序几次,您会看到所有的结果都限制在我们刚刚定义的范围内。所以现在我们可以生成一个介于 0 和 n 之间的数字,但是如果我们不希望我们的范围从 0 开始怎么办?为此,我们需要对生成数字的行进行一次更改:

// Generate a random integer.
//int randomInteger = rand() % 250;
int randomInteger = rand() % 201 + 50;

记住,我们在模运算中使用的数字将生成一个介于 0 和 n-1 之间的数字,然后我们之后添加的数字将增加该数量的范围。因此,在这里,我们生成一个介于 0 和 200 之间的数字,然后增加 50 来获得一个介于 50 和 250 之间的数字。

提示

如果您对我们在这里所做的事情背后的数学不太了解,请前往 Khan Academy。这是一个学习的绝佳资源,有很多优秀的与数学相关的材料。

运行程序并注意生成的前五个数字。在我的情况下,它们是 91、226、153、219 和 124。现在再次运行。您会注意到发生了一些奇怪的事情;我们收到了完全相同的数字。

它们是以伪随机的方式生成的,对吧?也许这只是一个偶然。让我们再次运行它,看看我们得到了什么。你会再次得到相同的结果。要理解这里发生了什么,我们需要看一下种子

种子

我们刚刚创建了一个生成伪随机数的程序,但每次运行它时,我们都会得到相同的结果。我们知道这些数字是复杂方程和算法的结果,那为什么它们是相同的呢?这是因为每次运行程序时,我们都从相同的种子开始。

定义种子

种子为算法提供了一个起点。因此,在前面的例子中,是的,我们正在使用复杂的算法来生成数字,但我们每次都从相同的点开始算法。无论算法有多复杂,如果您从相同的点开始,并执行相同的操作,您将得到相同的结果。

想象一下,我们有三个人,每个人都要走 5 步相同的路径。如果他们都从同一个方块开始,他们最终会到达同一个方块:

定义种子

现在,在下一个图表中,我们给这三个人不同的起始位置。即使他们做的动作与之前相同,并且在同一路径上,但由于他们从不同的位置开始,他们的结果是不同的:

定义种子

在这个类比中,路径是算法,起始方块是种子。通过改变种子,我们可以从相同的动作中获得不同的结果。

你很可能以前使用过种子,甚至都不知道。像 Minecraft 和乐高世界这样的游戏,在生成世界之前,会给你设置一个种子的选项。如果你的朋友生成了一个看起来很棒的世界,他们可以获取他们的种子并给你。当你自己输入那个种子时,你就像你的朋友一样从同一个位置启动算法,最终得到相同的世界。

使用种子

现在我们知道了种子是什么,让我们修复上一个例子,以便我们不再生成相同的数字。为此,我们将使用std::srand()函数。它类似于std::rand(),但它需要一个参数。这个参数用于设置算法的种子。我们将在进入 while 循环之前调用std::srand()

提示

您只需要在应用程序运行时设置一次种子。一旦调用了std::srand(),所有后续对std::rand()的调用都将基于更新后的初始种子。

更新后的代码应该是这样的:

// Random number generation
// This program will generate a random number each time we press enter.

#include <iostream>

using namespace std;

int main()
{
  // Here we will call srand() to set the seed for future rand() calls.
  srand(100);

  while (true)
  {
    cout << "Press enter to generate a random number:";
    cin.get();

    // Generate a random integer.
    int randomInteger = rand() % 201 + 50;

    cout << randomInteger << endl << endl;
  }

  return 0;
}

现在当我们运行这段代码时,我们得到了不同的结果!我得到了 214、60、239、71 和 233。如果你的数字和我的不完全匹配,不要担心;它们都是 CPU 和供应商特定的。那么如果我们再次运行程序会发生什么呢?我们改变了种子。所以我们应该再次得到不同的数字,对吗?

不完全正确。我们调用了std::srand()并设置了一个新的种子,但每次运行程序时,我们又设置了相同的种子。我们每次都从相同的位置启动算法,所以看到了相同的结果。我们真正想做的是在运行时随机生成一个种子,这样算法总是从一个新的位置开始。

在运行时生成随机种子

有许多方法可以实现这一点,您的用例将决定哪种方法适合。对于我们作为游戏开发者来说,通常一些相对琐碎的东西,比如当前系统时间,就足够了。

这意味着如果你在完全相同的时间运行程序,你会得到相同的结果,但这几乎永远不会成为我们的问题。C++为我们提供了一个很好的函数来获取当前时间,time(),它位于<ctime>中。

让我们最后一次更新程序,并将time()作为参数传递给std::srand(),以便在每次运行时生成唯一的数字:

// Here we will call srand() to set the seed for future rand() calls.
//srand(100);
srand(time(nullptr));

现在,每次运行程序,我们都会得到唯一的数字!你可能已经注意到,如果连续多次运行程序,第一个数字总是与上次运行非常相似。这是因为在运行之间时间变化不大。这意味着起始点彼此接近,结果也反映了这一点。

控制随机性是生成随机数的关键

生成随机数的过程是创建过程生成游戏内容的重要组成部分。有许多生成随机数据的方法,比如噪声地图和其他外部系统,但在本书中,我们将坚持使用这些简单的 C++函数。

我们希望系统足够可预测,以便我们作为开发者控制它们,但它们也应该足够动态,以便为玩家创建变化。这种平衡很难实现,有时游戏会做错。在本章的后面,我们将看一些在将过程生成纳入游戏项目时需要注意的事项,以避免出现这种情况。

在游戏中使用过程生成

现在我们知道了过程生成是什么,以及它是我们添加的随机元素,让我们能够创建动态系统,让我们来看一些游戏中如何使用它的例子。它可以被利用的方式有无数种,以下只是一些主要的实现方式。

节省空间

俗话说,需要是发明之母。作为今天的开发者,我们被我们可以使用的硬件宠坏了。即使是今天最基本的机器也会有一个 500 GB 大小的硬盘作为标准。考虑到仅仅几十年前,那将是 MB 而不是 GB,这是相当奢侈的。

游戏分发在当时也是一个非常不同的游戏。今天,我们要么在物理光盘上购买游戏,蓝光光盘每层提供了惊人的 25 GB,要么从互联网上下载,那里根本没有大小限制。记住这一点,现在考虑一下大多数任天堂娱乐系统NES)游戏的大小仅为 128 到 384 KB!这些存储限制意味着游戏开发人员必须将大量内容放入一个小空间,程序生成是一个很好的方法。

由于过去无法构建大型关卡并存储它们,游戏被设计为通过算法构建它们的关卡和资源。你会把所有需要的资源放在存储介质上,然后让软件在玩家端组装关卡。

希望现在早期的桌子类比更容易理解了。就像平装家具更容易运输,然后可以在家里组装一样。随着硬件的发展,这已经不再是一个问题,但对于早期有存储问题的开发者来说,这是一个很好的解决方案。

地图生成

在现代视频游戏中,程序生成最突出的用途之一是生成游戏地图和地形。它可以被广泛使用,从生成简单的 2D 地图到完整的 3D 世界和地形。

在程序生成 3D 地形时,诸如Perlin 噪声生成的噪声图被用来表示通过产生具有高低浓度区域的图像来代表随机分布。这些数据,浓度和强度的变化,可以以许多方式使用。在生成地形时,它通常用于确定任意位置的高度。

地图生成

复杂的 3D 地形的程序生成超出了本书的范围。然而,我们将在本书的后面生成 2D 地牢。

提示

如果你想探索 3D 地形生成,请阅读诸如“分形地形生成”、“高度图”和“噪声生成”之类的术语。这将让你走上正确的道路。

纹理创建

程序生成的另一个突出例子是纹理的创建。与地形生成类似,纹理的程序生成使用噪声来创建变化。然后可以用来创建不同的纹理。不同的图案和方程也被用来创建更受控制的噪声,形成可识别的图案。

像这样程序性地生成纹理意味着你可以在没有任何存储开销的情况下拥有无限数量的可能纹理。从有限的初始资源池中,可以生成无尽的组合,下面的图像就是一个例子:

纹理创建

Perlin 噪声只是许多常用于程序生成的算法之一。研究这些算法超出了本书的范围,但如果你想进一步探索程序生成的用途,这将是一个很好的起点。

动画

传统上,游戏动画是由动画师创建的,然后导出为一个动画文件,可以直接在游戏中使用。这个文件将存储模型的每个部分在动画期间经历的各种动作。然后在运行时应用到游戏角色上。玩家当前的状态将决定应该播放哪种动画。例如,当你按下A键跳跃时,玩家将变为跳跃状态,并触发跳跃动画。这个系统运行良好,但非常死板。每一步、跳跃和翻滚都是相同的。

然而,程序生成可以用来创建实时的、动态的动画。通过获取角色骨骼的当前位置,并计算施加在它上面的多个力,可以计算出一个新的位置。程序动画最突出的例子是布娃娃物理效果。

声音

尽管不如前面的例子常见,程序生成也被用来创建游戏音效。这通常是通过操纵现有的声音来实现的。例如,声音可以被空间化,意味着当用户听到时,它似乎是来自特定位置。

在某种程度上,可以合成短暂的、一次性的音效,但由于它所带来的好处与实施它所需的工作量相比很少,它很少被使用。加载预制的声音会更容易得多。

注意

Sfxr 是一个小程序,可以从头开始生成随机音效。它的源代码是可用的。因此,如果你对声音合成感兴趣,它将作为一个很好的起点。你可以在github.com/grimfang4/sfxr找到这个项目。

程序生成的好处

我们已经看了一些程序生成在游戏中的关键用途。现在让我们来看看它的一些最重要的好处。

可以创建更大的游戏

如果你的游戏世界是手工建造的,由于种种原因,它将有大小限制。每个物体都需要手动放置,每个纹理/模型都需要手工制作,等等。所有这些都需要时间和金钱。即使是最大的手工制作游戏世界的大小,比如《巫师 3:狂猎》和《侠盗猎车手 V》中所见的那样,也远远不及程序生成的世界可以实现的规模。

如果一个游戏正确地利用程序生成,理论上,世界的大小是没有限制的。例如,《无人之境》是一个设定在一个无限的、程序生成的银河系中的科幻游戏。然而,当你开始制作真正巨大的地图时,硬件成为了一个限制因素。生成的区域需要保存到磁盘中以便重新访问,这很快就会累积起来。例如,要在《我的世界》中生成最大的世界,你将需要大约 409PB 的存储空间来存储关卡数据!

程序生成可以用来降低预算。

制作游戏是昂贵的。非常昂贵。事实上,大多数 AAA 游戏的制作成本高达数千万,甚至数亿美元。在这么高的预算下,任何节省金钱的选择都是受欢迎的。程序生成可以做到这一点。

假设我们正在制作一个需要 100 种砖块纹理的游戏。传统上,你需要让你的艺术家创建每一块砖。虽然它们会有最高质量,但这将耗费时间和金钱。另外,通过利用程序生成技术,你可以让一个艺术家创建一些资源,并使用它们来生成你需要使用的资源。

这只是一个例子,建模、设计等也是如此。以这种方式使用程序生成有利有弊,但这是一个有效的选择。

游戏玩法的多样性增加

如果你的游戏世界是手工制作的,那么玩家的体验将是固定的。每个人都会收集相同的物品,地形都是一样的,因此整体体验也将是一样的。程序生成游戏的显著特点是体验不同。游戏中有一种未知的感觉,每次玩都会有一些新的东西等着你去发现。

增加了可重复性

让我们从上一点继续。如果一个游戏是线性的,没有任何程序生成,那么在玩过一次游戏后挑战就消失了。你知道情节,你知道敌人会在哪里,除非它有一个惊人的故事或机制,否则你不会想再玩一次游戏。

然而,如果你的游戏利用程序生成,那么每次运行游戏时挑战都是新的。游戏总是在不断发展;环境总是新的。如果你看看那些具有最大重玩价值的游戏,它们往往是给玩家最大控制权的游戏。大多数这类游戏都会利用某种形式的程序生成来实现。

程序生成的缺点

和任何事物一样,事情都有两面性。程序生成为游戏带来了无数可能性和增强,但在实施时也需要考虑一些因素。

对硬件的负担更重

正如我们现在所知,程序生成是通过运行算法来创建内容。这些算法可能非常复杂,需要大量的计算能力。如果你开发的游戏大量使用程序生成,你需要确保普通消费者的 PC 或游戏机能够满足其需求。

例如,如果你选择在开放世界游戏中以程序方式生成树木,那么每当该区域需要生成时,CPU 和 GPU 的负担都会很大。性能较差的电脑可能无法胜任,因此游戏可能会出现卡顿。

世界可能会感到重复

另一个潜在的缺点是世界可能会感到重复。如果你允许游戏系统生成非常大的世界,但使用了少量和基本的算法,那么必然会生成很多重复的区域。模式和重复的区域会很容易被发现,这将大大降低游戏的质量。

你牺牲了质量控制

计算机可能比我们人类更快地进行数字计算,但有一件事我们绝对比计算机优秀,那就是创造力。无论程序算法有多么神奇,都无法取代人类的触感。经验丰富的设计师为项目带来的微小变化和细微差别都会因此而牺牲。

这也意味着你无法保证所有玩家都能获得相同的游戏质量。有些玩家可能会生成一个非常棒的地图,有利于游戏进行,而其他人可能生成一个明显阻碍游戏进行的地图。

你可能会生成一个无法玩的世界

在前一点的极端情况下,可能会生成一个完全无法玩的关卡。这种风险取决于你的程序内容生成得有多好,但这一点应该始终被考虑。

在生成 3D 地形地图时,你可能会意外生成一个对玩家来说太高无法攀爬的地形,或者封锁了需要进入的区域。2D 地图也是如此。在本书的后面,我们将随机生成地牢房间。例如,我们需要确保每个房间都有有效的入口和出口。

很难编写固定的游戏事件

继续前面的观点,程序生成是不确定的。如果你周围的整个世界都是纯粹通过程序和随机生成的,那么几乎不可能编写固定的游戏事件。

游戏事件是预先编写的事件,而程序生成的本质是创建未经脚本的世界。让这两者共同工作是一个艰巨的挑战。因此,游戏往往会同时使用程序生成和预先制作的游戏开发。通过这样,你可以得到固定的游戏事件和时刻,这些是驱动叙事所需要的,而在所有这些之间,你可以为玩家创造一个独特和开放的世界,让他们自由地探索和互动。

Rogue-like 游戏的简要历史

由于我们将实现我们所学的内容在一个类似 Rogue 的游戏中,让我们花一点时间来看看它们的历史。了解你所做的事情的起源总是很好的!

Rogue 是一款地牢爬行游戏,最初由Michael ToyGlenn Wichman开发,并于 1980 年首次发布。地牢的每个级别都是随机生成的,其中包括对象的位置。Rogue 定义了地牢爬行类型,并成为许多后续游戏的灵感来源。这就是为什么我们称这种类型的游戏为roguelikes,因为它们确实像 Rogue!

自从诞生以来,程序生成一直是 Roguelike 游戏的关键元素。这就是为什么我选择这种类型的游戏来介绍这个主题。我们将一起重新创建定义这种类型游戏的标志性特征,并以非常实际和动手的方式来处理程序生成。

我们将如何实现程序生成

在书的开头,我简要概述了每一章和我们将在其中涵盖的内容。现在我们已经了解了程序生成是什么,让我们具体看看一些我们将实施它的方式,因为我们努力创建我们自己的 Roguelike 游戏。这个列表并不详尽。

填充环境

当我们第一次加载游戏时,我们的对象将处于固定位置。我们将通过实现本章学到的关于随机数生成的知识来开始我们的努力,以在随机位置生成我们的对象。

在本章的最后,有一些可选的练习,包括在不同范围的集合中生成数字。如果你还不熟悉,我建议完成它们,因为我们将依靠它来实现这一点。

创建独特的游戏对象

程序生成的我个人最喜欢的一个方面是创建独特的对象和物品。知道游戏中有各种各样的物品是很棒的。知道这些物品甚至还不存在,而且可能性是无限的,更好!

我们将从简单地随机初始化对象的成员变量开始,然后逐步提供我们对象独特的精灵和属性。我们还将研究创建动态类,可以从单个基类创建高度独特的对象。

创建独特的艺术

使用程序生成从头开始生成纹理和材料是一个非常庞大的主题。有很多方法可以实现这一点。传统上,我们使用像 Perlin 噪声这样的基础函数,然后用图案和颜色进行扩展。我们不会深入探讨这个话题。相反,我们将使用Simple and Fast Multimedia Library (SFML)的内置图像处理功能,在运行时创建独特的纹理。

从简单的方法开始,我们将改变图像属性,如大小、颜色和比例,以创建现有资产的变化。然后,我们将使用渲染纹理来动态组合多个精灵组件,以创建我们敌人的独特资产。

音频操作

与图形一样,SFML 提供了许多函数,允许我们修改声音。因此,我们将使用这些来改变声音效果的音调和音量,以创建变化。然后,我们将使用高级函数来创建 3D 空间化声音,通过我们的音频为场景带来深度。

行为和机械

不仅是静态物品和资源可以通过程序生成,为了增加游戏玩法的多样性,我们将使用一些程序技术来创建动态的游戏机制。具体来说,我们将创建一个系统,为玩家生成一个随机目标,并在达成目标时提供一个随机奖励。

我们还将给我们的敌人一些基本的人工智能AI),以A 星A*)寻路的形式,让它们能够在关卡中追逐玩家。

地牢生成

在书的最后,一旦我们熟练掌握了使用随机数生成器RNG)和程序系统,以及我们的游戏项目,我们将实现 roguelike 的定义特征;随机生成的地牢。

我已经多次提到程序生成可以用来创建理论上无尽的游戏世界。因此,我们将实现一个系统,我们访问的每个房间都是随机生成的,并且我们将使用我们在后面章节学到的图形操作技术为每个楼层赋予独特的感觉。

基于组件的设计

程序生成就是关于创建动态系统、对象和数据。因此,我们希望拥有最灵活的游戏框架,以便很好地整合这一点。实现这一点的方法之一是组件化设计。因此,最后,我们将快速地看一下它,将我们的项目分解为更多基于组件的方法。

完整的游戏

这些是我们将要实现的主要系统变化。中间会有很多内容,但这些例子将涵盖我们将使用的主要机制和技能。当我们到达书的末尾时,你将拥有一个完全可用的 roguelike 游戏,其中包括一个无尽的随机生成地牢,随机生成的物品出现在随机位置,地牢层中的程序纹理,以及随机敌人,所有这些都是使用灵活的基于组件的架构实现的。

你不仅会学习实现程序生成在你自己的游戏中所需的技能,还会看到它们如何在彼此的背景下运作。孤立的练习很好,但没有什么比在一个真实的例子上工作更好。

练习

为了让你测试本章内容的知识,这里有一些练习供你做。它们对本书的其余部分并不是必需的,但做这些练习将帮助你评估所学内容的优势和劣势。

  1. 使用std::rand()函数和取模运算符(%),更新random_numbers.cpp以生成落在以下范围内的数字:
  • 0 到 1000

  • 150 到 600

  • 198 到 246

  1. 想出一种在运行时生成随机种子的新方法。有很多方法可以做到这一点。所以要有创意!在我的解决方案中,前几个数字总是相似的。看看你是否能生成一个减轻这一点的随机种子。

  2. 看看你的游戏收藏,找出哪些地方使用了程序生成。

  3. 以下哪些是程序生成的例子?

  • 加载一首歌

  • 布娃娃物理

  • 在运行时创建独特的对象

摘要

在本章中,我们了解到程序生成是通过使用算法来创建内容。这个概念可以应用于所有数字媒体,并且在游戏中用于创建动态系统和环境。程序生成带来了更大的游戏、多样性和动态性;但控制力较小,可能会影响性能,因为它对硬件要求较高。现代游戏中程序生成最流行的用途包括地形生成、纹理创建和程序动画。

在下一章中,我们将看一下本书提供的项目。当我们学习创建程序化系统时,我们将在一个真实的游戏项目中实现它们,最终目标是创建一个使用程序生成的游戏,这是一个大量利用程序生成的类型。我们将回顾游戏模板,我们将使用的 SFML 模块,并设置项目。然后,我们将在您的系统上编译它。

如果您熟悉 C++游戏开发并且以前使用过 SFML,您可能已经熟悉下一章中介绍的概念。如果是这种情况,请随意浏览本章,直接进入第三章使用 C++数据类型的 RNG的编程。

第二章:项目设置和分解

在我们自己实现过程生成之前,我们将快速浏览一下本书提供的游戏模板。未来,重点将放在我们创建的过程系统上,而不是底层模板和引擎。因此,在开始之前,熟悉模板和引擎将是有益的。

我们还将看一下Simple Fast Multimedia LibrarySFML),这是我们将要使用的框架。

在本章中,我们将涵盖以下主题:

  • 选择集成开发环境IDE

  • 提供的游戏模板的分解

  • SFML 概述

  • 多态

  • 项目设置和第一次编译

  • 对象管道

选择 IDE

在做任何事情之前,您需要一个可靠的 C++ IDE。您可能已经有自己喜欢使用的 IDE。如果您已经有一个,那很好。但如果没有,这是我喜欢的两个 IDE 的简要摘要。

Microsoft Visual Studio

Microsoft Visual Studio 是微软的行业标准 IDE。它支持多种语言,并提供大量的测试和兼容性工具。它还与许多微软服务绑定在一起,使其成为 Windows PC 上开发的首选。使用 Microsoft Visual Studio 的优缺点如下:

优点:

  • 它有许多免费版本可用

  • Microsoft Visual Studio 支持多种语言

  • 它得到了微软的广泛支持

  • 它具有高度可定制的环境,可通过可停靠窗口进行定制

  • 它具有智能代码补全功能

  • 它与许多微软功能集成

缺点:

  • 其完整版本非常昂贵

  • 其免费版本受限

  • 仅适用于 Windows PC

提示

Microsoft Visual Studio 和其他许多微软技术可供学生免费使用。有关更多信息,请访问www.dreamspark.com/Student/

Code::Blocks

Code::Blocks IDE 是一个免费、开源、跨平台的 IDE,用于 C、C++和 Fortran 编程语言的开发。它建立在插件架构之上,意味着可以通过安装各种插件来高度定制,以创建最适合您需求的 IDE。

优点:

  • 它是免费的

  • 它适用于所有操作系统

  • 通过安装插件,它可以高度定制

  • 它支持多个容器

  • 它具有智能代码补全功能

缺点:

  • 与 Microsoft Visual Studio 提供的功能和工具相比,它具有较少的功能和工具

这两个 IDE 都具有我们在 C++中创建游戏所需的功能。因此,一切都取决于个人偏好。我建议使用 Visual Studio,并且这是我在整本书中将使用的 IDE。

其他 IDE

Visual Studio 和 Code::Blocks 只是众多可用的 IDE 中的两个例子。如果您不喜欢这两个,以下是一些备选的跨平台 IDE。它们都能够开发 C++代码:

  • NetBeans(Windows、Mac OS X 和 Linux)

  • Eclipse(Windows、Mac OS X 和 Linux)

  • Code Lite(Windows、Mac OS X 和 Linux)

构建系统

使用构建系统是使用 IDE 的替代方法。这些系统将构建过程与您使用的 IDE 或代码编辑器分离,使您对过程有更多控制。构建系统允许您自动化构建过程的各个方面。它可能是一些简单的事情,比如递增构建号,或者高级的事情,比如自动化单元测试。

有许多可用的构建系统,包括以下内容:

  • Make

  • CMake

  • MSBuild

  • Gradle

我们不会在书中涵盖这些系统的设置或使用。因此,请前往每个系统的相关网站查找文档和使用说明。

提示

有关构建系统及其提供的好处的更多信息,请访问www.cs.virginia.edu/~dww4s/articles/build_systems.html#make

分解游戏模板

学习的最佳方式是通过实践。例子很好,但没有什么比真正投入并在一个真正的游戏中工作更好。提供的游戏模板将允许我们在一个真正的游戏中实现我们将要学习的系统,而不是它们成为一系列孤立的练习。

熟悉这个模板不仅会帮助使本书中的代码示例更清晰,还会使每章末尾的练习更容易。这也将使您能够在项目完成后使用所学知识来实现自己的系统。

下载模板

在开始之前,请下载游戏模板,以便在浏览一些关键点时可以使用源代码。模板可在 Packt Publishing 官方网站www.packtpub.com/support上下载。

我们很快会设置它,但现在让我们快速查看一些其关键特性。

类图

项目下载包中包含了我们解决方案的完整类图像。如果您在任何时候对模板的结构有任何疑问,请参考该图表。

类图是查看软件完整结构的好方法。随着游戏变得越来越大,继承结构变得越来越复杂。如果您有可用的工具,定期查看类图并保持其结构是一个好主意。这将帮助您确定您的结构需要哪些工作,以及哪些不需要。

提示

在 Microsoft Visual Studio 中创建图表受限于专业版或更高版本。但是,有各种免费工具可用,例如 Doxygen www.stack.nl/~dimitri/doxygen/index.html和 ArgoUML argouml.tigris.org/,它们可以从源代码创建 UML 图表。

对象层次结构

模板中的所有对象都遵循一组继承层次结构。所有类的基础是Object类。这提供了一个sprite,一个position,一个Update()虚函数和一个Draw()虚函数。

所有类都从这个基类扩展,通过覆盖这些虚拟函数来实现它们自己的行为。在我们的main游戏类中,我们为主要基类创建容器,将所有物品和敌人分组到可以轻松迭代的单个集合中:

std::vector<std::unique_ptr<Item>> m_items;
std::vector<std::unique_ptr<Enemy>> m_enemies;

基类指针的向量使我们能够利用多态性,并将从相同父类继承的所有类存储在单个数据结构中。如果您对多态性不熟悉,不要担心。在本章的末尾,我们将研究多态性和对象管道,以将对象添加到游戏中。

提示

我们在 C++11 中使用std::unique_ptr智能指针而不是原始指针。有关智能指针及其好处的更多信息,请访问msdn.microsoft.com/en-us/library/hh279674.aspx

级别数据

提供的游戏模板是一个roguelike模板。鉴于此,级别被描述为一个网格。在这种情况下,表示网格的最佳方式是使用 2D 数组,并且为了存储我们需要的所有信息,我们将使用名为Tile的自定义数据类型,如下所示:

/**
 * A struct that defines the data values our tiles need.
 */ 
struct Tile {
TILE type;         // The type of tile this is.

int columnIndex;   // The column index of the tile.

int rowIndex;      // The row index of the tile.

sf::Sprite sprite; // The tile sprite.

int H;             // Heuristic / movement cost to goal.

int G;             // Movement cost. (Total of entire path)

int F;             // Estimated cost for full path. (G + H)

Tile* parentNode;  // Node to reach this node.
};

这个struct允许我们拥有一个Tile类型的单个 2D 数组,可以存储每个瓦片需要的所有信息。在创建这种类型的游戏时,这种方法非常常见。该数组位于Level类中,在游戏开始时实例化。它封装了与级别相关的所有数据。

目前,级别数据存储在一个简单的文本文件中,在运行时通过对定义所有瓦片类型的枚举进行简单查找来解析。我们将在本章末尾的示例中进行这方面的工作。

以下屏幕截图显示了级别数据是如何保存的:

级别数据

碰撞

碰撞是基于您当前所站的瓦片的ID。每当玩家开始移动时,将计算成功移动后他们将处于的位置。然后使用这个位置来计算他们所在的网格“瓦片”。然后使用这个瓦片来确定应执行什么操作;操作可能涉及执行阻塞移动、拾取物品或受到伤害。

注意

这种类型的碰撞可能导致子弹穿过纸的问题,但鉴于游戏的速度,这在我们的情况下不是问题。如果您不知道这个问题是什么,请在网上查找;它可能在以后的项目中让您出乎意料!

输入

输入是通过自定义的静态Input类处理的。它的工作方式很像 SFML 提供的Input类,但它将多个可能的输入组合成一个调用。例如,当检查左键是否按下时,它将检查A键、左箭头键、左D-Pad 和模拟摇杆。如果使用标准的Input类来完成这个任务,您将不得不分别检查所有四个。提供的Input类简化了这一过程。

input.h中定义了一个公共的键码枚举,并包含以下用于轮询输入的值:

/**
 * An enum denoting all possible input keys.
 */
enum class KEY
{
  KEY_LEFT,
  KEY_RIGHT,
  KEY_UP,
  KEY_DOWN,
  KEY_ATTACK,
  KEY_ESC
};

要检查输入,我们只需静态调用Inputs IsKeyPressed(KEY keycode),传递前面提到的有效键码之一。

SFML 简单快速多媒体库

虽然您可能有 C++的经验,但可能没有 SFML 的先验经验。没关系,本书不假设任何先验经验,所以现在让我们简要地浏览一下它

定义 SFML

SFML,简称Simple and Fast Multimedia Library,是一个软件开发库,提供了对多个系统组件的简单访问。它是用 C++编写的,并分为以下简洁的模块:

  • 系统

  • 窗口

  • 图形

  • 音频

  • 网络

使用这种架构,您可以轻松地选择如何使用 SFML,从简单的窗口管理器到使用 OpenGL,再到完整的多媒体库,能够制作完整的视频游戏和多媒体软件。

为什么我们会使用 SFML

SFML 既是免费的、开源的,又有一个充满活力的社区。在官方网站上有活跃的论坛和一系列优秀的教程,为那些希望学习的人提供了丰富的资源。使用 SFML 的另一个引人注目的原因是它是用 C++编写的,并且有许多其他语言的绑定,这意味着您几乎可以用任何您喜欢的语言编程。您可能会发现您希望使用的语言已经有了绑定!

SFML 最吸引人的特点是它是一个多平台库。使用 SFML 编写的应用程序可以在大多数常见操作系统上编译和运行,包括 Windows、Linux 和 Mac OS X,在撰写本书时,Android 和 iOS 版本即将上市。

提示

为了使您的应用程序跨各种平台兼容,请记住您还必须确保您的本地代码或其他使用的库(如果有的话)也是跨平台兼容的。

学习 SFML

在本书的过程中,我们将研究 SFML 的特点和功能,以实现我们的过程系统,但不会更多。我们不会深入研究这个库,因为那需要一整本书。幸运的是,Packt Publishing 出版了一些专门针对这个问题的好书:

如果您想了解更多关于 SFML 的信息,那么这些书是一个很好的起点。官方 SFML 网站上也有一些很棒的教程和活跃的论坛。访问www.sfml-dev.org/learn.php获取更多信息。

替代方案

虽然 SFML 是跨平台游戏开发的一个很好的选择,但并不是唯一的选择。有许多出色的库可供选择,每个都有自己的方法和风格。因此,虽然我们将在这个项目中使用 SFML,但建议您为下一个项目四处寻找。您可能会遇到您新的最喜欢的库。

以下是一些建议供将来参考:

多态

在开始游戏模板之前,我们将看一下多态。这是面向对象编程的一个重要特性,我们将在许多我们将创建的过程系统中充分利用它。因此,重要的是您不仅要对它有一个扎实的理解,还要了解用于实现它的技术和潜在的陷阱。

提示

如果您已经对多态有很好的理解,可以跳过本节,或者访问msdn.microsoft.com/en-us/library/z165t2xk(v=vs.90)以深入讨论该主题。

多态是通过独立实现的共同接口访问不同对象的能力。这是一个非常正式的定义。因此,让我们将其分解为用于实现它的各种技术和特性。值得注意的是,虽然多态是游戏行业的标准方法,但它仍然是编程的其他学派之一。

继承

继承可能是实现多态的关键组成部分。继承是通过继承其变量和函数来扩展现有类,然后添加自己的内容。

让我们看一个典型的游戏示例。假设我们有一个有三种不同武器的游戏:剑、魔杖和斧头。这些类将共享一些公共变量,如攻击力、耐久度和攻击速度。创建三个单独的类并将这些信息添加到每个类中将是一种浪费,因此我们将创建一个包含所有共享信息的父类。然后,子类将继承这些值并按照自己的方式使用它们。

继承创建了一个“是一个”关系。这意味着由于斧头是从武器继承而来,斧头就是一种武器。在父类中创建一个共同接口,并通过子类以独特的方式实现它的概念是实现多态的关键。

注意

通过接口,我指的是父类传递给子类的函数和变量集合。

下图以简单的类图形式说明了这种情况:

继承

在各个武器中突出显示的Attack()函数都是从Weapon类中定义的单个Attack()函数继承而来的。

提示

为了保持适当的封装和范围,重要的是给予我们的变量和函数正确的可见性修饰符。如果您对此不确定,或者需要一个快速提醒,可以访问msdn.microsoft.com/en-us/library/kktasw36.aspx

虚函数

继续使用通用武器示例,我们现在有一个父类,提供了许多函数和变量,所有子类都将继承。为了能够表示与父类不同的行为,我们需要能够重写父函数。这是通过使用虚函数实现的。

虚函数是可以被实现类重写的函数。为了实现这一点,父类必须将函数标记为虚函数。只需在函数声明前加上 virtual 关键字即可:

Virtual void Attack();

在子类中,我们可以通过提供自己的定义来重写该函数,前提是两个函数的签名相同。这种重写是自动完成的,但是 C++11 引入了override关键字,用于明确指示函数将重写父类的函数。override 关键字是可选的,但被认为是良好的实践,并且建议使用。使用方法如下:

Void Attack() override;

C++11 还引入了final关键字。该关键字用于指定不能在派生类中重写的虚函数。它也可以应用于不能被继承的类。您可以如下使用 final 关键字:

Void Attack() final;

在这种情况下,Attack()函数无法被继承类重写。

纯虚函数

我们刚刚介绍的虚函数允许继承类可选地重写函数。重写是可选的,因为如果在子类中找不到默认实现,父类将提供默认实现。

然而,纯虚函数不提供默认实现。因此,它必须由继承类实现。此外,如果一个类包含纯虚函数,它就变成了抽象类。这意味着它无法被实例化,只有继承类可以,前提是它们为纯虚函数提供了实现。如果一个类从抽象类继承,并且没有为纯虚函数提供实现,那么该类也变成了抽象类。

声明纯虚函数的语法如下:

Virtual void Attack() = 0;

Weapon父类的例子中,它被SwordAxeWand继承,将Weapon设为抽象类是有意义的。我们永远不会实例化Weapon对象;它的唯一目的是为其子类提供一个公共接口。由于每个子类都需要有一个Attack()函数,因此在Weapon中将Attack()函数设为纯虚函数是有意义的,因为我们知道每个子类都会实现它。

指针和对象切片

多态谜题的最后一部分是指针的使用。考虑以下两行代码:

Weapon myWeapon = Sword();
Std::unique_ptr<Weapon> myWeapon = std::make_unique<Sword>();

在第一行中,我们没有使用指针;在第二行中,我们使用了指针。这似乎是一个小差别,但它产生了极其不同的结果。为了正确演示这一点,我们将看一个定义了多种武器的小程序。

提示

如果Weapon类包含一个纯虚函数,前面代码的第一行将无法编译,因为它是抽象的,无法实例化。

您可以从 Packt Publishing 网站下载此程序的代码。它将在Examples文件夹中,项目名称为polymorphism_example

#include <iostream>

// We're using namespace std here to avoid having to fully qualify everything with std::
using namespace std;

int main()
{

  // Here we define a base Weapon struct.
  // It provides a single data type, and a method to return it.
  struct Weapon
  {
    string itemType = "Generic Weapon";

    virtual string GetItemType()
    {
      return itemType;
    }
  };

  // Here we inherit from the generic Weapon struct to make a specific Sword struct.
  // We override the GetItemType() function to change the itemType variable before returning it.
  struct Sword : public Weapon
  {
    string GetItemType() override
    {
      itemType = "Sword";
      return itemType;
    }
  };

  Weapon myWeapon = Sword();

  // output the type of item that weapon is then wait.
  cout << myWeapon.GetItemType().c_str() << endl;
  std::cin.get();

  return 0;
}

在这段代码中,我们创建了一个基本结构Weapon。然后我们从中继承,创建了一个名为Sword的具体实现。基本Weapon结构定义了GetItemType()函数,而Sword重写它以更改并返回物品类型。这是一个很简单的继承和多态的例子,但有一些重要的事情我们需要知道,否则可能会让我们困惑。

目前,代码中Weapon对象是这样实例化的:

Weapon myWeapon = Sword()

让我们运行代码,看看我们得到了什么:

指针和对象切片

尽管我们为myWeapon分配了一个Sword对象,但它是一个Weapon对象。这里发生了什么?问题在于myWeapon被赋予了一个固定类型的武器。当我们尝试为它分配一个Sword对象时,它被传递给Weaponcopy构造函数并被切割,只留下一个Weapon对象。因此,当我们调用GetItemType()函数时,我们调用的是Weapon中的函数。

提示

有关对象切割的更深入解释,请访问www.bogotobogo.com/cplusplus/slicing.php

有两种方法可以链接 SFML:静态动态库。静态库是编译到可执行文件中的库。这意味着您的可执行文件会更大,但您不必担心在运行时获取库。动态库不会链接到可执行文件中,这会导致可执行文件更小,但会创建依赖关系。

  // Create our weapon object.
  //Weapon myWeapon = Sword();
 std::unique_ptr<Weapon> myWeapon = std::make_unique<Sword>();

提示

unique_ptr这样的智能指针需要include <memory>。所以不要忘记将其添加到文件的顶部。

既然我们现在把myWeapon改成了指针,我们还需要改变以下内容:

// Output the type of item that weapon is then wait.
//cout << myWeapon.GetItemType().c_str() << endl;
cout << myWeapon->GetItemType().c_str() << endl;

在使用指针时,我们需要使用->运算符来访问它的变量和函数。现在,让我们重新运行代码,看看输出是什么:

指针和对象切割

下载 SFML

由于myWeapon现在是指向Weapon对象的指针,我们避免了对象切割。由于Sword是从Weapon派生出来的,指向内存中的Sword并不是问题。它们共享一个公共接口,因此我们实现了这种重写行为。回到最初的定义,多态性是通过独立实现的公共接口访问不同对象的能力。

接下来,您需要为您的编译器选择正确的软件包。如果您使用 Microsoft Visual Studio,您只需要选择与您版本匹配的年份,如果您使用 Code::Blocks,或者其他任何 IDE,选择您正在使用的GNU 编译器集合(GCC)的版本。

本书提供了一个专门为本书创建的roguelike游戏的模板。它被设计为接收我们将要涵盖的工作,并且在本书结束时,您将拥有一个完全功能的 roguelike 游戏,实现了您将学到的一切。现在我们已经复习了我们对多态性的理解,让我们开始设置模板。第一步是下载并链接 SFML。

提示

所提供的项目链接了 SMFL 32 位 Windows 库。这应该适合大多数系统。如果这与您的系统兼容,您可以跳过以下步骤。

下载 SFML

SFML 有许多不同的预编译软件包可用。例如,在撰写本书时的最新版本仅在 Windows 上就有 12 个软件包可用,因此重要的是您为您的系统下载正确的软件包。以下步骤将帮助您下载并设置 SFML:

  1. 访问www.sfml-dev.org/download.php查找 SFML 下载页面。除非您特别需要针对 64 位机器,否则选择 32 位库。32 位程序在 64 位机器上可以正常工作。

  2. 这一次,我们按照预期调用了Sword结构中的重写函数,这归结为我们如何定义myWeapon

  3. 一旦确定了适合您系统的正确版本,请下载并提取.zip文件的内容到您想要保存 SFML 的位置。这个位置与您的项目无关;它们不需要共享一个目录。

提示

如果您希望或需要这样做,可以自己构建 SFML 以创建自定义软件包。有关如何执行此操作的说明,请访问github.com/SFML/SFML

为了避免这种情况并充分利用多态性,我们需要使用指针。让我们对代码进行以下更改:

链接 SFML

提示

有关staticdynamic库之间的区别的更多信息,请访问www.learncpp.com/cpp-tutorial/a1-static-and-dynamic-libraries/

我们将进行动态链接,这意味着要运行游戏,您将需要.dll文件。

为此,首先从 SFML 源中将游戏需要的DLL文件复制到项目的可执行位置。将所有文件从<sfml-install-path/bin>复制到<project-location/Debug>

接下来,我们必须告诉编译器 SFML 头文件在哪里,链接器输出库在哪里。头文件是.hpp文件,库是.lib文件。这一步根据您使用的 IDE 有所不同。

在 Microsoft Visual Studio 中,将以下内容添加到项目的属性中:

  • SFML 头文件的路径(<sfml-install-path>/include)到C/C++ | General | Additional Include Directories

  • SFML 库的路径(<sfml-install-path>/lib)到Linker | General | Additional Library Directories

在 Code::Blocks 中,将以下内容添加到项目的Build OptionsSearch Directories选项卡:

  • SFML 头文件的路径(<sfml-install-path>/include)到Compiler搜索目录

  • SFML 库的路径(<sfml-install-path>/lib)到Linker搜索目录

提示

这些路径在DebugRelease配置中是相同的。因此,它们可以全局设置为项目。

最后一步是将我们的项目链接到正在使用的 SFML 库。SFML 由五个模块组成,但我们不会使用所有模块。我们使用SystemWindowsGraphicsAudio。因此,我们只需要链接到这些库。与上一步不同,项目配置很重要。DebugRelease配置有单独的库。因此,您需要确保链接正确的库。

Debug配置中,我们需要添加以下库:

  • sfml-system-d.lib

  • sfml-window-d.lib

  • sfml-graphics-d.lib

  • sfml-audio-d.lib

现在,对于Release配置做同样的事情。但是,从每个中删除-d。例如,在Debug配置中添加sfml-system-d.lib,在Release配置中添加sfml-system.lib

要将它们添加到 Microsoft Visual Studio 中,必须通过导航到Linker | Input | Additional Dependencies将它们添加到项目的属性中。

要将它们添加到 Code::Blocks 中,必须在Linker Settings选项卡下的项目构建选项的Link Libraries列表中添加它们。

提示

如果您对此设置有任何疑问,请访问www.sfml-dev.org/learn.php获取完整的详细信息以及图片。

运行项目

现在 SFML 已链接到我们的项目,我们应该准备进行第一次构建。以下截图显示了我们目前空白的地牢游戏:

运行项目

目前,我们有一个可以运行的应用程序,在一个固定的房间中生成一个玩家。第一个任务涉及添加一个项目。

添加一个项目

我们创建的所有项目都需要继承自基类Item,因为所有游戏项目都存储在std::unique_ptr<Item>类型的单个向量中。通过这种数据结构,我们可以利用多态性,并将所有项目子类存储在一个结构中;通过这种方式,我们可以更新和绘制每个项目。

要添加到这个向量中,只需通过唯一指针实例化一个新项目。然后,使用.push_back()方法将其添加到向量中。由于我们使用的是唯一指针,因此必须使用std::move()来实现。

提示

如果您不清楚为什么我们在这里必须使用std::move,请在互联网上搜索唯一指针。

Game::PopulateLevel函数中,让我们添加一个宝石项目,如下所示:

// Create a gem object.
std::unique_ptr<Gem> gem = std::make_unique<Gem>();

// Set the gem position.
gem->SetPosition(sf::Vector2f(m_screenCenter.x + 50.f, m_screenCenter.y));

// Add the gem to our collection of all objects.
m_items.push_back(std::move(gem));

我们所要做的就是通过一个独特的指针创建一个新对象,给它一个位置,然后使用 std::move 函数将其添加到关卡中所有物品的列表中。简单!

更新和绘制

一旦物品被添加到所有对象的向量中,它将自动更新:

// Update all items.
UpdateItems(playerPosition);

这个函数遍历所有的物品,检查它们是否被收集;如果不是,就更新它们。每个对象的 Update() 函数都有一个名为 timeDelta 的参数。这是一个包含自上次更新以来经过的时间的浮点数。它在主游戏循环中用于保持游戏逻辑固定在 60 fps。

提示

要了解更多关于主游戏循环的信息,请访问 gafferongames.com/game-physics/fix-your-timestep/,这是一个关于这个主题的很棒的文章。

物品的绘制方式类似;它们的容器只是在 Game::Draw 函数中进行迭代。循环如下:

// Have all objects draw themselves. 
for (const auto& item : m_items) 
{ 
    item->Draw(m_window, timeDelta); 
}

m_window 变量是一个指向渲染窗口的指针。因此,我们将它传递给每个对象,以便它可以用它来绘制自己。

现在,如果你运行游戏,你会看到房间里的宝石和金子,就像下面的截图所示:

更新和绘制

练习

为了帮助你测试本章内容的知识,这里有一些练习题供你练习。它们对于本书的其余部分并不是必要的,但是练习它们将有助于你评估所涵盖材料的优势和劣势。

  1. 为你的游戏创建一个名称,并更改主窗口的文本以反映这一变化。

  2. 考虑以下代码:

class A
{
public:
    int x;
protected:
    int y;
private:
    int z;
};

class B : protected A
{

};

B 类中 xyz 的可见性是什么?

  1. 在关卡中添加更多物品。

总结

在本章中,我们做了一些准备工作,以便开始编写游戏并创建程序系统。我们看了看将要使用的软件和库,以及我们将扩展的游戏模板。我们还快速学习了多态性和实现它的技术。

我们现在准备开始创建我们自己的程序系统。我们刚刚介绍的基础工作并不是非常令人兴奋,但对于理解我们将要涉及的工作至关重要。在下一章中,我们将利用我们在 C++ 数据类型中学到的关于随机数生成的知识来生成随机物品,并给我们的玩家随机属性。

第三章:使用 C++数据类型进行 RNG

在第一章中,程序化生成简介,我们了解到伪随机数生成是随机程序生成的核心。请记住,程序化系统本质上不是随机的,我们需要引入随机性。为了开始我们的旅程,我们将研究一系列不同的 C++数据类型,并使用随机数生成器(RNG)在运行时为它们赋予随机值。在随机但仍受控的方式下使用核心 C++数据类型的能力将成为我们未来所有系统的基础。

在本章中,我们将涵盖以下主题:

  • 设置游戏种子

  • 枚举器

  • 随机设置布尔值

  • 访问数组中的随机元素

  • 生成随机字符串

  • 随机数分布

设置游戏种子

在做任何事情之前,我们需要设置游戏种子。如果没有种子,我们每次运行游戏时都会得到相同的结果。正如我们所学的,这只需要我们调用std::srand()函数并传递一个随机参数作为种子。我们将使用当前系统时间作为我们的种子,对我们的目的来说已经足够随机了。

我们对std::srand()函数的调用是任意的,只要在对std::rand()函数的任何调用之前调用它即可。文件main.cpp包含了函数main(),这是应用程序的入口点。我们将在这里调用std::srand()函数。

我们更新后的main()函数现在应该是这样的:

// Entry point of the application.
int main()
{
    // Set a random seed.
    std:: srand(static_cast<unsigned int>(time(nullptr)));

    // Create the main game object.
    Game game;

    // Create a Boolean that we can store out result it.
    bool result;

    // Initialize and run the game object.
    result = game.Initialize();

    if (result)
    {
        game.Run();
    }

    // Shutdown and release the game object.
    game.Shutdown();

    // Exit the application.
    return 0;
}

现在每次运行游戏时,我们都会设置一个随机种子,因此我们对std::rand()的调用会产生唯一的结果。

提示

如果您希望游戏在运行之间保持一致,可以使用硬编码的值作为种子。只是不要忘记改回来,否则以后会想为什么事情不随机!

随机设置布尔值

也许最简单的数据类型是谦卑的布尔值。只有两种状态,true 和 false,应该不难随机设置!当表示为整数时,这两种状态具有以下属性:

  • False = 0 或更低

  • True = 1 或更高

因此,要随机分配一个布尔值,我们只需要生成数字 0 或 1。

生成 0 到 1 之间的数字

在第一章中,程序化生成简介,我们介绍了在特定范围内生成随机数。现在我们将把它用起来。使用std::rand()函数,我们将生成一个介于 0 和 1 之间的数字:

std::rand() % 2;

提示

请记住,std::rand()生成一个介于0RAND_MAX之间的数字。然后我们计算该结果除以 2 的余数。这样就只剩下了 0 和 1 的范围。

bool不一定要用truefalse关键字设置。您可以将整数赋给bool,其状态将由整数的值决定,使用前面规定的规则。小于 1 的任何数字都是 false,大于 0 的任何数字都是 true。这意味着我们可以直接将结果传递给 bool:

bool myBool = std::rand() % 2;

将这些放在一起,我们可以创建一个简单的控制台应用程序,每次用户按下Enter键时都会随机输出 true 或 false。

您可以从 Packt Publishing 网站下载此程序的代码。它将在Examples文件夹中,项目名称为random_boolean

#include <iostream>

using namespace std;

int main()
{
  // Loop forever.
  while (true)
{
    // Generate a number between 0 and 1.
    bool myBool = rand() % 2;
    if (myBool)
    {
        cout << "true";
    }
    else
    {
        cout << "false";
    }
    return 0;
}

这段代码的输出结果如下:

生成 0 到 1 之间的数字

每次我们按下Enter键,我们都会得到一个随机的布尔值。即使是这种简单的随机生成也可以让我们开始构建我们的程序化地牢游戏。让我们立即将其应用到房间创建时物品的生成上。

提示

请记住,在这个小例子应用程序中,我们没有随机设置种子。因此,每次运行程序时,该程序将生成相同的值序列。

选择物品是否生成

当前,当我们启动游戏时,宝石和黄金物品总是会生成。让我们使用这个随机布尔赋值来决定是否创建这两个物品。为了实现这一点,我们将封装它们的生成代码在一个if语句中,其参数将是我们随机布尔赋值的结果。

Game::PopulateLevel方法是我们生成物品的地方。我们将用以下代码替换当前的代码:

// Populate the level with items.
void Game::PopulateLevel()
{
    // A Boolean variable used to determine if an object should be spawned.bool canSpawn;

    // Spawn gold.
    canSpawn = std::rand() % 2;
    if (canSpawn)
    {
       std::unique_ptr<Gold> gold = std::make_unique<Gold>();
       gold->SetPosition(sf::Vector2f(m_screenCenter.x - 50.f, m_screenCenter.y));
       m_items.push_back(std::move(gold));
    }

    // Spawn a gem.
    canSpawn = std::rand() % 2;
    if (canSpawn)
    {
       std::unique_ptr<Gem> gem = std::make_unique<Gem>();
       gem->SetPosition(sf::Vector2f(m_screenCenter.x + 50.f, m_screenCenter.y));
       m_items.push_back(std::move(gem));
    }
}

现在,每次我们运行游戏,宝石和黄金是否生成都是随机的。

选择物品是否生成

这是一个简单的改变,但是创建程序生成游戏的第一步。没有单一的算法或函数可以使游戏程序化。这是一系列小技巧的集合,比如这样的技巧可以使系统在运行时不可预测和确定。

随机数分配

让我们在随机数生成的基础上分配随机数字。我们首先生成 0 到 100 之间的n个数字。如果我们把它们加在一起,我们就得到一个随机总数,其中我们的每个单独的数字代表了一个百分比。然后我们可以取得我们目标数字的百分比来得到一个随机部分。以下代码演示了这一点,并会让它更清晰。

您可以从 Packt 网站下载此程序的代码。它将在Examples文件夹中,项目名称为random_distribution

#include <iostream>

using namespace std;

// Entry method of the application.
int main()
{
  // Create and initialize our variables.
  int upperLimit = 0;

  // Output instructions.
  cout << "Enter a number, and we'll split it into three random smaller numbers:" << endl;
  cin >> upperLimit;
  cout << endl;

  float number1Bias = rand() % 101;
  float number2Bias = rand() % 101;
  float number3Bias = rand() % 101;

  float total = number1Bias + number2Bias + number3Bias;

  // Output the numbers.
  cout << upperLimit * (number1Bias / total) << endl;
  cout << upperLimit * (number2Bias / total) << endl;
  cout << upperLimit * (number3Bias / total) << endl;

  // Pause so we can see output.
  cin.get();
  cin.get();

  // Exit function.
  return 0;
}

这种方法确保了数字的每个部分都是完全随机的。需要考虑一个轻微的舍入误差,但这对我们的应用程序不是问题。

随机数分配

让我们不浪费时间,将这项新技能应用到游戏中!

给玩家随机属性

这种随机分配数字的经典方式是给玩家随机属性。传统上,游戏中的角色会获得n个属性点,由玩家来分配。由于我们正在制作一个程序生成游戏,我们将随机分配它们,以创建程序生成的角色属性。

为此,我们需要将以前的代码与玩家属性变量的赋值连接起来。我们的玩家属性目前是固定的,并且是以下方式分配的:

m_attack = 10;
m_defense = 10;
m_strength = 10;
m_dexterity = 10;
m_stamina = 10;

让我们用以下代码替换它来随机分配属性。我们还会给玩家添加一个变量,这样我们就可以改变玩家有多少stat点可以分配。

首先,将以下变量添加到玩家中,并不要忘记将其添加到我们的初始化列表中:

int m_statPoints;

现在让我们使用这个来给我们的玩家随机属性:

// Randomly distribute other stat.
m_statPoints = 50;

float attackBias = std::rand() % 101;
float defenseBias = std::rand() % 101;
float strengthBias = std::rand() % 101;
float dexterityBias = std::rand() % 101;
float staminaBias = std::rand() % 101;

float total = attackBias + defenseBias + strengthBias + dexterityBias + staminaBias;

m_attack += m_statPoints * (attackBias / total);
m_defense += m_statPoints * (defenseBias / total);
m_strength += m_statPoints * (strengthBias / total);
m_dexterity += m_statPoints * (dexterityBias / total);
m_stamina += m_statPoints * (staminaBias / total);

每次我们加载游戏时,我们的玩家的属性点都是随机分配的。这种随机分配一定数量的方法可以用在很多其他地方,比如在玩家之间分享战利品,或者在多个实体之间分配伤害。

给玩家随机属性

访问集合中的随机元素

当我们有类似对象的集合时,它们通常存储在数组和向量等结构中。通常在处理这些结构时,我们访问特定的元素,它们的统一性和顺序使它们有用。

要访问特定的元素,我们只需提供它在集合中的索引。因此,要访问数组的一个随机元素,我们只需提供一个随机索引,这只是生成一个随机数的简单情况。

让我们看一个例子。在下面的例子中,我们创建了一个字符串向量,其中我们填充了动物的名字。每次我们按回车键,我们通过生成一个 0 到向量大小之间的数字来访问向量的一个随机元素。

您可以从 Packt 网站下载此程序的代码。它将在Examples文件夹中,项目名称为random_element

#include <iostream>
#include <vector>

using namespace std;

// Entry method of the application.
int main()
{
  // Create and populate an array of animals.
  vector<string> animals = { "Dog", "Cat", "Bird", "Fox", "Lizard" };

  // Output the instructions.
  cout << "Press enter for the name of a random animal!" << endl;

  // Loop forever.
  while (true)
  {
    // Wait for user input.
    cin.get();

    // Generate a random index.
    int randomIndex;
    randomIndex = rand() % animals.size();

    // Output the name of the randomly selected animal.
    cout << animals[randomIndex].c_str();
  }

  // Exit function.
  return 0;
}

输出如下:

访问集合中的随机元素

访问集合的随机元素是创建程序系统的一个很好的工具。在游戏中的任何地方,只要有一个对象,您都可以创建一个备用的数组或向量,并在运行时随机选择一个。仅凭这一点,您就可以创建一个高度随机化的游戏,每次运行都是独一无二的。

生成随机物品

目前,当我们加载游戏时,设置物品会被生成。我们需要添加一些随机性,一个简单的switch语句就足够了。在可能的情况下,我们总是希望添加选项来创建随机和程序生成的内容。

要随机生成我们的物品,我们需要生成一个介于0和我们拥有的物品数量之间的随机数,然后在switch语句中使用它。如前所述,没有一种方法可以进行程序生成,因此还有其他方法可以实现这一点。

让我们添加数字生成和switch语句来选择要生成的物品。更新后的Game::PopulateLevel函数应该如下所示:

// Populate the level with items.
void Game::PopulateLevel()
{
    // A Boolean variable used to determine if an object should be spawned.
    bool canSpawn;

    // Spawn an item.
    canSpawn = std::rand() % 2;
    if (canSpawn)
    {
        int itemIndex = std::rand() % 2;
        std::unique_ptr<Item> item;
        switch (itemIndex)
        {
            case 0:
                item = std::make_unique<Gold>();
            break;

            case 1:
                item = std::make_unique<Gem>();
            break;
        }
        item->SetPosition(sf::Vector2f(m_screenCenter.x, m_screenCenter.y));
        m_items.push_back(std::move(item));
    }
}

现在我们可以看到,当我们运行游戏时,如果可以生成一个物品,它将是金色物品或宝石。我们在游戏中有很多物品,在下一章中,我们将扩展此系统以包括它们所有,从一个函数中填充整个级别:

生成随机物品

生成随机字符

由于我们已经介绍了从固定词汇表生成随机字符串,让我们看看如何生成随机字符。char数据类型是一个单个的,一个字节的字符。

字符串实际上只是一个以空字符结尾的字符序列,所以下面的代码行产生了完全相同的结果:

Stirng myStringLiteral = "hello";
string myString = { 'h', 'e', 'l', 'l', 'o', '\0' };

同样,以下代码在语义上是正确的:

char myCharArray[6] = { 'h', 'e', 'l', 'l', 'o', '\0' };
string stringVersion = myCharArray;

由于char是一个字节,它具有 0 到 255 的可能整数表示。每个这些十进制值代表一个不同的字符。在 ASCII 表中可以找到查找表。例如,字符a的十进制值为97。我们可以在分配char时使用这些整数,如下所示:

char myChar = 97;

提示

在 C++中,char的最大十进制值是 255。如果超过这个值,它将溢出并通过表格循环。例如,将 char 值设置为 353 将导致字符a。 ASCII 表可以在www.asciitable.com/找到。

因此,要生成一个随机字符,我们需要生成一个介于 0 和 255 之间的数字,这是我们现在非常熟悉的。

您可以从 Packt 网站下载此程序的代码。它将在Examples文件夹中,项目名称为random_character

#include <iostream>

using namespace std;

// Entry method of the application.
int main()
{
  // Loop forever.
  while (true)
  {
    // Output instructions.
    cout << "Press enter to generate a random character from the ASCII standard:" << endl;

    // Pause for user input.
    cin.get();

    // The ASCII characters range from 0 - 127 in decimal.
    int randInt = rand() % 128;

    // To turn that into a char, we can just assign the int.
    char randChar = randInt;

    // Output the random char.
    cout << "Random Char: " << randChar << "\n" << endl;
  }

  // Exit function.
  return 0;
}

通过这段代码,我们从整个 ASCII 表中生成一个随机字符。要在更具体的范围内生成字符,我们只需要限制我们生成的数字范围。

例如,查看 ASCII 表可以看到小写字母表从 97 开始,直到 122。让我们调整随机数生成器,只生成这个范围内的值:

// The ASCII characters range from 0 - 127 in decimal.
//int randInt = rand() % 128;
int randInt = std::rand() % 128;
int randInt = std::rand() % 26 + 97;

现在我们可以看到输出只是小写字母表中的字母,如下面的屏幕截图所示:

生成随机字符

重复循环

生成随机数的另一个用途是循环执行一定次数的代码。例如,当我们生成物品时,我们对生成代码进行单独调用。如果我们只想每次生成一个物品,这是可以的,但是当我们想生成随机数量的物品时怎么办。

我们需要随机调用我们的代码,稍后我们将把它封装在自己的函数中,这可以通过for循环实现。在for循环中,我们指定循环迭代的次数,所以我们可以生成一个随机数来代替使用固定值。每次运行代码时,都会生成一个新的随机数,循环每次的大小都会不同。

您可以从www.packtpub.com/support下载此程序的代码。它将在Chapter 3文件夹中,名为random_loops.cpp

// Include our dependencies.
#include <iostream>
#include <ctime>

// We include std so we don't have to fully qualify everything.
using namespace std;

void HelloWorld();

// Entry method of the application.
int main()
{
  // First we give the application a random seed.
  srand(time(nullptr));

  // Loop forever.
  while (true)
  {
    // Output the welcome message.
    cout << "Press enter to iterate a random number of times:" << endl;

    // Pause for user input.
    cin.get();

    // Generate a random number between 1 and 10.
    int iterations = rand() % 10 + 1;

    // Now loop that number of times.
    for (int i = 0; i < iterations; i++)
    {
      cout << "Iteration " << i << ": ";
      HelloWorld();
    }

    // Output ending message.
    cout << endl << "We made " << iterations << " call(s) to HelloWorld() that time!" << endl << endl;
  }

  // Exit function.
  return 0;
}

// Outputs the text Hello World!.
void HelloWorld()
{
  cout << "Hello World!" << endl;
}

输出显示在以下截图中:

重复循环

生成随机数量的物品

在我们的Game::PopulateLevel函数中生成物品,并且能够随机调用函数的次数,让我们更新代码,以便在游戏开始时生成随机数量的物品。

为了实现这一点,我们只需要像在上一个练习中一样创建相同的循环,并将我们的生成代码封装在其中。让我们用以下代码更新Game::PopulateLevel

// Populate the level with items.
void Game::PopulateLevel()
{
  // A Boolean variable used to determine if an object should be spawned.
  bool canSpawn;

 // Generate a random number between 1 and 10.
 int iterations = std::rand() % 10 + 1;

 // Now loop that number of times.
 for (int i = 0; i < iterations; i++)
 {
 // Spawn an item.
 canSpawn = std::rand() % 2;

    if (canSpawn)
    {
      int itemIndex = std::rand() % 2;
      std::unique_ptr<Item> item;

      switch (itemIndex)
      {
      case 0:
        item = std::make_unique<Gold>();
        break;

      case 1:
        item = std::make_unique<Gem>();
        break;
      }

      item->SetPosition(sf::Vector2f(m_screenCenter.x, m_screenCenter.y));
      m_items.push_back(std::move(item));
    }
  }
}

现在当我们运行代码时,会生成一堆物品。它们目前是在彼此之上生成的,但不用担心,我们将在下一章中解决这个问题!

生成随机数量的物品

练习

为了让您测试本章内容的知识,这里有一些练习题,您应该完成。它们对本书的其余部分并不是必不可少的,但通过完成它们,您可以评估自己在所涵盖材料上的优势和劣势。

  1. 为随机字符串生成器添加更多选项。尝试创建一个使用两个随机单词的生成器。

  2. 修改随机字符生成程序,以便生成大写字母 A-Z 和小写字母 a-z 的字符。

  3. 玩家目前是在水平中的固定位置生成的。创建一组可能的生成坐标,并在运行时随机选择它们,以便生成位置有所变化。

总结

在本章中,我们已经了解了一系列 C++数据类型,并将 RNG 与它们的使用结合起来。以随机但受控的方式使用这些数据类型的能力是实现随机程序系统的关键。记住,程序生成只是根据计算结果创建内容。这并不是自然而然的随机,我们必须像本章中所做的那样引入随机性。我们对游戏所做的增加很小,但是是创建程序生成游戏的第一步。当我们运行游戏时,每次都会有一点不同。

在下一章中,我们将通过在地图周围的随机位置生成物品和敌人来进一步开发我们的水平。程序生成的环境是程序生成游戏中的一个重要部分,将游戏对象生成在随机位置是实现这一目标的重要一步。

第四章:程序化填充游戏环境

现在我们已经熟悉了使用核心 C++数据类型的随机数生成器RNG),让我们看看如何创建一个高度随机化的环境。这将包括随机生成和定位物品、敌人等。在本章中,我们还将触及随机地图生成,然后在本书末尾直面挑战。

物体生成的方式在很大程度上取决于你的级别数据的基础设施。对于大多数 2D 游戏,你可以采取与本章演示的类似的方法,如果不是完全相同的方法。然而,3D 游戏需要更多的工作,因为有一个额外的维度需要处理,但原则仍然是有效的。

在本章中,我们将涵盖以下主题:

  • 在程序化填充环境时的障碍

  • 定义生成区域

  • 随机选择游戏tile

  • 在随机位置生成物品

  • 程序化生成环境的变化

潜在障碍

随机生成游戏环境并不像看起来那么简单。不仅仅是在级别范围内生成一个随机数。虽然这在技术上可能有效,但那里没有控制,因此生成的环境将有许多缺陷。物体可能重叠,位于无法到达的地方,或者按照不好的顺序布置。为了生成有意义且可玩的级别,需要更多的控制。

保持在一个级别的范围内

我相信我们都玩过一个物品生成在我们触及不到的地方的游戏。当在地图周围随机生成物体时,物体生成在触及不到的地方是非常令人恼火的。因此,建立准确的边界以内可以生成物体是很重要的。

正如你所想象的,这项任务的复杂性将与你的环境的复杂性相匹配。对我们来说,我们的级别被描述为一个简单的 2D 数组。因此,计算边界是相当容易的。

避免物体重叠

即使你完美地定义了你的级别边界,你还没有成功。环境通常不是空的,大部分都充满了风景和其他游戏对象。在选择随机生成坐标时,重要的是要考虑这些对象,以免在其中生成对象,再次将物品推出玩家的触及范围之外。

同样,我们不必太担心这一点,因为我们将有简单的没有风景的级别。

创建有意义的级别

说来话长,级别必须是有意义的。即使我们避免生成玩家无法触及的物品,也不会互相重叠,但如果它们都生成在一个遥远的角落,那也不好。

我们需要在我们的 RNG 操作的范围内创建合适的参数,以便我们对结果保持适当的控制。这是程序化生成游戏的一个主要陷阱。一次又一次,你会看到一个级别并不合理,因为算法产生了一个奇怪的结果。

级别瓦片

在我们开始使用“级别”网格之前,我们需要知道它是如何设置的!我们的“级别”被描述为一个自定义类型Tile的 2D 数组,这是在Level.h中定义的一个struct

// A struct that defines the data values our tiles need.
struct Tile
{
TILE type;          // The type of tile this is.
int columnIndex;    // The column index of the tile.
int rowIndex;       // The row index of the tile.
sf::Sprite sprite;  // The tile sprite.
int H;              // Heuristic / movement cost to goal.
int G;              // Movement cost. (Total of entire path)
int F;              // Estimated cost for full path. (G + H)
Tile* parentNode;   // Node to reach this node.
};

现在不要担心最后四个值;当我们到达寻路部分时,我们会在稍后使用它们!现在,我们只需要知道每个tile结构存储其类型,在 2D 数组中的位置和其精灵。所有可能的tile类型都在Util.h中的枚举器中定义,如下所示:

// All possible tiles.
enum class TILE {
  WALL_SINGLE,
  WALL_TOP_END,
  WALL_SIDE_RIGHT_END,
  WALL_BOTTOM_LEFT,
  WALL_BOTTOM_END,
  WALL_SIDE,
  WALL_TOP_LEFT,
  WALL_SIDE_LEFT_T,
  WALL_SIDE_LEFT_END,
  WALL_BOTTOM_RIGHT,
  WALL_TOP,
  WALL_BOTTOM_T,
  WALL_TOP_RIGHT,
  WALL_SIDE_RIGHT_T,
  WALL_TOP_T,
  WALL_INTERSECTION,
  WALL_DOOR_LOCKED,
  WALL_DOOR_UNLOCKED,
  WALL_ENTRANCE,
  FLOOR,
  FLOOR_ALT,
  EMPTY,
  COUNT
};

这给每个tile类型一个字符串常量。因此,我们可以使用这些值而不是使用模糊的数字。有了这个,让我们开始吧。

定义生成区域

现在我们知道了前方的障碍,以及级别数据是如何存储的,让我们看看如何在我们的roguelike对象中随机生成物品的位置。

计算级别边界

第一步是计算级别边界。由于我们正在制作一个 2Droguelike对象,描述为一个 2D 数组,我们需要确定适合生成物品的 tile。如果这是为了一个 3D 游戏,你还需要考虑第三个轴。虽然我们可以找到地图的左上角点并计算到右下角的距离,但这几乎肯定会引起问题。

我们之前提到过,重要的是物品生成在有效的级别区域内。如果我们采用这种简单的方法,就有可能在墙壁上生成物品。以下伪代码显示了如何实现这一点:

  for (int i = 0; i < GRID_WIDTH; ++i)
  {
    for (int j = 0; j < GRID_HEIGHT; ++j)
    {
      m_grid[i][j].markAsSpawnable();
    }
  }

如果我们在游戏中使用这种简单的方法,下面的截图显示了生成区域:

计算级别边界

正如我们所看到的,所创建的生成区域超出了可玩级别区域,尽管它在技术上是在级别边界内。

检查底层游戏网格

在我们的情况下,最简单的方法是检查底层游戏网格。由于级别网格中的每个地板 tile 都有一个唯一的 tile 类型,表示它是什么类型的 tile,我们可以遍历级别网格,并只标记具有有效类型的 tile 作为可能的生成位置。前面的伪代码已经被修改和更新,以便进行这个检查:

for (int i = 0; i < GRID_WIDTH; ++i)
{
    for (int j = 0; j < GRID_HEIGHT; ++j)
    {
        if (m_grid[i][j].type == TILE::FLOOR || m_grid[i][j].type == TILE::FLOOR_ALT)
        { 
            m_grid[i][j].markAsSpawnable();
        }
    }
}

如果我们进行这样的检查,我们最终会得到以下可能的生成区域:

检查底层游戏网格

如您所见,这是一个更好的生成物品区域。下一步是在这个区域内选择一个点作为生成位置。

选择一个合适的游戏 tile

现在,为了找到合适的 tile,我们将生成随机的生成坐标。我们知道所有具有TILE::FLOORTILE::FLOOR_ALT类型的 tile 都是地板 tile。因此,我们可以随机选择一个 tile,并推断它是否适合生成物品。

为了避免自己进行这些检查,项目提供了Level::IsFloor函数。它相当不言自明;你可以传递一个 tile 或其索引,如果它是一个地板 tile,它将返回 true。从现在开始,我们将使用它来检查生成物品的 tile 是否有效。

随机选择一个 tile

我们将首先看的功能是从底层网格中选择一个值。在我们的情况下,级别数据是用 2D 数组描述的。因此,我们只需要生成一个随机列和一个行索引。

提示

记住,这个范围是行数和列数-1,因为所有索引都从 0 开始。如果我们有一个有 10 行和 10 列的网格,那么它们的编号是 0 到 9,总共是 10。

以下是一些伪代码,用于生成一个具有 10 行和 10 列的 2D 数组的随机索引:

// Generate random indices.
int randomColumn = std::rand() % 10;
int randomRow = std::rand() % 10;

// Get the tile of the random tile.
Tile* tile = m_level.GetTile(randomColumn, randomRow);

要从级别中获取Tile对象,我们只需要调用Level::GetTile函数并传递随机生成的索引。

检查一个 tile 是否合适

要检查一个tile是否有效,我们可以使用之前看过的Level::IsFloor函数。以下伪代码将实现这一点:

// Get the type of the random tile.
Tile* tile = m_level.GetTile(1, 1);

// Check if the tile is a floor tile.
if (m_level.IsFloor(*tile))
{
  // tile is valid
}

转换为绝对位置

现在我们可以在游戏网格中选择一个有效的tile,我们需要将该位置转换为绝对屏幕位置。要将索引转换为相对于网格的位置,我们只需要将它们乘以游戏中一个 tile 的宽度。在我们的情况下,tile 的大小是 50 个方形像素。例如,如果我们在网格中的位置是[1][6],相对于网格的位置将是 50*300。

现在我们只需要将网格的位置添加到这些值中,使它们成为相对于我们窗口的绝对坐标。将网格位置转换为绝对位置的做法将会派上用场。所以让我们将这种行为封装在自己的函数中。

Level.h中,添加以下代码:

/**
 * Returns the position of a tile on the screen.
 */
sf::Vector2f GetActualTileLocation(int columnIndex, int rowIndex);

Level.cpp中,添加以下函数的定义:

sf::Vector2f Level::GetActualTileLocation(int columnIndex, int rowIndex)
{
    sf::Vector2f location;

    location.x = m_origin.x + (columnIndex * TILE_SIZE) + (TILE_SIZE / 2);
    location.y = m_origin.y + (rowIndex * TILE_SIZE) + (TILE_SIZE / 2);

    return location;
}

在随机位置生成物品

现在,让我们将所有这些内容联系起来,在地图中随机生成物品。以下是我们将采取的步骤的快速概述:

  1. level数据中选择一个随机“瓷砖”。

  2. 检查这个瓷砖是否是“地板”瓷砖。如果不是,返回到步骤 1。

  3. 将瓷砖位置转换为绝对位置并将其提供给物品。

第一步是在level数据中选择一个随机瓷砖。在本章的前面,我们已经介绍了如何实现这一点:

// Declare the variables we need.
int columnIndex(0), rowIndex(0);
Tile tileType;

// Generate a random index for the row and column.
columnIndex = std::rand() % GRID_WIDTH;
rowIndex = std::rand() % GRID_HEIGHT;

// Get the tile type.
tileType = m_level.GetTileType(columnIndex, rowIndex);

现在我们需要检查随机选择的瓷砖是否适合生成物品。我们知道可以通过检查瓷砖的类型来做到这一点,但我们需要将其纳入某种循环中,以便如果随机选择的瓷砖不合适,它将再次尝试。为了实现这一点,我们将随机选择瓷砖的代码包装在一个while语句中,如下所示:

// Declare the variables we need.
int columnIndex(0), rowIndex(0);

// Loop until we select a floor tile.
while (!m_level.IsFloor(columnIndex, rowIndex))
{
    // Generate a random index for the row and column.
    columnIndex = std::rand() % GRID_WIDTH;
    rowIndex = std::rand() % GRID_HEIGHT;
}

提示

值得注意的是,在这里使用 while 循环并不适合所有类型的游戏。在我们的游戏中,可以生成物品的区域比不能生成的区域更多。因此,可以很容易地找到有效位置。如果情况不是这样,适合生成位置很少,那么 while 循环可能会无限期地阻塞游戏,因为它在循环中寻找区域。请极度谨慎地使用 while 语句。

现在,此代码将循环,直到找到一个合适但仍然随机的“瓷砖”,我们可以在其中生成物品。这非常有用,很可能会被多次重复使用。因此,我们将为该代码创建一个名为Level::GetRandomSpawnLocation的专用函数,如下所示:

/**
 * Returns a valid spawn location from the currently loaded level
 */
sf::Vector2f GetRandomSpawnLocation();

现在,将以下代码添加到新函数的主体中:

// Returns a valid spawn location from the currently loaded level.
sf::Vector2f Level::GetRandomSpawnLocation()
{
    // Declare the variables we need.
    int rowIndex(0), columnIndex(0);

    // Loop until we select a floor tile.
    while (!m_level.IsFloor(columnIndex, rowIndex))
    {
        // Generate a random index for the row and column.
        columnIndex = std::rand() % GRID_WIDTH;
        rowIndex = std::rand() % GRID_HEIGHT;
    }

    // Convert the tile position to absolute position.
    sf::Vector2f tileLocation(m_level.GetActualTileLocation(columnIndex, rowIndex));

    // Create a random offset.
    tileLocation.x += std::rand() % 21 - 10;
    tileLocation.y += std::rand() % 21 - 10;

    return tileLocation;
}

请注意,在函数的结尾,我们添加了一个return语句。当找到合适的“瓷砖”时,我们使用之前添加的函数获取绝对位置,然后返回该值。我们还对物品的坐标添加了随机偏移量,以便它们不都固定在所在“瓷砖”的中心位置。

现在我们有一个函数,它将返回在级别中适合生成位置的绝对坐标。非常方便!最后一步是将此函数合并到Game::PopulateLevel生成函数中。

目前,我们已经手动设置了物品的位置。要使用新函数,只需用Level::GetRandomSpawnLocation()函数的结果替换固定值:

    item->SetPosition(sf::Vector2f(m_screenCenter.x, m_screenCenter.y));
    item->SetPosition(m_level.GetRandomSpawnLocation());
    m_items.push_back(std::move(item));
}

现在,每次创建物品时,其位置将随机生成。如果现在运行游戏,我们将看到物品随机分布在级别中,但只在有效的瓷砖上,玩家可以到达的瓷砖上:

在随机位置生成物品

扩展生成系统

在上一章中,我们介绍了枚举器的使用;我们将在这里充分利用它。我们将把物品“生成”代码分解为自己专用的函数。这将使我们更好地控制如何填充级别。我们还将扩展此系统以包括所有物品和敌人!

使用枚举器表示对象类型

构建此系统的第一步是查看物品。在Util.h中,所有物品类型都在以下枚举器中描述:

// Spawnable items.
enum class ITEM {
  HEART,
  GEM,
  GOLD,
  POTION,
  KEY,
  COUNT
};

在决定生成哪些物品时,我们将从这些枚举值中选择随机值。

可选参数

在此系统中,我们将使用的另一种技术是使用可选参数。默认情况下,该函数将在随机位置生成物品,但有时我们可能希望使用固定位置覆盖此行为。这可以通过使用可选参数来实现。

考虑以下函数声明:

void TestFunction(OBJECT object, sf::Vector2f position);

从此声明创建的TestFunction()函数需要传递需要生成坐标。我们可以只传递等于{0.f, 0.f}sf::Vector值并忽略这些值,但这有点混乱。

可选参数是在函数声明中给定默认值的参数。如果在函数调用中没有提供这些参数,将使用默认值。让我们以以下方式重写相同的函数声明,这次利用可选参数:

void TestFunction(OBJECT object, sf::Vector2f position = { -1.f, -1.f } );

提示

另一种方法是创建两个不同的函数。一个函数带有参数,另一个函数没有;您可以给它们不同的名称以突出差异。

现在,position变量的默认值是{-1.f, -1.f}。因此,如果在函数调用中没有传递值,将使用这些默认值。这是我们需要生成函数的行为。因此,考虑到这一点,让我们声明一个名为Game::SpawnItem的新函数,如下所示:

/**
 * Spawns a given item in the level.
 */
void SpawnItem(ITEM itemType, sf::Vector2f position = { -1.f, -1.f });

设置了默认值后,现在需要确定是否应该使用它们。为了检查这一点,我们只需评估position变量的xy值。如果xy保持为-1.f,那么我们知道用户没有覆盖它们,并且希望随机生成值。然而,如果xy不是-1.f,那么它们已经被覆盖,我们应该使用它们。

提示

我使用-1.f作为默认参数,因为它是一个无效的生成坐标。默认参数应该让您轻松确定它们是否已被覆盖。

以下代码将选择一个随机的生成位置:

// Choose a random, unused spawn location if not overridden.
sf::Vector2f spawnLocation;
if ((position.x >= 0.f) || (position.y >= 0.f))
{
    spawnLocation = position;
}
else
{
    spawnLocation = m_level.GetRandomSpawnLocation();
}

由于position变量是可选的,以下函数调用都是有效的:

SpawnITem(GOLD);
SpawnITem(GOLD, 100.f, 100.f);

完整的生成函数

现在,让我们把所有这些放在一起,创建SpawnItem()函数,如下所示:

// Spawns a given object type at a random location within the map. Has the option to explicitly set a spawn location.
void Game::SpawnItem(ITEM itemType, sf::Vector2f position)
{
    std::unique_ptr<Item> item;

    int objectIndex = 0;

    // Choose a random, unused spawn location.
    sf::Vector2f spawnLocation;

    if ((position.x >= 0.f) || (position.y >= 0.f))
    {
        spawnLocation = position;
    }
    else
    {
        spawnLocation = m_level.GetRandomSpawnLocation();
    }

    // Check which type of object is being spawned.
    switch (itemType)
    {
        case ITEM::POTION:
            item = std::make_unique<Potion>();
        break;

        case ITEM::GEM:
            item = std::make_unique<Gem>();
        break;

        case ITEM::GOLD:
            item = std::make_unique<Gold>();
        break;

        case ITEM::KEY:
            item = std::make_unique<Key>();
        break;

        case ITEM::HEART:
            item = std::make_unique<Heart>();
        break;
    }

    // Set the item position.
    item->SetPosition(spawnLocation);

    // Add the item to the list of all items.
    m_items.push_back(std::move(item));
}

为了测试新函数,我们可以以以下方式更新Game::PopulateLevel函数:

if (canSpawn)
{
  int itemIndex = std::rand() % 2;
 SpawnItem(static_cast<ITEM>(itemIndex));
  std::unique_ptr<Item> item;

  switch (itemIndex)
  {
  case 0:
    item = std::make_unique<Gold>();
    break;

  case 1:
    item = std::make_unique<Gem>();
    break;
  }

  item->SetPosition(sf::Vector2f(m_screenCenter.x, m_screenCenter.y));
  item->SetPosition(m_level.GetRandomSpawnLocation());
  m_items.push_back(std::move(item));
}

这可能看起来是为了一个看似不影响游戏玩法的小改变而做了很多工作,但这是重要的。软件应该以易于维护和可扩展的方式构建。现在这个系统已经建立,我们可以通过一个函数调用生成一个物品。太棒了!

游戏的快速运行确认了代码按预期工作,并且我们迈出了朝着完全程序化的环境迈出了一大步,如下截图所示:

完整的生成函数

更新生成代码

现在Game::SpawnItem函数已经启动运行,让我们稍微重构一下Game::PopulatelLevel函数。在Game.h中,让我们声明以下静态const

static int const MAX_ITEM_SPAWN_COUNT = 50;

我们可以使用这个常量来代替for循环的硬编码限制。这样做的目的是从代码中删除所有硬编码的值。如果我们在这里硬编码一个值而不使用const,每次想要更改值时都必须手动更改。这既耗时又容易出错。使用const,我们只需更改它的值,这将影响到它被使用的每个实例。

现在我们已经熟悉了函数的功能,可以整理一些变量,如下所示:

// Populate the level with items.
void Game::PopulateLevel()
{
    // Spawn items.
    for (int i = 0; i < MAX_ITEM_SPAWN_COUNT; i++)
    {
        if (std::rand() % 2)
        {
            SpawnItem(static_cast<ITEM>(std::rand() % 2));
        }
    }
}

整理好了这些,现在我们可以将这种方法扩展到生成敌人到关卡中!

随机生成敌人

现在我们可以生成游戏中的物品,让我们使用相同的系统来生成敌人!我们将首先定义一个Game::SpawnEnemy函数,如下所示:

/**
 * Spawns a given enemy in the level.
 */
void SpawnEnemy(ENEMY enemyType, sf::Vector2f position = { -1.f, -1.f });

另外,声明另一个静态const来限制我们可以生成的敌人的最大数量:

  static int const MAX_ENEMY_SPAWN_COUNT = 20;

有了这个声明,我们现在可以添加函数的定义。它将类似于Game::SpawnItem函数,只是不再通过物品枚举中的值进行切换,而是创建在以下枚举中定义的敌人:

// Enemy types.
enum class ENEMY {
  SLIME,
  HUMANOID,
  COUNT
};

让我们添加这个定义:

// Spawns a given number of enemies in the level.
void Game::SpawnEnemy(ENEMY enemyType, sf::Vector2f position)
{
    // Spawn location of enemy.
    sf::Vector2f spawnLocation;

    // Choose a random, unused spawn location.
    if ((position.x >= 0.f) || (position.y >= 0.f))
    {
        spawnLocation = position;
    }
    else
    {
        spawnLocation = m_level.GetRandomSpawnLocation();
    }

    // Create the enemy.
    std::unique_ptr<Enemy> enemy;

    switch (enemyType)
    {
        case ENEMY::SLIME:
            enemy = std::make_unique<Slime>();
        break;
        case ENEMY::HUMANOID:
            enemy = std::make_unique<Humanoid>();
        break;
    }

    // Set spawn location.
    enemy->SetPosition(spawnLocation);

    // Add to list of all enemies.
    m_enemies.push_back(std::move(enemy));
}

现在,要调用这个函数,我们需要回到Game::Populate函数,并添加另一个循环,以类似于创建物品的方式创建敌人:

// Populate the level with items.
void Game::PopulateLevel()
{
    // Spawn items.
    for (int i = 0; i < MAX_ITEM_SPAWN_COUNT; i++)
    {
        if (std::rand() % 2)
        {
            SpawnItem(static_cast<ITEM>(std::rand() % 2));
        }
    }

    // Spawn enemies.
    for (int i = 0; i < MAX_ENEMY_SPAWN_COUNT; i++)
    {
        if (std::rand() % 2)
        {
            SpawnEnemy(static_cast<ENEMY>(std::rand() % static_cast<int>(ENEMY::COUNT)));
        }
    }
}

有了这个,物品和敌人将在整个级别随机生成。这个系统非常灵活和简单。要添加另一个物品或敌人,我们只需要将其添加到相关的枚举器中,并添加相应的switch语句。这是在生成程序内容和系统时所需要的灵活方法。

让我们运行游戏,看看填充的级别:

随机生成敌人

生成随机瓷砖

环境特征的生成将在这里简要介绍,因为本书的最后一章专门讨论了程序生成游戏地图。这是我们的最终目标。因此,为了开始,我们将生成一些表面的环境特征,以备后来随机生成级别。

添加一个新的tile到游戏中将大大增加级别的多样性。程序生成的一个问题是环境可能会感觉过于不自然和通用。因此,这将有助于避免这种情况。

让我们将以下声明添加到Game.h中:

/**
 * Spawns a given number of a certain tile at random locations in the level.
 */
void SpawnRandomTiles(TILE tileType, int count);

这个函数有两个参数。一个允许我们指定我们想要生成的tile索引,另一个允许我们指定数量。我们本可以跳过创建一个函数,直接在Game::PopulateLevel函数中硬编码行为,这样也可以工作,但不能用于其他用途。

然而,通过我们的方法,我们可以轻松地重用代码,指定需要使用的tile和我们希望生成的瓷砖数量。如果我们使用随机数来确定这些值,我们甚至可以在系统中获得更多的程序生成和随机性。在编写程序系统时,始终牢记这一点,并尽量避免使用硬编码的值。即使最终可能不会使用,也要创建选项。

添加一个新的游戏瓷砖

下一步是在级别对象中添加新的tile资源,Level::AddTile()函数就是这样做的。在Game::Initialize中,我们将调用这个函数并添加一个新的tile,如下所示:

// Add the new tile type to level.
m_level.AddTile("../resources/tiles/spr_tile_floor_alt.png", TILE::FLOOR_ALT);

这个函数有两个参数,即resourcepathtile应该具有的ID参数值。在这种情况下,我们使用TILE::FLOOR_ALT值。

选择一个随机瓷砖

如果我们要在级别中随机生成瓷砖,我们需要首先在游戏网格中选择一个随机的地板瓷砖。幸运的是,我们已经编写了代码来做到这一点;它在Level::GetRandomSpawnLocation()函数中。因此,我们可以使用这段代码并将其添加到新的函数中。我们还为需要创建的瓷砖数量创建了一个参数。因此,我们将把所有内容都放在一个for循环中,以便正确重复这个过程的次数。

让我们给这个函数一个定义,如下所示:

// Spawns a given number of a given tile randomly in the level.
void Game::SpawnRandomTiles(TILE tileType, int count)
{
    // Declare the variables we need.
    int rowIndex(0), columnIndex(0), tileIndex(0);

    // Loop the number of tiles we need.
    for (int i = 0; i < count; i++)
    {
        // Declare the variables we need.
        int columnIndex(0), rowIndex(0);

        // Loop until we select a floor tile.
        while (!m_level.IsFloor(columnIndex, rowIndex))
        {
            // Generate a random index for the row and column.
            columnIndex = std::rand() % GRID_WIDTH;
            rowIndex = std::rand() % GRID_HEIGHT;
        }

        // Now we change the selected tile.
        m_level.SetTile(columnIndex, rowIndex, tileType);
    }
}

一旦我们找到一个有效的地板瓷砖,我们就可以将其类型更新为传递的类型。

实现 SpawnRandomTiles 函数

最后一步是调用Game::SpawnRandomTiles。这个函数依赖于已经存在的级别网格。因此,我们将在Game::Initialize函数的末尾调用它,如下所示:

// Change a selection of random tiles to the cracked tile sprite.
SpawnRandomTiles(TILE::FLOOR_ALT, 15);

提示

我在这里硬编码了参数,但为了使它更随机,你可以生成随机数来代替它们。我把这留作本章的一个练习!

现在只需运行游戏,看看我们的工作在下面的截图中的效果。我们可以看到,原来地板是单一瓷砖的地方,现在是随机分布的破碎瓷砖,我们可以通过我们设计的函数来控制精灵和它们的数量:

实现 SpawnRandomTiles 函数

练习

为了帮助你测试本章内容的知识,这里有一些练习,你应该去做。它们对本书的其余部分并不是必须的,但是做这些练习将帮助你评估自己在所学内容上的优势和劣势:

  1. 向游戏中添加一个新物品。然后,将其与生成系统连接起来,以便它可以与现有物品随机生成。

  2. 向游戏中添加你自己的tile。将其与生成代码连接起来,并更改底层级别网格,使玩家无法穿过它。

  3. 检查在调用Game::SpawnRandomTiles()时我们创建的瓦片数量是否是硬编码的:

// change a selection of random tiles to the cracked tile sprite
this->SpawnRandomTiles(tileIndex, 15);

在运行时使用 RNG 生成一个计数。

  1. 现在我们有了 Game::SpawnItem 函数,更新我们的敌人物品掉落以使用它。

  2. 由于我们现在有一个函数来计算实际的瓦片位置,更新我们的火炬生成代码,这样我们就不需要自己进行位置计算了。

总结

在本章中,我们实现了 RNG 来在关卡中以程序方式生成合适的生成位置,并将这一行为封装在自己的函数中。然后我们使用这个函数在地图周围的随机位置生成物品和敌人。

在下一章中,我们将讨论创建独特的、随机生成的游戏对象。在运行时,某些物品将以程序方式生成,这意味着可能会有几乎无限数量的可能组合。在前几章中,我们介绍了用于实现这一点的技能和技术,现在是时候把它们整合起来,建立我们自己的程序系统!

第五章:创建独特和随机的游戏对象

在本章中,我们将使我们的类更加随机。在第三章中,我们涉及了类似的主题,通过给玩家随机的统计数据,所以我们将继续沿着这条路走下去,构建更大、更多功能的程序类。

随机生成游戏物品是为游戏带来多样性和可重玩性的好方法。例如,《无主之地》中的所有武器都是随机生成的;每个箱子和战利品掉落都会包含一个独特的物品。这给游戏带来了一种未知的元素,每次找到一个物品时都不知道它可能是什么。

在本章中,我们将涵盖以下主题:

  • 给对象随机精灵

  • 为我们的玩家生成随机特质

  • 随机分配统计数据

  • 程序生成一系列游戏物品

创建一个随机的玩家角色

在第三章,“使用 C++数据类型进行 RNG”,我们给了我们的玩家随机的统计数据。让我们继续进一步发展player对象。我们将给我们的player一个随机的职业,并使用这个来设置一个合适的精灵和统计数据。我们还将给玩家随机的特质,这将增强某些统计数据。

选择玩家职业

让我们首先为玩家分配一个随机的职业。第一步是定义一个枚举器,它将定义可能的职业。我们将把这个放在Util.h中的其他枚举器中:

// Player classes.
enum class PLAYER_CLASS {
  WARRIOR,
  MAGE,
  ARCHER,
  THIEF,
  COUNT
};

现在,在player类的构造函数中,我们将随机选择其中一个类。为此,我们需要生成一个从 0 到 3 的数字,并将其用作枚举器中的索引。我们还将创建一个变量来保存选择,以防以后使用。

我们将从Player.h中声明变量,如下所示:

/**
 * The player's class.
 */
PLAYER_CLASS m_class;

提示

我们不能将这个变量称为“class”,因为它是 C++中的关键字。在命名变量时要牢记关键字,以避免这种冲突

在构造函数中,让我们生成随机索引并设置类如下:

// Generate a random class.
m_class = static_cast<PLAYER_CLASS>(std::rand() % stat-ic_cast<int>(PLAYER_CLASS::COUNT));

就是这么简单。现在每次创建玩家时,都会选择一个随机的职业,这可以用来实现不同的行为和外观。

精灵和纹理概述

在我们开始处理对象的精灵之前,让我们花点时间看看我们的游戏是如何处理精灵和纹理的。您可能已经知道,要在 SFML 中绘制对象,我们需要一个精灵和一个纹理资源。当我们想要改变精灵时,我们实际上只需要改变sf::sprite持有引用的sf::Texture对象。鉴于此,精灵存储在它们所属的对象中,而纹理存储在单个“静态纹理管理器类”中。

“纹理”是一种昂贵且沉重的资源,因此将它们全部放在一个对象中,并仅通过引用与它们交互,是理想的。这意味着我们不必担心它们的移动或使对象变得沉重。 TextureManager类的使用方式如下:

  • 要向游戏添加“纹理”,我们静态调用TextureManager::AddTexture,并传递我们想要加载的精灵的路径,该函数返回管理器类中纹理的索引。

  • 要从manager中获取“纹理”,我们静态调用TextureManager::GetTexture,将我们想要的“纹理”的ID作为唯一参数传递。作为回报,如果存在,我们将得到对“纹理”的引用。

这对我们的游戏意味着,我们不再将“纹理”存储在对象中,而是存储它们的纹理管理器 ID。每当我们想要实际的“纹理”时,我们只需调用先前描述的TextureManager::GetTexture函数。

提示

“纹理资源管理器”类还做了一些其他聪明的事情,比如避免两次加载相同的纹理。我建议您查看该类,并在自己的游戏中采用相同的方法,以确保资源得到正确处理。

设置适当的精灵

现在player类已经生成了一个随机类,让我们更新精灵以反映这一点。玩家是有动画的,因此有一个包含在数组中定义的八个纹理 ID 的集合。

目前,玩家加载相同的固定纹理集:

// Load textures.
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_UP)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_walk_up.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_DOWN)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_walk_down.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_RIGHT)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_walk_right.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_LEFT)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_walk_left.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_UP)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_idle_up.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_DOWN)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_idle_down.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_RIGHT)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_idle_right.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_LEFT)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_idle_left.png");

让我们更新这样,如果我们生成一个战士,我们将加载战士纹理,如果我们加载一个法师,我们将加载法师纹理,依此类推。这可以通过简单地使用玩家的类在switch语句中加载适当的纹理来实现。

然而,这将创建大量重复的代码:

// Load textures.
switch (m_class)
{
    case PLAYER_CLASS::WARRIOR:
    m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_LEFT)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_walk_left.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_UP)] = TextureManager::AddTexture("../resources/players/warrior/spr_warrior_idle_up.png");
    . . .
    break;

    case PLAYER_CLASS::MAGE:
    . . .
    m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_LEFT)] = TextureManag-er::AddTexture("../resources/players/mage/spr_mage_walk_left.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_UP)] = TextureManag-er::AddTexture("../resources/players/mage/spr_mage_idle_up.png");
    . . .

对于每种类别,我们将重复相同的代码,唯一的变化是资源中类别的名称。考虑到这一点,我们可以从更好的角度来处理这个问题,并在运行时生成资源路径。

提示

在阅读以下代码之前,请尝试自己实现这个。如果遇到困难,代码总是在这里,你甚至可以想出自己的方法!

我们将声明一个字符串变量,可以保存类的名称,并通过对玩家的类执行switch语句来设置这个变量。然后我们可以使用这个变量来加载纹理,而不是固定的类名:

std::string className;

// Set class-specific variables.
switch (m_class)
{
case PLAYER_CLASS::WARRIOR:
  className = "warrior";
  break;

case PLAYER_CLASS::MAGE:
  className = "mage";
  break;

case PLAYER_CLASS::ARCHER:
  className = "archer";
  break;

case PLAYER_CLASS::THIEF:
  className = "thief";
  break;
}

// Load textures.
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_UP)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_walk_up.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_DOWN)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_walk_down.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_RIGHT)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_walk_right.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_LEFT)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_walk_left.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_UP)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_idle_up.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_DOWN)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_idle_down.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_RIGHT)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_idle_right.png");
m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_LEFT)] = TextureManager::AddTexture("../resources/players/" + className + "/spr_" + className + "_idle_left.png");

现在,每次加载游戏时,玩家将是一个随机类,并且有一个匹配的精灵来显示,如下截图所示。

设置适当的精灵

现在玩家类已经设置,我们可以更新 UI 和玩家投射物以反映它。为此,我们需要从玩家那里获取玩家类。因此,让我们首先向玩家类添加一个简单的 getter 函数。不要忘记声明:

// Returns the player's class.
PLAYER_CLASS Player::GetClass() const
{
 return m_class;
}

这些都是简单的改变;我们可以切换玩家的类,并在每种情况下加载正确的精灵,而不是固定的代码。让我们从投射物开始。这个精灵设置在Game::Initialize中,现在我们所要做的就是为类选择正确的精灵:

// Load the correct projectile texture.
//m_projectileTextureID = TextureManager::AddTexture("../resources/projectiles/spr_sword.png");

switch (m_player.GetClass())
{
case PLAYER_CLASS::ARCHER:
 m_projectileTextureID = TextureManager::AddTexture("../resources/projectiles/spr_arrow.png");
 break;
case PLAYER_CLASS::MAGE:
 m_projectileTextureID = TextureManager::AddTexture("../resources/projectiles/spr_magic_ball.png");
 break;
case PLAYER_CLASS::THIEF:
 m_projectileTextureID = TextureManager::AddTexture("../resources/projectiles/spr_dagger.png");
 break;
case PLAYER_CLASS::WARRIOR:
 m_projectileTextureID = TextureManager::AddTexture("../resources/projectiles/spr_sword.png");
 break;
}

现在,让我们继续进行玩家 UI。在屏幕左上角,我们有玩家的统计数据,其中一个精灵显示了玩家。由于类是动态的,我们需要相应地更新这个精灵。这个精灵设置在Game::LoadUI中,并且它将以与我们设置投射物的方式相似的方式设置。我们将把这留给你自己完成。

增强玩家统计数据

现在玩家有了一个类,我们可以做的另一件事是相应地增强统计数据。我们将在分配玩家的统计点之前给某些值一个初始值。

我们已经有一个switch语句,我们用它来加载适当的纹理,所以我们可以添加代码到这里。像往常一样,我们不会硬编码这个值,而是留给随机数神,如下所示:

// Set class-specific variables.
switch (m_class)
{
case PLAYER_CLASS::WARRIOR:
 m_strength += std::rand() % 6 + 5;
  className = "warrior";
  break;

case PLAYER_CLASS::MAGE:
 m_defense = std::rand() % 6 + 5;
  className = "mage";
  break;

case PLAYER_CLASS::ARCHER:
 m_dexterity = std::rand() % 6 + 5;
  className = "archer";
  break;

case PLAYER_CLASS::THIEF:
 m_stamina = std::rand() % 6 + 5;
  className = "thief";
  break;
}

有了这个,我们可以使某些类更有可能在给定技能中具有更高的统计点,并且通过使用随机数,我们可以在我们可以创建的player对象中引入更多的随机性和差异。

随机角色特征

游戏中有五个统计数据,即AttackDefenseStrengthDexterityStamina。让我们创建影响每个统计数据的特征,以便每个角色都倾向于某些统计数据,因此也倾向于某些游戏风格!这意味着玩家必须改变他们的游戏方式来适应他们生成的每个角色。

我们需要首先定义这些特征,所以让我们创建一个枚举器来做到这一点。我们将在Util.h中声明以下内容:

// Player traits.
enum class PLAYER_TRAIT {
  ATTACK,
  DEFENSE,
  STRENGTH,
  DEXTERITY,
  STAMINA,
  COUNT
};

现在我们需要在player类中创建一个变量来存储当前活动的特征。我们将给玩家两个特征,因此将声明一个具有该大小的数组。但是,我们将创建一个静态const来定义特征计数,而不是硬编码该值,如下所示:

/**
 * The number of traits that the player can have.
 */
static const int PLAYER_TRAIT_COUNT = 2;

提示

我们总是希望尽可能地使代码灵活。因此,在这种情况下,使用具有适当名称的静态const比硬编码的值更可取。

随时可以给玩家更多特征;只需创建一个更大的数组,并根据需要修改代码,我们继续前进。现在,让我们定义将保存特征的变量:

/**
 * An array containing the character's traits.
 */
PLAYER_TRAIT m_traits[PLAYER_TRAIT_COUNT];

要将特征随机分配给玩家,现在我们需要生成两个随机数,并将它们用作PLAYER_TRAIT枚举类型的索引。我们将把这种行为封装在自己的函数中。这样,我们可以在游戏运行时随意改变玩家的特征。

让我们在Player类中声明以下函数:

/**
 * Chooses 2 random traits for the character.
 */
void SetRandomTraits();

我们需要这个函数来生成两个索引,然后在 switch 语句中使用它们来增加适当的状态,就像我们确定player类时所做的那样。让我们添加这个,如下所示:

// Chooses random traits for the character.
void Player::SetRandomTraits()
{
    // Generate the traits.
    for (int i = 0; i < PLAYER_TRAIT_COUNT; ++i)
    {
        m_traits[i] = static_cast<PLAYER_TRAIT>(std::rand() % static_cast<int>(PLAYER_TRAIT::COUNT));
    }

    // Action the traits.
    for (PLAYER_TRAIT trait : m_traits)
    {
         switch (trait)
        {
            case PLAYER_TRAIT::ATTACK: default:
                m_attack += rand() % 6 + 5;
            break;
            case PLAYER_TRAIT::ATTACK: default:
                m_attack += std::rand() % 6 + 5;
            break;
            case PLAYER_TRAIT::DEFENSE:
                m_defense += std::rand() % 6 + 5;
            break;
            case PLAYER_TRAIT::STRENGTH:
                m_strength += std::rand() % 6 + 5;
            break;
            case PLAYER_TRAIT::DEXTERITY:
                m_dexterity += std::rand() % 6 + 5;
            break;

        case PLAYER_TRAIT::STAMINA:
            m_stamina += std::rand() % 6 + 5;
        break;
        }
    }
}

虽然这种方法成功地生成了随机特征,但它有一个很大的缺陷;没有检查以确保生成了两个唯一的特征。我们可以给玩家五个特征,虽然这很不太可能,但我们可以给他们五次相同的特征。本章末尾的一个练习是修改这一点,确保只生成唯一的特征索引。我强烈建议尝试一下。

有了这个函数的编写,现在我们只需要在玩家的构造函数中调用它:

// Set random traits.
SetRandomTraits();

现在每次创建玩家时,他们将随机选择两个特征。最后一步是在 UI 中绘制玩家的特征。为此,我们需要从玩家那里获取特征并修改状态精灵。

返回玩家特征数组

特征存储在数组中,C++不允许我们从函数中返回整个数组。为了解决这个问题,我们需要做一些花哨的事情。因此,让我们快速分支出去,看看我们如何解决这个问题。

首先,在Player.h中需要声明以下函数,如下所示:

/**
 * Gets the players current traits.
 * @return The players two current traits.
 */
PLAYER_TRAIT* GetTraits();

我们将给出以下定义:

// Return the players traits.
PLAYER_TRAIT* Player::GetTraits()
{
  return &m_traits[0];
}

提示

请注意,这个函数意味着玩家特征变量可以被改变。

数组只是顺序存储在内存中的值的集合。以下图表显示了它的外观:

返回玩家特征数组

考虑到这一点,如果我们返回第一个元素的地址,然后可以通过顺序读取以下内存来找到其余的值。为了证明这一点,看一下以下两行,它们的工作方式相同:

m_traits[2] = 1;
GetTraits()[2] = 1;

因此,虽然我们不返回完整的数组,但我们返回第一个元素,这就是我们所需要的。现在我们可以以与通常相同的方式访问数组。

设置特征精灵

现在剩下的就是在主Game类中绘制特征。我们已经在窗口底部绘制了玩家的状态。因此,为了指示被特征增强的状态,我们可以使精灵变大,并切换到其备用纹理。状态精灵在Game::LoadUI函数中加载和初始化。

在开始之前,我们需要知道玩家有多少特征。因此,让我们在player对象中添加一个快速的GetTraitCount()函数来给我们这个信息;不要忘记在 Player.h 中添加声明:

// Returns the number of traits the player has.
int Player::GetTraitCount()
{
  return PLAYER_TRAIT_COUNT;
}

现在,在Game::LoadUI中,一旦我们加载了状态精灵,我们就可以调用这个函数,并构建一个循环来迭代这个次数,如下所示:

// Set player traits.
int traitCount = m_player.GetTraitCount();

for (int i = 0; i < traitCount; ++i)
{

}

现在,我们需要检查每个特征,并将其精灵比例设置为1.2f,使其比邻近的精灵稍大。我们还将切换到其备用纹理,带有白色背景。这已经在项目中设置好了,所以我们需要做的就是以以下方式进行切换:

for (int i = 0; i < traitCount; ++i)
{
  switch (m_player.GetTraits()[i])
  {
  case PLAYER_TRAIT::ATTACK:
    m_attackStatSprite->setTexture(TextureManager::GetTexture(m_attackStatTextureIDs[1]));
    m_attackStatSprite->setScale(sf::Vector2f(1.2f, 1.2f));
    break;

  case PLAYER_TRAIT::DEFENSE:
    m_defenseStatSprite->setTexture(TextureManager::GetTexture(m_defenseStatTextureIDs[1]));
    m_defenseStatSprite->setScale(sf::Vector2f(1.2f, 1.2f));
    break;

  case PLAYER_TRAIT::STRENGTH:
    m_strengthStatSprite->setTexture(TextureManager::GetTexture(m_strengthStatTextureIDs[1]));
    m_strengthStatSprite->setScale(sf::Vector2f(1.2f, 1.2f));
    break;

  case PLAYER_TRAIT::DEXTERITY:
    m_dexterityStatSprite->setTexture(TextureManager::GetTexture(m_dexterityStatTextureIDs[1]));
    m_dexterityStatSprite->setScale(sf::Vector2f(1.2f, 1.2f));
    break;

  case PLAYER_TRAIT::STAMINA:
    m_staminaStatSprite->setTexture(TextureManager::GetTexture(m_staminaStatTextureIDs[1]));
    m_staminaStatSprite->setScale(sf::Vector2f(1.2f, 1.2f));
    break;
  }
}

现在,如果我们运行游戏,我们可以清楚地看到哪些精灵当前被特征增强,如下截图所示。我们之前已经连接了它们的行为。因此,我们知道这些图标对角色的状态产生了影响。

设置特征精灵

过程生成敌人类

现在玩家已经完全生成,让我们将一些应用到敌人身上。我们目前有两个主要的敌人类,即“史莱姆”和“人形”。 “史莱姆”是一个简单的史莱姆敌人,但我们的“人形”类是为了扩展而存在的。目前,该类加载骷髅的精灵,但让它可以成为多种人形敌人;在我们的情况下,它可以是哥布林或骷髅。

我们本可以为这些敌人制作单独的类,但由于它们的大部分代码都是相同的,这是没有意义的。相反,我们有这个模糊的“人形”类,可以成为人形敌人的形式。我们所需要做的就是改变精灵,以及如果我们希望它们有不同的玩法,我们分配统计数据的方式。从这里我们可以从“单一”类中创建许多不同的敌人。我们很快也会在药水上使用相同的方法!

现在,我们将从Util.h中定义一个枚举器,表示不同类型的人形敌人:

// Enemy humanoid types.
enum class HUMANOID {
  GOBLIN,
  SKELETON,
  COUNT
};

现在,如果我们回想一下player构造函数,我们生成了一个类,并对该变量执行了一个开关,以执行依赖于类的行为。我们将在这里使用完全相同的方法。我们将从我们刚刚定义的枚举器中生成一个随机敌人类型,然后相应地设置精灵和统计数据。

Humanoid::Humanoid中,让我们选择一个随机的人形类型,并创建一个字符串来保存敌人的名称,如下所示:

// Default constructor.
Humanoid::Humanoid()
{
    // Generate a humanoid type. (Skeleton or Goblin).
    HUMANOID humanoidType = static_cast<HUMANOID>(std::rand() % static_cast<int>(HUMANOID::COUNT));
    std::string enemyName;

    // Set enemy specific variables.
    switch (humanoidType)
    {
        case HUMANOID::GOBLIN:
            enemyName = "goblin";
        break;

        case HUMANOID::SKELETON:
            enemyName = "skeleton";
        break;
    }
    // Load textures.
    m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_UP)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_walk_up.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_DOWN)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_walk_down.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_RIGHT)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_walk_right.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_LEFT)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_walk_left.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_UP)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_idle_up.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_DOWN)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_idle_down.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_RIGHT)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_idle_right.png");
    m_textureIDs[static_cast<int>(ANIMATION_STATE::IDLE_LEFT)] = TextureManager::AddTexture("../resources/enemies/" + enemyName + "/spr_" + enemyName + "_idle_left.png");

    // Set initial sprite.
    SetSprite(TextureManager::GetTexture(m_textureIDs[static_cast<int>(ANIMATION_STATE::WALK_UP)]), false, 8, 12.f);
}

完成这些后,如果现在运行游戏,您将看到有哥布林和骷髅敌人从“单一”类中生成,如下截图所示:

过程生成敌人类

程序化物品

现在玩家和敌人都已经处理好了,让我们把注意力转向物品。我们有许多类可以随机分配其成员变量。我们将设置“药水”类的方式与我们设置“人形”类的方式相同,从“单一”类中创建多个不同的对象。

随机宝石和心类

我们将从最小的类开始,即“心”和“宝石”。这些都是非常简单的类,目前只有一个硬编码的变量。让我们更新一下,使它们的值在创建时随机生成。由于我们希望每次创建对象时都发生这种情况,我们将把它放在物品的构造函数中。

Gem::Gem中,我们将进行以下更改:

// Set the value of the gem.
// m_scoreValue = 50;
m_scoreValue = std::rand() % 100;

Heart::Heart中,我们将进行以下更改:

// Set health value.
// m_health = 15;
m_health = std::rand() % 11 + 10;

如果现在运行游戏,并快速查看一下,您将看到这些物品提供不同的分数和生命值。完美!

随机宝石和心类

随机金类

对于最后两个物品,我们只是生成了一个随机值。对于金物品,我们将进一步进行。我们将使用这个随机值来确定对象应该具有的精灵。

为此,我们将把总金值范围分为三个段。我们将定义一个较低范围,一个较高范围,剩下的就是中间范围。例如,如果我们要生成 0 到 10 之间的金值,我们可以有以下情况:

  • 小于 3 的都是小的

  • 大于 7 的都是大的

  • 其他任何都是中等

通过这样做,我们可以设置与金值匹配的精灵。我们将把这段代码放在构造函数中,因为这是应该在每次创建金对象时调用的代码,我们永远不需要手动调用它的行为:

// Default constructor.
Gold::Gold()
{
    // Randomly generate the value of the pickup.
    this->goldValue = std::rand() % 21 + 5;

    // Choose a sprite based on the gold value.
    int textureID;
    if (this->goldValue < 9)
    {
        textureID = TextureManager::AddTexture("../resources/loot/gold/spr_pickup_gold_small.png");
    }
    else if (this->goldValue >= 16)
    {
        textureID = TextureManager::AddTexture("../resources/loot/gold/spr_pickup_gold_large.png");
    }
    else
    {
        textureID = TextureManager::AddTexture("../resources/loot/gold/spr_pickup_gold_medium.png");
    }

    // Set the sprite.
    this->SetSprite(TextureManager::GetTexture(textureID), false, 8, 12.f);

    // Set the item type.
    m_type = ITEM::GOLD;
}

您可以看到我们生成了一个随机金值,然后简单地使用了几个if语句来定义我们的范围。让我们再次运行游戏,看看金对象。您将看到它们的精灵变化,因此被拾取时的金值也会有所不同:

随机金类

随机药水类

对于最大的类更新,我们将把注意力转向potion类。这个类目前有一个固定的精灵,并且不给玩家任何东西。通过humanoid类,我们可以生成一个随机类型,并从单一类中实质上创建两个不同的敌人。我们将使用相同的方法来处理药水。

创建一个随机药水

首先,让我们在Util.h中定义一个枚举器,表示所有的药水类型。我们将为每个统计数据创建一个:

// Potions.
enum class POTION {
  ATTACK,
  DEFENSE,
  STRENGTH,
  DEXTERITY,
  STAMINA,
  COUNT
};

为了节省大量的输入,药水类已经有了每种可能统计数据的成员变量和getter函数,我们只需要使用它们。我们将添加的一个是用来保存药水类型的变量,以及一个返回它的函数。当捡起物品时,我们需要这些信息!

让我们在Potion.h中声明以下内容:

public:
  /**
   * Gets the potion type.
   * @return The potion type.
   */
  POTION GetPotionType() const;

private:
  /**
   * The potion type.
   */
  POTION m_potionType;

GetPotionType是一个简单的getter函数,所以在继续之前让我们快速给它一个主体:

// Gets the potion type.
POTION Potion::GetPotionType() const
{
    return m_potionType;
}

如果你查看 Potion 的初始化列表,你会注意到它将所有的统计变量都设置为 0。从这一点开始,我们可以选择一个随机类型,并设置它的精灵和相应的统计数据,将其余部分保持在它们的默认值 0,因为我们不会使用它们。

首先,我们将生成一个随机值来表示其类型,并创建一个变量来存储精灵路径。以下代码需要放在Potion::Potion中:

// The string for the sprite path.
std::string spriteFilePath;

// Set the potion type.
m_potionType = static_cast<POTION>(std::rand() % static_cast<int>(POTION::COUNT));

有了选定的类型,我们可以切换这个值,设置适当的统计数据,并给spriteFilePath设置适当的资源路径,如下所示:

// Set stat modifiers, sprite file path, and item name.
switch (m_potionType)
{
case POTION::ATTACK:
  m_dexterity = std::rand() % 11 + 5;
  spriteFilePath = "../resources/loot/potions/spr_potion_attack.png";
  break;

case POTION::DEFENSE:
  m_dexterity = std::rand() % 11 + 5;
  spriteFilePath = "../resources/loot/potions/spr_potion_defense.png";
  break;

case POTION::STRENGTH:
  m_strength = std::rand() % 11 + 5;
  spriteFilePath = "../resources/loot/potions/spr_potion_strength.png";
  break;

case POTION::DEXTERITY:
  m_dexterity = std::rand() % 11 + 5;
  spriteFilePath = "../resources/loot/potions/spr_potion_dexterity.png";
  break;

case POTION::STAMINA:
  m_stamina = std::rand() % 11 + 5;
  spriteFilePath = "../resources/loot/potions/spr_potion_stamina.png";
  break;
}

最后,我们只需要以以下方式设置物品精灵和类型,然后就完成了。请注意,这种类型与药水类型不同:

// Load and set sprite.
SetSprite(TextureManager::GetTexture(TextureManager::AddTexture(spriteFilePath)), false, 8, 12.f);

// Set the item type.
m_type = ITEM::POTION;

如果我们现在运行游戏,并杀死一些敌人,直到我们得到一个药水掉落,我们应该看到药水类型发生变化。从一个单一类中,我们创建了 5 种药水,运行时创建,提供了增益,也是在运行时生成的。

创建一个随机药水

确定药水捡起

现在我们有一个单一类,有五种不同的潜在增益,我们需要确定我们正在捡起的药水。这就是Potion::GetType函数派上用场的地方。当我们接触到药水对象时,我们可以检查药水的类型,并使用它来确定我们将调用哪个统计数据获取函数。

例如,如果我们捡起一个药水,它的类型是POTION::ATTACK,那么我们知道我们需要调用Potion::GetAttack函数。物品捡起代码位于Game::UpdateItems函数中。在这个函数中,我们检查与对象的碰撞,并检查它是什么类型的物品。

当我们确定我们捡起了一个药水时,我们需要调用Potion::GetPotionType函数,但是我们有一个问题。由于我们利用多态性将所有物品存储在单个集合中,此时药水物品的类型是Item。为了访问Potion::GetPotionType函数,我们需要使用dynamic_cast进行转换:

提示

如果你不确定为什么我们在这里使用dynamic_cast而在其他地方使用static_cast,请阅读不同类型的转换。

让我们将这种情况添加到Game::UpdateItems中的捡起代码中:

case ITEM::POTION:
{
  // Cast to position and get type.
  Potion& potion = dynamic_cast<Potion&>(item);
  POTION potionType = potion.GetPotionType();
}
break;
}

我们现在确定了我们捡起了一个药水并将该物品转换为药水对象。接下来,我们可以检查药水的类型,并调用适当的getter函数来获取药水值。最后,我们将更新玩家的相应统计数据,如下所示:

switch (potionType)
{
case POTION::ATTACK:
  m_player.SetAttack(m_player.GetAttack() + potion.GetAttack());
  break;

case POTION::DEFENSE:
  m_player.SetDefense(m_player.GetDefense() + potion.GetDefense());
  break;

case POTION::STRENGTH:
  m_player.SetStrength(m_player.GetStrength() + potion.GetStrength());
  break;

case POTION::DEXTERITY:
  m_player.SetDexterity(m_player.GetDexterity() + potion.GetDexterity());
  break;

case POTION::STAMINA:
  m_player.SetStamina(m_player.GetStamina() + potion.GetStamina());
  break;
}

有了这个药水系统就完成了。从一个单一类中,我们创建了五种不同的药水,所有值都是随机生成的。

练习

为了帮助你测试本章内容的知识,以下是一些练习题,你应该完成。它们对于本书的其余部分并不是必要的,但是完成它们将帮助你评估所涵盖材料的优势和劣势:

  1. player类添加你自己的特性。项目中包含了一个备用的特性资源,你可以使用。

  2. 在生成player特性时,我们发现可能会多次给玩家相同的特性。改进Player::SetRandomTraits函数,使这种情况不再可能。

  3. 我们给玩家和敌人的属性并没有与他们造成或承受多少伤害挂钩。将这些属性挂钩起来,使它们对玩家和敌人产生更大的影响。

总结

在本章中,我们看了如何使游戏对象独特和随机化,赋予它们随机属性、精灵和变化。通过这种方法,游戏可以生成的物品种类几乎是无限的。当我们有多个类只有轻微不同时,我们可以设计模糊的类,这些类非常灵活,大大增加了多样性。

在下一章中,我们将加强我们的程序化工作。我们将摆脱简单地随机设置成员变量的方式,尝试创建程序化艺术和图形。我们将为敌人程序化地创建纹理,并改变关卡精灵,为地牢的每一层赋予独特的感觉。

第六章:程序生成艺术

游戏的艺术是其定义特征之一。通常是我们首先吸引我们的东西,也是让我们着迷的驱动力之一;出色的美学效果可以走很远。鉴于此,我们希望确保这个领域尽可能丰富、多样和沉浸。

然而,艺术在财务上昂贵且耗时。不仅如此,在硬件层面也很昂贵!游戏纹理可以达到 4K 大小,创建一千个 4K 纹理并将它们存储在传统游戏媒体上并不容易。幸运的是,在创建艺术时可以采用各种程序生成技术来帮助解决其中的一些问题。

在本章中,我们将涵盖以下主题:

  • 程序生成如何与艺术结合使用

  • 程序生成艺术的优缺点

  • 使用 SFML 精灵修改器

  • 保存修改后的精灵

  • 通过程序创建精灵

程序生成如何与艺术结合使用

游戏艺术是程序生成的一个很好的候选对象。手工创建它在开发者投入和硬件层面上都很昂贵,并且可以通过程序进行操纵。然而,像一切事物一样,它有一系列的优点和缺点。因此,在我们开始之前,让我们先来看看它们。

使用精灵效果和修改器

程序生成可以与游戏艺术结合的最简单方式可能是通过使用内置函数来操纵现有的精灵和模型。例如,大多数游戏引擎和框架都会提供一些编辑图形的功能,如颜色、透明度和比例修改器。

将这些功能与随机数生成器(RNG)结合使用是开始生成随机游戏艺术的一种简单快速的方法。例如,Simple and Fast Multimedia Library(SFML)提供了改变精灵颜色和大小的功能。即使只使用这些功能,我们也可以在运行时生成各种不同的纹理。如下截图所示:

使用精灵效果和修改器

组合多个纹理

从简单修改现有纹理的方式升级,是将多个纹理组合在一起创建新的纹理。加入一些随机数生成器,你就可以轻松地创建大量的精灵。在本章中,我们将使用这种技术为我们的敌人随机生成盔甲!

我们将从一个基本的敌人精灵开始,随机选择一些盔甲,并将其绘制在原始图像上,以创建一个随机精灵!稍后再详细介绍,但现在先看看它会是什么样子:

组合多个纹理

从头开始创建纹理

创建程序纹理的最复杂方式是使用算法从头开始创建它们。诸如 Perlin 噪声之类的算法可以用来创建自然外观的纹理基础,然后可以使用诸如图像乘法之类的技术来创建各种程序纹理。

例如,可以将基本的 Perlin 噪声纹理、白噪声纹理和纯色结合起来创建程序纹理,如下所示:

从头开始创建纹理

采用这种方法,对生成第一和第二个纹理的算法进行更改将导致不同的最终纹理。这种技术可以用来为游戏创建无尽的独特纹理,而不会产生存储问题。

提示

这种类型的程序图像创建超出了本书的范围。如果你希望进一步深入了解,请阅读有关纹理合成和 Perlin 噪声等算法的资料。

创建复杂的动画

计算能力的增长也催生了程序动画。传统上,动画游戏资源,如角色,会由动画师在 3D 动画软件中制作动画。然后,游戏引擎在运行时加载这个动画例程,并应用于给定的模型以使其移动。

由于计算机现在能够进行比以往更多的计算,程序动画变得越来越受欢迎。现在很多游戏中都使用布娃娃身体,这是程序动画的一个很好的例子。与播放一组固定的动画例程不同,身体的信息,如重量、速度和刚度,被用来计算身体应该处于的位置,以创建逼真和动态的运动。

程序生成艺术的好处

游戏艺术的程序生成为我们开发人员和玩家带来了一系列好处。从其多功能性,到成本效益和节省时间,让我们来看看其中的一些好处。

多功能性

程序生成游戏艺术的主要好处是多功能性。游戏艺术的制作成本很高,因此对于给定项目来说,会有一定的限制。虽然让艺术家为我们的游戏创建成千上万种纹理会很好,但这是不可行的。相反,我们可以创建一些资源,利用程序技术将这些资源转化为成千上万种可能的纹理,并为游戏带来多样性和丰富性。

廉价生产

在前面的观点上进行扩展,由于我们不必支付艺术家手工创建所有这些纹理,程序生成为我们节省了时间和金钱。在本章中,我们将要处理的示例是为我们的敌人提供随机护甲。将有三种类型的护甲,每种有三个等级,敌人所拥有的护甲的组合也将是随机的。可能的组合数量是巨大的,让艺术家手工创建它们将是昂贵的。

它需要很少的存储空间

继续以给予敌人护甲的例子,即使我们可以让艺术家手工制作所有的精灵,它们将如何被存储?虽然对于在线游戏来说这不是太大的问题,因为游戏和下载大小通常没有限制,但是那些需要传统媒体(如光盘)发行的游戏必须明智地利用空间。在这方面,纹理是一种昂贵的资源。因此,创建一些资源并通过程序从中创建纹理可以缓解这些问题。

程序生成艺术的缺点

好处与坏处并存,程序生成的艺术也不例外。虽然它灵活并节省空间,但它也有一些缺点。

缺乏控制

第一个缺点是应用程序不可知的,这是程序生成的一个整体缺点;它带来的失控。如果你通过程序生成艺术,你会失去一个熟练艺术家所能赋予的触感。内容可能缺乏特色,由于是确定性过程的结果,而不是创造性的过程,可能会感觉非常僵硬。一个好的程序算法可以在一定程度上缓解这个问题,但很难生成感觉和看起来像一个有才华的艺术家所创作的自然内容。

可重复性

程序生成艺术的另一个潜在问题是,事物可能会显得非常重复和不自然。内容将通过算法产生,输出的变化是使用术语的差异的结果。鉴于此,每个算法都有可能产生的内容范围。如果算法的操作范围太小,纹理将会重复,并且可能会感到不自然和重复使用,尽管程序生成被用来缓解这个问题!这完全取决于算法的质量和使用方式。

性能重

程序生成艺术通常涉及大量的读取和复制纹理,这些通常是昂贵的操作,特别是如果你使用高分辨率纹理。以敌人盔甲为例,如果我们手动创建精灵,我们只需要加载纹理,这是一个单一的操作。如果我们程序生成一个精灵,我们必须加载每个组件,编辑它们,并重新渲染它们以创建一个新的纹理。

使用 SFML 精灵修改器

现在我们已经确定了程序生成艺术的一些优点和缺点,开始吧!我们将首先看一下的天真方法是简单地使用sprite修改器,如coloralpha来改变现有的精灵。使用这种方法,我们将使用 SFML 提供的内置精灵修改器。大多数引擎和框架都会有类似的函数,如果没有,你也可以自己创建!

SFML 中颜色的工作原理

让我们从最简单的程序生成精灵的方法开始,在运行时为它生成一个独特的颜色。在 SFML 中,颜色简单地是四个uint8值的集合,每个颜色通道一个,还有一个 alpha 通道:

sf::Color::Color  (
Uint8   red,
Uint8   green,
Uint8   blue,
Uint8   alpha = 255
)

SFML 中的每个sf::Sprite都有一个sf::Color成员变量。这个颜色值与纹理中像素的颜色值相乘,得到最终的颜色。下图演示了这一点:

SFML 中颜色的工作原理

在上图中,我们可以看到最左边的原始图像。此外,我们还可以看到精灵设置了各种颜色时的结果图像。

提示

为了获得最佳效果,最好从单色灰色基础纹理开始,以便颜色调制到达正确的颜色。

sf::Color类型还有一个alpha值,用于确定对象的不透明度。alpha 通道越低,对象就越透明。通过这个值,你可以改变对象的不透明度,如下图所示:

SFML 中颜色的工作原理

了解了 SFML 如何处理颜色,让我们通过为史莱姆角色生成一个随机精灵,并在程序中设置它的颜色和 alpha 值来将其付诸实践。

提示

要了解更多关于 SFML 如何处理颜色的信息,请阅读www.sfml-dev.org/learn.php上找到的 SFML 文档。要了解更多详细信息,请前往 SFML 使用的图形 API OpenGL 文档。

创建随机颜色的精灵

在 SFML 中,精灵对象有一个名为setColor()的成员函数。这个函数接受一个sf::Color对象,并将其设置为在绘制时与精灵纹理相乘的值。我们知道sf::Color本质上只是四个uint8值,每个值的范围是 0 到 255。鉴于此,要生成一个随机颜色,我们可以为这些颜色通道生成随机值,或者随机选择 SFML 预定义颜色中的一个。

史莱姆敌人是一个很好的选择,因为它在许多颜色下都会看起来很棒,而基础精灵是一种沉闷的灰色。将颜色与这个精灵相乘将起到很好的效果。当我们设置史莱姆精灵时,我们将使用这两种方法随机给它一个颜色。让我们从选择预定义颜色开始。

随机选择预设颜色

SFML 带有以下预定义颜色:

sf::Color black       = sf::Color::Black;
sf::Color white       = sf::Color::White;
sf::Color red         = sf::Color::Red;
sf::Color green       = sf::Color::Green;
sf::Color blue        = sf::Color::Blue;
sf::Color yellow      = sf::Color::Yellow;
sf::Color magenta     = sf::Color::Magenta;
sf::Color cyan        = sf::Color::Cyan;
sf::Color transparent = sf::Color::Transparent;

这些在Color.hpp中定义,并涵盖了最受欢迎的颜色。首先的问题是我们需要一种随机选择的方法。为此,我们可以创建一个匹配颜色值的枚举器,生成一个随机索引,然后使用它来将枚举器值与匹配的预定义颜色相匹配。当我们看代码时,这将变得更清晰。

我们将首先在Util.h文件中添加以下枚举器定义:

// Colors provided by SFML.
enum class COLOR {
  BLACK,
  WHITE,
  RED,
  GREEN,
  BLUE,
  YELLOW,
  MAGENTA,
  CYAN,
  TRANSPARENT,
  COUNT
};

对于每个预定义颜色,我们已经为enum添加了相应的值,确保它以COUNT结尾。有了这个定义,我们只需要计算 0 到COLOR::COUNT之间的数字,然后在switch语句中使用它。这是我们现在已经使用了几次的方法,所以我们应该对它很熟悉。

跳转到史莱姆敌人的构造函数,我们将从生成一个随机索引开始:

int colorIndex = std::rand() % static_cast<int>(COLOR::COUNT);

现在,我们只需要切换colorIndex值并设置相应的颜色:

switch (colorIndex)
{
case static_cast<int>(COLOR::BLACK):
  m_sprite.setColor(sf::Color::Black);
  break;

case static_cast<int>(COLOR::BLUE):
  m_sprite.setColor(sf::Color::Blue);
  break;

这应该对我们定义的每个枚举值进行继续。现在,你会看到每个生成到游戏中的史莱姆敌人都有不同的预定义颜色:

随机选择预设颜色

随机生成颜色

第二个选项,给了我们更多的控制权,就是随机生成我们自己的颜色。这种方法给了我们更广泛的可能性范围,同时也让我们可以访问 alpha 通道;然而,我们失去了一些控制。当从预定义颜色中选择时,我们知道我们最终会得到一种令人愉悦的颜色,这是我们无法保证当为每个通道生成我们自己的值时。尽管如此,让我们看看我们将如何做。

我们知道sf:color有四个通道(r、g、b 和 a),每个值都在 0 到 255 之间。为了生成随机颜色,我们需要为 r、g 和 b 通道生成值;a 是 alpha 通道,它将允许我们改变精灵的不透明度。

首先,我们将定义变量并为 r、g 和 b 通道生成随机值,如下所示:

int r, g, b, a;

r = std::rand() % 256;
g = std::rand() % 256;
b = std::rand() % 256;

对于 alpha 通道,我们希望在数字生成方面更加精确。alpha 值为 0 太低了;我们几乎看不到精灵。因此,我们将生成一个在 100 到 255 范围内的数字,如下所示:

a = std::rand() % 156 + 100;

现在我们有了这些值,我们需要创建一个sf::color对象,将rgba值传递给color构造函数:

sf::Color color(r, g, b, a);

最后一步是调用sf::sprite::setColor(),传递新的颜色。完整的代码如下,应该放在史莱姆敌人的构造函数中:

// Choose the random sprite color and set it.
int r, g, b, a;

r = std::rand() % 256;
g = std::rand() % 256;
b = std::rand() % 256;
a = std::rand() % 156 + 100;
sf::Color color(r, g, b, 255);

m_sprite.setColor(color);

现在,如果我们运行游戏,我们应该会得到三个非常不同颜色的史莱姆,每个都有不同程度的不透明度,如下截图所示:

随机生成颜色

生成随机颜色

我们将要玩耍的最后一个精灵修改器是缩放。使用sf::Sprite::setScale()函数,我们可以设置精灵的水平和垂直缩放。默认缩放为 1,所以如果我们使用值为 2 进行缩放,精灵将变大一倍。同样,如果我们设置为 0.5 的缩放,它将变小一半。鉴于此,我们需要生成接近 1 的浮点数。0.5 到 1.5 的范围应该给我们足够的大小差异!

所以,我们需要生成一个浮点数,但std::rand()函数只会生成一个整数值。别担心!我们可以使用一个简单的技巧来得到一个浮点数!我们只需要生成一个 5 到 15 之间的数字,然后除以 10 得到浮点值:

float scale;
scale = (std::rand() % 11 + 5) / 10.f;

现在随机比例值已经生成,我们现在只需要调用sf::sprite::setScale()函数,并使用scale变量作为缩放值。完整的代码如下:

// Generate a random scale between 0.5 and 1.5 and set it.
float scale;
scale = (std::rand() % 11 + 5) / 10.f;

m_sprite.setScale(sf::Vector2f(scale, scale));

运行游戏后,你会看到史莱姆敌人有不同的颜色,它们的大小也不同:

生成随机大小的精灵

保存修改后的精灵

在我们的游戏中,每次运行游戏时,我们都将生成新的精灵。我们希望每次运行都是独一无二的,所以一旦我们生成了一个精灵并使用它,我们就可以让它离开。然而有时,你可能想保留一个精灵。例如,你可能想创建一个随机的 NPC 并在整个游戏中保持相同的角色。

到目前为止,我们用来创建图像的两种数据类型是sf::Spritesf::Texture。这些类让我们通过一组预定义的成员函数与图像交互。它非常适用于标准绘图和简单的图像操作,但我们无法访问原始图像信息。这就是sf::Image发挥作用的地方!

将纹理传递到图像

Sf::Image是一个用于加载、操作和保存图像的类。与其他数据类型不同,sf::Image为我们提供了原始图像数据,允许我们与图像中的每个像素交互。我们稍后将使用更多这方面的功能,但现在,我们对sf::Image::saveToFile函数感兴趣。

通过这个函数,我们可以将图像保存到文件;我们只需要将纹理放入图像中。幸运的是,有一个函数可以做到这一点!sf::Texture类有一个名为copyToImage的函数,它将纹理中的原始图像数据复制到图像中。所以,我们应该能够将纹理复制到图像并保存它,对吗?好吧,让我们试试看。

Slime::Slime中,在我们修改了精灵之后,让我们添加以下调试代码:

// Save the sprite to file.
sf::Image img = m_sprite.getTexture()->copyToImage();
img.saveToFile("../resources/test.png");

如果你看一下我们创建的文件并将其与原始图像进行比较,你会发现有些奇怪的地方:

保存图像到文件

我们对精灵所做的修改不会编辑纹理。相反,每次绘制对象时都会进行修改。当我们像这样输出纹理时,我们只是输出了放入的相同精灵!为了保存通过精灵修改所做的更改,我们还需要利用sf::RenderTexture类。

绘制到 RenderTexture 类

由于精灵修改不会应用到纹理上,我们需要以某种方式捕捉一旦渲染完成的精灵。再次,SFML 通过其sf::RenderTexture类来解决这个问题。这个类允许我们渲染到纹理而不是屏幕,解决了修改不会应用到纹理上的问题。

首先,我们需要创建一个sf::RenderTexture对象。为此,我们需要知道我们将要绘制的区域的大小,并且在这里有一些需要记住的事情。我们正在改变对象的大小。因此,如果我们只是获取纹理的大小,它要么太大要么太小。相反,我们需要获取纹理的大小并将其乘以我们应用于精灵的相同比例值。

让我们写一些代码来使事情更清晰。我们将首先创建sf::RenderTarget对象,如下所示:

// Create a RenderTarget.
sf::RenderTexture texture;

int textureWidth(m_sprite.getTexture()->getSize().x);
int textureHeight(m_sprite.getTexture()->getSize().y);
texture.create(textureWidth * scale, textureHeight * scale);

正如你所看到的,我们将获取纹理的大小并将其乘以我们修改精灵的相同比例。

最后,我们将对象绘制到渲染视图中,如下所示:

// Draw the sprite to our RenderTexture.
texture.draw(m_sprite);

保存图像到文件

从这一点开始,代码与我们的第一次尝试相同,但有一点修改。因为精灵是动画的,我们改变了它的原点和textureRect属性,以将其切割成子部分以便动画角色。为了看到整个纹理,这需要恢复。此外,当我们调用sf::Texture::copyToImage时,精灵会垂直翻转。在保存文件之前,我们需要将其翻转回来。

以下是用于保存修改后 slime 纹理的完整代码示例:

// Create a RenderTarget.
sf::RenderTexture texture;

int textureWidth(m_sprite.getTexture()->getSize().x);
int textureHeight(m_sprite.getTexture()->getSize().y);
texture.create(textureWidth * scale, textureHeight * scale);

// Revert changes the animation made.
m_sprite.setOrigin(sf::Vector2f(0.f, 0.f));
m_sprite.setTextureRect(sf::IntRect(0, 0, textureWidth, textureHeight));

// Draw the sprite to our RenderTexture.
texture.draw(m_sprite);

// Copy the texture to an image and flip it.
sf::Image img = texture.getTexture().copyToImage();
img.flipVertically();

// Save the sprite to file.
img.saveToFile("../resources/test.png");

提示

完成后不要忘记删除这段代码,因为保存文件很昂贵,而且会搞乱动画!

现在,如果你运行游戏并查看文件,你会看到我们所做的修改。

将纹理传递到图像

以程序方式创建敌人精灵

拥有渲染到sf::RenderTexture并存储结果的能力打开了无限的可能性。其中之一是组合多个精灵以创建新的、更多功能的精灵。我们可以多次绘制到sf::RenderTexture类,并且精灵将重叠。这是一种非常有用的技术,可以用来生成大量的精灵变化,而无需进行大量工作。这在以下截图中显示:

创建敌人精灵的过程

使用这种方法,我们将为我们的敌人创建随机盔甲。我们将有三件盔甲;头部、躯干和腿部。对于每个部分,我们还将有三种变化;青铜、银和金。这本身就给我们提供了大量可能的组合。然后,让我们考虑到我们需要这个对于每个角色,我们有两个,每个角色有八个精灵。这是一个巨大的纹理数量。完全不可能手动创建所有这些。

将精灵分解为组件

我们将创建的盔甲精灵将直接放在默认的敌人动画上。在这里需要考虑的最重要的事情是,当它们在彼此上方绘制时,它们的大小和位置将对齐。

当创建一个sf::RenderTexture类时,我们定义一个大小。然后绘制到它的一切将相对于这个区域的左上角定位。如果我们的精灵大小不同,当我们开始绘制时,它们将不对齐。以下示例已经将它们的背景变暗,以便我们可以看到这一点。在第一个示例中,精灵已经被裁剪,我们可以看到这使它们在彼此上方叠放时不对齐:

将精灵分解为组件

在第二个示例中,精灵的大小相同,并且都相对于它们将被绘制在其上的精灵定位。因此,它们将很好地对齐:

将精灵分解为组件

我们将为每个敌人创建盔甲,因此对于每个敌人动画,我们需要创建一个匹配的盔甲精灵。这已经完成了以节省时间,您会注意到这些精灵只有灰色版本。为了节省更多时间,我们将使用精灵修改器来改变颜色。

这是骷髅行走精灵条上的盔甲叠加精灵的示例:

将精灵分解为组件

绘制设置

在我们编写任何关于生成盔甲的代码之前,我们需要改变Humanoid类处理其纹理的方式。由于我们将创建的纹理对于类的每个实例都是独一无二的,并且只会被使用一次,所以没有必要将Texture管理器填满它们。相反,我们将创建自己的纹理数组,并覆盖默认的绘制行为以使用新的纹理!

我们将从在Humanoid.h中定义纹理数组开始,如下所示:

  /**
   * An array of modified textures.
   */
  sf::Texture m_textures[static_cast<int>(ANIMATION_STATE::COUNT)];

现在,在Humanoid构造函数中,我们需要用默认的敌人纹理填充这个数组。这是因为我们将覆盖默认的绘制行为以使用修改后的精灵数组覆盖默认的精灵。只有在生成盔甲时才会创建修改后的精灵。因此,我们需要确保我们有默认的精灵作为后备。我们将用默认精灵填充数组,然后如果我们生成盔甲,就覆盖它们。

将以下代码添加到Humanoid::Humanoid中。然后,我们的准备工作就完成了,我们可以开始了:

// Copy textures.
for (int i = 0; i < static_cast<int>(ANIMATION_STATE::COUNT); ++i)
{
  m_textures[i] = TextureManager::GetTexture(m_textureIDs[i]);
}

随机选择精灵组件

我们的敌人可以拥有三种可能的盔甲部件;头部、躯干和腿部,我们希望我们的敌人拥有这些类型的混合。因此,让我们给每个敌人一次生成这些部件的机会。这意味着拥有更多装备的敌人生成的可能性更小,这正是我们想要的;一个全副武装的骷髅应该是一个罕见的生成!

提示

不要忘记游戏机制的平衡。在创建程序化系统时,很容易专注于技术,而忽视平衡。设计系统时一定要牢记这一点。您可以访问www.paranoidproductions.com/,这里包含了很多关于这个主题的信息。

让我们开始创建一个函数,将所有这些行为放进去。护甲是设计用来覆盖哥布林和骷髅精灵的。因此,我们可以将它放在Humanoid类中,并为两种变体生成护甲!

让我们声明Humanoid::GenerateArmor函数,如下所示:

private:
 /**
  * Generates random armor for the humanoid.
  */
void GenerateArmor();

我们需要做的第一件事是创建我们将要绘制的sf::RenderTexture对象。我们将为每个精灵使用两个纹理:一个用于护甲,一个用于最终图像。我们将首先绘制护甲,然后将其绘制在默认敌人精灵上,以创建最终纹理。

让我们给新函数一个主体并设置对象:

// Randomly generates armor.
void Humanoid::GenerateArmor()
{
    // Create arrays of textures.
    const int textureCount = static_cast<int>(ANIMATION_STATE::COUNT);
    sf::RenderTexture armorTextures[textureCount];
    sf::RenderTexture finalTextures[textureCount];
    sf::Image renderImage;
    // Setup all render textures.
    for (int i = 0; i < static_cast<int>(ANIMATION_STATE::COUNT); ++i)
    {
        sf::Vector2u textureSize = m_textures[i].getSize();
        armorTextures[i].create(textureSize.x, textureSize.y);
        finalTextures[i].create(textureSize.x, textureSize.y);
    }

现在我们可以添加代码来选择敌人将拥有哪些护甲。我们说过每个物品都有 20%的生成几率。因此,我们需要生成一个从 0 到 4(包括 4)的数字。这样一来,结果为 0 的概率就是 20%。因此,我们可以使用这个来确定是否应该生成该护甲物品:

// Create variables to determine what armor be created.
int hasHelmet(0), hasTorso(0), hasLegs(0);

hasHelmet = std::rand() % 5;
hasTorso = std::rand() % 5;
hasLegs = std::rand() % 5;

// Spawn helmet.
if (hasHelmet == 0)
{
}

// spawn torso.
if (hasTorso == 0)
{
}

// spawn legs.
if (hasLegs == 0)
{
}

现在我们已经随机选择了敌人将拥有的护甲物品(如果有的话),我们可以将注意力转向通过编辑精灵来创建不同的护甲等级。这需要大量的代码来实现。因此,从这一点开始,我们将只关注头盔选项。

加载默认护甲纹理

首先,我们需要加载默认的护甲纹理。每个敌人有八种可能的动画状态,这意味着我们需要加载所有八种头盔对应的纹理。我们将以与在构造函数中加载默认精灵类似的方式来做,创建一个纹理数组,并使用动画状态的枚举作为索引,如下所示:

// Spawn helmet.
if (hasHelmet == 0)
{
  // Load the default helmet textures.
  int defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::COUNT)];

  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::WALK_UP)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_walk_front.png");
  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::WALK_DOWN)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_walk_front.png");
  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::WALK_RIGHT)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_walk_side.png");
  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::WALK_LEFT)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_walk_side.png");
  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::IDLE_UP)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_idle_front.png");
  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::IDLE_DOWN)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_idle_front.png");
  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::IDLE_RIGHT)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_idle_side.png");
  defaultHelmetTextureIDs[static_cast<int>(ANIMATION_STATE::IDLE_LEFT)] = TextureManager::AddTexture("../resources/armor/helmet/spr_helmet_idle_side.png");

默认精灵加载完毕后,我们现在可以选择它们属于哪种护甲等级,因此,我们需要对它们应用什么颜色进行选择。

选择护甲等级

每种类型将有三种护甲等级,即黄金、白银和青铜。因此,我们需要决定使用哪种等级。我们可以采取一种天真的方法,从 0 到 2 生成一个数字,但这并不理想。每个等级的生成机会都是相同的,即 33%。

让我们在选择护甲等级时更加狡猾,使白银比青铜更加稀有,黄金更加稀有。为了做到这一点,我们仍然会使用std::rand()函数,但我们会更加聪明地使用结果。首先,我们需要决定每种生成的可能性。假设我们希望其中 50%是青铜,35%是白银,15%是黄金。

这些百分比看起来不错,很好处理,因为它们总和为 100。为了复制它们的机会,我们需要生成一个从 1 到 100 的数字,并且我们可以用它来获得期望的百分比:

  • 我们有 50%的机会生成一个介于 1 到 50 之间的数字,因为它代表了总可能范围的一半(50/100)

  • 我们有 35%的机会生成一个在 51 到 85 范围内的数字,因为这个范围包括了 100 个可能值中的 35 个(35/100)

  • 最后,我们有 15%的机会生成一个在 86 到 100 范围内的数字,因为这个范围包括了 100 个可能值中的 15 个(15/100)

让我们将以下代码添加到我们的函数中,继续从上一段代码加载默认纹理:

// Generate random number to determine tier.
sf::Color tierColor;
int tierValue = std::rand() % 100 + 1;

// Select which tier armor should be created.
if (tierValue < 51)
{
    tierColor = sf::Color(110, 55, 28, 255); // Bronze.
}
else if (tierValue < 86)
{
    tierColor = sf::Color(209, 208, 201, 255); // Silver.
}
else
{
    tierColor = sf::Color(229, 192, 21, 255); // Gold.
}

注意

我们使用了std::rand() % 100 + 1,而不是std::rand() % 100。虽然它们在技术上做的是一样的事情,但第一个生成了一个从 1 到 100 的数字,而后一个生成了一个从 0 到 99 的数字。第一个使我们更容易处理。

我们创建了一个简单的if语句,定义了我们之前确定的每个范围。然而,当我们来到金色的if语句时,就没有必要了,因为我们已经定义了其他范围。因此,我们现在知道剩下的任何东西都在 86 到 100 的范围内。因此,我们可以简单地使用一个else语句,节省了一个评估。

在这个阶段,我们已经随机选择了一个头盔,加载了默认精灵,并选择了一个阶级。

渲染盔甲纹理

下一步是编辑盔甲纹理并将其覆盖在默认敌人纹理上。目前,每种盔甲类型我们只有一个灰色精灵。我们需要使用本章前面学到的精灵修改技巧来创建青铜和金色版本。我们可以将灰色保留为银色!

完成此操作所需的流程如下:

  • 加载默认头盔纹理

  • 使用我们之前设置的tierColor变量编辑颜色

  • armorTextures数组中绘制修改后的盔甲纹理

我们需要对敌人的每个动画都这样做。因此,我们将armorTextures数组封装在一个for循环中,迭代ANIMATION_STATE枚举的每个值,如下所示:

// Render helmet to armor texture.
for (int i = 0; i < static_cast<int>(ANIMATION_STATE::COUNT); ++i)
{
  // Load the default helmet texture and set its color.
  sf::Sprite tempSprite;
  tempSprite.setTexture(TextureManager::GetTexture(defaultHelmetTextureIDs[i]));
  tempSprite.setColor(tierColor);

  // Flip the texture vertically.
  sf::Vector2u size = armorTextures[i].getTexture().getSize();
  tempSprite.setTextureRect(sf::IntRect(0, size.y, size.x, -size.y));

  // Draw the texture.
  armorTextures[i].draw(tempSprite);
}}

armorTextures数组现在包含所有头盔精灵,并且它们的颜色已经设置为随机的阶级值。现在我们需要对躯干和腿做完全相同的事情,再次绘制相同的armorTextures数组,以便我们可以构建盔甲纹理。这留作本章末尾的练习。现在,让我们看看如何将这些组合在一起创建最终纹理。

渲染最终纹理

现在盔甲纹理已经创建,我们需要将它们渲染在默认敌人纹理的上方,以创建最终图像。我们在构造函数中创建了所有默认纹理的副本,所以我们只需要在上面绘制我们新创建的盔甲纹理,然后保存为最终纹理。需要记住的一件事是sf::Texture::copyToImage函数会垂直翻转图像。因此,在保存最终版本之前,我们需要将其翻转回来。

让我们添加这最后一部分代码。这段代码需要放在所有盔甲已生成的后面,因此将是Humanoid::GenerateArmor函数中的最后一块代码:

// Create the final render texture.
for (int i = 0; i < static_cast<int>(ANIMATION_STATE::COUNT); ++i)
{
    sf::Sprite baseSprite, armorSprite;

    // Draw the default texture.
    baseSprite.setTexture(m_textures[i]);
    finalTextures[i].draw(baseSprite);

    // Draw armor on top.
    armorSprite.setTexture(armorTextures[i].getTexture());
    finalTextures[i].draw(armorSprite);

    // Flip the texture vertically.
    sf::Image img = finalTextures[i].getTexture().copyToImage();
    img.flipVertically();

    // Store the resulting texture.
    m_textures[i].loadFromImage(img);
}

现在这个函数已经完成,剩下的就是在我们的构造函数末尾调用它:

    . . .
    // Copy textures.
    for (int i = 0; i < static_cast<int>(ANIMATION_STATE::COUNT); ++i)
    {
        m_textures[i] = TextureManager::GetTexture(m_textureIDs[i]);
    }

    // Generate armor.
    GenerateArmor();
}

覆盖默认绘制行为

我们对象的动画代码位于基类Object中。当纹理需要更新时,它会去m_textureIDs变量中获取正确的纹理,从TextureManager类中。由于我们已经创建了自己的纹理并将它们存储在新的m_textures数组中,我们需要覆盖这个默认行为以提供我们自己的纹理。

首先,我们需要通过在Humanoid.h中添加以下声明来覆盖更新函数:

/**
* Overrides the update event of enemy.
* @param timeDelta The time that has elapsed since the last update.
*/
void Update(float timeDelta) override;

我们仍然需要调用父类的实现,因为那里是动画逻辑所在。但是,一旦完成了这一点,我们需要在绘制之前提供我们自己的纹理。幸运的是,这很容易做到:

// Overrides the update event of enemy.
void Humanoid::Update(float timeDelta)
{
    // Call parent functionality.
    Enemy::Update(timeDelta);

    // Update the texture with our custom textures.
    m_sprite.setTexture(m_textures[m_currentTextureIndex]);
}

调试和测试

在运行游戏之前,让我们添加一些调试代码来看看我们的工作。之前,我们介绍了如何将纹理保存为图像文件。所以,让我们在这里使用它来保存我们将创建的所有程序精灵。

让我们使用以下代码更新创建最终纹理的循环:

// Save the texture to disk.
if ((hasHelmet == 0) || (hasTorso == 0) || (hasLegs == 0))
{
  std::stringstream stream;
  stream << "../resources/test_" << i << ".png";
  img.saveToFile(stream.str());
}

这段代码所做的一切就是在生成一件盔甲时将纹理保存到资源文件夹中。如果你运行游戏几次,记住每个骷髅只有 20%的几率调用这段代码,并前往resources文件夹,你会看到以下精灵:

调试和测试

这些就是程序生成的精灵!在我的例子中,它是一个骷髅,带有一个我们不必绘制的随机层级的随机一部分盔甲。我们绘制了组成部分,进行了一些程序编辑,并以编程方式将它们组合在一起!

好了,经过这一切,是时候测试代码了。如果一切顺利,当你运行游戏时,你应该会看到一些带头盔的骷髅和哥布林!请记住,每个敌人只有 20%的几率戴着头盔。如果你运气不好,可能需要运行几次游戏才能看到它:

调试和测试

在继续之前,您可以删除我们刚刚添加的用于保存精灵的调试代码。这纯粹是为了调试目的。本章末尾的练习之一是完成代码,并为躯干和腿部盔甲选项添加相同的行为,但请随意进一步进行。实验!

编辑游戏瓦片

我们将要看的最终系统将为本书后面要介绍的内容奠定基础。我们将创建一个系统,使地牢的每一层都成为一个独特的环境,实现我们对游戏瓦片的精灵修改的了解。

游戏的目标是尽可能通过尽可能多的楼层,获得尽可能高的分数。在第九章中,程序生成地牢,我们将看看如何程序生成地牢,并且在每五层之后,我们将改变主题。让我们创建一个函数,以后在书中使用它来完成这个目标。

解决这个问题的最佳方法是向Level对象添加一个函数,设置所有瓦片精灵的颜色。这将是一个公共函数,因为我们将从主游戏类中调用它。

让我们从在Level头文件中定义sf::color函数开始,如下所示:

public:
  /**
   * Sets the overlay color of the level tiles.
   * @param tileColor The new tile overlay color
   */
  void SetColor(sf::Color tileColor);

这个函数的定义非常简单。它只是迭代网格中的所有精灵,将它们的颜色设置为传递的参数:

// Sets the overlay color of the level tiles.
void Level::SetColor(sf::Color tileColor)
{
  for (int i = 0; i < GRID_WIDTH; ++i)
  {
    for (int j = 0; j < GRID_HEIGHT; ++j)
    {
      m_grid[i][j].sprite.setColor(tileColor);
    }
  }
}

有了这个,我们实际上已经完成了。就是这样!我们将在本章后面使用这个函数,但让我们在这里测试一下。我们在Game.cpp中初始化Level对象,所以一旦我们加载了纹理,我们就可以调用Level::SetColor函数,并设置关卡的主题。

让我们用以下测试代码更新Game::Initialize函数:

// Set the color of the tiles
m_level.SetColor(sf::Color::Magenta);

有了这个,我们可以看到一旦我们正确实现了功能,关卡会是什么样子。让我们运行游戏,看看会发生什么:

编辑游戏瓦片

Level瓦片现在都有一个应用于构成环境的所有精灵的环境颜色,这样我们就可以为我们的关卡创建独特的外观和感觉。就像我之前提到的,我们将在以后以编程方式生成随机关卡时使用这个系统。现在,我们可以删除调试代码,坐等系统准备好使用!

练习

为了帮助你测试本章内容的知识,这里有一些练习,你应该通过它们进行练习。它们对于本书的其余部分并不是必要的,但通过它们的练习,可以帮助你评估自己在所涵盖材料中的优势和劣势:

  1. 给哥布林敌人一个稍微随机的颜色和比例,每次生成一个。

  2. 通过完成躯干和腿部盔甲的条件,完成为人形生物程序生成盔甲的代码。

  3. 尝试以更简洁的方式生成盔甲。我们使用了两种纹理;也许有一种方法只使用一种。看看你能否改进这个函数。

总结

在本章中,我们学习了如何程序生成游戏艺术。我们采取了一个天真的方法开始,简单地使用内置的精灵修改器和随机数生成器,然后算法地生成我们自己的图像。生成程序艺术是一个广阔的主题,你可以写一本关于这个主题的书。希望这一章对你介绍了这个主题。

在下一章中,我们将看一下艺术的表兄弟音频。现在我们的艺术是通过程序生成的,我们将使用类似的技术来创造声音的变化。我们还将使用 SFML 的音频功能来创建专门的 3D 声音,从而为关卡带来更多的深度。

第七章:程序修改音频

现在我们的游戏艺术已经接受了程序处理,让我们把注意力转向它的邻居,声音。优秀的声音对于一个好游戏至关重要。想想超级马里奥跳跃的声音有多具有标志性,或者吃豆人中吃豆鬼的声音!出色的配乐和游戏音效帮助玩家沉浸在我们作为游戏开发者创造的世界中。这是一个需要正确完成的领域,这里需要足够的多样性,以便你的玩家不会厌倦一遍又一遍地听到相同的音效。

我们可以手动创建大量的声音效果变体,但这不是程序化的方式!相反,我们将在运行时随机修改声音,以便每次播放时都创建略有不同的声音。然后,我们将利用 SFML 的音频功能创建空间化的 3D 声音,从而为游戏增添更多的深度和沉浸感。

从头开始程序生成音频是一个非常复杂的任务。我们在这个领域的工作将会相对简短,真正局限于对现有声音进行程序化修改,而不是完全创作它们。不过,这将作为一个向音频采用程序化方法的良好介绍。

在本章中,我们将涵盖以下主题:

  • SFML 音频

  • sf::soundsf::music 之间的区别

  • 修改现有的音效

  • 创建空间化的 3D 声音

SFML 音频简介

SFML 有自己专门的音频模块,提供了许多有用的函数,我们可以用来修改声音。SFML 中有两种主要的声音类型:sf::Soundsf::Music。我们将很快详细介绍这两种类型之间的区别。它还提供了许多函数来编辑声音的属性,如音调和音量。我们将使用这些函数给我们的声音效果增加一些变化。

sf::Sound 与 sf::Music

在开始处理音频之前,我们需要看一下 sf::Soundsf::Music 之间的区别:

  • Sf::Sound 适用于像拾取物品或脚步声这样的短声音剪辑。声音会完整地加载到内存中,并且准备好播放,没有延迟。

  • Sf::Music 用于更长、更大的声音文件,并不会加载到内存中;它在使用时会被流式传输。

这可能看起来是一个细微的差别,但使用正确的类型非常重要。例如,如果我们将游戏的音乐加载到一个 sf::Sound 对象中,游戏会使用大量内存!

sf::SoundBuffer

在 SFML 中创建精灵时,我们创建一个包含比例和位置等信息的 sf::Sprite 对象。纹理本身存储在一个 sf::Texture 对象中,精灵对象持有对它的引用。sf::Sound 类的工作方式与此类似,一个 sf::SoundBuffer 对象持有实际的声音,而 sf::Sound 只是持有对它的引用。

以下代码显示了如何加载声音:

sf::SoundBuffer buffer;
buffer.loadFromFile("sound.wav");

sf::Sound sound;
sound.setBuffer(buffer);
sound.play();

sf::SoundBuffer 对象必须保持活跃的时间与 sf::Sound 对象一样长。如果 sf::SoundBuffer 在持有对它引用的 sf::Sound 对象之前就超出了作用域,我们将会收到一个错误,因为它会尝试播放一个不再存在的声音。

另外,由于我们只持有对声音缓冲区的引用,它可以在多个声音对象中使用。要播放声音,我们只需调用 sf::Sound::play,这将在单独的线程中运行声音。

选择一个随机的主音轨

目前,游戏没有声音或音乐。在整本书的过程中,我们一直在频繁地运行游戏,一遍又一遍地听着相同的音轨会变得非常乏味。因此,我们一直等到现在才把它放进去。添加声音是一个非常简单的过程。因此,我们将完整地介绍这个过程。

首先,我们将添加一个主音乐轨,作为游戏的基础。但是,我们不会固定一条音轨,而是添加多种可能性,并在启动时随机选择一种。

让我们首先以通常的方式在枚举器中定义所有可能性。将以下代码添加到Util.h中:

// Music tracks.
enum class MUSIC_TRACK {
    ALT_1,
    ALT_2,
    ALT_3,
    ALT_4,
    COUNT
};

根据enum显示,我们将有四个可能的音轨。这些已经包含在/resources/music/文件夹中。因此,我们所要做的就是随机选择一条音轨并在游戏开始时加载它。由于我们希望这首音乐立即开始,我们将在Game类的构造函数中插入实现这一点的代码。

我们现在已经几次从枚举器中选择了一个随机值,所以应该很熟悉了。我们将生成一个 1 到MUSIC_TRACK_COUNT(包括)之间的数字,但是,与其像通常那样将其转换为枚举器类型,我们将把它留在整数形式。这背后的原因很快就会显而易见。

现在,让我们将以下代码添加到Game::Game中:

// Setup the main game music.
int trackIndex = std::rand() % static_cast<int>(MUSIC_TRACK::COUNT) + 1;

现在,我们之所以没有转换为enum类型,是因为在加载声音时我们可以很聪明。我们有四个音乐曲目可供选择,它们的名称如下:

  • msc_main_track_1.wav

  • msc_main_track_2.wav

  • msc_main_track_3.wav

  • msc_main_track_4.wav

请注意,它们名称中唯一不同的是它们的编号。我们已经生成了 1 到 4 之间的一个数字。因此,我们可以简单地使用这个索引来加载正确的音轨,而不是创建一个switch语句,如下所示:

// Load the music track.
m_music.openFromFile("../resources/music/msc_main_track_" + std::to_string(trackIndex) + ".wav");

现在,当我们调用m_music.play()时,声音将被流式传输。最后,通过调用这个函数来完成:

m_music.play();

如果我们现在运行游戏,我们将听到四个随机选择的音轨中的一个正在播放!

添加音效

现在,我们已经有了游戏的主要音乐,让我们把一些音效加入其中!我们已经介绍了sf::Sound,sf::SoundBuffer以及如何播放声音,所以我们已经准备好开始了。

我们的游戏中将会有一些音效。一个用于敌人的死亡,一个用于我们被击中,一个用于每个拾取,以及一个用于我们稍后将要播放的火炬的声音。

我们将首先在Game.h中为每个声音定义sf::Sound变量:

/**
 * Torch sound.
 */
sf::Sound m_fireSound;

/**
 * Gem pickup sound.
 */
sf::Sound m_gemPickupSound;

/**
 * Coin pickup sound.
 */
sf::Sound m_coinPickupSound;

/**
* Key collect sound.
*/
sf::Sound m_keyPickupSound;

/**
 * Enemy die sound.
 */
sf::Sound m_enemyDieSound;

/**
 * Player hit sound.
 */
sf::Sound m_playerHitSound;

现在,让我们在Game::Initialize中初始化这些声音,如下所示:

// Load all game sounds.
int soundBufferId;

// Load torch sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_fire.wav");
m_fireSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_fireSound.setLoop(true);
m_fireSound.play();

// Load enemy die sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_enemy_dead.wav");
m_enemyDieSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));

// Load gem pickup sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_gem_pickup.wav");
m_gemPickupSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));

// Load coin pickup sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_coin_pickup.wav");
m_coinPickupSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));

// Load key pickup sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_key_pickup.wav");
m_keyPickupSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));

// Load player hit sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_player_hit.wav");
m_playerHitSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));

音效初始化后,我们只需在需要时调用sf::Sound::play来播放声音。我们在Game::UpdateItems函数中处理物品拾取。因此,我们将把这段代码放在那里:

// check what type of object it was
switch (m_items[i]->GetType())
{
    case ITEM_GOLD:    
    {
        // Get the amount of gold.
        int goldValue = dynamic_cast<Gold&>(item).GetGoldValue();

        // Add to the gold total.
        m_goldTotal += goldValue;

        // Check if we have an active level goal regarding gold.
        if (m_activeGoal)
        {
            m_goldGoal -= goldValue;
        }

        // Play gold collect sound effect
 m_coinPickupSound.play();
    }
    break;

    case ITEM_GEM:
    {
        // Get the score of the gem.
        int scoreValue = dynamic_cast<Gem&>(item).GetScoreValue();

        // Add to the score total
        m_scoreTotal += scoreValue;

        // Check if we have an active level goal.
        if (m_activeGoal)
        --m_gemGoal;

 // Play the gem pickup sound
 m_gemPickupSound.play();
    }
    break;
}

这段代码只涵盖了金币和宝石的拾取。对于所有其他拾取和需要播放声音的情况,比如敌人死亡和玩家受到伤害时,需要做同样的事情。

编辑音效

添加了音效后,我们现在可以对它们进行修改以创建多样性。SFML 提供了许多我们可以操作声音的方式,其中包括以下内容:

  • 音调

  • 音量

  • 位置

我们将从最简单的开始:音调。然后,我们将通过创建空间化声音来涵盖音量和位置。每次播放声音效果时,这些值将被随机设置。在我们深入研究之前,让我们创建一个函数来封装声音的修改和播放。这将使我们免于在整个类中重复代码。

播放声音函数

与敌人和物品的碰撞在主游戏类中进行处理。因此,我们将在这里放置播放音效的函数。将以下函数声明添加到Game.h中:

/**
 * Plays the given sound effect, with randomized parameters./
 */
void PlaySound(sf::Sound& sound, sf::Vector2f position = { 0.f, 0.f });

这个函数接受两个参数:我们将要播放的声音作为引用传递,以避免昂贵的复制,我们还包括一个参数,用于指定我们想要播放声音的位置。请注意,我们给位置参数一个默认值{ 0.f, 0.f }。因此,如果我们希望这样做,它可以被忽略。当我们创建空间化声音时,我们将详细介绍这个参数的作用。

让我们暂时给这个类一个基本的主体,简单地播放通过参数传递的声音:

// Plays the given sound effect, with randomized parameters.
void Game::PlaySound(sf::Sound& sound, sf::Vector2f position)
{
    // Play the sound.
    sound.play();
}

请注意,如果游戏规模更大,我们有许多声音,将值得将播放声音的行为封装在管理它们的同一类中。这将确保所有与声音的交互都通过一个公共类进行,并保持我们的代码有组织性。

音频听众

SFML 带有一个静态听众类。这个类充当了关卡中的耳朵,因此在一个场景中只有一个听众。由于这是一个静态类,我们从不实例化它,并且通过它的静态函数与它交互,比如sf::Listener::setPosition

我所说的“在关卡中的耳朵”,是指在这个位置听到关卡中的所有声音。这就是我们创建 3D 声音的方式。例如,如果声音的来源在听众的右侧,那么在右扬声器中会听到更多声音。看一下下面的图表:

音频听众

在这个图表中,蓝色圆圈代表音频听众的位置,红色圆圈代表声音的位置。你可以看到,由于声音的来源在听众的右侧,我们可以利用这一点来确定声音应该从右扬声器中听到的比从左扬声器中听到的更多。这就是空间化声音的创建方式,我们将在本章后面详细讨论。

对于我们不希望声音被空间化的情况,SFML 给了我们sf::Sound::setRelativeToListener函数。这是一个不言自明的函数;声音的位置是相对于听众的位置而不是在场景中的绝对位置。我们将其设置为true,并给声音一个位置{0.f, 0.f, 0.f},将其放在听众的正上方。

关于前面的图表,这意味着蓝色的音频听众将直接放在红色的声源的正上方,这意味着它不是空间化的。这是我们希望捡起声音的行为。对于每个声音,我们需要调用这个函数,将true作为参数传递。

让我们更新代码来改变这一点:

// Load gem pickup sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_gem_pickup.wav");
m_gemPickupSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_gemPickupSound.setRelativeToListener(true);
// Load coin pickup sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_coin_pickup.wav");
m_coinPickupSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_coinPickupSound.setRelativeToListener(true);

// Load key pickup sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_key_pickup.wav");
m_keyPickupSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_keyPickupSound.setRelativeToListener(true);

// Load player hit sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_player_hit.wav");
m_playerHitSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_playerHitSound.setRelativeToListener(true); 

与玩家位置相同的位置产生的声音需要这个。例如,物品只有在敌人占据相同空间时才会被捡起。你永远不会从远处捡起物品,所以声音永远不会被空间化。

创建音调波动

音调是听到声音的感知频率。SFML 提供了一种增加或减少声音音调的方法,它通过增加/减少播放速度来实现。播放得更快,声音就会听起来更高。默认值为 1,因此生成一个小于或大于 1 的数字将给我们带来音调的波动。

我们将把这个行为添加到我们的新的Game::PlaySound函数中。首先,我们将生成一个介于 0.95 和 1.05 之间的数字,设置音调,并播放声音,如下所示:

// Plays the given sound effect, with randomized parameters.
void Game::PlaySound(sf::Sound& sound, sf::Vector2f position)
{
 // Generate and set a random pitch.
 float pitch = (rand() % 11 + 95) / 100.f;
 sound.setPitch(pitch);

    // Play the sound.
    sound.play();
}

现在,每当我们想要一个声音有这种音调波动时,我们需要通过这个函数播放它,而不是直接播放。这适用于所有的捡起声音。所以,让我们实现这个改变:

// check what type of object it was
switch (m_items[i]->GetType())
{
    case ITEM_GOLD:
    {
        // Get the amount of gold.
        int goldValue = dynamic_cast<Gold&>(item).GetGoldValue();

        // Add to the gold total.
        m_goldTotal += goldValue;

        // Check if we have an active level goal regarding gold.
        if (m_activeGoal)
        {
            m_goldGoal -= goldValue;
        }

 // Play gold collect sound effect
 PlaySound(m_coinPickupSound);
    }
    break;

    case ITEM_GEM:
    {
        // Get the score of the gem.
        int scoreValue = dynamic_cast<Gem&>(item).GetScoreValue();

        // Add to the score total
        m_scoreTotal += scoreValue;

        // Check if we have an active level goal.
        if (m_activeGoal)
        {
            --m_gemGoal;
        }

 // Play the gem pickup sound
 PlaySound(m_gemPickupSound);
    }
    break;
}

如果我们现在玩游戏并捡起一些物品,我们会听到每次捡起声音都略有不同,给声音效果带来了一些变化。如果你希望在捡起钥匙、敌人死亡和玩家受到攻击时播放的声音也有音调波动,确保它们也通过这个函数播放,而不是直接播放。

3D 声音-空间化

现在让我们看看如何创建一些 3D 音频来为游戏场景增加深度。当我们走过一个火炬时,我们希望听到它从我们身边经过,我们希望能够听到敌人从一个方向向我们走来。空间化允许我们做到这一点,SFML 有很好的功能来帮助我们实现这一点。

音频听众

我们已经定义了音频听者是什么以及它是如何用来创建空间化音频的。作为实现这一目标的第一步,我们需要在每次更新后设置听者的位置,确保关卡中的所有声音都是从玩家的视角听到的。

在每个游戏更新的开始,我们重新计算玩家的位置。在这之后,我们可以将听者类的位置更新到这个新位置。记住sf::Listener是一个静态类,我们不需要实例化它。我们所需要做的就是静态调用sf::Listener::setPosition

让我们将这个附加到Game::Update函数中,如下所示:

// Update the player.
m_player.Update(timeDelta, m_level);

// Store the player position as it's used many times.
sf::Vector2f playerPosition = m_player.GetPosition();

// Move the audio listener to the players location.
sf::Listener::setPosition(playerPosition.x, playerPosition.y, 0.f);

// If the player is attacking create a projectile.
if (m_player.IsAttacking())
{

继续前进,我们现在可以确保听者处于正确的位置,以便我们创建 3D 声音。

最小距离

最小距离是玩家在听到声音的全音量之前可以接近声源的最近距离。想象它是围绕声源的一个圆圈。这个圆圈的半径是MinDistance,如下图所示:

最小距离

在我们的情况下,声音的最小距离在游戏过程中不会改变,这意味着我们可以在加载声音时在Game::Initialize函数中设置它们的值一次。我们在这里使用的值是个人偏好的问题,但我发现最小距离为80.f效果很好。让我们设置这些值。

Game::Initialize函数进行以下修改:

// Load torch sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_fire.wav");
m_fireSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_fireSound.setLoop(true);
m_fireSound.setMinDistance(80.f);
m_fireSound.play();

// Load enemy die sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_enemy_dead.wav");
m_enemyDieSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_enemyDieSound.setMinDistance(80.f); 

衰减

衰减基本上意味着“减少”或“使某物变小”。在音频的上下文中,它是声音随着我们远离声源而变得更安静的速率。当我们超出最小距离时,这就会生效,并用于计算声音的音量。

在下图中,渐变代表声音的音量。左边的图显示了高衰减,声音下降得非常快,而右边的图显示了低衰减,声音下降得更平稳:

衰减

现在,让我们给我们的两个声音一个衰减值,就像我们在最小距离上做的那样。同样,这里使用的值取决于您,但我发现一个5.f的衰减值,略高于默认值,可以创建一个不错的淡出效果。

Game::Initialize函数进行以下修改:

// Load torch sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_fire.wav");
m_fireSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_fireSound.setLoop(true);
m_fireSound.setAttenuation(5.f);
m_fireSound.setMinDistance(80.f);
m_fireSound.play();

// Load enemy die sound.
soundBufferId = SoundBufferManager::AddSoundBuffer("../resources/sounds/snd_enemy_dead.wav");
m_enemyDieSound.setBuffer(SoundBufferManager::GetSoundBuffer(soundBufferId));
m_enemyDieSound.setAttenuation(5.f);
m_enemyDieSound.setMinDistance(80.f);

如果我们现在运行游戏,我们会看到当我们靠近火炬时,它们会变得更响亮,当我们走开时,它们会变得更安静。然而,它们并不是 3D 的。为此,我们需要更新声音的源!

声音的位置

声音的位置就是它在场景中的位置。正是这个位置和听者的位置被用来创建 3D 效果,并确定声音应该从哪个扬声器播放出来。

提示

要使用空间定位,您的声音需要是单声道(只有一个声道)。这个项目提供的声音是这样的,但是如果您要添加自己的声音,您需要记住这一点!具有多个声道的声音已经明确决定如何使用扬声器。

现在我们已经设置了衰减和最小距离,我们现在可以设置声音的正确位置,这样我们就可以听到 3D 效果。游戏中有两种声音将会是 3D 的:火炬的声音和敌人被杀死时的声音。由于关卡中有多个火炬,我们在这里有一些工作要做。我们将从两者中较简单的一个开始:敌人被杀死时的声音。

固定位置

首先,我们需要更新Game::PlaySound函数。目前它只生成一个随机音调,但我们需要它设置位置。您可能还记得,我们通过给位置参数一个默认值{0.f, 0.f }来使其成为可选参数。当我们传递一个位置并覆盖默认值时,这意味着我们想要利用 3D 声音。当我们留空时,这意味着我们不想这样做,声音将相对于听者。因此,{0.f, 0.f, 0.f}正是我们需要的。

让我们连接Game::PlaySound中的位置参数,并使用它来设置声音的位置,如下所示:

// Plays the given sound effect, with randomized parameters.
void Game::PlaySound(sf::Sound& sound, sf::Vector2f position)
{
    // Generate and set a random pitch.
    float pitch = (rand() % 11 + 95) / 100.f;
    sound.setPitch(pitch);

 // Set the position of the sound.
 sound.setPosition(position.x, position.y, 0.f);

    // Play the sound.
    sound.play();
}

声音的位置在三维空间中运作,但由于我们正在处理二维声音,我们可以将Z值保留为0.f。现在,当我们确定敌人已被杀死时,我们只需调用此函数并传递正确的声音和敌人的位置,因为声音就是来自那里,如下所示:

// 1 in 5 change of spawning potion.
else if ((std::rand() % 5) == 1)
{
    position.x += std::rand() % 31 - 15;
    position.y += std::rand() % 31 - 15;
    SpawnItem(ITEM::POTION, position);
}

// Play enemy kill sound.
PlaySound(m_enemyDieSound, enemy.GetPosition());

// Delete enemy.
enemyIterator = m_enemies.erase(enemyIterator);

现在是再次运行游戏并听听我们的成果的时候了。当我们杀死敌人时,我们可以听到他们离得越远,声音就越微弱。此外,如果我们向右边杀死一个敌人,我们会听到声音来自那个方向!为了完成我们的声音工作,让我们将相同的技术应用到火炬上,真正为关卡的音频增加一些深度。

注意

3D 声音的清晰度将取决于您的设置。例如,耳机可以让您轻松地听到不同方向的声音,而笔记本电脑扬声器可能就不那么清晰了。

移动位置

我们将为最后一个区域添加 3D 声音的是关卡中的火炬。当我们在关卡中走动时,能够在远处微弱地听到火炬的声音,或者当我们走过时在耳机中近距离地听到。然而,存在一个小问题。我们知道声音的空间化是在声音和听者相距一定距离时实现的。但是如果我们有一个需要来自多个位置的声音怎么办?我们可以为每个火炬设置一个声音,但这样很浪费。相反,我们将计算哪个火炬离玩家最近,并将其用作声源。

作为我们主要的更新函数的一部分,我们需要查看所有的火炬,并确定哪一个离玩家最近。当玩家在关卡中走动时,声源会切换,给我们一种每个火炬都发出自己的声音的印象,而实际上我们只有一个声源。

我们已经有一个函数来找到两个对象之间的距离,即Game::DistanceBetweenPoints。有了这个,我们可以遍历所有的火炬,并使用这个函数来获取到玩家的距离。让我们更新Game::Update函数以包括这个计算,如下所示:

// Update all projectiles.
UpdateProjectiles(timeDelta);

// Find which torch is nearest the player.
auto torches = m_level.GetTorches();

// If there are torches.
if (!torches->empty())
{
 // Store the first torch as the current closest.
 std::shared_ptr<Torch> nearestTorch = torches->front();
 float lowestDistanceToPlayer = DistanceBetweenPoints(playerPosition, nearestTorch->GetPosition());

 for (std::shared_ptr<Torch> torch : *torches)
 {
 // Get the distance to the player.
 float distanceToPlayer = DistanceBetweenPoints(playerPosition, torch->GetPosition());
 if (distanceToPlayer < lowestDistanceToPlayer)
 {
 lowestDistanceToPlayer = distanceToPlayer;
 nearestTorch = torch;
 }
 }
}

// Check if the player has moved grid square.
Tile* playerCurrentTile = m_level.GetTile(playerPosition);

正如您所看到的,对于关卡中的每个火炬,我们都会计算它离玩家有多远。如果它比我们上次检查的火炬更近,我们就将其标记为最近的。当这段代码完成时,我们最终得到了存储在名为nearestTorch的共享指针中的最近的火炬。

确定了最近的火炬后,我们可以使用它的位置作为火焰声音的位置。现在,对于其余的声音,我们一直在使用新的Game::PlaySound函数,但这里不适用。我们的火焰声音已经在循环播放,我们不需要重新开始它。我们只需要设置它的位置,所以我们会直接这样做。

让我们再次更新那段代码:

    // Get the distance to the player.
    float distanceToPlayer = DistanceBetweenPoints(playerPosition, torch->GetPosition());
    if (distanceToPlayer < lowestDistanceToPlayer)
        {
            lowestDistanceToPlayer = distanceToPlayer;
            nearestTorch = torch;
        }
    }

 m_fireSound.setPosition(nearestTorch->GetPosition().x, nearestTorch->GetPosition().y, 0.0f);
}

// Check if the player has moved grid square.
Tile* playerCurrentTile = m_level.GetTile(playerPosition);

让我们最后一次运行项目!现在我们应该听到一个随机的音乐曲目,一些我们的音效将以不断变化的音调播放,火炬和敌人死亡的声音将被空间化。

练习

为了帮助您测试对本章内容的理解,这里有一些练习供您练习。它们对本书的其余部分并不是必不可少的,但是练习它们将有助于您评估所涵盖材料的优势和劣势:

  1. 将更多的曲目添加到主曲目列表中。

  2. 在“关卡”中添加一个空间化的声音,当门打开时能听到。当玩家收集到“关卡”的钥匙时,能听到背景中门滑动打开的声音将帮助他们找到它。

  3. 在“关卡”中添加一些大气的音效;这些音效应该是空间化的,并且必须在随机的时间间隔内播放。到目前为止我们还没有涉及到这样的内容,所以这可能是一个挑战。

总结

在本章中,我们使用了 SFML 内置的音频修改器来对我们的音效进行修改。我们还利用这些修改器来创建空间化的 3D 声音,为我们的游戏场景增添了更多的深度。

在下一章中,我们将运用到目前为止学到的一切来创建复杂的程序行为和机制,包括寻路和独特的关卡目标。我们将赋予我们的敌人智能,让他们穿越关卡并追逐玩家,我们还将为玩家创建一个独特的关卡目标,并为其提供独特的奖励。

第八章:程序行为和机制

到目前为止,我们的工作重点一直是资源的程序生成。让我们利用所学的知识,扩展到程序生成行为和游戏机制。虽然创建程序生成的游戏行为听起来很新奇,但你在玩的每个游戏中都会遇到它;人工智能AI)。游戏中的 AI 是根据当前因素在运行时计算行为。这绝对算作程序生成!以前在接触大型主题时,我曾评论过整本书都可以专门讨论这个主题。嗯,对于 AI,你需要整个图书馆。对于我们的项目,我们将研究寻路;让敌人能够在我们的关卡中智能地追逐玩家。

我们将要看的另一个方面是机制的程序生成,特别是生成独特的游戏目标。一个很好的例子是游戏任务。你有多少次遇到过一个任务说,“杀死 X 只动物,给我 Y 只毛皮?”大概有一千次吧!我们可以使用程序生成在这里增加一些变化。我们可以为我们的地牢的每个房间/楼层生成随机目标,使其不那么静态。

在这一章中,我们将涵盖以下主题:

  • A*寻路算法

  • 生成独特的游戏任务

寻路简介

我们将首先着手解决最大的任务:实现一种寻路算法,使敌人能够在地图周围智能移动。在这样做之前,让我们先来看看寻路算法的整体情况,它们的作用以及实现方式!这个背景将帮助你更清晰地了解前面的任务,并展示我们拥有的丰富选择。

什么是寻路算法?

寻路算法是一种计算从一个位置到另一个位置的最佳路径的算法。一个好的算法会考虑地形和其他几个因素,以确保移动是智能的,不会产生任何奇怪的行为。还记得上次玩游戏时 NPC 一直走向墙壁的情况吗?这就是寻路错误产生的奇怪行为。每当敌人在游戏中绕着物体跑来找你时,这就是这种算法的结果,它们对于创造具有挑战性且自然的游戏玩法至关重要。

例如,在下图中,绿色圆圈是一个 NPC,它必须到达红色圆圈:

什么是寻路算法?

在这个例子中,NPC 不能直接朝着目标点前进,因为它会被困在墙壁里。相反,我们需要考虑墙壁并绕过它,如下图所示:

什么是寻路算法?

你可以看到 NPC 在这里聪明地避开了墙壁,同时尽可能高效地到达了目标。这就是寻路的本质,也是我们在本章的第一部分中要实现的内容。让我们来看看箭头后面发生了什么。

Dijkstra 算法

和任何事物一样,寻路可以有多种实现方式,也可以使用多种常见算法来实现。不同的算法有不同的特点,虽然它们的最终产品可能看起来相似,但它们的实现方式是不同的。游戏中最常见的寻路算法是A*,这是 Dijkstra 算法的扩展。

Dijkstra 算法是由 Edsger Dijkstra 于 1959 年创建的。它是一种最佳优先搜索算法,即它首先访问值最小的节点,以产生可能的最短路径。从起点开始,它向外辐射,依次检查每个节点,直到找到目标。你可以想象,这既耗费资源,又可能需要很长时间才能找到终点节点。

下图显示了 Dijkstra 算法在寻找终节点时需要搜索大部分可用节点:

Dijkstra 算法

A*算法

A*是 Dijkstra 算法的扩展。它的目标是通过引入启发式来帮助引导搜索来减少找到终节点所需的时间。启发式(或启发式技术)只是一种使用实用方法来解决问题的方法,它并不完美,但足够。例如,试错是一种基本的启发式。虽然不完美,但您将使用试错方法找到问题的解决方案。

就 A而言,我们的启发式正在考虑已经走过的距离,以引导搜索朝着终节点。再看一下前面显示 Dijkstra 算法的图表。现在,看看 A在以下图表中解决的相同路径查找问题:

A*算法

很明显,A实现倾向于目标位置,因此快速找到了目标节点。此外,看看每个算法必须查看多少节点才能找到目标。Dijkstra 算法实际上访问了每个节点,而在 A中,由于启发式,访问的节点明显更少。

A*的分解

在我们开始编写自己的 A*实现之前,将算法分解为其关键领域并独立查看每个领域对我们有好处。

代表节点的级别

当我们查看 A*时,最重要的理解领域可能是算法将如何查看我们的级别。虽然我们看到瓦片,但路径查找算法只看到节点。在这种情况下,节点只是表示实体可以在级别内移动到的有效位置。

节点的定义因游戏而异。例如,在我们的游戏中,级别已经被描述为瓦片的 2D 数组。因此,网格中的每个瓦片将充当节点。然而,在 3D 游戏中,我们没有这个网格,因此使用导航网格来创建可以表示为节点的表面。

提示

Valve 在其开发者维基页面上有一篇关于导航网格的很棒的文章。因此,如果您想了解更多有关此主题的信息,请访问developer.valvesoftware.com/wiki/Navigation_Meshes

以下图像显示了级别如何分割为其核心的 2D 瓦片数组。这些瓦片中的每一个将在 A*算法中用作节点。玩家可以移动到的有效位置(地板瓦片)用绿色标记,应该避免的瓦片(墙壁、障碍等)用橙色标记。

结果绿色是算法将尝试并找到路径的有效节点区域。

代表节点的级别

开放和关闭列表

一旦节点被识别,它们将存储到以下两个列表中:

  • 开放列表:此列表包含所有等待成为算法主题的节点。当我们进入一些代码时,这将更有意义,但算法一次操作一个节点,开放列表是此队列。

  • 关闭列表:此列表只包含已经通过算法的所有节点。一旦节点被添加到此列表中,它将被忽略,直到算法完成。

H、G 和 F 的成本

阅读 A*路径查找算法时,您将遇到 3 个字母:H、G 和 F。这些是算法中至关重要的值,但它们并不是非常描述性的。因此,让我们花一点时间看看每个值是什么以及它在计算路径中扮演的角色。

H 值

H 值,通常称为启发式,是从当前位置到目标节点的估计成本。级别中的每个节点都有一个 H 值,在路径规划算法开始时计算,然后在后续计算中使用。这个值有助于引导搜索朝着目标节点,而不是在所有方向上均匀分布。如何计算这个值取决于具体的实现方式,但一个常见的方法被称为曼哈顿距离。我们很快会介绍这到底是什么。

G 值

G 值是从起始节点到当前节点的当前移动成本。这是根据具体实现方式计算的。然而,与 H 值一样,常见的方法是曼哈顿距离,我们将使用这种方法。算法迭代时,每次建立两个节点之间的连接时,该单独移动的移动成本将被添加到迄今为止整个路径的移动成本中。这样,随着路径的建立,每个节点都知道它之前的整个路径有多长。

F 值

F 值只是 H 值和 G 值的总和。这个值用于确定算法下一个使用的节点。这个值越低,完整路径的估计值就越低。因此,算法优先考虑这些节点。这种行为是使 Dijkstra 算法,因此 A*算法成为最佳优先搜索算法的原因。

曼哈顿距离

路径规划算法的核心是计算两点之间的距离。如前所述,这是根据具体的实现方式来完成的,但有一种常见且廉价的方法称为曼哈顿距离(也称为出租车几何),这是我们将要使用的方法。

它正式定义为通过取它们的笛卡尔坐标的绝对差的和来计算两点之间的距离。

这听起来有点复杂,但实际上很简单。笛卡尔坐标只是一种相对于两个固定的垂直轴的位置的表示方式(即使这似乎不熟悉,我们在学校都学过),而绝对值只是表示我们忽略一个数字的符号。

看一下以下图表:

曼哈顿距离

我们在图表上有两个点:A(-4,4)B(5,-3)。以下伪代码计算了两者之间的曼哈顿距离:

// Calculate the absolute difference in X.
diffX = abs(-4 – 5) = 9;

// Calculate the absolute difference in Y.
diffY = abs(4 - -3) = 7;

// Add them to get the Manhattan distance.
manhattenDistance = 9 + 7 = 16;

就是这么简单!

节点的父节点

路径规划的另一个关键方面是节点的父节点概念。A*算法通过建立节点链来工作。一旦找到目标节点,我们就会沿着这条链返回,得到最终路径。当确定两个节点之间的最短路径时,节点 A 将被分配为节点 B 的父节点。

例如,以下截图显示了骷髅敌人找到了通往玩家的有效路径的情况:

节点的父节点

让我们想象一种情况,找到两个节点之间的路径。例如,节点67之间的路径。然后,将第一个节点设置为第二个节点的父节点,在这种情况下,节点6被设置为节点7的父节点。这样,每个节点都知道它来自哪里。当算法找到目标节点时(在我们的例子中,是节点2),我们可以使用这个父节点层次结构从目标节点向起始节点工作,得到最终的最短路径。在这种情况下,骷髅和玩家之间的最短路径是6752

伪算法

总结算法的分解,让我们看一个伪代码实现:

  1. 如果可能的话,预先计算 H 值。

  2. 将起始节点添加到开放列表中。

  3. 在开放列表中找到具有最低 F 值的节点。

  4. 从开放列表中删除该节点,并将其添加到关闭列表中。

  5. 对于所有相邻的节点,执行以下步骤:

  • 如果节点是目标节点,则将其父节点设置为当前节点并存储最终路径。

  • 如果节点在关闭列表中,则忽略它并转到步骤 3。

  • 如果节点不在关闭列表和开放列表中,则将其父节点设置为当前节点,并计算其 G 和 F 值。

  • 如果节点不在关闭列表中,但在开放列表中,则检查它与当前节点之间的路径是否比当前路径更快。

这是 A*算法的简化版本。希望这个分解给一些步骤提供了上下文。让我们开始编码吧!

编写 A*寻路算法

通过对 A*的基本原理的理解,让我们开始在游戏中实现它。这将允许敌人在级别中跟随我们的玩家,而不受其拓扑结构的影响。

对于这样一个复杂的算法,有一个视觉表示发生了什么是非常有帮助的。在适当的地方,我们将看一下使用以下示例发生了什么的视觉表示:

编写 A*寻路算法

瓦片数据类型

让我们先快速看一下在Level.h中定义的Tile结构。正如我们所见,一个节点包含了相当多的值。在实现中,级别瓦片将充当节点。因此,节点所需的所有信息都在其类型中定义:

// The level tile/node type.
struct Tile {
    TILE type;          // The type of tile this is.
    int columnIndex;    // The column index of the tile.
    int rowIndex;       // The row index of the tile.
    sf::Sprite sprite;  // The tile sprite.
    int H;              // Heuristic / movement cost to goal.
    int G;              // Movement cost. (Total of entire path).
    int F;              // Estimated cost for full path. (G + H).
    Tile* parentNode;   // Node to reach this node.
};

在本章的其余部分,节点与瓦片是同义词。因此,如果它们可以互换使用,不用担心。但是,请记住,在每个 A*实现中,节点的使用将取决于游戏。

创建支持函数

在我们实现算法本身之前,我们需要创建一些支持算法所需的函数和变量。请注意,这些是特定于实现的,并不是 A*算法的一部分。

Level 类

我们需要做一些基础工作的第一个类是Level类。我们需要一个函数来重置节点/瓦片中的所有变量,因为我们需要这些值在每次运行算法时都重置回它们的默认值。

Level.h中添加以下函数声明:

public:
/**
* Resets the A* data of all level tiles.
*/
void ResetNodes();

还要在Level.cpp中添加以下定义:

// Resets the A* data of all tiles.
void Level::ResetNodes()
{
    for (int i = 0; i < GRID_WIDTH; ++i)
    {
        for (int j = 0; j < GRID_HEIGHT; ++j)
        {
            m_grid[i][j].parentNode = nullptr;
            m_grid[i][j].H = 0;
            m_grid[i][j].G = 0;
            m_grid[i][j].F = 0;
        }
    }
}

您可以看到,我们在这里所做的一切都是迭代级别网格中的每个瓦片,并重置我们将在 A*计算中使用的所有变量。

敌人类

接下来,我们需要在Enemy类中创建一个运行算法的函数。在Enemy.h中添加以下函数声明:

public:
/**
* Recalculates the target position of the enemy.
*/
void UpdatePathfinding(Level& level, sf::Vector2fplayerPosition);

您可以看到,这个函数接受对级别的引用,主要玩家位置,并且是公共的。我们需要这个函数是公共的,这样我们才能从主游戏类中调用它。这是为了效率,稍后会更清楚为什么。我们将传递对级别对象的引用,因为敌人将需要访问级别信息,并且需要计算目标位置的玩家位置。

我们还需要在Enemy.h中添加以下变量:

private:
/**
* The target positions of the enemy.
*/
std::vector<sf::Vector2f> m_targetPositions;

/**
* The current target of the enemy.
*/
sf::Vector2f m_currentTarget;

完成这项工作后,我们可以在Enemy.cpp中为Enemy::UpdatePathFinding添加空的函数定义:

// Updates the target position of the enemy.
void Enemy::UpdatePathfinding(Level& level, sf::Vector2f playerPosition)
{
    // . . .

从这一点开始,所有的代码都将附加到这个函数中。有相当多的内容!

变量声明

函数中的第一步将是声明我们将使用的所有变量:

    // Create all variables.
    std::vector<Tile*> openList;
    std::vector<Tile*> closedList;
    std::vector<Tile*> pathList;
    std::vector<Tile*>::iterator position;
    Tile* currentNode;

openListclosedList变量用于管理节点。openList变量中的节点正在等待检查,closedList变量中的节点已经被检查,从现在开始应该被忽略。当我们在实现中遇到它们时,将会详细解释。pathList变量将存储最终路径中的所有节点。

位置变量是一个迭代器,将用于查找和删除我们的向量中的值。最后,currentNode变量用于跟踪我们当前正在处理的节点。

下一步是重置所有节点。每次运行函数时,我们需要节点具有它们的默认值。为了实现这一点,我们将调用刚刚创建的Level::ResetNodes函数,如下所示:

// Reset all nodes.
level.ResetNodes();

设置的最后一步将是识别起点和终点节点,标记我们正在寻找的路径的起点和终点。起始节点将是敌人的位置。终点节点,也就是我们的目标,是玩家的位置:

// Store the start and goal nodes.
Tile* startNode = level.GetTile(m_position);
Tile* goalNode = level.GetTile(playerPosition);

Level::GetTile函数返回给定位置的瓦片,因此我们可以使用它来获取节点。一旦我们确定了这些节点,我们将进行快速检查,以确保它们不是相同的节点。如果是,它们之间就没有有效的路径,我们可以简单地清除当前路径并退出函数,如下所示:

// Check we have a valid path to find. If not we can just end the function as there's no path to find.
if (startNode == goalNode)
{
    // Clear the vector of target positions.
    m_targetPositions.clear();

    // Exit the function.
    return;
}

此时,我们已经声明了将要使用的所有变量,重置了所有节点的默认值,并确定了我们正在处理有效路径。是时候进入算法的主体部分了!

预先计算 H 值

我们 A*算法实现的下一步是计算级别中每个节点的 H 值。请记住,H 值是从起始节点到目标节点的路径的估计成本。

我们将使用曼哈顿距离。因此,对于级别中的每个瓦片,我们需要计算到目标节点的距离,如下所示:

// Pre-compute our H cost (estimated cost to goal) for each node.
for (int i = 0; i < level.GetSize().x; ++i)
{
    for (int j = 0; j < level.GetSize().y; ++j)
    {
        int rowOffset, heightOffset;
        Tile* node = level.GetTile(i, j);

        heightOffset = abs(node->rowIndex - goalNode->rowIndex);
        rowOffset = abs(node->columnIndex - goalNode-> columnIndex);

        node->H = heightOffset + rowOffset;
    }
}

定义主循环

我们现在将定义算法实际发生的主循环,但在这样做之前,我们需要快速将起始节点添加到开放节点列表中,如下所示:

// Add the start node to the open list.
openList.push_back(startNode);

开放列表是算法留下的所有节点的列表。只要此列表中有值,算法就应该运行。因此,我们将定义此行为以创建主循环,如下所示:

// While we have values to check in the open list.
while (!openList.empty())
{

算法的下一步是决定我们将在下一个操作的节点。您可能记得 F 值用于此目的。开放列表包含所有等待检查的节点。因此,我们需要遍历这个向量并找到具有最低 F 值(完整路径的估计成本)的节点:

// Find the node in the open list with the lowest F value and mark it as current.
int lowestF = INT_MAX;

for (Tile* tile : openList)
{
    if (tile->F < lowestF)
    {
        lowestF = tile->F;
        currentNode = tile;
    }
}

这段代码非常简单。我们最初将lowestF设置为INT_MAX,这是一个包含int的最大值的宏,因为我们可以确保没有任何 F 值会接近那个值。当我们确定具有较小 F 值的节点时,我们更新lowestF值,并标记该节点为下一个需要操作的节点。

一旦我们确定了具有最低 F 值的节点,我们就将其从openList中删除,并将其添加到closedList向量中,以确保我们不会再次操作相同的节点,如下所示:

// Remove the current node from the open list and add it to the closed list.
position = std::find(openList.begin(), openList.end(), currentNode);
if (position != openList.end())
    openList.erase(position);

closedList.push_back(currentNode);

这就是迭代器变量发挥作用的地方。迭代器只是具有迭代一系列元素的能力的对象。要从向量中删除项目,我们调用std::find(),传递向量的开始、结束和我们要查找的值。如果找到该值,std::find()将返回指向该元素的迭代器。如果未找到该值,它将返回一个指向虚构元素的迭代器,该元素将跟随向量中的最后一个元素。然后,我们在openList中调用 erase,传递此迭代器值以找到正确的元素。

查找相邻节点

现在选择了下一个节点并将其分配给currentNode变量后,是时候识别所有相邻节点了。这是另一个将根据每个特定实现而有所不同的领域。

在我们的情况下,级别被定义为 2D 网格。因此,很容易获取周围的节点:

查找相邻节点

您可以从前面的图表中看到,列和行索引ij分别从-1 到 1,围绕中间的瓷砖。我们可以利用这一点来获取我们想要检查的周围节点。我们只对有效的地板节点感兴趣,所以在获取它们时,我们可以执行这些检查。

让我们在函数中实现这一点,如下所示:

// Find all valid adjacent nodes.
std::vector<Tile*> adjacentTiles;

Tile* node;

// Top.
node = level.GetTile(currentNode->columnIndex, currentNode-> rowIndex - 1);
if ((node != nullptr) && (level.IsFloor(*node)))
{
    adjacentTiles.push_back(level.GetTile(currentNode-> columnIndex, currentNode->rowIndex - 1));
}

// Right.
node = level.GetTile(currentNode->columnIndex + 1, currentNode-> rowIndex);
if ((node != nullptr) && (level.IsFloor(*node)))
{
    adjacentTiles.push_back(level.GetTile(currentNode->columnIndex + 1, currentNode->rowIndex));
}

// Bottom.
node = level.GetTile(currentNode->columnIndex, currentNode-> rowIndex + 1);
if ((node != nullptr) && (level.IsFloor(*node)))
{
    adjacentTiles.push_back(level.GetTile(currentNode-> columnIndex, currentNode->rowIndex + 1));
}

// Left.
node = level.GetTile(currentNode->columnIndex - 1, currentNode-> rowIndex);
if ((node != nullptr) && (level.IsFloor(*node)))
{
    adjacentTiles.push_back(level.GetTile(currentNode->columnIndex - 1, currentNode->rowIndex));
}

在这段代码中,我们得到了周围的 4 个节点,确保它们都是有效的地板砖。只有在这种情况下,它们才会被添加到需要检查的相邻节点列表中。有了这些确定的节点,现在我们需要循环遍历每个节点。for循环将允许我们这样做:

// For all adjacent nodes.
for (Tile* node : adjacentTiles)
{

当我们到达目标节点时,算法结束。因此,每次选择相邻节点时,我们都可以检查是否已经到达目标节点。有了目标节点存储在一个变量中,这是一个简单的检查:

// If the node is our goal node.
if (node == goalNode)
{

由于我们通过最低的 F 值选择节点,第一次到达目标节点时,我们知道我们已经走过了最短的可能路径。在继续寻找这条路径之前,我们首先需要将目标节点的父节点设置为当前节点:

// Parent the goal node to current.
node->parentNode = currentNode;

接下来,我们需要构建一个包含从起始节点到目标节点的所有节点的列表。没有固定的方法来做这个,但我们将使用while语句。当节点有父节点时,将节点添加到列表中,然后将节点设置为其父节点。让我们为此添加代码:

// Store the current path.
while (node->parentNode != nullptr)
{
    pathList.push_back(node);
    node = node->parentNode;
}

通过这种方式,我们从目标节点到起始节点构建了一个完整的路径。请注意,结果路径是反向的,但我们稍后会解决这个问题!

现在,最后一步是退出主循环。我们目前嵌套在一个while循环和一个for循环中。为了退出这个循环,我们需要清空开放列表并调用breakbreak组件将我们从for循环中踢出来,现在开放列表为空,我们也退出了while循环:

    // Empty the open list and break out of our for loop.
    openList.clear();
    break;
}
else
{

现在,这一切都完成了,我们已经找到了目标节点,存储了从起点到目标的节点路径,并退出了主循环。这一切都是找到目标节点的结果。现在我们需要把注意力转向我们没有找到目标节点的情况。

计算 G 和 F 成本

如果一个节点在关闭列表中,那么它已经成为算法的主题。所有相邻节点都已经被检查并计算了它们的 G 和 F 值。如果是这种情况,我们可以简单地忽略这个节点:

// If the node is not in the closed list.
position = std::find(closedList.begin(), closedList.end(), node);
if (position == closedList.end())
{

确保节点不在关闭列表中后,我们接下来检查开放列表:

// If the node is not in the open list.
position = std::find(openList.begin(), openList.end(), node);
if (position == openList.end())
{

与之前的检查不同,如果我们的节点在开放列表中,我们不会忽略它。如果节点不在开放列表中,那么这是算法第一次遇到它。如果是这种情况,我们需要执行以下操作:

  1. 将节点添加到开放列表中。

  2. parent设置为currentNode(在检查 F 值时,它是最后一个节点)。

  3. 计算它的 G 值。

  4. 计算它的 F 值。

我们将首先将其添加到开放列表中并设置其父节点;这些都是快速简单的任务:

// Add the node to the open list.
openList.push_back(node);

// Set the parent of the node to the current node.
node->parentNode = currentNode;

计算 G 和 F 成本

您可能还记得 G 成本是从起始节点到该节点的移动总成本。在我们的网格中,我们可以朝四个方向移动,不会对角线移动,所以每次移动成本为10。这个值是特定于实现而不是算法。这是两个节点之间移动的成本,10只是一个很好的数值。

提示

我们之所以不使用对角线,只是为了更容易地进行演示。本章末尾的一个练习是添加对角线移动,我强烈建议您尝试一下!

由于我们知道节点之间的移动成本是10,现在我们需要将currentNode的 G 成本加到其中以得到最终值。currentNode的 G 成本是到目前为止的路径成本,所以将最后的移动成本加到其中,新节点就得到了从起始节点到自身的路径总成本:

// Calculate G (total movement cost so far) cost.
node->G = currentNode->G + 10;

最后,我们需要计算节点的 F 成本,这只是它的 G 和 H 成本的总和。我们刚刚计算了 G 成本,并且在算法开始时预先计算了 H 成本。所需的只是一个简单的加法:

// Calculate the F (total movement cost + heuristic) cost.
node->F = node->G + node->H;

检查更优越的路径

算法的最后一步是检查节点是否已经在开放列表中,如果是的话,我们已经生成了它的 G 和 F 值。然而,现在我们需要检查它们是否是最低可能的值。

在下图中,节点7是节点8的父节点,节点8是节点5的父节点:

检查更优越的路径

这导致了从节点785的移动成本为30。然而,这并不是最短的路径。从75的移动成本,假设我们允许对角线移动,是14。如果我们从路径中去掉8,总移动成本就是24,低于当前的 30。在这种情况下,我们将7作为5的父节点,而不是8。由于我们不使用对角线移动,这个例子不适用,除非你自己添加对角线移动。

希望这能展示出我们正在寻找更优越的路径,如下图所示:

检查更优越的路径

我们可以看到,节点5的移动成本更低,它是7的父节点。这创建了一个比之前更短的对角线路径。

让我们在函数中添加一些代码来包含这个行为:

}
else
{
    // Check if this path is quicker that the other.
    int tempG = currentNode->G + 10;

    // Check if tempG is faster than the other. I.e, whether it's faster to go A->C->B that A->C.
    if (tempG < node->G)
    {
        // Re-parent node to this one.
        node->parentNode = currentNode;
    }
}}}}}

创建最终路径

A算法的最后一部分是将节点列表转换为敌人可以跟随的有效路径。在为 A算法做准备的工作中,我们向Enemy类添加了以下变量:

/**
* The target positions of the enemy.
*/
std::vector<sf::Vector2f> m_targetPositions;

这个向量将保存一个目标位置列表,我们将从最终路径的节点中获取。然而,在这之前,我们需要确保清空它。这样做是为了确保每次运行寻路算法时,玩家都有一组新的坐标移动到。让我们清空这个向量。同样,这段代码只是追加到Enemy::UpdatePathFinding函数中,如下所示:

// Clear the vector of target positions.
m_targetPositions.clear();

现在,为了将瓦片转换为目标位置,我们将遍历最终节点的向量,获取它们的实际位置,并将它们添加到m_targetPositions向量中,如下所示:

// Store the node locations as the enemies target locations.
for (Tile* tile : pathList)
{
    m_targetPositions.push_back(level.GetActualTileLocation(tile-> columnIndex, tile->rowIndex));
}

我们还需要做最后一件事,这很容易被忽视。当我们找到目标节点并创建最终路径列表时,我们将它们从目标节点存储回起始节点。这意味着最终路径是反向的。Enemy::UpdatePathFinding函数的最后一步是将m_targetPositions向量反转以纠正这个问题,并添加最终的闭合括号:

// Store the node locations as the enemies target locations.
for (Tile* tile : pathList)
{
    m_targetPositions.push_back(level.GetActualTileLocation(tile-> columnIndex, tile->rowIndex));
}

// Reverse the target position as we read them from goal to origin and we need them the other way around.
std::reverse(m_targetPositions.begin(), m_targetPositions.end());

就是这样!我们完成了。A*算法已经完成。基础敌人类有一个函数,可以创建一个目标位置的向量,并以最快的路径将敌人带到玩家那里。下一步是使敌人能够跟随这条路径!

提示

如果你想进一步探索寻路,可以前往qiao.github.io/PathFinding.js/visual/。这是一个很棒的应用,可以可视化一系列流行的寻路算法。

在游戏中实现 A*

现在我们有了一个可以计算最短路径的函数,我们需要将这个行为整合到游戏中。

使敌人能够跟随路径

现在我们需要让敌人按照寻路算法生成的目标位置向量进行移动。我们需要敌人不断地跟随这条路径,所以我们将重写它的基类Update函数,因为它在每个游戏的 tick 期间被调用。这将会是相当简单的代码;如果向量中有位置,就以固定的速度朝着它移动。当到达位置时,我们就从向量中移除它。当向量为空时,我们就知道敌人已经到达目的地。

我们将从在Enemy.h中添加函数声明开始:

public:
/**
 * Overrides the default Update function in Enemy
 */
void Update(float timeDelta) override;

现在我们可以添加代码来跟随路径。就像我们刚才说的,如果目标位置的向量中有值,就以固定的速度朝向它移动。我们通过创建和标准化移动向量来实现这一点。

提示

我们不会涵盖这种移动背后的数学原理。所以,如果你想了解更多,请查看www.fundza.com/vectors/normalize/以获取概述。

以下代码用于创建和标准化移动向量:

// Updates the enemy.
void Enemy::Update(float timeDelta)
{
    // Move towards current target location.
    if (!m_targetPositions.empty())
    {
        sf::Vector2f targetLocation = m_targetPositions.front();
        m_velocity = sf::Vector2f(targetLocation.x - m_position.x, targetLocation.y - m_position.y);

        if (abs(m_velocity.x) < 10.f && abs(m_velocity.y) < 10.f)
        {
            m_targetPositions.erase(m_targetPositions.begin());
        }
        else
        {
            float length = sqrt(m_velocity.x * m_velocity.x + m_velocity.y * m_velocity.y);
            m_velocity.x /= length;
            m_velocity.y /= length;

            m_position.x += m_velocity.x * (m_speed * timeDelta);
            m_position.y += m_velocity.y * (m_speed * timeDelta);

            m_sprite.setPosition(m_position);
        }
    }

    // Call character update.
    Entity::Update(timeDelta);
}

你还可以看到在函数的最后我们调用了Entity::Update。动画代码就在这个函数中。我们需要确保它仍然被调用!

调用寻路行为

将寻路整合到游戏中的最后一步是在我们想要生成新路径时调用Enemy::UpdatePathFinding函数。敌人在每次游戏更新时都会更新,但我们不希望那么频繁地更新路径。

尽管 A*是一个高效的算法,但我们仍然希望尽可能少地调用它。路径只有在玩家移动到新的瓷砖时才会改变,所以在此之前更新寻路是没有意义的。为了实现这一点,我们需要能够告诉上次更新时玩家所在的瓷砖,以及本次更新时玩家所在的瓷砖。让我们在Game.h中添加以下变量,并确保在类初始化器中给它一个默认值:

/**
 * The last tile that the player was on.
 */
Tile* m_playerPreviousTile;

Game::Update函数中,我们现在可以检查玩家是否移动到了一个瓷砖上,如果是这样,就调用关卡中所有敌人的Enemy::UpdatePathFinding函数,如下所示:

// Check if the player has moved grid square.
Tile* playerCurrentTile = m_level.GetTile(playerPosition);

if (m_playerPreviousTile != playerCurrentTile)
{
    // Store the new tile.
    m_playerPreviousTile = playerCurrentTile;

    // Update path finding for all enemies if within range of the player.
    for (const auto& enemy : m_enemies)
    {
        if (DistanceBetweenPoints(enemy->GetPosition(), playerPosition) < 300.f)
            enemy->UpdatePathfinding(m_level, playerPosition);
    }
}

就是这样!我们现在可以测试游戏了。我们应该看到敌人在关卡中跟随我们,而不是像静止的物体一样站着,如下面的截图所示:

调用寻路行为

查看我们的路径

我们已经让代码运行起来了,这很棒,但让我们添加一些调试代码,以便我们可以看到敌人正在生成的路径。我不会详细介绍这段代码,因为它只是为了演示目的。它基本上只在目标位置的向量中的每个点上绘制一个精灵。

Enemy.h中,我们将声明以下变量和函数:

public:
/**
 * Override the default draw function.
 */
void Draw(sf::RenderWindow& window, float timeDelta) override;

private:
/**
 * Debug sprite for path
 */
sf::Sprite m_pathSprite;

/**
 * Debug font for the path
 */
sf::Font m_font;

/**
 * Debug text for the path
 */
sf::Text m_text;

Enemy::Enemy中,我们将设置调试精灵和字体,如下所示:

// Set the sprite.
int textureID = TextureManager::AddTexture("../resources/spr_path.png");
m_pathSprite.setTexture(TextureManager::GetTexture(textureID));

// Set the sprite origin.
sf::Vector2u spriteSize = m_pathSprite.getTexture()->getSize();
m_pathSprite.setOrigin(sf::Vector2f(static_cast<float>(spriteSize.x / 2), static_cast<float>(spriteSize.y / 2)));

// Set the font.
m_font.loadFromFile("../resources/fonts/04B_03__.TTF");

// Set the text.
m_text.setFont(m_font);
m_text.setCharacterSize(12);

此外,我们将为名为Enemy::Draw的新绘制函数添加一个主体:

// Override the default draw function.
void Enemy::Draw(sf::RenderWindow& window, float timeDelta)
{
    Object::Draw(window, timeDelta);

    // DEBUG Draw the current path
    for (int i = 0; i < m_targetPositions.size(); i++)
    {
        // draw the path sprite
        m_pathSprite.setPosition(m_targetPositions[i]);
        window.draw(m_pathSprite);

        // set the path index
        std::ostringstream ss;
        ss << i;
        std::string str(ss.str());
        m_text.setString(str);
        m_text.setPosition(m_targetPositions[i]);
        window.draw(m_text);
    }
}

这段代码将显示敌人的 A算法找到的路径,帮助我们可视化 A算法的操作。让我们运行游戏并看一下。记住,当你完成时,你需要删除这个调试代码,因为它会影响性能。下面的截图显示了我们敌人的路径:

查看我们的路径

程序生成的关卡目标

在本章中,我们要构建的最终系统是生成随机化的关卡目标。在每个关卡中,我们必须找到钥匙、找到出口,并杀死所有挡路的敌人。通过添加玩家也可以完成的随机目标,让游戏增加更多的玩法和挑战。每次进入一个关卡,我们将可能给玩家一个可选任务,如果完成了,将获得随机奖励。

变量和函数声明

创建此系统的第一步是声明我们将需要的变量和函数。我们将封装生成目标行为到自己的函数中。首先,我们需要在Game.h中声明以下private函数:

private:
/**
 * Generates a level goal.
 */
void GenerateLevelGoal();

考虑到我们想要生成的目标类型(杀死敌人、收集黄金和宝石),我们需要变量来保存这些值。让我们在Game.h中也声明以下private变量:

private:
/**
 * The value of gold remaining for the current goal.
 */
int m_goldGoal;

/**
 * The value of gems remaining for the current goal.
 */
int m_gemGoal;

/**
 * The number of kills remaining for the current goal.
 */
int m_killGoal;

最后,我们将要能够判断我们是否有一个活动目标,并将目标绘制到屏幕上。我们将声明一个布尔值来跟踪我们是否有一个目标,以及一个字符串对象来存储当前目标的描述:

/**
 * A string describing the current level goal.
 */
sf::String m_goalString;

/**
 * A boolean denoting if a goal is currently active.
 */
bool m_activeGoal;

生成随机目标

现在我们可以生成随机目标。我们有三种类型可用,即黄金、宝石和敌人。因此,首先我们需要选择要创建的目标中的哪一个。

通过在Game.cpp中添加以下代码,让Game::GenerateLevelGoal具有实体:

// Generates a random level goal.
void Game::GenerateLevelGoal()
{
    std::ostringstream ss;

    // Reset our goal variables.
    m_killGoal = 0;
    m_goldGoal = 0;
    m_gemGoal = 0;

    // Choose which type of goal is to be generated.
    int goalType = rand() % 3;

    switch (goalType)
    {
        case 0:    // Kill X Enemies
        break;

        case 1:    // Collect X Gold
        break;

        case 2:    // Collect X Gems
        break;
    }
}

我们首先定义了一个流对象,稍后我们将使用它,并将目标变量重置为0。这样做是为了确保每次调用此函数时目标都是全新的。然后,我们生成一个介于02之间的数字,并在switch语句中使用它。

对于每种情况,我们需要生成一个随机数作为目标值,并将其设置为适当的变量。我们还需要构造一个描述目标的字符串,并将其存储在m_goalString变量中,如下所示:

switch (goalType)
{
case 0:        // Kill X Enemies
    m_killGoal = rand() % 6 + 5;

    // Create the string describing the goal.
    ss << "Current Goal: Kill " << m_killGoal << " enemies" << "!" << std::endl;
    break;

case 1:        // Collect X Gold
    m_goldGoal = rand() % 51 + 50;

    // Create the string describing the goal.
    ss << "Current Goal: Collect " << m_goldGoal << " gold" << "!" << std::endl;
    break;

case 2:        // Collect X Gems
    m_gemGoal = rand() % 6 + 5;

    // Create the string describing the goal.
    ss << "Current Goal: Collect " << m_gemGoal << " gems" << "!" << std::endl;
    break;
}

// Store our string.
m_goalString = ss.str();

完成后,我们的目标基本上已经创建。现在我们需要通过将m_activeGoal变量设置为true来激活目标:

    // Set the goal as active.
    m_activeGoal = true;
}

完成的函数看起来像这样:

// Generates a random level goal.
void Game::GenerateLevelGoal()
{
    std::ostringstream ss;

    // Choose which type of goal is to be generated.
    int goalType = rand() % 3;

    switch (goalType)
    {
    case 0:        // Kill X Enemies
        m_killGoal = rand() % 6 + 5;

        // Create the string describing the goal.
        ss << "Current Goal: Kill " << m_killGoal << " enemies" << "!" << std::endl;
        break;

    case 1:        // Collect X Gold
        m_goldGoal = rand() % 51 + 50;

        // Create the string describing the goal.
        ss << "Current Goal: Collect " << m_goldGoal << " gold" << "!" << std::endl;
        break;

    case 2:        // Collect X Gems
        m_gemGoal = rand() % 6 + 5;

        // Create the string describing the goal.
        ss << "Current Goal: Collect " << m_gemGoal << " gems" << "!" << std::endl;
        break;
    }

// Store our string.
m_goalString = ss.str();

    // Set the goal as active.
    m_activeGoal = true;
}

在下一章中,当我们把注意力转向关卡时,我们将适当地连接这个函数,但现在,我们可以通过在Game::Game中调用它来测试它。添加以下调试代码,以便我们可以测试该函数:

// DEBUG: Generate a level goal.
GenerateLevelGoal();

检查目标是否完成

现在我们可以在调用函数时生成随机的关卡目标。我们现在需要将游戏玩法与这些目标连接起来,以便我们可以知道其中一个是否已经完成。每当我们处理与目标相关的动作时,我们需要检查是否有活动目标,并做出相应的响应。

从击杀计数开始,当我们确定敌人已被击败时,我们将检查是否有活动目标,如果是这样,我们会递减m_killGoal变量,如下所示:

// If the enemy is dead remove it.
if (enemy.IsDead())
{
    enemyIterator = m_enemies.erase(enemyIterator);

    // If we have an active goal decrement killGoal.
 if (m_activeGoal)
 {
 --m_killGoal;
 }
}

其他关卡目标也采用相同的方法。在对象拾取代码中,当我们拾取黄金或宝石时,我们将检查是否有活动的关卡目标,如果是这样,我们会递减相应的值,如下所示:

switch (m_items[i]->GetType())
{
case GAME_OBJECT::GOLD:
{
    // cast the item to a gold type
    Gold& gold = dynamic_cast<Gold&>(*m_items[i]);

    . . .

    // Check if we have an active level goal.
 if (m_activeGoal)
 {
 m_goldGoal -= gold.GetGoldValue();
 }
}
break;

case GAME_OBJECT::GEM:
{
    // cast the item to a gem type
    Gem& gem = dynamic_cast<Gem&>(*m_items[i]);

    . . .

    // Check if we have an active level goal.
 if (m_activeGoal)
    {
 --m_gemGoal;
    }
}
break;

. . .

完成后,游戏中的动作现在已经与目标计数器连接起来。接下来,我们需要实际检查是否已经实现了目标。我们将把这段代码放在Game::Update的最后,以确保所有其他动作都已执行。

检查我们是否实现了目标很简单。首先,我们检查是否有一个活动目标。然后,我们检查所有计数器变量是否小于或等于0。如果是这样,那么我们知道我们已将适当的计数器减少到0。通过这种方法,其他值将下降到负值,但我们不会收集足够的战利品来解决这个问题。让我们在Game::Update的末尾添加这段代码:

// Check if we have completed an active goal.
if (m_activeGoal)
{
    if ((m_gemGoal <= 0) &&
        (m_goldGoal <= 0) &&
        (m_killGoal <= 0))
    {
        m_scoreTotal += std::rand() % 1001 + 1000;
        m_activeGoal = false;
    }
}

完成后,大部分目标系统已经建立起来。您可以看到,如果我们确定目标是活动的,并且所有计数器都为 0 或更低,我们会奖励玩家。我们还将m_activeGoal变量设置为false,以显示目标现在已经实现。

在屏幕上绘制目标

现在的最后一步是在屏幕上绘制我们的目标!我们有一个bool变量,表示我们是否有一个活动目标,当我们生成目标时,我们将其描述存储在一个字符串变量中。绘制它就像调用Game::DrawText并传递描述一样简单,但我们只会在m_activeGoal变量为true时这样做。

现在是时候通过在Game::Draw中添加以下内容来完成这个系统了:

// Draw the level goal if active.
if (m_activeGoal)
{
    DrawString(m_goalString, sf::Vector2f(m_window.getSize().x / 2, m_window.getSize().y - 75), 30);
}

现在,如果您运行游戏,您将看到每次显示一个独特的目标:

在屏幕上绘制目标

我们可以在这里结束一天,但我们可以做得更好!由于定义关卡目标的字符串只存储一次,当我们创建它时,它不会随着我们朝着目标努力而更新自身。让我们来解决这个问题!如果我们回到Game::Update并找到我们是否已经实现了目标的检查点,我们可以在这里进行一些修改来实现这一点。

目前,我们会检查是否已经实现了活动目标,但只有在实现了目标时才会执行操作。这是我们更新字符串的机会。我们只需要确定设置了哪种类型的目标,这可以通过检查我们的目标变量的值来实现,并以与我们在Game::GenerateLevelGoal中所做的相同方式重建字符串。

// Check if we have completed an active goal.
if (m_activeGoal)
{
    if ((m_gemGoal <= 0) &&
        (m_goldGoal <= 0) &&
        (m_killGoal <= 0))
    {
        m_scoreTotal += std::rand() % 1001 + 1000;
        m_activeGoal = false;
    }
    else
    {
        std::ostringstream ss;

        if (m_goldGoal > 0)
            ss << "Current Goal: Collect " << m_goldGoal << " gold" << "!" << std::endl;
        else if (m_gemGoal > 0)
            ss << "Current Goal: Collect " << m_gemGoal << " gem" << "!" << std::endl;
        else if (m_killGoal > 0)
            ss << "Current Goal: Kill " << m_killGoal << " enemies" << "!" << std::endl;

        m_goalString = ss.str();
    }
}

现在,当我们有一个活动目标时,屏幕上的字符串会随着我们朝着目标努力而更新!

练习

为了帮助你测试本章内容的知识,这里有一些练习,你应该进行练习。它们对于本书的其余部分并不是必不可少的,但是进行练习将帮助你评估所涵盖材料的优势和劣势。

  1. 在计算路径查找时,我们目前不允许对角线移动。更新算法,使其允许对角线移动。为了帮助你入门,当计算 G 成本时,你需要确定我们是对角线移动还是直线移动。

  2. 目前,敌人会在整个关卡中追逐我们。修改函数,使敌人只在一定距离内追逐玩家。

  3. 目前,我们的敌人以固定速度移动,并且不考虑我们在早期章节生成的速度变量。在游戏中整合速度变量,使敌人以正确的速度移动。

总结

在本章中,我们将我们的努力扩展到程序行为和机制,而不仅仅是资源。具体来说,我们实现了 A*路径查找算法,为敌人提供一些智能和在关卡周围的自然移动,并创建了随机关卡目标。希望这已经很好地证明了程序生成不仅仅局限于资源;它可以用于游戏的每个方面。

在下一章中,我们将实现可能是 roguelike 游戏最具标志性的特征:程序生成的关卡。到目前为止,我们一直在使用相同的固定关卡,所以是时候开始程序生成它们了!我们还将在关卡之间创建一些差异,并实现我们刚刚创建的目标生成器!

第九章:程序地牢生成

也许是地牢游戏最具标志性和定义性的特征之一就是它们的程序生成关卡。这是导致该类型游戏具有可重复性的主要特征之一。它使游戏保持新鲜和具有挑战性,让玩家保持警惕。

在本书的过程中,我们从简单生成单个数字逐步实现了复杂的程序行为,比如路径查找。现在是时候展示我们的杰作了:程序生成我们的关卡。除此之外,我们还将使用我们在第六章中创建的函数来使关卡更加独特,程序生成艺术

在本章中,我们将涵盖以下主题:

  • 程序设计关卡的好处

  • 迷宫生成

  • 房间生成

  • 瓦片映射

程序设计关卡的好处

游戏关卡和环境的程序生成带来了许多好处,不仅对玩家有益,对开发者也有益。在使用之前了解技术的优缺点总是好事。因此,在我们实施之前,让我们先看看它为游戏带来的一些最大好处。

可重复性

程序生成关卡最明显的好处是它们的多样性和给游戏带来的可重复性。每次运行,环境都会发生变化。这意味着玩家无法学习物品和敌人的位置,这保持了挑战的新鲜感,给玩家理由一次又一次地玩游戏。

减少开发时间

程序生成的一个普遍好处是它节省的开发时间。在我们的地牢游戏中,我们将拥有无数个独特的关卡。如果我们手动创建关卡,这是不可能的。我们最多只能限制在一百个关卡。

利用这样的程序生成可以减轻开发者的工作量,节省时间和金钱,并扩大了可能性的范围。

更大的游戏世界

记住,程序生成本身并不是随机的。我们通过在算法和计算中使用随机值和术语来引入随机性。鉴于此,我们可以在关卡设计中使用程序生成来共享关卡,而无需实际存储它们。

许多随机生成世界的游戏允许你输入一个世界种子。有了这个值,两台不同机器上的人可以生成相同的关卡。通过这种方法,你可以生成一个理论上无限的关卡,确保所有玩家生成相同的关卡。此外,你只需要存储世界种子,而不是潜在的数百兆字节的世界数据。

考虑因素

和一切一样,都有两面性。因此,尽管程序生成关卡带来了好处,但也需要考虑和妥协。

控制的缺失

控制的缺失是程序生成的一个常见陷阱,但在生成关卡时可能比其他情况更为突出。游戏关卡是我们讲述故事和尝试游戏机制的竞技场。因此,它们通常由专门的关卡设计师手工制作。将这项工作交给算法会导致严重的控制丧失。

拥有简单机制和故事的游戏通常会表现得不错,但如果你有复杂的机制或者想以特定方式讲述故事,程序生成关卡可能需要你放弃更多的控制权。算法永远无法复制经验丰富的专业人员带来的小细节。

所需的计算能力

还需要考虑的一个问题是所需的计算能力。在我们的情况下,情况并不那么糟糕。我们只需要生成一个小尺寸的 2D 数组。然而,如果您在大规模生成 3D 地形,这个成本就变得更加重要,需要加以考虑。

想象一下,我们需要处理一个 1000x1000 的关卡网格。每次生成关卡时都会有大量的计算需要进行,我们需要确保所有玩家的硬件都能够应对!随着计算能力的稳步增加,这变得不再是一个问题。事实上,这就是游戏变得非常复杂和动态的原因。我们有实现它所需的硬件,但我们仍然需要意识到其限制。

适用性

最后的考虑只是您的游戏是否会受益于程序生成。仅仅因为在游戏中可能可以实现它,并不意味着它就应该存在。如果您不需要大量的关卡,并且有复杂的机制和系统,那么可能不值得实现它。最好花时间精心制作一些您知道会非常有效的关卡。

这是一个需要牢记的好点。不要被游戏的技术性和代码的精彩所迷惑。最重要的是,您的游戏需要有趣和引人入胜。始终优先考虑游戏性。

地牢生成概述

地牢生成是一个广泛的主题,有各种可能的实现方式,每种实现方式都有其自身的特点。然而,在不同算法的细微差别之下,地牢生成通常涉及生成房间和迷宫,以及将两者整合在一起,如下图所示:

地牢生成概述

程序生成地牢与我们在路径查找上所做的工作并没有太大的不同。它关键在于将关卡视为节点并对其进行操作。在我们实现之前,我们将其分解为之前确定的三个主要阶段,即生成房间、生成迷宫以及将它们整合在一起。

生成房间

地牢是一系列相互连接的房间,它们的生成是许多系统中的第一步。这背后没有复杂的算法;我们只是选择一个房间大小,并在关卡中放置一些房间。这个关卡的特性将由房间的数量、大小以及它们的放置方式等因素决定,如下图所示:

生成房间

生成迷宫

地牢生成的另一个重要步骤是在可玩区域生成迷宫,将关卡变成一系列相连的走廊。然后这些走廊可以连接现有的房间,或者在其中雕刻房间以创建开放区域。有许多算法用于生成这样的迷宫,我们将使用流行的递归回溯算法。别担心,我们很快就会详细了解这个算法!以下截图显示了这样一个迷宫的示例:

生成迷宫

连接房间和迷宫

如果您选择先生成房间,然后创建迷宫来连接它们,最后一步是将它们整合在一起。目前,迷宫会直接经过所有的房间,但幸运的是,将它们连接起来是一项简单的任务。我们只需要查看每个房间的周围,并向有效的相邻路径添加连接块,如下图所示:

连接房间和迷宫

在我们的实现中,实际上我们要以另一种方式来做。我们将生成一个迷宫,然后在其中开辟出开放区域。这种方法创建了更多开放和迷宫般的区域,而第一种方法则创建了相互连接的封闭房间。

递归回溯法

递归回溯法,顾名思义,涉及递归调用一个函数,在游戏网格中的两个瓦片之间雕刻通道。通过选择随机方向来雕刻这条路径,算法在解决递归之前尽可能远地雕刻其路径,然后回到起始节点。

以下是一个这样的算法的伪代码:

  1. 选择随机方向并连接到相邻节点,如果它尚未被访问。这个节点成为当前节点(一个递归调用)。

  2. 如果每个方向上的相邻单元格都已经被访问过,那么返回到上一个单元格(从上一个递归调用中返回)。

  3. 如果你回到了起始节点,算法就完成了。

正如我们所看到的,实际上并没有太多的东西!唯一的陷阱是你需要将整个迷宫保存在内存中。对于大型迷宫,这种方法可能效率低下,甚至可能根本不可能!然而,对于我们的实现,它将完美地工作。

程序化生成地下城

现在是时候将这个理论付诸实践,在我们的游戏中实现程序化地下城生成。我们将把Level类从从文本文件加载其数据转移到在运行时生成数据,并且我们还将覆盖将正确的精灵应用到随机关卡中的瓦片上。

正如我们所确定的,一种处理这个问题的方法是在整个游戏区域生成一个迷宫,然后生成房间来雕刻出一些更大的开放区域。这种方法不仅生成了更紧密、更交织的关卡,而且还省去了我们连接迷宫和房间的步骤,使我们只需两个步骤就能生成出色的关卡:

程序化生成地下城

改变我们看待迷宫的方式

在我们编写任何代码之前,我们将对项目进行一些更改,以便我们可以轻松地看到整个关卡。目前,视图是放大的,并且光线挡住了关卡。我们希望在处理算法时能够看到整个迷宫。所以让我们做一些改变。

我们要做的第一件事是禁用主游戏视图,而是使用UI视图来绘制所有东西。Game视图以原始大小的两倍绘制所有东西,而 UI 视图以 1:1 的比例绘制东西。通过禁用对Game视图的更改,我们将看到更多的关卡。

更新以下代码:

case GAME_STATE::PLAYING:
{
  // Set the main game view.
  //m_window.setView(m_views[static_cast<int>(VIEW::MAIN)]);

我们在这里所做的一切就是注释掉了设置主游戏视图的那一行。现在让我们对负责在关卡中绘制光线的代码做同样的操作:

// Draw level light.
//for (const sf::Sprite& sprite : m_lightGrid)
//{
//  m_window.draw(sprite);
//}

这两个改变大大改变了关卡的外观,将帮助我们在工作时看到迷宫:

改变我们看待迷宫的方式

更新游戏和关卡类

在我们开始实现迷宫生成器之前,我们需要定义一些我们将要使用的函数。首先,我们的关卡目前是从Level::LoadLevelFromFile函数中加载的。我们需要为新代码创建一个合适的函数。让我们删除Level::LoadLevelFromFile函数,并在Level.h中加入以下代码:

public:
/**
 * Generates a random level.
 */
void GenerateLevel();

我们将需要在Game类中添加一个类似的函数,它将封装所有生成随机关卡的代码,因此请确保您也在Game.h中添加相同的函数声明。我们有一些与生成关卡相关的函数,所有这些函数都可以封装在这个函数中。我们需要添加以下内容:

  • 调用Level::GenerateLevel:这使得在关卡中放置钥匙成为可能

  • 调用Game::PopulateLevel:这有助于生成一个随机的关卡目标

注意其中一个项目是向级别添加一个钥匙。该项目已经存在于我们的解决方案中,所有支持代码也都存在,因此我们很快就能够在级别中生成一个。

让我们把这个函数添加到Game.cpp中:

// Generates a new level.
void Game::GenerateLevel()
{
  // Generate a new level.
  m_level.GenerateLevel();

  // Add a key to the level.
  SpawnItem(ITEM::KEY);

  // Populate the level with items.
  PopulateLevel();

  // 1 in 3 change of creating a level goal.
  if (((std::rand() % 3) == 0) && (!m_activeGoal))
  {
    GenerateLevelGoal();
  }
}

我们在第八章程序行为和机制中创建了Goal::GenerateLevelGoal函数。因此,这就是我们实际实现它的地方。每次生成新的关卡时,我们都有三分之一的机会生成一个目标,如果当前没有活动的目标的话。

既然我们现在有了能够随机生成我们的关卡的函数,并且已经添加了钥匙,让我们快速添加代码,以便在玩家到达门时生成新的关卡。我们已经准备好了 if 语句,我们只需要添加行为:

. . .

if (playerTile.type == TILE::WALL_DOOR_UNLOCKED)
{
	// Clear all current items.
	m_items.clear();

	// Clear all current enemies.
	m_enemies.clear();

	// Generate a new room.
	GenerateLevel();

	// Set the key as not collected.
	m_keyUiSprite->setColor(sf::Color(255, 255, 255, 60));
}

. . .

现在这个已经完成了,我们唯一剩下的事情就是调用我们的Game::GenerateLevel函数,而不是我们已经弃用的Level::LoadLevelFromFile,并删除设置玩家位置和调用Game::PopulateLevel的代码。我们的新的Game::GenerateLevel函数将处理所有这些。让我们更新Game::Initialize中的以下代码:

// Load the level.
//m_level.LoadLevelFromFile("../resources/data/level_data.txt");

// Set the position of the player.
//m_player.SetPosition(sf::Vector2f(1155.f, 940.f));

// Populate level.
//PopulateLevel();

// Generate a level.
GenerateLevel();

现在代码已经更新,我们现在可以把注意力转向地牢生成算法。

生成迷宫

创建随机地牢的第一阶段是在整个游戏区域生成一个迷宫。我们已经介绍了我们将使用的递归回溯方法。但是,我们需要在此之前做一些准备工作。

生成迷宫之前的准备

递归回溯算法通过在两个节点之间建立通道来工作。鉴于此,我们需要迷宫处于这样的位置,即网格中的所有节点都被墙壁包围,也就是说,看起来像这样:

生成迷宫之前的准备

阴影方块代表墙砖,空白方块代表地板空间。您将在左侧网格中看到每个地板砖都被四面墙壁包围。右侧的砖块显示了算法运行后网格的样子,打破这些墙壁以创建路径。我们的任务是使网格看起来像左侧的那个!

当您查看左侧的网格时,您会发现所有阴影砖块都具有奇数索引;只有具有偶数列和行索引的砖块是空白的。这样很容易创建这个网格。我们需要循环遍历所有砖块,如果两个索引都是偶数,我们就留下空白。否则,我们将其转换为墙砖。

让我们开始定义Level::GenerateLevel函数,通过实现这个来开始:

// Generates a random level.
void Level::GenerateLevel()
{
    // Create the initial grid pattern.
    for (int i = 0; i < GRID_WIDTH; ++i)
    {
        for (int j = 0; j < GRID_HEIGHT; ++j)
        {
            if ((i % 2 != 0) && (j % 2 != 0))
            {
                // Odd tiles, nothing.
                m_grid[i][j].type = TILE::EMPTY;
            }
            else
            {
                m_grid[i][j].type = TILE::WALL_TOP;
                m_grid[i][j].sprite.setTexture(TextureManager::GetTexture(m_textureIDs[static_cast<int>(TILE::WALL_TOP)]));
            }
            // Set the position.
            m_grid[i][j].sprite.setPosition(m_origin.x + (TILE_SIZE * i), m_origin.y + (TILE_SIZE * j));
        }
    }
}

在运行游戏之前,我们需要快速禁用任何使用级别网格的代码。这包括我们对Game::PopulateLevel的调用以及在Game::GenerateLevel中放置钥匙。还包括在Game::Initialize中对Game::SpawnRandomTiles的调用。这些函数依赖于级别网格的设置,但现在还没有设置!如果不禁用这些,游戏将因为寻找地板瓦片而挂起!完成后我们会重新启用它们。

如果现在运行游戏,您将看到我们有一个看起来像左侧图像的网格。第一步完成了!

下面的截图显示了我们现在运行游戏时的结果:

生成迷宫之前的准备

开辟通道

现在,棋盘格模式已经创建,是时候实现算法的主体部分了。以下是递归回溯算法的工作原理的提醒:

  1. 选择一个随机方向,并与相邻节点建立连接(如果尚未访问)。这个节点成为当前节点(递归调用)。

  2. 如果每个方向的所有相邻单元格都已经被访问过,则返回到上一个单元格(从先前的递归调用返回)。

  3. 如果回到起始节点,则算法完成。

我们知道这个算法是递归的,所以让我们从声明包含算法的函数开始。由于这个函数将在两个节点之间创建路径,我们将把它称为CreatePath

private:
/**
 * Creates a path between two nodes in the recursive backtracker algorithm.
 */
void CreatePath(int columnIndex, int rowIndex);

从算法分解的第一个点开始,我们需要识别我们正在处理的节点并选择一个随机方向。获取正确的节点很容易,为了选择一个随机方向,我们将使用一个数组。我们可以定义一个sf::vector2i数组,定义所有可能的方向。例如,{-2, 0}将表示向左移动一个瓷砖,因为我们将把列索引减 2。

记住,由于棋盘格的模式,我们必须每次移动两个瓷砖。直接相邻的瓷砖是一堵墙,所以我们需要再走一步才能到达我们想要处理的瓷砖。然后我们需要打乱方向数组,这样算法就不会倾向于任何一个特定的方向。例如,如果我们不这样做,它将总是首先检查北方,导致很多向北的通道!

让我们开始定义Level::CreatePath函数,将以下内容添加到Level.cpp中:

// Create a path between two tiles in the level grid.
void Level::CreatePath(int columnIndex, int rowIndex)
{
  // Store the current tile.
  Tile* currentTile = &m_grid[columnIndex][rowIndex];

  // Create a list of possible directions and sort randomly.
  sf::Vector2i directions[] = { { 0, -2 }, { 2, 0 }, { 0, 2 }, { -2, 0 } };
  std::random_shuffle(std::begin(directions), std::end(directions));

接下来,我们遍历这些方向,并检查是否可以找到任何尚未被访问的有效瓷砖。如果一个瓷砖存在于网格中,并且你可以根据它是否为空来判断它是否已经被访问。

让我们通过将以下代码附加到打开函数的定义来添加这个功能:

// For each direction.
for (int i = 0; i < 4; ++i)
{
  // Get the new tile position.
  int dx = currentTile->columnIndex + directions[i].x;
  int dy = currentTile->rowIndex + directions[i].y;

  // If the tile is valid.
  if (TileIsValid(dx, dy))
  {
    // Store the tile.
    Tile* tile = &m_grid[dx][dy];

    // If the tile has not yet been visited.
    if (tile->type == TILE::EMPTY)
    {

如果代码到达这一点,我们知道我们正在看一个新的瓷砖,因为它既有效又当前为空。为了创建到它的路径,我们需要拆掉我们之间的墙,并将墙和我们的新瓷砖都改为地板瓷砖。现在我们再次调用Level::CreatPath,传递新瓷砖的索引作为参数。递归就发生在这里,算法继续前进。

让我们完成函数的定义,使用以下代码来实现这一点:

  // Mark the tile as floor.
  tile->type = TILE::FLOOR;
  tile->sprite.setTexture(TextureManager::GetTexture(m_textureIDs[static_cast<int>(TILE::FLOOR)]));

  // Knock that wall down.
  int ddx = currentTile->columnIndex + (directions[i].x / 2);
  int ddy = currentTile->rowIndex + (directions[i].y / 2);

  Tile* wall = &m_grid[ddx][ddy];
  wall->type = TILE::FLOOR;
  wall->sprite.setTexture(TextureManager::GetTexture(m_textureIDs[static_cast<int>(TILE::FLOOR)]));

  // Recursively call the function with the new tile.
  CreatePath(dx, dy);
}}}}

让我们明确一下这里到底发生了什么。每当识别到一个空瓷砖时,就会对Level::CarvePath进行递归调用,并传递该瓷砖的索引。当它这样做时,它会通过关卡,逐渐深入递归。

当所有方向都被检查并且没有有效的瓷砖时,Level::CreatePath的当前调用将返回,允许上一次调用检查其剩余的方向。随着这个过程的继续,算法会沿着路径返回,直到它到达起始节点,此时节点已经被访问。

希望函数中的注释清楚地说明了哪一部分在做什么。现在这一步完成后,我们现在可以在Level::GenerateLevel函数中调用它,就在我们设置网格之后:

// Generates a random level.
void Level::GenerateLevel()
{
  // Create the initial grid pattern.
  for (int i = 0; i < GRID_WIDTH; ++i)
  {

 // Make the first call to CarvePassage, starting the recursive backtracker algorithm.
 CreatePath(1, 1);
}

让我们再次编译项目,看看我们有什么:

Carving passages

我们有迷宫了!对于一些游戏来说,这已经足够了,但我们不想要所有的单瓷砖路径。我们想要更多的开放区域,这样我们就可以与敌人战斗!你还会看到瓷砖精灵看起来非常奇怪。现在不要担心它,我们一旦添加了房间就会修复它!

添加房间

之前,我们学到了添加房间是一个简单的任务。现在我们可以亲自看到这一点。我们的目标是添加一些开放区域,最简单的方法是选择一些随机位置,并将周围的瓷砖转换为地板瓷砖。为了保持Level类的整洁,我们将把这种行为包含在它自己的函数中。在Level.h中添加以下函数声明:

private

/**
 * Adds a given number of randomly sized rooms to the level to create some open space.
 */
void CreateRooms(int roomCount);

在我们不断努力编写通用和可扩展的代码的过程中,我们添加了一个参数来表示我们想要创建多少个房间,这样我们可以随意变化它。

让我们直接开始定义这个函数。首先,我们需要一个循环,每次迭代都要添加一个我们希望添加的房间。在Level.cpp中添加以下方法定义:

// Adds a given number of randomly sized rooms to the level to create some open space.
void Level::CreateRooms(int roomCount)
{
  for (int i = 0; i < roomCount; ++i)
  {

现在我们可以创建我们的房间了。第一个任务是决定我们想要它们有多大。通过尝试算法,我发现拥有更多数量的较小房间效果很好。和往常一样,我们将通过使房间的大小落在一个随机范围内来增加一些随机性:

// Generate a room size.
int roomWidth = std::rand() % 2 + 1;
int roomHeight = std::rand() % 2 + 1;

这将生成宽度和高度为 1 或 2 的房间。我知道这听起来很小,但相信我。它确实非常有效!

接下来,我们需要为这个房间在关卡中选择一个位置。我们将选择一个随机点并围绕它建造房间。为此,我们需要生成一个随机瓷砖索引,然后创建嵌套的for循环来迭代 2D 数组,从而描述房间:

// Choose a random starting location.
int startI = std::rand() % (GRID_WIDTH - 2) + 1;
int startY = std::rand() % (GRID_HEIGHT - 2) + 1;

for (int j = -1; j < roomWidth; ++j)
{
  for (int z = -1; z < roomHeight; ++z)
  {

在生成起始位置时,你可以看到我们小心翼翼地没有包括任何方向上的外边缘。这些是关卡的挡墙,应该保持不变。

函数的最后部分现在只涉及将房间瓷砖变成地板瓷砖。首先,我们通过调用Level::TileIsValid来检查我们是否超出了边界。然后,我们确保新的标题不位于网格的外边缘;外部行/列都应该是墙,以限制关卡。如果这两个条件都满足,我们可以使用以下代码将其变成地板块:

int newI = startI + j;
int newY = startY + z;

// Check if the tile is valid.
if (TileIsValid(newI, newY))
{
  // Check if the tile is not on an outer wall.
  if ((newI != 0) && (newI != (GRID_WIDTH - 1)) && (newY != 0) && (newY != (GRID_HEIGHT - 1)))
  {
    m_grid[newI][newY].type = TILE::FLOOR;
    m_grid[newI][newY].sprite.setTexture(TextureManager::GetTexture(m_textureIDs[static_cast<int>(TILE::FLOOR)]));
  }
}}}}}

现在是时候调用这个函数了。目前在Level::GenerateLevel中,我们设置了我们的网格,然后进行了第一次调用Level::CreatePath来启动递归算法。当这个初始调用返回时,我们知道迷宫已经完全生成。在这个阶段,我们将创建房间。

让我们在第一次调用Level::CreatePath之后立即追加一个对新的Level::CreateRooms函数的调用:

. . .

// Make the first call to CarvePassage, starting the recursive backtracker algorithm.
CreatePath(1, 1);

// Add some rooms to the level to create some open space.
CreateRooms(10);

现在是时候进行另一个构建,这样我们就可以看到我们的工作。希望现在我们的关卡中有一个随机的迷宫,以及一些更大的开放区域,可以让玩家更自由地战斗:

添加房间

选择瓷砖纹理

到目前为止,我们一直在从文本文件中加载一个预构建的关卡。这个关卡文件已经知道需要使用哪些纹理以及它们应该被使用的位置,但由于我们现在是通过程序生成它们,情况就不一样了。我们需要决定哪些瓷砖应该有哪些精灵。

if/else 方法

这种方法的常见方式就是简单地创建一个庞大的if/else语句。原则上,这是一个简单的任务;通过一系列的if语句来定义每个瓷砖并设置正确的瓷砖。然而,在现实中,你最终会得到一堆复杂的代码,非常难以阅读。

想象一种情况,你有一个包含五十种可能变体的瓷砖集。为了选择哪个瓷砖放在哪里,需要的代码量将是疯狂的。幸运的是,这个问题有一个更简单的解决方案,这是我最喜欢的一个优雅解决问题的例子。

位运算瓷砖地图

在我们的游戏中,我们关注四个方向,即上、下、左和右。因此,当我们需要计算瓷砖纹理时,我们只需要在这四个方向上进行检查:

位运算瓷砖地图

在前面的图表中,你可以看到标有 0 的瓷砖是用来确定给瓷砖 X 的纹理的。这就是优雅解决方案的地方。如果我们从顶部瓷砖开始,从最低有效位开始计数,将瓷砖读入二进制数,我们得到 4 位二进制数,0000。如果瓷砖是墙,我们将相应的位设置为 1。如果瓷砖是地板,我们将其保持为 0。

如果我们将这个应用到围绕瓷砖 X 的四个可能的瓷砖位置,我们可以计算每个瓷砖的值:

位运算瓷砖地图

希望这张图能让事情更清晰。从顶部瓷砖开始,顺时针阅读,我们将瓷砖的值输入到一个位整数中,从最不重要的数字到最重要的数字。这使得周围每个瓷砖都有一个不同的值,我们可以通过以下图像来可视化这一点:

位瓷砖地图

计算瓷砖的值

在决定我们需要的瓷砖纹理时,我们评估了围绕目标瓷砖的瓷砖,并在有墙壁的地方存储了我们在前面图像中识别出的值。一个真实的例子将帮助你可视化这个过程。假设我们需要找到瓷砖 X 的正确纹理:

计算瓷砖的值

在这种情况下,瓷砖的值将按以下方式计算:

1 + 4 = 5

使用这种方法,X 的每种可能的瓷砖方向都通过一个从 0 到 15 的唯一值表示。如此优雅简单!

将瓷砖值映射到纹理

这个谜题的最后一块是将这些值映射到纹理上。在Util.h中,你会看到以下枚举器定义了所有类型:

// Tiles.
enum class TILE {
  WALL_SINGLE,
  WALL_TOP_END,
  WALL_SIDE_RIGHT_END,
  WALL_BOTTOM_LEFT,
  WALL_BOTTOM_END,
  WALL_SIDE,
  WALL_TOP_LEFT,
  WALL_SIDE_LEFT_T,
  WALL_SIDE_LEFT_END,
  WALL_BOTTOM_RIGHT,
  WALL_TOP,
  WALL_BOTTOM_T,
  WALL_TOP_RIGHT,
  WALL_SIDE_RIGHT_T,
  WALL_TOP_T,
  WALL_INTERSECTION,
  WALL_DOOR_LOCKED,
  WALL_DOOR_UNLOCKED,
  WALL_ENTRANCE,
  FLOOR,
  FLOOR_ALT,
  EMPTY,
  COUNT
};

虽然这些瓷砖的顺序可能看起来有些随机,但它们实际上是按照非常特定的顺序排列的。枚举器从 0 开始计数。因此,我们可以看到第一个值WALL_SINGLE的值为 0。回到我们的图表,我们可以看到这是正确的,因为这就是我们在没有周围瓷砖的情况下需要的纹理。

再举一个随机的例子,WALL_TOP值为 10。如果我们看一下网格,这意味着目标瓷砖的右侧和左侧的瓷砖都是墙。2 + 8 = 10。没错!对于所有可能的瓷砖,我计算出了它们的位掩码值,并确保它们的枚举器值匹配。

计算瓷砖纹理

让我们在项目中实现这个。首先,我们将在我们的Level头文件中声明一个函数,我们可以将这个行为封装起来:

/**
 * Calculates the correct texture for each tile in the level.
 */
void CalculateTextures();

对于函数的主体,我们希望首先迭代所有的瓷砖,识别哪些是墙。正是这些瓷砖需要计算正确的纹理:

// Calculates the correct texture for each tile in the level.
void Level::CalculateTextures()
{
  // For each tile in the grid.
  for (int i = 0; i < GRID_WIDTH; ++i)
  {
    for (int j = 0; j < GRID_HEIGHT; ++j)
    {
      // Check that the tile is a wall block.
      if (IsWall(i, j))
      {
        // Calculate bit mask.
        int value = 0;

        // Store the current type as default.
        TILE type = m_grid[i][j].type;

现在我们看看我们周围的瓷砖,使用我们之前计算的值,来得出瓷砖的最终值。我们按顺序检查每个瓷砖,再次从顶部开始,顺时针旋转,并根据适当的数量增加值,如果那里有墙的话:

// Top.
if (IsWall(i, j - 1))
{
  value += 1;
}

// Right.
if (IsWall(i + 1, j))
{
  value += 2;
}

// Bottom.
if (IsWall(i, j + 1))
{
  value += 4;
}

// Left.
if (IsWall(i - 1, j))
{
  value += 8;
}

在这个阶段剩下的就是为瓷砖分配正确的纹理和 ID。我们之前讨论过如何设置枚举器,表示瓷砖类型与这个值直接对应,所以我们可以简单地使用纹理值作为瓷砖类型和纹理的索引:

// Set the new type.
m_grid[i][j].type = static_cast<TILE>(value);
m_grid[i][j].sprite.setTexture(TextureManager::GetTexture(m_textureIDs[value]));
}}}}

有了这个函数就完成了。最后一步是确保我们在Level::GenerateLevel函数中添加一个调用,就在我们生成了房间之后,如下所示:

  . . .
  // Add some rooms to the level to create some open space.
  CreateRooms(10);

 // Finally, give each tile the correct texture.
 CalculateTextures();
}

让我们不浪费任何时间,开始构建我们的游戏:

计算瓷砖纹理

看起来多棒啊!多次运行它,看看生成的不同迷宫。我们生成一个迷宫,雕刻一些更大的区域,并解决纹理。程序生成的地牢。虽然这很棒,但我们可以做得更好。我们的迷宫缺乏特色和个性。所以让我们为环境引入一些审美的变化。

创建独特的地板主题

在第六章中,程序生成艺术,我们花了一些时间研究程序生成精灵。我们还创建了一个名为Level::SetColor的函数,它允许我们为地牢中的所有瓷砖设置覆盖颜色。让我们利用这一点,为地牢的每一层创建独特的感觉。

让我们创建每个都有独特审美的不同地板。每 5 层我们可以生成一个新的随机颜色,并将其应用到我们的地牢。我们的Level类已经有了以下变量:

/**
 * The floor number that the player is currently on.
 */
int m_floorNumber;

/**
 * The room number that the player is currently in.
 */
int m_roomNumber;

我们可以使用这些来跟踪我们生成了多少个房间,以及何时应该更改效果。首先,我们必须跟踪我们在哪个楼层和房间。在Level::GenerateLevel函数的末尾,我们将首先递增m_roomNumber变量。当它为5时,我们可以递增m_floorNumber并生成一个新的颜色叠加;不要忘记重置房间计数器:

    . . .

    // Calculate the correct texture for each tile.
    CalculateTextures();

 // Increment our room/floor count and generate new effect if necessary.
 m_roomNumber++;

 // Move to next floor.
 if (m_roomNumber == 5)
 {
 m_roomNumber = 0;
 m_floorNumber++;
 }
}

正如我们在第六章中学到的,程序生成艺术,要生成新颜色,我们需要生成三个介于 0 和 255 之间的随机值。这些值是组成颜色的红色、绿色和蓝色通道。第四个值是 alpha,表示精灵的透明度。

重要的是要记住,如果我们生成接近 0 的颜色值,我们将得到白色,如果我们在另一端走得太远,颜色将会太暗。因此,我们不会在 0 到 255 的范围内生成任何数字,而是略微限制,以便我们始终获得可用的颜色。alpha 值将每次设置为 255,因为我们不希望任何瓦片是透明的。

我们将生成一个随机颜色,然后调用Level::SetColor,将新生成的值传递给它。这将赋予关卡独特的美学:

// Increment our room/floor count and generate new effect if necessary.
m_roomNumber++;

if (m_roomNumber == 5)
{
  // Move to next floor.
  m_roomNumber = 0;
  m_floorNumber++;

 // Generate a random color and apply it to the level tiles.
 sf::Uint8 r = std::rand() % 101 + 100;
 sf::Uint8 g = std::rand() % 101 + 100;
 sf::Uint8 b = std::rand() % 101 + 100;

 SetColor(sf::Color(r, g, b, 255));
}

提示

这是我们第二次想要生成随机颜色。鉴于此,将其抽象为自己的函数可能是一个不错的选择。作为一个简短的练习,将此代码抽象为自己的函数,并相应地更新游戏代码。

在我们运行游戏并查看结果之前,我们需要再做一些更改。当前,随机关卡颜色仅在我们第一次移动楼层时设置。我们需要在生成关卡时执行相同的代码。我们可以在关卡的构造函数中执行此操作。让我们简单地将以下代码附加到Level::Level中:

. . .

// Store the column and row information for each node.
for (int i = 0; i < GRID_WIDTH; ++i)
{
    for (int j = 0; j < GRID_HEIGHT; ++j)
    {
        auto cell = &m_grid[i][j];
        cell->columnIndex = i;
        cell->rowIndex = j;
    }
}

// Generate a random color and apply it to the level tiles.
sf::Uint8 r = std::rand() % 101 + 100;
sf::Uint8 g = std::rand() % 101 + 100;
sf::Uint8 b = std::rand() % 101 + 100;

SetColor(sf::Color(r, g, b, 255));

现在我们准备再次运行游戏。我们可以看到当我们的关卡是随机颜色时,当我们通过 5 个关卡时,我们知道这种颜色将会改变!

让我们运行游戏,看看它的表现:

创建独特的楼层主题

添加入口和出口点

由于我们不再从预定义的关卡数据中加载关卡,我们需要为每个房间计算有效的入口和出口点。由于整个关卡是一个迷宫,我们可以在迷宫底部随机生成一个入口点,并使玩家的目标是找到顶部的出口。多个通道和死胡同将使玩家不断搜索!

我们已经在我们的墙枚举器中定义了这些瓦片,所以只需在关卡中找到它们的位置。和往常一样,我们将首先声明一个函数,其中将包含此行为。将执行单个任务的代码块封装在函数中总是一个好主意。这不仅使行为和责任清晰,还使代码更具可重用性。

让我们在Level.h中声明以下函数:

private:
/**
 * Generates an entry and exit point for the given level.
 */
void GenerateEntryExit();

现在,对于方法体,我们希望首先确定适合的起始和结束瓦片的索引。由于我们将在顶部和底部行放置瓦片,因此我们只需要生成一个索引,即列。行的索引分别为 0 和GRID_HEIGHT-1

为此,我们将随机选择一个列索引,并检查该位置是否适合作为入口节点。对于入口节点,我们需要确保上方没有瓦片。同样,对于出口节点,我们需要确保下方没有东西:

// Generates an entry and exit point for the given level.
void Level::GenerateEntryExit()
{
  // Calculates new start and end locations within the level.
  int startI, endI;
  startI = endI = -1;

  while (startI == -1)
  {
    int index = std::rand() % GRID_WIDTH;

    if (m_grid[index][GRID_HEIGHT - 1].type == TILE::WALL_TOP)
    {
      startI = index;
    }
  }

  while (endI == -1)
  {
    int index = std::rand() % GRID_HEIGHT;

    if (m_grid[index][0].type == TILE::WALL_TOP)
    {
      endI = index;
    }
}

提示

像这样使用while循环需要极度谨慎。如果不存在有效的瓦片,程序将挂起并崩溃。在这种情况下,由于算法的工作方式,我们可以确保始终存在有效的瓦片。

现在我们已经确定了起点和终点节点,剩下的就是将节点设置为正确类型的瓦片。入口节点需要设置为TILE::WALL_ENTRANCE,出口节点必须设置为TILE::WALL_DOOR_LOCKED,如下所示:

  // Set the tile textures for the entrance and exit tiles.
  SetTile(startI, GRID_HEIGHT - 1, TILE::WALL_ENTRANCE);
  SetTile(endI, 0, TILE::WALL_DOOR_LOCKED);
}

现在这个函数已经完成,我们只需要在生成关卡后调用它。我们将在Level::GenreateLevel函数的末尾调用它,就在我们计算纹理之后:

    . . .

        // Generate a random color and apply it to the level tiles.
        sf::Uint8 r = std::rand() % 101 + 100;
        sf::Uint8 g = std::rand() % 101 + 100;
        sf::Uint8 b = std::rand() % 101 + 100;

        SetColor(sf::Color(r, g, b, 255));
    }

 // Add entrance and exit tiles to the level.
 GenerateEntryExit();
}

设置玩家的生成位置

现在我们已经确定了入口和出口节点,我们需要相应地移动我们的玩家。生成起始节点的代码位于 level 类中,所以我们需要添加一个函数来返回这个起始位置。我们可以在游戏类中生成入口和出口节点,但这样设计很差。最好将代码放在它应该放置的地方,并创建gettersetter方法来访问它。

然而,在我们返回生成位置之前,我们实际上需要计算它!为了这样做,我们需要知道入口节点在哪里。一旦Level::GenerateEntryExit函数返回了这些信息,它就丢失了。我们可以遍历瓦片的底部行来找到它,但那样效率低下。相反,我们将在Level类中创建一个变量来保存这些信息,并在Level::GenerateEntryExit中计算生成位置。

让我们首先在Level.h中声明这些变量,如下所示:

/**
 * The spawn location for the current level.
 */
sf::Vector2f m_spawnLocation;

现在,我们知道每个关卡的入口都会在底部的某个地方。这意味着要计算生成位置,我们只需要找到正好在上面的瓦片的绝对位置。Level类已经有一个函数来获取瓦片的绝对位置,所以只需要调用一次该函数并传递正确的瓦片即可。

当我们在这里时,我们需要偷偷加入一点类似的代码。我们需要存储新出口的位置,以便Level::UnlockDoor函数知道要更改哪个瓦片。Level类已经有了这个信息的变量,所以我们将偷偷加入一个简单的一行代码。

让我们将这个行为追加到Level::GenerateEntryExit函数的末尾,如下所示:

  // Set the tile textures for the entrance and exit tiles.
  SetTile(startI, GRID_HEIGHT - 1, TILE::WALL_ENTRANCE);
  SetTile(endI, 0, TILE::WALL_DOOR_LOCKED);

 // Save the location of the exit door.
 m_doorTileIndices = sf::Vector2i(endI, 0);

 // Calculate the spawn location.
 m_spawnLocation = GetActualTileLocation(startI, GRID_HEIGHT - 2);
}

现在我们只需要一个非常简单的getter函数来返回玩家的生成位置,不要忘记声明:

// Returns the spawn location for the current level.
sf::Vector2f Level::SpawnLocation()
{
  return m_spawnLocation;
}

现在是时候将这个生成位置应用到玩家身上了。Game::GenerateLevel是我们生成关卡的函数,所以我们将在这里设置玩家的位置。在调用Level::GenerateLevel之后,我们可以获取生成位置,知道它将被更新,并将这个值作为玩家的位置。

我们现在也可以取消注释生成钥匙的代码,我们对Game::PopulateLevel的调用,以及我们对Game::SpawnRandomTiles的调用。现在我们的关卡已经设置好了,这些函数将按预期工作。让我们取消注释那些代码,并更新Game::GenerateLevel如下:

// Generates a new level.
void Game::GenerateLevel()
{
  // Generate a new level.
  m_level.GenerateLevel();

  // Add a key to the level.
  SpawnItem(ITEM::KEY);

  // Populate the level with items.
  PopulateLevel();

  // 1 in 3 change of creating a level goal.
  if (((std::rand() % 3) == 0) && (!m_activeGoal))
  {
    GenerateLevelGoal();
  }

 // Moves the player to the start.
 m_player.SetPosition(m_level.SpawnLocation());
}

是时候测试代码了。现在当我们运行游戏时,我们应该不仅能看到一个看起来很棒的迷宫,还能看到底部有一个入口,我们的玩家直接在上面,顶部有一个出口:

设置玩家的生成位置

撤销调试更改

我们的地牢生成工作现在已经完成了!让我们快速撤销我们对代码所做的调试更改。我们需要取消注释启用游戏视图和照明代码的行;这两行都在Game::Draw函数中:

. . .

case GAME_STATE::Playing:
{
  // Set the main game view.
  //m_window.setView(m_views[static_cast<int>(VIEW::MAIN)]);

 // Set the main game view.
 m_window.setView(m_views[static_cast<int>(VIEW::MAIN)]);

  // Draw level light.
  //for (const sf::Sprite& sprite : m_lightGrid)
  //{
  //  m_window.draw(sprite);
  //}

 // Draw level light.
 for (const sf::Sprite& sprite : m_lightGrid)
 {
 m_window.draw(sprite);
 }

提示

与其像这样添加或删除调试代码,不如创建一个可以在 DEBUG 模式下切换的dev模式。

练习

为了帮助你测试本章内容的知识,这里有一些练习题供你练习。它们对于本书的其余部分并不是必要的,但是练习它们将帮助你评估你在所学内容中的优势和劣势:

  1. 有许多不同的算法可用于生成迷宫,例如随机的 Prim 算法和 Kruskal 算法。选择其中一个算法,尝试用你自己的实现替换递归回溯实现。

  2. 我们使用了相当小的关卡尺寸。尝试增加关卡的大小,并改变生成的关卡的特征。增加房间的数量、它们的大小等等。

  3. 你可能已经注意到我们的火炬不见了!因为我们不再从关卡文件中加载关卡,我们需要自己添加它们。火炬应该放置在TILE::WALL_TOP类型的瓦片上。尝试自己创建这个函数。如果遇到困难,你可以看看下一章的代码,找到开始的提示。

总结

在本章中,我们学习了我们的游戏如何在运行时生成自己的关卡数据,而不是之前从文本文件中加载预定义的关卡数据。这为游戏带来了很高的可重玩性,确保游戏性保持新鲜和具有挑战性。我们还使用了在前几章中定义的函数,为我们的关卡增添更多特色;我们使用了精灵效果为每个楼层创造了独特的感觉。我们的游戏现在几乎所有方面都是程序生成的,我们已经完成了一个完整的 Roguelike 项目。

现在我们的模板项目工作已经完成,我们将在最后一章中来看一下基于组件的设计。程序生成的关键在于灵活性。因此,我们希望使用最灵活的架构。基于组件的架构可以实现这一点。对这种设计方法有很好的理解将有助于您进步并构建更大、更灵活的系统。

第十章:基于组件的架构

过程化游戏系统本质上非常灵活。因此,它们所实现的框架和基础设施需要具有相同的特性。基于组件的系统,如 Unity 游戏引擎,在这方面表现出色,并且通常比传统的基于继承的系统提供更多的灵活性。

在构建像游戏引擎这样的大型动态系统时,传统的基于继承的方法会带来问题。继承结构变得混乱,对象变得越来越大,因为它们需要做更多的事情。结果,行为变得不够封装。基于组件的方法解决了这些问题,因此为了完成我们的工作,我们将稍微分支一下,看看基于组件的系统是什么,为什么它与过程生成相辅相成,以及我们如何改进现有的引擎以从中受益。

在本章中,我们将涵盖以下主题:

  • 传统继承方法的问题

  • 基于组件的方法的利弊

  • 理解基于组件的架构

  • 实施基于组件的系统

提示

如果你对 Unity 引擎不熟悉,请访问unity3d.com/并了解一下。它是行业领先的游戏引擎之一,采用了基于组件的方法。最好的部分是它完全免费!

理解基于组件的架构

基于组件的架构,也称为基于组件的设计和模块化编程,是一种旨在将行为分解为简洁、可重用组件的软件设计方法。我们在一定程度上已经在面向对象的设计中做到了这一点,但基于组件的架构将其推向了更深的层次。例如,如果一个对象,如精灵或 3D 模型需要某种行为,它将通过对象拥有的组件来定义,而不是从“基础”类继承而来。

传统基于继承的方法的问题

在我们深入讨论基于组件的方法的利弊之前,让我们先看看传统的基于继承的方法带来的问题。正是这些问题我们将要解决。

复杂的继承结构

假设我们有一个简单的“玩家”对象,需要一个 3D 模型,并受到游戏物理的影响。让我们看看可能需要创建这个对象的继承结构:

复杂继承结构

从这个图表中可以看出,即使是这个简单的场景也可能导致复杂的继承结构。如果你现在用整个游戏引擎来替换这个简单的例子,你可以想象继承结构会变得多么复杂和难以管理。

这是传统继承设计的一个主要缺陷;随着系统变得越来越大,对象在继承树中变得越来越复杂和纠缠不清。当我们试图创建一个过程化系统时,这种复杂性并不会帮助我们。我们希望一个尽可能灵活的系统。

循环依赖

复杂继承结构可能出现的另一个问题是循环依赖。这是指类 A 依赖于类 B,而类 B 又依赖于类 A,依此类推。下面的图表应该能更清楚地说明这一点:

循环依赖

虽然循环依赖可以通过适当的程序结构来避免,但随着系统变得越来越大,这变得越来越困难。随着继承树的增长,依赖关系也会增加,并且可能在系统内部造成真正的问题。通过消除复杂的继承,我们也消除了混乱依赖的风险。

基于组件的架构的好处

作为开发人员,我们总是在权衡利弊。了解一种方法的优缺点是至关重要的,这样我们才能做出关于它是否适合解决方案的知情决定。由于我们已经确定了基于继承的方法存在一些缺陷,并且希望通过基于组件的方法来解决这些问题,让我们熟悉一下它的一些优缺点。

避免复杂的继承结构

我们之前确定了一个假设的游戏情况,并看了一下典型的基于继承的方法可能是什么样子。让我们看看如果采用基于组件的方法,同样的例子会是什么样子:

避免复杂的继承结构

很明显,这个解决方案比基于继承的等价物要简单得多,也更整洁。它不是从父级获取其行为,从而创建一系列依赖关系,而是被分解成简洁的组件,可以简单地附加到一个对象上。

代码被分解成高度可重用的块

基于组件的架构的另一个好处是一旦代码封装在组件中,代码的高重用价值。一旦封装,行为可以通过简单地附加组件来轻松赋予对象。这不仅避免了重复的代码,还使得通过组合多个组件轻松构建复杂对象变得容易。这就是它适用于过程生成的地方。我们可以像乐高一样用这些可重用的组件程序地组装对象。

易维护和可扩展

由于代码是可重用的,这也使得维护变得非常容易。如果一组对象都从同一个源获取它们的行为,那么只需要进行一次编辑,就会影响它们所有。

基于组件的系统也更容易扩展。由于我们的组件是简洁的单独模块,没有复杂的依赖关系,我们可以随意添加它们。如果我们想要新的行为,我们不必担心诸如“它将放在哪里?”,“它将依赖于什么?”,“它将继承自什么?”等问题。我们只需构建新组件并在需要时使用它。

基于组件的架构的缺点

现在是时候看看争论的对立面了。尽管基于组件的设计确实带来了一系列巨大的好处,但也有一些需要考虑的事情。

代码可能变得过于分散

在一定程度上,这就是基于组件的设计的目标:将代码分解成可管理的块。但这可能走得太远了。如果对象和功能被分解得太细,那么我们最终会发现代码库散布在数百个小组件中,变得一团糟。永远记住这一点。是的,我们确实希望将我们的代码分解成可管理和可重用的组件;只是不要过度使用!

不必要的开销

在前面的基础上,如果代码被分解成太多小组件,那么我们将看到无用的开销增加。如果一个项目包含许多组件,我们将经常发现自己在其中来回进行任务。虽然添加一个组件可能会使代码更易于管理和维护,但在使用时也会引入开销。

使用复杂

组件的最终缺点就是它们的使用,因为它可能比传统的对象模型更复杂。我们不是直接访问成员函数,而是必须通过它们所属的组件。如果一个对象中有 20 个组件,那么我们必须记住变量在哪里,需要使用哪个组件。虽然这并不是什么高深的科学,但肯定比拥有直接拥有所有行为和数据的单个对象更复杂。

概述

希望现在清楚了基于组件的设计如何比传统的基于继承的方法更有利于过程化设计。过程生成的关键在于灵活性,当系统增长到一定规模时,基于继承的系统可能无法提供这种灵活性。通过允许我们将代码分解为可重用的对象,基于组件的设计保持了代码的灵活性,并且没有依赖性,这样我们就可以随意移动组件。

设计组件系统

基于组件的系统可以以多种方式实现。因此,在编写任何代码之前,让我们看看一些可能性。目标是将可重用的行为分解为简洁的组件,并能够轻松地向现有对象添加和删除它们。所有对象共享一个名为对象的公共基类,因此我们将为该类添加向其添加组件和从中删除组件的功能。然后我们可以确保它将传播到项目中所有后续的类中。

有许多实现基于组件的方法的方式,没有单一的正确答案。例如,我们可以创建一个函数来单独添加或删除每个组件。下面是一个例子:

bool	AttachSpriteComponent(SpriteComponent spriteCompontent);
bool	AttachInputComponent(InputComponent inputComponent);

虽然这将使事情变得简单,但是在类中会有很多重复的代码。而且,每次添加一个组件,我们都需要创建两个匹配的函数:一个用于获取组件,一个用于设置组件。这有点麻烦!

另一种方法是简单地将组件值公开。因此,我们可以直接通过它们所属的对象访问组件,而不是通过函数与组件交互:

Object object;
object.spriteComponent = SpriteComponent();

尽管这是一个吸引人的选择,因为这会让我们的生活变得简单千百倍,但是公开变量通常不是一个好主意。通常需要将变量公开以使代码工作,这通常表明系统架构存在缺陷。如果你发现这种情况,问题的原因应该得到解决。

如果我们看一个现有的基于组件的游戏引擎,比如 Unity,我们可以看到他们是如何解决这个问题的。以下代码演示了如何从 Unity 对象中获取一个组件。这是直接从 Unity 文档中摘取的:

// Disable the spring on the HingeJoint component.
HingeJoint hinge = GetComponent<HingeJoint>();
hinge.useSpring = false;

我们可以看到定义了一个名为GetComponent的函数,并提供了一个类型来返回相应的组件。我们可以使用枚举器创建一个类似的系统,以表示类型,允许用户通过参数指定组件类型,然后在switch语句中使用它来返回正确的变量。假设我们创建了一个AttachComponent函数,用以下声明来向对象添加一个组件:

void AttachComponent(COMPONENT_TYPE type, Component component);

在函数定义中,我们有类似于这样的东西:

void Object::AttachComponent(COMPONENT_TYPE type, Component component)
{
    switch (type)
    {
        case SOME_TYPE:
            m_someTypeVariable = component;
        break;
. . .

如果用户传递了匹配的类型和组件,这种方法就可以正常工作,但是这并不能保证。例如,用户可以指定一个移动组件,但实际上传递了一个音频组件,这将是不好的!我们将通过模板来解决这个问题!

C++模板

C++模板允许我们定义与通用类型一起工作的函数和类。这允许函数或类接受任何类型,而且只需要编写一次。这正是我们想要的。我们希望为组件定义一个单一的获取/设置函数,并使用模板使其通用和灵活。

让我们看一个模板的实际例子,以更好地了解它们是如何工作的。

使用模板

假设我们需要一个函数来添加两个数字,并且我们希望支持一系列类型。为了实现这一点,我们可以为我们想要支持的每种类型声明一个函数,如下所示:

int Add(int value1, int value2)
{
	return value1 + value2;
}

double Add(double value1, double value2)
{
    return value1, value2;
}

看看这两个函数,它们唯一不同的是它们的返回和参数类型。如果我们能够说“不用担心类型,我会在后面给你”,然后只有一个函数,那该有多好呀?这就是模板的作用!

模板声明

C++模板允许我们定义具有通用类型的函数,并在调用函数时指定类型。这是一个非常有用的功能,可以创建灵活和可重用的代码,而不是有多个几乎相同的函数定义。如果使用模板,前面的例子只需要一个函数:

template<typename T>
T Add(T value1, T value2)
{
    T value;
    Value = value1 + value2;
    return value;
}

注意

模板参数可以使用typenameclass关键字。这两个关键字完全可以互换使用并起到相同的作用。但是,它们可以用作提示来表示参数类型。如果参数是类,则使用class,对于所有其他类型(int、char*等),使用typename

以下语法用于声明模板:

template<template-parameters> function-declaration;

在声明中,我们创建了一个名为T的模板参数。这给了我们一个模糊的数据类型,可以在函数声明中使用,直到稍后在调用模板时设置实际类型。通用的T类型可以像普通类型一样使用:指定返回类型、创建变量和设置参数类型。

提示

模板参数的名称可以是任何你喜欢的,尽管最常见的是TYPET

模板还可以定义多个类型。例如,假设一个函数需要使用两种不同的数据类型。我们可以使用以下模板:

template<typename T1, typename T2>
bool AreEqual(T1 value1, T2 value2)
{
    return value1==value2;
}

最后,模板也可以与普通数据类型一起使用,它们不必是模糊的:

template<typename T, int I>
T IntegerMultiply(T value1)
{
    return value1 / value2;
}

使用模板

有了定义的模板,让我们看看如何使用它们。我们给模板指定了模糊的类型,因此调用它们的一种方式是明确告诉模板我们想要使用的类型。这是通过在函数/类调用后的<>括号中传递类型来完成的:

float floatValue = Add<float>(1.f, 2.f);
bool isEqual = AreEqual<double, int>(5.0, 5);
int intValue = IntegerMultiply<float, 2>(1.f);

前两个是直接的;我们为每个模板参数分配了一个类型。然而,最后一个略有不同。由于第二个类型是固定的,所以不需要在尖括号中指定它。相反,我们可以像普通参数一样使用它,传递我们想要使用的值。这样,括号中只剩下一个参数:通用类型值。

需要注意的重要事情是,模板参数的值是在编译时确定的。这意味着对于模板的每个不同实例化,都会创建一个唯一的函数。在最后的例子中,int的值被传递为模板函数,这意味着创建了一个硬编码为乘以值 2 的函数。

假设我们调用了IntegerMultiple两次:

int intValue = IntegerMultiply<float, 2>(1.f);
int intValue = IntegerMultiply<float, 10>(1.f);

尽管我们调用了相同的模板,编译器将创建两个不同版本的IntegerMultiply。一个版本将始终乘以 2,另一个版本将始终乘以 10。因此,第二个模板的参数,整数,必须是常量表达式。以下代码将导致编译错误:

int a = 10;
int intValue = IntegerMultiply<float, a>(1.f);

当编译器可以解析类型时,这些函数也可以在尖括号中不明确表示类型的情况下调用。为了发生这种情况,关于类型的歧义必须不存在。例如,以下调用是可以的:

float floatValue = Add(1.f, 2.f);
bool isEqual = AreEqual(5.0, 5);

在这些调用中,模板中的每个模糊类型都被赋予一个单一类型。因此,编译器可以自动推断T的类型。然而,考虑一种不同的参数传递的情况:

float floatValue = Add(1.f, 2);

T现在有两个可能的值,这意味着编译器无法推断类型,将导致错误。

模板特化

现在我们对模板的一般工作原理有了了解,让我们来看看模板特化。我们已经知道可以使用通用类型定义模板,并在调用函数时稍后定义它们。如果所有可能的实现共享相同的行为,那就没问题,但如果我们想要根据类型的不同行为呢?

假设我们想要使用Add函数与字符串类型。我们想要传入两个单词,但当这种情况发生时,我们想要在它们之间放一个空格。默认的模板函数无法实现这一点,因此我们必须为这种情况专门设置。要为模板设置专门的内容,我们只需创建一个声明,其中用固定的类型替换模糊的类型,这在我们的情况下是std::string

template<>
std::string Add<std::string>(std::string value1, std::string  value2)
{
    std::string result;
    result = value1 + " " + value2;
    return result;
}

现在,当调用template函数并指定std::string类型时,它将使用这个定义而不是通用的定义。有了这个,我们仍然可以使用模板,但为某些类型提供特定的实现。非常方便。

提示

如果您想了解更多关于 C++模板的知识,请访问www.cplusplus.com/doc/tutorial/templates/。这是一个很棒的网站,它对这个主题有一些很棒的信息。

函数重载

与模板有些相似,函数重载是我们可以使代码和类更加灵活的另一种方式。在本书的过程中,我们已经使用了重载函数,但它们是由代码库提供的。因此,让我们快速看一下它们。

当我们定义函数时,我们设置了固定的参数类型。这里有一个例子:

void DoStuff(T parameter);

有了这个定义,我们只能传递T类型的参数。如果我们想要选择参数呢?如果我们想要能够传递T类型或Y类型的参数呢。好吧,我们可以重新定义函数,设置相同的返回类型和名称,但使用唯一的参数:

void DoStuff(T parameter);
void DoStuff(Y parameter);

现在我们有两个具有不同参数的函数声明。当我们调用DoStuff时,我们可以选择传递哪个参数。另外,使用函数重载,每个声明都有自己的函数体,就像模板特化一样。虽然在表面上相似,但函数重载和模板特化的工作方式不同,不过这超出了本书的范围。目前,我们只需要对它们有一个基本的理解,就可以开始了!

提示

与模板一样,要了解更多关于函数重载的内容,请访问www.cplusplus.com/doc/tutorial/functions2/

创建一个基础组件

理论已经讲解完毕,让我们将其实现到我们的项目中。本章的主要信息迄今为止一直是使用组件来避免混乱的继承,但我们仍然需要一些继承,因为我们需要使用多态!

每个对象都能够持有一系列组件,因此我们将它们存储在一个单一的generic容器中。为了做到这一点,我们需要利用多态性,确保所有组件都继承自一个共同的基类。现在我们要实现这个基类。

让我们向项目中添加一个新的类,并将其命名为Component。我们将把实现留给你来完成.cpp

#ifndef COMPONENT_H
#define COMPONENT_H

class Component
{
public:

    /**
    * Default Constructor.
    */
    Component();

    /**
    * Create a virtual function so the class is polymorphic.
    */
    virtual void Update(float timeDelta) {};
};
#endif

请注意,我们在这里添加了一个virtual update函数,因为一个类必须至少有一个virtual函数才能是多态的。有了Component基类创建后,我们现在可以添加getset组件的函数,它们将驻留在基础Object类中,以便所有对象都可以使用它们。

组件函数

如果我们考虑我们想要的行为,我们需要能够给一个对象赋予任何给定类型的组件。我们还需要能够稍后获取相同的组件。我们将称这些函数为AttachComponentGetComponent

在本章的前面,我们确定了如何使用模板来创建具有通用类型的函数,并在需要时给它们提供真实值。我们将使用模板和多态性来创建这两个函数。

附加组件

我们要编写的第一个函数将用于将给定类型的组件附加到Object类。由于我们已经确定要将所有组件存储在单个通用容器中,因此这个函数将是一个相对简单的模板。我们唯一需要注意的是我们不应该添加相同的组件两次!

让我们首先定义容器,因为那是我们将存储对象的地方。由于我们需要利用多态性,我们不能存储实际对象。因此,我们将使用共享指针,以便我们可以轻松地传递它们。

让我们首先在Object.h中定义通用容器。不要忘记#include我们的新组件类,以便 Object 可以看到它:

private:
/**
 * A collection of all components the object has attached.
 */
std::vector<std::shared_ptr<Component>> m_components;

现在是实际的AttachComponent方法的时候了。我们可以采取一个天真的方法,只是将新组件附加到“通用”容器。问题在于我们可能会添加多个相同类型的组件,这不是我们想要的。在将组件添加到集合之前,我们将首先检查是否已经存在相同类型的组件,为此,我们将使用std::dynamic_pointer_cast函数。

这个函数让我们在指针之间进行转换,并在失败时返回空指针。这非常方便,当与模板结合使用时,我们可以创建一个单一的函数,它将接受任何组件类型,创建一个组件,检查是否已经存在相同类型的组件,如果存在,它将覆盖它。我们将在头文件中内联定义这个模板函数。让我们将以下代码添加到Object.h中:

/**
 * Attaches a component to the object.
 */
template <typename T>
std::shared_ptr<T> AttachComponent()
{
    // First we'll create the component.
    std::shared_ptr<T> newComponent = std::make_shared<T>();

    // Check that we don't already have a component of this type.
    for (std::shared_ptr<Component>& exisitingComponent : m_components)
    {
        if (std::dynamic_pointer_cast<T>(exisitingComponent))
        {
            // If we do replace it.
            exisitingComponent = newComponent;
            return newComponent;
        }
    }

    // The component is the first of its type so add it.
    m_components.push_back(newComponent);

    // Return the new component.
    return newComponent;
};

使用模板,我们可以操作通用的T类型,这允许我们执行转换以检查类型是否匹配。如果它们匹配,我们用新的组件覆盖旧的组件;如果不匹配,我们只是将其添加到我们的集合中。完成后,如果用户想要,我们还会返回新的组件。

就是这样,使用这种模板的美妙之处在于系统的可扩展性。无论我们添加了 1000 个组件,这个函数都能够将它们附加到任何对象。

返回一个组件

我们需要创建的下一个模板是用于返回给定组件的函数。同样,让我们考虑一下我们将在何处需要通用类型。该函数将需要返回组件类型,因此需要是通用的,并且我们还需要找到正确的组件类型。因此,我们将像上一个函数一样在指针转换中使用通用类型。

让我们在Object的头文件中定义这个模板:

/**
* Gets a component from the object.
*/
template <typename T>
std::shared_ptr<T> GetComponent()
{
    // Check that we don't already have a component of this type.
    for (std::shared_ptr<Component> exisitingComponent : m_components)
    {
        if (std::dynamic_pointer_cast<T>(exisitingComponent))
        {
            return std::dynamic_pointer_cast<T>(exisitingComponent);
        }
    }

    return nullptr;
};

有了这个,我们可以将任何组件添加到任何对象并返回正确的类型。最棒的部分是两个简单的函数提供了所有这些功能!模板是多么棒!

如果你想在我们继续之前测试这段代码,你可以这样做。在Game::Initialize函数的末尾,添加以下行:

m_player.AttachComponent<Component>();
m_player.AttachComponent<Component>();

std::shared_ptr<Component> component = m_player.GetComponent<Component>();

如果您使用断点并在运行时查看值,您会发现这段代码做了以下几件事情:

  • 它向通用容器添加一个新的Component对象

  • 它试图添加第二个Component对象;所以它代替了当前的

  • 它意识到我们想要类型为Component的组件;所以它返回它

创建一个变换组件

有了附加和返回组件的能力,让我们构建并添加我们的第一个组件。我们先从一个简单的开始。目前,所有对象默认都有一个由Object基类提供的位置。让我们将这个行为分解成自己的组件。

封装变换行为

由于我们正在将基于继承的方法转换为基于组件的方法,第一项任务是将Object类中的变换行为取出来。目前,这包括一个“单一”的位置变量和一个既“获取”又“设置”该值的函数。

让我们创建一个名为TransformComponent的新类,并将这个行为移到其中,如下所示:

#ifndef TRANSFORMCOMPONENT_H
#define TRANSFORMCOMPONENT_H

#include "Component.h"

class TransformComponent : public Component
{
public:
    TransformComponent();
    void SetPosition(sf::Vector2f position);
    sf::Vector2f&  GetPosition();

private:
    sf::Vector2f  m_position;
};
#endif

我们还将从Object.cpp文件中获取函数定义,并将它们放在TransformComponent.cpp中,如下所示:

#include "PCH.h"
#include "TransformComponent.h"

TransformComponent::TransformComponent() :
m_position({ 0.f, 0.f })
{
}

void TransformComponent::SetPosition(sf::Vector2f position)
{
    m_position = position;
}

sf::Vector2f& TransformComponent::GetPosition()
{
    return m_position;
}

现在我们有一个组件,它将为对象提供一个位置。我们需要做的最后一件事是在Object类中包含这个组件的头文件,以便所有扩展类都可以看到它。让我们在Object.h中添加以下代码:

. . .

#ifndef OBJECT_H
#define OBJECT_H

#include "Component.h"
#include "TransformComponent.h"

class Object
{
public:

. . .

现在是时候将这个组件添加到对象中了!这是一个庞大的任务,留给你自己在空闲时间完成,但为了演示如何完成,我们将快速地将组件添加到player类中。

向玩家添加一个 transform 组件

由于我们将附加和获取组件的两个函数放在了基类Object中,我们可以直接从 player 中调用AttachComponent。我们将在构造函数中执行此操作,因为我们需要在逻辑之前设置好组件。让我们前往Player::Player并将以下代码添加到其中:

// Add a transform component.
AttachComponent<TransformComponent>();

就是这样!player现在拥有我们添加到 transform 组件的所有数据和功能,当我们想要使用它时,我们可以简单地通过这个新组件进行。你可能记得我们曾经将开销标识为基于组件的设计的潜在缺点之一。现在我们可以看到,将行为移入组件中引入了开销。

使用 transform 组件

这个谜题的最后一部分将是看看我们如何使用新的组件。以前,如果我们想要获取玩家的位置,我们只需要使用以下代码:

// Get the position.
sf::Vector2f playerPos = m_position;

// Set the position.
m_position = sf::Vector2f(100.f, 100.f);

由于这些值现在属于transform组件,我们需要做一个小改变,并通过component来访问这些值,如下所示:

// Get the transform component.
auto transformComponent = GetComponent<TransformComponent>();

// Get the position.
sf::Vector2f position = transformComponent->GetPosition();

// Set the position.
transformComponent->SetPosition(sf::Vector2f(100.f, 100.f));

由于这些函数是公共的,我们可以在任何地方调用它们。例如,如果我们在游戏类中想要获取玩家对象的位置,我们会这样做:

sf::Vector2f position = m_player.GetComponent<TransformComponent>()->GetPosition();

更新游戏代码

有了这个架构,并且了解了transform组件的工作原理,现在是时候更新游戏代码,以利用新的组件。这将需要一些改变。因此,我们不会在本章中详细介绍它们;这留给你来完成!

每个具有位置的对象都需要添加一个transform组件,并且现在需要通过组件访问这些位置变量的地方。如果你在任何时候遇到困难,请参考之前的代码示例。如果你自己运行项目并进行这些更改,请确保在完成后运行项目,以确保一切仍然正常运行:

更新游戏代码

尽管外表看起来可能相同,但我们知道底层系统现在更加灵活、可维护和可扩展。让我们创建更多的组件!

创建一个 SpriteComponent

我们接下来要做的是一个SpriteComponent。这将为object提供一个staticanimated的精灵。这是一个经常被许多对象重复使用的行为,因此是一个很好的候选项,可以移入一个组件中。

封装精灵行为

目前,所有与动画相关的行为都是从基类Object继承的。以下代码包括了我们将从Object中提取出来的所有与精灵和动画相关的函数和变量,放入它自己的类中:

public:
    virtual void Draw(sf::RenderWindow &window, float timeDelta);
    bool SetSprite(sf::Texture& texture, bool isSmooth, int frames = 1, int frameSpeed = 0);
    sf::Sprite& GetSprite();
    int GetFrameCount() const;
    bool IsAnimated();
    void SetAnimated(bool isAnimated);

protected:

    sf::Sprite m_sprite;

private:

    void NextFrame();

private:

    int m_animationSpeed;
    bool m_isAnimated;
    int m_frameCount;
    int m_currentFrame;
    int m_frameWidth;
    int m_frameHeight;

目前,我们创建的每个对象都具有这些变量和函数,但并不一定需要它们,这是一种浪费。有了我们的component,我们可以给一个对象赋予这种行为,而不必担心继承。

让我们从项目中创建一个新的类,并将其命名为SpriteComponent,确保它扩展了基类Component

提示

保持一个清晰的项目很重要。创建文件夹,并将你的类组织成逻辑组!

现在,我们可以添加所有从Object中提取出来的函数和变量:

#ifndef SPRITECOMPONENT_H
#define SPRITECOMPONENT_H

#include <SFML/Graphics.hpp>
#include "Component.h"

class SpriteComponent : public Component
{
public:
    SpriteComponent();

    virtual void Draw(sf::RenderWindow &window, float timeDelta);
    bool SetSprite(sf::Texture& texture, bool isSmooth, int frames = 1, int frameSpeed = 0);
    sf::Sprite& GetSprite();
    int GetFrameCount() const;
    bool IsAnimated();
    void SetAnimated(bool isAnimated);

private:

    void NextFrame();

private:
sf::Sprite m_sprite;
    int m_animationSpeed;
    bool m_isAnimated;
    int m_frameCount;
    int m_currentFrame;
    int m_frameWidth;
    int m_frameHeight;
};
#endif

在这里,我们对我们使用的public/protected/private修饰符进行了一些微小的更改。以前,由于继承关系,许多函数和变量都被赋予了protected关键字,暴露给子类。由于我们正在摆脱继承,所有这些现在都被移动到private中。

现在我们只需要在构造函数的初始化列表中初始化变量,并在SpriteComponenet.cpp中添加函数的定义。同样,这些可以直接从Object类中提取并移植过来。还要记得在Object.h中包含这个类:

. . .

#ifndef OBJECT_H
#define OBJECT_H

#include "Component.h"
#include "TransformComponent.h"
#include "SpriteComponent.h"

class Object
{
public:

. . .

类完成并包含了头文件,我们现在可以实现这个组件了!

向玩家类添加精灵组件

让我们继续使用玩家类来演示,给这个类添加一个sprite组件。我们之前决定最好的地方是在构造函数中。所以,在我们创建transform组件之后,让我们在Player::Player中添加以下代码:

    . . .

    // Add a transform component.
    AttachComponent<TransformComponent>();

 // Add a sprite component.
 AttachComponent<SpriteComponent>();
}

更新后的绘图管道

现在我们的objects能够接收sprite组件,我们需要更新绘图管道以便能够使用它们。目前,我们在主游戏循环中循环遍历所有对象,依次绘制每个对象。然而,现在对象本身不再负责绘制,sprite组件负责(如果有的话)。在main draw循环中,我们需要检查它们是否附加了 sprite 组件,如果有,就调用组件的Draw函数。GetComponent函数如果没有找到组件则返回nullprt,这样很容易检查:

. . .

// Draw all objects.
for (const auto& item : m_items)
{
    //item->Draw(m_window, timeDelta);

 auto spriteComponent = item->GetComponent<SpriteComponent>();

 if (spriteComponent)
 {
 spriteComponent->Draw(m_window, timeDelta);
    }
}

. . .

随着绘图管道的更新,让我们快速看一下如何使用component

更新游戏代码

再次来了大工程!在每个使用精灵的场合,我们需要更新代码以通过sprite组件进行。与上一个组件一样,这会给代码带来许多变化,因此这是另一个需要您自行完成的任务。

本章末尾还建议您尝试将这个组件分成多种类型:一种用于static精灵,另一种用于animated精灵。这将使代码更加封装和高效,因为当前这个组件即使不需要动画也提供了动画。

如果您尝试了这个,希望没有发生什么灾难,您仍然能够编译没有问题。如果一切顺利,我们将看不到任何新东西,但这是件好事!

更新游戏代码

创建音频组件

我们要创建的最后一个组件是一个audio组件。现在,这是我们将从头开始创建的第一个组件。然而,我们之前对前两个组件的经验应该使得这个组件很容易实现。

定义音频组件的行为

这与我们过去的组件略有不同。我们不是封装现有的行为,而是需要定义它。我们将创建一个简单的audio组件,我们唯一的行为是能够播放一个音效。为此,我们需要一个变量来保存音频对象,一个设置音频缓冲区的函数,以及一个播放音频的函数。

在用于设置音频缓冲区的函数中,我们将使用函数重载。如果我们考虑如何使用这个函数,我们可能要么传递一个已经创建的音频缓冲区到组件中,要么传递一个路径并在使用之前创建它。我们在本章前面讨论过函数重载,这是一个典型的使用案例。我们定义相同的函数名和返回类型,但参数类型不同。

让我们将这个新的AudioComponent类添加到项目中,如下所示:

#ifndef AUDIOCOMPONENT_H
#define AUDIOCOMPONENT_H

#include "SFML/Audio.hpp"
#include "Component.h"

class AudioComponent
{
public:
    AudioComponent();

    void Play();
    bool SetSoundBuffer(sf::SoundBuffer& buffer);
    bool SetSoundBuffer(std::string filePath);

private:
    sf::Sound m_sound;
};
#endif

再次,我们将把这个类作为一个练习留给你来完成,并为这些函数提供定义。完成这个类之后,我们不要忘记把这个类包含在Object.h文件中,这样所有的对象都可以看到并使用它。

. . .

#ifndef OBJECT_H
#define OBJECT_H

#include "Component.h"
#include "TransformComponent.h"
#include "SpriteComponent.h"
#include "AudioComponent.h"

class Object

. . .

向玩家类添加音频组件

最后一步是实际将我们的组件连接到对象上。我们之前已经介绍过这个过程,只需要在AttachComponent函数中添加一个调用,指定AudioComponent作为类型。为了演示这一点,让我们在玩家身上添加一个音频组件,以及精灵和变换组件:

    . . .

    // Add a transform component.
    AttachComponent<TransformComponent>();

    // Add a sprite component.
    AttachComponent<SpriteComponent>();

    // Add an audio component.
    AttachComponent<AudioComponent>();
}

使用音频组件

使用音频组件非常简单。我们给它一个声音缓冲区,这可以是一个预先构建的,也可以是需要加载的文件路径,然后调用AudioComponent::Play函数。让我们给玩家他们自己的攻击声音,而不是将其保存在main游戏类中。在给玩家添加音频组件之后,让我们设置它将使用的声音:

    . . .

    // Add an audio component.
    AttachComponent<AudioComponent>();

    // Set the player's attack sound.
    GetComponent<AudioComponent>()->SetSoundBuffer("../resources/sounds/snd_player_hit.wav");
}

main类中,当我们检测到与玩家的碰撞时,我们现在通过这个组件播放声音,而不是直接播放:

. . .

// Check for collision with player.
if (enemyTile == playerTile)
{
    if (m_player.CanTakeDamage())
    {
        m_player.Damage(10);
        //PlaySound(m_playerHitSound);
        m_player.GetComponent<AudioComponent>()->Play();
    }
}

. . .

你可以看到将这个行为添加到对象中是多么容易,而且将它添加到尽可能多的对象中也不需要太多工作!如果我们想要做出改变,我们只需要改变组件类,它就会影响所有的子类。太棒了!

练习

为了帮助你测试本章内容的知识,这里有一些练习题供你练习。它们对于本书的其余部分并不是必须的,但是练习它们会帮助你评估你在所学内容中的优势和劣势。

  1. 将游戏输入从一个固定的static类移动到一个组件中。

  2. SpriteComponent分成两个单独的组件;一个提供静态精灵,一个提供动画精灵。

  3. 创建一个封装特定行为的组件并在游戏中使用它。

总结

在本章中,我们深入研究了基于组件的架构,包括在创建过程系统时带来的主要优势,以及如何通过模板的使用来实现它。本章概述的方法只是许多可能实现之一,因此我鼓励您尝试不同的方法。它的即插即用性使其非常灵活,这是在创建过程系统时我们寻求的一个重要特征。

在下一章中,我们将回顾项目和我们所涵盖的主题,因为我们即将结束本书。对于我们使用的每个过程生成领域,我们还将确定一些跳跃点,以便您希望更深入地探索该主题。

第十一章:结语

我们的游戏已经完成,经过一次快速的组件设计探索,我们对程序内容生成的介绍也完成了。我们从生成和使用随机数开始,一直到创建复杂的程序行为和内容。我们涉及了许多主题,希望通过这本书的学习,能够帮助定义程序生成是什么,并提供一些如何在游戏中实现它的实例。

在你最后关闭这本书之前,让我们快速回顾一下项目,看看我们是如何以及在哪里实现程序生成的。然后在完成我们的工作之前,我们将再次重申它的优缺点。

项目分解

我们的游戏项目最初是一个空白的地牢游戏模板,功能有限,但通过我们的工作,我们将其变成了一个完全成熟的程序生成地牢游戏。让我们回顾一下项目,看看我们如何使用程序生成来实现这一点。

如果你希望深入探讨每一章的话,我们还将提供一些可能的进一步练习。这本书的目标是向你介绍这个主题的基础知识,希望你在进一步学习时能够快速上手。

程序生成环境

我们首先在关卡周围随机生成游戏物品。这涉及在给定范围内生成随机数,并将其用作瓷砖索引和枚举器值。这是我们第一次使用随机数和枚举器来选择随机值和物品,这是我们在整本书中都大量依赖的技术。

如果你希望进一步探索,可以看看如何偏向物品的生成位置,或者限制它在地图的某些区域。例如,你可以偏向生成位置,使所有宝石倾向于生成在关卡的右侧,所有黄金倾向于生成在左侧。虽然对我们的项目没有立即好处,但你可以想象在其他情况下它可能有用。你可能希望所有的敌人生成在关卡的某个特定部分,或者某个物品生成在地图的某个区域。更多地控制游戏关卡将非常有益。

创建独特和随机的游戏对象

现在我们的物品已经分布在各个关卡中,我们开始关注如何使它们更随机和独特。我们不是在运行时硬编码物品变量,而是在运行时生成它们,使物品更加多样化。我们使用这种技术从一个类中创建多个对象类型,比如所有药水都来自同一个类。

在项目中进一步扩展这一点,为什么不尝试添加一些随机的护甲和/或武器呢?它们可以被敌人掉落,并具有随机的精灵和统计数据。你可以采用我们在药水中采用的相同方法,创建一个模糊的类,可以生成各种可能性。

程序生成艺术

在本章中,我们看了如何通过程序生成艺术。我们从简单的方法开始,使用 SFML 内置的精灵修改函数,然后转向更复杂的方法,通过将多个精灵组件一起渲染,创建新的、独特的精灵,给敌人随机生成护甲。

如果你想学到更多,你应该看看如何完全从头开始创建艺术。有一些算法,比如 Perlin 和 Simplex 噪声,可以生成 2D 噪声。这些算法可以作为程序纹理的基础。开始研究这些算法,然后从那里开始。

程序修改音频的生成。

通过程序生成音频是一项复杂的任务。因此,在本章中,我们的工作有些简短,而且真正局限于对现有声音进行程序修改,而不是完全创作它们。与艺术一样,SFML 提供了一系列编辑声音的功能,比如音调和音量,这些功能被用来给简单的声音增加一些变化。然后,我们使用 SFML 的内置音频功能创建了空间化的 3D 声音,为我们的游戏增添了一些深度。

声音可以完全通过程序生成,但这是一个复杂而困难的过程,因此与其他程序实现相比并不那么受欢迎。然而,如果你想进一步了解这一点,也许可以先创建单个声音并学习计算机如何产生它们。从这里开始,就是学习如何将这些声音组合在一起创造出吸引人的东西,然后生成一个程序算法来实现。这绝对不是一件简单的事情!

程序行为和机制

从简单使用随机数和从枚举器中选择值开始,我们实现了一个更复杂和丰富的程序系统,为敌人提供了基本的 AI 形式的路径规划。我们实现了 A*路径规划,使敌人能够在迷宫中追逐玩家。我们还创建了一个系统来生成随机的关卡目标,因此现在我们的玩家定期会面临一个独特的目标,以换取独特的奖励。

我们生成的游戏机制相当简单,那么为什么不试着自己生成一些更复杂的呢?你可以给玩家一个真正需要完成的任务,以便继续玩游戏。或者,如果是 AI 工作吸引了你,你可以在此基础上进行扩展,使敌人更聪明,更具挑战性。也许如果他们失去了对玩家的视线,他们会停止追逐,或者他们会试图预测玩家的移动来挡住你,而不仅仅是跟随。

程序生成地牢

作为我们的巅峰之作,我们实现了程序生成地牢。在此之前,关卡是从文本文件中加载的,但我们实现了一个递归回溯算法来生成一个迷宫,然后在迷宫中添加房间以创建更开阔的区域。因此,关卡现在是程序生成的,我们可以通过一个函数调用生成一个新的关卡。

有很多方法可以用来实现程序生成地牢,以及许多不同的算法可以用于此。如果你对这个领域感兴趣,有很多探索的空间。看看一些替代算法,并尝试实现。尝试生成一些具有不同特征的房间,或者添加一些环境和美学特征,给关卡增添更多特色。

提示

进一步阅读的好资源是weblog.jamisbuck.org/。这个博客是关于迷宫生成的一切的宝库,涵盖了许多算法。你一定要去看看。

基于组件的架构

程序内容生成围绕灵活性展开,因此,我觉得涉及基于组件的设计会是结束我们工作的一个好方法。通过基于组件的设计,我们可以创建一个灵活的代码基础,其固有的灵活性将使实施程序系统变得更容易。

在本章中,我们介绍了基于组件的设计的基本原理,并看了一些孤立的例子。一个很好的练习是运行项目,将其完全转换为基于组件的方法。这将使你真正熟悉这个概念,并且你将准备好在下一个项目中使用它。

程序生成的利与弊

最后一次,让我们来看看在我们的游戏中使用程序生成的主要利与弊。

利与弊

  • 它创造了动态内容

  • 它可以节省内存使用

  • 它可以节省开发时间和金钱

  • 它创造了大量的选择

  • 它增加了可重复性

缺点

  • 您放弃控制*

  • 它可能会对硬件造成负担

  • 可能会感觉重复

  • 很难编写游戏事件的脚本

  • 可能会生成无法使用的内容

注意

*您放弃的控制量取决于您的算法质量。最终,你是写算法的人。因此,你可以让它做你想要的事情。

总结

我希望你觉得这本书中的内容有用。目标是向您介绍程序生成的广泛主题,我觉得与真实游戏一起工作是最好的方法。我们涵盖了开发的关键领域,并确定了在每个方面使用程序生成的方法。希望现在您已经有足够的知识来在自己的游戏中使用它,并且可以进行进一步阅读,了解更多您最感兴趣的领域。

记住,程序生成不仅仅是一种方法或一种途径。它是内容的动态生成。没有一种正确的实现方式,所以要进行实验。找到新的动态创建内容的方法并进行尝试。没有错误的答案。

愉快的编程!

posted @ 2024-05-15 15:27  绝不原创的飞龙  阅读(25)  评论(0编辑  收藏  举报