C---游戏开发秘籍-全-

C++ 游戏开发秘籍(全)

原文:zh.annas-archive.org/md5/260E2BE0C3FA0FF74505C2A10CA40511

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书详细介绍了 C++的一些方面,这些方面可以用于游戏开发。

本书涵盖的内容

第一章,“游戏开发基础”,解释了 C++编程的基础知识,编写小型程序用于游戏,并且如何在游戏中处理内存。

第二章,“面向对象的方法和游戏设计”,解释了在游戏中使用面向对象的概念,您将制作一个小型的原型文本游戏。

第三章,“游戏开发中的数据结构”,介绍了 C++中所有简单和复杂的数据结构,并展示了如何在游戏中有效地使用它们。

第四章,“游戏开发的算法”,解释了可以在游戏中使用的各种算法。它还涵盖了衡量算法效率的方法。

第五章,“事件驱动编程-制作您的第一个 2D 游戏”,介绍了 Windows 编程,创建精灵和动画。

第六章,“游戏开发的设计模式”,解释了如何在游戏开发中使用众所周知的设计模式以及何时不要使用它们。

第七章,“组织和备份”,解释了备份数据的重要性以及在团队中共享数据的重要性。

第八章,“游戏开发中的人工智能”,解释了如何在游戏中编写人工智能。

第九章,“游戏开发中的物理学”,解释了如何使物体碰撞以及如何使用第三方物理库,如 Box2D,来制作游戏。

第十章,“游戏开发中的多线程”,解释了如何使用 C++11 的线程架构来制作游戏。

第十一章,“游戏开发中的网络”,解释了编写多人游戏的基础知识。

第十二章,“游戏开发中的音频”,解释了如何向游戏添加声音和音乐效果,并在播放声音时避免内存泄漏。

第十三章,“技巧和窍门”,介绍了使用 C++制作游戏的一些巧妙技巧。

您需要为这本书做什么

对于这本书,您需要一台 Windows 机器和一个可用的 Visual Studio 2015 Community Edition 的副本。

这本书是为谁准备的

这本书主要适用于想要进入游戏行业的大学生,或者想要早早动手并了解游戏编程基础的热情学生。这本书还有一些非常技术性的章节,对于行业专业人士来说,这些章节将非常有用,可以作为参考或在解决复杂问题时随身携带。

部分

在这本书中,您会发现一些经常出现的标题(准备工作,如何做,它是如何工作的,还有更多,另请参阅)。

为了清晰地说明如何完成一个食谱,我们使用以下部分:

准备工作

这一部分告诉您食谱中可以期待什么,并描述了为食谱设置任何所需的软件或任何初步设置的方法。

如何做...

这一部分包含了遵循食谱所需的步骤。

它是如何工作的...

这一部分通常包括对前一部分发生的事情的详细解释。

还有更多...

这一部分包含有关食谱的其他信息,以使读者对食谱更加了解。

另请参阅

本节提供了有用的链接,可获取其他有用信息。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些示例以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“如果您有一个名为main.cpp的文件,它将生成一个名为main.o的目标代码。”

代码块设置如下:

#include <iostream>
#include <conio.h>

using namespace std;

int countTotalBullets(int iGun1Ammo, int iGun2Ammo)
{
    return iGun1Ammo + iGun2Ammo;
}

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“点击下载 Visual Studio Community。”

注意

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

提示

提示和技巧显示如下。

第一章:游戏开发基础

在本章中,将涵盖以下食谱:

  • 在 Windows 上安装一个 IDE

  • 选择合适的源代码控制工具

  • 使用调用堆栈进行内存存储

  • 谨慎使用递归

  • 使用指针存储内存地址

  • 在各种数据类型之间进行转换

  • 使用动态分配更有效地管理内存

  • 使用位操作进行高级检查和优化

介绍

在本章中,我们将介绍你在游戏开发中需要了解的基本概念。

在一个人开始编码之前的第一步是安装一个集成开发环境IDE)。现在有一些在线 IDE 可用,但我们将使用离线独立的 IDE,Visual Studio。许多程序员在早期阶段没有开始使用的下一个最重要的事情是修订控制软件

修订控制软件有助于将代码备份到一个中心位置;它有对所做更改的历史概述,您可以访问并在需要时恢复,它还有助于解决不同程序员同时对同一文件进行的工作之间的冲突。

在我看来,C++最有用的特性是内存处理。它让开发人员对内存分配方式有很大的控制,这取决于程序的当前使用和需求。因此,我们可以在需要时分配内存,并相应地释放它。

如果我们不释放内存,我们可能很快就会用完内存,特别是如果我们使用递归。有时需要将一种数据类型转换为另一种,以防止数据丢失,在函数中传递正确的数据类型等。C++提供了一些方法,我们可以通过这些方法进行转换。

本章的食谱主要关注这些主题,并处理实现它们的实际方法。

在 Windows 上安装一个 IDE

在这个步骤中,我们将发现在 Windows 机器上安装 Visual Studio 有多么容易。

准备工作

要完成这个步骤,你需要一台运行 Windows 的机器。不需要其他先决条件。

操作步骤

Visual Studio 是一个强大的 IDE,大多数专业软件都是用它编写的。它有很多功能和插件,帮助我们写出更好的代码:

  1. 转到www.visualstudio.com

  2. 点击下载 Visual Studio Community操作步骤…

下载 Visual Studio Community

  1. 这应该下载一个.exe文件。

  2. 下载完成后,双击安装文件开始安装。

  3. 确保你的 Windows 机器上有必要的所有更新。

  4. 你也可以下载任何版本的 Visual Studio 或 Visual C++ Express。

  5. 如果应用程序要求开始环境设置,请从可用选项中选择C++

注意

以下是需要注意的几点:

  • 你需要一个 Microsoft 账户来安装它。

  • 还有其他免费的 C++ IDE,比如NetBeansEclipseCode::Blocks

  • 虽然 Visual Studio 只适用于 Windows,但 Code::Blocks 和其他跨平台的 IDE 也可以在 Mac 和 Linux 上运行。

在本章的其余部分,所有的代码示例和片段都将使用 Visual Studio 提供。

工作原理

IDE 是一个编程环境。IDE 包括各种功能,这些功能在一个 IDE 到另一个 IDE 可能会有所不同。然而,在所有 IDE 中都存在的最基本的功能是代码编辑器、编译器、调试器、链接器和 GUI 构建器。

代码编辑器,或者另一种称呼为源代码编辑器,对程序员编写的代码进行编辑非常有用。它们提供诸如自动校正、语法高亮、括号补全和缩进等功能。下面是 Visual Studio 代码编辑器的示例快照:

工作原理…

编译器是一个将您的 C++代码转换为目标代码的计算机程序。这是为了创建可执行文件所必需的。如果您有一个名为main.cpp的文件,它将生成一个名为main.o的目标代码。

链接器是一个将编译器生成的目标代码转换为可执行文件或库文件的计算机程序:

工作原理...

编译器和链接器

调试器是一个帮助测试和调试计算机程序的计算机程序。

GUI 构建器帮助设计师和程序员轻松创建 GUI 内容或小部件。它使用拖放所见即所得工具编辑器。

选择正确的源代码控制工具

在这个步骤中,我们将看到使用正确的版本控制来备份我们的代码是多么容易。将备份到中央服务器的优势是您永远不会丢失工作,可以在任何计算机上下载代码,还可以回到过去的任何更改。想象一下,就像我们在游戏中有一个检查点,如果遇到问题,可以回到那个检查点。

准备工作

要完成这个步骤,您需要一台运行 Windows 的计算机。不需要其他先决条件。

如何做…

选择正确的版本控制工具非常重要,因为它将节省大量时间来组织数据。有几种版本控制工具可用,因此非常重要的是我们应该了解所有这些工具,这样我们就可以根据自己的需求选择正确的工具。

首先分析一下你可以选择的选项。选择主要包括Concurrent Versions SystemCVS),Apache SubversionSVN),MercurialGIT

工作原理...

CVS 已经存在很长时间了,因此有大量的文档和帮助可用。然而,缺乏原子操作经常导致源代码损坏,不太适合长期分支操作。

SVN 是作为对 CVS 的改进而制作的,它解决了许多与原子操作和源代码损坏有关的问题。它是免费和开源的。它有许多不同 IDE 的插件。然而,这个工具的一个主要缺点是它在操作中相对非常慢。

GIT 主要是为 Linux 开发的,但它大大提高了操作速度。它也适用于 UNIX 系统。它具有廉价的分支操作,但与 Linux 相比,它对单个开发人员的支持有限。然而,GIT 非常受欢迎,许多人更喜欢 GIT 而不是 SVN 或 CVS。

Mercurial 在 GIT 之后不久出现。它具有基于节点的操作,但不允许合并两个父分支。

因此,总之,如果您想要一个其他人可以推送和拉取的中央存储库,请使用 SVN。尽管它有局限性,但很容易学习。如果您想要一个分布式模型,请使用 Mercurial 或 GIT。在这种情况下,每台计算机上都有一个存储库,并且通常有一个被视为官方的存储库。如果团队规模相对较小,通常更喜欢 Mercurial,并且比 GIT 更容易学习。

我们将在另一章节中更详细地研究这些内容。

提示

有关下载代码包的详细步骤在本书的前言中有提及。请查看。

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/C++Game-Development-Cookbook。我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

使用调用堆栈进行内存存储

C++仍然是大多数游戏开发者首选的语言的主要原因是你可以自己处理内存并且在很大程度上控制内存的分配和释放。因此,我们需要了解为我们提供的不同内存空间。当数据被“推”到堆栈上时,堆栈增长。当数据被“弹”出堆栈时,堆栈缩小。不可能在不先弹出放在其上面的所有数据的情况下弹出堆栈上的特定数据。把这想象成一系列从上到下排列的隔间。堆栈的顶部是堆栈指针指向的任何隔间(这是一个寄存器)。

每个隔间都有一个顺序地址。其中一个地址被保存在堆栈指针中。在那个神奇的地址下面的所有东西,被称为堆栈的顶部,被认为是在堆栈上。在堆栈顶部以上的所有东西被认为是堆栈之外的。当数据被推送到堆栈上时,它被放入堆栈指针上面的一个隔间中,然后堆栈指针被移动到新的数据上。当数据从堆栈上弹出时,堆栈指针的地址通过向下移动来改变。

准备工作

你需要在你的 Windows 机器上安装一个可用的 Visual Studio 副本。

如何做…

C++可能是目前最好的编程语言之一,而其中一个主要原因是它也是一种低级语言,因为我们可以操纵内存。要理解内存处理,了解内存堆栈的工作方式非常重要:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为main.cpp的源文件,或者任何你想要命名的源文件。

  5. 添加以下代码行:

#include <iostream>
#include <conio.h>

using namespace std;

int countTotalBullets(int iGun1Ammo, int iGun2Ammo)
{
    return iGun1Ammo + iGun2Ammo;
}

int main()
{
    int iGun1Ammo = 3;
    int iGun2Ammo = 2;
    int iTotalAmmo = CountTotalBullets(iGun1Ammo, iGun2Ammo);

    cout << "Total ammunition currently with you is"<<iTotalAmmo;

    _getch();
}

它是如何工作的…

当你调用函数CountTotalBullets时,代码会分支到被调用的函数。参数被传递进来,函数体被执行。当函数完成时,一个值被返回,控制返回到调用函数。

但从编译器的角度来看,它是如何真正工作的呢?当你开始你的程序时,编译器创建一个堆栈。堆栈是为了在你的程序中保存每个函数的数据而分配的内存的一个特殊区域。堆栈是一个后进先出LIFO)的数据结构。想象一副牌;放在牌堆上的最后一张牌将是最先拿出的。

当你的程序调用CountTotalBullets时,一个堆栈帧被建立。堆栈帧是堆栈中专门留出来管理该函数的区域。这在不同的平台上非常复杂和不同,但这些是基本步骤:

  1. CountTotalBullets的返回地址被放在堆栈上。当函数返回时,它将在这个地址继续执行。

  2. 为你声明的返回类型在堆栈上留出空间。

  3. 所有函数参数都被放在堆栈上。

  4. 程序分支到你的函数。

  5. 局部变量在定义时被推送到堆栈上。

谨慎使用递归

递归是一种编程设计形式,函数多次调用自身以通过将大型解决方案集拆分为多个小解决方案集来解决问题。代码大小肯定会缩短。然而,如果不正确使用,递归可能会非常快地填满调用堆栈,导致内存耗尽。

准备工作

要开始使用这个方法,你应该对调用堆栈和函数调用期间内存分配有一些先验知识。你需要一台装有 Visual Studio 的 Windows 机器。

如何做…

在这个方法中,你将看到使用递归是多么容易。递归编程非常聪明,但也可能导致一些严重的问题:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为main.cpp的源文件,或者任何你想要命名的源文件。

  5. 添加以下代码行:

#include <iostream>
#include <conio.h>

using namespace std;
int RecursiveFactorial(int number);
int Factorial(int number);
int main()
{
    long iNumber;
    cout << "Enter the number whose factorial you want to find";
    cin >> iNumber;

    cout << RecursiveFactorial(iNumber) << endl;
    cout << Factorial(iNumber);

    _getch();
    return 0;
}

int Factorial(int number)
{
    int iCounter = 1;
    if (number < 2)
    {
        return 1;
    }
    else
    {
        while (number>0)
        {
            iCounter = iCounter*number;
            number -= 1;
        }

    }
    return iCounter;
}

int RecursiveFactorial(int number)
{
    if (number < 2)
    {
        return 1;
    }
    else
    {
        while (number>0)
    {
            return number*Factorial(number - 1);
        }
    }

}

工作原理...

从前面的代码中可以看出,这两个函数都可以找到一个数字的阶乘。然而,使用递归时,每次函数调用时堆栈大小都会急剧增长;堆栈指针必须在每次调用时更新,并且数据被推送到堆栈上。使用递归时,由于函数调用自身,每次从内部调用函数时,堆栈大小都会不断增加,直到内存耗尽并创建死锁或崩溃。

想象一下找到 1000 的阶乘。该函数将在自身内部被调用很多次。这是一种导致灾难的方法,我们应该尽量避免这种编码实践。

还有更多...

如果要找到大于 15 的数字的阶乘,可以使用比 int 更大的数据类型,因为得到的阶乘将太大而无法存储在 int 中。

使用指针存储内存地址

在前两个示例中,我们已经看到内存不足可能会成为我们的问题。然而,直到现在,我们对分配多少内存以及分配给每个内存地址的内容没有任何控制。使用指针,我们可以解决这个问题。在我看来,指针是 C++中最重要的主题。如果你对 C++的概念必须清晰,并且如果你要成为一个优秀的 C++开发人员,你必须擅长使用指针。指针一开始可能看起来很可怕,但一旦你掌握了它,指针就很容易使用。

准备就绪

对于这个示例,你需要一台装有 Visual Studio 的 Windows 机器。

如何做...

在这个示例中,我们将看到使用指针有多么容易。一旦你熟悉使用指针,我们就可以很容易地操纵内存并在内存中存储引用:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为main.cpp的源文件,或者任何你想要命名源文件。

  5. 添加以下代码行:

#include <iostream>
#include <conio.h>

using namespace std;

int main()
{
    float fCurrentHealth = 10.0f;

    cout << "Address where the float value is stored: " << &fCurrentHealth << endl;
    cout << "Value at that address: " << *(&fCurrentHealth) << endl;

    float* pfLocalCurrentHealth = &fCurrentHealth;
    cout << "Value at Local pointer variable: "<<pfLocalCurrentHealth << endl;
    cout << "Address of the Local pointer variable: "<<&pfLocalCurrentHealth << endl;
    cout << "Value at the address of the Local pointer variable: "<<*pfLocalCurrentHealth << endl;

    _getch();
    return 0;
}

工作原理...

C++程序员最强大的工具之一是直接操作计算机内存。指针是一个保存内存地址的变量。C++程序中使用的每个变量和对象都存储在内存的特定位置。每个内存位置都有一个唯一的地址。内存地址将根据所使用的操作系统而变化。所占用的字节数取决于变量类型:float = 4 字节short = 2 字节

工作原理...

指针和内存存储

内存中的每个位置都是 1 字节。指针pfLocalCurrentHealth保存了存储fCurrentHealth的内存位置的地址。因此,当我们显示指针的内容时,我们得到的是与包含fCurrentHealth变量的地址相同的地址。我们使用&运算符来获取pfLocalCurrentHealth变量的地址。当我们使用*运算符引用指针时,我们得到存储在该地址的值。由于存储的地址与存储fCurrentHealth的地址相同,我们得到值10

还有更多...

让我们考虑以下声明:

  • const float* pfNumber1

  • float* const pfNumber2

  • const float* const pfNumber3

所有这些声明都是有效的。但是它们的含义是什么?第一个声明说明pfNumber1是一个指向常量浮点数的指针。第二个声明说明pfNumber2是一个指向浮点数的常量指针。第三个声明说明pfNumber3是一个指向常量整数的常量指针。引用和这三种 const 指针之间的关键区别如下:

  • const指针可以是 NULL

  • 引用没有自己的地址,而指针有

引用的地址是实际对象的地址

  • 指针有自己的地址,并且它的值是它指向的值的地址

注意

有关指针和引用的更多信息,请访问以下链接:

stackoverflow.com/questions/57483/what-are-the-differences-between-a-pointer-variable-and-a-reference-variable-in/57492#57492

在不同数据类型之间进行转换

转换是一种将一些数据转换为不同类型数据的转换过程。我们可以在内置类型或我们自己的数据类型之间进行转换。一些转换是由编译器自动完成的,程序员不必干预。这种转换称为隐式转换。其他转换必须由程序员直接指定,称为显式转换。有时我们可能会收到关于数据丢失的警告。我们应该注意这些警告,并考虑这可能会对我们的代码产生不利影响。当接口期望特定类型的数据,但我们想要提供不同类型的数据时,通常会使用转换。在 C 中,我们可以将任何东西转换为任何东西。然而,C++为我们提供了更精细的控制。

准备工作

对于这个教程,你需要一台装有 Visual Studio 的 Windows 机器。

如何做…

在这个教程中,我们将看到如何在各种数据类型之间轻松转换或转换。通常,程序员即使在 C++中也使用 C 风格的转换,但这是不推荐的。C++为不同情况提供了自己的转换风格,我们应该使用它:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为main.cpp的源文件,或者任何你想要命名的源文件。

  5. 添加以下代码行:

#include <iostream>
#include <conio.h>

using namespace std;

int main()
{
    int iNumber = 5;
    int iOurNumber;
    float fNumber;

    //No casting. C++ implicitly converts the result into an int and saves 
    //into a float
    fNumber = iNumber/2;
    cout << "Number is " << fNumber<<endl;

    //C-style casting. Not recommended as this is not type safe
    fNumber = (float)iNumber / 2;
    cout << "Number is " << fNumber<<endl;

    //C++ style casting. This has valid constructors to make the casting a safe one
    iOurNumber = static_cast<int>(fNumber);
    cout << "Number is " << iOurNumber << endl;

    _getch();
    return 0;
}

它是如何工作的…

在 C++中有四种类型的转换操作符,取决于我们要转换的内容:static_castconst_castreinterpret_castdynamic_cast。现在,我们将看看static_cast。在讨论动态内存和类之后,我们将看看剩下的三种转换技术。从较小的数据类型转换为较大的类型称为提升,保证不会丢失数据。然而,从较大的数据类型转换为较小的数据类型称为降级,可能会导致数据丢失。当发生这种情况时,编译器通常会给出警告,你应该注意这一点。

让我们看看之前的例子。我们已经用值5初始化了一个整数。接下来,我们初始化了一个浮点变量,并存储了5除以2的结果,即2.5。然而,当我们显示变量fNumber时,我们看到显示的值是2。原因是 C++编译器隐式地将5/2的结果转换为整数并存储它。因此,它类似于计算 int(5/2),即 int(2.5),计算结果为2。因此,为了实现我们想要的结果,我们有两个选项。第一种方法是 C 风格的显式转换,这是不推荐的,因为它没有类型安全检查。C 风格转换的格式是(resultant_data_type) (expression),在这种情况下类似于 float (5/2)。我们明确告诉编译器将表达式的结果存储为浮点数。第二种方法,更符合 C++风格的转换方法,是使用static_cast操作。这种方法有适当的构造函数来指示转换是类型安全的。static_cast操作的格式是static_cast<resultant_data_type> (expression)。编译器会检查转换是否安全,然后执行类型转换操作。

更有效地管理内存,使用动态分配

程序员通常处理内存的五个领域:全局命名空间,寄存器,代码空间,堆栈和自由存储区。当数组被初始化时,必须定义元素的数量。这导致了许多内存问题。大多数情况下,我们分配的元素并没有全部被使用,有时我们需要更多的元素。为了帮助解决这个问题,C++通过使用自由存储区在.exe文件运行时进行内存分配。

自由存储区是一个可以用来存储数据的大内存区域,有时被称为。我们可以请求一些自由存储区的空间,它会给我们一个地址,我们可以用来存储数据。我们需要将该地址保存在一个指针中。自由存储区直到程序结束才会被清理。程序员有责任释放程序使用的任何自由存储区内存。

自由存储区的优势在于不需要预先分配所有变量。我们可以在运行时决定何时需要更多内存。内存被保留并保持可用,直到显式释放为止。如果在函数中保留内存,当控制从该函数返回时,仍然可用。这比全局变量编码要好得多。只有可以访问指针的函数才能访问存储在内存中的数据,并且它为该数据提供了一个严格控制的接口。

准备工作

对于这个配方,你需要一台装有 Visual Studio 的 Windows 机器。

如何做...

在这个配方中,我们将看到动态分配是多么容易。在游戏中,大部分内存都是在运行时动态分配的,因为我们从来不确定应该分配多少内存。分配任意数量的内存可能导致内存不足或内存浪费:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 添加一个名为main.cpp的源文件,或者任何你想要命名的源文件。

  4. 添加以下代码行:

#include <iostream>
#include <conio.h>
#include <string>

using namespace std;

int main()
{

  int iNumberofGuns, iCounter;
  string * sNameOfGuns;
  cout << "How many guns would you like to purchase? ";
  cin >> iNumberofGuns;
  sNameOfGuns = new string[iNumberofGuns];
  if (sNameOfGuns == nullptr)
    cout << "Error: memory could not be allocated";
  else
  {
    for (iCounter = 0; iCounter<iNumberofGuns; iCounter++)
    {
      cout << "Enter name of the gun: ";
      cin >> sNameOfGuns[iCounter];
    }
    cout << "You have purchased: ";
    for (iCounter = 0; iCounter<iNumberofGuns; iCounter++)
      cout << sNameOfGuns[iCounter] << ", ";
    delete[] sNameOfGuns;
  }

  _getch();
  return 0;
}

它是如何工作的...

您可以使用new关键字将内存分配给自由存储区;new后面跟着您想要分配的变量的类型。这允许编译器知道需要分配多少内存。在我们的示例中,我们使用了 string。new关键字返回一个内存地址。这个内存地址被分配给一个指针sNameOfGuns。我们必须将地址分配给一个指针,否则地址将丢失。使用new运算符的格式是datatype * pointer = new datatype。所以在我们的示例中,我们使用了sNameOfGuns = new string[iNumberofGuns]。如果新的分配失败,它将返回一个空指针。我们应该始终检查指针分配是否成功;否则我们将尝试访问未分配的内存的一部分,并且可能会收到编译器的错误,如下面的屏幕截图所示,您的应用程序将崩溃:

它是如何工作的...

当你完成内存的使用后,必须在指针上调用 delete。Delete 将内存返回给自由存储区。请记住,指针是一个局部变量。指针声明所在的函数作用域结束时,自由存储区上的内存不会自动释放。静态内存和动态内存的主要区别在于,静态内存的创建/删除是自动处理的,而动态内存必须由程序员创建和销毁。

delete[]运算符向编译器发出需要释放数组的信号。如果你不加括号,只有数组中的第一个元素会被删除。这将导致内存泄漏。内存泄漏真的很糟糕,因为这意味着有未被释放的内存空间。请记住,内存是有限的空间,所以最终你会遇到麻烦。

当我们使用delete[]时,编译器如何知道它必须从内存中释放n个字符串?运行时系统将项目数存储在某个位置,只有当你知道指针sNameOfGuns时才能检索到。有两种流行的技术可以做到这一点。这两种技术都被商业编译器使用,都有权衡,都不是完美的:

  • 技术 1:

过度分配数组,并将项目数放在第一个元素的左侧。这是两种技术中较快的一种,但对于程序员错误地使用delete sNameOfGuns而不是delete[] sNameOfGuns更敏感。

  • 技术 2:

使用关联数组,以指针作为键,项目数作为值。这是两种技术中较慢的一种,但对于程序员错误地使用delete sNameOfGuns而不是delete[] sNameOfGuns不太敏感。

更多内容...

我们还可以使用一个名为VLD的工具来检查内存泄漏。

注意

vld.codeplex.com/下载 VLD。

设置完成后,安装 VLD 到你的系统上。这可能会或可能不会正确设置 VC++目录。如果没有,可以通过右键单击项目页面并将 VLD 目录添加到名为包含目录的字段中手动设置,如下图所示:

更多内容...

设置目录后,在源文件中添加头文件<vld.h>。执行应用程序并退出后,输出窗口将显示应用程序中是否存在任何内存泄漏。

理解错误消息

在调试构建时,你可能会在调试期间在内存中看到以下值:

  • 0xCCCCCCCC:这指的是在堆栈上分配的值,但尚未初始化。

  • 0xCDCDCDCD:这意味着内存已经在堆中分配,但尚未初始化(干净内存)。

  • 0xDDDDDDDD:这意味着内存已经从堆中释放(死内存)。

  • 0xFEEEFEEE:这指的是值被从自由存储中释放。

  • 0xFDFDFDFD:"无人之地"栅栏,它们被放置在调试模式下堆内存的边界上。它们不应该被覆盖,如果被覆盖了,这可能意味着程序正在尝试访问数组最大大小之外的索引处的内存。

使用位操作进行高级检查和优化

在大多数情况下,程序员不需要过多地担心位,除非有必要编写一些压缩算法,当我们制作游戏时,我们永远不知道是否会出现这样的情况。为了以这种方式压缩和解压文件,你需要实际上在位级别提取数据。最后,你可以使用位操作来加速你的程序或执行巧妙的技巧。但这并不总是推荐的。

准备就绪

对于这个示例,你需要一台装有 Visual Studio 的 Windows 机器。

如何做...

在这个示例中,我们将看到使用位操作通过操作内存执行操作是多么容易。位操作也是通过直接与内存交互来优化代码的好方法:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 添加一个名为main.cpp的源文件,或者任何你想要命名的源文件。

  4. 添加以下代码行:

#include <iostream>
#include <conio.h>

using namespace std;

void Multi_By_Power_2(int iNumber, int iPower);
void BitwiseAnd(int iNumber, int iNumber2);
void BitwiseOr(int iNumber, int iNumber2);
void Complement(int iNumber4);
void BitwiseXOR(int iNumber,int iNumber2);

int main()
{
  int iNumber = 4, iNumber2 = 3;
  int iPower = 2;
  unsigned int iNumber4 = 8;

  Multi_By_Power_2(iNumber, iPower);
  BitwiseAnd(iNumber,iNumber2);
  BitwiseOr(iNumber, iNumber2);
  BitwiseXOR(iNumber,iNumber2);
  Complement(iNumber4);

  _getch();
  return 0;
}

void Multi_By_Power_2(int iNumber, int iPower)
{
  cout << "Result is :" << (iNumber << iPower)<<endl;
}
void BitwiseAnd(int iNumber, int iNumber2)
{
  cout << "Result is :" << (iNumber & iNumber2) << endl;
}
void BitwiseOr(int iNumber, int iNumber2)
{
  cout << "Result is :" << (iNumber | iNumber2) << endl;
}
void Complement(int iNumber4)
{
  cout << "Result is :" << ~iNumber4 << endl;
}
void BitwiseXOR(int iNumber,int iNumber2)
{
  cout << "Result is :" << (iNumber^iNumber2) << endl;
}

工作原理...

左移操作符相当于将数字的所有位向左移动指定的位数。在我们的例子中,我们发送给函数Multi_By_Power_2的数字是43。数字4的二进制表示是100,所以如果我们将最高有效位(1)向左移动三位,我们得到10000,这是16的二进制。因此,左移等同于整数除以2^shift_arg,即4*2³,这又是16。类似地,右移操作等同于整数除以2^shift_arg

现在让我们考虑我们想要打包数据,以便压缩数据。考虑以下示例:

int totalammo,type,rounds;

我们正在存储枪支的总子弹数;枪支的类型,但只能是步枪或手枪;以及它可以发射的每轮总子弹数。目前我们使用三个整数值来存储数据。然而,我们可以将所有前述数据压缩成一个单一整数,从而压缩数据:

int packaged_data;
packaged_data = (totalammo << 8) | (type << 7) | rounds;

如果我们假设以下符号:

  • 总弹药数:A

  • 类型:T

  • 轮数:R

数据的最终表示将类似于这样:

AAAAAAATRRRRRRR

第二章:游戏中的面向对象方法和设计

在本章中,我们将介绍以下教程:

  • 使用类进行数据封装和抽象

  • 使用多态性来重用代码

  • 使用复制构造函数

  • 使用运算符重载来重用运算符

  • 使用函数重载来重用函数

  • 使用文件进行输入和输出

  • 创建您的第一个简单的基于文本的游戏

  • 模板 - 何时使用它们

介绍

以下图表显示了OOP面向对象编程)的主要概念。让我们假设我们需要制作一款赛车游戏。因此,汽车由发动机、车轮、底盘等组成。所有这些部分都可以被视为单独的组件,也可以用于其他汽车。同样,每辆汽车的发动机都可以是不同的,因此我们可以为每个单独的组件添加不同的功能、状态和属性。

所有这些都可以通过面向对象编程实现:

介绍

我们需要在任何包含状态和行为的设计中使用面向对象的系统。让我们考虑一个像Space Invaders的游戏。游戏由两个主要角色组成,玩家飞船和敌人。还有一个 boss,但那只是敌人的高级版本。玩家飞船可以有不同的状态,如存活、空闲、移动、攻击和死亡。它还有一些行为,比如左/右移动,单发/连发/导弹。同样,敌人也有状态和行为。这是使用面向对象设计的理想条件。boss 只是敌人的高级形式,因此我们可以使用多态性和继承的概念来实现结果。

使用类进行数据封装和抽象

类用于将信息组织成有意义的状态和行为。在游戏中,我们处理许多不同类型的武器、玩家、敌人和地形,每种都有自己的状态和行为类型,因此必须使用具有类的面向对象设计。

准备就绪

要完成本教程,您需要一台运行 Windows 的计算机。您需要在 Windows 计算机上安装 Visual Studio 的工作副本。不需要其他先决条件。

如何做…

在本教程中,我们将看到使用 C++中的面向对象编程轻松创建游戏框架有多容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cppCEnemy.hCEnemy.cpp的源文件。

  5. 将以下代码添加到Souce.cpp

#include "CEnemy.h"
#include <iostream>
#include <string>
#include <conio.h>
#include "vld.h"

using namespace std;

int main()
{
  CEnemy* pEnemy = new CEnemy(10,100,"DrEvil","GOLD");

  int iAge;
  int iHealth;
  string sName;
  string sArmour;

  iAge = pEnemy->GetAge();
  iHealth = pEnemy->TotalHealth();
  sArmour = pEnemy->GetArmourName();
  sName = pEnemy->GetName();

  cout << "Name of the enemy is :" << sName << endl;
  cout << "Name of " << sName << "'s armour is :" << sArmour << endl;
  cout << "Health of " << sName << " is :" << iHealth << endl;
  cout << sName << "'s age is :" << iAge;

delete pEnemy;
  _getch();
}
  1. 将以下代码添加到CEnemy.h
#ifndef _CENEMY_H
#define _CENEMY_H

#include <string>
using namespace std;

class CEnemy
{
public:
  string GetName()const;
  int GetAge()const;
  string GetArmourName()const;
  int TotalHealth()const;

  //ctors
  CEnemy(int,int,string,string);
//dtors
  ~CEnemy();
private:
  int m_iAge;
  int m_iHealth;
  string m_sName;
  string m_sArmour;
};

#endif
  1. 将以下代码添加到CEnemy.cpp
#include <iostream>
#include <string>
#include "CEnemy.h"

using namespace std;

CEnemy::CEnemy(int Age,int Health,int Armour,int Name)
{
  m_iAge = Age;
  m_iHealth = Health;
  m_sArmour = Armour;
  m_sName = Name;
}

int CEnemy::GetAge()const
{
  return m_iAge;
}

int CEnemy::TotalHealth()const
{
  return m_iHealth;
}

string CEnemy::GetArmourName()const
{
  return m_sArmour;
}

string CEnemy::GetName()const
{
  return m_sName;
}

它是如何工作的…

创建一个面向对象的程序,我们需要创建类和对象。虽然我们可以在同一个文件中编写类的定义和声明,但建议将定义和声明分开为两个单独的文件。声明类文件称为头文件,而定义类文件称为源文件。

CEnemy头文件中,我们定义了我们需要的成员变量和函数。在一个类中,我们可以选择将变量分为公共、受保护或私有。公共状态表示它们可以从类外部访问,受保护状态表示只有从当前基类继承的子类可以访问它,而私有状态表示它们可以被类的任何实例访问。在 C++类中,默认情况下,一切都是私有的。因此,我们将所有成员函数都创建为公共的,以便我们可以从驱动程序中访问它们,例如本例中的Source.cpp。头文件中的成员变量都是私有的,因为它们不应该直接从类外部访问。这就是我们所说的抽象。我们为名称和护甲定义了一个字符串类型的变量,为健康和年龄定义了一个整数类型的变量。即使我们目前没有为它们创建任何功能,也建议创建构造函数和析构函数。最好还使用一个复制构造函数。稍后在本章中会解释这个原因。

CEnemy源文件中,我们对成员变量进行了初始化,并声明了函数。我们在每个函数的末尾使用了const关键字,因为我们不希望函数改变成员变量的内容。我们只希望它们返回已经分配的值。作为一个经验法则,除非有必要不使用它,我们应该总是使用它。这使得代码更安全、有组织和可读。我们在构造函数中初始化了变量;我们也可以创建参数化构造函数,并从驱动程序中分配值给它们。或者,我们也可以创建设置函数来分配值。

从驱动程序中,我们创建一个CEnemy类型的指针对象。当对象被初始化时,它调用适当的构造函数并将值分配给它们。然后我们通过使用->运算符对指针进行解引用来调用函数。因此,当我们调用p->函数时,它与(*p).function 相同。由于我们是动态分配内存,我们还应该删除对象,否则会出现内存泄漏。我们已经使用vld来检查内存泄漏。这个程序没有任何内存泄漏,因为我们使用了delete关键字。只需注释掉delete pEnemy;这一行,你会注意到程序在退出时有一些内存泄漏。

使用多态来重用代码

多态意味着具有多种形式。通常,当类的层次结构存在某种关联时,我们使用多态。我们通常通过使用继承来实现这种关联。

准备工作

你需要在 Windows 机器上安装 Visual Studio 的工作副本。

如何做…

在这个示例中,我们将看到如何使用相同的函数并根据需要覆盖它们的不同功能。此外,我们还将看到如何在基类和派生类之间共享值:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为 Source.cpp 的源文件和三个名为Enemy.hDragon.hSoldier.h的头文件。

  5. 将以下代码行添加到Enemy.h中:

#ifndef _ENEMY_H
#define _ENEMY_H

#include <iostream>

using namespace std;

class CEnemy {
protected:
  int m_ihealth,m_iarmourValue;
public:
  CEnemy(int ihealth, int iarmourValue) : m_ihealth(ihealth), m_iarmourValue(iarmourValue) {}
  virtual int TotalHP(void) = 0;
  void PrintHealth()
  {
    cout << "Total health is " << this->TotalHP() << '\n';
  }
};

   #endif
  1. 将以下代码行添加到Dragon.h中:
#ifndef _DRAGON_H
#define _DRAGON_H

#include "Enemy.h"
#include <iostream>

using namespace std;

class CDragon : public CEnemy {
public:
  CDragon(int m_ihealth, int m_iarmourValue) : CEnemy(m_ihealth, m_iarmourValue)
  {
  }
  int TotalHP()
  {
    cout << "Dragon's ";
    return m_ihealth*2+3*m_iarmourValue;
  }
};

  #endif
  1. 将以下代码行添加到Soldier.h中:
#ifndef _SOLDIER_H
#define _SOLDIER_H

#include "Enemy.h"
#include <iostream>

using namespace std;

class CSoldier : public CEnemy {
public:
  CSoldier(int m_ihealth, int m_iarmourValue) : CEnemy(m_ihealth, m_iarmourValue) {}
  int TotalHP()
  {
    cout << "Soldier's ";
    return m_ihealth+m_iarmourValue;
  }
};

#endif
  1. 将以下代码行添加到Source.cpp中:
// dynamic allocation and polymorphism
#include <iostream>
#include <conio.h>
#include "vld.h"
#include "Enemy.h"
#include "Dragon.h"
#include "Soldier.h"

int main()
 {
  CEnemy* penemy1 = new CDragon(100, 50);
  CEnemy* penemy2 = new CSoldier(100, 100);

  penemy1->PrintHealth();
  penemy2->PrintHealth();

  delete penemy1;
  delete penemy2;

  _getch();
  return 0;

}

它是如何工作的…

多态是具有不同形式的能力。因此,在这个例子中,我们有一个Enemy接口,它没有任何用于计算总体健康的功能。然而,我们知道所有类型的敌人都应该有一个计算总体健康的功能。因此,我们通过将基类中的函数设置为纯虚函数(通过将其分配为0)来实现这个功能。

这使得所有子类都必须有自己的实现来计算总健康值。因此,CSoldier类和CDragon类都有自己的TotalHP实现。这种结构的优势在于,我们可以从基类创建子类的指针对象,并且在解析时,它调用子类的正确函数。

如果我们不创建虚函数,那么子类中的函数将隐藏基类的函数。然而,使用纯虚函数,这是不正确的,因为这将创建一个编译器错误。编译器在运行时解析函数的方式是通过一种称为动态分派的技术。大多数语言使用动态分派。C++使用单一转发动态分派。它借助虚拟表来实现。当CEnemy类定义虚函数TotalHP时,编译器向类添加一个隐藏的成员变量,该成员变量指向一个名为虚方法表(VMT)或 Vtable 的函数指针数组。在运行时,这些指针将被设置为指向正确的函数,因为在编译时还不知道是调用基函数还是由CDragonCSoldier实现的派生函数。

基类中的成员变量是受保护的。这意味着派生类也可以访问成员变量。从驱动程序中,因为我们动态分配了内存,我们也应该删除,否则我们将会有内存泄漏。当析构函数标记为虚函数时,我们确保调用正确的析构函数。

使用复制构造函数

复制构造函数用于将一个对象复制到另一个对象。C++为我们提供了一个默认的复制构造函数,但不建议使用。我们应该为更好的编码和组织实践编写自己的复制构造函数。它还可以最小化使用 C++提供的默认复制构造函数可能引起的崩溃和错误。

准备工作

您需要在 Windows 机器上安装 Visual Studio 的工作副本。

如何做...

在这个示例中,我们将看到编写复制构造函数有多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cppTerrain.h的源文件。

  5. Terrain.h中添加以下代码行:

#pragma once
#include <iostream>

using namespace std;
class CTerrain
{
public:
  CTerrainCTerrain();
  ~CTerrain();

  CTerrain(const CTerrain &T)
  {
    cout << "\n Copy Constructor";
  }
  CTerrain& operator =(const CTerrain &T)
  {
    cout << "\n Assignment Operator";
    return *this;
  }
};
  1. Source.cpp中添加以下代码行:
#include <conio.h>
#include "Terrain.h"

using namespace std;

int main()
{
  CTerrain Terrain1,Terrain2;

  Terrain1 = Terrain2;

  CTerrain Terrain3 = Terrain1;

  _getch();
  return 0;
}

它是如何工作的...

在这个例子中,我们创建了自己的复制构造函数和赋值运算符。当我们给已经初始化的两个对象赋值时,赋值运算符被调用。当我们初始化一个对象并将其设置为另一个对象时,复制构造函数被调用。如果我们不创建自己的复制构造函数,新创建的对象只是持有被赋值对象的浅层引用。如果对象被销毁,那么浅层对象也会丢失,因为内存也会丢失。如果我们创建自己的复制构造函数,就会创建一个深层复制,即使第一个对象被删除,第二个对象仍然在不同的内存位置中保存信息。

它是如何工作的...

因此,浅层复制(或成员逐一复制)将一个对象的成员变量的确切值复制到另一个对象中。两个对象中的指针最终指向相同的内存。深层复制将在自由存储器上分配的值复制到新分配的内存中。因此,在浅层删除中,浅层复制中的对象是灾难性的:

它是如何工作的...

然而,深层复制为我们解决了这个问题:

它是如何工作的...

使用运算符重载来重用运算符

C++为我们提供了许多运算符。但是,有时我们需要重载这些运算符,以便我们可以在自己创建的数据结构上使用它们。当然,我们也可以重载运算符以改变其含义。例如,我们可以将+(加号)改为行为像-(减号),但这并不推荐,因为这通常没有任何意义或帮助我们。此外,这可能会让使用相同代码库的其他程序员感到困惑。

准备工作

您需要在 Windows 机器上安装 Visual Studio 的工作副本。

如何做…

在这个示例中,我们将看到如何重载运算符以及在 C++中允许重载哪些运算符。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cppvector3.hvector3.cpp的源文件。

  5. 将以下代码添加到Source.cpp

#include "vector3.h"
#include <conio.h>
#include "vld.h"

int main()
{
  // Vector tests:

  // Create two vectors.
  CVector3 a(1.0f, 2.0f, 3.0f);
  CVector3 b(1.0f, 2.0f, 3.0f);

  CVector3 c;

  // Zero Vector.
  c.Zero();

  // Addition.
  CVector3 d = a + b;

  // Subtraction.
  CVector3 e = a - b;

  //Scalar Multiplication.
  CVector3 f1 = a * 10;

  //Scalar Multiplication.
  CVector3 f2 = 10 * a;

  //Scalar Division.
  CVector3 g = a / 10;

  // Unary minus.
  CVector3 h = -a;

  // Relational Operators.
  bool bAEqualsB = (a == b);
  bool bANotEqualsB = (a != b);

  // Combined operations +=.
  c = a;
  c += a;

  // Combined operations -=.
  c = a;
  c -= a;

  // Combined operations /=.
  c = a;
  c /= 10;

  // Combined operations *=.
  c = a;
  c *= 10;

  // Normalization.
  c.Normalize();

  // Dot Product.
  float fADotB = a * b;

  // Magnitude.
  float fMag1 = CVector3::Magnitude(a);
  float fMag2 = CVector3::Magnitude(c);

  // Cross product.
  CVector3 crossProduct = CVector3::CrossProduct(a, c);

  // Distance.
  float distance = CVector3::Distance(a, c);

  _getch();
  return (0);

}
  1. 将以下代码添加到vector3.h
#ifndef __VECTOR3_H__
#define __VECTOR3_H__

#include <cmath>

class CVector3
{
public:
  // Public representation: Not many options here.
  float x;
  float y;
  float z;

  CVector3();
  CVector3(const CVector3& _kr);
  CVector3(float _fx, float _fy, float _fz);

  // Assignment operator.
  CVector3& operator =(const CVector3& _kr);

  // Relational operators.
  bool operator ==(const CVector3& _kr) const;
  bool operator !=(const CVector3& _kr) const;

  // Vector operations
  void Zero();

  CVector3 operator -() const;
  CVector3 operator +(const CVector3& _kr) const;
  CVector3 operator -(const CVector3& _kr) const;

  // Multiplication and division by scalar.
  CVector3 operator *(float _f) const;
  CVector3 operator /(float _f) const;

  // Combined assignment operators to conform to C notation convention.
  CVector3& operator +=(const CVector3& _kr);
  CVector3& operator -=(const CVector3& _kr);
  CVector3& operator *=(float _f);
  CVector3& operator /=(float _f);

  // Normalize the vector
  void Normalize();
  // Vector dot product.
  // We overload the standard multiplication symbol to do this.
  float operator *(const CVector3& _kr) const;

  // Static member functions.

  // Compute the magnitude of a vector.
  static inline float Magnitude(const CVector3& _kr)
  {
    return (sqrt(_kr.x * _kr.x + _kr.y * _kr.y + _kr.z * _kr.z));
  }

  // Compute the cross product of two vectors.
  static inline CVector3 CrossProduct(const CVector3& _krA,
    const CVector3& _krB)
  {
    return
      (
      CVector3(_krA.y * _krB.z - _krA.z * _krB.y,
      _krA.z * _krB.x - _krA.x * _krB.z,
      _krA.x * _krB.y - _krA.y * _krB.x)
      );
  }

  // Compute the distance between two points.
  static inline float Distance(const CVector3& _krA, const CVector3& _krB)
  {
    float fdx = _krA.x - _krB.x;
    float fdy = _krA.y - _krB.y;
    float fdz = _krA.z - _krB.z;

    return sqrt(fdx * fdx + fdy * fdy + fdz * fdz);
  }
};

// Scalar on the left multiplication, for symmetry.
inline CVector3 operator *(float _f, const CVector3& _kr)
{
  return (CVector3(_f * _kr.x, _f * _kr.y, _f * _kr.z));
}

#endif // __VECTOR3_H__
  1. 将以下代码添加到vector3.cpp
#include "vector3.h"

// Default constructor leaves vector in an indeterminate state.
CVector3::CVector3()
{

}

// Copy constructor.
CVector3::CVector3(const CVector3& _kr)
: x(_kr.x)
, y(_kr.y)
, z(_kr.z)
{

}

// Construct given three values.
CVector3::CVector3(float _fx, float _fy, float _fz)
: x(_fx)
, y(_fy)
, z(_fz)
{

}

// Assignment operator, we adhere to C convention and return reference to the lvalue.
CVector3&
CVector3::operator =(const CVector3& _kr)
{
  x = _kr.x;
  y = _kr.y;
  z = _kr.z;

  return (*this);
}

// Equality operator.
bool
CVector3::operator ==(const CVector3&_kr) const
{
  return (x == _kr.x && y == _kr.y && z == _kr.z);
}

// Inequality operator.
bool
CVector3::operator !=(const CVector3& _kr) const
{
  return (x != _kr.x || y != _kr.y || z != _kr.z);
}

// Set the vector to zero.
void
CVector3::Zero()
{
  x = 0.0f;
  y = 0.0f;
  z = 0.0f;
}

// Unary minus returns the negative of the vector.
CVector3
CVector3::operator -() const
{
  return (CVector3(-x, -y, -z));
}

// Binary +, add vectors.
CVector3
CVector3::operator +(const CVector3& _kr) const
{
  return (CVector3(x + _kr.x, y + _kr.y, z + _kr.z));
}

// Binary –, subtract vectors.
CVector3
CVector3::operator -(const CVector3& _kr) const
{
  return (CVector3(x - _kr.x, y - _kr.y, z - _kr.z));
}

// Multiplication by scalar.
CVector3
CVector3::operator *(float _f) const
{
  return (CVector3(x * _f, y * _f, z * _f));
}

// Division by scalar.
// Precondition: _f must not be zero.
CVector3
CVector3::operator /(float _f) const
{
  // Warning: no check for divide by zero here.
  ASSERT(float fOneOverA = 1.0f / _f);

  return (CVector3(x * fOneOverA, y * fOneOverA, z * fOneOverA));
}

CVector3&
CVector3::operator +=(const CVector3& _kr)
{
  x += _kr.x;
  y += _kr.y;
  z += _kr.z;

  return (*this);
}

CVector3&
CVector3::operator -=(const CVector3& _kr)
{
  x -= _kr.x;
  y -= _kr.y;
  z -= _kr.z;

  return (*this);
}

CVector3&
CVector3::operator *=(float _f)
{
  x *= _f;
  y *= _f;
  z *= _f;

  return (*this);
}

CVector3&
CVector3::operator /=(float _f)
{
  float fOneOverA = ASSERT(1.0f / _f);

  x *= fOneOverA;
  y *= fOneOverA;
  z *= fOneOverA;

  return (*this);
}

void
CVector3::Normalize()
{
  float fMagSq = x * x + y * y + z * z;

  if (fMagSq > 0.0f)
  {
    // Check for divide-by-zero.
    float fOneOverMag = 1.0f / sqrt(fMagSq);

    x *= fOneOverMag;
    y *= fOneOverMag;
    z *= fOneOverMag;
  }
}

// Vector dot product.
//    We overload the standard multiplication symbol to do this.
float
CVector3::operator *(const CVector3& _kr) const
{
  return (x * _kr.x + y * _kr.y + z * _kr.z);
}

工作原理…

C++具有内置类型:int、char 和 float。每种类型都有许多内置运算符,如加法(+)和乘法(*)。C++还允许您将这些运算符添加到自己的类中。内置类型(int、float)上的运算符不能被重载。优先级顺序不能被改变。在重载运算符时要谨慎的原因有很多。目标是增加可用性和理解。在我们的示例中,我们已经重载了基本的乘法运算符,以便我们可以对我们创建的vector3对象进行加法、减法等操作。这非常方便,因为如果我们知道两个对象的位置向量,我们就可以在游戏中找到对象的距离。我们尽可能使用 const 函数。编译器将强制执行不修改对象的承诺。这可以是确保您的代码没有意外副作用的好方法。

所有接受向量的函数都接受向量的常量引用。我们必须记住,将参数按值传递给函数会调用构造函数。继承对于向量类并不是非常有用,因为我们知道CVector3是速度关键的。虚函数表会使类大小增加 25%,因此不建议使用。

此外,数据隐藏并没有太多意义,因为我们需要向量类的值。在 C++中可以重载一些运算符。C++不允许我们重载的运算符是:

(Member Access or Dot operator),?: (Ternary or Conditional Operator),:: (Scope Resolution Operator),.* (Pointer-to-member Operator),sizeof (Object size Operator) and typeid (Object type Operator)

使用函数重载来重用函数

函数重载是 C++中的一个重要概念。有时,我们希望使用相同的函数名称,但有不同的函数来处理不同的数据类型或不同数量的类型。这是有用的,因为客户端可以根据自己的需求选择正确的函数。C++允许我们通过函数重载来实现这一点。

准备工作

对于这个示例,您需要一台安装有 Visual Studio 工作副本的 Windows 机器。

如何做…

在这个示例中,我们将学习如何重载函数:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为main.cppCspeed.hCspeed.cpp的源文件。

  5. 将以下代码添加到main.cpp

#include <iostream>
#include <conio.h>
#include "CSpeed.h"

using namespace std;

//This is not overloading as the function differs only
//in return type
/*int Add(float x, float y)
{
  return x + y;
}*/

int main()
{
  CSpeed speed;

  cout<<speed.AddSpeed(2.4f, 7.9f)<<endl;
  cout << speed.AddSpeed(4, 5)<<endl;
  cout << speed.AddSpeed(4, 9, 12)<<endl;

  _getch();
  return 0;
}
  1. 将以下代码添加到CSpeed.cpp
#include "CSpeed.h"

CSpeed::CSpeed()
{

}

CSpeed::~CSpeed()
{

}
int CSpeed::AddSpeed(int x, int y, int z)
{
  return x + y + z;
}
int CSpeed::AddSpeed(int x, int y)
{
  return x + y;
}
float CSpeed::AddSpeed(float x, float y)
{
  return x + y;
}
  1. 将以下代码添加到CSpeed.h
#ifndef _VELOCITY_H
#define _VELOCITY_H

class CSpeed
{
public:
  int AddSpeed(int x, int y, int z);
  int AddSpeed(int x, int y);
  float AddSpeed(float x, float y);

  CSpeed();
  ~CSpeed();
private:

};

#endif

工作原理…

函数重载是一种函数多态的类型。函数只能通过参数列表中的参数数量和参数类型进行重载。函数不能仅通过返回类型进行重载。

我们已经创建了一个类来计算速度的总和。我们可以使用该函数来添加两个速度、三个速度或不同数据类型的速度。编译器将根据签名解析要调用的函数。有人可能会认为我们可以创建不同速度的不同对象,然后使用运算符重载来添加它们,或者使用模板编写一个模板函数。然而,我们必须记住,在简单的模板中,实现将保持不变,但在函数重载中,我们也可以更改每个函数的实现。

使用文件进行输入和输出

文件对于保存本地数据非常有用,这样我们可以在程序下次运行时检索数据,或者在程序退出后分析数据。对于我们在代码中创建并填充值的所有数据结构,除非我们将它们保存在本地或服务器/云端,否则这些值在应用程序退出后将丢失。文件用于包含保存的数据。我们可以创建文本文件、二进制文件,甚至具有我们自己加密的文件。当我们想要记录错误或生成崩溃报告时,文件非常方便。

准备就绪

对于这个食谱,您需要一台装有 Visual Studio 的 Windows 机器。

如何做...

在这个食谱中,我们将了解如何在 C++中使用文件处理操作来读取或写入文本文件。我们甚至可以使用 C++操作来创建二进制文件。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cppFile.hFile.cpp的源文件。

  5. 将以下代码添加到Source.cpp中:

#include <conio.h>
#include "File.h"

int main() {

  CFile file;

  file.WriteNewFile("Example.txt");
  file.WriteNewFile("Example.txt", "Logging text1");
  file.AppendFile("Example.txt", "Logging text2");
  file.ReadFile("Example.txt");

  _getch();
  return 0;
}
  1. 将以下代码添加到File.cpp中:
#include "File.h"
#include <string>
#include <fstream>
#include <iostream>

using namespace std;

CFile::CFile()
{
  Text = "This is the initial data";
}
CFile::~CFile()
{

}
void CFile::WriteNewFile(string Filename)const
{
  ofstream myfile(Filename);
  if (myfile.is_open())
  {
    myfile << Text;

    myfile.close();
  }
  else cout << "Unable to open file";
}
void CFile::WriteNewFile(string Filename,string Text)const
{
  ofstream myfile(Filename);
  if (myfile.is_open())
  {
    myfile << Text;

    myfile.close();
  }
  else cout << "Unable to open file";
}

void CFile::AppendFile(string Filename, string Text)const
{
  ofstream outfile;

  outfile.open(Filename, ios_base::app);
  outfile << Text;
       outfile.close();

}
void CFile::ReadFile(string Filename)const
{
  string line;
  ifstream myfile(Filename);
  if (myfile.is_open())
  {
    while (getline(myfile, line))
    {
      cout << line << '\n';
    }
    myfile.close();
  }

  else cout << "Unable to open file";
}
  1. 将以下代码添加到File.h中:
#ifndef _FILE_H
#define _FILE_H

#include <iostream>
#include <string.h>
using namespace std;

class CFile
{
public:
  CFile();
  ~CFile();

  void WriteNewFile(string Filename)const;
  void WriteNewFile(string Filename, string Text)const;
  void AppendFile(string Filename, string Text)const;
  void ReadFile(string Filename)const;
private:

  string Text;
};
#endif

它是如何工作的...

我们使用文件处理有各种原因。其中一些最重要的原因是在游戏运行时记录数据、从文本文件中加载数据以在游戏中使用,或者加密保存数据或加载游戏数据。

我们已经创建了一个名为CFile的类。这个类帮助我们向新文件写入数据,向文件追加数据,并从文件中读取数据。我们使用fstream头文件来加载所有文件处理操作。

文件中的所有内容都是以流的形式写入和读取的。在进行 C++编程时,我们必须使用流插入运算符(<<)从程序中向文件写入信息,就像我们使用该运算符向屏幕输出信息一样。唯一的区别是,您使用ofstreamfstream对象,而不是cout对象。

我们已经创建了一个构造函数,用于在没有任何数据的情况下创建文件时包含初始数据。如果我们只是创建或写入文件,每次都会创建一个新文件,并带有新数据。如果我们只想写入最近更新或最新的数据,这有时是有用的。但是,如果我们想向现有文件添加数据,我们可以使用append函数。追加函数从最后的文件位置指针位置开始向现有文件写入。

读取函数开始从文件中读取数据,直到达到最后一行写入的数据。我们可以将结果显示到屏幕上,或者如果需要,然后将内容写入另一个文件。我们还必须记住在每次操作后关闭文件,否则可能会导致代码的歧义。我们还可以使用seekpseekg函数来重新定位文件位置指针。

创建你的第一个简单游戏

创建一个简单的基于文本的游戏非常容易。我们所需要做的就是创建一些规则和逻辑,我们就会有一个游戏。当然,随着游戏变得更加复杂,我们需要添加更多的函数。当游戏达到一个点,其中有多个对象和敌人的行为和状态时,我们应该使用类和继承来实现所需的结果。

准备就绪

要完成这个示例,您需要一台运行 Windows 的机器。您还需要在 Windows 机器上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做...

在这个示例中,我们将学习如何创建一个简单的基于运气的抽奖游戏:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个Source.cpp文件。

  5. 将以下代码添加到其中:

#include <iostream>
#include <cstdlib>
#include <ctime>

int main(void) {
  srand(time(NULL)); // To not have the same numbers over and over again.

  while (true) { // Main loop.
    // Initialize and allocate.
    int inumber = rand() % 100 + 1 // System number is stored in here.
    int iguess; // User guess is stored in here.
    int itries = 0; // Number of tries is stored here.
    char canswer; // User answer to question is stored here.

    while (true) { // Get user number loop.
      // Get number.
      std::cout << "Enter a number between 1 and 100 (" << 20 - itries << " tries left): ";
      std::cin >> iguess;
      std::cin.ignore();

      // Check is tries are taken up.
      if (itries >= 20) {
        break;
      }

      // Check number.
      if (iguess > inumber) {
        std::cout << "Too high! Try again.\n";
      }
      else if (iguess < inumber) {
        std::cout << "Too low! Try again.\n";
      }
      else {
        break;
      }

      // If not number, increment tries.
      itries++;
    }

    // Check for tries.
    if (itries >= 20) {
      std::cout << "You ran out of tries!\n\n";
    }
    else {
      // Or, user won.
      std::cout << "Congratulations!! " << std::endl;
      std::cout << "You got the right number in " << itries << " tries!\n";
    }

    while (true) { // Loop to ask user is he/she would like to play again.
      // Get user response.
      std::cout << "Would you like to play again (Y/N)? ";
      std::cin >> canswer;
      std::cin.ignore();

      // Check if proper response.
      if (canswer == 'n' || canswer == 'N' || canswer == 'y' || canswer == 'Y') {
        break;
      }
      else {
        std::cout << "Please enter \'Y\' or \'N\'...\n";
      }
    }

    // Check user's input and run again or exit;
    if (canswer == 'n' || canswer == 'N') {
      std::cout << "Thank you for playing!";
      break;
    }
    else {
      std::cout << "\n\n\n";
    }
  }

  // Safely exit.
  std::cout << "\n\nEnter anything to exit. . . ";
  std::cin.ignore();
  return 0;
}

它是如何工作的...

游戏的工作原理是创建一个从 1 到 100 的随机数,并要求用户猜测该数字。会提供提示,告诉用户猜测的数字是高于还是低于实际数字。用户只有 20 次机会来猜测数字。我们首先需要一个伪随机数生成器,基于它我们将生成一个随机数。在这种情况下,伪随机数生成器是srand。我们选择了时间作为生成随机范围的值。

我们需要在一个无限循环中执行程序,这样程序只有在所有尝试用完或用户正确猜出数字时才会中断。我们可以为尝试设置一个变量,并为用户每次猜测增加一个。随机数由 rand 函数生成。我们使用rand%100+1,这样随机数就在 1 到 100 的范围内。我们要求用户输入猜测的数字,然后我们检查该数字是小于、大于还是等于随机生成的数字。然后显示正确的消息。如果用户猜对了,或者所有尝试都已经用完,程序应该跳出主循环。在这一点上,我们询问用户是否想再玩一次游戏。

然后,根据答案,我们重新进入主循环,并开始选择一个随机数的过程。

模板-何时使用它们

模板是 C++编程的一种方式,为编写泛型程序奠定基础。使用模板,我们可以以独立于任何特定数据类型的方式编写代码。我们可以使用函数模板或类模板。

准备工作

对于这个示例,您需要一台安装有 Visual Studio 的 Windows 机器。

如何做...

在这个示例中,我们将了解模板的重要性,如何使用它们以及使用它们提供给我们的优势。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 添加名为Source.cppStack.h的源文件。

  4. 将以下代码添加到Source.cpp中:

#include <iostream>
#include <conio.h>
#include <string>
#include "Stack.h"

using namespace std;

template<class T>
void Print(T array[], int array_size)
{
  for (int nIndex = 0; nIndex < array_size; ++nIndex)
  {  
    cout << array[nIndex] << "\t";
  }
  cout << endl;
}

int main()
{
  int iArray[5] = { 4, 5, 6, 6, 7 };
  char cArray[3] = { 's', 's', 'b' };
  string sArray[3] = { "Kratos", "Dr.Evil", "Mario" };

  //Printing any type of elements
  Print(iArray, sizeof(iArray) / sizeof(*iArray));
  Print(cArray, sizeof(cArray) / sizeof(*cArray));
  Print(sArray, sizeof(sArray) / sizeof(*sArray));

  Stack<int> iStack;

  //Pushes an element to the bottom of the stack
  iStack.push(7);

  cout << iStack.top() << endl;

  for (int i = 0; i < 10; i++)
  {
    iStack.push(i);
  }

  //Removes an element from the top of the stack
  iStack.pop();

  //Prints the top of stack
  cout << iStack.top() << endl;

  _getch();
}
  1. 将以下代码添加到Stack.h中:
#include <vector>

using namespace std;

template <class T>
class Stack {
private:
  vector<T> elements;     // elements

public:
  void push(T const&);  // push element
  void pop();               // pop element
  T top() const;            // return top element
  bool empty() const{       // return true if empty.
    return elements.empty();
  }
};

template <class T>
void Stack<T>::push(T const& elem)
{
  // append copy of passed element
  elements.push_back(elem);
}

template <class T>
void Stack<T>::pop()
{
  if (elements.empty()) {
    throw out_of_range("Stack<>::pop(): empty stack");
  }
  // remove last element
  elements.pop_back();
}

template <class T>
T Stack<T>::top() const
{
  if (elements.empty()) {
    throw out_of_range("Stack<>::top(): empty stack");
  }
  // return copy of last element
  return elements.back();
}

它是如何工作的...

模板是 C++中泛型编程的基础。如果函数或类的实现相同,但我们需要它们操作不同的数据类型,建议使用模板而不是编写新的类或函数。有人可能会说我们可以重载一个函数来实现相同的功能,但请记住,当重载一个函数时,我们可以根据数据类型改变实现,而且我们仍然在编写一个新的函数。使用模板,实现必须对所有数据类型都相同。这就是模板的优势:编写一个函数就足够了。使用高级模板和 C++11 特性,我们甚至可以改变实现,但我们将把这个讨论留到以后。

在这个示例中,我们使用了函数模板和类模板。函数模板是在Source.cpp中定义的。在print函数的顶部,我们添加了模板<class T>关键字类也可以被typename替换。两个关键字的原因是历史性的,我们不需要在这里讨论。函数定义的其余部分是正常的,只是我们使用了T代替了特定的数据类型。所以当我们从主函数调用函数时,T会被正确的数据类型替换。通过这种方式,只需使用一个函数,我们就可以打印所有数据类型。我们甚至可以创建自己的数据类型并将其传递给函数。

Stack.h 是一个类模板的示例,因为类使用的数据类型是通用的。我们选择了堆栈,因为它是游戏编程中非常流行的数据结构。它是一个LIFO后进先出)结构,因此我们可以根据我们的需求显示堆栈中的最新内容。push 函数将一个元素推入堆栈,而 pop 函数将一个元素从堆栈中移除。top 函数显示堆栈中的顶部元素,empty 函数清空堆栈。通过使用这个通用的堆栈类,我们可以存储和显示我们选择的数据类型。

在使用模板时需要记住的一件事是,编译器必须在编译时知道模板的正确实现,因此通常模板的定义和声明都在头文件中完成。然而,如果你想将两者分开,可以使用两种流行的方法。一种方法是使用另一个头文件,并在其末尾列出实现。另一种方法是创建一个.ipp.tpp文件扩展名,并在这些文件中进行实现。

第三章:游戏开发中的数据结构

在本章中,将涵盖以下示例:

  • 使用更高级的数据结构

  • 使用链表存储数据

  • 使用栈存储数据

  • 使用队列存储数据

  • 使用树存储数据

  • 使用图形存储数据

  • 使用 STL 列表存储数据

  • 使用 STL 映射存储数据

  • 使用 STL 哈希表存储数据

介绍

数据结构在视频游戏行业中用于将代码组织得更加清晰和易于管理。一个普通的视频游戏至少会有大约 2 万行代码。如果我们不使用有效的存储系统和结构来管理这些代码,调试将变得非常困难。此外,我们可能会多次编写相同的代码。

如果我们有一个大型数据集,数据结构对于搜索元素也非常有用。假设我们正在制作一个大型多人在线游戏。从成千上万在线玩游戏的玩家中,我们需要找出在某一天得分最高的玩家。如果我们没有将用户数据组织成有意义的数据结构,这可能需要很长时间。另一方面,使用合适的数据结构可以帮助我们在几秒钟内实现这一目标。

使用更高级的数据结构

在这个示例中,我们将看到如何使用更高级的数据结构。程序员的主要任务是根据需要选择正确的数据结构,以便最大限度地减少存储和解析数据所需的时间。有时,选择正确的数据结构比选择适当的算法更重要。

准备工作

要完成这个示例,您需要一台运行 Windows 的计算机。您还需要在 Windows 计算机上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

操作步骤...

在这个示例中,我们将看到使用高级数据结构是多么容易,以及为什么我们应该使用它们。如果我们将数据组织成合适的结构,访问数据会更快,也更容易对其应用复杂的算法。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cppLinkedList.h/LinkedList.cppHashTables.h/HashTables.cpp的源文件。

  5. 将以下代码添加到Source.cpp中:

#include "HashTable.h"
#include <conio.h>

int main()
{
  // Create 26 Items to store in the Hash Table.
  Item * A = new Item{ "Enemy1", NULL };
  Item * B = new Item{ "Enemy2", NULL };
  Item * C = new Item{ "Enemy3", NULL };
  Item * D = new Item{ "Enemy4", NULL };
  Item * E = new Item{ "Enemy5", NULL };
  Item * F = new Item{ "Enemy6", NULL };
  Item * G = new Item{ "Enemy7", NULL };
  Item * H = new Item{ "Enemy8", NULL };
  Item * I = new Item{ "Enemy9", NULL };
  Item * J = new Item{ "Enemy10", NULL };
  Item * K = new Item{ "Enemy11", NULL };
  Item * L = new Item{ "Enemy12", NULL };
  Item * M = new Item{ "Enemy13", NULL };
  Item * N = new Item{ "Enemy14", NULL };
  Item * O = new Item{ "Enemy15", NULL };
  Item * P = new Item{ "Enemy16", NULL };
  Item * Q = new Item{ "Enemy17", NULL };
  Item * R = new Item{ "Enemy18", NULL };
  Item * S = new Item{ "Enemy19", NULL };
  Item * T = new Item{ "Enemy20", NULL };
  Item * U = new Item{ "Enemy21", NULL };
  Item * V = new Item{ "Enemy22", NULL };
  Item * W = new Item{ "Enemy23", NULL };
  Item * X = new Item{ "Enemy24", NULL };
  Item * Y = new Item{ "Enemy25", NULL };
  Item * Z = new Item{ "Enemy26", NULL };

  // Create a Hash Table of 13 Linked List elements.
  HashTable table;

  // Add 3 Items to Hash Table.
  table.insertItem(A);
  table.insertItem(B);
  table.insertItem(C);
  table.printTable();

  // Remove one item from Hash Table.
  table.removeItem("Enemy3");
  table.printTable();

  // Add 23 items to Hash Table.
  table.insertItem(D);
  table.insertItem(E);
  table.insertItem(F);
  table.insertItem(G);
  table.insertItem(H);
  table.insertItem(I);
  table.insertItem(J);
  table.insertItem(K);
  table.insertItem(L);
  table.insertItem(M);
  table.insertItem(N);
  table.insertItem(O);
  table.insertItem(P);
  table.insertItem(Q);
  table.insertItem(R);
  table.insertItem(S);
  table.insertItem(T);
  table.insertItem(U);
  table.insertItem(V);
  table.insertItem(W);
  table.insertItem(X);
  table.insertItem(Y);
  table.insertItem(Z);
  table.printTable();

  // Look up an item in the hash table
  Item * result = table.getItemByKey("Enemy4");
  if (result!=nullptr)
  cout << endl<<"The next key is "<<result->next->key << endl;

  _getch();
  return 0;
}
  1. 将以下代码添加到LinkedList.h中:
#ifndef LinkedList_h
#define LinkedList_h

#include <iostream>
#include <string>
using namespace std;

//*****************************************************************
// List items are keys with pointers to the next item.
//*****************************************************************
struct Item
{
  string key;
  Item * next;
};

//*****************************************************************
// Linked lists store a variable number of items.
//*****************************************************************
class LinkedList
{
private:
  // Head is a reference to a list of data nodes.
  Item * head;

  // Length is the number of data nodes.
  int length;

public:
  // Constructs the empty linked list object.
  // Creates the head node and sets length to zero.
  LinkedList();

  // Inserts an item at the end of the list.
  void insertItem(Item * newItem);

  // Removes an item from the list by item key.
  // Returns true if the operation is successful.
  bool removeItem(string itemKey);

  // Searches for an item by its key.
  // Returns a reference to first match.
  // Returns a NULL pointer if no match is found.
  Item * getItem(string itemKey);

  // Displays list contents to the console window.
  void printList();

  // Returns the length of the list.
  int getLength();

  // De-allocates list memory when the program terminates.
  ~LinkedList();
};

#endif
  1. 将以下代码添加到LinkedList.cpp中:
#include "LinkedList.h"

// Constructs the empty linked list object.
// Creates the head node and sets length to zero.
LinkedList::LinkedList()
{
  head = new Item;
  head->next = NULL;
  length = 0;
}

// Inserts an item at the end of the list.
void LinkedList::insertItem(Item * newItem)
{
  if (!head->next)
  {
    head->next = newItem;
newItem->next=NULL;
    length++;
    return;
  }
//Can be reduced to fewer lines of codes.
//Using 2 variables p and q to make it more clear
  Item * p = head->next;
  Item * q = p->next;
  while (q)
  {
    p = q;
    q = p->next;
  }
  p->next = newItem;
  newItem->next = NULL;
  length++;
}

// Removes an item from the list by item key.
// Returns true if the operation is successful.
bool LinkedList::removeItem(string itemKey)
{
  if (!head->next) return false;
  Item * p = head;
  Item * q = head->next;
  while (q)
  {
    if (q->key == itemKey)
    {
      p->next = q->next;
      delete q;
      length--;
      return true;
    }
    p = q;
    q = p->next;
  }
  return false;
}

// Searches for an item by its key.
// Returns a reference to first match.
// Returns a NULL pointer if no match is found.
Item * LinkedList::getItem(string itemKey)
{
  Item * p = head;
  Item * q = p->next;
  while (q)
  {

if (q->key == itemKey))
  {  
return p;
  }
p = q;  
q = p->next;
  }
  return NULL;
}

// Displays list contents to the console window.
void LinkedList::printList()
{
  if (length == 0)
  {
    cout << "\n{ }\n";
    return;
  }
  Item * p = head;
  Item * q = p->next;
  cout << "\n{ ";
  while (q)
  {
    p = q;
    if (p != head)
    {
      cout << p->key;
      if (q->next) cout << ", ";
      else cout << " ";
    }
    q = p->next;
  }
  cout << "}\n";
}

// Returns the length of the list.
int LinkedList::getLength()
{
  return length;
}

// De-allocates list memory when the program terminates.
LinkedList::~LinkedList()
{
  Item * p = head;
  Item * q = head;
  while (q)
  {
    p = q;
    q = p->next;
    if (q) 
  }
delete p;
}
  1. 将以下代码添加到HashTable.cpp中:
#include "HashTable.h"

// Constructs the empty Hash Table object.
// Array length is set to 13 by default.
HashTable::HashTable(int tableLength)
{
  if (tableLength <= 0) tableLength = 13;
  array = new LinkedList[tableLength];
  length = tableLength;
}

// Returns an array location for a given item key.
int HashTable::hash(string itemKey)
{
  int value = 0;
  for (int i = 0; i < itemKey.length(); i++)
    value += itemKey[i];
  return (value * itemKey.length()) % length;
}

// Adds an item to the Hash Table.
void HashTable::insertItem(Item * newItem)
{
If(newItem)
{
  int index = hash(newItem->key);
  array[index].insertItem(newItem);
}
}

// Deletes an Item by key from the Hash Table.
// Returns true if the operation is successful.
bool HashTable::removeItem(string itemKey)
{
  int index = hash(itemKey);
  return array[index].removeItem(itemKey);
}

// Returns an item from the Hash Table by key.
// If the item isn't found, a null pointer is returned.
Item * HashTable::getItemByKey(string itemKey)
{
  int index = hash(itemKey);
  return array[index].getItem(itemKey);
}

// Display the contents of the Hash Table to console window.
void HashTable::printTable()
{
  cout << "\n\nHash Table:\n";
  for (int i = 0; i < length; i++)
  {
    cout << "Bucket " << i + 1 << ": ";
    array[i].printList();
  }
}

// Returns the number of locations in the Hash Table.
int HashTable::getLength()
{
  return length;
}

// Returns the number of Items in the Hash Table.
int HashTable::getNumberOfItems()
{
  int itemCount = 0;
  for (int i = 0; i < length; i++)
  {
    itemCount += array[i].getLength();
  }
  return itemCount;
}

// De-allocates all memory used for the Hash Table.
HashTable::~HashTable()
{
  delete[] array;
}
  1. 将以下代码添加到HashTables.h中:
#ifndef HashTable_h
#define HashTable_h

#include "LinkedList.h"

//*****************************************************************
// Hash Table objects store a fixed number of Linked Lists.
//*****************************************************************
class HashTable
{
private:

  // Array is a reference to an array of Linked Lists.
  LinkedList * array;

  // Length is the size of the Hash Table array.
  int length;

  // Returns an array location for a given item key.
  int hash(string itemKey);

public:

  // Constructs the empty Hash Table object.
  // Array length is set to 13 by default.
  HashTable(int tableLength = 13);

  // Adds an item to the Hash Table.
  void insertItem(Item * newItem);

  // Deletes an Item by key from the Hash Table.
  // Returns true if the operation is successful.
  bool removeItem(string itemKey);

  // Returns an item from the Hash Table by key.
  // If the item isn't found, a null pointer is returned.
  Item * getItemByKey(string itemKey);

  // Display the contents of the Hash Table to console window.
  void printTable();

  // Returns the number of locations in the Hash Table.
  int getLength();

  // Returns the number of Items in the Hash Table.
  int getNumberOfItems();

  // De-allocates all memory used for the Hash Table.
  ~HashTable();
};

#endif

它是如何工作的...

我们创建了这个类来使用哈希表存储不同的敌人,然后使用键从哈希表中搜索特定的敌人。而哈希表则是使用链表创建的。

LINKEDLIST文件中,我们定义了一个结构来存储哈希表中的键和指向下一个值的指针。主类包含了一个名为ITEM的结构的指针引用。除此之外,该类还包含了数据的长度和用于插入项、删除项、查找元素、显示整个列表以及查找列表长度的成员函数。

HASHTABLE文件中,使用链表创建了一个哈希表。创建了一个链表的引用,以及哈希表数组的长度和一个返回哈希表数组中特定项的数组位置的私有函数。除此之外,哈希表具有与链表类似的功能,如插入项、删除项和显示哈希表。

从驱动程序中,创建一个结构的对象来初始化要推送到哈希表中的项。然后创建一个哈希表的对象,并将项推送到表中并显示。还可以从表中删除一个项。最后,搜索一个名为Enemy4的特定项并显示下一个键。

使用链表存储数据

在这个示例中,我们将看到如何使用链表来存储和组织数据。链表在游戏行业的主要优势是它是一种动态数据结构。然而,它不适合搜索和插入元素,因为您需要找到信息。搜索是O(n)。这意味着我们可以在运行时为这种数据结构分配内存。在游戏中,大多数东西都是在运行时创建、销毁和更新的,因此使用链表非常合适。链表还可以用于创建堆栈和队列等线性数据结构,在游戏编程中同样重要。

准备工作

您需要在 Windows 机器上安装一个可用的 Visual Studio 副本。

如何做到...

在这个示例中,我们将看到使用链表是多么容易。链表是存储数据的好方法,并且被用作其他数据结构的基本机制:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为Source.cpp的源文件。

  5. 将以下代码添加到其中:

#include <iostream>
#include <conio.h>

using namespace std;

typedef struct LinkedList {
  int LevelNumber;
  LinkedList * next;
} LinkedList;

int main() {
  LinkedList * head = NULL;
  int i;
  for (i = 1; i <= 10; i++) {
    LinkedList * currentNode = new LinkedList;
    currentNode->LevelNumber = i;
    currentNode->next = head;
    head = currentNode;
  }
  while (head) {
    cout << head->LevelNumber << " ";
    head = head->next;
  }
delete head;
  _getch();
  return 0;
}

它是如何工作的...

链表用于创建存储数据和包含下一个节点地址的字段的数据结构。链表由节点组成。

它是如何工作的...

在我们的例子中,我们使用结构创建了一个链表,并使用迭代来填充链表。如前所述,链表的主要概念是它包含某种数据,并包含下一个节点的地址信息。在我们的例子中,我们创建了一个链表来存储当前级别的编号和下一个要加载的级别的地址。这种结构对于存储我们想要加载的级别非常重要。通过遍历链表,我们可以按正确的顺序加载级别。甚至游戏中的检查点也可以以类似的方式编程。

使用堆栈存储数据

堆栈是 C++中线性数据结构的一个例子。在这种类型的数据结构中,数据输入的顺序非常重要。最后输入的数据是要删除的第一条数据。这就是为什么有时也称为后进先出LIFO)数据结构。将数据输入堆栈的过程称为push,删除数据的过程称为pop。有时我们只想打印堆栈顶部的值,而不删除或弹出。堆栈在游戏行业的各个领域都有用,尤其是在为游戏创建 UI 系统时。

准备工作

您需要在 Windows 机器上安装一个可用的 Visual Studio 副本。

如何做到...

在这个示例中,我们将发现使用堆栈数据结构是多么容易。堆栈是最容易实现的数据结构之一,并且在多个领域中使用:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为Source.cpp的源文件。

  5. 将以下代码添加到其中:

#include <iostream>
#include <conio.h>
#include <string>

using namespace std;

class Stack
{
private:
  string UI_Elements[10];
  int top;
public:
  Stack()
  {
    top = -1;
  }

  void Push(string element)
  {
    if (top >= 10)
    {
      cout << "Some error occurred";
    }
    UI_Elements[++top] = element;
  }

  string Pop()
  {
    if (top == -1)
    {
      cout << "Some error occurred";
    }
    return UI_Elements[top--];
  }

  string Top()
  {
    return UI_Elements[top];
  }

  int Size()
  {
    return top + 1;
  }

  bool isEmpty()
  {
    return (top == -1) ? true : false;
  }
};

int main()
{
    Stack _stack;

    if (_stack.isEmpty())
    {
      cout << "Stack is empty" << endl;
    }
    // Push elements    
    _stack.Push("UI_Element1");
    _stack.Push("UI_Element2");
    // Size of stack
    cout << "Size of stack = " << _stack.Size() << endl;
    // Top element    
    cout << _stack.Top() << endl;
    // Pop element    
    cout << _stack.Pop() << endl;
    // Top element    
    cout << _stack.Top() << endl;

    _getch();
    return 0;
  }

它是如何工作的...

在这个例子中,我们使用STACK数据结构将各种 UI 元素推入堆栈。STACK本身是通过数组创建的。在推入元素时,我们需要检查堆栈是否为空或已经存在一些元素。在弹出元素时,我们需要删除堆栈顶部的元素,并相应地更改指针地址。在打印堆栈的 UI 元素时,我们遍历整个堆栈,并从顶部显示它们。让我们考虑一个具有以下级别的游戏:主菜单、章节选择、级别选择和游戏开始。当我们想退出游戏时,我们希望用户以相反的顺序选择级别。因此,第一个级别应该是游戏开始(暂停状态),然后是级别选择、章节选择,最后是主菜单。这可以很容易地通过堆栈来实现,就像前面的例子中所解释的那样。

使用队列存储数据

队列是动态数据结构的一个例子。这意味着队列的大小可以在运行时改变。这在编程游戏时是一个巨大的优势。队列从数据结构的后面进行入队/插入操作,从数据结构的前面进行出队/删除/推出操作。这使它成为一个先进先出FIFO)的数据结构。想象一下,在游戏中,我们有一个库存,但我们希望玩家使用他拿起的第一个物品,除非他手动切换到另一个物品。这可以很容易地通过队列实现。如果我们想设计成当前物品切换到库存中最强大的物品,我们可以使用优先队列来实现这个目的。

准备工作

对于这个教程,你需要一台装有 Visual Studio 的 Windows 机器。

如何做…

在这个教程中,我们将使用链表来实现队列数据结构。实现队列非常容易,它是一个非常健壮的数据结构:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为Source.cpp的源文件。

  5. 向其中添加以下代码行:

#include <iostream>
#include <queue>
#include <string>
#include <conio.h>

using namespace std;

int main()
{
  queue <string> gunInventory;
  gunInventory.push("AK-47");
  gunInventory.push("BullPup");
  gunInventory.push("Carbine");

  cout << "This is your weapons inventory" << endl << endl;
  cout << "The first gun that you are using is "
    << gunInventory.front() << endl << endl;
  gunInventory.pop();
  cout << "There are currently " << gunInventory.size()
    << " more guns in your inventory. " << endl << endl
    << "The next gun in the inventory is "
    << gunInventory.front() << "." << endl << endl

    << gunInventory.back() << " is the last gun in the inventory."
    << endl;

  _getch();
  return 0;

}

它是如何工作的…

我们使用 STL 队列来创建队列结构,或者说使用队列结构。队列结构,正如我们所知,是在需要使用 FIFO 数据结构时非常重要的。就像在第一人称射击游戏中,我们可能希望用户使用他拿起的第一把枪,剩下的枪放在库存中。这是队列的一个理想案例,就像例子中解释的那样。队列结构的前端保存了拿起的第一把枪,或者当前的枪,剩下的枪按照拿起的顺序存储在库存中。有时候,在游戏中,我们希望如果拿起的枪比正在使用的更强大,它应该自动切换到那个枪。在这种情况下,我们可以使用一个更专门的队列形式,称为优先队列,我们只需要指定队列按照什么参数进行排序。

使用树来存储数据

树是非线性数据结构的一个例子,不像数组和链表是线性的。树经常用在需要层次结构的游戏中。想象一辆汽车有很多部件,所有部件都是功能的,可升级的,并且可以互动。在这种情况下,我们将使用树数据结构为汽车创建整个类。树使用父子关系在所有节点之间进行遍历。

准备工作

对于这个教程,你需要一台装有 Visual Studio 的 Windows 机器。

如何做…

在这个教程中,我们将实现一个二叉树。二叉树有很多变种。我们将创建最基本的二叉树。很容易向二叉树添加新的逻辑来实现平衡二叉树,AVL 树等等:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为CTree.cpp的源文件。

  5. 向其中添加以下代码行:

// Initialize the node with a value and pointers
// to left child
// and right child
struct node
{
  string data_value;
  node *left;
  node *right;
};

class Binary_Tree
{
public:
  Binary_Tree();
  ~Binary_Tree();

  void insert(string key);
  node *search(string key);
  void destroy_tree();

private:
  void destroy_tree(node *leaf);
  void insert(string key, node *leaf);
  node *search(string key, node *leaf);

  node *root;
};

Binary_Tree::Binary_Tree()
{
  root = NULL;
}

Binary_Tree::~Binary_Tree()
{
  destroy_tree();
}

void Binary_Tree::destroy_tree(node *leaf)
{
  if (leaf != NULL)
  {
    destroy_tree(leaf->left);
    destroy_tree(leaf->right);
    delete leaf;
  }
}

void Binary_Tree::insert(string key, node *leaf)
{
  if (key< leaf->key_value)
  {
    if (leaf->left != NULL)
      insert(key, leaf->left);
    else
    {
      leaf->left = new node;
      leaf->left->key_value = key;
      leaf->left->left = NULL;  
      leaf->left->right = NULL;  
    }
  }
  else if (key >= leaf->key_value)
  {
    if (leaf->right != NULL)
      insert(key, leaf->right);
    else
    {
      leaf->right = new node;
      leaf->right->key_value = key;
      leaf->right->left = NULL;
      leaf->right->right = NULL;
    }
  }
}

node *Binary_Tree::search(string key, node *leaf)
{
  if (leaf != NULL)
  {
    if (key == leaf->key_value)
      return leaf;
    if (key<leaf->key_value)
      return search(key, leaf->left);
    else
      return search(key, leaf->right);
  }
  else return NULL;
}

void Binary_Tree::insert(string key)
{
  if (root != NULL)
    insert(key, root);
  else
  {
    root = new node;
    root->key_value = key;
    root->left = NULL;
    root->right = NULL;
  }
}
node *Binary_Tree::search(string key)
{
  return search(key, root);
}

void Binary_Tree::destroy_tree()
{
  destroy_tree(root);
}

它是如何工作的…

我们使用一个结构来存储值和左孩子和右孩子的指针。没有特定的规则来决定哪些元素应该是左孩子,哪些元素应该是右孩子。如果我们愿意,我们可以决定所有低于根元素的元素都在左边,所有高于根元素的元素都在右边。

它是如何工作的…

树数据结构中的插入和删除都是以递归方式完成的。要插入元素,我们遍历树并检查它是否为空。如果为空,我们创建一个新节点,并通过递归方式添加所有相应的节点,通过检查新节点的值是大于还是小于根节点。搜索元素的方式也类似。如果要搜索的元素的值小于根节点,则我们可以忽略树的整个右侧部分,正如我们在search函数中所看到的,并继续递归搜索。这大大减少了搜索空间并优化了我们的算法。这意味着在运行时搜索项目将更快。假设我们正在创建一个需要实现程序化地形的游戏。在场景加载后,我们可以使用二叉树根据它们出现在左侧还是右侧来将整个级别划分为部分。如果这些信息在树中正确存储,那么游戏摄像机可以使用这些信息来决定哪个部分被渲染,哪个部分不被渲染。这也创建了一个很好的剔除优化级别。如果父级没有被渲染,我们可以忽略检查树的其余部分进行渲染。

使用图来存储数据

在这个教程中,我们将看到使用图数据结构存储数据是多么容易。如果我们必须创建一个像 Facebook 一样的系统来与朋友和朋友的朋友分享我们的游戏,图数据结构非常有用。图可以以几种方式实现。最常用的方法是使用边和节点。

准备工作

要完成这个教程,您需要一台运行 Windows 的机器。您还需要在 Windows 机器上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做…

在这个教程中,我们将看到如何实现图。图是一个非常好的数据结构,用于将各种状态和数据与边缘条件相互连接。任何社交网络算法都以某种方式使用图数据结构:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加CGraph.h/CGraph.cpp文件。

  5. 将以下代码添加到CGraph.h

#include <iostream>
#include <vector>
#include <map>
#include <string>

using namespace std;

struct vertex
{
  typedef pair<int, vertex*> ve;
  vector<ve> adj; //cost of edge, destination vertex
  string name;
  vertex(string s)
  {
    name = s;
  }
};

class graph
{
public:
  typedef map<string, vertex *> vmap;
  vmap work;
  void addvertex(const string&);
  void addedge(const string& from, const string& to, double cost);
};
  1. 将以下代码添加到CGraph.cpp
void graph::addvertex(const string &name)
{
  vmap::iterator itr = work.begin();
  itr = work.find(name);
  if (itr == work.end())
  {
    vertex *v;
    v = new vertex(name);
    work[name] = v;
    return;
  }
  cout << "\nVertex already exists!";
}

void graph::addedge(const string& from, const string& to, double cost)
{
  vertex *f = (work.find(from)->second);
  vertex *t = (work.find(to)->second);
  pair<int, vertex *> edge = make_pair(cost, t);
  f->adj.push_back(edge);
}

它是如何工作的…

图由边和节点组成。因此,在实现图数据结构时,首先要做的是创建一个结构来存储节点和顶点信息。下图有六个节点和七条边。要实现一个图,我们需要了解从一个节点到另一个节点的每条边的成本。这些被称为邻接成本。要插入一个节点,我们创建一个节点。要向节点添加边,我们需要提供有关需要连接的两个节点和边的成本的信息。

获取信息后,我们使用边的成本和其中一个节点创建一对,并将该边的信息推送到另一个节点:

它是如何工作的…

使用 STL 列表来存储数据

STL 是一个标准模板库,其中包含许多基本数据结构的实现,这意味着我们可以直接用它们来实现我们的目的。列表在内部实现为双向链表,这意味着插入和删除可以在两端进行。

准备工作

对于这个教程,您需要一台安装有 Visual Studio 的 Windows 机器。

如何做…

在这个教程中,我们将看到如何使用 C++为我们提供的内置模板库来轻松创建复杂的数据结构。创建复杂的数据结构后,我们可以轻松地使用它来存储数据和访问数据:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 添加一个名为Source.cpp的源文件。

  4. 将以下代码添加到其中:

#include <iostream>
#include <list>
#include <conio.h>

using namespace std;

int main()
{
  list<int> possible_paths;
  possible_paths.push_back(1);
  possible_paths.push_back(1);
  possible_paths.push_back(8);
  possible_paths.push_back(9);
  possible_paths.push_back(7);
  possible_paths.push_back(8);
  possible_paths.push_back(2);
  possible_paths.push_back(3);
  possible_paths.push_back(3);

  possible_paths.sort();
  possible_paths.unique();

  for (list<int>::iterator list_iter = possible_paths.begin();
    list_iter != possible_paths.end(); list_iter++)
  {
    cout << *list_iter << endl;
  }

  _getch();
  return 0;

}

它是如何工作的…

我们已经使用列表将可能的路径成本值推送到某个 AI 玩家到达目的地的值中。我们使用了 STL 列表,它带有一些内置的函数,我们可以在容器上应用这些函数。我们使用sort函数按升序对列表进行排序。我们还有unique函数来删除列表中的所有重复值。在对列表进行排序后,我们得到了最小的路径成本,因此我们可以将该路径应用于 AI 玩家。尽管代码大小大大减小,编写起来更容易,但我们应该谨慎使用 STL,因为我们从来不确定内置函数背后的算法。例如,sort函数很可能使用快速排序,但我们不知道。

使用 STL 地图来存储数据

地图是 STL 的关联容器之一,它存储由键值和映射值组成的元素,遵循特定的顺序。地图是 C++为我们提供的 STL 的一部分。

准备就绪

对于这个示例,您需要一台安装有 Visual Studio 的 Windows 机器。

如何做…

在这个示例中,我们将看到如何使用 C++提供的内置模板库来创建复杂的数据结构。创建复杂的数据结构后,我们可以轻松地使用它来存储数据和访问数据:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 添加名为Source.cpp的源文件。

  4. 将以下代码行添加到其中:

#include <iostream>
#include <map>
#include <conio.h>

using namespace std;

int main()
{
  map <string, int> score_list;

  score_list["John"] = 242;
  score_list["Tim"] = 768;
  score_list["Sam"] = 34;

  if (score_list.find("Samuel") == score_list.end())
  {
    cout << "Samuel is not in the map!" << endl;
  }

  cout << score_list.begin()->second << endl;

  _getch();
  return 0;

}

它是如何工作的…

我们已经使用 STL 地图创建了一个键/值对,用于存储玩我们游戏的玩家的姓名和他们的高分。我们可以在地图中使用任何数据类型。在我们的示例中,我们使用了一个字符串和一个整数。创建数据结构后,非常容易找到玩家是否存在于数据库中,我们还可以对地图进行排序并显示与玩家关联的分数。第二个字段给出了值,而第一个字段给出了键。

使用 STL 哈希表来存储数据

地图和哈希表之间最大的区别在于,地图数据结构是有序的,而哈希表是无序的。两者都使用键/值对的相同原则。无序地图的最坏情况搜索复杂度为O(N),因为它不像地图那样有序,地图的复杂度为O(log N)

准备就绪

对于这个示例,您需要一台安装有 Visual Studio 的 Windows 机器。

如何做…

在这个示例中,我们将看到如何使用 C++为我们提供的内置模板库来创建复杂的数据结构。创建复杂的数据结构后,我们可以轻松地使用它来存储数据和访问数据:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 添加名为Source.cpp的源文件。

  4. 将以下代码行添加到其中:

#include <unordered_map>
#include <string>
#include <iostream>
#include <conio.h>

using namespace std;

int main()
{
  unordered_map<string, string> hashtable;
  hashtable.emplace("Alexander", "23ms");
  hashtable.emplace("Christopher", "21ms");
  hashtable.emplace("Steve", "55ms");
  hashtable.emplace("Amy", "17ms");
  hashtable.emplace("Declan", "999ms");

  cout << "Ping time in milliseconds: " << hashtable["Amy"] << endl<<endl;
  cout << "----------------------------------" << endl << endl;

  hashtable.insert(make_pair("Fawad", "67ms"));

  cout << endl<<"Ping time of all player is the server" << endl;
  cout << "------------------------------------" << endl << endl;
  for (auto &itr : hashtable)
  {
    cout << itr.first << ": " << itr.second << endl;
  }

  _getch();
  return 0;
}

它是如何工作的…

该程序计算当前在服务器上玩我们游戏的所有玩家的 ping 时间。我们创建一个哈希表,并使用emplace关键字存储所有玩家的姓名和 ping 时间。我们还可以使用make_pair关键字稍后插入新玩家及其 ping 时间。创建哈希表后,我们可以轻松地显示特定玩家的 ping 时间,或者服务器上所有玩家的 ping 时间。我们使用迭代器来遍历哈希表。第一个参数给出了键,第二个参数给出了值。

第四章:游戏开发算法

在本章中,将涵盖以下示例:

  • 使用排序技术来排列项目

  • 使用搜索技术查找项目

  • 找到算法的复杂性

  • 查找设备的字节顺序

  • 使用动态规划来解决复杂问题

  • 使用贪婪算法解决问题

  • 使用分治算法解决问题

介绍

算法是指应用于执行任务的一系列步骤。搜索和排序算法是我们可以在容器中搜索或排序元素的技术。一个容器本身将没有任何优势,除非我们可以在该容器中搜索项目。根据某些容器,某些算法对某些容器比其他容器更强大。由于算法在较慢的系统上运行速度较慢,在较快的系统上运行速度较快,计算时间并不是衡量算法有效性的有效方法。算法通常是以步骤来衡量的。游戏是实时应用程序,因此将应用的算法必须对游戏至少以每秒 30 帧的速度执行有效。理想的帧速率是每秒 60 帧。

使用排序技术来排列项目

排序是一种排列容器中的项目的方法。我们可以按升序或降序排列它们。如果我们必须实现游戏的最高分系统和排行榜,排序就变得必要了。在游戏中,当用户获得比以前的最高分更高的分数时,我们应该将该值更新为当前最高分,并将其推送到本地或在线排行榜。如果是本地的,我们应该按降序排列所有用户以前的最高分,并显示前 10 个分数。如果是在线排行榜,我们需要对所有用户的最新最高分进行排序并显示结果。

做好准备

要完成本示例,您需要一台运行 Windows 的计算机。您还需要在 Windows 计算机上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做...

在这个示例中,我们将发现使用不同的排序技术来排列容器中的项目是多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加一个名为Sorting.h的头文件。

  5. 将以下代码添加到其中:

// Bubble Sort
template <class T>
void bubble_sort(T a[], int n)
{
  T temp;
  for (int i = 0; i<n; i++)
  {
    for (int j = 0; j<n - i - 1; j++)
    {
      if (a[j]>a[j + 1])
      {
        temp = a[j];
        a[j] = a[j + 1];
        a[j + 1] = temp;
      }
    }
  }
}

//Insertion Sort
template <class T>
void insertion_sort(T a[], int n)
{
  T key;
  for (int i = 1; i<n; i++)
  {
    key = a[i];
    int j = i - 1;
    while (j >= 0 && a[j]>key)
    {
      a[j + 1] = a[j];
      j = j - 1;
    }
    a[j + 1] = key;
  }
}

//Selection Sort
template <class T>
int minimum_element(T a, int low, int up)
{
  int min = low;
  while (low<up)
  {
    if (a[low]<a[min])
      min = low;
    low++;
  }
  return min;
}

template <class T>

void selection_sort(T a[], int n)
{
  int i = 0;
  int loc = 0;
  T temp;
  for (i = 0; i<n; i++)
  {
    loc = minimum_element(a, i, n);
    temp = a[loc];
    a[loc] = a[i];
    a[i] = temp;
  }
}

//Quick Sort
template <class T>
int partition(T a[], int p, int r)
{
  T x;
  int i;
  x = a[r];
  i = p - 1;
  for (int j = p; j <= r - 1; j++)
  {
    if (a[j] <= x)
    {
      i = i + 1;
      swap(a[i], a[j]);
    }
  }
  swap(a[i + 1], a[r]);
  return i + 1;
}
template <class T>
void quick_sort(T a[], int p, int r)
{
  int q;
  if (p<r)
  {
    q = partition(a, p, r);
    quick_sort(a, p, q - 1);
    quick_sort(a, q + 1, r);
  }
}

它是如何工作的...

在这个例子中,使用了四种排序技术。这四种技术是冒泡 排序选择 排序插入 排序快速 排序

冒泡排序是一种排序算法,通过不断遍历要排序的容器,比较相邻的每一对项目,并在它们的顺序错误时交换它们。这个过程一直持续到不再需要交换为止。平均、最好和最坏情况的顺序为O(n²)

插入排序是一种简单的排序算法,是一种比较排序,其中排序的容器是一次构建一个条目。这是一种非常简单的算法来实现。然而,在大型数据集上它并不那么有效。最坏和平均情况的顺序为O(n²),最好的情况是,当容器排序时,顺序为O(n)

选择排序是一种算法,它试图在每次通过时将项目放在排序列表中的正确位置。最好、最坏和平均情况的顺序为O(n²)

快速排序是一种算法,它创建一个枢轴,然后根据枢轴对容器进行排序。然后移动枢轴并继续该过程。快速排序是一种非常有效的算法,适用于几乎所有的现实世界数据和大多数现代架构。它很好地利用了内存层次结构。甚至内置的标准模板库使用了快速排序的修改版本作为其排序算法。该算法的最佳和平均情况是O(nlog n),最坏情况是O(n²)*。

使用搜索技术查找项目

搜索技术是一组涉及在容器中查找项目的算法过程。搜索和排序是相辅相成的。排序的容器将更容易搜索。在容器排序或排序后,我们可以应用适当的搜索算法来查找元素。假设我们需要找到已用于杀死超过 25 名敌人的枪支的名称。如果容器存储了枪支名称和与该枪支相关的总击杀数的值,我们只需要首先按击杀数升序对该容器进行排序。然后我们可以进行线性搜索,找到第一支击杀数超过 25 的枪支。相应地,容器中该枪支之后的项目将具有超过 25 次击杀,因为容器已排序。但是,我们可以应用更好的搜索技术。

准备工作

您需要在 Windows 计算机上安装 Visual Studio 的工作副本。

如何做...

在这个教程中,我们将发现如何轻松地将搜索算法应用到我们的程序中:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cpp的源文件。

  5. 将以下代码行添加到其中:

#include <iostream>
#include <conio.h>

using namespace std;

bool Linear_Search(int list[], int size, int key)
{
  // Basic sequential search
  bool found = false;
  int i;

  for (i = 0; i < size; i++)
  {
    if (key == list[i])
      found = true;
    break;
  }

  return found;
}
bool Binary_Search(int *list, int size, int key)
{
  // Binary search
  bool found = false;
  int low = 0, high = size - 1;

  while (high >= low)
  {
    int mid = (low + high) / 2;
    if (key < list[mid])
      high = mid - 1;
    else if (key > list[mid])
      low = mid + 1;
    else
    {
      found = true;
      break;
    }
  }

  return found;
}

它是如何工作的...

在容器中搜索项目可以通过多种方式完成。但是,容器是否已排序很重要。让我们假设容器已排序。搜索项目的最糟糕方式是遍历整个容器并搜索项目。对于大数据集,这将花费大量时间,并且在游戏编程中绝对不可取。搜索项目的更好方式是使用二分搜索。二分搜索通过将容器分成两半来工作。它在中点检查要搜索的值是否小于或大于中点值。如果大于中点值,我们可以忽略容器的第一半,并继续仅在第二半中搜索。然后再将第二半进一步分成两半,重复这个过程。因此,通过这样做,我们可以极大地减少算法的搜索空间。这个算法的顺序是 O(log n)。

查找算法的复杂性

我们需要一种有效的方法来衡量算法。这样我们将发现我们的算法是否有效。算法在较慢的机器上运行得更慢,在较快的机器上运行得更快,因此计算时间不是衡量算法的有效方法。算法应该被衡量为步骤数。我们称之为算法的顺序。我们还需要找出算法顺序的最佳情况、最坏情况和平均情况。这将给我们一个更清晰的图片,我们的算法将如何应用于小数据集和大数据集。应避免复杂算法或高阶算法,因为这将增加设备执行任务所需的步骤数,从而减慢应用程序的速度。此外,使用这样的算法进行调试变得困难。

准备工作

您需要在 Windows 计算机上安装 Visual Studio 的工作副本。

如何做...

在这个教程中,我们将发现找到算法的复杂性是多么容易。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cpp的源文件。

  5. 将以下代码行添加到其中:

#include <iostream>
#include <conio.h>

using namespace std;

void Cubic_Order()
{
  int n = 100;
  for (int i = 0; i < n; i++)
  {
    for (int j=0; j < n; j++)
    {
      for (int k = 0; k < n; k++)
      {
        //Some implementation
      }
    }
  }
}
void Sqaure_Order()
{
  int n = 100;
  for (int i = 0; i < n; i++)
  {
    for (int j = 0; j < n; j++)
    {
      //Some implementation
    }
  }
}

int main()
{
  Cubic_Order();
  Sqaure_Order();

  return 0;
}

它是如何工作的...

在这个例子中,我们可以看到算法的顺序,或者“大 O”符号,随着实现的不同而变化。如果我们采用第一个函数Cubic_Order,最内部的实现将需要nnn步来找到答案。因此它的顺序是 n 的三次方,O(n³)。这真的很糟糕。想象一下,如果 n 是一个非常大的数据集,例如让我们说n=1000,它将需要 1,000,000,000 步来找到解决方案。尽量避免立方阶算法。第二个函数square_order具有平方阶。最内部的实现将需要nn步来找到解决方案,因此该算法的顺序是O(n²)*。这也是不好的做法。

我们应该尝试至少达到O(log N)的复杂度。如果我们不断将搜索空间减半,例如使用二分搜索,我们可以实现对数N的复杂度。有一些算法可以实现O(log N)的顺序,这是非常优化的。

一般规则是,所有遵循分而治之的算法都将具有O(log N)的复杂度。

查找设备的字节顺序

平台的字节顺序是指最重要的字节在该设备上的存储方式。这些信息非常重要,因为许多算法可以根据这些信息进行优化。值得注意的是,两种最流行的渲染 SDK,DirectX 和 OpenGL,在它们的字节顺序上有所不同。两种不同类型的字节顺序称为大端和小端。

做好准备

对于这个配方,您需要一台安装有 Visual Studio 的 Windows 机器。

如何做…

在这个配方中,我们将发现查找设备的字节顺序是多么容易。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cpp的源文件。

  5. 将以下代码添加到其中:

Source.cpp

#include <stdio.h>
#include <iostream>
#include <conio.h>

using namespace std;

bool isBigEndian()
{
  unsigned int i = 1;
  char *c = (char*)&i;
  if (*c)
    return false;
  else
    return true;
}
int main()
{
  if (isBigEndian())
  {
    cout << "This is a Big Endian machine" << endl;
  }
  else
  {
    cout << "This is a Little Endian machine" << endl;
  }

  _getch();
  return 0;
}

它是如何工作的…

小端和大端是不同的多字节数据类型在不同机器上存储的方式。在小端机器上,多字节数据类型的最不重要的字节首先存储。另一方面,在大端机器上,多字节数据类型的二进制表示的最重要的字节首先存储。

在前面的程序中,一个字符指针c指向一个整数i。由于字符的大小是 1 个字节,当解引用字符指针时,它将只包含整数的第一个字节。如果机器是小端的,那么*c将是1(因为最后一个字节是先存储的),如果机器是大端的,那么*c将是 0。

假设整数存储为 4 个字节;那么,一个值为 0x01234567 的变量x将存储如下:

它是如何工作的…

大多数情况下,编译器会处理字节顺序;但是,如果我们从小端机器发送数据到大端机器,字节顺序在网络编程中会成为一个问题。此外,如果我们将渲染管线从 DirectX 切换到 OpenGL,也会成为一个问题。

使用动态规划来解决复杂问题

动态规划是解决问题的一种非常现代的方法。这个过程涉及将一个大问题分解成更小的问题块,找到这些问题块的解决方案,并重复这个过程来解决整个复杂问题。一开始很难掌握这种技术,但通过足够的练习,任何问题都可以用动态规划解决。我们在编程视频游戏时遇到的大多数问题都会很复杂。因此,掌握这种技术将非常有用。

做好准备

对于这个配方,您需要一台安装有 Visual Studio 的 Windows 机器。

如何做…

在这个配方中,我们将发现使用动态规划解决问题是多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加名为Source.cpp的源文件。

  5. 将以下代码添加到其中:

#include<iostream>
#include <conio.h>

using namespace std;

int max(int a, int b) { return (a > b) ? a : b; }

int knapSack(int TotalWeight, int individual_weight[], int individual_value[], int size)
{

  if (size == 0 || TotalWeight == 0)
    return 0;
  if (individual_weight[size - 1] > TotalWeight)
    return knapSack(TotalWeight, individual_weight, individual_value, size - 1);
  else return max(individual_value[size - 1] + knapSack(TotalWeight - individual_weight[size - 1], individual_weight, individual_value, size - 1),
    knapSack(TotalWeight, individual_weight, individual_value, size - 1)
    );
}

int main()
{
  int individual_value[] = { 60, 100, 120 };
  int individual_weight[] = { 10, 25, 40 };
  int  TotalWeight = 60;
  int size = sizeof(individual_value) / sizeof(individual_weight[0]);
  cout << "Total value of sack "<<knapSack(TotalWeight, individual_weight, individual_value, size);

  _getch();
  return 0;
}

它是如何工作的…

这是一个经典的背包问题的例子。这可以应用于游戏编程中的许多场景,特别是用于 AI 资源管理。让我们假设 AI 可以携带的总重量(袋子)是一个常数。在我们的例子中,这是背包的总重量。游戏中 AI 收集的每个物品都有重量和价值。现在 AI 需要决定如何填满他的库存/袋子,以便他可以以最大价值出售总袋子并获得硬币。

我们通过递归来解决问题,通过解决每个小组合的物品(重量和价值)并检查两个组合的最大值,并重复这个过程直到达到背包的总重量。

使用贪婪算法解决问题

贪婪算法通过在每个阶段找到最优解来工作。因此,在处理下一步之前,它将根据先前的结果和应用程序当前的需求决定下一步。这样,它比动态规划更好。然而,我们不能将这个原则应用到所有问题上,因此贪婪算法不能用于所有情况。

准备工作

要完成这个配方,你需要一台运行 Windows 的机器。你还需要在 Windows 机器上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做…

在这个配方中,我们将发现使用贪婪算法解决问题有多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择Win32 控制台应用程序

  4. 添加Source.cpp文件。

  5. 将以下代码添加到其中:

#include <iostream>
#include <conio.h>

using namespace std;

void printMaxActivities(int start_Time[], int finish_Time[], int n)
{
  int i, j;  
  i = 0;
  cout << i;
  for (j = 1; j < n; j++)
  {    
    if (start_Time[j] >= finish_Time[i])
    {
      cout << j;
      i = j;
    }
  }
}

int main()
{
  int start_Time[] = { 0, 2, 4, 7, 8, 11 };
  int finish_Time[] = { 2, 4, 6, 8, 9, 15 };
  int n = sizeof(start_Time) / sizeof(start_Time[0]);
  printMaxActivities(start_Time, finish_Time, n);

  _getch();
  return 0;
}

它是如何工作的…

在这个例子中,我们有一组不同活动的开始时间和结束时间。我们需要找出哪些活动可以由一个人完成。我们可以假设容器已经根据结束时间排序。因此,在每次通过时,我们检查当前开始时间是否大于或等于前一个结束时间。只有在这种情况下我们才能接受任务。我们遍历容器并不断检查相同的条件。因为我们在每一步都在检查,所以这个算法非常优化。

使用分治算法解决问题

一般来说,分治算法基于以下思想。我们想要解决的整个问题可能太大,无法一次理解或解决。我们将它分解成较小的部分,分别解决这些部分,然后将这些独立的部分组合起来。

准备工作

对于这个配方,你需要一台运行 Windows 的机器,并且安装了一个可用的 Visual Studio 副本。

如何做…

在这个配方中,我们将发现使用贪婪算法解决问题有多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 添加一个名为Source.cpp的源文件。

  4. 将以下代码添加到其中:

#include <iostream>
#include <conio.h>

using namespace std;

const int MAX = 10;

class rray
{
private:
  int arr[MAX];
  int count;
public:
  array();
  void add(int num);
  void makeheap(int);
  void heapsort();
  void display();
};
array ::array()
{
  count = 0;
  for (int i = 0; i < MAX; i++)
    arr[MAX] = 0;
}
void array ::add(int num)
{
  if (count < MAX)
  {
    arr[count] = num;
    count++;
  }
  else
    cout << "\nArray is full" << endl;
}
void array ::makeheap(int c)
{

  for (int i = 1; i < c; i++)
  {
    int val = arr[i];
    int s = i;
    int f = (s - 1) / 2;
    while (s > 0 && arr[f] < val)
    {
      arr[s] = arr[f];
      s = f;
      f = (s - 1) / 2;
    }
    arr[s] = val;
  }
}
void array ::heapsort()
{
  for (int i = count - 1; i > 0; i--)
  {
    int ivalue = arr[i];
    arr[i] = arr[0];
    arr[0] = ivalue;
    makeheap(i);

  }
}
void array ::display()
{
  for (int i = 0; i < count; i++)
    cout << arr[i] << "\t";
  cout << endl;
}
void main()
{
  array a;

  a.add(11);
  a.add(2);
  a.add(9);
  a.add(13);
  a.add(57);
  a.add(25);
  a.add(17);
  a.add(1);
  a.add(90);
  a.add(3);
  a.makeheap(10);
  cout << "\nHeap Sort.\n";
  cout << "\nBefore Sorting:\n";
  a.display();
  a.heapsort();
  cout << "\nAfter Sorting:\n";
  a.display();

  _getch();
}

它是如何工作的…

堆排序 算法的工作原理是首先将要排序的数据组织成一种特殊类型的二叉树,称为。堆本身在定义上具有树顶部的最大值,因此堆排序算法也必须颠倒顺序。它通过以下步骤实现:

  1. 删除最顶部的物品(最大的)并用最右边的叶子替换它。最顶部的物品存储在一个数组中。

  2. 重新建立堆。

  3. 重复步骤 1 和 2,直到堆中没有更多的物品。排序后的元素现在存储在一个数组中。

第五章:事件驱动编程-制作你的第一个 2D 游戏

在本章中,将涵盖以下食谱:

  • 开始制作 Windows 游戏

  • 使用 Windows 类和句柄

  • 创建你的第一个窗口

  • 添加键盘和鼠标控制以及文本输出

  • 使用 GDI 与 Windows 资源

  • 使用对话框和控件

  • 使用精灵

  • 使用动画精灵

介绍

Windows 编程是创建适当应用程序的开始。我们需要知道如何将我们的游戏打包成一个可执行文件,以便我们的所有资源,如图像、模型和声音,都能得到适当的加密并打包成一个文件。通过这样做,我们确保文件是安全的,并且在分发时不能被非法复制。然而,应用程序仍然在运行时使用这些文件。

Windows 编程也标志着开始理解 Windows 消息泵。这个系统非常重要,因为所有主要的编程范式都依赖于这个原则,特别是当我们进行事件驱动的编程时。

事件驱动编程的主要原则是,基于事件,我们应该处理一些东西。这里需要理解的概念是我们多久检查一次事件以及我们应该多久处理它们。

开始制作 Windows 游戏

在我们开始制作 Windows 游戏之前,首先要了解的是窗口或消息框是如何绘制的。我们需要了解 Windows 提供给我们的众多内置函数以及我们可以使用的不同回调函数。

准备工作

通过这个食谱,你需要一台运行 Windows 的机器。你还需要在 Windows 机器上安装一个可用的 Visual Studio 副本。没有其他先决条件。

如何做...

在这个食谱中,我们将看到在 Windows 中创建消息框是多么容易。我们可以创建不同类型的消息框,只需要几行代码。按照以下步骤进行:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 Windows 应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 将以下代码添加到Source.cpp中:

#define WIN32_LEAN_AND_MEAN

#include <windows.h>
#include <windowsx.h>

int WINAPI WinMain(HINSTANCE _hInstance,
  HINSTANCE _hPrevInstance,
  LPSTR _lpCmdLine,
  int _iCmdShow)
{
  MessageBox(NULL, L"My first message",
    L"My first Windows Program",
    MB_OK | MB_ICONEXCLAMATION);

  return (0);
}

它是如何工作的...

WINMAIN()是 Windows 程序的入口点。在这个例子中,我们使用了内置函数来创建一个消息框。windows.h包含了我们需要调用 Windows API 中的内置函数的所有必要文件。消息框通常用于显示一些内容。我们还可以将消息框与默认的 Windows 声音关联起来。消息框的显示也可以在很大程度上进行控制。我们需要使用正确类型的参数来实现这一点。

我们还可以使用其他类型的消息框:

  • MB_OK:一个按钮,带有OK消息

  • MB_OKCANCEL:两个按钮,带有OKCancel消息

  • MB_RETRYCANCEL:两个按钮,带有RetryCancel消息

  • MB_YESNO:两个按钮,带有YesNo消息

  • MB_YESNOCANCEL:三个按钮,带有YesNoCancel消息

  • MB_ABORTRETRYIGNORE:三个按钮,带有AbortRetryIgnore消息

  • MB_ICONEXCLAIMATION:出现一个感叹号图标

  • MB_ICONINFORMATION:出现一个信息图标

  • MB_ICONQUESTION:出现一个问号图标

  • MB_ICONSTOP:出现一个停止标志图标

像所有良好的 Win32 或 Win64 API 函数一样,MessageBox返回一个值,让我们知道发生了什么。

使用 Windows 类和句柄

为了编写游戏,我们不需要对 Windows 编程了解很多。我们需要知道的是如何打开一个窗口,如何处理消息,以及如何调用主游戏循环。Windows 应用程序的第一个任务是创建一个窗口。在窗口创建后,我们可以做各种其他事情,比如处理事件和处理回调。这些事件最终被游戏框架用来在屏幕上显示精灵,并使它们可移动和交互,以便我们可以玩游戏。

准备工作

您需要在 Windows 机器上安装一个可用的 Visual Studio 副本。

如何做…

在这个教程中,我们将发现使用 Windows 类和句柄有多么容易。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 Windows 应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 向其中添加以下代码行:

   // This only adds the necessary windows files and not all of them
#define WIN32_LEAN_AND_MEAN

#include <windows.h>   // Include all the windows headers.
#include <windowsx.h>  // Include useful macros.

#define WINDOW_CLASS_NAME L"WINCLASS1"

void GameLoop()
{
  //One frame of game logic occurs here...
}

LRESULT CALLBACK WindowProc(HWND _hwnd,
  UINT _msg,
  WPARAM _wparam,
  LPARAM _lparam)
{
  // This is the main message handler of the system.
  PAINTSTRUCT ps; // Used in WM_PAINT.
  HDC hdc;        // Handle to a device context.

  // What is the message?
  switch (_msg)
  {
  case WM_CREATE:
  {
            // Do initialization stuff here.

            // Return Success.
            return (0);
  }
    break;

  case WM_PAINT:
  {
           // Simply validate the window.
           hdc = BeginPaint(_hwnd, &ps);

           // You would do all your painting here...

           EndPaint(_hwnd, &ps);

           // Return Success.
           return (0);
  }
    break;

  case WM_DESTROY:
  {
             // Kill the application, this sends a WM_QUIT message.
             PostQuitMessage(0);

             // Return success.
             return (0);
  }
    break;

  default:break;
  } // End switch.

  // Process any messages that we did not take care of...

  return (DefWindowProc(_hwnd, _msg, _wparam, _lparam));
}

int WINAPI WinMain(HINSTANCE _hInstance,
  HINSTANCE _hPrevInstance,
  LPSTR _lpCmdLine,
  int _nCmdShow)
{
  WNDCLASSEX winclass; // This will hold the class we create.
  HWND hwnd;           // Generic window handle.
  MSG msg;             // Generic message.

  // First fill in the window class structure.
  winclass.cbSize = sizeof(WNDCLASSEX);
  winclass.style = CS_DBLCLKS | CS_OWNDC | CS_HREDRAW | CS_VREDRAW;
  winclass.lpfnWndProc = WindowProc;
  winclass.cbClsExtra = 0;
  winclass.cbWndExtra = 0;
  winclass.hInstance = _hInstance;
  winclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
  winclass.hCursor = LoadCursor(NULL, IDC_ARROW);
  winclass.hbrBackground =
    static_cast<HBRUSH>(GetStockObject(WHITE_BRUSH));
  winclass.lpszMenuName = NULL;
  winclass.lpszClassName = WINDOW_CLASS_NAME;
  winclass.hIconSm = LoadIcon(NULL, IDI_APPLICATION);

  // register the window class
  if (!RegisterClassEx(&winclass))
  {
    return (0);
  }

  // create the window
  hwnd = CreateWindowEx(NULL, // Extended style.
    WINDOW_CLASS_NAME,      // Class.
    L"My first Window",   // Title.
    WS_OVERLAPPEDWINDOW | WS_VISIBLE,
    0, 0,                    // Initial x,y.
    400, 400,                // Initial width, height.
    NULL,                   // Handle to parent.
    NULL,                   // Handle to menu.
    _hInstance,             // Instance of this application.
    NULL);                  // Extra creation parameters.

  if (!(hwnd))
  {
    return (0);
  }

  // Enter main event loop
  while (true)
  {
    // Test if there is a message in queue, if so get it.
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
    {
      // Test if this is a quit.
      if (msg.message == WM_QUIT)
      {
        break;
      }

      // Translate any accelerator keys.
      TranslateMessage(&msg);
      // Send the message to the window proc.
      DispatchMessage(&msg);
    }

    // Main game processing goes here.
    GameLoop(); //One frame of game logic occurs here...
  }

  // Return to Windows like this...
  return (static_cast<int>(msg.wParam));
}

它是如何工作的…

整个typedef结构_WNDCLASSEX的定义如下:

{
UINT cbSize;          // Size of this structure.
UINT style;           // Style flags.
WNDPROC lpfnWndProc;  // Function pointer to handler.
int cbClsExtra;       // Extra class info.
int cbWndExtra;       // Extra window info.
HANDLE hInstance;     // The instance of the app.
HICON hIcon;          // The main icon.
HCURSOR hCursor;      // The cursor for the window.
HBRUSH hbrBackground; // The Background brush to paint the window.
LPCTSTR lpszMenuName; // The name of the menu to attach.
LPCTSTR lpszClassName;// The name of the class itself.
HICON hIconSm;        // The handle of the small icon.
} WNDCLASSEX;

Windows API 为我们提供了多个 API 回调。我们需要决定拦截哪个消息以及在该消息泵中处理哪些信息。例如,WM_CREATE是一个 Windows 创建函数。我们应该在这里执行大部分初始化。同样,WM_DESTROY是我们需要销毁已创建对象的地方。我们需要使用 GDI 对象在窗口上绘制框和其他东西。我们还可以在窗口上显示自己的光标和图标。

创建您的第一个窗口

创建一个窗口是 Windows 编程的第一步。所有我们的精灵和其他对象都将绘制在这个窗口的顶部。有一个标准的绘制窗口的方法。因此,这部分代码将在所有使用 Windows 编程绘制东西的程序中重复。

准备工作

您需要在 Windows 机器上安装一个可用的 Visual Studio 副本。

如何做…

在这个教程中,我们将发现创建一个窗口有多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 Windows 应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 向其中添加以下代码行:

#define WIN32_LEAN_AND_MEAN

#include <windows.h>   // Include all the windows headers.
#include <windowsx.h>  // Include useful macros.
#include "resource.h"

#define WINDOW_CLASS_NAME L"WINCLASS1"

void GameLoop()
{
  //One frame of game logic occurs here...
}

LRESULT CALLBACK WindowProc(HWND _hwnd,
  UINT _msg,
  WPARAM _wparam,
  LPARAM _lparam)
{
  // This is the main message handler of the system.
  PAINTSTRUCT ps; // Used in WM_PAINT.
  HDC hdc;        // Handle to a device context.

  // What is the message?
  switch (_msg)
  {
  case WM_CREATE:
  {
            // Do initialization stuff here.

            // Return Success.
            return (0);
  }
    break;

  case WM_PAINT:
  {
           // Simply validate the window.
           hdc = BeginPaint(_hwnd, &ps);

           // You would do all your painting here...

           EndPaint(_hwnd, &ps);

           // Return Success.
           return (0);
  }
    break;

  case WM_DESTROY:
  {
             // Kill the application, this sends a WM_QUIT message.
             PostQuitMessage(0);

             // Return success.
             return (0);
  }
    break;

  default:break;
  } // End switch.

  // Process any messages that we did not take care of...

  return (DefWindowProc(_hwnd, _msg, _wparam, _lparam));
}

int WINAPI WinMain(HINSTANCE _hInstance,
  HINSTANCE _hPrevInstance,
  LPSTR _lpCmdLine,
  int _nCmdShow)
{
  WNDCLASSEX winclass; // This will hold the class we create.
  HWND hwnd;           // Generic window handle.
  MSG msg;             // Generic message.

  HCURSOR hCrosshair = LoadCursor(_hInstance, MAKEINTRESOURCE(IDC_CURSOR2));

  // First fill in the window class structure.
  winclass.cbSize = sizeof(WNDCLASSEX);
  winclass.style = CS_DBLCLKS | CS_OWNDC | CS_HREDRAW | CS_VREDRAW;
  winclass.lpfnWndProc = WindowProc;
  winclass.cbClsExtra = 0;
  winclass.cbWndExtra = 0;
  winclass.hInstance = _hInstance;
  winclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
  winclass.hCursor = LoadCursor(_hInstance, MAKEINTRESOURCE(IDC_CURSOR2));
  winclass.hbrBackground =
    static_cast<HBRUSH>(GetStockObject(WHITE_BRUSH));
  winclass.lpszMenuName = NULL;
  winclass.lpszClassName = WINDOW_CLASS_NAME;
  winclass.hIconSm = LoadIcon(NULL, IDI_APPLICATION);

  // register the window class
  if (!RegisterClassEx(&winclass))
  {
    return (0);
  }

  // create the window
  hwnd = CreateWindowEx(NULL, // Extended style.
    WINDOW_CLASS_NAME,      // Class.
    L"Packt Publishing",   // Title.
    WS_OVERLAPPEDWINDOW | WS_VISIBLE,
    0, 0,                    // Initial x,y.
    400, 400,                // Initial width, height.
    NULL,                   // Handle to parent.
    NULL,                   // Handle to menu.
    _hInstance,             // Instance of this application.
    NULL);                  // Extra creation parameters.

  if (!(hwnd))
  {
    return (0);
  }

  // Enter main event loop
  while (true)
  {
    // Test if there is a message in queue, if so get it.
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
    {
      // Test if this is a quit.
      if (msg.message == WM_QUIT)
      {
        break;
      }

      // Translate any accelerator keys.
      TranslateMessage(&msg);
      // Send the message to the window proc.
      DispatchMessage(&msg);
    }

    // Main game processing goes here.
    GameLoop(); //One frame of game logic occurs here...
  }

  // Return to Windows like this...
  return (static_cast<int>(msg.wParam));
}

它是如何工作的…

在这个例子中,我们使用了标准的 Windows API 回调。我们查询传递的消息参数,并根据此拦截并执行适当的操作。我们使用WM_PAINT消息为我们绘制窗口,使用WM_DESTROY消息销毁当前窗口。要绘制窗口,我们需要一个设备上下文的句柄,然后我们可以适当地使用BeginPaintEndPaint。在主结构中,我们需要填充 Windows 结构并指定需要加载的当前光标和图标。在这里,我们可以指定我们将使用什么颜色刷来绘制窗口。最后,指定窗口的大小并注册。之后,我们需要不断地查看消息,将其翻译,并最终将其分派到 Windows 过程中。

添加键盘和鼠标控制以及文本输出

在视频游戏中,我们最需要的一个重要的东西是与之交互的人机界面。最常见的界面设备是键盘和鼠标。因此,了解它们的工作原理以及如何检测按键和移动是非常重要的。同样重要的是要知道如何在屏幕上显示特定的文本;这对于调试和 HUD 实现非常有用。

准备工作

对于这个教程,您需要一个带有可用的 Visual Studio 副本的 Windows 机器。

如何做…

在这个教程中,我们将发现检测键盘和鼠标事件有多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 Windows 应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 向其中添加以下代码行:

#define WIN32_LEAN_AND_MEAN 
#include <windows.h> //Include all the Windows headers.
#include <windowsx.h> //Include useful macros.
#include <strstream>
#include <string>
#include <cmath>

#include "resource.h"
#include "mmsystem.h"
//also uses winmm.lib

using namespace std;

#define WINDOW_CLASS_NAME "WINCLASS1"

HINSTANCE g_hInstance;
//RECT g_rect;
const RECT* g_prect;

POINT g_pos;
int g_iMouseX;
int g_iMouseY;

bool IS_LEFT_PRESSED  = 0;
bool IS_RIGHT_PRESSED = 0;
bool IS_UP_PRESSED    = 0;
bool IS_DOWN_PRESSED  = 0;

bool IS_LMB_PRESSED = 0;
bool IS_RMB_PRESSED = 0;
bool IS_MMB_PRESSED = 0;

int LAST_KEYPRESS_ASCII = 0;

float ang = 0.0f;

template<typename T>
std::string ToString(const T& _value)
{
  std::strstream theStream;
  theStream << _value << std::ends;
  return (theStream.str());
}

//GameLoop
void GameLoop()
{
  ang += 0.0005f;
  //One frame of game logic goes here
}

//Event handling (window handle, message handle --
LRESULT CALLBACK WindowProc(HWND _hwnd, UINT _msg, WPARAM _wparam, LPARAM _lparam)
{
  //This is the main message handler of the system.
  PAINTSTRUCT ps; //Used in WM_PAINT
  HDC hdc;        // Handle to a device context.

      if ((GetAsyncKeyState(VK_LEFT) & 0x8000) == 0x8000)
      {
        IS_LEFT_PRESSED = TRUE;
      }
      else
      {
        IS_LEFT_PRESSED = FALSE;
      }

      if ((GetAsyncKeyState(VK_RIGHT) & 0x8000) == 0x8000)
      {
        IS_RIGHT_PRESSED = TRUE;
      }
      else
      {
        IS_RIGHT_PRESSED = FALSE;
      }

      if ((GetAsyncKeyState(VK_UP) & 0x8000) == 0x8000)
      {
        IS_UP_PRESSED = TRUE;
      }
      else
      {
        IS_UP_PRESSED = FALSE;
      }

      if ((GetAsyncKeyState(VK_DOWN) & 0x8000) == 0x8000)
      {
        IS_DOWN_PRESSED = TRUE;
      }
      else
      {
        IS_DOWN_PRESSED = FALSE;
      }

  //What is the message?
  switch(_msg)
  {
  case WM_CREATE:
    {
      //Do initialisation stuff here.
      //Return success.
      return(0);
    }
    break;

  case WM_PAINT:
    {
      ////Simply validate the window.
      hdc = BeginPaint(_hwnd, &ps);

      InvalidateRect( _hwnd,
        g_prect,
        FALSE);              

      string temp;
      int iYDrawPos = 15;

      COLORREF red = RGB(255,0,0);

      SetTextColor(hdc, red);

      temp = "MOUSE X: ";
      temp += ToString((g_pos.x));
      while (temp.size() < 14)
      {
        temp += " ";
      }

      TextOut(hdc,30,iYDrawPos,temp.c_str(), static_cast<int>(temp.size()));

      iYDrawPos+= 13;

      temp = "MOUSE Y: ";
      temp += ToString((g_pos.y));
      while (temp.size() < 14)
      {
        temp += " ";
      }

      TextOut(hdc,30,iYDrawPos,temp.c_str(), static_cast<int>(temp.size()));

      iYDrawPos+= 13;

      if (IS_LEFT_PRESSED == TRUE)
      {
        TextOut(hdc,30,iYDrawPos,"LEFT IS PRESSED", 24);
      }
      else
      {
        TextOut(hdc,30,iYDrawPos,"LEFT IS NOT PRESSED ", 20);
      }
      iYDrawPos+= 13;
      if (IS_RIGHT_PRESSED == TRUE)
      {
        TextOut(hdc,30,iYDrawPos,"RIGHT IS PRESSED", 25);
      }
      else
      {
        TextOut(hdc,30,iYDrawPos,"RIGHT IS NOT PRESSED ", 21);
      }
      iYDrawPos+= 13;
      if (IS_DOWN_PRESSED == TRUE)
      {
        TextOut(hdc,30,iYDrawPos,"DOWN IS PRESSED", 24);
      }
      else
      {
        TextOut(hdc,30,iYDrawPos,"DOWN IS NOT PRESSED", 20);
      }
      iYDrawPos+= 13;
      if (IS_UP_PRESSED == TRUE)
      {
        TextOut(hdc,30,iYDrawPos,"UP IS PRESSED", 22);
      }
      else
      {
        TextOut(hdc,30,iYDrawPos,"UP IS NOT PRESSED ", 18);
      }

//      TextOut(hdc, static_cast<int>(200 +(sin(ang)*200)), static_cast<int>(200 +(sin(ang)*200))) , "O", 1);

      EndPaint(_hwnd, &ps);

      //Return success.
      return(0);
    }
    break;

  case WM_DESTROY:
    {
      //Kill the application, this sends a WM_QUIT message.
      PostQuitMessage(0);

      //Return Sucess.
      return(0);
    }
    break;

  case WM_MOUSEMOVE:
    {
      GetCursorPos(&g_pos);
      // here is your coordinates
      //int x=pos.x;
      //int y=pos.y;
      return(0);
    }
  break;

  case WM_COMMAND:
    {

    }

  default:break;
  } // End switch.

  //Process any messages we didn't take care of...

  return(DefWindowProc(_hwnd, _msg, _wparam, _lparam));
}

int WINAPI WinMain(HINSTANCE _hInstance, HINSTANCE _hPrevInstance, LPSTR _lpCmdLine, int _nCmdShow)
{
  WNDCLASSEX winclass; ///This will hold the class we create
  HWND hwnd; //Generic window handle.
  MSG msg; //Generic message.

  g_hInstance = _hInstance;

  //First fill in the window class structure
  winclass.cbSize         = sizeof(WNDCLASSEX);
  winclass.style          = CS_DBLCLKS | CS_OWNDC | CS_HREDRAW | CS_VREDRAW;
  winclass.lpfnWndProc    = WindowProc;
  winclass.cbClsExtra     = 0;
  winclass.cbWndExtra     = 0;
  winclass.hInstance      = _hInstance;
  winclass.hIcon          = LoadIcon(g_hInstance, MAKEINTRESOURCE(IDI_ICON1));
  winclass.hCursor        = NULL;
  winclass.hbrBackground  = static_cast<HBRUSH>(GetStockObject(WHITE_BRUSH));
  winclass.lpszMenuName   = MAKEINTRESOURCE(IDR_MENU1);
  winclass.lpszClassName  = WINDOW_CLASS_NAME;
  winclass.hIconSm        = LoadIcon(g_hInstance, MAKEINTRESOURCE(IDI_ICON1));

  //Register the window class
  if (!RegisterClassEx(&winclass))
  { //perhaps use log manager here
    return(0);
  }

  //Create the window
  if (!(hwnd = CreateWindowEx(NULL, //Extended style.
                WINDOW_CLASS_NAME, //Class
                "Recipe4", //Title
                WS_OVERLAPPEDWINDOW | WS_VISIBLE,
                400,300, //Initial X, Y
                400,400, //Initial width, height.
                NULL, //handle to parent.
                NULL, //handle to menu
                _hInstance, //Instance of this application
                NULL))) //Extra creation parameters
  {
    return (0);
  }

  RECT rect;  
  rect.left = 0;
  rect.right = 400;
  rect.top = 0;
  rect.bottom = 400;
  g_prect = &rect;

  //Enter main event loop
  while (TRUE)
  {
    //Test if there is a message in queue, if so get it.
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
    {
      //Test if this is a quit
      if (msg.message == WM_QUIT)
      {
        break;
      }

      //Translate any accelerator keys
      TranslateMessage(&msg);
      //Send the message to the window proc.
      DispatchMessage(&msg);
    }

    //Main game processing goes here.
    GameLoop(); //One frame of game logic goes here...
  }
  //Return to Windows like this...
  return(static_cast<int>(msg.wParam));
}

它是如何工作的…

创建并注册主窗口。在回调函数中,我们使用一个名为GetAsyncKeyState(VK_KEYNAME)的函数来检测按下了哪个键。之后,我们执行按位AND操作来检查最后一次按键是否也是相同的键,并且它是否实际被按下。然后,我们有不同的布尔参数来检测按键按下的状态并存储它们。代码可能以更好的方式结构化,但这是理解如何检测按键按下的最简单方式。为了检测鼠标移动坐标,我们在WM_MOUSEMOVE中使用一个名为GetCursorPos的函数,并相应地获取屏幕上的xy坐标。最后,我们需要在屏幕上显示所有这些信息。为此,我们在屏幕上创建一个矩形。在那个矩形中,我们需要使用一个名为TextOut的函数来显示该信息。TextOut函数使用设备上下文的句柄、xy坐标以及要显示的消息。

使用 Windows 资源与 GDI

图形 设备接口GDI)允许我们使用位图、图标、光标等进行有趣的事情。如果我们没有实现其他渲染替代方案,如 OpenGL 或 DirectX,GDI 将用作渲染替代方案。

准备工作

对于这个教程,您需要一台运行 Windows 的计算机,并安装了可用的 Visual Studio 副本。

如何做…

在这个教程中,我们将发现使用 Windows GDI 加载资源有多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 Windows 应用程序。

  4. 右键单击资源文件,并从添加资源子部分添加一个新的光标。

  5. 将自动为您创建一个resource.h文件。

  6. 添加一个名为Source.cpp的源文件,并向其中添加以下代码:

#define WIN32_LEAN_AND_MEAN

#include <windows.h>   // Include all the windows headers.
#include <windowsx.h>  // Include useful macros.
#include "resource.h"

#define WINDOW_CLASS_NAME L"WINCLASS1"

void GameLoop()
{
  //One frame of game logic occurs here...
}

LRESULT CALLBACK WindowProc(HWND _hwnd,
  UINT _msg,
  WPARAM _wparam,
  LPARAM _lparam)
{
  // This is the main message handler of the system.
  PAINTSTRUCT ps; // Used in WM_PAINT.
  HDC hdc;        // Handle to a device context.

  // What is the message?
  switch (_msg)
  {
  case WM_CREATE:
  {
            // Do initialization stuff here.

            // Return Success.
            return (0);
  }
    break;

  case WM_PAINT:
  {
           // Simply validate the window.
           hdc = BeginPaint(_hwnd, &ps);

           // You would do all your painting here...

           EndPaint(_hwnd, &ps);

           // Return Success.
           return (0);
  }
    break;

  case WM_DESTROY:
  {
             // Kill the application, this sends a WM_QUIT message.
             PostQuitMessage(0);

             // Return success.
             return (0);
  }
    break;

  default:break;
  } // End switch.

  // Process any messages that we did not take care of...

  return (DefWindowProc(_hwnd, _msg, _wparam, _lparam));
}

int WINAPI WinMain(HINSTANCE _hInstance,
  HINSTANCE _hPrevInstance,
  LPSTR _lpCmdLine,
  int _nCmdShow)
{
  WNDCLASSEX winclass; // This will hold the class we create.
  HWND hwnd;           // Generic window handle.
  MSG msg;             // Generic message.

  HCURSOR hCrosshair = LoadCursor(_hInstance, MAKEINTRESOURCE(IDC_CURSOR2));

  // First fill in the window class structure.
  winclass.cbSize = sizeof(WNDCLASSEX);
  winclass.style = CS_DBLCLKS | CS_OWNDC | CS_HREDRAW | CS_VREDRAW;
  winclass.lpfnWndProc = WindowProc;
  winclass.cbClsExtra = 0;
  winclass.cbWndExtra = 0;
  winclass.hInstance = _hInstance;
  winclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
  winclass.hCursor = LoadCursor(_hInstance, MAKEINTRESOURCE(IDC_CURSOR2));
  winclass.hbrBackground =
    static_cast<HBRUSH>(GetStockObject(WHITE_BRUSH));
  winclass.lpszMenuName = NULL;
  winclass.lpszClassName = WINDOW_CLASS_NAME;
  winclass.hIconSm = LoadIcon(NULL, IDI_APPLICATION);

  // register the window class
  if (!RegisterClassEx(&winclass))
  {
    return (0);
  }

  // create the window
  hwnd = CreateWindowEx(NULL, // Extended style.
    WINDOW_CLASS_NAME,      // Class.
    L"PacktUp Publishing",   // Title.
    WS_OVERLAPPEDWINDOW | WS_VISIBLE,
    0, 0,                    // Initial x,y.
    400, 400,                // Initial width, height.
    NULL,                   // Handle to parent.
    NULL,                   // Handle to menu.
    _hInstance,             // Instance of this application.
    NULL);                  // Extra creation parameters.

  if (!(hwnd))
  {
    return (0);
  }

  // Enter main event loop
  while (true)
  {
    // Test if there is a message in queue, if so get it.
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
    {
      // Test if this is a quit.
      if (msg.message == WM_QUIT)
      {
        break;
      }

      // Translate any accelerator keys.
      TranslateMessage(&msg);
      // Send the message to the window proc.
      DispatchMessage(&msg);
    }

    // Main game processing goes here.
    GameLoop(); //One frame of game logic occurs here...
  }

  // Return to Windows like this...
  return (static_cast<int>(msg.wParam));
}

它是如何工作的…

加载新的光标是最容易实现的任务。我们需要修改以下行:

winclass.hCursor = LoadCursor(_hInstance, MAKEINTRESOURCE(IDC_CURSOR2))

如果我们在这里指定 null,将加载默认的 Windows 光标。相反,我们可以加载刚刚创建的光标。确保在resource.h中指定光标的引用名称为IDC_CURSOR2。我们可以随意命名它,但是我们需要从LoadCursor函数中调用适当的引用。MAKEINTRESOURCE使我们能够从源代码中关联到资源文件。同样,如果需要,我们可以加载多个光标并在运行时切换它们。加载其他资源,如图标和其他位图时,也使用相同的过程。当我们修改资源文件时,相应的resource.h文件必须关闭,否则将无法编辑它。同样,如果我们想手动编辑source.h文件,我们需要关闭相应的.rc或资源文件。

使用对话框和控件

对话框是 Windows 编程的强制特性之一。如果我们正在创建一个完整的应用程序,总会有一个阶段需要以某种形式使用对话框。对话框可以是编辑框、单选按钮、复选框等形式。对话框有两种形式:模态和非模态。模态对话框需要立即响应,而非模态对话框更像是浮动框,不需要立即响应。

准备工作

要完成这个教程,您需要一台运行 Windows 的计算机。您还需要在 Windows 计算机上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做…

在这个教程中,我们将发现创建对话框有多么容易。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 窗口应用程序。

  4. 创建一个新的资源文件。

  5. 选择对话框作为资源的类型。

  6. 以您想要的任何方式编辑框。

  7. 将创建一个相应的resource.h文件。

  8. 将以下代码添加到Source.cpp文件中:

#define WIN32_LEAN_AND_MEAN

#include <windows.h>   // Include all the windows headers.
#include <windowsx.h>  // Include useful macros.
#include "resource.h"
#define WINDOW_CLASS_NAME L"WINCLASS1"

void GameLoop()
{
  //One frame of game logic occurs here...
}

BOOL CALLBACK AboutDlgProc(HWND hDlg, UINT msg, WPARAM wparam, LPARAM lparam)
{
  switch (msg)
  {
    case WM_INITDIALOG:
      break;
    case WM_COMMAND:
      switch (LOWORD(wparam))
      {
      case IDOK:
        EndDialog(
          hDlg, //Handle to the dialog to end.
          0);   //Return code.
        break;
      case IDCANCEL:
        EndDialog(
          hDlg, //Handle to the dialog to end.
          0);   //Return code.
        break;
      default:
        break;
      }

  }

  return true;
}

LRESULT CALLBACK WindowProc(HWND _hwnd,
  UINT _msg,
  WPARAM _wparam,
  LPARAM _lparam)
{
  // This is the main message handler of the system.
  PAINTSTRUCT ps; // Used in WM_PAINT.
  HDC hdc;        // Handle to a device context.

  // What is the message?
  switch (_msg)
  {
  case WM_CREATE:
  {
            // Do initialization stuff here.

            // Return Success.
            return (0);
  }
    break;

  case WM_PAINT:
  {
           // Simply validate the window.
           hdc = BeginPaint(_hwnd, &ps);

           // You would do all your painting here...

           EndPaint(_hwnd, &ps);

           // Return Success.
           return (0);
  }
    break;

  case WM_DESTROY:
  {
             // Kill the application, this sends a WM_QUIT message.
             PostQuitMessage(0);

             // Return success.
             return (0);
  }
    break;

  default:break;
  } // End switch.

  // Process any messages that we did not take care of...

  return (DefWindowProc(_hwnd, _msg, _wparam, _lparam));
}

int WINAPI WinMain(HINSTANCE _hInstance,
  HINSTANCE _hPrevInstance,
  LPSTR _lpCmdLine,
  int _nCmdShow)
{
  WNDCLASSEX winclass; // This will hold the class we create.
  HWND hwnd;           // Generic window handle.
  MSG msg;             // Generic message.

  // First fill in the window class structure.
  winclass.cbSize = sizeof(WNDCLASSEX);
  winclass.style = CS_DBLCLKS | CS_OWNDC | CS_HREDRAW | CS_VREDRAW;
  winclass.lpfnWndProc = WindowProc;
  winclass.cbClsExtra = 0;
  winclass.cbWndExtra = 0;
  winclass.hInstance = _hInstance;
  winclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
  winclass.hCursor = LoadCursor(NULL, IDC_ARROW);
  winclass.hbrBackground =
    static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH));
  winclass.lpszMenuName = NULL;
  winclass.lpszClassName = WINDOW_CLASS_NAME;
  winclass.hIconSm = LoadIcon(NULL, IDI_APPLICATION);

  // register the window class
  if (!RegisterClassEx(&winclass))
  {
    return (0);
  }

  // create the window
  hwnd = CreateWindowEx(NULL, // Extended style.
    WINDOW_CLASS_NAME,      // Class.
    L"My first Window",   // Title.
    WS_OVERLAPPEDWINDOW | WS_VISIBLE,
    0, 0,                    // Initial x,y.
    1024, 980,                // Initial width, height.
    NULL,                   // Handle to parent.
    NULL,                   // Handle to menu.
    _hInstance,             // Instance of this application.
    NULL);                  // Extra creation parameters.

  if (!(hwnd))
  {
    return (0);
  }

  DialogBox(_hInstance, MAKEINTRESOURCE(IDD_DIALOG1), hwnd, AboutDlgProc);

  // Enter main event loop
  while (true)
  {
    // Test if there is a message in queue, if so get it.
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
    {
      // Test if this is a quit.
      if (msg.message == WM_QUIT)
      {
        break;
      }

      // Translate any accelerator keys.
      TranslateMessage(&msg);
      // Send the message to the window proc.
      DispatchMessage(&msg);
    }

    // Main game processing goes here.
    GameLoop(); //One frame of game logic occurs here...
  }

  // Return to Windows like this...
  return (static_cast<int>(msg.wParam));
}

它是如何工作的…

resource.h文件自动为我们创建之后,我们可以手动编辑它以适当地命名对话框。创建主窗口后,我们需要获取窗口句柄,然后调用对话框框函数,如下所示:

DialogBox(_hInstance, MAKEINTRESOURCE(IDD_DIALOG1), hwnd, AboutDlgProc)

与主窗口回调非常相似,对话框框也有自己的回调。我们需要相应地拦截消息并执行我们的操作。BOOL CALLBACK AboutDlgProc是我们可以使用的回调。我们有一个类似的初始化消息。对于我们的对话框,大多数拦截将发生在WM_COMMAND中。根据wparam参数,我们需要进行切换,以便知道我们是否点击了OK按钮还是CANCEL按钮,并采取适当的步骤。

使用精灵

要开发任何 2D 游戏,我们都需要精灵。精灵是计算机图形的元素,可以保持在屏幕上,被操纵和被动画化。GDI 允许我们使用精灵来创建我们的游戏。可能游戏中的所有资源都将是精灵,从 UI 到主要角色等等。

准备就绪

对于这个示例,您需要一台运行 Windows 的机器,并且安装了 Visual Studio 的工作副本。

如何做...

在这个示例中,我们将了解如何在游戏中使用精灵:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 创建一个新的资源类型。

  4. 选择Sprite选项作为新的资源类型。

  5. 添加以下源文件:backbuffer.h/cppClock.h/cppGame.h/.cppsprite.h/cppUtilities.h

  6. 将以下代码添加到backbuffer.h中:

#pragma once

#if !defined(__BACKBUFFER_H__)
#define __BACKBUFFER_H__

// Library Includes
#include <Windows.h>

// Local Includes

// Types

// Constants

// Prototypes
class CBackBuffer
{
  // Member Functions
public:
  CBackBuffer();
  ~CBackBuffer();

  bool Initialise(HWND _hWnd, int _iWidth, int _iHeight);

  HDC GetBFDC() const;

  int GetHeight() const;
  int GetWidth() const;

  void Clear();
  void Present();

protected:

private:
  CBackBuffer(const CBackBuffer& _kr);
  CBackBuffer& operator= (const CBackBuffer& _kr);

  // Member Variables
public:

protected:
  HWND m_hWnd;
  HDC m_hDC;
  HBITMAP m_hSurface;
  HBITMAP m_hOldObject;
  int m_iWidth;
  int m_iHeight;

private:

};

#endif    // __BACKBUFFER_H__
  1. 将以下代码添加到backbuffer.cpp中:
// Library Includes

// Local Includes

// This include
#include "BackBuffer.h"

// Static Variables

// Static Function Prototypes

// Implementation

CBackBuffer::CBackBuffer()
: m_hWnd(0)
, m_hDC(0)
, m_hSurface(0)
, m_hOldObject(0)
, m_iWidth(0)
, m_iHeight(0)
{

}

CBackBuffer::~CBackBuffer()
{
  SelectObject(m_hDC, m_hOldObject);

  DeleteObject(m_hSurface);
  DeleteObject(m_hDC);
}

bool
CBackBuffer::Initialise(HWND _hWnd, int _iWidth, int _iHeight)
{
  m_hWnd = _hWnd;

  m_iWidth = _iWidth;
  m_iHeight = _iHeight;

  HDC hWindowDC = ::GetDC(m_hWnd);

  m_hDC = CreateCompatibleDC(hWindowDC);

  m_hSurface = CreateCompatibleBitmap(hWindowDC, m_iWidth, m_iHeight);

  ReleaseDC(m_hWnd, hWindowDC);

  m_hOldObject = static_cast<HBITMAP>(SelectObject(m_hDC, m_hSurface));

  HBRUSH brushWhite = static_cast<HBRUSH>(GetStockObject(LTGRAY_BRUSH));
  HBRUSH oldBrush = static_cast<HBRUSH>(SelectObject(m_hDC, brushWhite));

  Rectangle(m_hDC, 0, 0, m_iWidth, m_iHeight);

  SelectObject(m_hDC, oldBrush);

  return (true);
}

void
CBackBuffer::Clear()
{
  HBRUSH hOldBrush = static_cast<HBRUSH>(SelectObject(GetBFDC(), GetStockObject(LTGRAY_BRUSH)));

  Rectangle(GetBFDC(), 0, 0, GetWidth(), GetHeight());

  SelectObject(GetBFDC(), hOldBrush);
}

HDC
CBackBuffer::GetBFDC() const
{
  return (m_hDC);
}

int
CBackBuffer::GetWidth() const
{
  return (m_iWidth);
}

int
CBackBuffer::GetHeight() const
{
  return (m_iHeight);
}

void
CBackBuffer::Present()
{
  HDC hWndDC = ::GetDC(m_hWnd);

  BitBlt(hWndDC, 0, 0, m_iWidth, m_iHeight, m_hDC, 0, 0, SRCCOPY);

  ReleaseDC(m_hWnd, hWndDC);
}
  1. 将以下代码添加到Clock.h中:
#pragma once

#if !defined(__CLOCK_H__)
#define __CLOCK_H__

// Library Includes

// Local Includes

// Types

// Constants

// Prototypes
class CClock
{
  // Member Functions
public:
  CClock();
  ~CClock();

  bool Initialise();

  void Process();

  float GetDeltaTick();

protected:

private:
  CClock(const CClock& _kr);
  CClock& operator= (const CClock& _kr);

  // Member Variables
public:

protected:
  float m_fTimeElapsed;
  float m_fDeltaTime;
  float m_fLastTime;
  float m_fCurrentTime;

private:

};

#endif    // __CLOCK_H__
  1. 将以下代码添加到Clock.cpp中:
// Library Includes
#include <windows.h>

// Local Includes
#include "Clock.h"

// Static Variables

// Static Function Prototypes

// Implementation

CClock::CClock()
: m_fTimeElapsed(0.0f)
, m_fDeltaTime(0.0f)
, m_fLastTime(0.0f)
, m_fCurrentTime(0.0f)
{

}

CClock::~CClock()
{

}

bool
CClock::Initialise()
{
  return (true);
}

void
CClock::Process()
{
  m_fLastTime = m_fCurrentTime;

  m_fCurrentTime = static_cast<float>(timeGetTime());

  if (m_fLastTime == 0.0f)
  {
    m_fLastTime = m_fCurrentTime;
  }

  m_fDeltaTime = m_fCurrentTime - m_fLastTime;

  m_fTimeElapsed += m_fDeltaTime;
}

float
CClock::GetDeltaTick()
{
  return (m_fDeltaTime / 1000.0f);
}
  1. 将以下代码添加到Game.h中:
#pragma once

#if !defined(__GAME_H__)
#define __GAME_H__

// Library Includes
#include <windows.h>

// Local Includes
#include "clock.h"

// Types

// Constants

// Prototypes
class CBackBuffer;

class CGame
{
  // Member Functions
public:
  ~CGame();

  bool Initialise(HINSTANCE _hInstance, HWND _hWnd, int _iWidth, int _iHeight);

  void Draw();
  void Process(float _fDeltaTick);

  void ExecuteOneFrame();

  CBackBuffer* GetBackBuffer();
  HINSTANCE GetAppInstance();
  HWND GetWindow();

  // Singleton Methods
  static CGame& GetInstance();
  static void DestroyInstance();

protected:

private:
  CGame();
  CGame(const CGame& _kr);
  CGame& operator= (const CGame& _kr);

  // Member Variables
public:

protected:
  CClock* m_pClock;

  CBackBuffer* m_pBackBuffer;

  //Application data
  HINSTANCE m_hApplicationInstance;
  HWND m_hMainWindow;

  // Singleton Instance
  static CGame* s_pGame;

private:

};

#endif    // __GAME_H__
  1. 将以下代码添加到Game.cpp中:
// Library Includes

// Local Includes
#include "Clock.h"
#include "BackBuffer.h"
#include "Utilities.h"

// This Include
#include "Game.h"

// Static Variables
CGame* CGame::s_pGame = 0;

// Static Function Prototypes

// Implementation

CGame::CGame()
: m_pClock(0)
, m_hApplicationInstance(0)
, m_hMainWindow(0)
, m_pBackBuffer(0)
{

}

CGame::~CGame()
{
  delete m_pBackBuffer;
  m_pBackBuffer = 0;

  delete m_pClock;
  m_pClock = 0;
}

bool
CGame::Initialise(HINSTANCE _hInstance, HWND _hWnd, int _iWidth, int _iHeight)
{
  m_hApplicationInstance = _hInstance;
  m_hMainWindow = _hWnd;

  m_pClock = new CClock();
  VALIDATE(m_pClock->Initialise());
  m_pClock->Process();

  m_pBackBuffer = new CBackBuffer();
  VALIDATE(m_pBackBuffer->Initialise(_hWnd, _iWidth, _iHeight));

  ShowCursor(false);

  return (true);
}

void
CGame::Draw()
{
  m_pBackBuffer->Clear();

  // Do all the game's drawing here...

  m_pBackBuffer->Present();
}

void
CGame::Process(float _fDeltaTick)
{
  // Process all the game's logic here.
}

void
CGame::ExecuteOneFrame()
{
  float fDT = m_pClock->GetDeltaTick();

  Process(fDT);
  Draw();

  m_pClock->Process();

  Sleep(1);
}

CGame&
CGame::GetInstance()
{
  if (s_pGame == 0)
  {
    s_pGame = new CGame();
  }

  return (*s_pGame);
}

void
CGame::DestroyInstance()
{
  delete s_pGame;
  s_pGame = 0;
}

CBackBuffer*
CGame::GetBackBuffer()
{
  return (m_pBackBuffer);
}

HINSTANCE
CGame::GetAppInstance()
{
  return (m_hApplicationInstance);
}

HWND
CGame::GetWindow()
{
  return (m_hMainWindow);
}
  1. 将以下代码添加到sprite.h中:
#pragma once

#if !defined(__SPRITE_H__)
#define __SPRITE_H__

// Library Includes
#include "windows.h"

// Local Includes

// Types

// Constants

// Prototypes
class CSprite
{
  // Member Functions
public:
  CSprite();
  ~CSprite();

  bool Initialise(int _iResourceID, int _iMaskResourceID);

  void Draw();
  void Process(float _fDeltaTick);

  int GetWidth() const;
  int GetHeight() const;

  int GetX() const;
  int GetY() const;
  void SetX(int _i);
  void SetY(int _i);

  void TranslateRelative(int _iX, int _iY);
  void TranslateAbsolute(int _iX, int _iY);

protected:

private:
  CSprite(const CSprite& _kr);
  CSprite& operator= (const CSprite& _kr);

  // Member Variables
public:

protected:
  //Center handle
  int m_iX;
  int m_iY;

  HBITMAP m_hSprite;
  HBITMAP m_hMask;

  BITMAP m_bitmapSprite;
  BITMAP m_bitmapMask;

  static HDC s_hSharedSpriteDC;
  static int s_iRefCount;

private:

};

#endif    // __SPRITE_H__
  1. 将以下代码添加到sprite.cpp中:
// Library Includes

// Local Includes
#include "resource.h"
#include "Game.h"
#include "BackBuffer.h"
#include "Utilities.h"

// This include
#include "Sprite.h"

// Static Variables
HDC CSprite::s_hSharedSpriteDC = 0;
int CSprite::s_iRefCount = 0;

// Static Function Prototypes

// Implementation

CSprite::CSprite()
: m_iX(0)
, m_iY(0)
{
  ++s_iRefCount;
}

CSprite::~CSprite()
{
  DeleteObject(m_hSprite);
  DeleteObject(m_hMask);

  --s_iRefCount;

  if (s_iRefCount == 0)
  {
    DeleteDC(s_hSharedSpriteDC);
    s_hSharedSpriteDC = 0;
  }
}

bool
CSprite::Initialise(int _iSpriteResourceID, int _iMaskResourceID)
{
  HINSTANCE hInstance = CGame::GetInstance().GetAppInstance();

  if (!s_hSharedSpriteDC)
  {
    s_hSharedSpriteDC = CreateCompatibleDC(NULL);
  }

  m_hSprite = LoadBitmap(hInstance, MAKEINTRESOURCE(_iSpriteResourceID));
  VALIDATE(m_hSprite);
  m_hMask = LoadBitmap(hInstance, MAKEINTRESOURCE(_iMaskResourceID));
  VALIDATE(m_hMask);

  GetObject(m_hSprite, sizeof(BITMAP), &m_bitmapSprite);
  GetObject(m_hMask, sizeof(BITMAP), &m_bitmapMask);

  return (true);
}

void
CSprite::Draw()
{
  int iW = GetWidth();
  int iH = GetHeight();

  int iX = m_iX - (iW / 2);
  int iY = m_iY - (iH / 2);

  CBackBuffer* pBackBuffer = CGame::GetInstance().GetBackBuffer();

  HGDIOBJ hOldObj = SelectObject(s_hSharedSpriteDC, m_hMask);

  BitBlt(pBackBuffer->GetBFDC(), iX, iY, iW, iH, s_hSharedSpriteDC, 0, 0, SRCAND);

  SelectObject(s_hSharedSpriteDC, m_hSprite);

  BitBlt(pBackBuffer->GetBFDC(), iX, iY, iW, iH, s_hSharedSpriteDC, 0, 0, SRCPAINT);

  SelectObject(s_hSharedSpriteDC, hOldObj);
}

void
CSprite::Process(float _fDeltaTick)
{

}

int
CSprite::GetWidth() const
{
  return (m_bitmapSprite.bmWidth);
}

int
CSprite::GetHeight() const
{
  return (m_bitmapSprite.bmHeight);
}

int
CSprite::GetX() const
{
  return (m_iX);
}

int
CSprite::GetY() const
{
  return (m_iY);
}

void
CSprite::SetX(int _i)
{
  m_iX = _i;
}

void
CSprite::SetY(int _i)
{
  m_iY = _i;
}

void
CSprite::TranslateRelative(int _iX, int _iY)
{
  m_iX += _iX;
  m_iY += _iY;
}

void
CSprite::TranslateAbsolute(int _iX, int _iY)
{
  m_iX = _iX;
  m_iY = _iY;
}
  1. 将以下代码添加到Utilities.h中:
// Library Includes

// Local Includes
#include "resource.h"
#include "Game.h"
#include "BackBuffer.h"
#include "Utilities.h"

// This include
#include "Sprite.h"

// Static Variables
HDC CSprite::s_hSharedSpriteDC = 0;
int CSprite::s_iRefCount = 0;

// Static Function Prototypes

// Implementation

CSprite::CSprite()
: m_iX(0)
, m_iY(0)
{
  ++s_iRefCount;
}

CSprite::~CSprite()
{
  DeleteObject(m_hSprite);
  DeleteObject(m_hMask);

  --s_iRefCount;

  if (s_iRefCount == 0)
  {
    DeleteDC(s_hSharedSpriteDC);
    s_hSharedSpriteDC = 0;
  }
}

bool
CSprite::Initialise(int _iSpriteResourceID, int _iMaskResourceID)
{
  HINSTANCE hInstance = CGame::GetInstance().GetAppInstance();

  if (!s_hSharedSpriteDC)
  {
    s_hSharedSpriteDC = CreateCompatibleDC(NULL);
  }

  m_hSprite = LoadBitmap(hInstance, MAKEINTRESOURCE(_iSpriteResourceID));
  VALIDATE(m_hSprite);
  m_hMask = LoadBitmap(hInstance, MAKEINTRESOURCE(_iMaskResourceID));
  VALIDATE(m_hMask);

  GetObject(m_hSprite, sizeof(BITMAP), &m_bitmapSprite);
  GetObject(m_hMask, sizeof(BITMAP), &m_bitmapMask);

  return (true);
}

void
CSprite::Draw()
{
  int iW = GetWidth();
  int iH = GetHeight();

  int iX = m_iX - (iW / 2);
  int iY = m_iY - (iH / 2);

  CBackBuffer* pBackBuffer = CGame::GetInstance().GetBackBuffer();

  HGDIOBJ hOldObj = SelectObject(s_hSharedSpriteDC, m_hMask);

  BitBlt(pBackBuffer->GetBFDC(), iX, iY, iW, iH, s_hSharedSpriteDC, 0, 0, SRCAND);

  SelectObject(s_hSharedSpriteDC, m_hSprite);

  BitBlt(pBackBuffer->GetBFDC(), iX, iY, iW, iH, s_hSharedSpriteDC, 0, 0, SRCPAINT);

  SelectObject(s_hSharedSpriteDC, hOldObj);
}

void
CSprite::Process(float _fDeltaTick)
{

}

int
CSprite::GetWidth() const
{
  return (m_bitmapSprite.bmWidth);
}

int
CSprite::GetHeight() const
{
  return (m_bitmapSprite.bmHeight);
}

int
CSprite::GetX() const
{
  return (m_iX);
}

int
CSprite::GetY() const
{
  return (m_iY);
}

void
CSprite::SetX(int _i)
{
  m_iX = _i;
}

void
CSprite::SetY(int _i)
{
  m_iY = _i;
}

void
CSprite::TranslateRelative(int _iX, int _iY)
{
  m_iX += _iX;
  m_iY += _iY;
}

void
CSprite::TranslateAbsolute(int _iX, int _iY)
{
  m_iX = _iX;
  m_iY = _iY;
}

它是如何工作的...

正如我们所知,后备缓冲用于首先绘制图像,然后我们交换缓冲区以将其呈现到屏幕上。这个过程也被称为呈现。我们创建了一个通用的backbuffer类,它帮助我们交换缓冲区。sprite类用于加载精灵并将它们推送到后备缓冲区,然后可以对它们进行处理并最终绘制到屏幕上。精灵类还提供了一些基本的实用函数,帮助我们获取精灵的宽度和高度。大多数函数只是在 Windows 自己的 API 函数和回调的顶部包装。我们还创建了一个clock类,它帮助我们跟踪时间,因为每个时间点都应该实现为时间增量的函数。如果我们不这样做,那么游戏可能会根据执行它的机器而出现波动的行为。game类用于将所有内容放在一起。它有一个backbuffer的实例,这是一个单例类,处理窗口和其他资源的上下文。

使用动画精灵

使用动画精灵是游戏编程的重要部分。除非对精灵应用某种形式的动画,否则它看起来不够真实,整个游戏沉浸感将会丧失。虽然动画可以通过多种方式实现,但我们只会看到精灵带动画的条带动画,因为这是 2D 游戏中最常用的动画形式。

准备就绪

要完成这个示例,您需要一台运行 Windows 的机器。您还需要在 Windows 机器上安装 Visual Studio 的工作副本。不需要其他先决条件。

如何做...

在这个示例中,我们将发现创建对话框有多么容易。

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择 Win32 Windows 应用程序。

  4. 添加一个AnimatedSprite.cpp文件。

  5. 将以下代码添加到Source.cpp中:

// This include
#include "AnimatedSprite.h"

// Static Variables

// Static Function Prototypes

// Implementation

CAnimatedSprite::CAnimatedSprite()
: m_fFrameSpeed(0.0f)
, m_fTimeElapsed(0.0f)
, m_iCurrentSprite(0)
{

}

CAnimatedSprite::~CAnimatedSprite()
{
  Deinitialise();
}

bool
CAnimatedSprite::Deinitialise()
{
  return (CSprite::Deinitialise());
}

bool
CAnimatedSprite::Initialise(int _iSpriteResourceID, int _iMaskResourceID)
{
  return (CSprite::Initialise(_iSpriteResourceID, _iMaskResourceID));
}

void
CAnimatedSprite::Draw()
{
  int iTopLeftX = m_vectorFrames[m_iCurrentSprite];
  int iTopLeftY = 0;

  int iW = GetFrameWidth();
  int iH = GetHeight();

  int iX = m_iX - (iW / 2);
  int iY = m_iY - (iH / 2);

  HDC hSpriteDC = hSharedSpriteDC;

  HGDIOBJ hOldObj = SelectObject(hSpriteDC, m_hMask);

  BitBlt(CGame::GetInstance().GetBackBuffer()->GetBFDC(), iX, iY, iW, iH, hSpriteDC, iTopLeftX, iTopLeftY, SRCAND);

  SelectObject(hSpriteDC, m_hSprite);

  BitBlt(CGame::GetInstance().GetBackBuffer()->GetBFDC(), iX, iY, iW, iH, hSpriteDC, iTopLeftX, iTopLeftY, SRCPAINT);

  SelectObject(hSpriteDC, hOldObj);
}

void
CAnimatedSprite::Process(float _fDeltaTick)
{
  m_fTimeElapsed += _fDeltaTick;

  if (m_fTimeElapsed >= m_fFrameSpeed &&
    m_fFrameSpeed != 0.0f)
  {
    m_fTimeElapsed = 0.0f;
    ++m_iCurrentSprite;

    if (m_iCurrentSprite >= m_vectorFrames.size())
    {
      m_iCurrentSprite = 0;
    }
  }

  CSprite::Process(_fDeltaTick);
}

void
CAnimatedSprite::AddFrame(int _iX)
{
  m_vectorFrames.push_back(_iX);
}

void
CAnimatedSprite::SetSpeed(float _fSpeed)
{
  m_fFrameSpeed = _fSpeed;
}

void
CAnimatedSprite::SetWidth(int _iW)
{
  m_iFrameWidth = _iW;
}

int
CAnimatedSprite::GetFrameWidth()
{
  return (m_iFrameWidth);
}

它是如何工作的...

为了使动画正常工作,我们需要加载一系列图像作为精灵条。图像数量越多,动画就会更流畅。对于相应数量的精灵,我们还需要加载它们的蒙版,以便它们可以一起贴图。我们需要将所有图像存储在一个向量列表中。为了使动画正常工作,所有图像必须等间距分布。在正确存储它们之后,我们可以通过控制在一定时间内要绘制多少帧/精灵来以我们想要的速度快速或缓慢地运行动画。在屏幕上绘制精灵的剩余过程保持不变。

第六章:游戏开发的设计模式

在本章中,将涵盖以下示例:

  • 使用单例设计模式

  • 使用工厂方法

  • 使用抽象工厂方法

  • 使用观察者模式

  • 使用享元模式

  • 使用策略模式

  • 使用命令设计模式

  • 使用设计模式创建高级游戏

介绍

让我们假设我们面临某个问题。过了一段时间,我们找到了解决这个问题的方法。现在,如果问题再次发生,或者类似的问题模式再次发生,我们将知道如何通过应用解决先前问题的相同原则来解决问题。设计模式就类似于这个。已经有 23 种这样的解决方案被记录下来,它们为处理与已记录的问题具有相似模式的问题提供了微妙的解决方案。这些解决方案由作者描述,更常被称为四人帮。它们不是完整的解决方案,而是可以应用于类似情况的模板或框架。然而,设计模式最大的缺点之一是,如果它们没有被正确应用,它们可能会证明是灾难性的。设计模式可以被分类为结构型、行为型或创建型。我们将只研究其中一些,在游戏开发中经常使用的。

使用单例设计模式

单例设计模式是游戏中最常用的设计模式。不幸的是,它也是游戏中最常被滥用和错误应用的设计模式。单例设计模式有一些优点,我们将讨论。然而,它也有很多严重的后果。

准备工作

要完成本示例,您需要一台运行 Windows 的计算机。您还需要在 Windows 计算机上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做…

在这个示例中,我们将看到创建单例设计模式有多么容易。我们还将看到这种设计模式的常见陷阱:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 控制台应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 将以下代码添加到其中:

#include <iostream>
#include <conio.h>

using namespace std;

class PhysicsManager
{
private:
  static bool bCheckFlag;
  static PhysicsManager *s_singleInstance;
  PhysicsManager()
  {
    //private constructor
  }
public:
  static PhysicsManager* getInstance();
  void GetCurrentGravity()const;

  ~PhysicsManager()
  {
    bCheckFlag = false;
  }
};

bool PhysicsManager::bCheckFlag = false;

PhysicsManager* PhysicsManager::s_singleInstance = NULL;

PhysicsManager* PhysicsManager::getInstance()
{
  if (!bCheckFlag)
  {
    s_singleInstance = new PhysicsManager();
    bCheckFlag = true;
    return s_singleInstance;
  }
  else
  {
    return s_singleInstance;
  }
}

void PhysicsManager::GetCurrentGravity() const
{
  //Some calculations for finding the current gravity
  //Probably a base variable which constantly gets updated with value
  //based on the environment
  cout << "Current gravity of the system is: " <<9.8<< endl;
}

int main()
{
  PhysicsManager *sc1, *sc2;
  sc1 = PhysicsManager::getInstance();
  sc1->GetCurrentGravity();
  sc2 = PhysicsManager::getInstance();
  sc2->GetCurrentGravity();

  _getch();
  return 0;
}

它是如何工作的…

开发人员希望使用单例类的主要原因是他们希望限制类的实例只有一个。在我们的示例中,我们使用了PhysicsManager类。我们将构造函数设为私有,然后分配一个静态函数来获取类的实例和其方法的句柄。我们还使用一个布尔值来检查是否已经创建了一个实例。如果是,我们不分配新实例。如果没有,我们分配一个新实例并调用相应的方法。

尽管这种设计模式看起来很聪明,但它有很多缺陷,因此在游戏设计中应尽量避免使用。首先,它是一个全局变量。这本身就是不好的。全局变量保存在全局池中,可以从任何地方访问。其次,这鼓励了糟糕的耦合,可能会出现在代码中。第三,它不友好并发。想象一下有多个线程,每个线程都可以访问这个全局变量。这是灾难的开始,死锁会发生。最后,新程序员最常犯的一个错误是为所有事物创建管理器,然后将管理器设为单例。事实上,我们可以通过有效地使用面向对象编程和引用来避免创建管理器。

上述代码显示了一种懒惰初始化单例的值,因此可以改进。然而,本示例中描述的所有基本问题仍将存在。

使用工厂方法

工厂本质上是创建其他类型对象的仓库。在工厂方法设计模式中,创建新类型的对象,比如敌人或建筑,是通过接口和子类决定需要实例化哪个类来实现的。这也是游戏中常用的模式,非常有用。

准备工作

您需要在 Windows 机器上安装一个可用的 Visual Studio 副本。

如何做…

在这个示例中,我们将发现实现工厂方法设计模式是多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 控制台应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 添加以下代码行:

#include <iostream>
#include <conio.h>
#include <vector>

using namespace std;

class IBuilding
{
public:
  virtual void TotalHealth() = 0;
};

class Barracks : public IBuilding
{
public:
  void TotalHealth()
  {
    cout << "Health of Barrack is :" << 100;
  }
};
class Temple : public IBuilding
{
public:
  void TotalHealth()
  {
    cout << "Health of Temple is :" << 75;
  }
};
class Farmhouse : public IBuilding
{
public:
  void TotalHealth()
  {
    cout << "Health of Farmhouse is :" << 50;
  }
};

int main()
{
  vector<IBuilding*> BuildingTypes;
  int choice;

  cout << "Specify the different building types in your village" << endl;
  while (true)
  {

    cout << "Barracks(1) Temple(2) Farmhouse(3) Go(0): ";
    cin >> choice;
    if (choice == 0)
      break;
    else if (choice == 1)
      BuildingTypes.push_back(new Barracks);
    else if (choice == 2)
      BuildingTypes.push_back(new Temple);
    else
      BuildingTypes.push_back(new Farmhouse);
  }
  cout << endl;
  cout << "There are total " << BuildingTypes.size() << " buildings" << endl;
  for (int i = 0; i < BuildingTypes.size(); i++)
  {
    BuildingTypes[i]->TotalHealth();
    cout << endl;
  }

  for (int i = 0; i < BuildingTypes.size(); i++)
    delete BuildingTypes[i];

  _getch();
}

工作原理…

在这个例子中,我们创建了一个Building接口,其中有一个纯虚函数TotalHealth。这意味着所有派生类必须重写这个函数。因此,我们可以保证所有的建筑都有这个属性。我们可以通过添加更多的属性来扩展这个结构,比如生命值、总存储容量、村民生产速度等,根据游戏的性质和设计。派生类有它们自己的TotalHealth实现。它们也被命名为反映它们是什么类型的建筑。这种设计模式的最大优势是,客户端只需要一个对基础接口的引用。之后,我们可以在运行时创建我们需要的建筑类型。我们将这些建筑类型存储在一个向量列表中,最后使用循环来显示内容。由于我们有引用IBuilding*,我们可以在运行时分配任何新的派生类。无需为所有派生类创建引用,比如Temple*等等。下面的屏幕截图显示了用户定义村庄的输出:

工作原理…

使用抽象工厂方法

抽象工厂是创建设计模式的一部分。这是创建对象的最佳方式之一,也是游戏中常见的重复设计模式之一。它就像是一个工厂的工厂。它使用一个接口来创建一个工厂。工厂负责创建对象,而不指定它们的类类型。工厂基于工厂方法设计模式生成这些对象。然而,有人认为抽象工厂方法也可以使用原型设计模式来实现。

准备工作

您需要在 Windows 机器上安装一个可用的 Visual Studio 副本。

如何做…

在这个示例中,我们将发现实现抽象工厂模式是多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 控制台应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 添加以下代码行:

#include <iostream>
#include <conio.h>
#include <string>

using namespace std;

//IFast interface
class IFast
{
public:
  virtual std::string Name() = 0;
};

//ISlow interface
class ISlow
{
public:
  virtual std::string Name() = 0;
};
class Rapter : public ISlow
{
public:
  std::string Name()
  {
    return "Rapter";
  }
};

class Cocumbi : public IFast
{
public:
  std::string Name()
  {
    return "Cocumbi";
  }
};
   . . . . .// Similar classes can be written here
class AEnemyFactory
{
public:
  enum Enemy_Factories
  {
    Land,
    Air,
    Water
  };

  virtual IFast* GetFast() = 0;
  virtual ISlow* GetSlow() = 0;

  static AEnemyFactory* CreateFactory(Enemy_Factories factory);
};

class LandFactory : public AEnemyFactory
{
public:
  IFast* GetFast()
  {
    return new Cocumbi();
  }

  ISlow* GetSlow()
  {
    return new Marzel();
  }
};

class AirFactory : public AEnemyFactory
{
public:
  IFast* GetFast()
  {
    return new Zybgry();
  }

  ISlow* GetSlow()
  {
    return new Bungindi();
  }
};

class WaterFactory : public AEnemyFactory
{
public:
  IFast* GetFast()
  {
    return new Manama();
  }

  ISlow* GetSlow()
  {
    return new Pokili();
  }
};

//CPP File
AEnemyFactory* AEnemyFactory::CreateFactory(Enemy_Factories factory)
{
  if (factory == Enemy_Factories::Land)
  {
    return new LandFactory();
  }
  else if (factory == Enemy_Factories::Air)
  {
    return new AirFactory();
  }
  else if (factory == Enemy_Factories::Water)
  {
    return new WaterFactory();
  }
}

int main(int argc, char* argv[])
{
  AEnemyFactory *factory = AEnemyFactory::CreateFactory
    (AEnemyFactory::Enemy_Factories::Land);

  cout << "Slow enemy of Land: " << factory->GetSlow()->Name() << "\n";
  delete factory->GetSlow();
  cout << "Fast enemy of Land: " << factory->GetFast()->Name() << "\n";
  delete factory->GetFast();
  delete factory;
  getchar();

  factory = AEnemyFactory::CreateFactory(AEnemyFactory::Enemy_Factories::Air);
  cout << "Slow enemy of Air: " << factory->GetSlow()->Name() << "\n";
  delete factory->GetSlow();
  cout << "Fast enemy of Air: " << factory->GetFast()->Name() << "\n";
  delete factory->GetFast();
  delete factory;
  getchar();

  factory = AEnemyFactory::CreateFactory(AEnemyFactory::Enemy_Factories::Water);
  cout << "Slow enemy of Water: " << factory->GetSlow()->Name() << "\n";
  delete factory->GetSlow();
  cout << "Fast enemy of Water: " << factory->GetFast()->Name() << "\n";
  delete factory->GetFast();
  getchar();

  return 0;
}

工作原理…

在这个例子中,我们创建了两个接口,分别是IFastISlow。之后,我们创建了几个敌人,并决定它们是快还是慢。最后,我们创建了一个抽象类,其中有两个虚函数来获取快速敌人和慢速敌人。这意味着所有的派生类必须重写并有自己的实现这些函数。因此,实际上我们创建了一个工厂的工厂。我们从抽象类创建的陆地、空中和水中敌人工厂都引用了慢和快的两个接口。因此,陆地、水域和空中本身也是工厂。

因此,从客户端,我们可以请求一个快速的陆地敌人或一个慢速的水域敌人,然后我们可以得到适当的敌人显示给我们。如下面的屏幕截图所示,我们可以得到如下显示的输出:

工作原理…

使用观察者模式

观察者设计模式在游戏中并不常用,但游戏开发人员应该更经常地使用它,因为这是处理通知的一种非常聪明的方式。在观察者设计模式中,一个组件与其他组件维护一对多的关系。这意味着当主要组件发生变化时,所有依赖组件也会更新。想象一个物理系统。我们希望enemy1enemy2在物理系统更新时立即更新,所以我们应该使用这种模式。

准备工作

为此食谱,您需要一台装有 Visual Studio 的 Windows 机器。

如何做…

在这个食谱中,我们将找出实现观察者模式有多容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 Windows 应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 向其添加以下代码行:

#include <iostream>
#include <vector>
#include <conio.h>

using namespace std;

class PhysicsSystem {

  vector < class Observer * > views;
  int value;
public:
  void attach(Observer *obs) {
    views.push_back(obs);
  }
  void setVal(int val) {
    value = val;
    notify();
  }
  int getVal() {
    return value;
  }
  void notify();
};

class Observer {

  PhysicsSystem *_attribute;
  int iScalarMultiplier;
public:
  Observer(PhysicsSystem *attribute, int value)
  {
    If(attribute)
{

_attribute = attribute;
}
    iScalarMultiplier = value;

    _attribute->attach(this);
  }
  virtual void update() = 0;
protected:
  PhysicsSystem *getPhysicsSystem() {
    return _attribute;
  }
  int getvalue()
  {
    return iScalarMultiplier;
  }
};

void PhysicsSystem::notify() {

  for (int i = 0; i < views.size(); i++)
    views[i]->update();
}

class PlayerObserver : public Observer {
public:
  PlayerObserver(PhysicsSystem *attribute, int value) : Observer(attribute, value){}
  void update() {

    int v = getPhysicsSystem()->getVal(), d = getvalue();
    cout << "Player is dependent on the Physics system" << endl;
    cout << "Player new impulse value is " << v / d << endl << endl;
  }
};

class AIObserver : public Observer {
public:
  AIObserver(PhysicsSystem *attribute, int value) : Observer(attribute, value){}
  void update() {
    int v = getPhysicsSystem()->getVal(), d = getvalue();
    cout << "AI is dependent on the Physics system" << endl;
    cout << "AI new impulse value is " << v % d << endl << endl;
  }
};

int main() {
  PhysicsSystem subj;

  PlayerObserver valueObs1(&subj, 4);
  AIObserver attributeObs3(&subj, 3);
  subj.setVal(100);

  _getch();
}

它是如何工作的…

在这个例子中,我们创建了一个不断更新其值的物理系统。依赖于物理系统的其他组件必须附加到它,这样它们就会在物理系统更新时得到通知。

我们创建的物理系统持有一个向量列表,其中包含所有正在观察的组件。除此之外,它包含了获取当前值或为其设置值的方法。它还包含一个方法,一旦物理系统中的值发生变化,就通知所有依赖组件。Observer类包含对物理系统的引用,以及一个纯虚函数用于更新,派生类必须覆盖这个函数。PlayerObserverAIObserver类可以从这个类派生,并根据物理系统中的变化实现它们自己的冲量。除非它们从中分离出来,否则 AI 和玩家系统将不断地从物理系统接收更新。

这是一个非常有用的模式,在游戏中有很多实现。下面的屏幕截图显示了典型输出的样子:

它是如何工作的…

使用飞行权重模式

飞行权重设计模式在我们想要减少用于创建对象的内存量时大多被使用。当我们想要创建数百次或数千次的东西时,通常会使用这种模式。具有森林结构的游戏经常使用这种设计模式。这种设计模式属于结构设计类别。在这种模式中,对象,比如树对象,被分成两部分,一部分取决于对象的状态,一部分是独立的。独立部分存储在飞行权重对象中,而依赖部分由客户端处理,并在调用时发送到飞行权重对象。

准备工作

为此食谱,您需要一台装有 Visual Studio 的 Windows 机器。

如何做…

在这个食谱中,我们将找出实现飞行权重模式有多容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 控制台应用程序。

  4. 添加一个名为Source.cpp的源文件。

  5. 向其添加以下代码行:

#include <iostream>
#include <string>
#include <map>
#include <conio.h>

using namespace std;

class TreeType
{
public:
  virtual void Display(int size) = 0;

protected:
  //Some Model we need to assign. For relevance we are substituting this with a character symbol
  char symbol_;
  int width_;
  int height_;
  float color_;

  int Size_;
};

class TreeTypeA : public TreeType
{
public:
  TreeTypeA()
  {
    symbol_ = 'A';
    width_ = 94;
    height_ = 135;
    color_ = 0;

    Size_ = 0;
  }
  void Display(int size)
  {
    Size_ = size;
    cout << "Size of " << symbol_ << " is :" << Size_ << endl;
  }
};

class TreeTypeB : public TreeType
{
public:
  TreeTypeB()
  {
    symbol_ = 'B';
    width_ = 70;
    height_ = 25;
    color_ = 0;

    Size_ = 0;
  }
  void Display(int size)
  {
    Size_ = size;
    cout << "Size of " << symbol_ << " is :" << Size_ << endl;
  }
};

class TreeTypeZ : public TreeType
{
public:
  TreeTypeZ()
  {
    symbol_ = 'Z';
    width_ = 20;
    height_ = 40;
    color_ = 1;

    Size_ = 0;
  }
  void Display(int size)
  {
    Size_ = size;
    cout <<"Size of " << symbol_ << " is :" << Size_ << endl;
  }
};

// The 'FlyweightFactory' class
class TreeTypeFactory
{
public:
  virtual ~TreeTypeFactory()
  {
    while (!TreeTypes_.empty())
    {
      map<char, TreeType*>::iterator it = TreeTypes_.begin();
      delete it->second;
      TreeTypes_.erase(it);
    }
  }
  TreeType* GetTreeType(char key)
  {
    TreeType* TreeType = NULL;
    if (TreeTypes_.find(key) != TreeTypes_.end())
    {
      TreeType = TreeTypes_[key];
    }
    else
    {
      switch (key)
      {
      case 'A':
        TreeType = new TreeTypeA();
        break;
      case 'B':
        TreeType = new TreeTypeB();
        break;
        //…
      case 'Z':
        TreeType = new TreeTypeZ();
        break;
      default:
        cout << "Not Implemented" << endl;
        throw("Not Implemented");
      }
      TreeTypes_[key] = TreeType;
    }
    return TreeType;
  }
private:
  map<char, TreeType*> TreeTypes_;
};

//The Main method
int main()
{
  string forestType = "ZAZZBAZZBZZAZZ";
  const char* chars = forestType.c_str();

  TreeTypeFactory* factory = new TreeTypeFactory;

  // extrinsic state
  int size = 10;

  // For each TreeType use a flyweight object
  for (size_t i = 0; i < forestType.length(); i++)
  {
    size++;
    TreeType* TreeType = factory->GetTreeType(chars[i]);
    TreeType->Display(size);
  }

  //Clean memory
  delete factory;

  _getch();
  return 0;
}

它是如何工作的…

在这个例子中,我们创建了一个森林。飞行权重模式的基本原则被应用,其中结构的一部分是共享的,而另一部分由客户端决定。在这个例子中,除了大小(这可以是任何东西,大小只是选择作为一个例子),每个其他属性都被选择为共享。我们创建一个包含所有属性的树型接口。然后我们有派生类,它们有它们的属性被覆盖和一个方法来设置size属性。我们可以有多个这样的树。一般来说,树的种类越多,森林看起来就越详细。假设我们有 10 种不同类型的树,所以我们需要有 10 个不同的类从接口派生,并有一个方法从客户端大小分配size属性。

最后,我们有了树工厂,它在运行时为每棵树分配。我们创建一个对接口的引用,就像任何工厂模式一样。但是,我们不是直接实例化一个新对象,而是首先检查地图,看看树的属性是否已经存在。如果没有,我们分配一个新对象,并将属性推送到地图中。因此,下次请求类似已经分配的树结构的树时,我们可以从地图中共享属性。最后,从客户端,我们创建一个森林类型的文档,然后将其提供给工厂,它使用文档中列出的树为我们生成森林。由于大多数属性是共享的,内存占用非常低。以下屏幕截图显示了森林是如何创建的:

它是如何工作的…

使用策略模式

策略设计模式是设计代码的一种非常聪明的方式。在游戏中,这主要用于 AI 组件。在这种模式中,我们定义了大量的算法,并且所有这些算法都具有一个共同的接口签名。然后在运行时,我们可以更改算法的客户端。因此,实际上,这些算法是独立于客户端的。

准备工作

要完成这个示例,您需要一台运行 Windows 的机器。您还需要在 Windows 机器上安装一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做…

在这个示例中,我们将发现实现策略模式是多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目。

  3. 选择一个 Win32 控制台应用程序。

  4. 添加一个Source.cpp文件。

  5. 将以下代码行添加到其中:

#include <iostream>
#include <conio.h>

using namespace std;

class SpecialPower
{
public:
  virtual void power() = 0;
};

class Fire : public SpecialPower
{
public:
  void power()
  {
    cout << "My power is fire" << endl;
  }
};

class Invisibility : public SpecialPower
{
public:
  void power()
  {
    cout << "My power is invisibility" << endl;
  }
};

class FlyBehaviour
{
public:
  virtual void fly() = 0; 
};

class FlyWithWings : public FlyBehaviour
{
public:
  void fly()
  {
    cout << "I can fly" << endl;
  }
};

class FlyNoWay : public FlyBehaviour
{
public:
  void fly()
  {
    cout << "I can't fly!" << endl;
  }
};

class FlyWithRocket : public FlyBehaviour
{
public:
  void fly()
  {
    cout << "I have a jetpack" << endl;
  }
};

class Enemy
{

public:

  SpecialPower *specialPower;
  FlyBehaviour   *flyBehaviour;

  void performPower()
  {
    specialPower->power();
  }

  void setSpecialPower(SpecialPower *qb)
  {
    cout << "Changing special power..." << endl;
    specialPower = qb;
  }

  void performFly()
  {
    flyBehaviour->fly();
  }

  void setFlyBehaviour(FlyBehaviour *fb)
  {
    cout << "Changing fly behaviour..." << endl;
    flyBehaviour = fb;
  }

  void floatAround()
  {
    cout << "I can float." << endl;
  }

  virtual void display() = 0; // Make this an abstract class by having a pure virtual function

};

class Dragon : public Enemy
{
public:
  Dragon()
  {
    specialPower = new Fire();
    flyBehaviour = new FlyWithWings();
  }

  void display()
  {
    cout << "I'm a dragon" << endl;
  }

};

class Soldier : public Enemy
{
public:
  Soldier()
  {
    specialPower = new Invisibility();
    flyBehaviour = new FlyNoWay();
  }

  void display()
  {
    cout << "I'm a soldier" << endl;
  }
};

int main()
{
  Enemy *dragon = new Dragon();
  dragon->display();
  dragon->floatAround();
  dragon->performFly();
  dragon->performPower();

  cout << endl << endl;

  Enemy *soldier = new Soldier();
  soldier->display();
  soldier->floatAround();
  soldier->performFly();
  soldier->setFlyBehaviour(new FlyWithRocket);
  soldier->performFly();
  soldier->performPower();
  soldier->setSpecialPower(new Fire);
  soldier->performPower();

  _getch();
  return 0;
}

它是如何工作的…

在这个例子中,我们为敌人可能具有的不同属性创建了不同的接口。因此,由于我们知道特殊能力是每种敌人类型都会具有的属性,我们创建了一个名为SpecialPower的接口,然后从中派生了两个类,分别是FireInvisibility。我们可以添加任意多的特殊能力,我们只需要创建一个新的类,并从特殊能力接口派生。同样,所有的敌人类型都应该有一个飞行属性。它们要么飞行,要么不飞行,要么借助喷气背包飞行。

因此,我们创建了一个FlyBehaviour接口,并让不同的飞行类型类从中派生。之后,我们创建了一个敌人类型的抽象类,其中包含了这两个接口作为引用。因此,任何派生类都可以决定需要什么飞行类型和特殊能力。这也使我们能够在运行时更改特殊能力和飞行能力。下面的屏幕截图显示了这种设计模式的简要示例:

它是如何工作的…

使用命令设计模式

命令设计模式通常涉及将命令封装为对象。这在游戏网络中被广泛使用,其中玩家的移动被发送为作为命令运行的对象。命令设计模式中要记住的四个主要点是客户端、调用者、接收者和命令。命令对象了解接收者对象。接收者在接收到命令后执行工作。调用者执行命令,而不知道是谁发送了命令。客户端控制调用者,并决定在哪个阶段执行哪些命令。

准备工作

对于这个示例,您需要一台安装有 Visual Studio 的 Windows 机器。

如何做…

在这个示例中,我们将发现实现命令模式是多么容易:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目控制台应用程序。

  3. 添加以下代码行:

#include <iostream>
#include <conio.h>

using namespace std;
class NetworkProtocolCommand
{
public:
  virtual void PerformAction() = 0;
};
class ServerReceiver
{
public:
  void Action()
  {
    cout << "Network Protocol Command received" <<endl;

  }
};
class ClientInvoker
{
  NetworkProtocolCommand *m_NetworkProtocolCommand;

public:
  ClientInvoker(NetworkProtocolCommand *cmd = 0) : m_NetworkProtocolCommand(cmd)
  {
  }

  void SetCommad(NetworkProtocolCommand *cmd)
  {
    m_NetworkProtocolCommand = cmd;
  }

  void Invoke()
  {
    if (0 != m_NetworkProtocolCommand)
    {
      m_NetworkProtocolCommand->PerformAction();
    }
  }
};

class MyNetworkProtocolCommand : public NetworkProtocolCommand
{
  ServerReceiver *m_ServerReceiver;

public:
  MyNetworkProtocolCommand(ServerReceiver *rcv = 0) : m_ServerReceiver(rcv)
  {
  }

  void SetServerReceiver(ServerReceiver *rcv)
  {
    m_ServerReceiver = rcv;
  }

  virtual void PerformAction()
  {
    if (0 != m_ServerReceiver)
    {
      m_ServerReceiver->Action();
    }
  }
};

int main()
{
  ServerReceiver r;
  MyNetworkProtocolCommand cmd(&r);
  ClientInvoker caller(&cmd);
  caller.Invoke();

  _getch();
  return 0;
}

它是如何工作的…

正如我们在这个例子中所看到的,我们已经设置了一个接口,通过网络协议命令发送信息。从该接口,我们可以派生多个子实例用于客户端。然后我们需要创建一个服务器接收器,用于接收来自客户端的命令。我们还需要创建一个客户端调用者,用于调用命令。该类中还应该有对网络协议命令的引用。最后,从客户端,我们需要创建一个服务器实例,并将该实例附加到我们创建的网络协议命令的子对象上。然后我们使用客户端调用者来调用命令,并通过网络协议命令将其发送到接收器。这确保了抽象的维护,并且整个消息都是通过数据包发送的。以下截图显示了部分过程:

工作原理…

使用设计模式创建高级游戏

在了解基本设计模式之后,将它们结合起来创建一个好的游戏是很重要的。需要多年的实践才能最终理解哪种架构适合游戏结构。我们经常不得不同时使用几种设计模式来编写可以应用于游戏的清晰代码。工厂模式可能是您最常用的设计模式,但这纯粹是我个人经验的一个轶事参考。

准备工作

对于这个示例,您需要一台安装了 Visual Studio 的 Windows 机器。

如何做…

在这个示例中,我们将发现如何轻松地结合设计模式来创建一个游戏:

  1. 打开 Visual Studio。

  2. 创建一个新的 C++项目控制台应用程序。

  3. 添加以下代码行:

#ifndef _ISPEED_H
#define _SPEED_H

class ISpeed
{
  public:
    virtual void speed() = 0;

};

#end
#ifndef _ISPECIALPOWER
#define _ISPECIALPOWER
class ISpecialPower
{
public:
  virtual void power() = 0;
};
#endif

#ifndef _IENEMY_H
#define _IENEMY_H

#include "ISpecialPower.h"
#include "ISpeed.h"

class IEnemy
{

public:

  ISpecialPower *specialPower;
  ISpeed   *speed;

  void performPower()
  {
    specialPower->power();
  }

  void setSpecialPower(ISpecialPower *qb)
  {

  }

};
#endif
#include <iostream>
#include "ISpeed.h"

#pragma once
class HighSpeed :public ISpeed
{
public:
  HighSpeed();
  ~HighSpeed();
};

#include "IEnemy.h"

class Invisibility;
class HighSpeed;

class Soldier : public IEnemy
{
public:
  Soldier()
  {

  }

};

工作原理…

上面的代码只是代码的一小部分。假设我们需要制作一个游戏,其中有不同类的敌人,以及不同类型的能力,以及一些特殊的增益或增强。对此的一种方法是将所有能力和特殊增益视为从接口派生的单独类。因此,我们需要为速度创建一个接口,它可以从HighSpeed类派生,依此类推。同样,我们可以创建一个SpecialPower接口,它可以由Fire类等派生。我们需要为角色可能具有的所有属性组创建接口。最后,我们需要创建一个角色(IEnemy)的接口,它由SoldierArcherGrenadier类等派生。IEnemy接口还应该持有对所有其他接口的引用,比如ISpecialPowerISpeed。通过这种方式,IEnemy的子类可以决定他们想要拥有什么能力和速度。这类似于策略设计模式。如果我们想要将敌人分组到类型中,比如陆地敌人和空中敌人,我们可以进一步改进这个结构。在这种情况下,我们要么为IType创建一个接口,并让LandAir类从中派生,要么我们可以创建一个工厂,根据客户端请求的类型为我们创建敌人类型。创建的每种敌人类型也将是从IEnemy派生的类,因此它也将具有对先前接口的引用。随着游戏的复杂性增加,我们可以添加更多的设计模式来帮助我们。

第七章:组织和备份

在本章中,将涵盖以下内容:

  • 版本控制

  • 安装一个版本控制客户端

  • 选择一个主机来保存您的数据

  • 为您的代码添加源代码控制-提交和更新您的代码

  • 解决冲突

  • 创建一个分支

介绍

假设我们需要在一个有很多开发人员的项目上工作。如果每个开发人员都在不同的源文件上工作,一种(相当可怕的)工作方式是通过电子邮件或 FTP 客户端获取新更新的源文件,并将其替换到您的项目中。现在如果开发人员,包括您自己,都在同一个源文件上工作。我们仍然可以遵循这种可怕的方式,并将我们已经工作过的部分添加到我们通过 FTP 收到的文件中,但很快这将变得非常繁琐,几乎不可能工作。因此,我们有一个将文件保存到某个中央仓库或分布式仓库的系统,然后有手段更新和发送代码,以便每个开发人员都使用最新的副本。有各种各样的方法来执行这个操作,通常被称为对代码进行版本控制。

版本控制

修订控制是跨开发人员共享文件的一种非常有效的方式。有各种版本控制系统,每种系统都有其优点和缺点。我们将看看目前最流行的三种版本控制系统。

准备工作

要完成这个教程,您需要一台运行 Windows 的计算机。不需要其他先决条件。

如何做...

在这个教程中,我们将看到可用于我们的不同类型的源代码控制:

  1. 转到此链接并下载一个 SVN 客户端:tortoisesvn.net/downloads.html

  2. 转到此链接并下载 GIT 客户端:desktop.github.com

  3. 转到此链接并下载一个 Mercurial 客户端:tortoisehg.bitbucket.org/download/index.html

它是如何工作的...

有各种类型的 SVN 客户端可供我们使用。每种都有其优点和缺点。

SVN 具有许多功能,可以解决与原子操作和源文件损坏相关的问题。它是免费和开源的。它有许多不同 IDE 的插件。然而,这个工具的一个主要缺点是它在操作中相对非常慢。

GIT 主要是为 Linux 而设计的,但它大大提高了操作速度。它也可以在 UNIX 系统上运行。它具有廉价的分支操作,但与 Linux 相比,它对单个开发人员的 Windows 支持有限。然而,GIT 非常受欢迎,许多人更喜欢 GIT 而不是 SVN。

安装一个版本控制客户端

有很多版本控制客户端。然而,我们将看看一个 SVN 客户端。Tortoise SVN 是迄今为止最受 SVN 用户欢迎的。尽管 GIT 是另一个非常受欢迎的系统,但我们将在这个教程中看看 Tortoise SVN。Tortoise SVN 提供了一个非常友好和直观的界面,因此即使是初学者也很容易掌握。在几个小时内,一个完全的新手就可以理解使用 Tortoise SVN 的基础知识。

准备工作

您需要一台 Windows 机器。不需要其他先决条件。

如何做...

在这个教程中,我们将发现安装和使用 Tortoise SVN 有多么容易:

  1. 转到此链接:tortoisesvn.net/downloads.html

  2. 根据您使用的是 32 位还是 64 位 Windows 机器,下载并安装正确的版本。

  3. 在您的计算机上创建一个新文件夹。

  4. 右键单击文件夹。

  5. 检查一个名为SVN Checkout…的新命令现在可以使用。

它是如何工作的...

在我们转到下载站点并安装软件包后,它将安装在系统上,并添加了许多 shell 和内核命令。因此,当我们右键单击文件夹时,“SVN Checkout…”命令现在被添加为任何新文件夹的属性。还有另一个名为Tortoise SVN的命令可供我们使用,它还有更多命令。在我们检出项目后,“SVN Checkout…”将被替换为SVN UpdateSVN Commit。我们只需要确保根据我们使用的操作系统版本向计算机添加了正确的安装程序。

选择托管数据的主机

在我们开始对我们的代码进行版本控制之前,我们需要决定将代码文件保存到哪里。有很多种方法可以做到这一点,但我们将讨论两种最流行的方法。第一种方法是将文件保存在本地,并将个人计算机视为托管数据的服务器。第二种方法是使用云服务来为我们托管数据文件。

准备工作

您需要一个可用的 Windows 计算机。

如何做...

在这个教程中,我们将了解如何轻松地在本地或云端托管文件。

对于保存在云端的文件,请按照以下步骤操作:

  1. 转到以下链接:xp-dev.com

  2. 转到计划并选择最适合您需求的计划。还有一个免费的 10MB 计划。

  3. 选择计划后,您将被重定向以为当前项目创建名称。

  4. 新项目现在将显示在仪表板上。您可以根据您的计划创建多个项目。

  5. 单击一个项目。这应该打开更多选项卡。目前最重要的是:

  • 存储库

  • 项目跟踪

  • 活动

  • 设置

  1. 单击存储库以创建一个新的存储库。

  2. 生成的链接现在可以用于对项目中的文件进行版本控制。

  3. 要向项目添加用户,请单击设置并邀请用户加入项目。

对于保存在本地服务器上的文件:

  1. 将新项目或空项目保存在您的计算机上。

  2. 从这里下载Visual SVN Serverwww.visualsvn.com/server/

  3. 安装软件。

  4. 然后从现有项目创建一个项目。

  5. 您的项目现在已准备好进行版本控制。

  6. 要添加用户,请单击用户并添加用户名和密码。

它是如何工作的...

当我们在xp-dev上创建项目时,实际上是xp-dev根据我们选择的计划在其服务器上为我们创建了一个云空间。之后,对于文件的每次迭代,它都会在服务器上保存一个副本。在仪表板上,一旦我们创建一个存储库,我们就可以创建一个新的存储库,生成的 URL 现在将是项目的 URL。通过这种方式,我们可以恢复到任何迭代或恢复文件,如果我们误删了它。当我们提交文件时,文件的新副本现在保存在服务器上。当我们更新项目时,服务器上的最新版本现在被推送到您的本地计算机。通过这种方式,xp-dev保存了所有更新和提交的整个活动历史。系统的缺点是,如果xp-dev客户端关闭,那么所有云服务也将关闭。因此,由于您无法进行任何更新或提交,项目将会受到影响。

托管的另一种方法是使用您自己的本地计算机。Visual SVN Server 基本上将您的计算机变成了服务器。之后,该过程与xp-dev处理所有更新和提交的方式非常相似。

我们还可以从亚马逊或 Azure 那里获取一些空间,并将该空间用作服务器。在这种情况下,步骤与第二种方法(本地服务器)非常相似。登录到亚马逊空间或 Azure 空间后,将其视为您的计算机,然后重复本地服务器的步骤。

添加源代码控制-提交和更新您的代码。

在协作项目或个人工作时,您可以对文件执行的最重要的操作之一是添加源代码控制。这样做的最大优势是文件始终有备份和版本控制。假设您进行了一些本地更改,并且发生了许多崩溃。由于这些崩溃,您将怎么做?一种选择是追溯您的步骤并将它们改回以前的状态。这是一个浪费时间的过程,也存在风险。如果您的文件有备份,您只需要对特定的修订执行还原操作,代码就会恢复到那一点。同样,如果我们错误地删除了一个文件,我们总是可以更新项目,它将从服务器中拉取最新的文件。

准备工作

对于这个教程,您将需要一台 Windows 机器和安装了 SVN 客户端的版本。数据托管服务现在应该已经集成,并且您应该有一个 URL。不需要其他先决条件。

如何做...

在这个教程中,我们将找出添加源代码控制有多么容易:

  1. 在机器上创建一个新文件夹。

  2. 将其重命名为您想要的任何名称。

  3. 右键单击并检查 SVN 命令是否显示为其中一个选项。

  4. 单击SVN Checkout。使用您从xp-dev或您的本地服务器或云服务器收到的 URL。

  5. 将文件添加到新文件夹中。它可以是任何格式。

  6. 右键单击文件,然后选择Tortoise SVN | 添加

  7. 转到根文件夹,然后选择SVN | 提交

  8. 删除文件。

  9. 转到SVN | 更新

  10. 对文件进行一些更改。

  11. 选择SVN | 提交

  12. 然后选择Tortoise SVN,然后还原到此修订版(修订版1)。

它是如何工作的...

SVN 检出成功后,项目要么从本地机器复制到服务器,要么从服务器复制到本地机器,具体取决于哪个是最新的。一旦我们将文件添加到文件夹中,我们必须记住文件仍然是本地的。只有我们可以看到它并访问它。正在处理该项目的其他人将对此一无所知。现在,一个新程序员在这个阶段可能犯的一个常见错误是忘记将文件添加到 SVN。当您提交项目时,该文件将不会显示。在提交部分有一个显示未版本化文件的复选框。但是,我不建议这种方法,因为在这种情况下,所有临时文件也将显示出来。一个更好的方法是右键单击文件,然后转到Tortoise SVN | 添加。这将为修订添加文件。现在我们可以进行 SVN 提交,文件将存储在服务器上。

当我们删除文件时,我们必须记住我们只是在本地删除了文件。它仍然存在于服务器上。因此,当我们执行 SVN 更新时,文件将再次被恢复。我们必须小心不要执行Tortoise SVN | 删除和提交。这将从服务器中删除该修订版的文件。现在,如果我们对文件进行一些更改,我们可以SVN 提交它。我们不再需要选择Tortoise SVN | 添加。这将在服务器上创建文件的新版本。现在两个版本的文件都存在。我们可以拥有任意数量的版本。要访问任何修订版,我们需要选择根文件夹或任何特定文件,然后执行还原到此修订版(编号)。服务器然后查找我们请求的版本,并将正确的副本推送给我们。

解决冲突

让我们考虑一个由多个程序员共同处理的单个源文件。您可能有一些本地更改。当您尝试更新时,可能会发生 SVN 客户端足够智能地将文件合并在一起。但是,在大多数情况下,它将无法正确合并,我们需要有效地解决冲突。但是,SVN 客户端将显示冲突的文件。

准备工作

对于这个教程,您需要一台 Windows 机器和安装了 SVN 客户端的版本。还需要一个版本化的项目。

如何做...

在这个教程中,我们将发现解决冲突有多容易:

  1. 拿一个已经版本化并提交到 SVN 的项目。

  2. 在编辑器中打开文件并对文件进行更改。

  3. 执行SVN 更新操作。

  4. 文件现在显示冲突。

  5. 使用Diff 工具Win Merge查看两个文件之间的差异(您可能需要单独安装 Win Merge)。

  6. 通常,左侧将是本地修订版本,右侧将是服务器上的版本。但是,这些也可以互换。

  7. 查看差异后,您可以以两种方式解决冲突:

  • 选择你想要从服务器和本地更改中选择的部分。

  • 选择使用“我的”解决冲突或选择使用“他们的”解决冲突

它是如何工作的...

冲突发生时,客户端无法决定本地副本还是服务器副本应该被视为正确的工作版本。大多数良好的客户端在更新后会显示这个错误。其他客户端会在代码中插入两个部分,通常用r>>>>>m>>>>标记,表示哪一部分是服务器的,哪一部分是我们的。在 Tortoise SVN 上,如果我们选择忽略冲突,那么这些标记可能会显示为单独的文件或包含在文件中。更好的方法是始终解决冲突。如果我们使用诸如 Win Merge 之类的工具,它会将两个修订版本并排显示,我们可以比较并选择我们需要的部分,或整个文件。之后,一旦我们接受了更改并提交了它们,该文件将成为服务器上的更新版本。因此,更新他们的代码的其他人也会得到我们所做的更改。

创建一个分支

让我们假设我们正在制作一款游戏,该游戏计划在年底发布。然而,我们还需要展示一个经过精心打磨的版本供 GDC 或 E3 展示。此时,制作人可能会要求我们制作一个特定于 E3 或 GDC 的版本。这个 GDC 或 E3 版本可以被打磨并稳定下来,而主要版本可能会继续通过添加新功能进行实验。

准备工作

要完成本教程,您需要一台运行 Windows 的机器,并安装了 SVN 客户端的版本。还需要一个版本化的项目。不需要其他先决条件。

如何做...

在这个教程中,我们将发现创建一个分支有多容易:

  1. 右键单击版本化项目。

  2. 转到仓库浏览器。

  3. 选择要创建分支的根文件夹。

  4. 选择目的地。

  5. 现在创建了一个分支。

  6. 通过使用 URL 在机器上检出创建的分支。

它是如何工作的...

当我们从根文件夹创建一个分支时,会创建该文件夹及其后续子文件夹的镜像副本。从那时起,这两者可以独立工作。主根有一个 URL,分支也有自己的 URL。我们可以像为根文件夹一样更新和提交到分支。此外,所有其他功能对分支也是可用的。有时,在我们对分支进行更改后,我们可能需要将它们推回到根目录。虽然 SVN 客户端 Tortoise SVN 为我们提供了一个合并分支的工具,但它很少成功,往往我们需要手动进行合并。

第八章:游戏开发中的人工智能

在本章中,将涵盖以下食谱:

  • 向游戏添加人工智能

  • 在游戏中使用启发式

  • 使用二进制空间分区树

  • 创建决策制定 AI

  • 添加行为动作

  • 使用神经网络

  • 使用遗传算法

  • 使用其他航路点系统

介绍

人工智能AI)可以用许多方式来定义。人工智能处理在不同情况下找到相似之处和在相似情况下找到差异。AI 可以帮助游戏变得更加真实。玩游戏的用户应该感觉到他们正在与另一个人竞争。实现这一点非常困难,可能会消耗大量的处理周期。事实上,每年都会举行图灵测试来确定 AI 是否能愚弄其他人相信它是人类。现在,如果我们为 AI 使用了大量的处理周期,那么以超过 40 FPS 的速度执行游戏可能会变得非常困难。因此,我们需要编写高效的算法来实现这一点。

向游戏添加人工智能

向游戏添加人工智能可能很容易,也可能非常困难,这取决于我们试图实现的现实水平或复杂性。在这个食谱中,我们将从添加人工智能的基础开始。

准备工作

要完成本食谱,您需要一台运行 Windows 的机器和一个版本的 Visual Studio。不需要其他先决条件。

如何做到这一点…

在这个食谱中,我们将看到向游戏添加基本人工智能有多么容易。添加一个名为Source.cpp的源文件。将以下代码添加到其中:

// Basic AI : Keyword identification

#include <iostream>
#include <string>
#include <string.h>

std::string arr[] = { "Hello, what is your name ?", "My name is Siri" };

int main()
{

  std::string UserResponse;

  std::cout << "Enter your question? ";
  std::cin >> UserResponse;

  if (UserResponse == "Hi")
  {
    std::cout << arr[0] << std::endl;
    std::cout << arr[1];
  }

  int a;
  std::cin >> a;
  return 0;

}

它是如何工作的…

在上一个示例中,我们使用字符串数组来存储响应。软件的想法是创建一个智能聊天机器人,可以回答用户提出的问题并与他们交互,就像它是人类一样。因此,第一项任务是创建一个响应数组。接下来要做的事情是询问用户问题。在这个例子中,我们正在搜索一个名为Hi的基本关键字,并根据此显示适当的答案。当然,这是一个非常基本的实现。理想情况下,我们会有一个关键字和响应的列表,当触发任何关键字时。我们甚至可以通过询问用户的名字来个性化这一点,然后每次都将其附加到答案中。

用户还可以要求搜索某些内容。这实际上是一件非常容易的事情。如果我们正确检测到用户渴望搜索的单词,我们只需要将其输入到搜索引擎中。页面显示任何结果,我们都可以向用户报告。我们还可以使用语音命令输入问题并给出回应。在这种情况下,我们还需要实现某种NLP自然语言 处理)。在正确识别语音命令之后,所有其他流程都是完全相同的。

在游戏中使用启发式

在游戏中添加启发式意味着定义规则。我们需要为 AI 代理定义一组规则,以便它以最佳方式移动到目的地。例如,如果我们想编写一个路径规划算法,并且只定义其起始和结束位置,它可能以许多不同的方式到达那里。然而,如果我们希望代理以特定方式达到目标,我们需要为其建立一个启发式函数。

准备工作

您需要一台 Windows 机器和一个运行 Visual Studio 的工作副本。不需要其他先决条件。

如何做到这一点…

在这个食谱中,我们将发现为我们的游戏添加启发式函数进行路径规划有多么容易。添加一个名为Source.cpp的源文件,并将以下代码添加到其中:

    for (auto next : graph.neighbors(current)) {
      int new_cost = cost_so_far[current] + graph.cost(current, next);
      if (!cost_so_far.count(next) || new_cost < cost_so_far[next]) {
        cost_so_far[next] = new_cost;
        int priority = new_cost + heuristic(next, goal);
        frontier.put(next, priority);
        came_from[next] = current;
      }

它是如何工作的…

定义启发式的方法有很多种。然而,最简单的思考方法是它是一个为 AI 提供提示和方向以达到指定目标的函数。假设我们的 AI 需要从点A到点D。现在,地图上还有点BC。AI 应该如何决定要走哪条路径?这就是启发式函数提供的内容。在这个例子中,我们在称为A*的路径查找算法中使用了启发式。在特殊情况下,启发式函数为0,我们得到一个称为Dijkstra的算法。

让我们先考虑 Dijkstra。稍后理解A*会更容易。

它是如何工作的...

让我们考虑我们需要找到sx之间的最短路径,至少遍历所有节点一次。styxz是不同的节点或不同的子目的地。从一个节点到另一个节点的数字是从一个节点到另一个节点的成本。该算法规定我们从s开始,值为0,并认为所有其他节点都是无限的。接下来要考虑的是与s相邻的节点。与s相邻的节点是ty。到达它们的成本分别为510。我们注意到这一点,然后用510替换这些节点的无限值。现在让我们考虑节点y。相邻的节点是txz。到达x的成本是5(它的当前节点值)加上9(路径成本值)等于14。同样,到达z的成本是5 + 2 = 7。因此,我们分别用147替换xz的无限值。现在,到达t的成本是5 + 3 = 8。然而,它已经有一个节点值。它的值是10。由于8<10,我们将t替换为8。我们继续对所有节点进行这样的操作。之后,我们将得到遍历所有节点的最小成本。

A*有两个成本函数:

  • g(x): 这与 Dijkstra 相同。这是到达节点x的实际成本。

  • h(x): 这是从节点x到目标节点的近似成本。这是一个启发式函数。这个启发式函数不应该高估成本。这意味着从节点x到达目标节点的实际成本应该大于或等于h(x)。这被称为可接受的启发式。

每个节点的总成本使用f(x) = g(x)+h(x)计算。

A*中,我们不需要遍历所有节点,我们只需要找到从起点到目的地的最短路径。A*搜索只会扩展一个节点,如果它看起来很有前途。它只关注从当前节点到达目标节点,而不是到达其他每个节点。如果启发式函数是可接受的,它是最优的。因此,编写启发式函数是检查是否扩展到节点的关键。在前面的例子中,我们使用相邻节点并形成一个优先列表来决定。

使用二进制空间分区树

有时在游戏中,我们需要处理大量的几何图形和庞大的 3D 世界。如果我们的游戏摄像头一直渲染所有内容,那么成本将非常昂贵,游戏将无法以更高的帧率平稳运行。因此,我们需要编写智能算法,以便将世界划分为更易管理的块,可以使用树结构轻松遍历。

准备就绪

你需要有一台运行良好的 Windows 机器和一个运行良好的 Visual Studio 副本。

如何做...

添加一个名为Source.cpp的源文件。然后将以下代码添加到其中:

sNode(elemVec& toProcess, const T_treeAdaptor& adap)
      : m_pFront(NULL)
      , m_pBack(NULL)
    {
      // Setup
      elemVec frontVec, backVec;
      frontVec.reserve(toProcess.size());
      backVec.reserve(toProcess.size());

      // Choose which node we're going to use.
      adap.ChooseHyperplane(toProcess, &m_hp);

      // Iterate across the rest of the polygons
      elemVec::iterator iter = toProcess.begin();
      for (; iter != toProcess.end(); ++iter)
      {
        T_element front, back;
        switch (adap.Classify(m_hp, *iter))
        {
        case BSP_RELAT_IN_FRONT:
          frontVec.push_back(*iter);
          break;
       <...> 
      }

      // Now recurse if necessary
      if (!frontVec.empty())
        m_pFront = new sNode(frontVec, adap);
      if (!backVec.empty())
        m_pBack = new sNode(backVec, adap);
    }

    sNode(std::istream& in)
    {
      // First char is the child state
      // (0x1 means front child, 0x2 means back child)
      int childState;
      in >> childState;

      // Next is the hyperplane for the node
      in >> m_hp;

      // Next is the number of elements in the node
      unsigned int nElem;
      in >> nElem;
      m_contents.reserve(nElem);

      while (nElem--)
      {
        T_element elem;
        in >> elem;
        m_contents.push_back(elem);
      }

      // recurse if we have children.
      if (childState & 0x1)
        m_pFront = new sNode(in);
      else
        m_pFront = NULL;
      if (childState & 0x2)
        m_pBack = new sNode(in);
      else
        m_pBack = NULL;
    }

它是如何工作的...

二进制空间分区BSP)树,顾名思义,是一个树结构,其中一个几何空间被分割。更准确地说,在 BSP 中,一个平面被分割成更多的超平面。一个平面是这样的,它的维度比它所在的环境空间少一个。因此,一个 3D 平面将有 2D 超平面,而一个 2D 平面将有 1D 线。这背后的想法是一旦我们以逻辑方式将平面分割成这些超平面,我们可以将形成保存到树结构中。最后,我们可以实时遍历树结构,为整个游戏提供更好的帧率。

让我们考虑一个例子,世界看起来像下面的图表。摄像机必须决定应该渲染哪些区域,哪些不应该。因此,使用逻辑算法进行划分是必要的:

它是如何工作的…

应用算法后,树结构应该如下所示:

它是如何工作的…

最后,我们像处理任何其他树结构一样遍历这个算法,使用父节点和子节点的概念,得到摄像机应该渲染的所需部分。

创建决策制定 AI

决策树是机器学习中最有用的东西之一。在大量的情景中,基于某些参数,决策是必不可少的。如果我们能够编写一个能够做出这些决定的系统,那么我们不仅可以拥有一个写得很好的算法,而且在游戏玩法方面也会有很多的不可预测性。这将为游戏增加很多变化,并有助于整体游戏的可重复性。

准备工作

对于这个食谱,你需要一台 Windows 机器和 Visual Studio。不需要其他先决条件。

如何做…

在这个食谱中,我们将发现添加源代码控制是多么容易:

/* Decision Making AI*/

#include <iostream>
#include <ctime>

using namespace std;

class TreeNodes
{
public:
  //tree node functions
  TreeNodes(int nodeID/*, string QA*/);
  TreeNodes();

  virtual ~TreeNodes();

  int m_NodeID;

  TreeNodes* PrimaryBranch;
  TreeNodes* SecondaryBranch;
};

//constructor
TreeNodes::TreeNodes()
{
  PrimaryBranch = NULL;
  SecondaryBranch = NULL;

  m_NodeID = 0;
}

//deconstructor
TreeNodes::~TreeNodes()
{ }

//Step 3! Also step 7 hah!
TreeNodes::TreeNodes(int nodeID/*, string NQA*/)
{
  //create tree node with a specific node ID
  m_NodeID = nodeID;

  //reset nodes/make sure! that they are null. I wont have any funny business #s -_-
  PrimaryBranch = NULL;
  SecondaryBranch = NULL;
}

//the decision tree class
class DecisionTree
{
public:
  //functions
  void RemoveNode(TreeNodes* node);
  void DisplayTree(TreeNodes* CurrentNode);
  void Output();
  void Query();
  void QueryTree(TreeNodes* rootNode);
  void PrimaryNode(int ExistingNodeID, int NewNodeID);
  void SecondaryNode(int ExistingNodeID, int NewNodeID);
  void CreateRootNode(int NodeID);
  void MakeDecision(TreeNodes* node);

  bool SearchPrimaryNode(TreeNodes* CurrentNode, int ExistingNodeID, int NewNodeID);
  bool SearchSecondaryNode(TreeNodes* CurrentNode, int ExistingNodeID, int NewNodeID);

  TreeNodes* m_RootNode;

  DecisionTree();

  virtual ~DecisionTree();
};

int random(int upperLimit);

//for random variables that will effect decisions/node values/weights
int random(int upperLimit)
{
  int randNum = rand() % upperLimit;
  return randNum;
}

//constructor
//Step 1!
DecisionTree::DecisionTree()
{
  //set root node to null on tree creation
  //beginning of tree creation
  m_RootNode = NULL;
}

//destructor
//Final Step in a sense
DecisionTree::~DecisionTree()
{
  RemoveNode(m_RootNode);
}

//Step 2!
void DecisionTree::CreateRootNode(int NodeID)
{
  //create root node with specific ID
  // In MO, you may want to use thestatic creation of IDs like with entities. depends on how many nodes you plan to have
  //or have instantaneously created nodes/changing nodes
  m_RootNode = new TreeNodes(NodeID);
}

//Step 5.1!~
void DecisionTree::PrimaryNode(int ExistingNodeID, int NewNodeID)
{
  //check to make sure you have a root node. can't add another node without a root node
  if (m_RootNode == NULL)
  {
    cout << "ERROR - No Root Node";
    return;
  }

  if (SearchPrimaryNode(m_RootNode, ExistingNodeID, NewNodeID))
  {
    cout << "Added Node Type1 With ID " << NewNodeID << " onto Branch Level " << ExistingNodeID << endl;
  }
  else
  {
    //check
    cout << "Node: " << ExistingNodeID << " Not Found.";
  }
}

//Step 6.1!~ search and add new node to current node
bool DecisionTree::SearchPrimaryNode(TreeNodes *CurrentNode, int ExistingNodeID, int NewNodeID)
{
  //if there is a node
  if (CurrentNode->m_NodeID == ExistingNodeID)
  {
    //create the node
    if (CurrentNode->PrimaryBranch == NULL)
    {
      CurrentNode->PrimaryBranch = new TreeNodes(NewNodeID);
    }
    else
    {
      CurrentNode->PrimaryBranch = new TreeNodes(NewNodeID);
    }
    return true;
  }
  else
  {
    //try branch if it exists
    //for a third, add another one of these too!
    if (CurrentNode->PrimaryBranch != NULL)
    {
      if (SearchPrimaryNode(CurrentNode->PrimaryBranch, ExistingNodeID, NewNodeID))
      {
        return true;
      }
      else
      {
        //try second branch if it exists
        if (CurrentNode->SecondaryBranch != NULL)
        {
          return(SearchSecondaryNode(CurrentNode->SecondaryBranch, ExistingNodeID, NewNodeID));
        }
        else
        {
          return false;
        }
      }
    }
    return false;
  }
}

//Step 5.2!~    does same thing as node 1\.  if you wanted to have more decisions, 
//create a node 3 which would be the same as this maybe with small differences
void DecisionTree::SecondaryNode(int ExistingNodeID, int NewNodeID)
{
  if (m_RootNode == NULL)
  {
    cout << "ERROR - No Root Node";
  }

  if (SearchSecondaryNode(m_RootNode, ExistingNodeID, NewNodeID))
  {
    cout << "Added Node Type2 With ID " << NewNodeID << " onto Branch Level " << ExistingNodeID << endl;
  }
  else
  {
    cout << "Node: " << ExistingNodeID << " Not Found.";
  }
}

//Step 6.2!~ search and add new node to current node
//as stated earlier, make one for 3rd node if there was meant to be one
bool DecisionTree::SearchSecondaryNode(TreeNodes *CurrentNode, int ExistingNodeID, int NewNodeID)
{
  if (CurrentNode->m_NodeID == ExistingNodeID)
  {
    //create the node
    if (CurrentNode->SecondaryBranch == NULL)
    {
      CurrentNode->SecondaryBranch = new TreeNodes(NewNodeID);
    }
    else
    {
      CurrentNode->SecondaryBranch = new TreeNodes(NewNodeID);
    }
    return true;
  }
  else
  {
    //try branch if it exists
    if (CurrentNode->PrimaryBranch != NULL)
    {
      if (SearchSecondaryNode(CurrentNode->PrimaryBranch, ExistingNodeID, NewNodeID))
      {
        return true;
      }
      else
      {
        //try second branch if it exists
        if (CurrentNode->SecondaryBranch != NULL)
        {
          return(SearchSecondaryNode(CurrentNode->SecondaryBranch, ExistingNodeID, NewNodeID));
        }
        else
        {
          return false;
        }
      }
    }
    return false;
  }
}

//Step 11
void DecisionTree::QueryTree(TreeNodes* CurrentNode)
{
  if (CurrentNode->PrimaryBranch == NULL)
  {
    //if both branches are null, tree is at a decision outcome state
    if (CurrentNode->SecondaryBranch == NULL)
    {
      //output decision 'question'
      ///////////////////////////////////////////////////////////////////////////////////////
    }
    else
    {
      cout << "Missing Branch 1";
    }
    return;
  }
  if (CurrentNode->SecondaryBranch == NULL)
  {
    cout << "Missing Branch 2";
    return;
  }

  //otherwise test decisions at current node
  MakeDecision(CurrentNode);
}

//Step 10
void DecisionTree::Query()
{
  QueryTree(m_RootNode);
}

////////////////////////////////////////////////////////////
//debate decisions   create new function for decision logic

// cout << node->stringforquestion;

//Step 12
void DecisionTree::MakeDecision(TreeNodes *node)
{
  //should I declare variables here or inside of decisions.h
  int PHealth;
  int MHealth;
  int PStrength;
  int MStrength;
  int DistanceFBase;
  int DistanceFMonster;

  ////sets random!
  srand(time(NULL));

  //randomly create the numbers for health, strength and distance for each variable
  PHealth = random(60);
  MHealth = random(60);
  PStrength = random(50);
  MStrength = random(50);
  DistanceFBase = random(75);
  DistanceFMonster = random(75);

  //the decision to be made string example: Player health: Monster Health:  player health is lower/higher
  cout << "Player Health: " << PHealth << endl;
  cout << "Monster Health: " << MHealth << endl;
  cout << "Player Strength: " << PStrength << endl;
  cout << "Monster Strength: " << MStrength << endl;
  cout << "Distance Player is From Base: " << DistanceFBase << endl;
  cout << "Distance Player is From Monster: " << DistanceFMonster << endl;

  if (PHealth > MHealth)
  {
    std::cout << "Player health is greater than monster health";
    //Do some logic here
  }
  else
  {
    std::cout << "Monster health is greater than player health";
    //Do some logic here
  }

  if (PStrength > MStrength)
  {
    //Do some logic here
  }
  else
  {
  }

  //recursive question for next branch. Player distance from base/monster. 
  if (DistanceFBase > DistanceFMonster)
  {
  }
  else
  {
  }

}

void DecisionTree::Output()
{
  //take respective node
  DisplayTree(m_RootNode);
}

//Step 9
void DecisionTree::DisplayTree(TreeNodes* CurrentNode)
{
  //if it doesn't exist, don't display of course
  if (CurrentNode == NULL)
  {
    return;
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  //need to make a string to display for each branch
  cout << "Node ID " << CurrentNode->m_NodeID << "Decision Display: " << endl;

  //go down branch 1
  DisplayTree(CurrentNode->PrimaryBranch);

  //go down branch 2
  DisplayTree(CurrentNode->SecondaryBranch);
}

void DecisionTree::RemoveNode(TreeNodes *node)
{

  if (node != NULL)
  {
    if (node->PrimaryBranch != NULL)
    {
      RemoveNode(node->PrimaryBranch);
    }

    if (node->SecondaryBranch != NULL)
    {
      RemoveNode(node->SecondaryBranch);
    }

    cout << "Deleting Node" << node->m_NodeID << endl;

    //delete node from memory
    delete node;
    //reset node
    node = NULL;
  }
}

int main()
{
  //create the new decision tree object
  DecisionTree* NewTree = new DecisionTree();

  //add root node   the very first 'Question' or decision to be made
  //is monster health greater than player health?
  NewTree->CreateRootNode(1);

  //add nodes depending on decisions
  //2nd decision to be made
  //is monster strength greater than player strength?
  NewTree->PrimaryNode(1, 2);

  //3rd decision
  //is the monster closer than home base?
  NewTree->SecondaryNode(1, 3);

  //depending on the weights of all three decisions, will return certain node result
  //results!
  //Run, Attack, 
  NewTree->PrimaryNode(2, 4);
  NewTree->SecondaryNode(2, 5);
  NewTree->PrimaryNode(3, 6);
  NewTree->SecondaryNode(3, 7);

  NewTree->Output();

  //ask/answer question decision making process
  NewTree->Query();

  cout << "Decision Made. Press Any Key To Quit." << endl;

  int a;
  cin >> a;

  //release memory!
  delete NewTree;

  //return random value
  //return 1;

}

它是如何工作的…

正如其名称所示,决策树是树数据结构的一个子集。因此,有一个根节点和两个子节点。根节点表示一个条件,子节点将有可能的解决方案。在下一个级别,这些解决方案节点将成为条件的一部分,这将导致另外两个解决方案节点。因此,正如前面的例子所示,整个结构是基于树结构建模的。我们有一个根节点,然后是主节点和次级节点。我们需要遍历树来不断地找到基于根节点和子节点的情况的答案。

我们还编写了一个Query函数,它将查询树结构,找出情况的最可能场景。这将得到一个决策函数的帮助,它将添加自己的启发式水平,结合查询的结果,生成解决方案的输出。

决策树非常快,因为对于每种情况,我们只检查了树的一半。因此,实际上我们将搜索空间减少了一半。树结构也使其更加健壮,因此我们也可以随时添加和删除节点。这给了我们很大的灵活性,游戏的整体架构也得到了改进。

添加行为动作

当我们谈论游戏中的人工智能时,寻路之后需要考虑的下一个最重要的事情就是移动。AI 何时决定走路、跑步、跳跃或滑行?能够快速而正确地做出这些决定将使 AI 在游戏中变得非常有竞争力,极其难以击败。我们可以通过行为动作来实现所有这些。

准备工作

对于这个食谱,你需要一台 Windows 机器和 Visual Studio。不需要其他先决条件。

如何做…

在这个例子中,你将发现创建决策树是多么容易。添加一个名为Source.cpp的源文件,并将以下代码添加到其中:

/* Adding Behavorial Movements*/

#include <iostream>
using namespace std;
class Machine
{
  class State *current;
public:
  Machine();
  void setCurrent(State *s)
  {
    current = s;
  }
  void Run();
  void Walk();
};

class State
{
public:
  virtual void Run(Machine *m)
  {
    cout << "   Already Running\n";
  }
  virtual void Walk(Machine *m)
  {
    cout << "   Already Walking\n";
  }
};

void Machine::Run()
{
  current->Run(this);
}

void Machine::Walk()
{
  current->Walk(this);
}

class RUN : public State
{
public:
  RUN()
  {
    cout << "   RUN-ctor ";
  };
  ~RUN()
  {
    cout << "   dtor-RUN\n";
  };
  void Walk(Machine *m);
};

class WALK : public State
{
public:
  WALK()
  {
    cout << "   WALK-ctor ";
  };
  ~WALK()
  {
    cout << "   dtor-WALK\n";
  };
  void Run(Machine *m)
  {
    cout << " Changing behaviour from WALK to RUN";
    m->setCurrent(new RUN());
    delete this;
  }
};

void RUN::Walk(Machine *m)
{
  cout << "   Changing behaviour RUN to WALK";
  m->setCurrent(new WALK());
  delete this;
}

Machine::Machine()
{
  current = new WALK();
  cout << '\n';
}

int main()
{
  Machine m;
  m.Run();
  m.Walk();
  m.Walk();

  int a;
  cin >> a;

  return 0;
}

它是如何工作的…

在这个例子中,我们实现了一个简单的状态机。状态机是根据状态机设计模式创建的。因此,在这种情况下,状态是行走和奔跑。目标是,如果 AI 正在行走,然后需要切换到奔跑,它可以在运行时这样做。同样,如果它正在奔跑,它可以在运行时切换到行走。但是,如果它已经在行走,而请求来了要求行走,它应该通知自己不需要改变状态。

所有这些状态的变化都由一个名为 machine 的类处理,因此得名状态机模式。为什么这种结构被许多人优先于传统的状态机设计,是因为所有状态不需要在一个类中定义,然后使用 switch case 语句来改变状态。虽然这种方法是正确的,但是每增加一个步骤都需要改变和添加到相同的类结构中。这是未来可能出现错误和灾难的风险。相反,我们采用更面向对象的方法,其中每个状态都是一个独立的类。

machine类持有指向StateTo类的指针,然后将请求推送到状态的适当子类。如果我们需要添加跳跃状态,我们不需要在代码中做太多改动。我们只需要编写一个新的jump类并添加相应的功能。因为机器持有指向基类(状态)的指针,它将相应地将跳跃请求推送到正确的派生类。

使用神经网络

人工神经网络ANNs)是一种高级的人工智能形式,用于一些游戏中。它们可能不会直接在游戏中使用;然而,在生产阶段可能会用于训练 AI 代理人。神经网络主要用作预测算法。基于某些参数和历史数据,它们计算 AI 代理人最可能的决策或属性。ANNs 不仅限于游戏;它们被用于多个不同的领域来预测可能的结果。

准备工作

要完成这个示例,您需要一台运行 Windows 和 Visual Studio 的计算机。

如何做…

看一下以下代码片段:

class neuralNetworkTrainer
{

private:

  //network to be trained
  neuralNetwork* NN;

  //learning parameters
  double learningRate;          // adjusts the step size of the weight update  
  double momentum;            // improves performance of stochastic learning (don't use for batch)

  //epoch counter
  long epoch;
  long maxEpochs;

  //accuracy/MSE required
  double desiredAccuracy;

  //change to weights
  double** deltaInputHidden;
  double** deltaHiddenOutput;

  //error gradients
  double* hiddenErrorGradients;
  double* outputErrorGradients;

  //accuracy stats per epoch
  double trainingSetAccuracy;
  double validationSetAccuracy;
  double generalizationSetAccuracy;
  double trainingSetMSE;
  double validationSetMSE;
  double generalizationSetMSE;

  //batch learning flag
  bool useBatch;

  //log file handle
  bool loggingEnabled;
  std::fstream logFile;
  int logResolution;
  int lastEpochLogged;

public:  

  neuralNetworkTrainer( neuralNetwork* untrainedNetwork );
  void setTrainingParameters( double lR, double m, bool batch );
  void setStoppingConditions( int mEpochs, double dAccuracy);
  void useBatchLearning( bool flag ){ useBatch = flag; }
  void enableLogging( const char* filename, int resolution );

  void trainNetwork( trainingDataSet* tSet );

private:
  inline double getOutputErrorGradient( double desiredValue, double outputValue );
  double getHiddenErrorGradient( int j );
  void runTrainingEpoch( std::vector<dataEntry*> trainingSet );
  void backpropagate(double* desiredOutputs);
  void updateWeights();
};

class neuralNetwork
{

private:

  //number of neurons
  int nInput, nHidden, nOutput;

  //neurons
  double* inputNeurons;
  double* hiddenNeurons;
  double* outputNeurons;

  //weights
  double** wInputHidden;
  double** wHiddenOutput;
  friend neuralNetworkTrainer;

public:

  //constructor & destructor
  neuralNetwork(int numInput, int numHidden, int numOutput);
  ~neuralNetwork();

  //weight operations
  bool loadWeights(char* inputFilename);
  bool saveWeights(char* outputFilename);
  int* feedForwardPattern( double* pattern );
  double getSetAccuracy( std::vector<dataEntry*>& set );
  double getSetMSE( std::vector<dataEntry*>& set );

private:

  void initializeWeights();
  inline double activationFunction( double x );
  inline int clampOutput( double x );
  void feedForward( double* pattern );

};

工作原理

在这个示例片段中,我们创建了一个骨干来编写一个可以预测屏幕上绘制的字母的神经网络。许多设备和触摸屏平板电脑都具有检测您在屏幕上绘制的字母的能力。让我们以游戏设计的方式来思考这个问题。如果我们想创建一个游戏,在游戏中我们绘制形状,然后会给我们相应的武器,我们可以在战斗中使用,我们可以使用这个作为模板来训练代理人在游戏发布到市场之前识别形状。通常,这些游戏只能检测基本形状。这些可以很容易地被检测到,不需要神经网络来训练代理人。

在游戏中,ANNs 主要用于创建良好的 AI 行为。然而,在游戏进行时使用 ANNs 是不明智的,因为它们成本高,训练代理人需要很长时间。让我们看下面的例子:

类别 速度 HP
近战 速度(4) 25(HP)
弓箭手 速度(7) 22(HP)
魔法 速度(6.4) 20(HP)
? 速度(6.6) 21(HP)

根据数据,未知的最可能的类是什么?参数的数量(类别速度HP)只有三个,但实际上将超过 10 个。仅仅通过观察这些数字来预测类别将是困难的。这就是 ANN 的用武之地。它可以根据其他列的数据和以前的历史数据预测任何缺失的列数据。这对设计师来说是一个非常方便的工具,可以用来平衡游戏。

我们实现的 ANN 的一些概念是必要的。

ANN 通常由三种类型的参数定义:

  • 神经元不同层之间的互连模式。

  • 更新相互连接权重的学习过程。

  • 将神经元加权输入转换为其输出激活的激活函数。

让我们看一下下面解释层的图表:

它是如何工作的...

输入层是我们提供所有已知的列数据的层,包括历史数据和新数据。该过程首先涉及提供我们已经知道输出的数据。这个阶段被称为学习阶段。有两种类型的学习算法,监督和非监督。这些的解释超出了本书的范围。之后,有一个训练算法,用于最小化期望输出中的错误。反向传播是一种这样的技术,通过调整计算神经网络函数的权重,直到我们接近期望的结果。在网络设置并为已知输出提供正确结果后,我们可以提供新数据并找到未知列数据的结果。

使用遗传算法

遗传算法GA)是一种进化算法EA)的方法。当我们想要编写预测算法时,它们特别有用,其中只选择最强的,其余的被拒绝。这就是它得名的原因。因此,在每次迭代中,它会发生突变,进行交叉,并且只选择最好的进入下一代种群。遗传算法背后的想法是经过多次迭代后,只有最佳的候选者留下。

准备就绪

要完成这个配方,您需要一台安装了 Visual Studio 的 Windows 机器。

如何做...

在这个配方中,我们将发现编写遗传算法有多么容易:

void crossover(int &seed);
void elitist();
void evaluate();
int i4_uniform_ab(int a, int b, int &seed);
void initialize(string filename, int &seed);
void keep_the_best();
void mutate(int &seed);
double r8_uniform_ab(double a, double b, int &seed);
void report(int generation);
void selector(int &seed);
void timestamp();
void Xover(int one, int two, int &seed);

它是如何工作的...

起初,遗传算法可能看起来非常难以理解或毫无意义。然而,遗传算法非常简单。让我们想象一种情况,我们有一片充满了具有不同属性的龙的土地。龙的目标是击败具有某些属性的人类玩家。

龙(AI)

它是如何工作的...

人类(玩家)

它是如何工作的...

因此,为了使龙对抗人类具有竞争力,它必须学会奔跑,防御和攻击。让我们看看遗传算法如何帮助我们做到这一点:

步骤 1(初始种群)

龙(AI):

这是我们的初始种群。每个都有自己的属性集。我们只考虑三条龙。实际上,会有更多。

步骤 1(初始种群)

步骤 2(适应函数)

适应度函数(%)确定种群中特定龙的适应程度。100%是完美适应度。

步骤 2(适应函数)

步骤 3 交叉

基于适应函数和缺失的属性,将进行交叉或繁殖阶段,以创建具有两种属性的新龙:

表 1

适应度 属性 1 属性 2 属性 3
60% 龙 1 奔跑 防御 攻击
75% 龙 2 奔跑 防御 攻击
20% 龙 3 奔跑 防御 攻击

表 2

步骤 3 交叉

适应度函数最低的龙将从种群中移除。(适者生存)。

步骤 3 交叉

步骤 4 突变

因此,我们现在有了一条新的龙,它既可以奔跑又可以攻击,并且适应度函数为67%

步骤 4 突变

现在,我们必须重复这个过程(新一代)与种群中的其他龙,直到我们对结果满意为止。理想的种群将是当所有龙都具有以下能力时:

步骤 4 突变

然而,这并不总是可能的。我们需要确保它更接近目标。这里描述的所有阶段都被实现为函数,并且可以根据 AI 代理的要求进行扩展。

现在你可能会问,为什么我们不一开始就创建具有所有属性的龙呢?这就是自适应 AI 发挥作用的地方。如果我们在用户玩游戏之前就定义了龙的所有属性,随着游戏的进行,可能会很容易击败龙。然而,如果 AI 龙可以根据玩家如何击败它们来适应,那么击败 AI 可能会变得越来越困难。当玩家击败 AI 时,我们需要记录参数,并将该参数作为龙的目标属性添加,它可以在几次交叉和突变后实现。

使用其他航点系统

航点是编写路径规划算法的一种方式。它们非常容易编写。然而,如果没有正确考虑,它们可能会非常有 bug,AI 看起来可能非常愚蠢。许多旧游戏经常出现这种 bug,这导致了航点系统实现的革命。

准备工作

要完成这个配方,你需要一台运行 Windows 的机器,并安装了 Visual Studio 的版本。不需要其他先决条件。

如何做到...

在这个配方中,我们将发现创建航点系统有多么容易:

#include <iostream>

using namespace std;

int main()
{
  float positionA = 4.0f; float positionB = 2.0f; float positionC = -1.0f; float positionD = 10.0f; float positionE = 0.0f;

  //Sort the points according to Djisktra's
  //A* can be used on top of this to minimise the points for traversal
  //Transform the  objects over these new points.
  return 0;
}

它是如何工作的...

在这个例子中,我们将讨论航点系统的基本实现。顾名思义,航点只是我们希望 AI 代理跟随的世界空间中的 2D/3D 点。代理所要做的就是从点A移动到点B。然而,这有复杂性。例如,让我们考虑以下图表:

它是如何工作的...

AB很容易。现在,要从BC,它必须遵循 A*或 Djikstra 的算法。在这种情况下,它将避开中心的障碍物,向C移动。现在假设它突然在旅途中看到了用户在点A。它应该如何反应?如果我们只提供航点,它将查看允许移动到的点的字典,并找到最接近它的点。答案将是A。然而,如果它开始朝A走去,它将被墙挡住,可能会陷入循环,不断撞墙。你可能在旧游戏中经常看到这种行为。在这种情况下,AI 必须做出决定,返回B,然后再到A。因此,我们不能单独使用航点算法。为了更好的性能和效率,我们需要编写一个决策算法和一个路径规划算法。这是大多数现代游戏中使用的技术,还有NavMesh等技术。

第九章:游戏开发中的物理

在本章中,将介绍以下食谱:

  • 在游戏中使用物理规则

  • 使物体发生碰撞

  • 安装和集成 Box2D

  • 制作基本的 2D 游戏

  • 制作 3D 游戏

  • 创建一个粒子系统

  • 在游戏中使用布娃娃

介绍

在现代游戏和过去的游戏中,总是添加了某种类型的物理以增加现实感。尽管游戏中的大多数物理是对实际物理规则的近似或优化,但它确实很好地实现了期望的结果。游戏中的物理基本上是牛顿运动定律的粗略实现,结合了基本的碰撞检测原理。

游戏开发者的诀窍是以这样一种方式编写代码,使其不会成为 CPU 的瓶颈,游戏仍然以期望的框架运行。我们将讨论一些我们需要引入物理到游戏中的基本概念。为了简单起见,我们已经将Box2D集成到我们的引擎中,并且与渲染器(OpenGL)一起,我们将输出物体之间的一些物理交互。对于 3D 物理,我们将从Bullet Physics SDK 获得帮助,并显示期望的结果。

在游戏中使用物理规则

在游戏中引入物理的第一步是准备好环境,以便可以对物体应用适当的计算,并且物理模拟可以对其进行操作。

做好准备

要完成这个食谱,您需要一台运行 Windows 和 Visual Studio 的计算机。不需要其他先决条件。

如何做...

在这个食谱中,我们将看到向游戏中添加物理规则是多么容易:

  1. 首先,在游戏场景中设置所有物体。

  2. 给它们属性,使它们具有矢量点和速度。

  3. 根据物体的形状分配边界框或边界圆。

  4. 对每个物体施加力。

  5. 根据形状检测它们之间的碰撞。

  6. 解决约束。

  7. 输出结果。

看一下以下代码片段:

#include <Box2D/Collision/b2Collision.h>
#include <Box2D/Collision/Shapes/b2CircleShape.h>
#include <Box2D/Collision/Shapes/b2PolygonShape.h>

void b2CollideCircles(
  b2Manifold* manifold,
  const b2CircleShape* circleA, const b2Transform& xfA,
  const b2CircleShape* circleB, const b2Transform& xfB)
{
  manifold->pointCount = 0;

  b2Vec2 pA = b2Mul(xfA, circleA->m_p);
  b2Vec2 pB = b2Mul(xfB, circleB->m_p);

  b2Vec2 d = pB - pA;
  float32 distSqr = b2Dot(d, d);
  float32 rA = circleA->m_radius, rB = circleB->m_radius;
  float32 radius = rA + rB;
  if (distSqr > radius * radius)
  {
    return;
  }

  manifold->type = b2Manifold::e_circles;
  manifold->localPoint = circleA->m_p;
  manifold->localNormal.SetZero();
  manifold->pointCount = 1;

  manifold->points[0].localPoint = circleB->m_p;
  manifold->points[0].id.key = 0;
}

void b2CollidePolygonAndCircle(
  b2Manifold* manifold,
  const b2PolygonShape* polygonA, const b2Transform& xfA,
  const b2CircleShape* circleB, const b2Transform& xfB)
{
  manifold->pointCount = 0;

  // Compute circle position in the frame of the polygon.
  b2Vec2 c = b2Mul(xfB, circleB->m_p);
  b2Vec2 cLocal = b2MulT(xfA, c);

  // Find the min separating edge.
  int32 normalIndex = 0;
  float32 separation = -b2_maxFloat;
  float32 radius = polygonA->m_radius + circleB->m_radius;
  int32 vertexCount = polygonA->m_count;
  const b2Vec2* vertices = polygonA->m_vertices;
  const b2Vec2* normals = polygonA->m_normals;

  for (int32 i = 0; i < vertexCount; ++i)
  {
    float32 s = b2Dot(normals[i], cLocal - vertices[i]);

    if (s > radius)
    {
      // Early out.
      return;
    }

    if (s > separation)
    {
      separation = s;
      normalIndex = i;
    }
  }

  // Vertices that subtend the incident face.
  int32 vertIndex1 = normalIndex;
  int32 vertIndex2 = vertIndex1 + 1 < vertexCount ? vertIndex1 + 1 : 0;
  b2Vec2 v1 = vertices[vertIndex1];
  b2Vec2 v2 = vertices[vertIndex2];

  // If the center is inside the polygon ...
  if (separation < b2_epsilon)
  {
    manifold->pointCount = 1;
    manifold->type = b2Manifold::e_faceA;
    manifold->localNormal = normals[normalIndex];
    manifold->localPoint = 0.5f * (v1 + v2);
    manifold->points[0].localPoint = circleB->m_p;
    manifold->points[0].id.key = 0;
    return;
  }

  // Compute barycentric coordinates
  float32 u1 = b2Dot(cLocal - v1, v2 - v1);
  float32 u2 = b2Dot(cLocal - v2, v1 - v2);
  if (u1 <= 0.0f)
  {
    if (b2DistanceSquared(cLocal, v1) > radius * radius)
    {
      return;
    }

    manifold->pointCount = 1;
    manifold->type = b2Manifold::e_faceA;
    manifold->localNormal = cLocal - v1;
    manifold->localNormal.Normalize();
    manifold->localPoint = v1;
    manifold->points[0].localPoint = circleB->m_p;
    manifold->points[0].id.key = 0;
  }
  else if (u2 <= 0.0f)
  {
    if (b2DistanceSquared(cLocal, v2) > radius * radius)
    {
      return;
    }

    manifold->pointCount = 1;
    manifold->type = b2Manifold::e_faceA;
    manifold->localNormal = cLocal - v2;
    manifold->localNormal.Normalize();
    manifold->localPoint = v2;
    manifold->points[0].localPoint = circleB->m_p;
    manifold->points[0].id.key = 0;
  }
  else
  {
    b2Vec2 faceCenter = 0.5f * (v1 + v2);
    float32 separation = b2Dot(cLocal - faceCenter, normals[vertIndex1]);
    if (separation > radius)
    {
      return;
    }

    manifold->pointCount = 1;
    manifold->type = b2Manifold::e_faceA;
    manifold->localNormal = normals[vertIndex1];
    manifold->localPoint = faceCenter;
    manifold->points[0].localPoint = circleB->m_p;
    manifold->points[0].id.key = 0;
  }
}

它是如何工作的...

身体展现物理属性的第一步是成为刚体。然而,如果您的身体应该具有某种流体物理特性,比如塑料或其他软体,这就不成立了。在这种情况下,我们将不得不以不同的方式设置世界,因为这是一个更加复杂的问题。简而言之,刚体是世界空间中的任何物体,即使外部力作用于它,它也不会变形。即使在 Unity 或 UE4 等游戏引擎中,如果将一个物体分配为刚体,它也会根据引擎的物理模拟属性自动反应。设置好刚体后,我们需要确定物体是静态的还是动态的。这一步很重要,因为如果我们知道物体是静态的,我们可以大大减少计算量。动态物体必须分配速度和矢量位置。

在完成上一步之后,下一步是添加碰撞器或边界对象。这些实际上将用于计算碰撞点。例如,如果我们有一个人的 3D 模型,有时使用精确的身体网格进行碰撞并不明智。相反,我们可以使用一个胶囊,它是一个在身体两端各有两个半球的圆柱体,手部也有类似的结构。对于 2D 物体,我们可以在圆形边界对象或矩形边界对象之间做出选择。以下图表显示了物体为黑色,边界框为红色。现在我们可以对物体施加力或冲量:

它是如何工作的...

管道中的下一步是实际检测两个物体何时发生碰撞。我们将在下一个步骤中进一步讨论这个问题。但是假设我们需要检测圆 A是否与圆 B发生了碰撞;在大多数情况下,我们只需要知道它们是否发生了碰撞,而不需要知道具体的接触点。在这种情况下,我们需要编写一些数学函数来检测。然后我们返回输出,并根据此编写我们的碰撞逻辑,最后显示结果。

在前面的例子中,有一个名为b2CollidePolygonAndCircle的函数,用于计算多边形和圆之间的碰撞。我们定义了两个形状,然后尝试计算确定多边形和圆的点是否相交的各种细节。我们需要找到边缘列表点,然后计算这些点是否在另一个形状内部,依此类推。

使物体发生碰撞

物理系统的一个重要部分是使物体发生碰撞。我们需要弄清楚物体是否发生了碰撞,并传递相关信息。在这个步骤中,我们将看看不同的技术来做到这一点。

准备工作

你需要一台运行正常的 Windows 机器和一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做…

在这个步骤中,我们将找出检测碰撞有多容易:

#include <Box2D/Collision/b2Collision.h>
#include <Box2D/Collision/Shapes/b2PolygonShape.h>

// Find the max separation between poly1 and poly2 using edge normals from poly1.
static float32 b2FindMaxSeparation(int32* edgeIndex,
             const b2PolygonShape* poly1, const b2Transform& xf1,
             const b2PolygonShape* poly2, const b2Transform& xf2)
{
  int32 count1 = poly1->m_count;
  int32 count2 = poly2->m_count;
  const b2Vec2* n1s = poly1->m_normals;
  const b2Vec2* v1s = poly1->m_vertices;
  const b2Vec2* v2s = poly2->m_vertices;
  b2Transform xf = b2MulT(xf2, xf1);

  int32 bestIndex = 0;
  float32 maxSeparation = -b2_maxFloat;
  for (int32 i = 0; i < count1; ++i)
  {
    // Get poly1 normal in frame2.
    b2Vec2 n = b2Mul(xf.q, n1s[i]);
    b2Vec2 v1 = b2Mul(xf, v1s[i]);

    // Find deepest point for normal i.
    float32 si = b2_maxFloat;
    for (int32 j = 0; j < count2; ++j)
    {
      float32 sij = b2Dot(n, v2s[j] - v1);
      if (sij < si)
      {
        si = sij;
      }
    }

    if (si > maxSeparation)
    {
      maxSeparation = si;
      bestIndex = i;
    }
  }

  *edgeIndex = bestIndex;
  return maxSeparation;
}

static void b2FindIncidentEdge(b2ClipVertex c[2],
           const b2PolygonShape* poly1, const b2Transform& xf1, int32 edge1,
           const b2PolygonShape* poly2, const b2Transform& xf2)
{
  const b2Vec2* normals1 = poly1->m_normals;

  int32 count2 = poly2->m_count;
  const b2Vec2* vertices2 = poly2->m_vertices;
  const b2Vec2* normals2 = poly2->m_normals;

  b2Assert(0 <= edge1 && edge1 < poly1->m_count);

  // Get the normal of the reference edge in poly2's frame.
  b2Vec2 normal1 = b2MulT(xf2.q, b2Mul(xf1.q, normals1[edge1]));

  // Find the incident edge on poly2.
  int32 index = 0;
  float32 minDot = b2_maxFloat;
  for (int32 i = 0; i < count2; ++i)
  {
    float32 dot = b2Dot(normal1, normals2[i]);
    if (dot < minDot)
    {
      minDot = dot;
      index = i;
    }
  }

  // Build the clip vertices for the incident edge.
  int32 i1 = index;
  int32 i2 = i1 + 1 < count2 ? i1 + 1 : 0;

  c[0].v = b2Mul(xf2, vertices2[i1]);
  c[0].id.cf.indexA = (uint8)edge1;
  c[0].id.cf.indexB = (uint8)i1;
  c[0].id.cf.typeA = b2ContactFeature::e_face;
  c[0].id.cf.typeB = b2ContactFeature::e_vertex;

  c[1].v = b2Mul(xf2, vertices2[i2]);
  c[1].id.cf.indexA = (uint8)edge1;
  c[1].id.cf.indexB = (uint8)i2;
  c[1].id.cf.typeA = b2ContactFeature::e_face;
  c[1].id.cf.typeB = b2ContactFeature::e_vertex;
}

// Find edge normal of max separation on A - return if separating axis is found
// Find edge normal of max separation on B - return if separation axis is found
// Choose reference edge as min(minA, minB)
// Find incident edge
// Clip

// The normal points from 1 to 2
void b2CollidePolygons(b2Manifold* manifold,
            const b2PolygonShape* polyA, const b2Transform& xfA,
            const b2PolygonShape* polyB, const b2Transform& xfB)
{
  manifold->pointCount = 0;
  float32 totalRadius = polyA->m_radius + polyB->m_radius;

  int32 edgeA = 0;
  float32 separationA = b2FindMaxSeparation(&edgeA, polyA, xfA, polyB, xfB);
  if (separationA > totalRadius)
    return;

  int32 edgeB = 0;
  float32 separationB = b2FindMaxSeparation(&edgeB, polyB, xfB, polyA, xfA);
  if (separationB > totalRadius)
    return;

  const b2PolygonShape* poly1;  // reference polygon
  const b2PolygonShape* poly2;  // incident polygon
  b2Transform xf1, xf2;
  int32 edge1;          // reference edge
  uint8 flip;
  const float32 k_tol = 0.1f * b2_linearSlop;

  if (separationB > separationA + k_tol)
  {
    poly1 = polyB;
    poly2 = polyA;
    xf1 = xfB;
    xf2 = xfA;
    edge1 = edgeB;
    manifold->type = b2Manifold::e_faceB;
    flip = 1;
  }
  else
  {
    poly1 = polyA;
    poly2 = polyB;
    xf1 = xfA;
    xf2 = xfB;
    edge1 = edgeA;
    manifold->type = b2Manifold::e_faceA;
    flip = 0;
  }

  b2ClipVertex incidentEdge[2];
  b2FindIncidentEdge(incidentEdge, poly1, xf1, edge1, poly2, xf2);

  int32 count1 = poly1->m_count;
  const b2Vec2* vertices1 = poly1->m_vertices;

  int32 iv1 = edge1;
  int32 iv2 = edge1 + 1 < count1 ? edge1 + 1 : 0;

  b2Vec2 v11 = vertices1[iv1];
  b2Vec2 v12 = vertices1[iv2];

  b2Vec2 localTangent = v12 - v11;
  localTangent.Normalize();

  b2Vec2 localNormal = b2Cross(localTangent, 1.0f);
  b2Vec2 planePoint = 0.5f * (v11 + v12);

  b2Vec2 tangent = b2Mul(xf1.q, localTangent);
  b2Vec2 normal = b2Cross(tangent, 1.0f);

  v11 = b2Mul(xf1, v11);
  v12 = b2Mul(xf1, v12);

  // Face offset.
  float32 frontOffset = b2Dot(normal, v11);

  // Side offsets, extended by polytope skin thickness.
  float32 sideOffset1 = -b2Dot(tangent, v11) + totalRadius;
  float32 sideOffset2 = b2Dot(tangent, v12) + totalRadius;

  // Clip incident edge against extruded edge1 side edges.
  b2ClipVertex clipPoints1[2];
  b2ClipVertex clipPoints2[2];
  int np;

  // Clip to box side 1
  np = b2ClipSegmentToLine(clipPoints1, incidentEdge, -tangent, sideOffset1, iv1);

  if (np < 2)
    return;

  // Clip to negative box side 1
  np = b2ClipSegmentToLine(clipPoints2, clipPoints1,  tangent, sideOffset2, iv2);

  if (np < 2)
  {
    return;
  }

  // Now clipPoints2 contains the clipped points.
  manifold->localNormal = localNormal;
  manifold->localPoint = planePoint;

  int32 pointCount = 0;
  for (int32 i = 0; i < b2_maxManifoldPoints; ++i)
  {
    float32 separation = b2Dot(normal, clipPoints2[i].v) - frontOffset;

    if (separation <= totalRadius)
    {
      b2ManifoldPoint* cp = manifold->points + pointCount;
      cp->localPoint = b2MulT(xf2, clipPoints2[i].v);
      cp->id = clipPoints2[i].id;
      if (flip)
      {
        // Swap features
        b2ContactFeature cf = cp->id.cf;
        cp->id.cf.indexA = cf.indexB;
        cp->id.cf.indexB = cf.indexA;
        cp->id.cf.typeA = cf.typeB;
        cp->id.cf.typeB = cf.typeA;
      }
      ++pointCount;
    }
  }

  manifold->pointCount = pointCount;
}

工作原理…

假设场景中的物体已经设置为刚体,并且为每个物体添加了适当的冲量,下一步是检测碰撞。冲量是作用在物体上的力。这种力短暂地作用在物体上,并导致动量的一些变化。

在游戏中,碰撞检测通常分为两个阶段。第一阶段称为广相碰撞,下一阶段称为窄相碰撞。广相阶段成本较低,因为它处理的是哪些物体最有可能发生碰撞的概念。窄相阶段成本更高,因为它实际上比较了每个物体是否发生碰撞。在游戏环境中,不可能将所有内容都放在窄相阶段。因此,大部分工作都是在广相阶段完成的。广相算法使用扫描和修剪(排序和修剪)或空间分区树。在扫描和修剪技术中,对实体的边界框的所有下端和上端进行排序并检查是否相交。之后,它被发送到窄相阶段进行更详细的检查。因此,在这种方法中,我们需要在实体改变方向时更新其边界框。另一种使用的技术是BSP。我们已经在之前的章节中讨论过 BSP。我们需要将场景分割成这样的方式,使得在每个子分区中,只有一定数量的物体可以发生碰撞。在窄相碰撞中,会应用更像素完美的碰撞检测算法。

有各种方法来检查碰撞。这完全取决于充当边界框的形状。此外,了解边界框的对齐方式也很重要。在正常情况下,边界框将是轴对齐的,并且将被称为AABB。要检测两个 Box2D 边界框是否发生碰撞,我们需要执行以下操作:

bool BoxesIntersect(const Box2D &a, const Box2D &b)
{
    if (a.max.x < b.min.x) return false; // a is left of b
    if (a.min.x > b.max.x) return false; // a is right of b
    if (a.max.y < b.min.y) return false; // a is above b
    if (a.min.y > b.max.y) return false; // a is below b
    return true; // boxes overlap
}

然后我们可以扩展这一点,以检测更复杂的形状,如矩形、圆形、线条和其他多边形。如果我们正在编写自己的 2D 物理引擎,那么我们将不得不为每种形状相互交叉编写一个函数。如果我们使用诸如 Box2D 或 PhysX 之类的物理引擎,这些函数已经被写好,我们只需要正确和一致地使用它们。

安装和集成 Box2D

要能够使用 2D 物理,一个很好的开源物理引擎是 Box2D。这个引擎带有许多对于任何 2D 游戏都常见的函数,因此我们不必重新发明轮子并重新编写它们。

准备工作

你需要有一台运行正常的 Windows 机器。

如何做…

按照以下步骤进行:

  1. 转到box2d.org/

  2. 浏览到box2d.org/downloads/

  3. 从 GitHub 下载或克隆最新版本。

  4. 在您的 Visual Studio 版本中构建解决方案。一些项目可能无法工作,因为它们是在不同版本的 Visual Studio 中构建的。

  5. 如果出现错误,请清理解决方案,删除bin文件夹,然后重新构建它。

  6. 解决方案成功重建后,运行TestBed项目。

  7. 如果您能成功运行应用程序,Box2D 已经集成。

工作原理…

Box2D 是一个完全由 C++构建的物理引擎。由于它给了我们访问源代码的权限,这意味着我们也可以从头开始构建它,并检查每个函数是如何编写的。由于该项目托管在 GitHub 上,每次进行新开发时,我们都可以克隆它并更新所有最新的代码。

在解决方案中,Box2D 已经有一个名为TestBed的项目,其中包含许多可以运行的示例应用程序。实际上,这是许多不同类型应用程序的集合。Test Entries是所有应用程序的入口点。它是一个包含我们想要在TestBed项目中呈现的不同应用程序的长数组。该数组包含应用程序的名称和初始化世界的静态函数。

最后,物理模拟的输出被馈送到渲染器,这种情况下是 OpenGL,并为我们绘制场景。

制作基本的 2D 游戏

每个 2D 游戏都是不同的。然而,我们可以概括将在大多数 2D 游戏中使用的物理函数。在这个教程中,我们将使用 Box2D 的内置函数和TestBed项目创建一个基本场景。该场景将模仿我们这个时代最流行的 2D 游戏之一,愤怒的小鸟TM。

准备工作

对于这个教程,您需要一台 Windows 机器和安装了 Visual Studio 的版本。不需要其他先决条件。

操作步骤…

在这个教程中,我们将发现使用 Box2D 为 2D 游戏添加一个简单的架构是多么容易:

class Tiles : public Test
{
public:
  enum
  {
    e_count = 10
  };

  Tiles()
  {
    m_fixtureCount = 0;
    b2Timer timer;

    {
      float32 a = 1.0f;
      b2BodyDef bd;
      bd.position.y = -a;
      b2Body* ground = m_world->CreateBody(&bd);

#if 1
      int32 N = 200;
      int32 M = 10;
      b2Vec2 position;
      position.y = 0.0f;
      for (int32 j = 0; j < M; ++j)
      {
        position.x = -N * a;
        for (int32 i = 0; i < N; ++i)
        {
          b2PolygonShape shape;
          shape.SetAsBox(a, a, position, 0.0f);
          ground->CreateFixture(&shape, 0.0f);
          ++m_fixtureCount;
          position.x += 2.0f * a;
        }
        position.y -= 2.0f * a;
      }
#else
      int32 N = 200;
      int32 M = 10;
      b2Vec2 position;
      position.x = -N * a;
      for (int32 i = 0; i < N; ++i)
      {
        position.y = 0.0f;
        for (int32 j = 0; j < M; ++j)
        {
          b2PolygonShape shape;
          shape.SetAsBox(a, a, position, 0.0f);
          ground->CreateFixture(&shape, 0.0f);
          position.y -= 2.0f * a;
        }
        position.x += 2.0f * a;
      }
#endif
    }

    {
      float32 a = 1.0f;
      b2PolygonShape shape;
      shape.SetAsBox(a, a);

      b2Vec2 x(-7.0f, 0.75f);
      b2Vec2 y;
      b2Vec2 deltaX(1.125f, 2.5f);
      b2Vec2 deltaY(2.25f, 0.0f);

      for (int32 i = 0; i < e_count; ++i)
      {
        y = x;

        for (int32 j = i; j < e_count; ++j)
        {
          b2BodyDef bd;
          bd.type = b2_dynamicBody;
          bd.position = y;

          b2Body* body = m_world->CreateBody(&bd);
          body->CreateFixture(&shape, 5.0f);
          ++m_fixtureCount;
          y += deltaY;
        }

        x += deltaX;
      }
    }

    m_createTime = timer.GetMilliseconds();
  }

  void Step(Settings* settings)
  {
    const b2ContactManager& cm = m_world->GetContactManager();
    int32 height = cm.m_broadPhase.GetTreeHeight();
    int32 leafCount = cm.m_broadPhase.GetProxyCount();
    int32 minimumNodeCount = 2 * leafCount - 1;
    float32 minimumHeight = ceilf(logf(float32(minimumNodeCount)) / logf(2.0f));
    g_debugDraw.DrawString(5, m_textLine, "dynamic tree height = %d, min = %d", height, int32(minimumHeight));
    m_textLine += DRAW_STRING_NEW_LINE;

    Test::Step(settings);

    g_debugDraw.DrawString(5, m_textLine, "create time = %6.2f ms, fixture count = %d",
      m_createTime, m_fixtureCount);
    m_textLine += DRAW_STRING_NEW_LINE;

  }

  static Test* Create()
  {
    return new Tiles;
  }

  int32 m_fixtureCount;
  float32 m_createTime;
};

#endif

工作原理…

在这个示例中,我们使用 Box2D 引擎来计算物理。如前所述,Test Entries的主类用于存储应用程序的名称和静态创建方法。在这种情况下,应用程序的名称是Tiles。在瓷砖应用程序中,我们使用 Box2D 形状和函数创建了一个物理世界。瓷砖金字塔是用方块创建的。这些方块是动态的,这意味着它们会根据施加在它们身上的力而做出反应和移动。基座或地面也由瓷砖制成。但是,这些瓷砖是静止的,不会移动。我们为构成地面和金字塔的所有瓷砖分配位置和速度。逐个为每个瓷砖分配位置和速度是不切实际的,因此我们使用迭代循环来实现这一点。

场景构建完成后,我们可以通过鼠标点击与金字塔进行交互。从 GUI 中,还可以打开或关闭其他属性。按下空格键还会在随机位置触发一个球,它将摧毁瓷砖的形成,就像愤怒的小鸟一样。我们还可以编写逻辑,使所有与地面碰撞的瓷砖消失,并在每次发生碰撞时为得分加分,然后我们就有了一个小型的 2D 愤怒的小鸟克隆。

制作 3D 游戏

当我们把注意力从 2D 物理转移到 3D 物理时,变化不大。现在我们需要担心另一个维度。如前面的教程中所述,我们仍然需要维护环境,使其遵循牛顿规则并解决约束。在 3D 空间中旋转物体时可能会出现很多问题。在这个教程中,我们将使用 Bullet Engine SDK 来查看 3D 物理的一个非常基本的实现。

准备工作

对于这个教程,您需要一台 Windows 机器和安装了 Visual Studio 的版本。

操作步骤…

在这个示例中,我们将看到在 3D 中编写物理世界是多么容易。

对于广相碰撞,请查看以下代码片段:

void  b3DynamicBvhBroadphase::getAabb(int objectId,b3Vector3& aabbMin, b3Vector3& aabbMax ) const
{
  const b3DbvtProxy*            proxy=&m_proxies[objectId];
  aabbMin = proxy->m_aabbMin;
  aabbMax = proxy->m_aabbMax;
}

对于窄相碰撞,请参阅以下代码:

void b3CpuNarrowPhase::computeContacts(b3AlignedObjectArray<b3Int4>& pairs, b3AlignedObjectArray<b3Aabb>& aabbsWorldSpace, b3AlignedObjectArray<b3RigidBodyData>& bodies)
{
  int nPairs = pairs.size();
  int numContacts = 0;
  int maxContactCapacity = m_data->m_config.m_maxContactCapacity;
  m_data->m_contacts.resize(maxContactCapacity);

  for (int i=0;i<nPairs;i++)
  {
    int bodyIndexA = pairs[i].x;
    int bodyIndexB = pairs[i].y;
    int collidableIndexA = bodies[bodyIndexA].m_collidableIdx;
    int collidableIndexB = bodies[bodyIndexB].m_collidableIdx;

    if (m_data->m_collidablesCPU[collidableIndexA].m_shapeType == SHAPE_SPHERE &&
      m_data->m_collidablesCPU[collidableIndexB].m_shapeType == SHAPE_CONVEX_HULL)
    {
//     computeContactSphereConvex(i,bodyIndexA,bodyIndexB,collidableIndexA,collidableIndexB,&bodies[0],
//     &m_data->m_collidablesCPU[0],&hostConvexData[0],&hostVertices[0],&hostIndices[0],&hostFaces[0],&hostContacts[0],nContacts,maxContactCapacity);
    }

    if (m_data->m_collidablesCPU[collidableIndexA].m_shapeType == SHAPE_

  m_data->m_contacts.resize(numContacts);

<. . . . . . .  More code to follow . . . . . . . .>
}

工作原理…

正如我们从上面的示例中看到的,即使在 3D 中,物理碰撞系统也必须分为阶段:广相和窄相。在广相碰撞中,我们现在考虑 Vector3,而不仅仅是两个浮点数,因为现在我们有三个轴(xyz)。我们需要输入对象 ID,然后检查边界框的范围。同样,对于窄相碰撞,我们的问题域和计算保持不变。我们现在将其更改为支持 3D。先前的示例显示了在窄相碰撞中需要找到接触点的问题的一部分。我们创建一个数组,并根据碰撞回调保存所有接触点。稍后,我们可以编写其他方法来检查这些点是否重叠。

创建一个粒子系统

粒子系统在游戏中非常重要,可以增加游戏整体感觉的视觉表现。粒子系统很容易编写,只是一个或多个粒子的集合。因此,我们需要创建一个具有一些属性的单个粒子,然后让粒子系统决定需要多少粒子。

准备工作

对于这个示例,您需要一台 Windows 机器和安装了 Visual Studio 的版本。

如何做…

添加一个名为Source.cpp的源文件。然后将以下代码添加到其中:

class Particle

{
  Vector3 location;
  Vector3 velocity;
  Vector3 acceleration;
  float lifespan;

  Particle(Vector3 vec)
  {

    acceleration = new Vector3(.05, 0.05);
    velocity = new Vector3(random(-3, 3), random(-4, 0));
    location = vec.get();
    lifespan = 125.0;
  }

    void run()
    {
    update();
    display();
    }

  void update() {
    velocity.add(acceleration);
    location.add(velocity);
    lifespan -= 2.0;
  }

  void display()
  {
    stroke(0, lifespan);
    fill(0, lifespan);
    trapezoid(location.x, location.y, 8, 8);
  }

    boolean isDead()
    {
    if (lifespan < 0.0) {
      return true;
    }
    else {
      return false;
    }
  }
};

Particle p;

void setup()
{
  size(800, 600);
  p = new Particle(new Vector3(width / 2, 10));
}

void draw()
{
  for (int i = 0; i < particles.size(); i++) {
    Particle p = particles.get(i);
    p.run();

      if (p.isDead()) {
        particles.remove(i);
      }
  }
}

工作原理…

正如我们在示例中看到的,我们的第一个任务是创建一个particle类。particle类将具有诸如velocityaccelerationpositionlifespan之类的属性。因为我们在 3D 空间中制作粒子,所以我们使用 Vector3 来表示粒子的属性。如果我们要在 2D 空间中创建粒子,我们将使用 Vector2 来做到这一点。在构造函数中,我们分配属性的起始值。然后我们有两个主要函数,updatedisplayupdate函数在每一帧更新velocityposition,并减少寿命,以便在寿命结束时消失。在display函数中,我们需要指定我们希望如何查看粒子:它是否应该有描边或填充等。在这里,我们还必须指定粒子的形状。最常见的形状是球体或圆锥体。我们使用了梯形只是为了表示我们可以指定任何形状。最后,从客户程序中,我们需要调用这个对象,然后访问各种函数来显示粒子。

然而,所有这些只会在屏幕上显示一个粒子。当然,我们可以创建一个包含 100 个对象的数组,这样就可以在屏幕上显示 100 个粒子。更好的方法是创建一个粒子系统,它可以创建一个粒子数组。要绘制的粒子数量由客户程序指定。根据请求,粒子系统绘制所需数量的粒子。此外,必须有一个函数来确定哪些粒子需要从屏幕上移除。这取决于每个粒子的寿命。

在游戏中使用布娃娃

布娃娃物理是一种特殊的程序动画,通常用作游戏中传统静态死亡动画的替代品。布娃娃动画的整个理念是,角色死亡后,身体的骨骼就像布娃娃一样行为。因此得名。这与现实主义无关,但为游戏增添了特别的乐趣元素。

准备工作

对于这个示例,您需要一台 Windows 机器和安装了 Visual Studio 的版本。还需要 DirectX SDK;最好使用 DirectX 2010 年 6 月版。

如何做…

让我们看一下以下代码:

#include "RagDoll.h"
#include "C3DETransform.h"
#include "PhysicsFactory.h"
#include "Physics.h"
#include "DebugMemory.h"

RagDoll::RagDoll(C3DESkinnedMesh * a_skinnedMesh, C3DESkinnedMeshContainer * a_skinnedMeshContainer, int totalParts, int totalConstraints)
{
  m_skinnedMesh = a_skinnedMesh;
  m_skinnedMeshContainer = a_skinnedMeshContainer;
  m_totalParts = totalParts;
  m_totalConstraints = totalConstraints;

  m_ragdollBodies = (btRigidBody**)malloc(sizeof(btRigidBody) * totalParts);
  m_ragdollShapes = (btCollisionShape**)malloc(sizeof(btCollisionShape) * totalParts);
  m_ragdollConstraints = (btTypedConstraint**)malloc(sizeof(btTypedConstraint) * totalConstraints);

  m_boneIndicesToFollow = (int*) malloc(sizeof(int) * m_skinnedMesh->GetTotalBones());

  m_totalBones = m_skinnedMesh->GetTotalBones();

  m_bonesCurrentWorldPosition = (D3DXMATRIX**)malloc(sizeof(D3DXMATRIX) * m_totalBones);

  m_boneToPartTransforms = (D3DXMATRIX**)malloc(sizeof(D3DXMATRIX) * m_totalBones);

  for(int i = 0; i < totalConstraints; i++)
  {
    m_ragdollConstraints[i] = NULL;
  }

  for(int i = 0; i < totalParts; i++)
  {
    m_ragdollBodies[i] = NULL;
    m_ragdollShapes[i] = NULL;

  }

  for(int i = 0; i < m_totalBones; i++)
  {    
    m_boneToPartTransforms[i] = NULL;
    m_boneToPartTransforms[i] = new D3DXMATRIX();

    m_bonesCurrentWorldPosition[i] = NULL;
    m_bonesCurrentWorldPosition[i] = new D3DXMATRIX();
  }

  m_constraintCount = 0;
}

RagDoll::~RagDoll()
{
  free(m_ragdollConstraints);  
  free(m_ragdollBodies);
  free(m_ragdollShapes);  

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

    delete m_boneToPartTransforms[i];
    m_boneToPartTransforms[i] = NULL;

    delete m_bonesCurrentWorldPosition[i];
    m_bonesCurrentWorldPosition[i] = NULL;
  }

  free(m_bonesCurrentWorldPosition);
  free(m_boneToPartTransforms);    
  free(m_boneIndicesToFollow);    

}

int RagDoll::GetTotalParts()
{
  return m_totalParts;
}

int RagDoll::GetTotalConstraints()
{
  return m_totalConstraints;
}

C3DESkinnedMesh *RagDoll::GetSkinnedMesh()
{
  return m_skinnedMesh;
}

//sets up a part of the ragdoll
//int index = the index number of the part
//int setMeshBoneTransformIndex = the bone index that this part is linked to,
//float offsetX, float offsetY, float offsetZ = translatin offset for the part in bone local space
//float mass = part's mass,
//btCollisionShape * a_shape = part's collision shape
void RagDoll::SetPart(int index, int setMeshBoneTransformIndex, float offsetX, float offsetY, float offsetZ,float mass, btCollisionShape * a_shape)
{  
  m_boneIndicesToFollow[setMeshBoneTransformIndex] = index;

  //we set the parts position according to the skinned mesh current position

  D3DXMATRIX t_poseMatrix = m_skinnedMeshContainer->GetPoseMatrix()[setMeshBoneTransformIndex];
  D3DXMATRIX *t_boneWorldRestMatrix = m_skinnedMesh->GetBoneWorldRestMatrix(setMeshBoneTransformIndex);

  D3DXMATRIX t_boneWorldPosition;
  D3DXMatrixMultiply(&t_boneWorldPosition, t_boneWorldRestMatrix, &t_poseMatrix);

  D3DXVECTOR3 * t_head = m_skinnedMesh->GetBoneHead(setMeshBoneTransformIndex);
  D3DXVECTOR3 * t_tail = m_skinnedMesh->GetBoneTail(setMeshBoneTransformIndex);        

  float tx = t_tail->x - t_head->x;
  float ty = t_tail->y - t_head->y;
  float tz = t_tail->z - t_head->z;

  //part's world matrix
  D3DXMATRIX *t_partMatrix = new D3DXMATRIX();
  *t_partMatrix = t_boneWorldPosition;

  D3DXMATRIX *t_centerOffset = new D3DXMATRIX();
  D3DXMatrixIdentity(t_centerOffset);
  D3DXMatrixTranslation(t_centerOffset, (tx / 2.0f) + offsetX, (ty / 2.0f) + offsetY, (tz/2.0f) + offsetZ);
  D3DXMatrixMultiply(t_partMatrix, t_partMatrix, t_centerOffset);

  D3DXVECTOR3 t_pos;
  D3DXVECTOR3 t_scale;
  D3DXQUATERNION t_rot;

  D3DXMatrixDecompose(&t_scale, &t_rot, &t_pos, t_partMatrix);

  btRigidBody* body = PhysicsFactory::GetInstance()->CreateRigidBody(mass,t_pos.x, t_pos.y, t_pos.z, t_rot.x, t_rot.y, t_rot.z, t_rot.w, a_shape);

  D3DXMATRIX t_partInverse;
  D3DXMatrixInverse(&t_partInverse, NULL, t_partMatrix);

  //puts the bone's matrix in part's local space, and store it in m_boneToPartTransforms
  D3DXMatrixMultiply(m_boneToPartTransforms[setMeshBoneTransformIndex], &t_boneWorldPosition, &t_partInverse);

  m_ragdollBodies[index] = body;

  delete t_partMatrix;
  t_partMatrix = NULL;

  delete t_centerOffset;
  t_centerOffset = NULL;

}

//when a bone is not going to have a part directly linked to it, it needs to follow a bone that has
//a part linked to
//int realBoneIndex = the bone that has no part linked
//int followBoneIndex = the bone that has a part linked
void RagDoll::SetBoneRelation(int realBoneIndex, int followBoneIndex)
{
  //it is going to the same thing the setPart method does, but the bone it is going to take
  //as a reference is the one passed as followBoneIndex and the the part's matrix is below
  //by calling GetPartForBoneIndex. Still there is going to be a new entry in m_boneToPartTransforms
  //which is the bone transform in the part's local space
  int partToFollowIndex = GetPartForBoneIndex(followBoneIndex);

  m_boneIndicesToFollow[realBoneIndex] = partToFollowIndex;

  D3DXMATRIX t_poseMatrix = m_skinnedMeshContainer->GetPoseMatrix()[realBoneIndex];
  D3DXMATRIX *t_boneWorldRestMatrix = m_skinnedMesh->GetBoneWorldRestMatrix(realBoneIndex);

  D3DXMATRIX t_boneWorldPosition;
  D3DXMatrixMultiply(&t_boneWorldPosition, t_boneWorldRestMatrix, &t_poseMatrix);

  D3DXMATRIX *t_partMatrix = new D3DXMATRIX();
  btTransform t_partTransform = m_ragdollBodies[partToFollowIndex]->getWorldTransform();
  *t_partMatrix = BT2DX_MATRIX(t_partTransform);

  D3DXMATRIX t_partInverse;
  D3DXMatrixInverse(&t_partInverse, NULL, t_partMatrix);

  D3DXMatrixMultiply(m_boneToPartTransforms[realBoneIndex], &t_boneWorldPosition, &t_partInverse);    

  delete t_partMatrix;
  t_partMatrix = NULL;  

}

btRigidBody ** RagDoll::GetRadollParts()
{
  return m_ragdollBodies;
}

btTypedConstraint **RagDoll::GetConstraints()
{
  return m_ragdollConstraints;
}

void RagDoll::AddConstraint(btTypedConstraint *a_constraint)
{
  m_ragdollConstraints[m_constraintCount] = a_constraint;
  m_constraintCount++;
}

//This method will return the world position that the given bone should have
D3DXMATRIX * RagDoll::GetBoneWorldTransform(int boneIndex)
{
  //the part world matrix is fetched, and then we apply the bone transform offset to obtain
  //the bone's world position
  int t_partIndex = GetPartForBoneIndex(boneIndex);

  btTransform  t_transform = m_ragdollBodies[t_partIndex]->getWorldTransform();    
  D3DXMATRIX t_partMatrix = BT2DX_MATRIX(t_transform);

  D3DXMatrixIdentity(m_bonesCurrentWorldPosition[boneIndex]);
  D3DXMatrixMultiply(m_bonesCurrentWorldPosition[boneIndex], m_boneToPartTransforms[boneIndex], &t_partMatrix);

  return m_bonesCurrentWorldPosition[boneIndex];
}

int RagDoll::GetPartForBoneIndex(int boneIndex)
{
  for(int i = 0; i < m_totalBones;i ++)
  {
    if(i == boneIndex)
    {
      return m_boneIndicesToFollow[i];
    }
  }

  return -1;
}

工作原理…

从上面的例子中可以看出,对于这个例子,你需要一个蒙皮网格模型。网格模型可以从一些免版税的网站下载,也可以通过 Blender 或任何其他 3D 软件包(如 Maya 或 Max)制作。由于布娃娃的整个概念是基于网格的骨骼,我们必须确保 3D 模型的骨骼设置正确。

之后,代码中有很多小部分。问题的第一部分是编写一个骨骼容器类,用于存储所有骨骼信息。接下来,我们需要使用骨骼容器类,并使用 Bullet 物理 SDK,为每个骨骼分配一个刚体。在设置好刚体之后,我们需要再次遍历骨骼,并创建骨骼之间的关系,这样当一个骨骼移动时,相邻的骨骼也会移动。最后,我们还需要添加约束,以便当物理引擎模拟布娃娃时,可以正确解决约束并将结果输出到骨骼。

第十章:游戏开发中的多线程

在本章中,将涵盖以下配方:

  • 游戏中的并发性-创建线程

  • 加入和分离线程

  • 向线程传递参数

  • 避免死锁

  • 数据竞争和互斥

  • 编写线程安全的类

介绍

要理解多线程,让我们首先了解线程的含义。线程是并发执行的单位。它有自己的调用堆栈,用于调用的方法,它们的参数和局部变量。每个应用程序在启动时至少有一个正在运行的线程,即主线程。当我们谈论多线程时,意味着一个进程有许多独立和并发运行的线程,但具有共享内存。通常,多线程与多处理混淆。多处理器有多个运行的进程,每个进程都有自己的线程。

尽管多线程应用程序可能编写起来复杂,但它们是轻量级的。然而,多线程架构不适合分布式应用程序。在游戏中,我们可能有一个或多个线程在运行。关键问题是何时以及为什么应该使用多线程。虽然这是相当主观的,但如果您希望多个任务同时发生,您将使用多线程。因此,如果您不希望游戏中的物理代码或音频代码等待主循环完成处理,您将对物理和音频循环进行多线程处理。

游戏中的并发性-创建线程

编写多线程代码的第一步是生成一个线程。在这一点上,我们必须注意应用程序已经运行了一个活动线程,即主线程。因此,当我们生成一个线程时,应用程序中将有两个活动线程。

准备工作

要完成这个配方,您需要一台运行 Windows 和 Visual Studio 的计算机。不需要其他先决条件。

如何做...

在这个配方中,我们将看到生成线程有多么容易。添加一个名为Source.cpp的源文件,并将以下代码添加到其中:

int ThreadOne()
{
  std::cout << "I am thread 1" << std::endl;
  return 0;
}

int main()
{
  std::thread T1(ThreadOne);

  if (T1.joinable()) // Check if can be joined to the main thread
    T1.join();     // Main thread waits for this to finish

  _getch();
  return 0;
}

它是如何工作的...

第一步是包含头文件thread.h。这使我们可以访问所有内置库,以便创建我们的多线程应用程序所需的所有库。下一步是创建我们需要线程的任务或函数。在这个例子中,我们创建了一个名为ThreadOne的函数。这个函数代表我们可以用来多线程的任何函数。这可以是物理函数,音频函数,或者我们可能需要的任何函数。为简单起见,我们使用了一个打印消息的函数。下一步是生成一个线程。我们只需要编写关键字thread,为线程分配一个名称(T1),然后编写我们想要线程的函数/任务。在这种情况下,它是ThreadOne

这会生成一个线程,并且不会独立于主线程执行。

加入和分离线程

线程生成后,它作为一个新任务开始执行,与主线程分开。然而,可能存在一些情况,我们希望任务重新加入主线程。这是可能的。我们可能还希望线程始终与主线程保持分离。这也是可能的。然而,在连接到主线程和分离时,我们必须采取一些预防措施。

准备工作

您需要一台运行 Windows 和 Visual Studio 的工作计算机。

如何做...

在这个配方中,我们将看到加入和分离线程有多么容易。添加一个名为Source.cpp的源文件。将以下代码添加到其中:

int ThreadOne()
{
  std::cout << "I am thread 1" << std::endl;
  return 0;
}

int ThreadTwo()
{
  std::cout << "I am thread 2" << std::endl;
  return 0;
}

int main()
{
  std::thread T1(ThreadOne);
  std::thread T2(ThreadTwo);

  if (T1.joinable()) // Check if can be joined to the main thread
    T1.join();     // Main thread waits for this to finish

  T2.detach();    //Detached from main thread

  _getch();
  return 0;
}

它是如何工作的...

在上面的例子中,首先生成了两个线程。这两个线程是T1T2。当线程被生成时,它们会独立并发地运行。然而,当需要将任何线程重新加入到主线程时,我们也可以这样做。首先,我们需要检查线程是否可以加入到主线程。我们可以通过 joinable 函数来实现这一点。如果函数返回true,则线程可以加入到主线程。我们可以使用join函数加入到主线程。如果我们直接加入,而没有首先检查线程是否可以加入到主线程,可能会导致主线程无法接受该线程而出现问题。线程加入到主线程后,主线程会等待该线程完成。

如果我们想要将线程从主线程分离,我们可以使用detach函数。然而,在我们将其从主线程分离后,它将永远分离。

向线程传递参数

就像在函数中一样,我们可能还想将参数和参数传递给线程。由于线程只是任务,而任务只是一系列函数的集合,因此有必要了解如何向线程发送参数。如果我们可以在运行时向线程发送参数,那么线程可以动态执行所有操作。在大多数情况下,我们会将物理、人工智能或音频部分线程化。所有这些部分都需要接受参数的函数。

准备工作

你需要一台 Windows 机器和一个安装好的 Visual Studio 副本。不需要其他先决条件。

如何做…

在这个食谱中,我们将发现为我们的游戏添加启发式函数进行路径规划有多么容易。添加一个名为Source.cpp的源文件。将以下代码添加到其中:

class Wrapper
{
public:
  void operator()(std::string& msg)
  {
    msg = " I am from T1";
    std::cout << "T1 thread initiated" << msg << std::endl;

  }
};

int main()
{
  std::string s = "This is a message";
  std::cout << std::this_thread::get_id() << std::endl;

  std::thread T1((Wrapper()), std::move(s));
  std::cout << T1.get_id() << std::endl;

  std::thread T2 = std::move(T1);
  T2.join();

  _getch();

}

工作原理…

传递参数的最佳方法是编写一个Wrapper类并重载()运算符。在我们重载()运算符之后,我们可以向线程发送参数。为此,我们创建一个字符串并将字符串存储在一个变量中。然后我们需要像往常一样生成一个线程;然而,我们不仅仅传递函数名,而是传递类名和字符串。在线程中,我们需要通过引用传递参数,因此我们可以使用ref函数。然而,更好的方法是使用move函数,其中我们注意内存位置本身并将其传递给参数。operator函数接受字符串并打印消息。

如果我们想创建一个新线程并使其与第一个线程相同,我们可以再次使用move函数来实现这一点。除此之外,我们还可以使用get_id函数来获取线程的 ID。

避免死锁

当两个或更多任务想要使用相同的资源时,就会出现竞争条件。在一个任务完成使用资源之前,另一个任务无法访问它。这被称为死锁,我们必须尽一切努力避免死锁。例如,资源Collision和资源Audio被进程Locomotion和进程Bullet使用:

  • Locomotion开始使用Collision

  • LocomotionBullet尝试开始使用Audio

  • Bullet“赢得”并首先获得Audio

  • 现在Bullet需要使用Collision

  • CollisionLocomotion锁定,它正在等待Bullet

准备工作

对于这个食谱,你需要一台 Windows 机器和一个安装好的 Visual Studio 副本。

如何做…

在这个食谱中,我们将发现避免死锁有多么容易:

#include <thread>
#include <string>
#include <iostream>

using namespace std;

void Physics()
{
  for (int i = 0; i > -100; i--)
    cout << "From Thread 1: " << i << endl;

}

int main()
{
  std::thread t1(Physics);
  for (int i = 0; i < 100; i++)
    cout << "From main: " << i << endl;

  t1.join();

  int a;
  cin >> a;
  return 0;
}

工作原理…

在上面的例子中,我们生成了一个名为t1的线程,它开始一个函数以从 0 到-100 打印数字,递减 1。还有一个主线程,它开始从 0 到 100 打印数字,递增 1。同样,我们选择了这些函数是为了简单理解。这些可以很容易地被A算法和搜索算法或其他任何我们想要的东西所替代。

如果我们看控制台输出,我们会注意到它非常混乱。原因是cout对象被主线程和t1同时使用。因此,发生了数据竞争的情况。每次谁赢得了竞争,谁就会显示数字。我们必须尽一切努力避免这种编程结构。很多时候,它会导致死锁和中断。

数据竞争和互斥锁

数据竞争条件在多线程应用程序中非常常见,但我们必须避免这种情况,以防止死锁发生。互斥锁帮助我们克服死锁。互斥锁是一个程序对象,允许多个程序线程共享相同的资源,比如文件访问,但不是同时。当程序启动时,会创建一个带有唯一名称的互斥锁。

准备工作

对于这个食谱,你需要一台 Windows 机器和安装了 Visual Studio 的版本。

如何做…

在这个食谱中,我们将看到理解数据竞争和互斥锁是多么容易。添加一个名为Source.cpp的源文件,并将以下代码添加到其中:

#include <thread>
#include <string>
#include <mutex>
#include <iostream>

using namespace std;

std::mutex MU;

void Locomotion(string msg, int id)
{
  std::lock_guard<std::mutex> guard(MU); //RAII
  //MU.lock();
  cout << msg << id << endl;
  //MU.unlock();
}
void InterfaceFunction()
{
  for (int i = 0; i > -100; i--)
    Locomotion(string("From Thread 1: "), i);

}

int main()
{
  std::thread FirstThread(InterfaceFunction);
  for (int i = 0; i < 100; i++)
    Locomotion(string("From Main: "), i);

  FirstThread.join();

  int a;
  cin >> a;
  return 0;
}

它是如何工作的…

在这个例子中,主线程和t1都想显示一些数字。然而,由于它们都想使用cout对象,这就产生了数据竞争的情况。为了避免这种情况,一种方法是使用互斥锁。因此,在执行print语句之前,我们有mutex.lock,在print语句之后,我们有mutex.unlock。这样可以工作,并防止数据竞争条件,因为互斥锁将允许一个线程使用资源,并使另一个线程等待它。然而,这个程序还不是线程安全的。这是因为如果cout语句抛出错误或异常,互斥锁将永远不会被解锁,其他线程将始终处于等待状态。

为了避免这种情况,我们将使用 C++的资源获取即初始化技术RAII)。我们在函数中添加一个内置的锁保护。这段代码是异常安全的,因为 C++保证所有堆栈对象在封闭范围结束时被销毁,即所谓的堆栈展开。当从函数返回时,锁和文件对象的析构函数都将被调用,无论是否抛出了异常。因此,如果发生异常,它不会阻止其他线程永远等待。尽管这样做,这个应用程序仍然不是线程安全的。这是因为cout对象是一个全局对象,因此程序的其他部分也可以访问它。因此,我们需要进一步封装它。我们稍后会看到这一点。

编写一个线程安全的类

在处理多个线程时,编写一个线程安全的类变得非常重要。如果我们不编写线程安全的类,可能会出现许多复杂情况,比如死锁。我们还必须记住,当我们编写线程安全的类时,就不会有数据竞争和互斥锁的潜在危险。

准备工作

对于这个食谱,你需要一台 Windows 机器和安装了 Visual Studio 的版本。

如何做…

在这个食谱中,我们将看到在 C++中编写一个线程安全的类是多么容易。添加一个名为Source.cpp的源文件,并将以下代码添加到其中:

#include <thread>
#include <string>
#include <mutex>
#include <iostream>
#include <fstream>

using namespace std;

class DebugLogger
{
  std::mutex MU;
  ofstream f;
public:
  DebugLogger()
  {
    f.open("log.txt");
  }
  void ResourceSharingFunction(string id, int value)
  {
    std::lock_guard<std::mutex> guard(MU); //RAII
    f << "From" << id << ":" << value << endl;
  }

};

void InterfaceFunction(DebugLogger& log)
{
  for (int i = 0; i > -100; i--)
    log.ResourceSharingFunction(string("Thread 1: "), i);

}

int main()
{
  DebugLogger log;
  std::thread FirstThread(InterfaceFunction,std::ref(log));
  for (int i = 0; i < 100; i++)
    log.ResourceSharingFunction(string("Main: "), i);

  FirstThread.join();

  int a;
  cin >> a;
  return 0;
}

它是如何工作的…

在上一个食谱中,我们看到尽管编写了互斥锁和锁,我们的代码仍然不是线程安全的。这是因为我们使用了一个全局对象cout,它也可以从代码的其他部分访问,因此不是线程安全的。因此,我们通过添加一层抽象来避免这样做,并将结果输出到日志文件中。

我们已经创建了一个名为Logfile的类。在这个类里,我们创建了一个锁保护和一个互斥锁。除此之外,我们还创建了一个名为f的流对象。使用这个对象,我们将内容输出到一个文本文件中。需要访问这个功能的线程将需要创建一个LogFile对象,然后适当地使用这个函数。我们在 RAII 系统中使用了锁保护。由于这种抽象层,外部无法使用这个功能,因此是非常安全的。

然而,即使在这个程序中,我们也需要采取一定的预防措施。我们应该采取的第一项预防措施是不要从任何函数中返回f。此外,我们必须小心,f不应该直接从任何其他类或外部函数中获取。如果我们做了上述任何一项,资源f将再次可用于程序的外部部分,将不受保护,因此将不再是线程安全的。

第十一章:游戏开发中的网络

本章将涵盖以下配方:

  • 理解不同的层

  • 选择适当的协议

  • 序列化数据包

  • 在游戏中使用套接字编程

  • 发送数据

  • 接收数据

  • 处理延迟

  • 使用同步模拟

  • 使用感兴趣区域过滤

  • 使用本地感知过滤

介绍

在现代视频游戏时代,网络在游戏的整体可玩性中扮演着重要角色。单人游戏提供平均约 15-20 小时的游戏时间。然而,通过多人游戏(联网)功能,游戏时间呈指数增长,因为现在用户必须与其他人类对手进行游戏并改进他们的战术。无论是 PC 游戏、游戏机还是移动游戏,具有多人游戏功能如今已成为一种常见特性。对于游戏的免费模式,其中货币化和收入模式基于应用内购买和广告,游戏必须每天拥有数千或数百万活跃用户。这是游戏赚钱的唯一途径。当我们谈论多人游戏时,我们不应该自欺欺人地认为这仅限于实时PvP(玩家对玩家)。它也可以是异步多人游戏,玩家与活跃玩家的数据竞争,而不是与玩家本身竞争。它给人一种错觉,即玩家正在与真实玩家竞争。此外,随着社交媒体的出现,网络也在帮助你与朋友竞争。例如,在Candy Crush中,完成一个关卡后,你会看到你的朋友在同一关卡中的表现以及下一个要击败的朋友是谁。所有这些增加了游戏的热度,并迫使你继续玩下去。

理解不同的层

从技术角度看,整个网络模型被划分为多个层。这个模型也被称为OSI开放系统互连)模型。每一层都有特殊的意义,必须正确理解才能与拓扑的其他层进行交互。

准备就绪

要完成这个配方,您需要一台运行 Windows 的机器。

如何做…

在这个配方中,我们将看到理解网络拓扑的不同层有多容易。看看下面的图表:

如何做…

工作原理…

要理解 OSI 模型,我们必须从堆栈的底部向上查看模型。OSI 模型的层包括:

  • 物理层:这建立了与网络的实际物理连接。这取决于我们是使用铜线还是光纤。它定义了所使用的网络拓扑结构,环形或总线等。它还定义了传输模式:是单工、半双工还是全双工。

  • 数据链路层:这提供了两个连接节点之间的实际链接。数据链路层有两个子层:MAC层(媒体访问控制)和LLC层(逻辑链路控制)。

  • 网络层:这一层提供了传输可变长度数据(称为数据报)的功能手段。传输发生在同一网络上的一个连接节点到另一个连接节点。这形成了 IP。

  • 传输层:这一层还提供了传输数据的功能手段。数据从源传输到目的地,经过一个或多个网络。这里使用的一些协议是 TCP 和 UDP。TCP传输控制协议,是一个安全连接。UDP用户数据报协议,是不太安全的。在视频游戏中,我们同时使用 TCP 和 UDP 协议。当用户需要登录服务器时,我们使用 TCP,因为它更安全,因为除非服务器对先前的数据做出确认,否则不会发送来自客户端的下一个信息。然而,它可能会比较慢,所以如果安全性比速度更重要,我们使用 TCP。用户登录后,游戏在其他玩家加入后开始。现在我们在大多数情况下使用 UDP,因为速度比安全性更重要,而少量丢失的数据包可能会产生巨大影响。UDP 数据包并不总是被接收,因为没有确认。

  • 会话层:这一层控制网络和远程计算机之间的连接。这一层负责建立、管理和终止连接。

  • 表示层:这一层控制需要在连接之间建立的不同语义。所有加密逻辑都写在这一层。

  • 应用层:这一层处理与软件应用程序本身的通信。这是离最终用户最近的一层。

选择适当的协议

在游戏中,大部分时间都需要做一个重要的决定:使用 TCP 还是 UDP。决定往往会偏向 UDP,但了解两者之间的区别仍然很重要。

准备工作

你需要一台 Windows 机器。不需要其他先决条件。

如何做…

在这个示例中,我们将发现决定使用 TCP 还是 UDP 有多么容易。

问以下问题:

  • 系统是否需要可靠交付?

  • 是否需要重传的要求?

  • 系统是否需要任何握手机制?

  • 它需要什么样的拥塞控制?

  • 速度是否是系统考虑的因素?

它是如何工作的…

TCP 和 UDP 建立在 IP 层之上:

它是如何工作的...

TCP 连接被认为是可靠的,因为启用了双向握手系统。一旦消息传递到终点,就会发送一个确认消息。它还支持各种其他服务,如拥塞控制和多路复用。TCP 也是全双工的,使其成为一个相当强大的连接。它通过字节序列号来处理数据的可靠传输。它设置了一个超时函数,并根据超时来决定包是否已经被传递。下图显示了握手协议是如何建立的:

它是如何工作的...

TCP 的另一个机制是滑动窗口机制,它保证了数据的可靠传递。它确保数据包按顺序传递,并在发送方和接收方之间建立了流量控制。

当我们不太关心数据包的顺序交付时,就会使用 UDP。主要关注的是数据包的快速交付。没有可靠性,也没有保证数据包会被交付。

需要有序交付的应用程序必须自行恢复数据报的顺序。数据报可以被写入目标地址,而不知道它是否存在或正在监听。消息也可以广播到特定子网上的所有主机。DOOM就是这样做的。有时,如果我们需要最小的可靠性,UDP 可以添加该功能。在那时,它也被称为可靠 UDP。

序列化数据包

序列化是网络系统中必须具备的一个关键特性。序列化的过程涉及将消息或数据转换为可以在网络上传输的格式,然后进行解码。有各种各样的序列化和反序列化数据的方式,最终取决于个人选择。

准备工作

你需要一个工作的 Windows 机器和 Visual Studio。不需要其他要求。

如何做…

在这个示例中,我们将看到序列化数据是多么容易。创建一个源文件,并从序列化类派生它:

using namespace xmls;

class LastUsedDocument: public Serializable
{
public:
  LastUsedDocument();
  xString Name;
  xString Path;
  xInt Size;
};

class DatabaseLogin: public Serializable
{
public:
  DatabaseLogin();
  xString HostName;
  xInt Port;
  xString User;
  xString Password;
};

class SerialisationData: public Serializable
{
public:
  SerialisationData();
  xString Data1;
  xString Data2;
  xString Data3;
  xInt Data4;
  xInt Data5;
  xBool Data6;
  xBool Data7;
  DatabaseLogin Login;
  Collection<LastUsedDocument> LastUsedDocuments;
};

LastUsedDocument::LastUsedDocument()
{
  setClassName("LastUsedDocument");
  Register("Name", &Name);
  Register("Path", &Path);
  Register("Size", &Size);
};

DatabaseLogin::DatabaseLogin()
{
  setClassName("DatabaseLogin");
  Register("HostName", &HostName);
  Register("Port", &Port);
  Register("User", &User);
  Register("Password", &Password);
};

SerialisationData::SerialisationData()
{
  setClassName("SerialisationData");
  Register("Data1", &Data1);
  Register("Data2", &Data2);
  Register("Data3", &Data3);
  Register("Data4", &Data4);
  Register("Data5", &Data5);
  Register("Data6", &Data6);
  Register("Data7", &Data7);
  Register("Login", &Login);
  Register("LastUsedDocuments", &LastUsedDocuments);
  setVersion("2.1");
};

int main()
{
  // Creating the Datas object
  cout << "Creating object..." << endl;
  SerialisationData *Datas=new SerialisationData;
  Datas->Data1="This is the first string";
  Datas->Data2="This is the second random data";
  Datas->Data3="3rd data";
  Datas->Data4=1234;
  Datas->Data5=5678;
  Datas->Data6=false;
  Datas->Data7=true;
  Datas->Login.HostName="aws.localserver.something";
  Datas->Login.Port=2000;
  Datas->Login.User="packt.pub";
  Datas->Login.Password="PacktPassword";

  for (int docNum=1; docNum<=10; docNum++)
  {
    LastUsedDocument *doc = Datas->LastUsedDocuments.newElement();
    std::stringstream docName;
    docName << "Document #" << docNum;
    doc->Name = docName.str();
    doc->Path = "{FILEPATH}"; // Set Placeholder for search/replace
    doc->setVersion("1.1");
  }

  cout << "OK" << endl;

  // Serialize the Datas object
  cout << "Serializing object... " << endl;
  string xmlData = Datas->toXML();
  cout << "OK" << endl << endl;
  cout << "Result:" << endl;
  cout << xmlData << endl << endl;

  cout << "Login, URL:" << endl;
  cout << "Hostname: " << Datas->Login.HostName.value();
  cout << ":" << Datas->Login.Port.toString() << endl << endl;
  cout << "Show all collection items" << endl;
  for (size_t i=0; i<Datas->LastUsedDocuments.size(); i++)
  {
    LastUsedDocument* doc = Datas->LastUsedDocuments.getItem(i);
    cout << "Item " << i << ": " << doc->Name.value() << endl;
  }
  cout << endl;

  cout << "Deserialization:" << endl;
  cout << "Class version: " << Serializable::IdentifyClassVersion(xmlData) << endl;
  cout << "Performing deserialization..." << endl;

  // Deserialize the XML text
  SerialisationData* dser_Datas=new SerialisationData;
  if (Serializable::fromXML(xmlData, dser_Datas))
  {
    cout << "OK" << endl << endl;

    // compare both objects
    cout << "Compareing objects: ";
    if (dser_Datas->Compare(Datas))
      cout << "equal" << endl << endl; 
else
      cout << "net equal" << endl << endl;

    // now set value
    cout << "Set new value for field >password<..." << endl;
    dser_Datas->Login.Password = "newPassword";
    cout << "OK" << endl << endl;

    cout << "compare objects again: ";
    if (dser_Datas->Compare(Datas))
      cout << "equal" << endl << endl; else
      cout << "net equal" << endl << endl;

    cout << "search and replace placeholders: ";
    dser_Datas->Replace("{FILEPATH}", "c:\\temp\\");
    cout << "OK" << endl << endl;

    //output xml-data
    cout << "Serialize and output xml data: " << endl;
    cout << dser_Datas->toXML() << endl << endl;

    cout << "Clone object:" << endl;
    SerialisationData *clone1(new SerialisationData);
    Serializable::Clone(dser_Datas, clone1);
    cout << "Serialize and output clone: " << endl;
    cout << clone1->toXML() << endl << endl;
    delete (clone1);
  }
  delete(Datas);
  delete(dser_Datas);
  getchar();
  return 0;
}

它是如何工作的…

如前所述,序列化是将数据转换为可以传输的格式。我们可以使用 Google API,或者使用 JSON 格式或 YAML 来实现。在这个示例中,我们使用了最初由 Lothar Perr 编写的 XML 序列化器。原始源代码可以在www.codeproject.com/Tips/725375/Tiny-XML-Serialization-for-Cplusplus找到。程序的整个思想是将数据转换为 XML 格式。在可序列化数据类中,我们公开地从可序列化类派生它。我们创建一个构造函数来注册所有的数据元素,并创建我们想要序列化的不同数据元素。数据元素是xString类的类型。在构造函数中,我们注册每个数据元素。最后,从客户端,我们分配正确的数据进行发送,并使用 XML 序列化器类和 tinyxml 生成所需的 XML。最后,这个 XML 将被发送到网络上,并在接收时,将使用相同的逻辑进行解码。XML 有时被认为对游戏来说相当沉重和繁琐。

在这些情况下,建议使用 JSON。一些现代引擎,如 Unity3D 和虚幻引擎,已经内置了可以用来序列化数据的 JSON 解析器。然而,XML 仍然是一个重要的格式。我们的代码可能产生的一个可能的输出示例如下:

它是如何工作的…

在游戏中使用套接字编程

套接字编程是最早用于在端到端连接之间传输数据的机制之一。即使现在,如果你习惯于编写套接字编程,它对于相对较小的游戏来说比使用第三方解决方案要好得多,因为它们会增加很多额外的空间。

准备工作

对于这个示例,你需要一个 Windows 机器和安装了 Visual Studio 的版本。

如何做…

在这个示例中,我们将发现编写套接字是多么容易:

struct sockaddr_in
{
      short      sin_family;
      u_short      sin_port;
      struct      in_addr sin_addr;
      char      sin_zero[8];
};

int PASCAL connect(SOCKET,const struct sockaddr*,int);
    target.sin_family = AF_INET; // address family Internet
    target.sin_port = htons (PortNo); //Port to connect on
    target.sin_addr.s_addr = inet_addr (IPAddress); //Target IP

    s = socket (AF_INET, SOCK_STREAM, IPPROTO_TCP); //Create socket
    if (s == INVALID_SOCKET)
    {
        return false; //Couldn't create the socket
    }

它是如何工作的…

当两个应用程序在不同的机器上进行通信时,通信通道的一端通常被描述为套接字。它是 IP 地址和端口的组合。当我们在不同的机器上使用信号或管道进行进程间通信时,就需要套接字。

伯克利套接字BSD)是第一个开发的互联网套接字 API。它是在加利福尼亚大学伯克利分校开发的,并免费提供给 UNIX 的所有伯克利系统发行版,它存在于所有现代操作系统中,包括 UNIX 变体,如 OS X 和 Linux。Windows 套接字基于 BSD 套接字,并提供额外的功能以符合常规的 Windows 编程模型。Winsock2 是最新的 API。

常见的域有:

  • AF UNIX:这个地址格式是 UNIX 路径名

  • AF INET:这个地址格式是主机和端口号

各种协议可以以以下方式使用:

  • TCP/IP(虚拟电路):SOCK_STREAM

  • UDP(数据报):SOCK_DGRAM

这些是建立简单套接字连接的步骤:

  1. 创建一个套接字。

  2. 将套接字绑定到一个地址。

  3. 等待套接字准备好进行输入/输出。

  4. 从套接字读取和写入。

  5. 重复从步骤 3 直到完成。

  6. 关闭套接字。

这些步骤在这里通过示例进行了解释:

  • int socket(domain, type, protocol)

参数domain应设置为PF_INET(协议族),而type是应该使用的连接类型。对于字节流套接字,使用SOCK_STREAM,而对于数据报(数据包)套接字,使用SOCK_DGRAMprotocol是正在使用的 Internet 协议。SOCK_STREAM通常会给出IPPROTO_TCP,而SOCK_DGRAM通常会给出IPPROTO_UDP

  • int sockfd;
sockfd = socket (PF_INET, SOCK_STREAM, 0):

socket()函数返回一个套接字描述符,供以后的系统调用使用,或者返回-1。当协议设置为0时,套接字会根据指定的类型选择正确的协议。

  • int bind(int Socket, struct sockaddr *myAddress, int AddressLen )

bind()函数将套接字绑定到本地地址。套接字是套接字描述符。myAddress是本地 IP 地址和端口。AddressSize参数给出地址的大小(以字节为单位),bind()在错误时返回-1

  • struct sockaddr_in {
  short int sin_family;     // set to AF_INET
  unsigned short int sin_port;   // Port number
  struct in_addr sin_addr;   // Internet address
  unsigned char sin_zero[8];   //set to all zeros
}

struct sockaddr_in是一个并行结构,它使得引用套接字地址的元素变得容易。sin_portsin_addr必须以网络字节顺序表示。

发送数据

在正确设置了套接字之后,下一步是创建正确的服务器和客户端架构。发送数据非常简单,只涉及几行代码。

准备工作

要完成这个教程,你需要一台安装了 Visual Studio 的 Windows 机器。

如何做…

在这个教程中,我们将看到发送数据是多么容易:

// Using the SendTo Function
#ifndef UNICODE
#define UNICODE
#endif

#define WIN32_LEAN_AND_MEAN

#include <winsock2.h>
#include <Ws2tcpip.h>
#include <stdio.h>
#include <conio.h>

// Link with ws2_32.lib
#pragma comment(lib, "Ws2_32.lib")

int main()
{

  int iResult;
  WSADATA wsaData;

  SOCKET SenderSocket = INVALID_SOCKET;
  sockaddr_in ReceiverAddress;

  unsigned short Port = 27015;

  char SendBuf[1024];
  int BufLen = 1024;

  //----------------------
  // Initialize Winsock
  iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
  if (iResult != NO_ERROR) {
    wprintf(L"WSAStartup failed with error: %d\n", iResult);
    return 1;

  }

  //---------------------------------------------
  // Create a socket for sending data
  SenderSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
  if (SenderSocket == INVALID_SOCKET) {
    wprintf(L"socket failed with error: %ld\n", WSAGetLastError());
    WSACleanup();
    return 1;
  }
  //---------------------------------------------
  // Set up the ReceiverAddress structure with the IP address of
  // the receiver (in this example case "192.168.1.1")
  // and the specified port number.
  ReceiverAddress.sin_family = AF_INET;
  ReceiverAddress.sin_port = htons(Port);
  ReceiverAddress.sin_addr.s_addr = inet_addr("192.168.1.1");

  //---------------------------------------------
  // Send a datagram to the receiver
  wprintf(L"Sending a datagram to the receiver...\n");
  iResult = sendto(SenderSocket,
    SendBuf, BufLen, 0, (SOCKADDR *)& ReceiverAddress, sizeof(ReceiverAddress));
  if (iResult == SOCKET_ERROR) {
    wprintf(L"sendto failed with error: %d\n", WSAGetLastError());
    closesocket(SenderSocket);
    WSACleanup();
    return 1;
  }
  //---------------------------------------------
  // When the application is finished sending, close the socket.
  wprintf(L"Finished sending. Closing socket.\n");
  iResult = closesocket(SenderSocket);
  if (iResult == SOCKET_ERROR) {
    wprintf(L"closesocket failed with error: %d\n", WSAGetLastError());
    WSACleanup();
    return 1;
  }
  //---------------------------------------------
  // Clean up and quit.
  wprintf(L"Exiting.\n");
  WSACleanup();

  getch();
  return 0;
}

//Using the Send Function
#ifndef UNICODE
#define UNICODE
#endif

#define WIN32_LEAN_AND_MEAN

#include <winsock2.h>
#include <Ws2tcpip.h>
#include <stdio.h>

// Link with ws2_32.lib
#pragma comment(lib, "Ws2_32.lib")

#define DEFAULT_BUFLEN 512
#define DEFAULT_PORT 27015

int main() {

  //----------------------
  // Declare and initialize variables.
  int iResult;
  WSADATA wsaData;

  SOCKET ConnectSocket = INVALID_SOCKET;
  struct sockaddr_in clientService;

  int recvbuflen = DEFAULT_BUFLEN;
  char *sendbuf = "Client: sending data test";
  char recvbuf[DEFAULT_BUFLEN] = "";

  //----------------------
  // Initialize Winsock
  iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
  if (iResult != NO_ERROR) {
    wprintf(L"WSAStartup failed with error: %d\n", iResult);
    return 1;
  }

  //----------------------
  // Create a SOCKET for connecting to server
  ConnectSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  if (ConnectSocket == INVALID_SOCKET) {
    wprintf(L"socket failed with error: %ld\n", WSAGetLastError());
    WSACleanup();
    return 1;
  }

  //----------------------
  // The sockaddr_in structure specifies the address family,
  // IP address, and port of the server to be connected to.
  clientService.sin_family = AF_INET;
  clientService.sin_addr.s_addr = inet_addr("127.0.0.1");
  clientService.sin_port = htons(DEFAULT_PORT);

  //----------------------
  // Connect to server.
  iResult = connect(ConnectSocket, (SOCKADDR*)&clientService, sizeof(clientService));
  if (iResult == SOCKET_ERROR) {
    wprintf(L"connect failed with error: %d\n", WSAGetLastError());
    closesocket(ConnectSocket);
    WSACleanup();
    return 1;
  }

  //----------------------
  // Send an initial buffer
  iResult = send(ConnectSocket, sendbuf, (int)strlen(sendbuf), 0);
  if (iResult == SOCKET_ERROR) {
    wprintf(L"send failed with error: %d\n", WSAGetLastError());
    closesocket(ConnectSocket);
    WSACleanup();
    return 1;
  }

  printf("Bytes Sent: %d\n", iResult);

  // shutdown the connection since no more data will be sent
  iResult = shutdown(ConnectSocket, SD_SEND);
  if (iResult == SOCKET_ERROR) {
    wprintf(L"shutdown failed with error: %d\n", WSAGetLastError());
    closesocket(ConnectSocket);
    WSACleanup();
    return 1;
  }

  // Receive until the peer closes the connection
  do {

    iResult = recv(ConnectSocket, recvbuf, recvbuflen, 0);
    if (iResult > 0)
      wprintf(L"Bytes received: %d\n", iResult);
    else if (iResult == 0)
      wprintf(L"Connection closed\n");
    else
      wprintf(L"recv failed with error: %d\n", WSAGetLastError());

  } while (iResult > 0);

  // close the socket
  iResult = closesocket(ConnectSocket);
  if (iResult == SOCKET_ERROR) {
    wprintf(L"close failed with error: %d\n", WSAGetLastError());
    WSACleanup();
    return 1;
  }

  WSACleanup();
  return 0;
}

工作原理…

用于在网络上通信的函数称为sendto。它声明为int sendto(int sockfd, const void *msg, int len, int flags);

sockfd是你想要发送数据的套接字描述符(由socket()返回或从accept()获得),而msg是指向你想要发送的数据的指针。len是数据的长度(以字节为单位)。为了简单起见,我们现在可以将flag设置为0sendto()返回实际发送的字节数(可能少于你告诉它发送的数量),或者在错误时返回-1。通过使用这个函数,你可以从一个连接点发送消息或数据到另一个连接点。这个函数可以用于使用内置的 Winsock 功能在网络上发送数据。send函数用于数据流,因此用于 TCP。如果我们要使用数据报和无连接协议,那么我们需要使用sendto函数。

接收数据

在正确设置了套接字并发送了数据之后,下一步是接收数据。接收数据非常简单,只涉及几行代码。

准备工作

要完成这个教程,你需要一台安装了 Visual Studio 的 Windows 机器。

如何做…

在这个教程中,我们将看到如何在网络上接收数据是多么容易。有两种方法可以做到这一点,一种是使用recv函数,另一种是使用recvfrom函数:

#define WIN32_LEAN_AND_MEAN

#include <winsock2.h>
#include <Ws2tcpip.h>
#include <stdio.h>

// Link with ws2_32.lib
#pragma comment(lib, "Ws2_32.lib")

#define DEFAULT_BUFLEN 512

#define DEFAULT_PORT "27015"

int __cdecl main() {

  //----------------------
  // Declare and initialize variables.
  WSADATA wsaData;
  int iResult;

  SOCKET ConnectSocket = INVALID_SOCKET;
  struct sockaddr_in clientService;

  char *sendbuf = "this is a test";
  char recvbuf[DEFAULT_BUFLEN];
  int recvbuflen = DEFAULT_BUFLEN;

  //----------------------
  // Initialize Winsock
  iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
  if (iResult != NO_ERROR) {
    printf("WSAStartup failed: %d\n", iResult);
    return 1;
  }

  //----------------------
  // Create a SOCKET for connecting to server
  ConnectSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  if (ConnectSocket == INVALID_SOCKET) {
    printf("Error at socket(): %ld\n", WSAGetLastError());
    WSACleanup();
    return 1;
  }

  //----------------------
  // The sockaddr_in structure specifies the address family,
  // IP address, and port of the server to be connected to.
  clientService.sin_family = AF_INET;
  clientService.sin_addr.s_addr = inet_addr("127.0.0.1");
  clientService.sin_port = htons(27015);

  //----------------------
  // Connect to server.
  iResult = connect(ConnectSocket, (SOCKADDR*)&clientService, sizeof(clientService));
  if (iResult == SOCKET_ERROR) {
    closesocket(ConnectSocket);
    printf("Unable to connect to server: %ld\n", WSAGetLastError());
    WSACleanup();
    return 1;
  }

  // Send an initial buffer
  iResult = send(ConnectSocket, sendbuf, (int)strlen(sendbuf), 0);
  if (iResult == SOCKET_ERROR) {
    printf("send failed: %d\n", WSAGetLastError());
    closesocket(ConnectSocket);
    WSACleanup();
    return 1;
  }

  printf("Bytes Sent: %ld\n", iResult);

  // shutdown the connection since no more data will be sent
  iResult = shutdown(ConnectSocket, SD_SEND);
  if (iResult == SOCKET_ERROR) {
    printf("shutdown failed: %d\n", WSAGetLastError());
    closesocket(ConnectSocket);
    WSACleanup();
    return 1;
  }

  // Receive until the peer closes the connection
  do {

    iResult = recv(ConnectSocket, recvbuf, recvbuflen, 0);
    if (iResult > 0)
      printf("Bytes received: %d\n", iResult);
    else if (iResult == 0)
      printf("Connection closed\n");
    else
      printf("recv failed: %d\n", WSAGetLastError());

  } while (iResult > 0);

  // cleanup
  closesocket(ConnectSocket);
  WSACleanup();

  return 0;
}

工作原理…

就像send函数一样,只有一个函数用于在网络上接收数据,可以声明如下:

int recv(int sockfd, void *buf,  int len, int flags);

sockfd是要从中读取的套接字描述符。下一个参数buf是要将信息读入的缓冲区,而len是缓冲区的最大长度。下一个参数recv()返回实际读入缓冲区的字节数,或者在错误时返回-1。如果recv()返回0,远程端已经关闭了连接。

使用这行代码,我们可以在网络上接收数据。如果数据在发送时被序列化,那么我们需要在这一点上对数据进行反序列化。这个过程将根据用于序列化数据的方法而有所不同。

处理延迟

网络游戏中经常出现的一个主要问题是延迟或卡顿。当两名玩家互相对战时,一方连接在高速网络上,另一方连接在非常低速的网络上,我们该如何更新数据呢?我们需要以一种方式更新数据,使得对两名玩家来说都看起来正常。没有玩家应该因为这种情况而获得不应有的优势。

准备工作

要完成这个配方,您需要一台运行 Windows 和 Visual Studio 的机器。

如何做到这一点…

在这个配方中,您将看到一些对抗延迟的技术。

通常,一个网络游戏会有以下更新循环。我们需要从循环结构中找出对抗延迟的最佳方法:

read_network_messages()
    read_local_input()
    update_world()
    send_network_updates()
    render_world()

它是如何工作的…

在大多数电脑游戏中,当实施网络功能时,通常会选择特定类型的客户端-服务器架构。通常会选择一个有权威的服务器。这意味着服务器决定时间、结果和其他因素。客户端基本上是“愚蠢”的,它所做的一切都是基于来自服务器的数据进行模拟。现在让我们考虑两名玩家正在玩一款多人 FPS 游戏。其中一名玩家连接在高速网络上,另一名连接在低速网络上。因此,如果客户端依赖服务器进行更新,准确地在客户端渲染玩家的位置将会非常困难。假设UserA连接在高速网络上,而UserB连接在低速网络上。UserAUserB开火。请注意,UserAUserB也在世界空间中移动。我们如何计算子弹的位置和每个玩家的位置呢?如果我们准确地渲染来自服务器的信息,那将不准确,因为UserAUserB收到更新之前可能已经移动到了新的位置。为了解决这个问题,通常有两种常用的解决方案。一种称为客户端预测。另一种方法进一步分为两种技术:插值和外推。请注意,如果计算机通过局域网连接,往返时间将是可以接受的。所有讨论的问题都集中在互联网上的网络连接。

在客户端预测中,客户端不再是“愚蠢”的,而是开始根据先前的移动输入来预测下一个位置和动画状态。最后,当它从服务器收到更新时,服务器将纠正错误,位置将被转换为当前接收到的位置。这种系统存在很多问题。如果预测错误,位置被更改为正确位置时会出现大的抖动。此外,让我们考虑声音和 VFX 效果。如果客户端UserA预测UserB正在行走并播放了脚步声音,后来服务器通知它UserB实际上在水中,我们该如何突然纠正这个错误呢?VFX 效果和状态也是如此。这种系统在许多Quake世界中被使用。

第二个系统有两个部分:外推和插值。在外推中,我们提前渲染。这在某种程度上类似于预测。它获取来自服务器的最后已知更新,然后在时间上向前模拟。因此,如果您落后 500 毫秒,并且您收到的最后更新是另一名玩家以每秒 300 个单位垂直于您的视图方向奔跑,那么客户端可以假设在“实时”中,玩家已经从他们最后已知的位置向前移动了 150 个单位。然后客户端可以在那个外推位置绘制玩家,本地玩家仍然可以更多或更少地瞄准另一名玩家。然而,这种系统的问题在于它很少会发生这样的情况。玩家的移动可能会改变,状态可能会改变,因此在大多数情况下应该避免使用这种系统。

在插值中,我们总是渲染过去的对象。例如,如果服务器每秒发送 25 次世界状态更新(确切地说),那么我们可能会在渲染中施加 40 毫秒的插值延迟。然后,当我们渲染帧时,我们在最后更新的位置和该位置之后的一个更新之间插值对象的位置。插值可以通过使用 C++中的内置 lerp 函数来完成。当对象到达最后更新的位置时,我们从服务器接收到新的更新(因为每秒 25 次更新意味着每 40 毫秒就会有更新),然后我们可以在接下来的 40 毫秒内开始朝着这个新位置移动。下图显示了来自服务器和客户端的碰撞箱位置的差异。

它是如何工作的…

如果数据包在 40 毫秒后没有到达,也就是说,发生了数据包丢失,那么我们有两个选择。第一个选择是使用上面描述的方法进行外推。另一个选择是使玩家进入空闲状态,直到从服务器接收到下一个数据包。

使用同步模拟。

在多人游戏中,可能会有数百或数千台计算机同时连接。所有计算机的配置都不同。所有这些计算机的速度也会有所不同。因此,问题是,我们如何同步所有这些系统上的时钟,使它们都同步?

准备工作

要完成这个配方,你需要一台运行 Windows 和 Visual Studio 的机器。

如何做…

在这个配方中,我们将从理论角度看一下同步时钟的两种方法。

看一下以下伪代码:

  • 方法 1
  1. UserA 发送一条消息。记录时间,直到他收到消息。

  2. UserB 发送一条消息。再次记录时间。

  3. 基于值计算中位数,以决定更新时钟的时间,用于更新两台计算机的时钟。

  • 方法 2
  1. 让服务器进行大部分计算。

  2. 让客户端进行一些本地计算。

  3. 当客户端从服务器接收更新时,要么纠正错误,要么根据结果进行插值。

它是如何工作的…

当我们尝试同步时钟时,有两种方法。一种方法是服务器尝试找到一个中位时间来同步所有时钟。为了做到这一点,我们可以在游戏设计本身中包含机制。服务器需要找出每台客户机的响应时间,因此必须发送消息。这些消息可以是在准备好时按 R,或者在客户机上加载地图并且服务器记录时间。最后,当它从所有机器中获得了时间,它计算一个中位数,然后在那个时间更新所有机器的时钟。服务器发送给机器计算这个中位数的消息越多,它就会越准确。然而,这并不保证同步。

因此,一个更好的方法是服务器进行所有计算,客户端也进行一些本地计算,使用之前配方中描述的技术。最后,当服务器向客户端发送更新时,客户端可以纠正自己或进行插值以获得期望的结果。这是一个更好的结果,也是一个更好的系统。

使用兴趣区域过滤

当我们编写网络算法时,我们需要决定需要向服务器更新或从服务器更新的各种对象或状态。对象的数量越多,序列化和发送数据所需的时间就越长。因此,有必要对每帧需要更新的内容进行优先级排序,以及哪些对象可以等待更多周期进行更新。

准备工作

要完成这个配方,你需要一台运行 Windows 的机器。

如何做…

在这个配方中,我们将看到创建兴趣区域过滤有多么容易:

  1. 创建场景中所有对象的列表。

  2. 为每个对象添加一个表示其优先级的参数。

  3. 基于优先级数字,将其传递给游戏的更新逻辑。

它是如何工作的...

在游戏中,我们需要按一定的优先级顺序定义对象。优先级顺序决定它们现在是否应该更新或稍后更新。需要优先处理的对象在很大程度上取决于游戏设计和一些研究。例如,在 FPS 游戏中,具有高优先级的对象将是用户当前瞄准的人物,附近的弹药,当然还有附近的敌人及其位置。在 RPG 或 RTS 的情况下可能会有所不同,因此它在不同游戏中肯定是不同的。

在为每个对象标记了优先级数字之后,我们可以告诉更新循环仅使用优先级为 1 和 2 的对象进行每帧更新,并使用优先级为 3 和 4 的对象进行延迟更新。这种结构也可以通过创建某种优先级队列来进行修改。从队列中,对象根据不同的更新逻辑弹出。较低优先级的对象也会同步,但在稍后的时间,而不是在当前帧中。

使用本地感知滤波器

这是网络游戏中对抗延迟的另一种方法。整个概念在数学上基于感知的概念。其基础是,如果对象在本地玩家的视图中正确更新和渲染,那么我们可以创造出一种逼真的幻觉,因此称为本地感知滤波器。

准备工作

要完成本教程,您需要一台运行 Windows 的计算机。

如何做到这一点...

在这个配方中,我们将了解实现子弹时间有多容易的理论概念。看一下以下伪代码:

  1. 计算相对于玩家的本地速度。

  2. 当子弹开始时加速它,并在它到达远程玩家时减速。

  3. 从远程玩家的角度来看,子弹应该看起来以比正常速度更快的速度射出,然后减速到正常速度。

它是如何工作的...

本地感知滤波器也称为子弹时间,首次在电影《黑客帝国》中使用。从那时起,它们已经在各种游戏中使用。在单人模式中很容易实现;然而,在多人模式中,由于涉及减慢渲染,它变得更加复杂。基本上,该过程是在本地和远程玩家附近时增加和减少被动实体的速度。这是一种用于隐藏网络虚拟环境中的通信延迟的方法,并在《分布式虚拟环境的本地感知滤波器》中介绍,P.M. Sharkey,(第 242-249 页)。为简单起见,我们将本地玩家称为p,远程玩家称为r,而被动实体,如子弹,称为e。假设d(i,j)是延迟,delta(i,j)是距离,我们得到以下方程:

它是如何工作的...

以图形格式,可以通过查看以下图表来解释这一点。因此,就p而言,它在上坡时速度较慢,然后在下坡时速度较快。就r而言,它在顶部速度更快。

注意

该方法的一个主要限制是不能用于瞬间命中武器。

它是如何工作的...

问题在于当e到达r时,pe的视图还没有到位,但是在p的视图中e会加速。为了解决这个问题,我们引入一个影子r,它缓冲了p对加速过程的视图。

添加缓冲后,我们将得到以下修订后的图表:

它是如何工作的...

所以在顶部,直到达到r之前不会加速,而在底部,它开始在位置p显示e。这也可以在以下网址查看演示:mikolalysenko.github.io/local-perception-filter-demo/

第十二章:游戏开发中的音频

在本章中,将介绍以下教程:

  • 安装 FMOD

  • 添加背景音乐

  • 添加音效

  • 创建音效管理器

  • 处理多个音频文件名

介绍

游戏开发中最重要的方面之一是音频编程。然而,令人奇怪的是,它也是游戏开发中最被忽视和低估的部分之一。要理解音频在游戏中的影响,可以尝试玩一款带有声音的游戏,比如反恐精英雷神,然后再尝试没有声音的游戏。这会产生巨大的影响。如果音频编程没有正确完成,可能会导致游戏崩溃和许多其他问题。

因此,学习正确的音频编程方式非常重要。大多数引擎都会有内置的声音组件。对于其他引擎,我们需要添加音频组件。在本章中,我们将介绍最流行的声音引擎之一。我们还将看看如何将 SDL 集成到我们的 C++代码中,以播放音频和音效。

安装 FMOD

开始的第一件事是安装 FMOD。这是最流行的音频引擎之一,几乎所有现代游戏引擎都在使用。它也可以添加到您选择的任何游戏引擎中。另一个流行的音频引擎叫做Wwise。这用于集成控制台编程的音频,例如在 PS4 上。

准备工作

要完成本教程,您需要一台运行 Windows 的计算机。

操作方法…

在本教程中,我们将看到不同类型的源控制可用于我们:

  1. 访问www.fmod.org/

  2. 要下载 FMOD,请访问www.fmod.org/download/

有一个编辑音频文件的创作工具。但是,我们应该下载 FMOD Studio 程序员 API 和低级程序员 API。

它还为所有现代引擎提供插件,如 Cocos2d-x、Unreal Engine 和 Unity3D。

工作原理…

FMOD 是一个低级 API,因此它提供了回调,帮助我们使用 FMOD 的接口来播放声音,暂停声音,以及执行许多其他操作。因为我们有源文件,我们可以构建库并在我们自己的引擎中使用它。FMOD 还为 Unity3D 提供了 API,这意味着代码也暴露给 C#,使其在 Unity3D 中更容易使用。

添加背景音乐

如果游戏没有背景音乐,就会显得不完整。因此,非常重要的是我们将播放音乐的方式集成到我们的 C++引擎中。有各种各样的方法可以做到这一点。我们将使用 SDL 在游戏中播放音乐。

准备工作

您需要一台运行 Windows 的计算机和一个可用的 Visual Studio 副本。还需要 SDL 库。

操作方法…

在本教程中,我们将了解播放背景音乐有多容易:

  1. 添加一个名为Source.cpp的源文件。

  2. 将以下代码添加到其中:

#include <iostream>
#include "../AudioDataHandler.h"

#include "../lib/SDL2/include/SDL2/SDL.h"

#include "iaudiodevice.hpp"
#include "iaudiocontext.hpp"
#include "audioobject.hpp"

#include "sdl/sdlaudiodevice.hpp"
#include "sdl/sdlaudiocontext.hpp"

#define FILE_PATH "./res/audio/testClip.wav"

int main(int argc, char** argv)
{
  SDL_Init(SDL_INIT_AUDIO);

  IAudioDevice* device = new SDLAudioDevice();
  IAudioContext* context = new SDLAudioContext();

  IAudioData* data = device->CreateAudioFromFile(FILE_PATH);

  SampleInfo info;
  info.volume = 1.0;
  info.pitch = 0.7298149802137;

  AudioObject sound(info, data);
  sound.SetPos(0.0);

  char in = 0;
  while(in != 'q')
  {
    std::cin >> in;
    switch(in)
    {
      case 'a':
        context->PlayAudio(sound);
        break;
      case 's':
        context->PauseAudio(sound);
        break;
      case 'd':
        context->StopAudio(sound);
        break;
    }
  }

  device->ReleaseAudio(data);
  delete context;
  delete device;

  SDL_Quit();
  return 0;
}

int main()
{
  AudioDataHandler _audioData;
  cout<<_audioData.GetAudio(AudioDataHandler::BACKGROUND);
}

工作原理…

在这个例子中,我们正在为我们的游戏播放背景音乐。我们需要创建一个接口作为现有 SDL 音频库的包装器。接口还可以提供一个基类以便将来派生。我们需要SDLAudioDevice,这是播放音乐的主处理对象。除此之外,我们还创建了一个指向音频数据对象的指针,它可以从提供的文件路径创建音频。设备处理对象有一个内置函数叫做CreateAudioFromFile,可以帮助我们完成这个过程。最后,我们有一个音频上下文类,它有播放、暂停和停止音频的功能。每个函数都以音频对象作为引用,用于对我们的音频文件执行操作。

添加音效

音效是向游戏添加一些紧张感或成就感的好方法。播放、暂停和停止音效的操作方式与我们在上一个示例中看到的背景音乐相同。然而,我们可以通过控制它们的位置、音量和音调来为声音文件增加一些变化。

准备工作

你需要一台正常工作的 Windows 机器。

如何做…

添加一个名为Source.cpp的源文件,并将以下代码添加到其中:

struct SampleInfo
{
  double volume;
  double pitch;
};

SampleInfo info;
info.volume = 1.0;
info.pitch = 0.7298149802137;

AudioObject sound(info, data);
sound.SetPos(0.0);

它是如何工作的…

在这个示例中,我们只关注游戏中涉及修改声音文件音调、音量和位置的部分。这三个属性可以被视为声音文件的属性,但还有其他属性。因此,首先要做的是创建一个结构。结构用于存储声音的所有属性。我们只需要在需要时填充结构的值。最后,我们创建一个音频对象,并将SampleInfo结构作为对象的参数之一传递进去。构造函数然后初始化声音具有这些属性。因为我们将属性附加到对象上,这意味着我们也可以在运行时操纵它们,并在需要时动态降低音量。音调和其他属性也可以以同样的方式进行操纵。

创建音效管理器

尽管不是最佳实践之一,但处理音频的最常见方法之一是创建一个管理器类。管理器类应确保整个游戏中只有一个音频组件,控制要播放、暂停、循环等哪种声音。虽然有其他编写管理器类的方法,但这是最标准的做法。

准备工作

对于这个示例,你需要一台 Windows 机器和 Visual Studio。

如何做…

在这个示例中,我们将了解如何使用以下代码片段轻松添加音效管理器:

#pragma once
#include <iostream>
#include "../lib/SDL2/include/SDL2/SDL.h"

#include "iaudiodevice.hpp"
#include "iaudiocontext.hpp"
#include "audioobject.hpp"

#include "sdl/sdlaudiodevice.hpp"
#include "sdl/sdlaudiocontext.hpp"

#define FILE_PATH "./res/audio/testClip.wav"

class GlobalAudioClass
{
private:

  AudioObject* _audObj;
  IAudioDevice* device = new SDLAudioDevice();
  IAudioContext* context = new SDLAudioContext();

  IAudioData* data = device->CreateAudioFromFile(FILE_PATH);

  SampleInfo info;

  static GlobalAudioClass *s_instance;

  GlobalAudioClass()
  {
    info.volume = 1.0;
   info.pitch = 0.7298149802137;
    _audObj = new AudioObject(info,data);
  }
  ~GlobalAudioClass()
  {
    //Delete all the pointers here
  }
public:
  AudioObject* get_value()
  {
    return _audObj;
  }
  void set_value(AudioObject* obj)
  {
    _audObj = obj;
  }
  static GlobalAudioClass *instance()
  {
    if (!s_instance)
      s_instance = new GlobalAudioClass();
    return s_instance;
  }
};

// Allocating and initializing GlobalAudioClass's
// static data member.  The pointer is being
// allocated - not the object inself.
GlobalAudioClass *GlobalAudioClass::s_instance = 0;

它是如何工作的…

在这个示例中,我们编写了一个单例类来实现音频管理器。单例类具有所有必要的sdl头文件和其他播放声音所需的设备和上下文对象。所有这些都是私有的,因此无法从其他类中访问。我们还创建了一个指向该类的静态指针,并将构造函数也设为私有。如果我们需要这个音频管理器的实例,我们必须使用静态的GlobalAudioClass *instance()函数。该函数会自动检查是否已经创建了一个实例,然后返回该实例,或者创建一个新的实例。因此,管理器类始终只存在一个实例。我们还可以使用管理器来设置和获取声音文件的数据,例如设置声音文件的路径。

处理多个声音文件名称

在游戏中,不会只有一个声音文件,而是多个声音文件需要处理。每个文件都有不同的名称、类型和位置。因此,单独定义所有这些并不明智。虽然这样做可以工作,但如果游戏中有超过 20 个音效,那么编码将会非常混乱,因此需要对代码进行轻微改进。

准备工作

在这个示例中,你需要一台 Windows 机器和安装了 SVN 客户端的版本化项目。

如何做…

在这个示例中,你将看到处理多个声音文件名称有多么容易。你只需要添加一个名为Source.cpp的源文件。将以下代码添加到其中:

#pragma once

#include <string>
using namespace std;

class AudioDataHandler
{
public:
  AudioDataHandler();
  ~AudioDataHandler();
  string GetAudio(int data) // Set one of the enum values here from the driver program
  {
    return Files[data];
  }

  enum AUDIO
  {
    NONE=0,
    BACKGROUND,
    BATTLE,
    UI
  };
private:
  string Files[] =
  {
    "",
    "Hello.wav",
    "Battlenn.wav",
    "Click.wav"
  }

};

int main()
{
  AudioDataHandler _audioData;
  cout<<_audioData.GetAudio(AudioDataHandler::BACKGROUND);
}

它是如何工作的…

在这个例子中,我们创建了一个音频数据处理类。该类有一个enum,其中存储了所有声音的逻辑名称,例如battle_musicbackground_music等。我们还有一个字符串数组,其中存储了声音文件的实际名称。顺序很重要,它必须与我们编写的enum的顺序相匹配。现在这个enum被创建了,我们可以创建这个类的对象,并设置和获取音频文件名。enum被存储为整数,并默认从0开始,名称作为字符串数组的索引。所以Files[AudioDataHandler::Background]实际上是Files[1],即Hello.wav,因此将播放正确的文件。这是一种非常整洁的组织音频数据文件的方式。在游戏中处理音频的另一种方式是在 XML 或 JSON 文件中具有音频文件的名称和其位置的属性,并有一个读取器解析这些信息,然后以与我们相同的方式填充数组。这样,代码就变得非常数据驱动,因为设计师或音频工程师可以只更改 XML 或 JSON 文件的值,而无需对代码进行任何更改。

第十三章:技巧和窍门

在本章中,将涵盖以下步骤:

  • 有效地注释你的代码

  • 在结构中使用位字段

  • 编写健全的技术设计文档

  • 使用 const 关键字优化你的代码

  • 在枚举中使用位移运算符

  • 在 C++11 中使用新的 lambda 特性

介绍

C++是一个广阔的海洋。掌握 C++需要许多概念和技巧。此外,程序员还可以不时学习一些小技巧,以帮助开发更好的软件。在本章中,我们将看一些程序员可以学习的技术。

有效地注释你的代码

程序员往往在解决问题时如此专注,以至于忘记注释他们的代码。虽然当他们在工作时这可能不是问题,但如果有其他团队成员参与,他们必须使用相同的代码部分,那么理解起来可能会变得非常困难。因此,从开发的早期阶段就注释代码是至关重要的。

准备工作

要完成这个步骤,你需要一台运行 Windows 和 Visual Studio 的机器。不需要其他先决条件。

如何做...

在这个步骤中,我们将看到注释代码是多么容易。让我们添加一个名为Source.cpp的源文件。将以下代码添加到文件中:

//Header files
#include <iostream>

class Game
{
  //Member variables (Already known)
public:
private:
protected:

};

//Adding 2 numbers
int Add(int a=4,int b=5)
{
  return a + b;
}

void Logic(int a,int b)
{
  if (a > 10 ? std::cout << a : std::cout << b);

}
int main()
{
  std::cout<<Add()<<std::endl;
  Logic(5,8);

  int a;
  std::cin >> a;
}

它是如何工作的...

注释应该写在任何部分,以帮助其他开发人员理解发生了什么。要注释代码,我们使用//双斜杠符号。我们在其中写的任何内容都不会被编译器编译,也会被忽略。因此,我们可以用它来备注代码中的不同方面。我们还可以使用/**/符号来注释多行。在/**/符号之间的任何内容都将被编译器忽略。如果我们需要调试应用程序,这种技术就会变得有用。我们首先注释掉我们认为是罪魁祸首的大部分代码。现在代码应该可以构建。然后我们开始取消注释代码,直到我们再次达到代码中断的地方。

有时程序员倾向于过度注释。例如,在加法函数的顶部写//Addition是没有必要的,因为我们可以清楚地看到正在添加两个数字。同样,我们也不应该少加注释。因为在Logic函数的顶部没有注释,我们不知道为什么要使用该函数以及该函数的作用。因此,我们必须记住适度添加注释。这只有通过实践和在团队环境中工作才能实现。

在结构中使用位字段

在结构中,我们可以使用位字段来表示我们希望结构的大小是多少。除此之外,了解结构实际占用的大小也很重要。

准备工作

你需要一台运行 Windows 的机器和一个可用的 Visual Studio 副本。不需要其他先决条件。

如何做...

在这个步骤中,我们将发现使用位字段来找到结构的大小是多么容易。添加一个名为Source.cpp的源文件。然后将以下代码添加到其中:

#include <iostream>

struct Type
{
  int a;
  unsigned char c[9];
  unsigned  b;
  float d;

};

struct Type2
{
  int a : 2;
  int b : 2;
};
int main()
{
  std::cout << sizeof(Type)<<std::endl;
  std::cout << sizeof(Type2);

  int a;
  std::cin >> a;
}

它是如何工作的...

正如你所看到的,在这个例子中,我们分配了一个 int 的结构体,一个 char 数组,一个未定义的无符号变量和一个浮点数。当我们执行程序时,输出应该是两个结构体的字节数。假设我们在 64 位机器上运行这个程序,int 是 4 字节,无符号 char 数组是 9 字节,默认情况下无符号是 4 字节,浮点数是 4 字节。如果我们把它们加起来,总共是 21 字节。但如果我们打印出来,我们会注意到输出是 24 字节。这是由于填充。C++总是以 4 字节的块获取数据。因此,它会一直填充额外的字节,直到大小是 4 的倍数。因为结构的大小是 21,最接近的 4 的倍数是 24,所以我们得到了那个答案。填充不是针对整个结构的,而是针对每个声明,例如:

struct structA
{
   char a;
   char b;
   char c;
   int d;
};

struct structB
{
   char a;
   int d;
   char b;
   char c;
};

Sizeof structA = 2 bytes
Sizeof structb = 3 bytes

看看第二个结构,我们所做的是分配了一个位字段。尽管 int 是 4 个字节,我们可以指示它只有 2 个字节。做法是在字节值后面加上一个:符号。因此,对于第二个结构,如果我们找到该值,它将输出为4而不是8

编写完善的技术设计文档

当我们启动一个项目时,通常会依赖两个文档。第一个文档是游戏设计文档,第二个是技术设计文档。技术设计文档应列出关键特性和关键特性的高级架构。然而,随着独立游戏的出现,这个系统正在迅速改变。但是,在大型游戏工作室中,这个流程仍然有效。

准备工作

您需要一个可用的 Windows 机器。

如何做…

在这个示例中,我们将看到创建技术设计文档有多么容易:

  1. 打开您选择的编辑器,最好是 Microsoft Word。

  2. 列出游戏的关键技术组件。

  3. 创建数据流图来表示引擎各个组件之间的数据流。

  4. 创建流程图以解释某个复杂部分的逻辑。

  5. 为游戏开发关键部分编写伪代码。

它是如何工作的…

一旦列出了关键组件,项目经理就可以自动评估每个任务的风险和复杂性。开发人员也将了解引擎或游戏的关键组件是什么。这也将帮助开发人员计划他们的行动。制作数据流图后,将很容易理解哪个组件依赖于哪个其他组件。因此,开发人员将知道他们必须在开始编码B之前实现A。流程图也是了解逻辑流程的好方法,有时有助于解决未来可能出现的歧义。最后,伪代码对于向开发人员解释他们必须如何实现代码或者说什么是一个可取的方法是至关重要的。由于伪代码与语言无关,因此即使在 C++之外的其他语言中编写游戏,也可以使用相同的伪代码。

使用 const 关键字优化您的代码

我们已经在之前的示例中看到const关键字用于使数据或指针常量,以便我们无法更改值或地址。使用const关键字还有一个优点。这在面向对象的范式中特别有用。

准备工作

对于这个示例,您将需要一个 Windows 机器和安装了 Visual Studio 的版本。

如何做…

在这个示例中,我们将发现如何有效地使用const关键字有多么容易:

#include <iostream>

class A
{
public:

  void Calc()const
  {
    Add(a, b);
    //a = 9;       // Not Allowed
  }
  A()
  {
    a = 10;
    b = 10;

  }
private:

  int a, b;
  void Add(int a, int b)const
  {

    std::cout << a + b << std::endl;
  }
};

int main()
{

  A _a;
  _a.Calc();

  int a;
  std::cin >> a;

  return 0;
}

它是如何工作的…

在这个示例中,我们正在编写一个简单的应用程序来添加两个数字。第一个函数是一个公共函数。这意味着它对其他类是可见的。每当我们编写公共函数时,我们必须确保它们不会损害该类的任何私有数据。例如,如果公共函数要返回成员变量的值或更改值,那么这个公共函数就非常危险。因此,我们必须确保该函数不能通过在函数末尾添加const关键字来修改任何成员变量。这确保了该函数不允许更改任何成员变量。如果我们尝试为成员变量分配不同的值,我们将收到编译器错误:

error C3490: 'a' cannot be modified because it is being accessed through a const object.

因此,这使得代码更加安全。然而,还有另一个问题。这个公共函数内部调用另一个私有函数。如果这个私有函数修改了成员变量的值会怎么样?同样,我们将面临相同的风险。因此,C++不允许我们调用该函数,除非它在函数末尾具有相同的 const 签名。这是为了确保该函数不能更改成员变量的值。

在枚举中使用位移操作符

正如我们之前在之前的示例中看到的,枚举用于表示一组状态。所有状态默认情况下都被赋予一个整数值,从0开始。但是,我们也可以指定不同的整数值。更有趣的是,我们可以使用位移操作符来组合一些状态,轻松地将它们设置为活动或非活动状态,并对它们进行其他操作。

准备工作

要完成这个示例,您需要一台安装了 Visual Studio 的 Windows 机器。

如何做…

在这个示例中,我们将看到在枚举中编写位移操作符是多么容易:

#include <iostream>

enum Flags
{
  FLAG1 = (1 << 0),
  FLAG2 = (1 << 1),
  FLAG3 = (1 << 2)
};

int main()
{

  int flags = FLAG1 | FLAG2;

  if (flags&FLAG1)
  {
    //Do Something
  }
  if (flags&FLAG2)
  {
    //Do Something
  }

  return 0;
}

它是如何工作的…

在上面的示例中,枚举中有三个标志状态。它们由位移操作符表示。因此,在内存中,第一个状态表示为0000,第二个状态表示为0001,第三个状态表示为0010。现在我们可以使用OR运算符(|)组合这些状态。我们可以有一个名为JUMP的状态和另一个名为SHOOT的状态。如果我们现在希望角色同时JUMPSHOOT,我们可以组合这些状态。我们可以使用&运算符来检查状态是否处于活动状态。类似地,如果我们需要从组合中移除一个状态,我们可以使用XOR运算符(^)。我们可以使用~运算符来禁用一个状态。

使用 C++ 11 的新 lambda 函数

Lambda 函数是 C++家族的新成员。它们可以被描述为匿名函数。

准备工作

要完成这个示例,您需要一台运行 Windows 和 Visual Studio 的机器。

如何做…

为了理解 lambda 函数,让我们看一下下面的代码:

#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

int main()
{
  vector<int> numbers{ 4,8,9,9,77,8,11,2,7 };
  int b = 10;
  for_each(numbers.begin(), numbers.end(), = mutable->void { if(y>b) cout<<  y<<endl;  });

  int a;
  cin >> a;

}

它是如何工作的…

Lambda 函数是 C++11 家族的新成员。它们是匿名函数,非常方便。它们通常作为参数传递给函数。lambda 函数的语法如下:

  • [捕获列表](参数)mutable(可选)异常属性-> ret {主体}

mutable关键字是可选的,用于修改参数并调用它们的非 const 函数。属性提供了闭包类型的规范。捕获列表是可选的,并且有一个允许的类型列表:

  • [a,&b]:这里a按值捕获,b按引用捕获

  • [this]:这通过值捕获this指针

  • [&]:这通过引用捕获 lambda 主体中使用的所有自动变量

  • [=]:这通过值捕获 lambda 主体中使用的所有自动变量

  • []:这不捕获任何东西

Params 是参数列表,就像命名函数一样,只是不允许默认参数(直到 C++14)。如果 auto 被用作参数的类型,lambda 就是一个通用 lambda(自 C++14 以来)。ret是函数的返回类型。如果没有提供类型,那么ret会尝试自动注入返回类型,如果没有返回任何东西,则为 void。最后,我们有函数的主体,用于编写函数的逻辑。

在这个示例中,我们存储了一个数字列表的向量。之后,我们遍历列表并使用 lambda 函数。lambda 函数存储所有大于 10 的数字并显示数字。lambda 函数可能很难开始,但是练习后,它们很容易掌握。

posted @ 2024-05-15 15:27  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报