C---虚幻-4-游戏创建学习指南-全-

C++ 虚幻 4 游戏创建学习指南(全)

原文:zh.annas-archive.org/md5/1c4190d0f9858df324374dcae7b4dd27

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

所以,你想使用虚幻引擎 4(UE4)自己编写游戏。你有很多理由这样做:

  • UE4 功能强大:UE4 提供了一些最先进、最美丽、最逼真的光照和物理效果,这类效果被 AAA 工作室所使用。

  • UE4 不依赖于特定设备:为 UE4 编写的代码将在 Windows 桌面机器、Mac 桌面机器、Android 设备和 iOS 设备上运行(撰写本书时——未来可能还会支持更多设备)。

因此,你可以使用 UE4 一次性编写游戏的主要部分,之后就可以无缝地部署到 iOS 和 Android 市场。当然,可能会有一些小问题:iOS 和 Android 的应用内购买将需要单独编程。

游戏引擎究竟是什么呢?

游戏引擎类似于汽车引擎:游戏引擎是驱动游戏运行的核心。你将告诉引擎你想要什么,然后(使用 C++代码和 UE4 编辑器),引擎将负责实际实现这些功能。

你将围绕 UE4 游戏引擎构建你的游戏,就像车身和车轮围绕实际汽车引擎构建一样。当你使用 UE4 发布游戏时,你基本上是在定制 UE4 引擎,并为其添加你自己的游戏图形、声音和代码。

使用 UE4 会花费我多少费用?

简而言之,答案是 19 美元和销售额的 5%。

“什么?”你说。19 美元?

没错。只需 19 美元,你就能获得一个世界级的 AAA 引擎的完全访问权限,包括源代码。考虑到其他引擎仅一个许可证就可能从 500 美元到 1000 美元不等,这真是一个大优惠。

我为什么不自己编写引擎并节省 5%的费用呢?

听我的,如果你想在一个合理的时间内创建游戏,而你又没有一支庞大的专业引擎程序员团队来帮助你,你将需要专注于你销售的东西(你的游戏)。

不必专注于编写游戏引擎,这让你有自由去思考如何制作实际的游戏。不必维护和修复你自己的引擎,这也能让你减轻心理负担。

游戏概览 – 玩-奖励-增长循环

我现在想展示这张图,因为它包含了一个许多新手开发者可能在编写他们的第一个游戏时可能会错过的核心概念。一个游戏可以包含音效、图形、逼真的物理效果,但仍然感觉不像一个游戏。为什么?

游戏概览 – 玩-奖励-增长循环

从循环的顶部开始,游戏中进行的 Play 行动(如击败怪物)会给玩家带来奖励(如金币或经验)。这些奖励反过来可以用于游戏内的 Growth(如属性提升或新的世界探索)。这种 Growth 然后以新的和有趣的方式推动游戏玩法。例如,一把新武器可以改变战斗的基本机制,新的咒语让你能够以完全不同的方式对抗一群怪物,或者新的交通方式让你能够到达之前无法到达的区域。

这是创建有趣游戏玩法的基本核心循环。关键是 Play 必须导致某种形式的 Reward——想想那些从讨厌的敌人中跳出来的闪闪发光的金币。为了使奖励有意义,它必须在游戏玩法中导致某种形式的 Growth。想想 The Legend of Zelda 中的钩锁解锁了多少新地点。

只有 Play(没有 Rewards 或 Growth)的游戏不会感觉像游戏:它只会感觉像一个非常基础的游戏原型。例如,想象一个只有开放世界、没有目标或任务,以及没有升级飞机或武器能力的飞行模拟器。这不会是一个很好的游戏。

只有 Play 和 Rewards(但没有 Growth)的游戏会感觉原始而简单。如果这些奖励不能用于任何目的,它们将不会满足玩家。

只有 Play 和 Growth(没有 Rewards)的游戏将仅仅被视为一个无意义的增加挑战,不会给玩家带来成就感。

具备所有三个要素的游戏将使玩家在有趣的 Play 中保持参与度。Play 有奖励的结果(掉落物品和故事进展),这导致游戏世界的 Growth。在构思你的游戏时考虑到这一点,将真正帮助你设计一个完整的游戏。

小贴士

原型是游戏概念证明。比如说,你想创建自己独特的 Blackjack 版本。你可能首先会编写一个原型来展示游戏将如何进行。

盈利模式

在你的游戏开发早期需要考虑的是你的盈利策略。你的游戏将如何赚钱?如果你试图创办一家公司,你必须从一开始就考虑你的收入来源。

你是否打算从购买价格中赚钱,例如 JamestownThe Banner SagaCastle CrashersCrypt of the Necrodancer?或者,你将专注于分发带有内购的免费游戏,例如 Clash of ClansCandy Crush SagaSubway Surfers

一类移动设备游戏(例如,iOS 上的建造类游戏)通过允许用户付费跳过 Play,直接进入循环中的奖励和 Growth 部分,赚取了大量收入。这种诱惑可能非常强大;许多人为了一个游戏就花费了数百美元。

为什么选择 C++

UE4 是用 C++编写的。要为 UE4 编写代码,你必须了解 C++。

C++是游戏程序员的一个常见选择,因为它提供了非常好的性能与面向对象编程特性的结合。它是一种非常强大且灵活的语言。

本书涵盖的内容

第一章,用 C++编码,讲述了如何启动并运行你的第一个 C++程序。

第二章,变量和内存,讲述了如何从计算机内存中创建、读取和写入变量。

第三章,If,Else 和 Switch,讲述了代码的分支:即根据程序条件允许不同的代码部分执行。

第四章,循环,讨论了如何重复执行特定的代码块,直到满足所需次数。

第五章,函数和宏,讲述了函数,它们是一组可以被多次调用的代码块,你可以按需多次调用。

第六章,对象、类和继承,讲述了类定义以及根据类定义实例化一些对象。

第七章,动态内存分配,讨论了堆分配的对象以及低级 C 和 C++风格的数组。

第八章,演员和棋子,是我们真正深入 UE4 代码的第一章。我们首先创建一个游戏世界来放置演员,并从一个自定义演员中派生出一个Avatar类。

第九章,模板和常用容器,探讨了 UE4 和 C++ STL 家族的数据集合,称为容器。通常,通过选择正确的容器类型,可以多次简化编程问题。

第十章,库存系统和拾取物品,讨论了创建具有拾取新物品能力的库存系统。

第十一章,怪物,讲述了如何创建追逐玩家并使用武器攻击的怪物。

第十二章,“魔法书”,教授如何在我们的游戏中创建和施展咒语。

您需要为这本书准备什么

要使用此文本,您需要两个程序。第一个是您的集成开发环境,或 IDE。第二个软件当然是 Unreal Engine 本身。

如果您使用的是 Microsoft Windows,那么您将需要 Microsoft Visual Studio 2013 Express Edition for Windows Desktop。如果您使用的是 Mac,那么您将需要 Xcode。可以从www.unrealengine.com/下载 Unreal Engine。

本书面向对象

这本书是为任何想要编写 Unreal Engine 应用程序的人准备的。文本首先会告诉您如何编译和运行您的第一个 C++应用程序,随后是描述 C++编程语言规则的章节。在介绍性的 C++章节之后,您就可以开始使用 C++构建自己的游戏应用程序了。

术语

在这本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“variableType将告诉您我们将要存储在变量中的数据类型。variableName是我们将用来读取或写入该内存片段的符号”。

代码块设置为如下:

struct Player
{
  string name;
  int hp;
  // A member function that reduces player hp by some amount
  void damage( int amount ) {
    hp -= amount;
  }
  void recover( int amount ) {
    hp += amount;
  }
};

新术语重要词汇以粗体显示。屏幕上出现的文本如下所示:从文件菜单中选择新建项目...

注意

一些相关但有点像旁注的额外信息,出现在这样的框中。

提示

技巧和窍门看起来像这样。

读者反馈

读者反馈始终欢迎。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中受益的标题非常重要。

要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个领域有专业知识,并且您对撰写或参与一本书感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

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

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从 www.packtpub.com/sites/default/files/downloads/6572OT_ColoredImages.pdf 下载此文件。

错误

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站,或添加到该标题的“错误”部分下的现有错误列表中。任何现有错误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

侵权

互联网上版权材料的侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过 <copyright@packtpub.com> 与我们联系,并提供涉嫌侵权材料的链接。

我们感谢您在保护我们作者和我们提供有价值内容的能力方面的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. 使用 C++ 编程

您是第一次编程。您有很多东西要学!

学术界通常在理论上描述编程概念,但喜欢将实现留给别人,最好是来自行业的人。在这本书中,我们不会这样做——在这本书中,我们将描述 C++ 概念背后的理论,并实现我们自己的游戏。

我首先建议您做练习。您不能仅仅通过阅读来学习编程。您必须通过练习与理论相结合。

我们将首先通过编写非常简单的 C++ 程序来开始编程。我知道您现在就想开始玩您完成的游戏。然而,您必须从开始的地方开始,才能达到那个终点(如果您真的想,可以跳到第十二章,“魔法书”,或者打开一些示例来了解我们将要走向何方)。

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

  • 在 Visual Studio 和 Xcode 中设置新项目

  • 您的第一个 C++ 项目

  • 如何处理错误

  • 什么是构建和编译?

设置我们的项目

我们的第一 C++ 程序将在 UE4 之外编写。首先,我将提供 Xcode 和 Visual Studio 2013 的步骤,但在此章之后,我将尝试只谈论 C++ 代码,而不提及您是否使用 Microsoft Windows 或 Mac OS。

在 Windows 上使用 Microsoft Visual C++

在本节中,我们将为 Windows 安装一个代码编辑器,即微软的 Visual Studio。如果您使用的是 Mac,请跳到下一节。

小贴士

Visual Studio 的 Express 版本是微软在其网站上提供的免费版本。请访问www.visualstudio.com/en-us/products/visual-studio-express-vs.aspx开始安装过程。

首先,您必须下载并安装Windows 桌面 Microsoft Visual Studio Express 2013。这是该软件的图标:

在 Windows 上使用 Microsoft Visual C++

小贴士

不要安装Express 2013 for Windows。这是一个不同的包,它用于与我们在这里做的事情不同的事情。

一旦您安装了 Visual Studio 2013 Express,请打开它。按照以下步骤操作,以便您可以真正地输入代码:

  1. 文件菜单中选择新建项目...,如图所示:在 Windows 上使用 Microsoft Visual C++

  2. 您将看到以下对话框:在 Windows 上使用 Microsoft Visual C++

    小贴士

    注意,底部有一个带有文本解决方案名称的小框。通常,Visual Studio 解决方案可能包含许多项目。然而,本书只与一个项目一起使用,但有时您可能会发现将许多项目集成到同一个解决方案中很有用。

  3. 现在,有五件事情需要处理,如下所示:

    1. 从左侧面板中选择Visual C++

    2. 从右侧面板中选择Win32 控制台应用程序

    3. 为你的应用命名(我使用了MyFirstApp)。

    4. 选择一个文件夹以保存你的代码。

    5. 点击确定按钮。

  4. 此后,将打开一个应用程序向导对话框,如图所示:在 Windows 上使用 Microsoft Visual C++

  5. 在此对话框中,我们需要注意四件事情,如下所述:

    1. 在左侧面板中点击应用程序设置

    2. 确保已选择控制台应用程序

    3. 选择空项目

    4. 点击完成

现在,你处于 Visual Studio 2013 环境中。这是你将进行所有工作和编写代码的地方。

然而,我们需要一个文件来写入我们的代码。因此,我们将向项目中添加一个 C++代码文件,如图所示:

在 Windows 上使用 Microsoft Visual C++

按照以下截图所示添加你的新源代码文件:

在 Windows 上使用 Microsoft Visual C++

现在,你将编辑Source.cpp。跳转到“你的第一个 C++程序”部分并输入你的代码。

在 Mac 上使用 XCode

在本节中,我们将讨论如何在 Mac 上安装 Xcode。如果你使用 Windows,请跳到下一节。

Xcode 适用于所有 Mac 机器。你可以通过 Apple App Store 获取 Xcode(它是免费的),如图所示:

在 Mac 上使用 XCode

  1. 在 Xcode 安装完成后,打开它。然后,从屏幕顶部的系统菜单栏中选择文件 | 新建 | 项目...,如图所示:在 Mac 上使用 XCode

  2. 在新建项目对话框中,在屏幕左侧选择OS X下的应用程序,然后在右侧面板中选择命令行工具。然后点击下一步在 Mac 上使用 XCode

    注意

    你可能会想点击SpriteKit Game图标,但不要点击它。

  3. 在下一个对话框中,为你的项目命名。确保填写所有字段,否则 Xcode 不会让你继续。确保项目的类型设置为C++,然后点击下一步按钮,如图所示:在 Mac 上使用 XCode

  4. 下一个弹出窗口将要求你选择一个位置以保存你的项目。在你的硬盘上选择一个位置并将其保存那里。Xcode 默认为每个创建的项目创建一个 Git 仓库。你可以取消选择创建 git 仓库——在本章中我们将不涉及 Git——如图所示:在 Mac 上使用 XCode

小贴士

Git 是一个版本控制系统。这基本上意味着 Git 会定期(每次你向仓库提交时)保存你项目中所有代码的快照。其他流行的源代码管理工具(scm)有 Mercurial、Perforce 和 Subversion。当多个人在同一项目上协作时,scm 工具具有自动合并和从仓库复制其他人的更改到本地代码库的能力。

好吧!你已经设置好了。点击 Xcode 左侧面板中的main.cpp文件。如果文件没有出现,请确保首先选中左侧面板顶部的文件夹图标,如图所示:

在 Mac 上使用 XCode

创建你的第一个 C++程序

我们现在将要编写一些 C++源代码。之所以称之为源代码,有一个非常好的原因:它是我们构建二进制可执行代码的源头。相同的 C++源代码可以在不同的平台上构建,例如 Mac、Windows 和 iOS,从理论上讲,在每个相应平台上执行相同操作的可执行代码应该会产生相同的结果。

在不久的过去,在 C 和 C++引入之前,程序员为每个他们针对的特定机器单独编写代码。他们用汇编语言编写代码。但现在,有了 C 和 C++,程序员只需编写一次代码,就可以通过将相同的代码发送到不同的编译器来部署到多个不同的机器上。

提示

实际上,Visual Studio 的 C++版本和 Xcode 的 C++版本之间有一些差异,但这些差异主要出现在处理高级 C++概念,如模板时。

使用 UE4 的一个主要好处是 UE4 将消除 Windows 和 Mac 之间的许多差异。UE4 团队做了很多魔法,以便相同的代码可以在 Windows 和 Mac 上运行。

注意

一个实用的技巧

代码在所有机器上以相同的方式运行非常重要,特别是对于网络游戏或允许分享重放等功能的游戏。这可以通过使用标准来实现。例如,IEEE 浮点标准用于在所有 C++编译器上实现十进制数学。这意味着计算结果如200 * 3.14159应该在所有机器上相同。

在 Microsoft Visual Studio 或 Xcode 中编写以下代码:

#include <iostream>  // Import the input-output library
using namespace std; // allows us to write cout
                     // instead of std::cout
int main()
{
  cout << "Hello, world" << endl;
  cout << "I am now a C++ programmer." << endl;
  return 0;      // "return" to the operating sys
}

Ctrl + F5在 Visual Studio 中运行前面的代码,或者在 Xcode 中按创建你的第一个 C++程序 + R 运行。

你第一次在 Visual Studio 中按Ctrl + F5时,你会看到这个对话框:

创建你的第一个 C++程序

选择不再显示此对话框——相信我,这将避免未来的问题。

你可能首先想到的是,“哇!一大堆乱七八糟的东西!”

的确,你很少在正常的英语文本中看到哈希符号(#)的使用(除非你在使用 Twitter)和花括号对{}。然而,在 C++代码中,这些奇怪的符号到处都是。你只需要习惯它们。

因此,让我们从第一行开始解释这个程序。

这是程序的第一行:

#include <iostream>  // Import the input-output library

这一行有两个需要注意的重要点:

  1. 我们看到的第一件事是#include语句。我们要求 C++复制并粘贴另一个名为<iostream>的 C++源文件的内容,直接到我们的代码文件中。《iostream》是一个标准的 C++库,它处理所有让我们能够打印文本到屏幕的粘性代码。

  2. 我们注意到的第二件事是//注释。C++会忽略双斜杠(//)之后直到该行结束的任何文本。注释非常有用,可以添加对某些代码的纯文本解释。你可能在源代码中看到/* */风格的 C 风格注释。在 C 或 C++中将任何文本用反斜杠星号/*和星号反斜杠*/包围,会给编译器一个指令,让这段代码被移除。

这是下一行代码:

using namespace std; // allows us to write cout
                     // instead of std::cout

这行旁边的注释解释了using语句的作用:它只是让你可以使用缩写(例如,cout),而不是使用完全限定的名称(在这个例子中,将是std::cout)来代替我们许多 C++代码命令。有些人不喜欢using namespace std;语句;他们更喜欢每次想要使用cout时都写std::cout。你可能会因为这类事情而陷入长篇大论。在本节文本中,我们更喜欢using namespace std;语句带来的简洁性。

这是下一行:

int main()

这是应用程序的起点。你可以把main看作是比赛中的起点线。int main()语句是 C++程序知道从哪里开始的方式;看看下面的图:

创建你的第一个 C++程序

如果你没有int main()程序标记,或者main拼写错误,那么你的程序将无法工作,因为程序将不知道从哪里开始。

下一行是一个你不太常见的字符:

{

这个{字符不是一个侧面的胡须。它被称为花括号,它表示你程序的起点。

接下来的两行将文本打印到屏幕上:

cout << "Hello, world" << endl;
cout << "I am now a C++ programmer." << endl;

cout语句代表控制台输出。双引号之间的文本将以与引号内完全相同的方式输出到控制台。你可以在双引号之间写任何你想要的内容,除了双引号本身,它仍然会是有效的代码。

提示

要在双引号之间输入一个双引号,你需要在你想放在字符串中的双引号字符前面加上一个反斜杠(\),如下所示:

cout << "John shouted into the cave \"Hello!\" The cave echoed."

反斜杠(\)符号是一个转义序列的例子。还有其他你可以使用的转义序列;你将最常找到的转义序列是\n,它用于将文本输出跳转到下一行。

程序的最后一行是return语句:

return 0;

这行代码表示 C++程序正在退出。你可以把return语句看作是返回到操作系统。

最后,你的程序结束的标志是闭合的花括号,它是一个反向的侧脸八字胡:

}

分号

分号(;)在 C++编程中很重要。注意在前面的代码示例中,大多数代码行都以分号结束。如果你不每行都加上分号,你的代码将无法编译,如果发生这种情况,你可能会被解雇。

处理错误

如果你输入代码时出错,那么你将会有语法错误。面对语法错误,C++会发出惨叫,你的程序甚至无法编译;同样,它也无法运行。

让我们尝试在我们的早期 C++代码中插入几个错误:

处理错误

警告!此代码列表包含错误。找出所有错误并修复它们是一个很好的练习!

作为练习,尝试找出并修复这个程序中的所有错误。

注意

注意,如果你对 C++非常不熟悉,这个练习可能很难。然而,这将向你展示在编写 C++代码时你需要多么小心。

修复编译错误可能是一件棘手的事情。然而,如果你将这个程序的文本输入到你的代码编辑器中并尝试编译它,它将导致编译器向你报告所有错误。一次修复一个错误,然后尝试重新编译。新的错误将出现,或者程序将正常工作,如以下截图所示:

处理错误

Xcode 在你尝试编译代码时显示你的代码中的错误

我向你展示这个示例程序是为了鼓励以下工作流程,只要你对 C++不熟悉:

  1. 总是从一个工作的 C++代码示例开始。你可以从“你的第一个 C++程序”部分分叉出许多新的 C++程序。

  2. 逐步修改你的代码。当你刚开始时,每写完一行新代码就编译一次。不要连续编码一两个小时,然后一次性编译所有新代码。

  3. 你可能需要几个月的时间才能写出第一次编写时就能按预期运行的代码。不要气馁。学习编码是件有趣的事情。

警告

编译器会标记出它认为可能是错误的地方。这些都是另一类编译器通知,被称为警告。警告是代码中的问题,你不必修复它们代码就能运行,但编译器建议修复。警告通常是代码不够完美的指示,修复代码中的警告通常被认为是良好的实践。

然而,并非所有的警告都会导致你的代码出现问题。一些程序员更喜欢禁用他们认为不是问题的警告(例如,警告 4018 警告有符号/无符号不匹配,你很可能稍后看到)。

什么是构建和编译?

您可能听说过一个计算机术语叫做编译。编译是将您的 C++ 程序转换成可以在 CPU 上运行的代码的过程。构建您的源代码与编译它意味着同一件事。

看看,您的源代码文件 code.cpp 实际上不能在计算机上运行。它必须先编译,才能运行。

这正是使用 Microsoft Visual Studio Express 或 Xcode 的全部意义。Visual Studio 和 Xcode 都是编译器。您可以在任何文本编辑程序中编写 C++ 源代码——甚至可以在记事本中。但您需要一个编译器才能在您的机器上运行它。

每个操作系统通常都有一个或多个可以编译 C++ 代码以在该平台上运行的 C++ 编译器。在 Windows 上,您有 Visual Studio 和 Intel C++ Studio 编译器。在 Mac 上,有 Xcode,在所有 Windows、Mac 和 Linux 上,有 GNU 编译器集合GCC)。

我们编写的相同 C++ 代码(源代码)可以使用不同操作系统的不同编译器进行编译,理论上,它们应该产生相同的结果。能够在不同平台上编译相同代码的能力称为可移植性。一般来说,可移植性是好事。

脚本语言

另有一类编程语言称为脚本语言。这些包括 PHP、Python 和 ActionScript 等语言。脚本语言不进行编译——对于 JavaScript、PHP 和 ActionScript,没有编译步骤。相反,它们在程序运行时从源代码进行解释。脚本语言的好处是,它们通常一开始就是平台无关的,因为解释器被精心设计成平台无关的。

练习 – ASCII 艺术字

游戏程序员喜欢 ASCII 艺术字。你可以只用字符来绘制一幅图画。以下是一个 ASCII 艺术字迷宫的例子:

cout << "****************" << endl;
cout << "*............*.*" << endl;
cout << "*.*.*******..*.*" << endl;
cout << "*.*.*..........*" << endl;
cout << "*.*.*.**********" << endl;
cout << "***.***........*" << endl;

使用 C++ 代码构建您自己的迷宫,或者用字符绘制一幅图画。

总结

总结一下,我们在我们的集成开发环境(IDE,Visual Studio 或 Xcode)中学习了如何编写我们的第一个 C++ 程序。这是一个简单的程序,但您应该把第一次让程序编译并运行视为您的第一个胜利。在接下来的章节中,我们将构建更复杂的程序,并开始为我们的游戏使用 Unreal Engine。

总结

上述截图是您的第一个 C++ 程序,下方的截图是它的输出,您的第一个胜利:

总结

第二章。变量和内存

要编写你的 C++游戏程序,你的计算机需要记住很多事情。比如玩家在世界中的位置、他的生命值、剩余的弹药量、世界中的物品位置、它们提供的升级以及组成玩家屏幕名称的字母。

你所拥有的计算机内部实际上有一个称为内存或 RAM 的电子草图板。从物理上看,计算机内存是由硅制成的,其外观与以下截图所示类似:

变量和内存

这 RAM 看起来像停车场吗?因为这就是我们要用的比喻

RAM 是随机访问内存的缩写。它被称为随机访问,因为你可以随时访问它的任何部分。如果你还留着一些 CD,它们是非随机访问的例子。CD 的设计是按顺序读取和播放的。我还记得在 CD 切换曲目需要花费很多时间的时候,在迈克尔·杰克逊的危险专辑上跳转曲目!然而,在 RAM 的不同单元格之间跳转并不需要花费太多时间。RAM 是一种快速内存访问类型,称为闪存。

RAM 被称为易失性闪存,因为当计算机关闭时,RAM 的内容会被清除,除非它们之前被保存到硬盘上,否则 RAM 的旧内容会丢失。

对于永久存储,你必须将你的数据保存到硬盘上。主要有两种硬盘类型,基于盘片的硬盘驱动器HDDs)和固态驱动器SSDs)。与基于盘片的 HDDs 相比,SSDs 更现代,因为它们使用 RAM 的快速访问(闪存)原理。然而,与 RAM 不同的是,SSD 上的数据在计算机关闭后仍然存在。如果你能获得 SSD,我强烈建议你使用它!基于盘片的驱动器已经过时了。我们需要一种方法在 RAM 上预留空间并从中读取和写入。幸运的是,C++使这变得很容易。

变量

计算机内存中可以读取或写入的保存位置被称为变量

变量是一个其值可以变化的组件。在计算机程序中,你可以将变量视为一个容器,你可以将一些数据存储在其中。在 C++中,这些数据容器(变量)有类型。你必须使用正确的数据容器类型来保存你的程序中的数据。

如果你想要保存一个整数,比如 1、0 或 20,你将使用int类型的容器。你可以使用浮点类型容器来携带浮点(小数)值,比如 38.87,你也可以使用字符串变量来携带字母字符串(可以将其视为“珍珠串”,其中每个字母都是一个珍珠)。

你可以将你的预留 RAM 空间想象成在停车场预留停车位:一旦我们声明了变量并为其获取了一个位置,操作系统就不会将那块 RAM 分配给其他人(甚至不是在同一台机器上运行的另一个程序)。你的变量的 RAM 旁边可能未被使用,或者可能被其他程序使用。

小贴士

操作系统存在是为了防止程序相互干扰,同时访问相同的计算机硬件位。一般来说,公民计算机程序不应该读取或写入彼此的内存。然而,一些类型的作弊程序(例如,地图作弊)秘密访问您的程序内存。为了防止在线游戏中的作弊,引入了像 PunkBuster 这样的程序。

声明变量 – 触摸硅

使用 C++在计算机内存中预留位置很容易。我们希望用一个好的、描述性的名称来命名我们将存储数据的内存块。

例如,假设我们知道玩家的生命值hp)将是一个整数(整体)数,例如 1、2、3 或 100。为了在内存中存储玩家的 hp,我们将声明以下代码行:

int hp;     // declare variable to store the player's hp

这行代码为存储整数(int代表整数)的内存块预留了一小块 RAM,称为 hp。以下是我们用于存储玩家 hp 的 RAM 块示例。这在我们内存中的所有停车位中为我们预留了一个停车位,我们可以通过其标签(hp)来引用这个内存空间。

声明变量 – 触摸硅

在内存中的所有其他空间中,我们得到一个位置来存储我们的 hp 数据

注意,在这个图中变量空间被标记为int:如果它是一个用于 double 或其他类型变量的空间。C++不仅通过名称,还通过变量的类型来记住您在内存中为程序预留的空间。

注意,我们还没有在 hp 的框中放入任何东西!我们稍后会做这件事——现在,hp 变量的值尚未设置,因此它将保留前一个占用者(可能是另一个程序留下的值)在该停车位上的值。告诉 C++变量的类型很重要!稍后,我们将声明一个变量来存储小数值,例如 3.75。

在内存中读取和写入您的预留位置

将值写入内存很容易!一旦你有一个hp变量,你只需使用=符号写入它:

hp = 500;

哇!玩家有 500 hp。

读取变量同样简单。要打印变量的值,只需这样做:

cout << hp << endl;

这将打印出存储在 hp 变量中的值。如果你更改了 hp 的值,然后再次使用cout,将打印出最新的值,如下所示:

hp = 1200;
cout << hp << endl; // now shows 1200

数字是一切

当你开始学习计算机编程时,你需要习惯的是,许多东西都可以以数字的形式存储在计算机内存中。玩家的 hp?正如我们在上一节中看到的,hp 可以只是一个整数。如果玩家受伤,我们就减少这个数字。如果玩家恢复健康,我们就增加这个数字。

颜色也可以存储为数字!如果你使用过标准的图像编辑程序,通常会有表示颜色的滑块,指示使用多少红色、绿色和蓝色,例如 Pixelmator 的颜色滑块。颜色由三个数字表示。以下屏幕截图显示的紫色颜色是(R=127,G=34,B=203):

数字无处不在

那么,关于世界几何呢?这些也只是数字:我们只需要存储一个包含 3D 空间点(x、y 和 z 坐标)的列表,然后存储另一个解释这些点如何连接形成三角形的点的列表。在下面的屏幕截图中,我们可以看到如何使用 3D 空间点来表示世界几何:

数字无处不在

颜色和 3D 空间点的数字组合将让你在你的游戏世界中绘制出大型的彩色景观。

前面示例中的技巧在于我们如何解释存储的数字,以便我们可以让它们代表我们想要它们代表的意义。

更多关于变量的内容

你可以把变量想象成动物携带的箱子。猫携带箱可以用来携带猫,但不能用来携带狗。同样,你应该使用浮点型变量来携带带有小数点的数字。如果你在int变量中存储小数值,它将不会适合:

int x = 38.87f;
cout << x << endl; // prints 38, not 38.87

实际上这里发生的事情是 C++对 38.87 进行自动类型转换,*将其转换为一个整数以适应int存储空间。它丢弃小数点,将 38.87 转换为整数值 38。

例如,我们可以修改代码以包含三种类型变量的使用,如下面的代码所示:

#include <iostream>
#include <string>  // need this to use string variables!
using namespace std;
int main()
{
  string name;
  int goldPieces;
  float hp;
  name = "William"; // That's my name
  goldPieces = 322; // start with this much gold 
  hp = 75.5f;       // hit points are decimal valued
  cout << "Character " << name << " has " 
           << hp << " hp and " 
           << goldPieces << " gold.";
}

在前三行中,我们声明了三个盒子来存储我们的数据部分,如下所示:

string name;
int goldPieces;
float hp;

这三行在内存中预留了三个位置(就像停车位一样)。接下来的三行将变量填充为我们想要的值,如下所示:

name = "William";
goldPieces = 322;
hp = 75.5f;

在计算机内存中,这看起来如下面的图所示:

更多关于变量的内容

你可以随时更改变量的内容。你可以使用=赋值运算符来写入变量,如下所示:

goldPieces = 522;// = is called the "assignment operator"

你也可以在任何时候读取变量的内容。这就是下面三行代码所做的事情,如下所示:

cout << "Character " << name << " has " 
     << hp << " hp and " 
     << goldPieces << " gold.";

看看这一行:

cout << "I have " << hp << " hp." << endl;

在这一行中,hp一词有两种用法。一种是在双引号之间,另一种则不是。双引号之间的单词总是按照你输入的方式精确输出。当不使用双引号时(例如,<< hp <),将执行变量查找。如果变量不存在,则你会得到编译器错误(未声明的标识符)。

内存中有一个为名称分配的空间,一个为玩家拥有的goldPieces数量分配的空间,以及一个为玩家生命值hp分配的空间。

提示

通常,你应该始终尝试在正确的变量中存储正确的数据类型。如果你不小心存储了错误的数据类型,你的代码可能会出现异常行为。

C++中的数学

C++中的数学运算很容易;+ (加),- (减),* (乘),/ (除)都是常见的 C++操作,并且将遵循正确的 BEDMAS 顺序(括号,指数,除法,乘法,加法,减法)。例如,我们可以像以下代码所示进行操作:

int answer = 277 + 5 * 4 / 2 + 20;

你可能还不熟悉另一个运算符:% (取模)。取模(例如,10 % 3)找到当x除以y时的余数。以下表格中有示例:

运算符(名称) 示例 答案
+ (加) 7 + 3 10
- (减) 8 - 5 3
* (乘) 5*6 30
/ (除) 12/6 2
% (取模) 10 % 3 1 (因为 10 除以 3 等于 3,余数=1)。

然而,我们通常不希望以这种方式进行数学运算。相反,我们通常希望通过一定的计算量来改变变量的值。这是一个较难理解的概念。比如说,玩家遇到了一个恶魔并受到了 15 点伤害。

以下代码行将用于减少玩家的生命值 15 点(信不信由你):

hp = hp - 15;                  // probably confusing :)

你可能会问为什么。因为在右侧,我们正在计算 hp 的新值(hp-15)。在找到 hp 的新值(比之前少 15)后,新值将被写入 hp 变量。

提示

陷阱

一个未初始化的变量具有它在内存中之前所持有的位模式。声明一个变量不会清除内存。所以,假设我们使用了以下代码行:

int hp;
hp = hp - 15;

第二行代码从其之前的值中减少了 15 点生命值。如果我们从未设置 hp = 100 或类似值,它的前一个值是多少?它可能是 0,但并不总是这样。

最常见的错误之一是在未初始化变量之前就使用它。

以下是一个进行此操作的缩写语法:

hp -= 15;

除了-=之外,您还可以使用+=向变量添加一些量,使用*=将变量乘以一个量,使用/=将变量除以一个量。

练习

执行以下操作后,写下x的值;然后,与您的编译器进行核对:

| 练习 | 答案 |
| --- | --- | --- |
| int x = 4; x += 4; | 8 |
| int x = 9; x-=2; | 7 |
| int x = 900; x/=2; | 450 |
| int x = 50; x*=2; | 100 |
| int x = 1; x += 1; | 2 |
| int x = 2; x -= 200; | -198 |
| int x = 5; x*=5; | 25 |

通用变量语法

在上一节中,你学习了在 C++中保存的每份数据都有一个类型。所有变量都是以相同的方式创建的;在 C++中,变量声明形式如下:

variableType variableName;

variableType告诉我们我们将要存储在变量中的数据类型。variableName是我们将用于读取或写入该内存块的符号。

原始类型

我们之前讨论了计算机内部的所有数据最终都将是数字。你的计算机代码负责正确解释这个数字。

据说 C++ 只定义了几个基本数据类型,如下表所示:

Char 一个单独的字母,例如 'a'、'b' 或 '+'
Short 从 -32,767 到 +32,768 的整数
Int 从 -2,147,483,647 到 +2,147,483,648 的整数
Float 从约 -1x10³⁸ 到 1x10³⁸ 的任何十进制值
Double 从约 -1x10³⁰⁸ 到 1x10³⁰⁸ 的任何十进制值
Bool true 或 false

前表中所提到的每种变量类型都有无符号版本。无符号变量可以包含自然数,包括 0(x >= 0)。例如,无符号 short 可能的值在 0 到 65535 之间。

注意

如果你进一步对 float 和 double 之间的区别感兴趣,请随时在互联网上查找。我将只解释最重要的用于游戏的 C++ 概念。如果你对这个文本中涵盖的某件事感到好奇,请随时查找。

结果表明,这些简单的数据类型本身就可以用来构建任意复杂的程序。“怎么做到的?”你可能会问。仅使用浮点数和整数构建 3D 游戏难道不难吗?

从浮点数和整数构建游戏并不真的很难,但更复杂的数据类型会有帮助。如果我们使用松散的浮点数来表示玩家的位置,编程将会变得繁琐且混乱。

对象类型

C++ 提供了结构来将变量分组在一起,这将使你的生活更加轻松。以下是一段代码示例:

#include <iostream>
using namespace std;
struct Vector        // BEGIN Vector OBJECT DEFINITION
{
  float x, y, z;     // x, y and z positions all floats
};                   // END Vector OBJECT DEFINITION.
// The computer now knows what a Vector is
// So we can create one.
int main()
{
  Vector v; // Create a Vector instance called v
  v.x=20, v.y=30, v.z=40; // assign some values
  cout << "A 3-space vector at " << v.x << ", " << v.y << ", " <<  v.z << endl;
}

这在内存中的样子非常直观;Vector 只是一个包含三个浮点数的内存块,如图所示。

对象类型

提示

不要混淆前一个截图中的 struct Vector 与 STL 的 std::vector。上面的 Vector 对象旨在表示一个三维向量,而 STL 的 std::vector 类型表示一个值的固定大小集合。

这里有一些关于前面代码列表的复习笔记:

首先,甚至在我们在使用我们的 Vector 对象类型之前,我们必须定义它。C++ 并没有内置的数学向量类型(它只支持标量数,他们认为这已经足够了!)。因此,C++ 允许你构建自己的对象结构,使你的生活更轻松。我们首先有以下定义:

struct Vector        // BEGIN Vector OBJECT DEFINITION
{
  float x, y, z;     // x, y, and z positions all floats
};                   // END Vector OBJECT DEFINITION.

这告诉计算机什么是 Vector(它是 3 个浮点数,所有这些都被声明为在内存中相邻)。Vector 在内存中的样子如图所示。

接下来,我们使用我们的 Vector 对象定义来创建一个名为 v 的 Vector 实例:

Vector v; // Create a Vector instance called v

struct Vector 定义实际上并没有创建一个 Vector 对象。你不能做Vector.x = 1。"你在谈论哪个对象实例?" C++编译器会问。你需要首先创建一个 Vector 实例,例如 Vector v1; 然后,你可以在 v1 实例上执行赋值,例如 v1.x = 0。

我们然后使用这个实例将值写入v

v.x=20, v.y=30, v.z=40; // assign some values

小贴士

在前面的代码中,我们使用了逗号来初始化同一行上的多个变量。这在 C++中是可以的。虽然你可以将每个变量单独放在一行上,但这里展示的方法也是可以的。

这使得v看起来像前面的截图。然后,我们打印它们:

cout << "A 3-space vector at " << v.x << ", " << v.y << ", " <<  v.z << endl;

在这里的两行代码中,我们通过简单地使用点(.)访问对象内的单个数据成员。v.x指的是对象v内的x成员。每个 Vector 对象将正好包含三个浮点数:一个叫x,一个叫y,一个叫z

练习 – Player

定义一个用于 Player 对象的 C++数据结构。然后,创建你的 Player 类的一个实例,并填充每个数据成员的值。

解决方案

让我们声明我们的 Player 对象。我们希望将所有与玩家相关的代码组合到 Player 对象中。我们这样做是为了使代码整洁有序。你在 Unreal Engine 中看到的代码将到处使用这样的对象;所以,请注意:

struct Player
{
  string name;
  int hp;
  Vector position;
}; // Don't forget this semicolon at the end!
int main()
{
  // create an object of type Player,
  Player me; // instance named 'me'
  me.name = "William";
  me.hp = 100.0f;
  me.position.x = me.position.y = me.position.z=0;
}

结构 Player 定义告诉计算机 Player 对象在内存中的布局方式。

小贴士

我希望你们注意到了结构声明末尾的强制分号。结构对象声明需要在末尾有一个分号,但函数不需要。这只是你必须记住的 C++规则。

在 Player 对象内部,我们声明了一个用于玩家名称的字符串,一个用于 hp 的浮点数,以及一个用于玩家完整 xyz 位置的 Vector 对象。

当我说对象时,我指的是 C++结构(或者稍后,我们将介绍术语类)。

等等!我们在 Player 对象中放了一个 Vector 对象!是的,你可以这样做。

在定义了 Player 对象内部有什么之后,我们实际上创建了一个名为 me 的 Player 对象实例,并给它赋了一些值。

在赋值之后,me 对象看起来如下所示:

解决方案

指针

一个特别难以理解的概念是指针的概念。指针并不难理解,但可能需要一段时间才能牢固掌握。

假设我们之前已经在内存中声明了一个类型为 Player 的变量:

Player me;
me.name = "William";
me.hp = 100.0f;

我们现在声明一个指向 Player 的指针:

Player* ptrMe;               // Declaring a pointer

*字符通常使事物变得特殊。在这种情况下,*使ptrMe变得特殊。*是使ptrMe成为指针类型的原因。

我们现在想将ptrMe链接到 me:

ptrMe = &me;                  // LINKAGE

小贴士

这个链接步骤非常重要。如果你在使用指针之前没有将指针链接到对象,你将得到一个内存访问违规。

ptrMe现在指向与 me 相同的对象。改变ptrMe将改变 me,如下面的图所示:

指针

指针能做什么?

当我们设置指针变量和它所指向的内容之间的链接时,我们可以通过指针来操作所指向的变量。

指针的一个用途是从代码的几个不同位置引用同一个对象。Player 对象是很好的候选对象。你可以为同一个对象创建任意数量的指针。被指向的对象不一定知道它们被指向,但可以通过指针对对象进行更改。

例如,假设玩家受到了攻击。他的 hp 会减少,这个减少将通过指针来完成,如图所示代码:

ptrMe->hp -= 33;      // reduced the player's hp by 33
ptrMe->name = "John";// changed his name to John

现在看看 Player 对象的当前样子:

指针能做什么?

因此,我们通过改变 ptrMe->name 来改变 me.name。因为 ptrMe 指向 me,通过 ptrMe 的更改会直接影响 me

除了有趣的箭头语法(当变量是指针时使用 ->),这个概念并不难理解。

地址运算符 &

注意前面代码示例中 & 符号的使用。& 运算符获取变量的内存地址。变量的内存地址是它在计算机内存空间中的位置。C++ 能够获取程序内存中任何对象的内存地址。变量的地址是唯一的,也是随机的。

假设我们打印一个整型变量 x 的地址,如下所示:

int x = 22;
cout << &x << endl; // print the address of x

在程序第一次运行时,我的电脑打印了以下内容:

0023F744

这个数字(&x 的值)只是变量 x 存储的内存单元。这意味着在这个特定的程序启动中,变量 x 位于内存单元编号 0023F744,如图所示:

地址运算符 &

现在,创建并分配一个指针变量到 x 的地址:

int *px;
px = &x;

我们在这里做的是将变量 x 的内存地址存储在变量 px 中。所以,我们用另一个叫做 px 的不同变量来隐喻地指向变量 x。这看起来可能和下面图示的类似:

地址运算符 &

在这里,变量 px 包含了变量 x 的地址。换句话说,变量 px 是另一个变量的引用。对 px 进行求差(即访问 px 所引用的变量)是通过使用 * 符号来完成的:

cout << *px << endl;

空指针

空指针是一个值为 0 的指针变量。一般来说,大多数程序员喜欢在创建新的指针变量时将其初始化为空(0)。计算机程序通常不能访问内存地址 0(它是保留的),所以如果你尝试引用空指针,你的程序将会崩溃,如图所示:

空指针

提示

Pointer Fun with Binky 是一个关于指针的有趣视频。看看 www.youtube.com/watch?v=i49_SNt4yfk

cin

cin 是 C++ 传统的从用户输入到程序的方式。cin 很容易使用,因为它在放置值时会查看将要放入的变量的类型。例如,如果我们想询问用户的年龄并将其存储在一个 int 变量中,我们可以这样做:

cout << "What is your age?" << endl;
int age;
cin >> age;

printf()

虽然我们到目前为止已经使用了 cout 来打印变量,但你需要了解另一个常用的用于打印到控制台的功能。这个功能被称为 printf 函数。printf 函数包含在 <iostream> 库中,所以你不需要额外 #include 任何内容来使用它。游戏行业的一些人更喜欢使用 printf 而不是 cout(我知道我就是这样),所以让我们来介绍它。

让我们继续了解 printf() 的工作原理,如下面的代码所示:

#include <iostream>
#include <string>
using namespace std;
int main()
{
  char character = 'A';
  int integer = 1;
  printf( "integer %d, character %c\n", integer, character );
}

小贴士

下载示例代码

你可以从你购买的所有 Packt 出版物书籍的账户中下载示例代码文件 www.packtpub.com。如果你在其他地方购买了这本书,你可以访问 www.packtpub.com/support 并注册以直接将文件通过电子邮件发送给你。

我们从一个格式字符串开始。格式字符串就像一个画框,变量将会被插入到格式字符串中 % 的位置。然后,整个内容会被输出到控制台。在上面的例子中,整数变量将被插入到第一个 % (%d) 的位置,字符将被插入到第二个 % (%c) 的位置,如下面的截图所示:

printf()

你必须使用正确的格式代码才能正确地格式化输出;请查看以下表格:

数据类型 格式代码
整数 %d
字符 %c
字符串 %s

要打印 C++ 字符串,你必须使用 string.c_str() 函数:

string s = "Hello";
printf( "string %s\n", s.c_str() );

s.c_str() 函数访问 printf 所需的 C 字符串指针。

如果你使用错误的格式代码,输出可能不会正确显示,或者程序可能会崩溃。

练习

询问用户的姓名和年龄,并使用 cin 读取它们。然后,使用 printf()(而不是 cout)在控制台为他发出问候。

解决方案

这就是程序的外观:

#include <iostream>
#include <string>
using namespace std;
int main()
{
  cout << "Name?" << endl;
  string name;
  cin >> name;
  cout << "Age?" << endl; 
  int age;
  cin >> age;
  cout << "Hello " << name << " I see you have attained " << age  << " years. Congratulations." << endl;
}

小贴士

字符串实际上是一种对象类型。里面只是一堆字符!

摘要

在本章中,我们讨论了变量和内存。我们讨论了变量上的数学运算以及它们在 C++ 中的简单性。

我们还讨论了如何使用这些更简单的数据类型(如浮点数、整数和字符)的组合来构建任意复杂的数据类型。这种构建称为对象。

第三章。If,Else 和 Switch

在上一章中,我们讨论了内存的重要性以及如何使用它来在计算机内部存储数据。我们讲述了如何使用变量为程序保留内存,以及我们如何在变量中包含不同类型的信息。

在本章中,我们将讨论如何控制程序的流程以及我们如何通过使用控制流语句来改变执行哪些代码。在这里,我们将讨论不同类型的控制流,如下所示:

  • If 语句

  • 如何使用==运算符检查相等性

  • Else 语句

  • 如何测试不等式(即,如何使用运算符>,>=,<,<=和!=检查一个数字是否大于或小于另一个)

  • 使用逻辑运算符(如 not (!),and (&&),or (||))

  • 我们使用虚幻引擎的第一个示例项目

  • 多于两种方式的分支:

    • ElseIf 语句

    • Switch 语句

分支

我们在第二章中编写的计算机代码,“变量和内存”是单向的:直接向下。有时,我们可能希望能够跳过代码的一部分。我们可能希望代码能够以多于一个方向分支。从图解的角度来看,我们可以用以下方式表示:

分支

流程图

换句话说,我们希望在特定条件下不运行某些代码行。前面的图称为流程图。根据这个流程图,如果我们饿了,那么我们将准备三明治,吃掉它,然后去沙发上休息。如果我们不饿,那么没有必要做三明治,所以我们只需在沙发上休息。

我们在这本书中只会偶尔使用流程图,但在 UE4 中,你甚至可以使用流程图来编写你的游戏(使用称为蓝图的东西)。

注意

本书是关于 C++代码的,因此在这本书中,我们将始终将我们的流程图转换为实际的 C++代码。

控制程序的流程

最终,我们希望代码在特定条件下以某种方式分支。改变下一行代码执行顺序的代码命令被称为控制流语句。最基本的控制流语句是if语句。为了能够编写if语句,我们首先需要一种检查变量值的方法。

因此,首先,让我们介绍==符号,它用于检查变量的值。

==运算符

为了在 C++中检查两个事物是否相等,我们需要使用不是一条而是两条连续的等号(==),如下所示:

int x = 5; // as you know, we use one equals sign 
int y = 4; // for assignment..
// but we need to use two equals signs 
// to check if variables are equal to each other
cout << "Is x equal to y? C++ says: " << (x == y) << endl;

如果你运行前面的代码,你会注意到输出如下:

Is x equal to y? C++ says: 0 

在 C++中,1 表示真,0 表示假。如果你想用 true 或 false 代替 1 和 0,你可以在cout代码行的boolalpha流操作符中使用,如下所示:

cout << "Is x equal to y? C++ says: " << boolalpha << 
        (x == y) << endl;

== 操作符是一种比较操作符。C++ 使用 == 来检查相等性而不是仅仅使用 = 的原因是因为我们已经在赋值操作符上使用了 = 符号了!(参见第二章中的更多关于变量部分,变量和内存)。如果我们使用单个 = 符号,C++ 会假设我们想要将 x 覆盖为 y,而不是比较它们。

编写 if 语句

现在我们已经掌握了双等号,让我们编写流程图。前面流程图代码如下:

bool isHungry = true;  // can set this to false if not
                       // hungry!
if( isHungry == true ) // only go inside { when isHungry is true
{
  cout << "Preparing snack.." << endl;
  cout << "Eating .. " << endl;
}
cout << "Sitting on the couch.." << endl;
}

提示

这是我们第一次使用 bool 变量!一个 bool 变量要么持有 true 的值,要么持有 false 的值。

首先,我们从一个名为 isHungrybool 变量开始,并将其设置为 true

然后,我们使用 if 语句,如下所示:

if( isHungry == true )

如果语句就像是对其下代码块的一个守护者。(记住,一个代码块是一组被 {} 包围的代码。)

编写 if 语句

只有当 isHungry == true 时,你才能读取 {} 之间的代码。

只有当 isHungry == true 时,你才能访问花括号内的代码。否则,你将无法访问,并被迫跳过整个代码块。

提示

我们可以通过简单地写下以下代码行来达到相同的效果:

if( isHungry )     // only go here if isHungry is true

这可以用作以下内容的替代:

if( isHungry == true )

人们可能使用 if( isHungry ) 形式的原因是为了避免出错的可能性。不小心写成 if( isHungry = true ) 将会在每次 if 语句被执行时将 isHungry 设置为 true!为了避免这种可能性,我们只需写 if( isHungry )。或者,有些人(明智的人)使用所谓的 Yoda 条件来检查 if 语句:if( true == isHungry )。我们这样写 if 语句的原因是,如果我们不小心写成 if( true = isHungry ),这将生成编译器错误,捕捉到这个错误。

尝试运行这段代码来理解我的意思:

int x = 4, y = 5;
cout << "Is x equal to y? C++ says: " << (x = y) << endl; //bad!
// above line overwrote value in x with what was in y,
// since the above line contains the assignment x = y
// we should have used (x == y) instead.
cout << "x = " << x << ", y = " << y << endl;

下面的行显示了前面代码行的输出:

Is x equal to y? C++ says: 5
x = 5, y = 5

包含 (x = y) 的代码行将 x 的前一个值(它是 4)覆盖为 y 的值(它是 5)。尽管我们试图检查 x 是否等于 y,但在前面的语句中发生的情况是 x 被赋值为 y 的值。

编写 else 语句

isHungry == true 时,我们使用 else 语句来让我们的代码在代码的 if 部分不运行的情况下执行某些操作。

例如,假设我们还有其他事情要做,假设我们不是很饿,如下面的代码片段所示:

bool isHungry = true;
if( isHungry )      // notice == true is implied!
{
  cout << "Preparing snack.." << endl;
  cout << "Eating .. " << endl;
}
else                // we go here if isHungry is FALSE
{
  cout << "I'm not hungry" << endl;
}
cout << "Sitting on the couch.." << endl;
}

关于 else 关键字,你需要记住以下几点重要事项:

  • else 语句必须始终紧随 if 语句之后。在 if 块的末尾和相应的 else 块之间不能有任何额外的代码行。

  • 你永远不能同时进入 if 和相应的 else 块。总是只有一个。编码 else 语句

    isHungry不等于 true 时,else 语句是你会采取的方式

你可以把if/else语句看作是一个守卫,将人们引向左边或右边。每个人要么走向食物(当isHungry==true时),要么远离食物(当isHungry==false时)。

使用其他比较运算符(>, >=, <, <=, 和 !=)测试不等式

其他逻辑比较也可以在 C++中轻松完成。><符号在数学中的含义就是它们所表示的。它们分别是大于(>)和小于(<)符号。>=的含义与数学中的符号相同。<=是 C++中的代码。由于键盘上没有符号,我们不得不在 C++中使用两个字符来表示它。!=是我们在 C++中表示“不等于”的方式。例如,如果我们有以下几行代码:

int x = 9;
int y = 7;

我们可以像这里所示那样询问计算机x > yx < y

cout << "Is x greater than y? " << (x > y) << endl;
cout << "Is x greater than OR EQUAL to y? " << (x >= y) << endl;
cout << "Is x less than y? " << (x < y) << endl;
cout << "Is x less than OR EQUAL to y? " << (x <= y) << endl;
cout << "Is x not equal to y? " << (x != y) << endl;

小贴士

我们需要在 x 和 y 的比较周围加上括号,这是因为有一个叫做运算符优先级的东西。如果我们没有括号,C++会在<<<运算符之间感到困惑。这很奇怪,你稍后会更好地理解这一点,但你需要 C++在输出结果(<<)之前先评估(x < y)的比较。有一个优秀的表格可供参考,链接为en.cppreference.com/w/cpp/language/operator_precedence

使用逻辑运算符

逻辑运算符允许你进行更复杂的检查,而不仅仅是检查简单的相等或不等。例如,要进入一个特殊房间,玩家需要同时拥有红色和绿色的钥匙卡。我们想要检查两个条件是否同时为真。为了进行这种复杂的逻辑语句检查,我们需要学习三个额外的结构:not!)、and&&)和or||)运算符。

非(!)运算符

!运算符可以用来反转布尔变量的值。以下是一个示例代码:

bool wearingSocks = true;
if( !wearingSocks ) // same as if( false == wearingSocks )
{
cout << "Get some socks on!" << endl;
}
else
{
	cout << "You already have socks" << endl;
}

这里的if语句检查你是否穿了袜子。然后,你会接到一个命令去拿一些袜子。!运算符将布尔变量中的值反转为其相反值。

我们使用所谓的真值表来显示对布尔变量使用!运算符的所有可能结果,如下所示:

wearingSocks !wearingSocks
true false
false true

因此,当wearingSocks的值为 true 时,!wearingSocks的值为false,反之亦然。

练习

  1. wearingSocks的值为 true 时,你认为!!wearingSocks的值会是什么?

  2. 在以下代码运行后,isVisible的值是多少?

bool hidden = true;
bool isVisible = !hidden;

解决方案

  1. 如果wearingSocks为真,则!wearingSocks为假。因此,!!wearingSocks再次变为真。这就像说我不饿。双重否定是“不不”,所以这句话的意思是我实际上饿了。

  2. 第二个问题的答案是假的。hidden为真,所以!hidden为假。假随后被保存到isVisible变量中。

小贴士

!运算符有时被俗称为感叹号。前面的双感叹号运算符(!!)是双重否定和双重逻辑反转。如果你对一个bool变量进行双感叹号操作,变量没有净变化。如果你对一个int变量进行双感叹号操作,它变成一个简单的bool变量(truefalse)。如果int值大于零,它将简化为true。如果int值已经是 0,它将简化为false

与(&&)运算符

假设我们只想在两个条件都为真时运行代码段。例如,如果我们穿着袜子并且穿着衣服,我们才算穿衣服。你可以使用以下代码来检查这一点:

bool wearingSocks = true;
bool wearingClothes = false;
if( wearingSocks && wearingClothes )// && requires BOTH to be true
{
	cout << "You are dressed!" << endl;
}
else
{
	cout << "You are not dressed yet" << endl;
}

或(||)运算符

我们有时希望如果任何一个变量为true,就运行代码段。

例如,如果玩家在关卡中找到特殊星星或完成关卡的时间少于 60 秒,他将获得一定的奖励,在这种情况下,你可以使用以下代码:

bool foundStar = true;
float levelCompleteTime = 25.f;
float maxTimeForBonus = 60.f;
// || requires EITHER to be true to get in the { below
if( foundStar || levelCompleteTime < maxTimeForBonus )
{
	cout << "Bonus awarded!" << endl;
}
else
{
	cout << "No bonus." << endl;
}

我们第一个虚幻引擎的示例

我们需要开始使用虚幻引擎。

小贴士

一个警告:当你打开你的第一个虚幻项目时,你会发现代码看起来非常复杂。不要气馁。只需关注突出显示的部分。在你作为程序员的整个职业生涯中,你将经常不得不处理包含你不懂的部分的非常大的代码库。然而,关注你理解的部分将使这部分工作变得富有成效。

打开虚幻引擎启动器应用程序(具有蓝色 UE4 图标 我们的第一个虚幻引擎示例)。选择启动虚幻引擎 4.4.3,如下截图所示:

我们的第一个虚幻引擎示例

小贴士

如果启动按钮变灰,你需要转到选项卡并下载一个引擎(约 3 GB)。

一旦启动引擎(可能需要几秒钟),你将进入虚幻项目浏览器屏幕(黑色 UE4 图标 我们的第一个虚幻引擎示例),如下截图所示。

现在,在 UE4 项目浏览器中选择新建项目选项卡。向下滚动,直到到达代码拼图。这是几个较简单的项目之一,代码不多,所以是一个好的开始。我们稍后会进入 3D 项目。

我们的第一个虚幻引擎示例

在这个屏幕上,这里有几点需要注意:

  • 确保你处于新建项目选项卡

  • 当你点击代码拼图时,确保它右边有C++图标,而不是蓝图拼图

  • 名称框中输入你的项目名称,Puzzle(这对于我稍后提供的示例代码非常重要)

  • 如果你想要更改存储文件夹(到不同的驱动器),点击下箭头,以便文件夹出现。然后,命名你想要存储项目的目录。

完成所有这些后,选择创建项目

Visual Studio 2013 将打开你的项目代码。

Ctrl+F5构建并启动项目。

一旦项目编译并运行,你应该会看到如以下截图所示的虚幻引擎编辑器:

使用虚幻引擎的第一个示例

看起来很复杂吗?哦,确实如此!我们稍后会在侧边的工具栏中探索一些功能。现在,只需选择播放(如前一张截图所示,标记为黄色)。

这将启动游戏。它应该看起来像这样:

使用虚幻引擎的第一个示例

现在,尝试点击这些方块。一旦点击一个方块,它就会变成橙色,这会增加你的分数。

我们将要找到执行此操作的代码段,并稍作修改其行为。

查找并打开PuzzleBlock.cpp文件。

提示

在 Visual Studio 中,项目中的文件列表位于解决方案资源管理器内。如果你的解决方案资源管理器被隐藏,只需从顶部菜单点击视图/解决方案资源管理器

在此文件中,向下滚动到底部,你会找到一个以以下词开始的代码段:

void APuzzleBlock::BlockClicked(UPrimitiveComponent* ClickedComp)

使用虚幻引擎的第一个示例

APuzzleBlock是类名,BlockClicked是函数名。每当拼图块被点击时,从起始{到结束}的代码段就会运行。希望这会在稍后变得更加清晰。

它有点像if语句。如果一个拼图块被点击,那么这个代码块就会为该拼图块运行。

我们将逐步讲解如何使方块在被点击时改变颜色(因此,第二次点击会将方块的颜色从橙色变回蓝色)。

以下步骤请务必小心操作:

  1. 打开PuzzleBlock.h文件。在行 25(包含以下代码)之后:

    /** Pointer to orange material used on active blocks */
    UPROPERTY()
    class UMaterialInstance* OrangeMaterial;
    

    在前面的代码行之后插入以下代码:

    UPROPERTY()
    class UMaterialInstance* BlueMaterial;
    
  2. 现在,打开PuzzleBlock.cpp文件。在行 40(包含以下代码)之后:

    // Save a pointer to the orange material
    OrangeMaterial = ConstructorStatics.OrangeMaterial.Get();
    

    在前面的代码行之后插入以下代码:

    BlueMaterial = ConstructorStatics.BlueMaterial.Get();
    
  3. 最后,在PuzzleBlock.cpp中,将void APuzzleBlock::BlockClicked代码段的内容(第 44 行)替换为以下代码:

    void APuzzleBlock::BlockClicked(UPrimitiveComponent* ClickedComp)
    {
      // --REPLACE FROM HERE--
      bIsActive = !bIsActive; // flip the value of bIsActive
      // (if it was true, it becomes false, or vice versa)
      if ( bIsActive )
      {
        BlockMesh->SetMaterial(0, OrangeMaterial);
      }
      else
      {
        BlockMesh->SetMaterial(0, BlueMaterial);
      }
      // Tell the Grid
      if(OwningGrid != NULL)
      {
        OwningGrid->AddScore();
      }
      // --TO HERE--
    }
    

提示

只需在void APuzzleBlock::BlockClicked (UPrimitiveComponent* ClickedComp)语句内部替换。

不要替换以void APuzzleBlock::BlockClicked开头的行。如果你没有将项目命名为 Puzzle,可能会出现错误(警告过你了)。

那么,让我们分析一下。这是第一行代码:

bIsActive = !bIsActive; // flip the value of bIsActive

这行代码只是翻转了 bIsActive 的值。bIsActive 是一个 bool 变量(它在 APuzzleBlock.h 中创建)。如果 bIsActive 为真,则 !bIsActive 将为假。所以,每当执行此行代码时(这发生在点击任何块上),bIsActive 的值就会反转(从 truefalse 或从 falsetrue)。

让我们考虑下一块代码:

if ( bIsActive )
  {
    BlockMesh->SetMaterial(0, OrangeMaterial);
  }
  else
  {
    BlockMesh->SetMaterial(0, BlueMaterial);
  }

我们只是改变了块的颜色。如果 bIsActive 为真,则块变为橙色。否则,块变为蓝色。

练习

到现在为止,你应该注意到,提高编程技能的最佳方式就是实际编程。你必须大量练习编程,才能显著提高编程技能。

创建两个整数变量,称为 x 和 y,并从用户那里读取它们。编写一个 if/else 语句对,打印较大值的变量名。

解答

前面练习的解答如下所示:

int x, y;
cout << "Enter two numbers integers, separated by a space " << endl;
cin >> x >> y;
if( x < y ) 
{
  cout << "x is less than y" << endl;
}
else
{
  cout << "x is greater than y" << endl;
}

小贴士

cin 期望一个数字时,不要输入字母。如果发生这种情况,cin 可能会失败,并给变量一个错误值。

多于两种方式的分支代码

在前面的章节中,我们只能使代码以两种方式之一分支。在模拟代码中,我们有以下代码:

if( some condition is true )
{
  execute this;
}
else // otherwise
{
  execute that;
}

小贴士

模拟代码是 假代码。编写模拟代码是头脑风暴和规划代码的绝佳方式,尤其是如果你还不习惯 C++。

这段代码有点像一条道路上的比喻性分叉,只有两个方向可以选择。

有时候,我们可能想要代码分支超过两个方向。我们可能希望代码以三种方式分支,甚至更多。例如,假设代码的走向取决于玩家当前持有的物品。玩家可以持有三种不同的物品之一:硬币、钥匙或沙币。C++ 允许这样做!实际上,在 C++ 中,你可以按需以任意数量的方向分支。

else if 语句

else if 语句是一种在超过两个可能的分支方向上进行编码的方式。在以下代码示例中,代码将根据玩家是否持有 CoinKeySanddollar 对象而以三种不同的方式之一执行。

#include <iostream>
using namespace std;
int main()
{
  enum Item  // enums define a new type of variable!
  {
    Coin, Key, Sanddollar // variables of type Item can have 
    // any one of these 3 values
  }
  Item itemInHand = Key;  // Try changing this value to Coin, 
                          // Sanddollar
  if( itemInHand == Key )
  {
    cout << "The key has a lionshead on the handle." << endl;
    cout << "You got into a secret room using the Key!" << endl;
  }
  else if( itemInHand == Coin )
  {
    cout << "The coin is a rusted brassy color. It has a picture  of a lady with a skirt." << endl;
    cout << "Using this coin you could buy a few things" << endl;
  }
  else if( itemInHand == Sanddollar )
  {
    cout << "The sanddollar has a little star on it." << endl;
    cout << "You might be able to trade it for something." <<  endl;
  }
  return 0; 
}

注意

注意,前面的代码只会在三种不同的方式中的一种。在 ifelse ifelse if 系列检查中,我们只会进入一个代码块。

The else if statement

练习

使用 C++ 程序回答以下问题。务必尝试这些练习,以便熟练掌握这些相等运算符。

#include <iostream>
using namespace std;
int main()
{
  int x;
  int y;
  cout << "Enter an integer value for x:" << endl;
  cin >> x; // This will read in a value from the console
  // The read in value will be stored in the integer 
  // variable x, so the typed value better be an integer!
  cout << "Enter an integer value for y:" << endl;
  cin >> y;
  cout << "x = " << x << ", y = " << y << endl;
  // *** Write new lines of code here
}

在指出 (// *** Write new...) 的位置编写一些新的代码行:

  1. 检查 xy 是否相等。如果它们相等,打印 x and y are equal。否则,打印 x and y are not equal

  2. 一个关于不等式的练习:检查 x 是否大于 y。如果是,打印 x is greater than y。否则,打印 y is greater than x

解答

要评估相等性,请插入以下代码:

if( x == y )
{
  cout << "x and y are equal" << endl;
}
else
{
  cout << "x and y are not equal" << endl;
}

要检查哪个值更大,请插入以下代码:

if( x > y )
{
  cout << "x is greater than y" << endl;
}
else if( x < y )
{
  cout << "y is greater than x" << endl;
}
else // in this case neither x > y nor y > x
{
  cout << "x and y are equal" << endl;
}

switch 语句

switch 语句允许你的代码以多种方式分支。switch 语句将要执行的操作是查看变量的值,并根据其值,代码将走向不同的方向。

我们还将在这里介绍 enum 构造:

#include <iostream>
using namespace std;
enum Food  // enums define a new type of variable!
{
  // a variable of type Food can have any of these values
  Fish,
  Bread,
  Apple,
  Orange
};
int main()
{
  Food food = Bread; // Change the food here
  switch( food )
  {
    case Fish:
      cout << "Here fishy fishy fishy" << endl;
      break;
    case Bread:
      cout << "Chomp! Delicious bread!" << endl;
      break;
    case Apple:
      cout << "Mm fruits are good for you" << endl;
      break;
    case Orange:
      cout << "Orange you glad I didn't say banana" << endl;
      break;
    default:  // This is where you go in case none
              // of the cases above caught
      cout << "Invalid food" << endl;
      break;
  }
  return 0;
}

switch 就像硬币分类器。当你投入 25 美分的硬币时,它会找到其所属的 25 美分堆。同样,switch 语句将简单地允许代码跳转到适当的部分。以下图表展示了分类硬币的示例:

switch 语句

switch 语句内部的代码将继续运行(逐行),直到遇到 break; 语句。break 语句会跳出 switch 语句。请查看以下图表以了解 switch 的工作原理:

switch 语句

  1. 首先,检查 Food 变量。它有什么值?在这种情况下,它包含 Fish

  2. switch 命令会跳转到正确的案例标签。(如果没有匹配的案例标签,switch 将会被跳过)。

  3. 执行了 cout 语句,控制台上出现了Here fishy fishy fishy

  4. 在检查变量并打印用户响应后,执行了 break 语句。这使得我们停止在 switch 中运行代码行,并退出 switch。接下来运行的代码行只是如果没有 switch 的话,程序中原本应该运行的下一行代码(在 switch 语句的闭合花括号之后)。这是底部的打印语句,它说“switch 结束”。

switchif 的比较

switchif / else if / else 链的比较。然而,switch 可以比 if / else if / else if / else 链更快地生成代码。直观地说,switch 只会跳转到代码的适当部分以执行。if / else if / else 链可能涉及更复杂的比较(包括逻辑比较),这可能会占用更多的 CPU 时间。你将使用 if 语句的主要原因是在括号内进行更多自定义的比较。

小贴士

枚举实际上是一个整型。为了验证这一点,请打印以下代码:

cout <<  "Fish=" << Fish << 
         " Bread=" << Bread << 
         " Apple=" << Apple << 
         " Orange=" << Orange << endl;

你将看到枚举的整数值——仅此而已。

有时,程序员想要在同一个 switch case 标签下分组多个值。比如说,我们有一个如下所示的 enum 对象:

enum Vegetables { Potato, Cabbage, Broccoli, Zucchini };

一个程序员想要将所有绿色蔬菜放在一起,因此他编写了以下 switch 语句:

switch( veg )
{
case Zucchini:	// zucchini falls through because no break
case Broccoli:	// was written here
  cout << "Greens!" << endl;
  break;
default:
  cout << "Not greens!" << endl;
  break;
}

在这种情况下,Zucchini 会穿透并执行与 Broccoli 相同的代码。非绿色蔬菜在 default 案例标签中。为了防止穿透,你必须记得在每个 case 标签后插入显式的 break 语句。

我们可以通过在switch中显式使用关键字break来编写一个不允许西葫芦掉过的相同switch版本:

switch( veg )
{
case Zucchini:	// zucchini no longer falls due to break
  cout << "Zucchini is a green" << endl;
  break;// stops case zucchini from falling through
case Broccoli:	// was written here
  cout << "Broccoli is a green" << endl;
  break;
default:
  cout << "Not greens!" << endl;
  break;
}

注意,良好的编程实践是即使它是列出的最后一个case,也应该breakdefault情况。

练习

完成以下程序,该程序有一个enum对象,其中包含一系列可供选择骑乘的坐骑。编写一个switch语句,为选定的坐骑打印以下消息:

马匹 骑手英勇而强大
骆马 这匹骆马又白又美丽
骆驼 你被分配了一匹骆驼来骑。你对此感到不满。
羊群 咩!羊群几乎承受不住你的重量。
马儿 马儿!

记住,enum对象实际上是一个int声明。enum对象中的第一个条目默认为 0,但你可以使用=运算符给enum对象赋予任何你想要的起始值。enum对象中的后续值是按顺序排列的int

小贴士

位移后的枚举

enum对象中,一个常见的做法是为每个条目分配一个位移后的值:

enum WindowProperties
{
  Bordered    = 1 << 0, // binary 001
  Transparent = 1 << 1, // binary 010
  Modal       = 1 << 2  // binary 100
};

位移后的值应该能够组合窗口属性。这是赋值将如何看起来:

// bitwise OR combines properties
WindowProperties wp = Bordered | Modal;

检查哪些WindowProperties已被设置涉及到使用位与的检查:

// bitwise AND checks to see if wp is Modal
if( wp & Modal )
{
cout << "You are looking at a modal window" << endl;
}

位运算是一种稍微超出本文范围的技术,但我包括这个提示只是为了让你知道。

解决方案

前一个练习的解决方案如下所示:

#include <iostream>
using namespace std;
enum Mount
{
  Horse=1, Mare, Mule, Sheep, Chocobo
  // Since Horse=1, Mare=2, Mule=3, Sheep=4, and Chocobo=5.
};
int main()
{
  int mount;  // We'll use an int variable for mount
              // so cin works
  cout << "Choose your mount:" << endl;
  cout << Horse << " Horse" << endl;
  cout << Mare << " Mare" << endl;
  cout << Mule << " Mule" << endl;
  cout << Sheep << " Sheep" << endl;
  cout << Chocobo << " Chocobo" << endl;
  cout << "Enter a number from 1 to 5 to choose a mount" << endl;
  cin >> mount;
    // Write your switch here. Describe what happens
    // when you mount each animal in the switch below
  switch( mount )
  {
    default:
      cout << "Invalid mount" << endl;
      break;
  }
return 0;
}

概述

在本章中,你学习了如何分支代码。分支使得代码能够走向不同的方向,而不是直接向下执行。

在下一章中,我们将继续介绍另一种类型的控制流语句,这将允许你返回并重复一行代码一定次数。重复的代码段将被称为循环。

第四章:循环

在上一章中,我们讨论了if语句。if语句允许你在代码块的执行上设置条件。

在本章中,我们将探讨循环,这是一种代码结构,它允许你在满足某些条件下重复执行一段代码。一旦条件变为假,我们就停止重复执行该代码块。

在本章中,我们将探讨以下主题:

  • 当循环

  • Do/while 循环

  • For 循环

  • Unreal Engine 中一个实用的循环示例

While 循环

while循环用于重复执行代码的一部分。如果你有一系列必须重复执行以实现某个目标的操作,这非常有用。例如,以下代码中的while循环会重复打印变量x的值,随着x从 1 增加到 5:

int x = 1;
while( x <= 5 ) // may only enter the body of the while when x<=5
{
  cout << "x is " << x << endl;
  x++;
}
cout << "Finished" << endl;

这是前面程序的输出:

x is 1
x is 2
x is 3
x is 4
x is 5
Finished

在代码的第一行,创建了一个整数变量x并将其设置为 1。然后,我们进入while条件。while条件表示,只要x小于或等于 5,就必须停留在随后的代码块中。

循环的每次迭代(迭代意味着绕循环走一圈)都会从任务中完成更多的工作(打印数字 1 到 5)。我们编程循环,一旦任务完成(当x <= 5不再为真时),就自动退出。

与上一章的if语句类似,只有当你满足while循环括号内的条件时,才允许进入while循环下面的代码块(在先前的例子中,x <= 5)。你可以尝试在while循环的位置用心理解一个if循环,如下面的代码所示:

int x = 1;
if( x <= 5 ) // you may only enter the block below when x<=5
{
  cout << "x is " << x << endl;
  x++;
}
cout << "End of program" << endl;

前面的代码示例只会打印x is 1。所以,while循环就像一个if语句,只是它具有这种特殊的自动重复属性,直到while循环括号中的条件变为false

注意

我想用一个视频游戏来解释while循环的重复。如果你不知道 Valve 的《传送门》,你应该玩一玩,至少为了理解循环。查看www.youtube.com/watch?v=TluRVBhmf8w获取演示视频。

while循环在底部有一个类似魔法的传送门,这会导致循环重复。以下截图说明了我的意思:

While 循环

while循环的末尾有一个传送门,它会带你回到开始的地方

在前面的截图中,我们从橙色传送门(标记为O)回到蓝色传送门(标记为B)。这是我们第一次在代码中返回。这就像时间旅行,只是对于代码来说。多么令人兴奋!

通过 while 循环块的唯一方法是不满足入口条件。在前面的例子中,一旦 x 的值变为 6(因此,x <= 5 变为 false),我们就不会再次进入 while 循环。由于橙色门户在循环体内,一旦 x 变为 6,我们就能到达完成状态。

无限循环

你可能会永远被困在同一个循环中。考虑以下代码块中修改后的程序(你认为输出会是什么?):

int x = 1;
while( x <= 5 ) // may only enter the body of the while when x<=5
{
  cout << "x is " << x << endl;
}
cout << "End of program" << endl;

这就是输出将看起来像这样:

x is 1
x is 1
x is 1
.
.
.
(repeats forever)

循环会无限重复,因为我们移除了改变 x 值的代码行。如果 x 的值保持不变且不允许增加,我们将被困在 while 循环体内。这是因为如果 x 在循环体内没有改变,循环的退出条件(x 的值变为 6)将无法满足。

以下练习将使用前几章的所有概念,例如 += 和递减操作。如果你忘记了什么,请返回并重新阅读前面的部分。

练习

  1. 编写一个 while 循环,该循环将打印从 1 到 10 的数字。

  2. 编写一个 while 循环,该循环将打印从 10 到 1 的数字(反向)。

  3. 编写一个 while 循环,该循环将打印数字 2 到 20,每次增加 2(例如 2、4、6 和 8)。

  4. 编写一个 while 循环,该循环将打印从 1 到 16 的数字及其旁边的平方。

以下是一个练习 4 的程序输出示例:

1 1
2 4
3 9
4 16
5 25

解决方案

前面练习的代码解决方案如下:

  1. 打印从 1 到 10 的数字的 while 循环的解决方案如下:

    int x = 1;
    while( x <= 10 )
    {
      cout << x << endl;
      x++;
    }
    
  2. 打印从 10 到 1 的反向数字的 while 循环的解决方案如下:

    int x = 10; // start x high
    while( x >= 1 ) // go until x becomes 0 or less
    {
      cout << x << endl;
      x--; // take x down by 1
    }
    
  3. 打印从 2 到 20 的数字,每次增加 2 的 while 循环的解决方案如下:

    int x = 2;
    while( x <= 20 )
    {
      cout << x << endl;
      x+=2; // increase x by 2's
    }
    
  4. 打印从 1 到 16 并显示其平方的 while 循环的解决方案如下:

    int x = 1;
    while( x <= 16 )
    {
      cout << x << "   " << x*x << endl; // print x and it's  square
      x++;
    }
    

do/while 循环

do/while 循环几乎与 while 循环相同。以下是一个 do/while 循环的例子,它与第一个我们检查的 while 循环等效:

int x = 1;
do
{
  cout << "x is " << x << endl;
  x++;
} while( x <= 5 ); // may only loop back when x<=5
cout << "End of program" << endl;

这里的唯一区别是,我们不需要在我们的第一次进入循环时检查 while 条件。这意味着 do/while 循环的体总是至少执行一次(而 while 循环可以在第一次遇到时完全跳过,如果进入 while 循环的条件是 false)。

for 循环

for 循环的结构与 while 循环略有不同,但两者非常相似。

让我们比较 for 循环与等效的 while 循环的解剖结构。以下是一些代码片段的例子:

The for loop An equivalent while loop

|

for( int x = 1; x <= 5; x++ )
{
  cout << x << endl;
}

|

int x = 1;
while( x <= 5 )
{
  cout << x << endl;
  x++;
}

|

for 循环在其括号内有三个语句。让我们按顺序检查它们。

for循环的第一个语句(int x = 1;)只执行一次,当我们第一次进入for循环的主体时。它通常用于初始化循环计数器的值(在这种情况下,变量x)。for循环内部的第二个语句(x <= 5;)是循环的重复条件。只要x <= 5,我们就必须继续停留在for循环的主体内部。for循环括号内的最后一个语句(x++;)在每次完成for循环的主体后执行。

以下序列图解释了for循环的进展:

The for loop

练习

  1. 编写一个for循环,用于计算从 1 到 10 的数字之和。

  2. 编写一个for循环,用于打印 6 的倍数,从 6 到 30(6、12、18、24 和 30)。

  3. 编写一个for循环,用于打印 2 到 100 的 2 的倍数(例如,2、4、6、8 等等)。

  4. 编写一个for循环,用于打印 1 到 16 的数字及其旁边的平方。

解决方案

以下是前面练习的解决方案:

  1. 打印从 1 到 10 的数字之和的for循环的解决方案如下:

    int sum = 0;
    for( int x = 1; x <= 10; x++ )
    {
      sum += x;
      cout << x << endl;
    }
    
  2. 从 30 开始打印 6 的倍数的for循环的解决方案如下:

    for( int x = 6; x <= 30; x += 6 )
    {
      cout << x << endl;
    }
    
  3. 打印从 2 到 100 的 2 的倍数的for循环的解决方案如下:

    for( int x = 2; x <= 100; x += 2 )
    {
      cout << x << endl;
    }
    
  4. 打印从 1 到 16 的数字及其平方的for循环的解决方案如下:

    for( int x = 1; x <= 16; x++ )
    {
      cout << x << " " << x*x << endl;
    }
    

使用 Unreal Engine 进行循环

在你的代码编辑器中,从第三章打开你的 Unreal Puzzle 项目,If, Else, 和 Switch

打开你的 Unreal 项目有几种方法。最简单的方法可能是导航到Unreal Projects文件夹(在 Windows 默认情况下,该文件夹位于你的用户Documents文件夹中)并在Windows 资源管理器中双击.sln文件,如下面的截图所示:

Looping with Unreal Engine

在 Windows 上,打开.sln 文件以编辑项目代码

现在,打开PuzzleBlockGrid.cpp文件。在这个文件中,向下滚动到以下语句开始的段落:

void APuzzleBlockGrid::BeginPlay()

注意,这里有一个for循环来生成最初的九个方块,如下面的代码所示:

// Loop to spawn each block
for( int32 BlockIndex=0; BlockIndex < NumBlocks; BlockIndex++ )
{
  // ...
}

由于NumBlocks(用于确定何时停止循环)被计算为Size*Size,我们可以通过改变Size变量的值来轻松地改变生成的方块数量。转到PuzzleBlockGrid.cpp的第 23 行,并将Size变量的值更改为四或五。然后再次运行代码。

你应该看到屏幕上的方块数量增加,如下面的截图所示:

Looping with Unreal Engine

将大小设置为 14 会创建更多的方块

摘要

在本章中,你学习了如何通过循环代码来重复执行代码行,这让你可以返回到它。这可以用来重复使用相同的代码行以完成一项任务。想象一下,在不使用循环的情况下打印从 1 到 10 的数字。

在下一章中,我们将探讨函数,它们是可重用代码的基本单元。

第五章。函数和宏

函数

有些事情需要重复。代码不是其中之一。函数是一组可以多次调用的代码,你可以按需多次调用。

类比是好的。让我们探讨一个涉及服务员、厨师、披萨和函数的类比。在英语中,当我们说一个人有一个函数时,我们的意思是这个人执行一些非常具体(通常,非常重要)的任务。他们可以一次又一次地做这项任务,并且每当他们被要求这样做时。

下面的漫画展示了服务员(调用者)和厨师(被调用者)之间的交互。服务员想要为他的餐桌提供食物,所以他要求厨师准备等待餐桌所需的食物。

厨师准备食物,然后将结果返回给服务员。

函数

在这里,厨师执行他的烹饪食物的功能。厨师接受了关于要烹饪什么类型食物的参数(三个辣味披萨)。然后厨师离开,做了一些工作,并带着三个披萨回来。请注意,服务员不知道也不关心厨师是如何烹饪披萨的。厨师为服务员抽象掉了烹饪披萨的过程,所以对服务员来说,烹饪披萨只是一个简单的一行命令。服务员只想完成他的请求,并得到披萨。

当一个函数(厨师)被调用并带有一些参数(要准备的披萨类型)时,该函数执行一些操作(准备披萨)并可选择返回一个结果(实际完成的披萨)。

一个 <cmath> 库函数的例子 – sqrt()

现在,让我们讨论一个更实际的例子,并将其与披萨例子联系起来。

<cmath> 库中有一个名为 sqrt() 的函数。让我快速展示它的用法,如下面的代码所示:

#include <iostream>
#include <cmath>
using namespace std;
int main()
{
  double rootOf5 = sqrt( 5 ); // function call to the sqrt  function
  cout << rootOf5  << endl;
}

因此,sqrt() 可以找到任何给定的数字的数学平方根。

你知道如何找到像 5 这样的困难数字的平方根吗?这并不简单。一个聪明的人坐下来编写了一个函数,可以找到所有类型的数字的平方根。你必须要理解 5 的平方根是如何找到的才能使用 sqrt(5) 函数调用吗?当然不需要!所以,就像服务员不需要理解如何烹饪披萨就能得到披萨作为结果一样,调用 C++库函数的人不需要完全理解该库函数内部的工作原理就能有效地使用它。

使用函数的优势如下:

  1. 函数将复杂任务抽象成一个简单可调用的例程。这使得调用者(通常是你的程序)所需的代码,例如“烹饪披萨”,只是一个单行命令。

  2. 函数避免了不必要的代码重复。假设我们有 20 或更多行代码可以找到双精度值的平方根。我们将这些代码行包装成一个可调用的函数;而不是反复复制粘贴这些 20 行代码,我们只需在需要求根时调用 sqrt 函数(带有要开方的数字)。

以下插图展示了寻找平方根的过程:

一个  库函数示例 – sqrt()

编写我们自己的函数

假设我们想要编写一些代码来打印一段道路,如下所示:

cout << "*   *" << endl;
cout << "* | *" << endl;
cout << "* | *" << endl;
cout << "*   *" << endl;

现在,假设我们想要打印两条道路,一行一行地打印,或者打印三条道路。或者,假设我们想要打印任意数量的道路。我们将不得不为每条我们试图打印的道路重复一次产生第一条道路的四个代码行。

假设我们引入了自己的 C++ 命令,当调用该命令时可以打印一段道路。下面是它的样子:

void printRoad()
{
  cout << "*   *" << endl;
  cout << "* | *" << endl;
  cout << "* | *" << endl;
  cout << "*   *" << endl;
}

这是函数的定义。C++ 函数具有以下结构:

编写我们自己的函数

使用函数很简单:我们只需通过名称调用我们想要执行的功能,然后跟随着两个圆括号 ()。例如,调用 printRoad() 函数将导致 printRoad() 函数运行。让我们跟踪一个示例程序来完全理解这意味着什么。

一个示例程序跟踪

下面是一个函数调用的完整示例:

#include <iostream>
using namespace std;
void printRoad()
{
  cout << "*   *" << endl;
  cout << "* | *" << endl;
  cout << "* | *" << endl;
  cout << "*   *" << endl;
}
int main()
{
  cout << "Program begin!" << endl;
  printRoad();
  cout << "Program end" << endl;
  return 0;
}

让我们从开始到结束跟踪程序的执行。记住,对于所有 C++ 程序,执行都是从 main() 的第一行开始的。

注意

main() 也是一个函数。它负责整个程序的执行。一旦 main() 执行了 return 语句,你的程序就结束了。

当达到 main() 函数的最后一行时,程序结束。

以下是对前面程序执行逐行跟踪的展示:

void printRoad()
{
  cout << "*   *" << endl;          // 3: then we jump up here
  cout << "* | *" << endl;          // 4: run this
  cout << "* | *" << endl;          // 5: and this
  cout << "*   *" << endl;          // 6: and this
}
int main()
{
  cout << "Program begin!" << endl; // 1: first line to execute
  printRoad();                      // 2: second line..
  cout << "Program end" << endl;    // 7: finally, last line
  return 0;                         // 8: and return to o/s
}

这就是这个程序的输出将看起来像这样:

Program begin!
*   *
* | *
* | *
*   *
Program end

下面是对前面代码逐行的解释:

  1. 程序的执行从 main() 的第一行开始,输出 program begin!

  2. 下一条要执行的代码是调用 printRoad()。这样做会将程序计数器跳转到 printRoad() 的第一行。然后按照顺序执行 printRoad() 的所有行(步骤 3–6)。

  3. 最后,在 printRoad() 函数调用完成后,控制权返回到 main() 语句。然后我们看到打印出 Program end

提示

不要忘记在调用 printRoad() 函数后加上括号。函数调用必须始终跟随着圆括号 (),否则函数调用将不会工作,并且你会得到编译器错误。

以下代码用于打印四条道路:

int main()
{
	printRoad();
	printRoad();
	printRoad();
	printRoad();
}

或者,你也可以使用以下代码:

for( int i = 0; i < 4; i++ )
printRoad();

因此,我们不必每次打印一个框时都重复四行cout,我们只需调用printRoad()函数来让它打印。此外,如果我们想改变打印的路的形状,我们只需简单地修改printRoad()函数的实现。

调用一个函数意味着逐行运行该函数的整个主体。函数调用完成后,程序的控制权随后恢复到函数调用的点。

练习

作为练习,找出以下代码中存在的问题:

#include <iostream>
using namespace std;
void myFunction()
{
   cout << "You called?" << endl;
}
int main()
{
   cout << "I'm going to call myFunction now." << endl;
   myFunction;
}

解决方案

这个问题的正确答案是,在main()的最后一行对myFunction的调用后面没有圆括号。所有函数调用都必须跟圆括号。main()的最后一行应该是myFunction();,而不是仅仅myFunction

带有参数的函数

我们如何扩展printRoad()函数以打印具有特定路段数的路?答案是简单的。我们可以让printRoad()函数接受一个参数,称为numSegments,以打印一定数量的路段。

以下代码片段显示了它的样子:

void printRoad(int numSegments)
{
  // use a for loop to print numSegments road segments
  for( int i = 0; i < numSegments; i++)
  {
    cout << "*   *" << endl;
    cout << "* | *" << endl;
    cout << "* | *" << endl;
    cout << "*   *" << endl;
  }
}

以下截图展示了接受一个参数的函数的解剖结构:

带有参数的函数

调用这个新的printRoad()版本,要求它打印四个路段,如下所示:

printRoad( 4 );    // function call

在前一个语句的function call括号中的 4 被分配给printRoad(int numSegments)函数的numSegments变量。这就是值 4 如何传递给numSegments的:

带有参数的函数

以下是如何将值 4 赋给numSegments变量的printRoad(4)函数的示例

因此,numSegments被分配了在printRoad()调用中括号内传递的值。

返回值的函数

返回值的函数的一个例子是sqrt()函数。sqrt()函数接受一个括号内的单个参数(要开方的数字)并返回该数字的实际根。

下面是sqrt函数的一个示例用法:

cout << sqrt( 4 ) << endl;

sqrt()函数所做的与厨师在准备披萨时所做的类似。

作为函数的调用者,你不需要关心sqrt()函数体内的内容;这个信息是不相关的,因为你想要的只是你传递的数字的平方根的结果。

让我们声明自己的简单函数,该函数返回一个值,如下面的代码所示:

int sum(int a, int b)
{
  return a + b;
}

以下截图展示了具有参数和返回值的函数的解剖结构:

返回值的函数

sum函数非常基础。它所做的只是取两个int数字ab,将它们相加,并返回一个结果。你可能会说,我们甚至不需要一个完整的函数来加两个数字。你说得对,但请稍等片刻。我们将使用这个简单的函数来解释返回值的概念。

你将这样使用 sum 函数(从 main() 中):

int sum( int a, int b )
{
  return a + b;
}
int main()
{
  cout << "The sum of 5 and 6 is " << sum( 5,6 ) << endl; 
}

为了使 cout 命令完成,必须评估 sum( 5,6 ) 函数调用。在 sum( 5,6 ) 函数调用发生的地方,sum( 5,6 ) 返回的值就被放在那里。

换句话说,这是 cout 在评估 sum( 5,6 ) 函数调用后实际看到的代码行:

cout << "The sum of 5 and 6 is " << 11 << endl;	

sum( 5,6 ) 返回的值实际上是在函数调用点剪切和粘贴的。

函数承诺返回值时,必须始终返回一个值(如果函数的返回类型不是 void)。

练习

  1. 编写一个 isPositive 函数,当传递给它的 double 类型的参数确实是正数时返回 true

  2. 完成以下函数定义:

    // function returns true when the magnitude of 'a'
    // is equal to the magnitude of 'b' (absolute value)
    bool absEqual(int a, int b){
        // to complete this exercise, try to not use
        // cmath library functions
    }
    
  3. 编写一个 getGrade() 函数,该函数接受一个整数参数(满分 100 分)并返回成绩(A、B、C、D 或 F)。

  4. 一个数学函数的形式为 f(x) = 3x + 4。请编写一个 C++ 函数,该函数返回 f(x) 的值。

解答

  1. isPositive 函数接受一个 double 类型的参数并返回一个布尔值:

    bool isPositive( double value )
    {
      return value > 0;
    }
    
  2. 以下为完成的 absEqual 函数:

    bool absEqual( int a, int b )
    {
      // Make a and b positive
    if( a < 0 )
        a = -a;
      if( b < 0 )
        b = -b;
      // now since they're both +ve,
      // we just have to compare equality of a and b together
      return a == b;
    }
    
  3. 以下代码给出了 getGrade() 函数:

    char getGrade( int grade )
    {
      if( grade >= 80 )
        return 'A';
      else if( grade >= 70 )
        return 'B';
      else if( grade >= 60 )
        return 'C';
      else if( grade >= 50 )
        return 'D';
      else
        return 'F';
    }
    
  4. 这个程序是一个简单的程序,应该能让你感到愉快。C++ 中 name 函数的起源实际上来自数学世界,如下面的代码所示:

    double f( double x )
    {
      return 3*x + 4;
    }
    

变量,重新审视

现在你对 C++ 编码有了更深入的理解,现在重新回顾你之前学过的主题总是很愉快。

全局变量

现在我们已经介绍了函数的概念,可以引入全局变量的概念。

什么是全局变量?全局变量是指任何可以被程序中所有函数访问的变量。我们如何使一个变量可以被程序中所有函数访问?我们只需在代码文件顶部声明全局变量,通常在 #include 语句之后或附近。

下面是一个包含一些全局变量的示例程序:

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

string g_string;	// global string variable,
// accessible to all functions within the program
// (because it is declared before any of the functions
// below!)

void addA(){ g_string += "A"; }
void addB(){ g_string += "B"; }
void addC(){ g_string += "C"; }

int main()
{
  addA();
  addB();
  cout << g_string << endl;
  addC();
  cout << g_string << endl;
}

在这里,相同的 g_string 全局变量可以访问程序中的所有四个函数(addA()addB()addC()main())。全局变量在程序运行期间持续存在。

小贴士

人们有时更喜欢在全局变量前加上 g_ 前缀,但给变量名加上 g_ 前缀并不是使变量成为全局变量的要求。

局部变量

局部变量是在代码块内定义的变量。局部变量在其声明块的末尾超出作用域。下一节将提供一些示例,变量的作用域

变量的作用域

变量的作用域是指变量可以被使用的代码区域。任何变量的作用域基本上是其定义的块。我们可以通过以下示例来演示变量的作用域:

int g_int; // global int, has scope until end of file
void func( int arg )
{
  int fx;
} // </fx> dies, </arg> dies

int main()
{
  int x; // variable <x> has scope starting here..
         // until the end of main()
  if( x == 0 )
  {
    int y;  // variable <y> has scope starting here,
            // until closing brace below
  } // </y> dies
  if( int x2 = x ) // variable <x2> created and set equal to <x>
  {
    // enter here if x2 was nonzero
  } // </x2> dies

for( int c = 0; c < 5; c++ ) // c is created and has
  { // scope inside the curly braces of the for loop
    cout << c << endl;
  } // </c> dies only when we exit the loop
} // </x> dies

定义变量作用域的主要因素是块。让我们讨论一下前面代码示例中定义的一些变量的作用域:

  • g_int:这是一个全局整数,其作用域从声明点开始,直到代码文件的末尾。也就是说,g_int可以在func()main()中使用,但不能在其他代码文件中使用。如果你需要一个在多个代码文件中使用的单个全局变量,你需要一个外部变量。

  • argfunc()的参数):这可以从func()的第一行(在开括号{之后)使用到func()的最后一行(直到闭括号})。

  • fx:这可以在func()内部的任何地方使用,直到func()的闭合花括号}

  • main()main()内部的变量):这可以按照注释中的标记使用。

注意,函数参数列表中的变量只能在函数声明下面的块中使用。例如,传递给func()arg变量:

void func( int arg )
{
  int fx;
} // </fx> dies, </arg> dies

arg变量将在func()函数的闭合花括号(})之后消失。这看起来有些反直觉,因为圆括号在技术上是在定义{}块的闭合花括号之外。

对于在for循环圆括号内声明的变量也是同样的情况。以下是一个for循环的例子:

for( int c = 0; c < 5; c++ )
{
  cout << c << endl;
} // c dies here

int c变量可以在for循环声明圆括号内或在其声明下面的块中使用。c变量将在其声明的for循环的闭合花括号之后消失。如果你想使c变量在for循环的括号之外继续存在,你需要在for循环之前声明c变量,如下所示:

int c;
for( c = 0; c < 5; c++ )
{
  cout << c << endl;
} // c does not die here

静态局部变量

静态局部变量与全局变量非常相似,只是它们具有局部作用域,如下面的代码所示:

void testFunc()
{
  static int runCount = 0; // this only runs ONCE, even on
  // subsequent calls to testFunc()!
  cout << "Ran this function " << ++runCount << " times" << endl;
} // runCount stops being in scope, but does not die here

int main()
{
  testFunc();  // says 1 time
  testFunc();  // says 2 times!
}

testFunc()函数内部使用static关键字,runCount变量会在testFunc()函数调用之间记住其值。因此,testFunc()的前两次单独运行输出如下:

Ran this function 1 times
Ran this function 2 times

这是因为静态变量只创建和初始化一次(在它们声明的函数第一次运行时),之后静态变量会保留其旧值。比如说,我们将runCount声明为一个常规的、局部的、非静态变量:

int runCount = 0; // if declared this way, runCount is local

然后,输出将看起来是这样的:

Ran this function 1 times
Ran this function 1 times

这里,我们看到testFunc两次都说了“运行了此函数 1 次”。作为一个局部变量,runCount的值在函数调用之间不会保留。

你不应该过度使用静态局部变量。一般来说,只有在绝对必要时才应该使用静态局部变量。

常量变量

const变量是一个你承诺编译器在第一次初始化后不会改变的值的变量。我们可以简单地声明一个,例如,用于pi的值:

const double pi = 3.14159;

由于 pi 是一个通用常数(你唯一可以依赖的保持不变的东西之一),因此在初始化后不应需要更改 pi。实际上,编译器应该禁止对 pi 的更改。例如,尝试给 pi 赋予新的值:

pi *= 2;

我们将得到以下编译器错误:

error C3892: 'pi' : you cannot assign to a variable that is const

这个错误完全合理,因为除了初始初始化之外,我们不应该能够更改 pi 的值——这是一个常量变量。

函数原型

函数原型是函数的签名,不包括函数体。例如,让我们从以下练习中原型化 isPositiveabsEqualgetGrade 函数:

bool isPositive( double value );
bool absEqual( int a, int b );
char getGrade( int grade );

注意函数原型只是函数所需的返回类型、函数名和参数列表。函数原型不包含函数体。函数体通常放在 .cpp 文件中。

.h 和 .cpp 文件

通常,将你的函数原型放在 .h 文件中,将函数体放在 .cpp 文件中。这样做的原因是你可以将你的 .h 文件包含在多个 .cpp 文件中,而不会出现多重定义错误。

以下截图为您清晰地展示了 .h.cpp 文件:

.h 和 .cpp 文件

在这个 Visual C++ 项目中,我们有三个文件:

.h 和 .cpp 文件

prototypes.h 包含

// Make sure these prototypes are
// only included in compilation ONCE
#pragma once
extern int superglobal; // extern: variable "prototype"
// function prototypes
bool isPositive( double value );
bool absEqual( int a, int b );
char getGrade( int grade );

prototypes.h 文件包含函数原型。我们将在几段中解释 extern 关键字的作用。

funcs.cpp 包含

#include "prototypes.h" // every file that uses isPositive,
// absEqual or getGrade must #include "prototypes.h"
int superglobal; // variable "implementation"
// The actual function definitions are here, in the .cpp file
bool isPositive( double value )
{
  return value > 0;
}
bool absEqual( int a, int b )
{
  // Make a and b positive
  if( a < 0 )
    a = -a;
  if( b < 0 )
    b = -b;
  // now since they're both +ve,
  // we just have to compare equality of a and b together
  return a == b;
}
char getGrade( int grade )
{
  if( grade >= 80 )
    return 'A';
  else if( grade >= 70 )
    return 'B';
  else if( grade >= 60 )
    return 'C';
  else if( grade >= 50 )
    return 'D';
  else
    return 'F';
}

main.cpp 包含

#include <iostream>
using namespace std;
#include "prototypes.h" // for use of isPositive, absEqual 
// functions
int main()
{
  cout << boolalpha << isPositive( 4 ) << endl;
  cout << absEqual( 4, -4 ) << endl;
}

当你将代码拆分为 .h.cpp 文件时,.h 文件(头文件)被称为接口,而 .cpp 文件(包含实际函数的文件)被称为实现。

对于一些程序员来说,最初令人困惑的部分是,如果我们只包含原型,C++ 如何知道 isPositivegetGrade 函数体的位置?我们不应该也将 funcs.cpp 文件包含到 main.cpp 中吗?

答案是“魔法”。你只需要在 main.cppfuncs.cpp 中包含 prototypes.h 头文件。只要两个 .cpp 文件都包含在你的 C++ 集成开发环境IDE)项目中(即它们出现在左侧的 解决方案资源管理器树视图中),编译器会自动完成原型到函数体的链接。

外部变量

extern 声明与函数原型类似,但它用于变量。你可以在 .h 文件中放置一个 extern 全局变量声明,并将此 .h 文件包含在许多其他文件中。这样,你可以有一个在多个源文件之间共享的单个全局变量,而不会出现链接器错误中找到的多重定义符号。你将在 .cpp 文件中放置实际的变量声明,这样变量就只声明一次。在上一个示例中,prototypes.h 文件中有一个 extern 变量。

C++ 宏属于一类称为预处理器指令的 C++ 命令。预处理器指令是在编译之前执行的。

宏以 #define 开头。例如,假设我们有以下宏:

#define PI 3.14159

在最低级别上,宏仅仅是编译前发生的复制粘贴操作。在先前的宏语句中,字面量 3.14159 将被复制并粘贴到程序中 PI 符号出现的所有地方。

以以下代码为例:

#include <iostream>
using namespace std;
#define PI 3.14159
int main()
{
  double r = 4;
  cout << "Circumference is " << 2*PI*r << endl;
}

C++ 预处理器将首先遍历代码,寻找对 PI 符号的任何使用。它会在这一行找到这样一个用法:

cout << "Circumference is " << 2*PI*r << endl;

在编译之前,前面的行将转换为以下内容:

cout << "Circumference is " << 2*3.14159*r << endl;

因此,#define 语句所发生的一切就是,在编译发生之前,所有使用的符号(例如,PI)的出现都将被字面数字 3.14159 替换。使用宏的这种方式的目的是避免将数字硬编码到代码中。符号通常比大而长的数字更容易阅读。

建议——尽可能使用 const 变量

你可以使用宏来定义常量变量。你也可以使用 const 变量表达式。所以,假设我们有以下一行代码:

#define PI 3.14159

我们将鼓励使用以下内容代替:

const double PI = 3.14159;

使用 const 变量将被鼓励,因为它将你的值存储在一个实际的变量中。变量是有类型的,有类型的数据是好事。

带参数的宏

我们也可以编写接受参数的宏。以下是一个带有参数的宏的示例:

#define println(X) cout << X << endl;

这个宏将做的是,每当在代码中遇到 println("Some value") 时,右侧的代码(cout << "Some value" << endl)将被复制并粘贴到控制台上。注意括号中的参数是如何被复制到 X 的位置的。假设我们有以下一行代码:

println( "Hello there" )

这将被替换为以下语句:

cout << "Hello there" << endl;

带参数的宏与非常短的功能完全一样。宏不能包含任何换行符。

建议——使用内联函数而不是带参数的宏

你必须了解关于带参数的宏的工作方式,因为你在 C++ 代码中会遇到很多。然而,尽可能的情况下,许多 C++ 程序员更喜欢使用内联函数而不是带参数的宏。

一个正常的函数调用执行涉及一个跳转到函数的指令,然后执行函数。内联函数是指其代码行被复制到函数调用点,并且不会发出跳转指令的函数。通常,使用内联函数对于非常小、简单的函数来说是有意义的,这些函数没有很多代码行。例如,我们可能会内联一个简单的函数 max,该函数找出两个值中的较大值:

inline int max( int a, int b )
{
  if( a > b ) return a;
  else return b;
}

在这个max函数被使用的任何地方,函数体的代码都会在函数调用的位置被复制和粘贴。不需要跳转到函数中可以节省执行时间,使得内联函数在效果上类似于宏。

使用内联函数有一个陷阱。内联函数必须将其主体完全包含在.h头文件中。这样编译器才能进行优化,并在使用函数的任何地方实际内联该函数。通常将函数内联是为了速度(因为你不需要跳转到代码的另一个部分来执行函数),但代价是代码膨胀。

以下是一些为什么内联函数比宏更受欢迎的原因:

  1. 宏容易出错:宏的参数没有类型。

  2. 宏必须写在一行中,否则你会看到它们使用转义字符

    \
    newline characters \
    like this \
    which is hard to read
    
  3. 如果宏没有仔细编写,会导致难以修复的编译器错误。例如,如果你没有正确地括号化你的参数,你的代码就会出错。

  4. 大型宏很难调试。

应该指出的是,宏确实允许你执行一些预处理器编译器的魔法。UE4 大量使用了带参数的宏,你稍后会看到。

总结

函数调用允许你重用基本代码。代码重用对于许多原因来说都很重要:主要是因为编程很困难,应该尽可能避免重复工作。编写sqrt()函数的程序员所付出的努力不需要被其他想要解决相同问题的程序员重复。

第六章:对象、类和继承

在上一章中,我们讨论了函数作为一种将相关代码行捆绑在一起的方式。我们讨论了函数如何抽象实现细节,以及 sqrt() 函数不需要你了解其内部工作原理即可使用它来找到根。这是一件好事,主要是因为它节省了程序员的精力和时间,同时使寻找平方根的实际工作变得更容易。当我们讨论对象时,这个 抽象 原理将再次出现。

简而言之,对象将方法和相关数据绑定到一个单一的结构中。这个结构被称为 。使用对象的主要思想是为游戏中的每个事物创建一个代码表示。代码中表示的每个对象都将有数据和相关的函数来操作这些数据。因此,你会有一个 对象 来表示玩家实例以及相关的函数,如 jump()shoot()pickupItem()。你也会有一个对象来表示每个怪物实例以及相关的函数,如 growl()attack() 和可能还有 follow()

然而,对象是变量的一种类型,并且只要你在那里保持它们,对象就会留在内存中。当你创建代表游戏中的事物的对象实例时,你将创建一个对象实例,当你代表游戏中的事物死亡时,你将销毁对象实例。

对象可以用来表示游戏中的事物,但它们也可以用来表示任何其他类型的事物。例如,你可以将一个图像存储为对象。数据字段将包括图像的宽度、高度以及其中的像素集合。C++ 字符串也是对象。

小贴士

本章包含许多可能一开始难以理解的关键词,包括 virtualabstract

不要让本章更难的部分让你感到困惑。我为了完整性而包括了众多高级概念的描述。然而,请记住,你不需要完全理解本章中的所有内容就能在 UE4 中编写有效的 C++ 代码。理解它是有帮助的,但如果某些内容让你感到困惑,不要陷入困境。阅读它,然后继续前进。可能的情况是,你一开始可能不会理解,但记住在编码时对相关概念的参考。然后,当你再次打开这本书时,“哇!”它就会变得有意义。

结构体对象

在 C++ 中,对象基本上是由更简单的类型组成的复合类型。C++ 中最基本的对象是 struct。我们使用 struct 关键字将许多较小的变量粘合在一起形成一个大的变量。如果你还记得,我们在 第二章 中简要介绍了 struct变量和内存。让我们回顾一下这个简单的例子:

struct Player
{
  string name;
  int hp;
};

这是Player对象的结构定义。玩家有一个string类型的name和一个表示hp值的整数。

如果你还记得第二章中的内容,即变量和内存,我们创建Player对象实例的方式是这样的:

Player me;    // create an instance of Player, called me

从这里,我们可以这样访问me对象的字段:

me.name = "Tom";
me.hp = 100;

成员函数

现在,这里是激动人心的部分。我们可以通过在struct Player定义内部编写这些函数来将成员函数附加到struct定义上。

struct Player
{
  string name;
  int hp;
  // A member function that reduces player hp by some amount
  void damage( int amount )	
  {
    hp -= amount;
  }
  void recover( int amount )
  {
    hp += amount;
}
};

成员函数就是一个在structclass定义内部声明的 C++函数。这不是一个好主意吗?

这里有一个有点奇怪的想法,所以我就直接说出来吧。struct Player的变量对所有struct Player内部的函数都是可访问的。在struct Player的每个成员函数内部,我们实际上可以像访问局部变量一样访问namehp变量。换句话说,struct Playernamehp变量在struct Player的所有成员函数之间是共享的。

this关键字

在一些 C++代码(在后面的章节中),你会看到更多对this关键字的引用。this关键字是一个指向当前对象的指针。例如,在Player::damage()函数内部,我们可以明确写出对this的引用:

void damage( int amount )
{
  this->hp -= amount;
}

this关键字仅在成员函数内部有意义。我们可以在成员函数中明确包含this关键字的用法,但如果没有写this,则默认我们谈论的是当前对象的hp

字符串是对象吗?

是的!每次你使用字符串变量时,你都是在使用一个对象。让我们尝试一下string类的成员函数。

#include <iostream>
#include <string>
using namespace std;
int main()
{
  string s = "strings are objects";
  s.append( "!!" ); // add on "!!" to end of the string!
  cout << s << endl;
}

我们在这里所做的是使用append()成员函数在字符串的末尾添加两个额外的字符(!!)。成员函数始终应用于调用成员函数的对象(点左侧的对象)。

小贴士

要查看对象上可用的成员和成员函数列表,请在 Visual Studio 中输入对象的变量名,然后输入一个点(.),然后按Ctrl和空格键。成员列表将弹出。

字符串是对象吗?

按下Ctrl和空格键将显示成员列表。

调用成员函数

成员函数可以使用以下语法调用:

objectName.memberFunction();

调用成员函数的对象位于点的左侧。要调用的成员函数位于点的右侧。成员函数调用始终后跟圆括号(),即使没有传递参数给括号。

因此,在程序中怪物攻击的部分,我们可以这样减少玩家的hp值:

player.damage( 15 );  // player takes 15 damage

这不是比以下内容更易读吗?

player.hp -= 15;      // player takes 15 damage

小贴士

当成员函数和对象被有效使用时,你的代码将读起来更像散文或诗歌,而不是一堆操作符的组合。

除了美观和可读性之外,编写成员函数的目的是什么?现在,在Player对象外部,我们只用一行代码就可以做更多的事情,而不仅仅是减少hp成员15。我们还可以在减少玩家的hp时做其他事情,比如考虑玩家的护甲,检查玩家是否无敌,或者当玩家受伤时产生其他效果。当玩家受伤时应该由damage()函数来抽象处理。

现在想想如果玩家有一个护甲等级。让我们给struct Player添加一个护甲等级字段:

struct Player
{
  string name;
  int hp;
  int armorClass;
};

我们需要通过玩家的护甲等级来减少玩家收到的伤害。所以现在我们可以输入一个公式来减少hp。我们可以通过直接访问player对象的数据字段来实现非面向对象的方式:

player.hp -= 15 – player.armorClass; // non OOP

否则,我们可以通过编写一个成员函数来改变player对象的数据成员,以实现面向对象的方式。在Player对象内部,我们可以编写一个成员函数damage()

struct Player
{
  string name;
  int hp;
  int armorClass; 
void damage( int dmgAmount )	
  {
    hp -= dmgAmount - armorClass;
  }
};

练习

  1. 在前面的代码中,玩家的damage函数中有一个微小的错误。你能找到并修复它吗?提示:如果造成的伤害小于玩家的armorClass,会发生什么?

  2. 只有一个护甲等级的数字并不能提供足够的关于护甲的信息!护甲的名字是什么?它看起来像什么?为玩家的护甲设计一个struct函数,包含名称、护甲等级和耐久性评分字段。

解决方案

解决方案在下一节中列出的struct玩家代码中,私有和封装

使用以下代码如何?

struct Armor
{
  string name;
  int armorClass;
  double durability;
};

struct Player中将会放置一个Armor实例:

struct Player
{
  string name;
  int hp;
  Armor armor; // Player has-an Armor
};

这意味着玩家有护甲。请记住这一点——我们将在以后探讨has-ais-a关系。

私有和封装

因此,我们现在已经定义了一些成员函数,其目的是修改和维护我们的Player对象的数据成员,但有些人提出了一个论点。

论点如下:

  • 一个对象的数据成员应该只通过其成员函数访问,永远不要直接访问。

这意味着你不应该直接从对象外部访问对象的数据成员,换句话说,直接修改玩家的hp

player.hp -= 15 – player.armorClass; // bad: direct member access

这应该被禁止,并且应该强制类用户使用适当的成员函数来更改数据成员的值:

player.damage( 15 );	// right: access thru member function

这个原则被称为封装。封装的概念是每个对象都应该只通过其成员函数进行交互。封装表示原始数据成员不应直接访问。

封装背后的原因如下:

  • 为了使类自包含:封装背后的主要思想是,当对象以这种方式编程时,它们工作得最好,即它们可以管理和维护自己的内部状态变量,而无需类外部的代码检查该类的私有数据。当对象以这种方式编码时,会使对象更容易处理,也就是说,更容易阅读和维护。要使玩家对象跳跃,你只需调用player.jump();让玩家对象管理其y-height位置的状态变化(使玩家跳跃!)当对象的内部成员没有暴露时,与该对象的交互会更加容易和高效。仅与对象的公共成员函数交互;让对象管理其内部状态(我们将在稍后解释privatepublic关键字)。

  • 为了避免破坏代码:当类外部的代码仅与该类的公共成员函数交互(类的公共接口)时,对象的内部状态管理可以自由更改,而不会破坏任何调用代码。这样,如果对象的内部数据成员因任何原因而更改,只要成员函数保持不变,所有使用该对象的代码仍然有效。

那么,我们如何防止程序员犯错误并直接访问数据成员呢?C++引入了访问修饰符的概念,以防止访问对象的内部数据。

下面是如何使用访问修饰符来禁止从struct Player外部访问struct Player的某些部分。

你首先需要决定你希望从类外部访问的struct定义的哪些部分。这些部分将被标记为public。所有其他将不会从struct外部访问的区域将被标记为private,如下所示:

struct Player
{
private:        // begins private section.. cannot be accessed 
                // outside the class until
  string name;
  int hp; 
  int armorClass;
public:         //  until HERE. This begins the public section
  // This member function is accessible outside the struct
  // because it is in the section marked public:
  void damage( int amount )
  {
    int reduction = amount – armorClass;
    if( reduction < 0 ) // make sure non-negative!
      reduction = 0;
    hp -= reduction;
  }
};

有些人喜欢将其设置为public

有些人会毫不犹豫地使用public数据成员,并且不封装他们的对象。尽管这被视为不良的面向对象编程实践,但这仍然是一个个人喜好问题。

然而,UE4 中的类有时确实会使用public成员。这是一个判断问题;数据成员应该是public还是private完全取决于程序员。

随着经验的积累,你会发现,当你将应该为private的数据成员设置为public时,有时你会陷入需要大量重构的情况。

类与结构体

你可能已经看到了另一种声明对象的方法,使用class关键字而不是struct,如下面的代码所示:

class Player // we used class here instead of struct!
{
  string name;
  //
};

C++中的classstruct关键字几乎相同。classstruct之间只有一个区别,那就是在struct关键字内部的数据成员默认会被声明为public,而在class关键字中,类内部的数据成员默认会被声明为private。(这就是为什么我使用struct来引入对象;我不想在class的第一行无解释地放置public。)

通常,对于简单类型,不使用封装,没有很多成员函数,并且必须与 C 语言向后兼容的情况,我们更倾向于使用struct。类几乎在其他所有地方都被使用。

从现在起,让我们使用class关键字而不是struct

获取器和设置器

你可能已经注意到,一旦我们将private添加到Player类定义中,我们就不能再从Player类外部读取或写入玩家的名称。

如果我们尝试使用以下代码读取名称:

Player me;
cout << me.name << endl;

或者按照以下方式写入名称:

me.name = "William";

使用带有private成员的struct Player定义,我们将得到以下错误:

main.cpp(24) : error C2248: 'Player::name' : cannot access private member declared in class 'Player'

这正是我们当我们将name字段标记为private时所期望的。我们使其在Player类外部完全不可访问。

获取器

获取器(也称为访问器函数)用于将内部数据成员的副本传递给调用者。为了读取玩家的名称,我们需要在Player类中添加一个特定的成员函数来检索该private数据成员的副本:

class Player
{
private:
  string name;  // inaccessible outside this class!
                //  rest of class as before
public:
  // A getter function retrieves a copy of a variable for you
  string getName()
{
  return name;
}
};

因此,现在可以读取玩家的名称信息。我们可以通过以下代码语句来实现:

cout << player.getName() << endl;

获取器用于检索那些从类外部无法访问的private成员。

提示

实际技巧–const 关键字

在类内部,你可以在成员函数声明中添加const关键字。const关键字的作用是向编译器承诺,运行此函数后对象的内部状态不会改变。添加const关键字的样子如下:

string getName() const
{
  return name;
}

在标记为const的成员函数内部不能对数据成员进行赋值。由于对象的内部状态在运行const函数后保证不会改变,编译器可以在函数调用const成员函数时进行一些优化。

设置器

设置器(也称为修改器函数或突变函数)是一个成员函数,其唯一目的是更改类内部内部变量的值,如下面的代码所示:

class Player
{
private:
  string name;  // inaccessible outside this class!
                //  rest of class as before
public:
  // A getter function retrieves a copy of a variable for you
  string getName()
{
  return name;
}
void setName( string newName )
{
  name = newName;
}
};

因此,我们仍然可以通过设置器函数从类外部更改classprivate函数,但只能通过这种方式。

但获取/设置操作的意义何在?

所以,当新手程序员第一次遇到对 private 成员进行获取/设置操作时,他们首先想到的问题是不是获取/设置自我矛盾?我的意思是,当我们打算以另一种方式再次暴露相同的数据时,隐藏对数据成员的访问有什么意义?这就像说,“你不能有任何巧克力,因为它们是私有的,除非你说请 getMeTheChocolate()。然后,你就可以有巧克力了。”

一些经验丰富的程序员甚至将获取/设置函数缩短为一行,如下所示:

string getName(){ return name; }
void setName( string newName ){ name = newName; }

让我们来回答这个问题。一个获取/设置对不是通过完全暴露数据来破坏封装性吗?

答案有两个方面。首先,获取成员函数通常只返回被访问的数据成员的副本。这意味着原始数据成员的值仍然受到保护,并且不能通过 get() 操作进行修改。

Set()(变更器方法)操作有点反直觉。如果设置器是一个 passthru 操作,例如 void setName( string newName ) { name=newName; },那么拥有设置器可能看起来没有意义。使用变更器方法而不是直接覆盖变量有什么优势?

使用变更器方法的论点是,在变量赋值之前编写额外的代码来保护变量免受错误值的侵害。比如说,我们有一个 hp 数据成员的设置器,它看起来可能像这样:

void setHp( int newHp )
{
  // guard the hp variable from taking on negative values
  if( newHp < 0 )
  {
    cout << "Error, player hp cannot be less than 0" << endl;
    newHp = 0;
  }
  hp = newHp;
}

变更器方法旨在防止内部 hp 数据成员取负值。你可能认为变更器方法有点事后诸葛亮。责任应该由调用代码在调用 setHp( -2 ) 之前检查它设置的值,而不是只在变更器方法中捕获这个问题吗?你不能使用一个 public 成员变量,并将确保变量不取无效值的责任放在调用代码中,而不是在设置器中吗?你可以。

然而,这正是使用变更器方法的核心原因。变更器方法背后的想法是,调用代码可以将任何它想要的值传递给 setHp 函数(例如,setHp( -2 )),而不必担心它传递给函数的值是否有效。然后 setHp 函数负责确保该值对 hp 变量有效。

一些程序员认为直接变更函数,如 getHp()/setHp(),是一种代码恶臭。一般来说,代码恶臭是一种不良的编程实践,人们并没有明显注意到,除了有一种感觉,觉得某些事情做得不够优化。他们认为可以编写高级成员函数来代替变更器。例如,我们不应该有 setHp() 成员函数,而应该有 public 成员函数,如 heal()damage()。关于这个话题的文章可在 c2.com/cgi/wiki?AccessorsAreEvil 查阅。

构造函数和析构函数

在你的 C++代码中,构造函数是一个简单的函数,当 C++对象首次创建时运行一次。析构函数在 C++对象被销毁时运行一次。比如说我们有以下程序:

#include <iostream>
#include <string>
using namespace std;
class Player
{
private:
  string name;  // inaccessible outside this class!
public:
  string getName(){ return name; }
// The constructor!
  Player()
  {
    cout << "Player object constructed" << endl;
    name = "Diplo";
  }
  // ~Destructor (~ is not a typo!)
  ~Player()
  {
    cout << "Player object destroyed" << endl;
  }
};

int main()
  {
    Player player;
    cout << "Player named '" << player.getName() << "'" << endl;
  }
  // player object destroyed here

因此,我们在这里创建了一个Player对象。这段代码的输出将如下所示:

Player object constructed
Player named 'Diplo'
Player object destroyed

对象构造过程中发生的第一件事是构造函数实际上运行了。这会打印出Player object constructed这一行。随后,会打印出带有玩家名字的行:Player named 'Diplo'。为什么玩家名字叫Diplo?因为这是在Player()构造函数中分配的名字。

最后,在程序结束时,调用玩家析构函数,我们看到Player object destroyed。当玩家对象在main()函数的末尾(在main}处)超出作用域时,玩家对象被销毁。

那么,构造函数和析构函数有什么好处?它们看起来就是用来:设置和销毁对象。构造函数可以用来初始化数据字段,析构函数可以用来删除任何动态分配的资源(我们还没有涉及动态分配的资源,所以现在不用担心这个最后一点)。

类继承

当你想基于某个现有的代码类创建一个新的、功能更强大的代码类时,你会使用继承。继承是一个复杂的话题。让我们从派生类(或子类)的概念开始。

派生类

考虑继承最自然的方式是通过类比动物王国。以下截图显示了生物的分类:

派生类

这个图表示的意思是都是哺乳动物。这意味着狗、猫、马和人都有一些共同的特征,例如有共同的器官(大脑有新皮层、肺、肝脏和女性的子宫),但在其他方面完全不同。它们走路的方式不同。它们说话的方式也不同。

如果你正在编写生物的代码,你只需要编写一次共同的功能。然后,你会为狗、猫、马和人这些类分别实现不同部分的代码。

上述图示的一个具体例子如下:

#include <iostream>
using namespace std;
class Mammal
{
protected:
  // protected variables are like privates: they are
  // accessible in this class but not outside the class.
  // the difference between protected and private is
  // protected means accessible in derived subclasses also
int hp;
  double speed;

public:
  // Mammal constructor – runs FIRST before derived class ctors!
Mammal()
{
  hp = 100;
  speed = 1.0;
  cout << "A mammal is created!" << endl;
}
~Mammal()
{
  cout << "A mammal has fallen!" << endl;
}
// Common function to all Mammals and derivatives
  void breathe()
  {
    cout << "Breathe in.. breathe out" << endl;
  }
  virtual void talk()
  {
    cout << "Mammal talk.. override this function!" << endl;
  }
  // pure virtual function, (explained below)
  virtual void walk() = 0;
};

// This next line says "class Dog inherits from class Mammal"
class Dog : public Mammal // : is used for inheritance
{
public:
  Dog()
  {
cout << "A dog is born!" << endl;
}
~Dog()
{
  cout << "The dog died" << endl;
}
  virtual void talk() override
  {
    cout << "Woof!" << endl; // dogs only say woof!
  }
  // implements walking for a dog
  virtual void walk() override
  {
    cout << "Left front paw & back right paw, right front paw &  back left paw.. at the speed of " << speed << endl;
  }
};

class Cat : public Mammal
{
public:
  Cat()
  {
    cout << "A cat is born" << endl;
  }
  ~Cat()
  {
    cout << "The cat has died" << endl;
  }
virtual void talk() override
  {
    cout << "Meow!" << endl;
  }
// implements walking for a cat.. same as dog!
  virtual void walk() override
  {
    cout << "Left front paw & back right paw, right front paw &  back left paw.. at the speed of " << speed << endl;
  }
};

class Human : public Mammal
{
// Data member unique to Human (not found in other Mammals)
  bool civilized;
public:
  Human()
  {
    cout << "A new human is born" << endl;
    speed = 2.0; // change speed. Since derived class ctor
    // (ctor is short for constructor!) runs after base 
    // class ctor, initialization sticks initialize member 
    // variables specific to this class
    civilized = true;
  }
  ~Human()
  {
    cout << "The human has died" << endl;
  }
  virtual void talk() override
  {
    cout << "I'm good looking for a .. human" << endl;
  }
// implements walking for a human..
  virtual void walk() override
  {
    cout << "Left, right, left, right at the speed of " << speed  << endl;
  }
  // member function unique to human derivative
  void attack( Human & other )
  {
    // Human refuses to attack if civilized
    if( civilized )
      cout << "Why would a human attack another? Je refuse" <<  endl;
    else
      cout << "A human attacks another!" << endl;
  }
};

int main()
{
  Human human;
  human.breathe(); // breathe using Mammal base class  functionality
  human.talk();
  human.walk();

  Cat cat;
  cat.breathe(); // breathe using Mammal base class functionality
  cat.talk();
  cat.walk();

  Dog dog;
  dog.breathe();
  dog.talk();
  dog.walk();
}

所有的DogCatHuman都从class Mammal继承。这意味着狗、猫和人是哺乳动物,还有更多。

继承的语法

继承的语法相当简单。让我们以Human类定义为例。以下截图是一个典型的继承语句:

继承的语法

冒号(:)左边的类是新派生类,冒号右边的类是基类。

继承有什么作用?

继承的目的是让派生类承担基类的所有特性(数据成员、成员函数),然后在此基础上扩展更多的功能。例如,所有哺乳动物都有一个breathe()函数。通过从Mammal类继承,DogCatHuman类都自动获得了breathe()的能力。

继承减少了代码的重复,因为我们不需要为DogCatHuman重新实现常见功能(如.breathe())。相反,这些派生类都享受了在class Mammal中定义的breathe()函数的重用。

然而,只有Human类有attack()成员函数。这意味着在我们的代码中,只有Human类会攻击。除非你在class Cat(或class Mammal)内部编写一个成员函数attack(),否则cat.attack()函数将引入编译器错误。

is-a 关系

继承通常被说成是is-a关系。当一个Human类从Mammal类继承时,我们说人类哺乳动物。

is-a 关系

人类继承了哺乳动物的所有特征

例如,一个Human对象在其内部包含一个Mammal函数,如下所示:

class Human
{
  Mammal mammal;
};

在这个例子中,我们可以说人类有一个Mammal在其某个地方(如果人类怀孕或以某种方式携带哺乳动物,这将是合理的)。

is-a 关系

这个人类类实例中附有一种哺乳动物

记住我们之前在Player内部给了它一个Armor对象。对于Player对象来说,从Armor类继承是没有意义的,因为这说不通玩家是装甲。在代码设计中决定一个类是否从另一个类继承时(例如,Human 类从 Mammal 类继承),你必须始终能够舒适地说出类似于 Human 类Mammal 的话。如果这个陈述听起来不对,那么很可能是继承对于这对对象的关系是错误的。

在前面的例子中,我们在这里引入了一些新的 C++关键字。第一个是protected

protected 变量

一个protected成员变量与一个publicprivate变量不同。所有这三个类别的变量都可以在定义它们的类内部访问。它们之间的区别在于对类外部的可访问性。一个public变量可以在类内部和类外部访问。一个private变量可以在类内部访问,但不能在类外部访问。一个protected变量可以在类内部和派生子类内部访问,但不能在类外部访问。因此,class Mammal中的hpspeed成员将在派生类 Dog、Cat、Horse 和 Human 中可访问,但不在这些类外部(例如在main()中)。

虚函数

虚函数是一个成员函数,其实现可以在派生类中重写。在这个例子中,talk() 成员函数(在 class Mammal 中定义)被标记为 virtual。这意味着派生类可能会也可能不会选择实现自己的 talk() 函数版本。

纯虚函数(以及抽象类)

纯虚函数是要求在派生类中重写的函数。class Mammal 中的 walk() 函数是纯虚的;它被声明如下:

virtual void walk() = 0;

之前代码末尾的 = 0 部分使得函数成为纯虚函数。

class Mammal 中的 walk() 函数是纯虚函数,这使得 Mammal 类成为抽象类。在 C++ 中,抽象类是至少有一个纯虚函数的任何类。

如果一个类包含一个纯虚函数并且是抽象的,那么这个类不能直接实例化。也就是说,你现在不能创建一个 Mammal 对象,因为 walk() 是一个纯虚函数。如果你尝试执行以下代码,你会得到一个错误:

int main()
{
  Mammal mammal;
}

如果你尝试创建一个 Mammal 对象,你会得到以下错误:

error C2259: 'Mammal' : cannot instantiate abstract class

然而,你可以创建 class Mammal 的派生类的实例,只要这些派生类实现了所有的纯虚成员函数。

多重继承

并非所有听起来很好的多重继承都是如此。多重继承是指一个派生类从多个基类继承。通常,如果我们继承的多个基类完全无关,这个过程会顺利无误。

例如,我们可以有一个 Window 类,它从 SoundManagerGraphicsManager 基类继承。如果 SoundManager 提供一个成员函数 playSound(),而 GraphicsManager 提供一个成员函数 drawSprite(),那么 Window 类将能够无障碍地使用这些额外的功能。

多重继承

游戏窗口从声音管理和图形管理器继承意味着游戏窗口将拥有这两组功能

然而,多重继承可能会有负面影响。比如说,我们想要创建一个从 DonkeyHorse 类派生的 Mule 类。然而,DonkeyHorse 类都从基类 Mammal 继承。我们立刻遇到了问题!如果我们调用 mule.talk(),但 mule 没有重写 talk() 函数,应该调用哪个成员函数,Horse 的还是 Donkey 的?这是模糊的。

私有继承

C++中较少讨论的特性是 私有 继承。每当一个类以公有方式从另一个类继承时,它对其所属的父类中的所有代码都是已知的。例如:

class Cat : public Mammal

这意味着所有代码都将知道 CatMammal 的一个对象,并且可以使用基类 Mammal* 指针指向 Cat* 实例。例如,以下代码将是有效的:

Cat cat;
Mammal* mammalPtr = &cat; // Point to the Cat as if it were a 
                          // Mammal

如果CatMammal公开继承,前面的代码是好的。私有继承是外部Cat类不允许知道父类的地方:

class Cat : private Mammal

在这里,外部调用代码不会“知道”Cat类是从Mammal类派生的。当继承为private时,编译器不允许将Cat实例强制转换为Mammal基类。当你需要隐藏某个类从某个父类派生的事实时,请使用private继承。

然而,在实际应用中,私有继承很少使用。大多数类只是使用 public 继承。如果你想了解更多关于私有继承的信息,请参阅stackoverflow.com/questions/406081/why-should-i-avoid-multiple-inheritance-in-c

将你的类放入头文件中

到目前为止,我们的类只是粘贴在 main() 之前。如果你继续这样编程,你的代码将全部在一个文件中,看起来像一大团混乱。

因此,将你的类组织到单独的文件中是一种良好的编程实践。当项目中有多个类时,这使得单独编辑每个类的代码变得容易得多。

从之前的 class Mammal 和其派生类开始。我们将把这个例子适当地组织到单独的文件中。让我们分步骤来做:

  1. 在你的 C++ 项目中创建一个名为 Mammal.h 的新文件。将整个 Mammal 类复制并粘贴到该文件中。注意,由于 Mammal 类使用了 cout,我们也在该文件中写了一个 #include <iostream> 语句。

  2. 在你的 Source.cpp 文件顶部写一个 " #include Mammal.h" 语句。

如下截图所示,这是它的一个示例:

将你的类放入头文件中

当代码编译时,这里发生的情况是将整个 Mammal 类复制并粘贴(#include)到包含 main() 函数的 Source.cpp 文件中,其余的类都从 Mammal 派生。由于 #include 是一个复制粘贴函数,代码将完全按照之前的方式运行;唯一的区别是它将组织得更好,更容易查看。在这一步编译和运行你的代码,以确保它仍然可以工作。

小贴士

确保你的代码经常编译和运行,尤其是在重构时。当你不知道规则时,你肯定会犯很多错误。这就是为什么你应该只在小步骤中重构。重构是我们现在正在进行的活动的名称——我们正在重新组织源代码,使其对我们代码库的其他读者来说更有意义。重构通常不涉及太多重写。

下一步你需要做的是将 Dog、Cat 和 Human 类隔离到它们自己的文件中。为此,创建 Dog.hCat.hHuman.h 文件并将它们添加到你的项目中。

让我们从 Dog 类开始,如下截图所示:

将你的类放入标题

如果你使用这个设置并尝试编译和运行你的项目,你将看到如下所示的 'Mammal' : 'class' 类型重新定义 错误,如下面的截图所示:

将你的类放入标题

这个错误意味着 Mammal.h 在你的项目中已被包含两次,一次在 Source.cpp 中,然后又在 Dog.h 中。这意味着实际上有两个版本的 Mammal 类被添加到编译代码中,C++ 不确定使用哪个版本。

有几种方法可以解决这个问题,但最简单(也是虚幻引擎使用的方法)是使用 #pragma once 宏,如下面的截图所示:

将你的类放入标题

我们在每个头文件顶部写上 #pragma once。这样,当第二次包含 Mammal.h 时,编译器不会再次复制粘贴其内容,因为它之前已经包含过了,其内容实际上已经在编译文件组中。

Cat.hHuman.h 也做同样的事情,然后将它们都包含到你的 Source.cpp 文件中,你的 main() 函数就在那里。

将你的类放入标题

包含所有类的图

现在我们已经将所有类包含到你的项目中,代码应该可以编译并运行。

.h 和 .cpp

下一个组织级别是将类声明留在头文件(.h)中,并将实际函数实现体放入一些新的 .cpp 文件中。同时,将现有成员留在 class Mammal 声明中。

对于每个类,执行以下操作:

  1. 删除所有函数体({} 之间的代码)并替换为仅有一个分号。对于 Mammal 类,它看起来如下所示:

    // Mammal.h
    #pragma once
    class Mammal
    {
    protected:
      int hp;
      double speed;
    
    public:
      Mammal();
      ~Mammal();
      void breathe();
      virtual void talk();
      // pure virtual function, 
      virtual void walk() = 0;
    };
    
  2. 创建一个名为 Mammal.cpp 的新 .cpp 文件。然后只需将成员函数体放入此文件中:

    // Mammal.cpp
    #include <iostream>
    using namespace std;
    
    #include "Mammal.h"
    Mammal::Mammal() // Notice use of :: (scope resolution operator)
    {
      hp = 100;
      speed = 1.0;
      cout << "A mammal is created!" << endl;
    }
    Mammal::~Mammal()
    {
      cout << "A mammal has fallen!" << endl;
    }
    void Mammal::breathe()
    {
      cout << "Breathe in.. breathe out" << endl;
    }
    void Mammal::talk()
    {
      cout << "Mammal talk.. override this function!" << endl;
    }
    

重要的是要注意在声明成员函数体时使用类名和作用域解析运算符(双冒号)。我们用 Mammal:: 前缀所有属于 Mammal 类的成员函数。

注意,纯虚函数没有函数体;它不应该有!纯虚函数只是在基类中声明(并初始化为 0),然后在派生类中实现。

练习

将上述不同的生物类完全分离成类头文件 (.h) 和类定义文件 (.cpp)

摘要

你在 C++ 中学习了关于对象的知识;它们是将数据成员和成员函数结合在一起形成代码包的代码片段,称为 classstruct。面向对象编程意味着你的代码将充满各种事物,而不仅仅是 intfloatchar 变量。你将有一个代表 Barrel 的变量,另一个代表 Player 的变量,等等,也就是说,一个代表你游戏中每个实体的变量。你将通过使用继承来重用代码;如果你不得不编写 CatDog 的实现,你可以在基类 Mammal 中编写共同的功能。我们还讨论了封装以及如何编程对象以便它们保持自己的内部状态,这样做既容易又高效。

第七章。动态内存分配

在上一章中,我们讨论了类定义以及如何设计自己的自定义类。我们讨论了通过设计自己的自定义类,我们可以构建代表游戏或程序中实体的变量。

在本章中,我们将讨论动态内存分配以及如何在内存中为对象组创建空间。

假设我们有一个简化的 class Player 版本,如之前所述,只有一个构造函数和一个析构函数:

class Player
{
  string name;
  int hp;
public:
  Player(){ cout << "Player born" << endl; }
  ~Player(){ cout << "Player died" << endl; }
};

我们之前讨论过 C++ 中变量的 作用域;为了回顾,变量的作用域是该变量可以被使用的程序部分。变量的作用域通常在其声明的代码块内部。代码块只是任何位于 { 和 } 之间的代码部分。以下是一个示例程序,说明了变量作用域:

动态内存分配

在这个示例程序中,x 变量的作用域贯穿整个 main() 函数。y 变量的作用域仅限于 if 块内部

我们之前提到,通常变量在超出作用域时被销毁。让我们通过 class Player 的实例来测试这个想法:

int main()
{
  Player player; // "Player born"
}                // "Player died" - player object destroyed here

该程序的输出如下:

Player born
Player died

玩家对象的析构函数在玩家对象的作用域结束时被调用。由于变量的作用域是在定义它的三行代码中的代码块,因此 Player 对象将在 main() 函数结束时立即被销毁,当它超出作用域时。

动态内存分配

现在,让我们尝试动态分配一个 Player 对象。这是什么意思?

我们使用 new 关键字来分配它!

int main()
{
  // "dynamic allocation" – using keyword new!
  // this style of allocation means that the player object will
  // NOT be deleted automatically at the end of the block where
  // it was declared!
Player *player = new Player();
} // NO automatic deletion!

该程序的输出如下:

Player born

玩家没有死!我们如何杀死玩家?我们必须显式地调用 deleteplayer 指针上。

删除关键字

delete 操作符会调用正在删除的对象的析构函数,如下面的代码所示:

int main()
{
  // "dynamic allocation" – using keyword new!
  Player *player = new Player();
  delete player; // deletion invokes dtor
}

该程序的输出如下:

Player born
Player died

因此,只有“正常”(或“自动”,也称为非指针类型)的变量类型在它们声明的代码块结束时被销毁。指针类型(使用 *new 声明的变量)即使在超出作用域时也不会自动销毁。

这有什么用?动态分配允许你控制对象的创建和销毁时间。这将在以后派上用场。

内存泄漏

因此,使用 new 创建的动态分配的对象不会自动删除,除非你明确地调用 delete。这里有一个风险!这被称为 内存泄漏。当使用 new 分配的对象从未被删除时,就会发生内存泄漏。可能发生的情况是,如果你的程序中有许多对象使用 new 分配,然后你停止使用它们,由于内存泄漏,你的计算机最终会耗尽内存。

下面是一个荒谬的示例程序来说明这个问题:

#include <iostream>
#include <string>
using namespace std;
class Player
{
  string name;
  int hp;
public:
  Player(){ cout << "Player born" << endl; }
  ~Player(){ cout << "Player died" << endl; }
};

int main()
{
  while( true ) // keep going forever,
  {
    // alloc..
    Player *player = new Player();
    // without delete == Memory Leak!
  }
}

如果这个程序运行足够长的时间,最终会消耗掉计算机的内存,如下面的截图所示:

内存泄漏

用于 Player 对象的 2 GB RAM!

注意,没有人打算编写包含这种类型问题的程序!内存泄漏问题意外发生。你必须注意你的内存分配,并删除不再使用的对象。

常规数组

C++ 中的数组可以声明如下:

#include <iostream>
using namespace std;
int main()
{
  int array[ 5 ];  // declare an "array" of 5 integers
                   // fill slots 0-4 with values
array[ 0 ] = 1;
array[ 1 ] = 2;
array[ 2 ] = 3;
array[ 3 ] = 4;
array[ 4 ] = 5;
  // print out the contents
  for( int index = 0; index < 5; index++ )
    cout << array[ index ] << endl;
}

在内存中,它看起来可能像这样:

常规数组

即,在 array 变量中有五个槽位或元素。每个槽位中都有一个常规的 int 变量。

数组语法

那么,你是如何访问数组中的一个 int 值的呢?要访问数组的单个元素,我们使用方括号,如下面的代码行所示:

array[ 0 ] = 10;

上述代码行会将数组槽位 0 的元素更改为 10:

数组语法

通常,要访问数组的特定槽位,你会写下以下内容:

array[ slotNumber ] = value to put into array;

请记住,数组槽位始终从 0 开始索引。要进入数组的第一个槽位,使用 array[0]。数组的第二个槽位是 array[1](不是 array[2])。上面数组的最后一个槽位是 array[4](不是 array[5])。array[5] 的数据类型超出了数组的范围!(前面图中没有索引为 5 的槽位。最高的索引是 4。)

不要超出数组的范围!有时它可能起作用,但有时你的程序会因为内存访问违规(访问不属于你的程序的内存)而崩溃。一般来说,访问不属于你的程序的内存会导致你的应用程序崩溃,而且如果它没有立即这样做,你的程序中会有一个隐藏的漏洞,它只会偶尔引起问题。你总是在索引数组时必须小心。

数组是 C++ 内置的,也就是说,你不需要包含任何特殊的东西就可以立即使用数组。你可以有任何类型的数据的数组,例如,intdoublestring,甚至你自己的自定义对象类型(Player)。

练习

  1. 创建一个包含五个字符串的数组,并在其中放入一些名字(可以是虚构的或随机的,这无关紧要)。

  2. 创建一个名为 temps 的双精度浮点数数组,包含三个元素,并将过去三天内的温度存储在其中。

解答

  1. 以下是一个包含五个字符串数组的示例程序:

    #include <iostream>
    #include <string>
    using namespace std;
    int main()
    {
      string array[ 5 ];  // declare an "array" of 5 strings
                          // fill slots 0-4 with values
    array[ 0 ] = "Mariam McGonical";
    array[ 1 ] = "Wesley Snice";
    array[ 2 ] = "Kate Winslett";
    array[ 3 ] = "Erika Badu";
    array[ 4 ] = "Mohammad";
      // print out the contents
      for( int index = 0; index < 5; index++ )
        cout << array[ index ] << endl;
    }
    
  2. 以下只是一个数组:

    double temps[ 3 ];
    // fill slots 0-2 with values
    temps[ 0 ] = 0;
    temps[ 1 ] = 4.5;
    temps[ 2 ] = 11;
    

C++ 风格的动态大小数组(new[] 和 delete[])

你可能已经想到,我们并不总是在程序开始时就知道数组的大小。我们需要动态地分配数组的大小。

然而,如果你尝试过,你可能已经注意到这并不起作用!

让我们尝试使用 cin 命令从用户那里获取数组大小。让我们询问用户他想要多大的数组,并尝试为他创建一个该大小的数组:

#include <iostream>
using namespace std;
int main()
{
  cout << "How big?" << endl;
  int size;       // try and use a variable for size..
  cin >> size;    // get size from user
  int array[ size ];  // get error: "unknown size"
}

我们得到以下错误:

error C2133: 'array' : unknown size

问题在于编译器想要分配数组的大小。然而,除非变量的大小被标记为 const,否则编译器在编译时无法确定其值。C++ 编译器无法在编译时确定数组的大小,因此会生成编译时错误。

为了解决这个问题,我们必须动态分配数组(在“堆”上):

#include <iostream>
using namespace std;
int main()
{
  cout << "How big?" << endl;
  int size;       // try and use a variable for size..
  cin >> size;
  int *array = new int[ size ];  // this works
  // fill the array and print
for( int index = 0; index < size; index++ )
{
  array[ index ] = index * 2;
  cout << array[ index ] << endl;
}
delete[] array; // must call delete[] on array allocated with 
                // new[]!
}

因此,这里的教训如下:

  • 为了动态分配某种类型(例如,int)的数组,你必须使用 new int[numberOfElementsInArray]

  • 使用 new[] 分配的数组必须在之后使用 delete[] 删除,否则您将得到内存泄漏!(这是 delete[] 带有方括号的!不是常规的 delete)。

动态 C 风格数组

C 风格数组是一个遗留话题,但它们仍然值得讨论,因为尽管它们很旧,您有时仍然可能会看到它们被使用。

我们声明 C 风格数组的做法如下:

#include <iostream>
using namespace std;
int main()
{
  cout << "How big?" << endl;
  int size;       // try and use a variable for size..
  cin >> size;
  // the next line will look weird..
  int *array = (int*)malloc( size*sizeof(int) ); // C-style
  // fill the array and print
for( int index = 0; index < size; index++ )
  {
    array[ index ] = index * 2;
    cout << array[ index ] << endl;
  }
free( array ); // must call free() on array allocated with 
               // malloc() (not delete[]!)
}

这里突出显示了差异。

使用 malloc() 函数创建 C 风格数组。malloc 一词代表“内存分配”。此函数要求您传入数组的字节数以创建,而不仅仅是您想在数组中包含的元素数量。因此,我们将请求的元素数量(大小)乘以数组内类型的 sizeof。以下表格列出了几种典型 C++ 类型在字节数上的大小:

C++ 原始类型 sizeof (字节大小)
int 4
float 4
double 8
long long 8

使用 malloc() 函数分配的内存必须在之后使用 free() 释放。

摘要

本章向您介绍了 C 和 C++ 风格的数组。在大多数 UE4 代码中,您将使用 UE4 编辑器内置的集合类 (TArray<T>)。然而,为了成为一名优秀的 C++ 程序员,您需要熟悉基本的 C 和 C++ 风格数组。

第八章。演员和棋子

现在,我们将真正深入到 UE4 代码中。一开始,它可能会看起来令人畏惧。UE4 类框架非常庞大,但别担心。框架虽然庞大,但你的代码不必如此。你会发现,你可以用相对较少的代码完成很多事情,并将很多内容显示在屏幕上。这是因为 UE4 引擎代码非常广泛且编程良好,以至于他们使得几乎任何与游戏相关的任务都能轻松完成。只需调用正确的函数,voila,你想要看到的内容就会出现在屏幕上。框架的整个概念就是它被设计成让你获得你想要的玩法,而不必花费大量时间在细节上费尽心思。

演员与棋子的区别

在本章中,我们将讨论演员和棋子。尽管听起来棋子似乎比演员更基础,但实际上情况正好相反。UE4 演员(Actor类)对象是可以在 UE4 游戏世界中放置的基本类型。为了在 UE4 世界中放置任何东西,你必须从Actor类派生。

Pawn是一个代表你可以或计算机的人工智能AI)在屏幕上控制的对象。Pawn类从Actor类派生,并具有由玩家直接或由 AI 脚本控制的附加能力。当一个棋子或演员被控制器或 AI 控制时,它被称为被该控制器或 AI 所拥有。

Actor类想象成戏剧中的角色。你的游戏世界将由许多演员组成,他们一起行动以使游戏玩法工作。游戏角色、非玩家角色NPC)甚至宝箱都将作为演员。

创建一个世界来放置你的演员

在这里,我们将从头开始创建一个基本关卡,我们可以将游戏角色放入其中。

UE4 团队已经很好地展示了如何使用世界编辑器在 UE4 中创建世界。我希望你能花点时间创建自己的世界。

首先,创建一个新的、空白的 UE4 项目以开始。为此,在 Unreal 启动器中,点击你最近安装的引擎旁边的启动按钮,如图所示:

创建一个世界来放置你的演员

这将启动 Unreal 编辑器。Unreal 编辑器用于视觉编辑你的游戏世界。你将在 Unreal 编辑器中花费大量时间,所以请慢慢来,尝试并玩弄它。

我将只介绍如何使用 UE4 编辑器的基础知识。然而,你需要让创意的源泉流淌,并投入一些时间来熟悉编辑器。

小贴士

要了解更多关于 UE4 编辑器的信息,请查看入门:UE4 编辑器简介播放列表,该播放列表可在www.youtube.com/playlist?list=PLZlv_N0_O1gasd4IcOe9Cx9wHoBB7rxFl找到。

启动 UE4 编辑器后,你将看到一个项目对话框。以下截图显示了需要执行的步骤,数字对应于它们需要执行的顺序:

创建一个世界来放置你的演员

执行以下步骤以创建项目:

  1. 在屏幕顶部选择新建项目标签页。

  2. 点击C++标签(第二个子标签)。

  3. 然后从可用的项目列表中选择基本代码

  4. 设置你的项目所在目录(我的目录是**Y:\Unreal Projects**)。选择一个空间充足的硬盘位置(最终项目大小约为 1.5 GB)。

  5. 为你的项目命名。我将其命名为GoldenEgg

  6. 点击创建项目以完成项目创建。

完成此操作后,UE4 启动器将启动 Visual Studio。Visual Studio 中只有几个源文件,但现在我们不会触碰它们。确保从屏幕顶部的配置管理器下拉菜单中选择开发编辑器,如图所示:

创建一个世界来放置你的演员

现在,通过在 Visual Studio 中按Ctrl + F5启动你的项目。你将发现自己处于 Unreal Engine 4 编辑器中,如图所示:

创建一个世界来放置你的演员

UE4 编辑器

我们将在这里探索 UE4 编辑器。我们将从控制开始,因为了解如何在 Unreal 中导航很重要。

编辑器控制

如果你以前从未使用过 3D 编辑器,那么控制可能会很难学。以下是在编辑模式下的基本导航控制:

  • 使用箭头键在场景中移动

  • Page UpPage Down上下垂直移动

  • 左键点击并拖动鼠标左右移动以改变面向的方向

  • 左键点击并拖动鼠标上下移动以推拉(前进和后退相机,与按上/下箭头键相同)

  • 右键点击并拖动以改变面向的方向

  • 中键点击并拖动以平移视图

  • 右键点击并使用WASD键在场景中移动

播放模式控制

在顶部栏中点击播放按钮,如图所示。这将启动播放模式。

播放模式控制

点击播放按钮后,控制会改变。在播放模式下,控制如下:

  • WASD键用于移动

  • 左右箭头键分别用于向左和向右看

  • 鼠标移动以改变你观察的方向

  • Esc键退出播放模式并返回到编辑模式

我建议你现在尝试向场景添加一些形状和对象,并尝试用不同的材料给它们上色。

向场景添加对象

向场景添加对象就像从内容浏览器选项卡拖放它们一样简单。默认情况下,内容浏览器选项卡停靠在窗口的左侧。如果看不到,请选择窗口并导航到内容浏览器以使其出现。

向场景添加对象

确保内容浏览器可见,以便将对象添加到你的级别中

接下来,在内容浏览器的左侧选择道具文件夹。

向场景添加对象

将内容浏览器中的内容拖放到你的游戏世界中

要调整对象大小,请按键盘上的R键。对象周围的操纵器将显示为框,表示调整大小模式。

向场景添加对象

按下键盘上的R键来调整对象大小

为了更改用于绘制对象的材料,只需从内容浏览器窗口内的材料文件夹中拖放一个新的材料。

向场景添加对象

从内容浏览器中的材料文件夹拖放一个材料到新颜色

材料就像油漆。你可以通过简单地拖放你想要的材料到你想涂覆的对象上,来给任何对象涂上你想要的材料。材料只是表面功夫:它们不会改变对象的其它属性(如重量)。

从头开始

如果你想要从头开始创建一个级别,只需点击文件并导航到新建级别...,如下所示:

从头开始

你可以选择默认空级别。我认为选择空级别是个好主意,原因将在后面提到。

从头开始

新级别最初将完全为黑色。再次尝试从内容浏览器选项卡拖放一些对象。

这次,我为地面平面添加了一个调整大小的形状/盒子,并用苔藓纹理化,还添加了几样道具/ SM_Rocks粒子/ P_Fire,最重要的是,一个光源。

一定要保存你的地图。这是我地图的快照(你的看起来怎么样?):

从头开始

如果你想更改启动编辑器时默认打开的级别,请转到项目设置 | 地图和模式;然后你将看到游戏默认地图编辑器启动地图设置,如下面的截图所示:

从头开始

添加光源

注意,如果你的场景完全为黑色,可能是因为你忘记在其中添加光源。

在上一个场景中,P_Fire粒子发射器充当光源,但它只发出少量的光。为了确保你的场景中的一切都看起来光线充足,你应该添加一个光源,如下所示:

  1. 前往窗口然后点击模式以确保显示光源面板:添加光源

  2. 然后,从模式面板中,将一个灯光对象拖入场景:添加光源

  3. 选择灯泡和盒子图标(它看起来像蘑菇,但不是)。

  4. 在左侧面板中点击灯光

  5. 选择你想要的灯光类型并将其拖入你的场景。

如果你没有光源,你的场景将完全变黑。

碰撞体积

你可能已经注意到,到目前为止,摄像机只是穿过场景中的所有几何形状,即使在播放模式中也是如此。这并不好。让我们让它变得这样,即玩家不能轻易穿过场景中的岩石。

有几种不同的碰撞体积类型。通常,在运行时进行完美的网格-网格碰撞成本太高。相反,我们使用一个近似值(边界体积)来猜测碰撞体积。

为对象编辑器添加碰撞检测

我们必须做的第一件事是将每个场景中的岩石与一个碰撞体积关联起来。

我们可以从 UE4 编辑器这样做:

  1. 点击场景中你想添加碰撞体积的对象。

  2. 场景大纲标签(默认显示在屏幕右侧)中右键单击此对象,并选择编辑,如图下所示:为对象编辑器添加碰撞检测

    注意

    你会发现自己处于网格编辑器中。

  3. 确保碰撞体积在屏幕顶部突出显示:为对象编辑器添加碰撞检测

  4. 前往碰撞菜单然后点击添加胶囊简化碰撞为对象编辑器添加碰撞检测

  5. 当成功添加碰撞体积时,它将显示为围绕对象的一组线条,如图下所示:为对象编辑器添加碰撞检测

    默认的碰撞胶囊(左侧)和手动调整大小的版本(右侧)

  6. 你可以按需调整大小(R)、旋转(E)、移动(W)并更改碰撞体积,就像你在 UE4 编辑器中操纵对象一样。

  7. 当你完成添加碰撞网格后,尝试点击播放;你会注意到你不能再穿过你的可碰撞对象。

向场景添加演员

现在我们已经设置了一个正在运行的场景,我们需要向场景添加一个演员。让我们首先为玩家添加一个带有碰撞体积的化身。为此,我们将必须从 UE4 的GameFramework类继承。

创建玩家实体

为了在屏幕上创建玩家的表示,我们需要从 Unreal 的Character类派生。

从 UE4 GameFramework 类继承

UE4 使得从基础框架类继承变得容易。你只需要执行以下步骤:

  1. 在 UE4 编辑器中打开你的项目。

  2. 前往文件然后选择添加代码到项目...。从 UE4 GameFramework 类继承

    通过导航到文件 | 添加代码到项目...,你可以从任何 UE4 GameFramework 类派生

  3. 从这里,选择你想要从其派生的基类。你有CharacterPawnActor等等,但就目前而言,我们将从Character派生:从 UE4 GameFramework 类继承

    选择你想要从其派生的 UE4 类

  4. 点击下一步 >以获取此对话框,在这里你命名类。我给我的玩家类命名为Avatar。从 UE4 GameFramework 类继承

  5. 最后,点击创建类以在代码中创建类,如图中所示。

当 UE4 要求你刷新 Visual Studio 项目时,请让它刷新。从解决方案资源管理器打开新的Avatar.h文件。

UE4 生成的代码看起来可能有点奇怪。记住我在第五章“函数和宏”中建议你避免的宏。UE4 代码广泛使用了这些宏。这些宏用于复制和粘贴样板启动代码,使你的代码能够与 UE4 编辑器集成。

以下代码显示了Avatar.h文件的内容:

#pragma once
// Avatar.h code file
#include "GameFramework/Character.h"
#include "Avatar.generated.h"
UCLASS()
class MYPROJECT_API AAvatar : public ACharacter
{
  GENERATED_UCLASS_BODY()
};

让我们暂时谈谈宏。

UCLASS()宏基本上使你的 C++代码类在 UE4 编辑器中可用。GENERATED_UCLASS_BODY()宏复制并粘贴 UE4 需要以 UE4 类正确工作的代码。

小贴士

对于UCLASS()GENERATED_UCLASS_BODY(),你并不真正需要理解 UE4 是如何施展魔法的。你只需要确保它们出现在正确的位置(它们生成类时的位置)。

将模型与 Avatar 类关联

现在我们需要将一个模型与我们的角色对象关联起来。为了做到这一点,我们需要一个可以操作的模型。幸运的是,UE4 市场上有大量免费样本模型可供使用。

下载免费模型

要创建玩家对象,我们将从市场标签下载动画启动包文件(这是免费的)。

下载免费模型

在编写本书时,从 Unreal 启动器点击市场并搜索动画启动包,它是免费的。

下载动画启动包文件后,你将能够将其添加到之前创建的任何项目中,如图中所示:

下载免费模型

当你在动画起始包下的添加到项目处点击时,你会看到一个弹出窗口,询问要将包添加到哪个项目中:

下载免费模型

简单地选择你的项目,新的艺术品将出现在你的内容浏览器中。

加载网格

通常,将资产硬编码到游戏中被认为是一种不好的做法。硬编码意味着你编写了指定要加载的资产的 C++代码。然而,硬编码意味着加载的资产是最终可执行文件的一部分,这意味着更改加载的资产在运行时是不可修改的。这是一个不好的做法。能够在运行时更改加载的资产会更好。

因此,我们将使用 UE4 蓝图功能来设置我们的Avatar类的模型网格和碰撞胶囊。

从我们的 C++类创建蓝图

  1. 这真的很简单。通过导航到窗口并点击类查看器来打开类查看器标签页,如图所示:从我们的 C++类创建蓝图

  2. 类查看器对话框中,开始输入你的 C++类的名字。如果你已经从 C++代码中正确创建并导出了这个类,它将显示出来,如图所示:从我们的 C++类创建蓝图

    小贴士

    如果你的Avatar类没有显示出来,请关闭编辑器并再次编译/运行 C++项目。

  3. 右键单击你想要创建蓝图的那个类(在我的例子中,是我的Avatar类)。

  4. 给你的蓝图起一个独特的名字。我给我的蓝图命名为BP_Avatar

  5. 现在,通过双击BP_Avatar(添加后它将出现在类查看器标签页中,位于Avatar之下)来打开这个蓝图进行编辑,如图所示:从我们的 C++类创建蓝图

  6. 你将看到你的新BP_Avatar对象的蓝图窗口,如图所示:从我们的 C++类创建蓝图

    注意

    从这个窗口,你可以将模型可视地附加到Avatar类。再次强调,这是推荐的模式,因为艺术家通常会为游戏设计师设置他们的资产。

  7. 要设置默认网格,请点击顶部的默认值按钮。向下滚动属性,直到你遇到网格从我们的 C++类创建蓝图

  8. 点击下拉菜单,并选择如图所示的HeroTPP作为你的网格。

  9. 如果HeroTPP没有出现在下拉菜单中,请确保你已经下载并将动画起始包添加到你的项目中。或者,如果你在视图 选项下选择显示引擎内容,你也可以将黄色的教程 TPP模型添加到你的项目中:从我们的 C++类创建蓝图

  10. 关于碰撞体积?在蓝图编辑器中点击您的角色所在的组件标签:从我们的 C++ 类创建蓝图

    如果您的胶囊没有封装您的模型,调整模型使其适合

    注意

    如果您的模型最终像我的一样,胶囊位置不正确!我们需要调整它。

  11. 点击蓝色的角色模型并按W键。将他向下移动,直到他适合胶囊内。如果胶囊不够大,您可以在细节标签下的胶囊高度胶囊半径中调整其大小,如下面的截图所示:从我们的 C++ 类创建蓝图

    您可以通过调整胶囊高度属性来拉伸胶囊

  12. 现在,我们准备将这个角色添加到游戏世界中。在 UE4 编辑器中,从类查看器标签拖动您的BP_Avatar模型到场景中。从我们的 C++ 类创建蓝图

    场景中添加的我们的角色类,处于 T-pose

角色的姿态被称为 T-pose。动画师通常将角色留在这个默认姿态。可以通过应用动画来使角色改变这个默认姿态,使其更加有趣。您想要他动起来,对吧!这很简单。

在蓝图编辑器的默认选项卡中,在网格上方,有一个动画部分,您可以在其中选择网格上的活动动画。如果您想使用某个动画资产,只需点击下拉菜单并选择您想要的动画即可。

然而,更好的做法是使用动画蓝图。这样,艺术家可以根据角色的动作正确设置动画。如果您从动画模式中选择使用动画蓝图,然后从下拉菜单中选择ASP_HeroTPP_AnimBlueprint,角色在游戏中的表现将看起来更好,因为动画将由蓝图(由艺术家完成)根据角色的移动进行调整。

从我们的 C++ 类创建蓝图

小贴士

这里无法涵盖所有内容。动画蓝图在第十一章 怪物 中有介绍。如果您对动画真的感兴趣,观看几节 Gnomon Workshop 关于逆运动学、动画和绑定教程也不会错,例如 Alex Alvarez 的 Rigging 101 课程,可在www.thegnomonworkshop.com/store/product/768/Rigging-101找到。

还有一件事:让我们让角色的摄像机出现在它的后面。这将为您提供第三人称视角,让您可以看到整个角色,如下面的截图和相应的步骤所示:

从我们的 C++ 类创建蓝图

  1. BP_Avatar蓝图编辑器中,点击组件标签。

  2. 点击 添加组件

  3. 选择添加一个 摄像头

视口将出现一个摄像头。你可以点击摄像头并移动它。将摄像头定位在玩家后面某个位置。确保玩家上的蓝色箭头指向与摄像头相同的方向。如果不是,旋转 Avatar 模型网格,使其与蓝色箭头指向相同方向。

从我们的 C++ 类创建蓝图

你模型网格上的蓝色箭头指示模型网格的前进方向。确保摄像头的开口方向与角色的前进向量相同。

编写控制游戏角色的 C++ 代码

当你启动你的 UE4 游戏,你可能会注意到摄像头是一个默认的、自由飞行的摄像头。我们现在要做的就是将起始角色设置为我们的 Avatar 类的实例,并使用键盘控制我们的角色。

将玩家设置为 Avatar 类的实例

在 Unreal 编辑器中,通过导航到 文件 | 将代码添加到项目... 并选择 游戏模式 来创建 游戏模式 的子类。我命名为 GameModeGoldenEgg

将玩家设置为 Avatar 类的实例

UE4 的 GameMode 包含游戏的规则,并描述了游戏如何被引擎所执行。我们稍后会更多地使用我们的 GameMode 类。现在,我们需要创建它的子类。

从 Visual Studio 重新编译你的项目,以便你可以创建一个 GameModeGoldenEgg 蓝图。通过转到菜单栏顶部的 蓝图 图标,点击 游戏模式,然后选择 + 创建 | GameModeGoldenEgg(或你在第一步中为你的 GameMode 子类命名的任何名称)来创建 GameMode 蓝图。

将玩家设置为 Avatar 类的实例

  1. 为你的蓝图命名;我命名为 BP_GameModeGoldenEgg,如图所示:将玩家设置为 Avatar 类的实例

  2. 你新创建的蓝图将在蓝图编辑器中打开。如果它没有打开,你可以从 类查看器 选项卡打开 BP_GameModeGoldenEgg 类。

  3. 默认角色类 面板中选择你的 BP_Avatar 类,如图所示。默认角色类 面板是用于玩家的对象类型。将玩家设置为 Avatar 类的实例

  4. 现在,启动你的游戏。你可以看到背面视图,因为摄像头放置在后面,如图所示:将玩家设置为 Avatar 类的实例

你会注意到你无法移动。这是为什么?答案是,我们还没有设置控制器输入。

设置控制器输入

  1. 要设置控制器输入,转到 设置 | 项目设置...设置控制器输入

  2. 接下来,在左侧面板中向下滚动,直到在 引擎 下方看到 输入设置控制器输入

  3. 在右侧,你可以设置一些 绑定。点击 轴映射 旁边的箭头以展开它。开始时只需添加两个轴映射,一个称为 Forward(连接到键盘字母 W)和一个称为 Strafe(连接到键盘字母 D)。记住你设置的名称;我们将在稍后的 C++ 代码中查找它们。

  4. 关闭 项目设置 对话框。现在,打开你的 C++ 代码。

Avatar.h 构造函数中,你需要添加三个成员函数声明,如下所示:

UCLASS()
class GOLDENEGG_API AAvatar : public ACharacter
{
  GENERATED_UCLASS_BODY()

  // New! These 3 new member function declarations
  // they will be used to move our player around!
  void SetupPlayerInputComponent(class UInputComponent*  InputComponent) override;
  void MoveForward( float amount );
  void MoveRight( float amount );
};

注意我们添加的第一个成员函数(SetupPlayerInputComponent)是虚拟函数的一个重写。SetupPlayerInputComponentAPawn 基类中的一个虚拟函数。

Avatar.cpp 文件中,你需要放置函数体。添加以下成员函数定义:

void AAvatar::SetupPlayerInputComponent(class UInputComponent* InputComponent)
{
  check(InputComponent);
  InputComponent->BindAxis("Forward", this,  &AAvatar::MoveForward);
  InputComponent->BindAxis("Strafe", this, &AAvatar::MoveRight);
}

这个成员函数查找我们在 Unreal 编辑器中创建的 ForwardStrafe 轴绑定,并将它们连接到 this 类内部的成员函数。我们应该连接到哪些成员函数呢?当然,我们应该连接到 AAvatar::MoveForwardAAvatar::MoveRight。以下是这两个函数的成员函数定义:

void AAvatar::MoveForward( float amount )
{
  // Don't enter the body of this function if Controller is
  // not set up yet, or if the amount to move is equal to 0
  if( Controller && amount )
  {
    FVector fwd = GetActorForwardVector();
    // we call AddMovementInput to actually move the
    // player by `amount` in the `fwd` direction
    AddMovementInput(fwd, amount);
  }
}

void AAvatar::MoveRight( float amount )
{
  if( Controller && amount )
  {
    FVector right = GetActorRightVector();
    AddMovementInput(right, amount);
  }
}

小贴士

Controller 对象和 AddMovementInput 函数在 APawn 基类中定义。由于 Avatar 类从 ACharacter 继承,而 ACharacter 又从 APawn 继承,因此我们可以自由使用基类 APawn 中的所有成员函数。现在你看到继承和代码重用的美妙之处了吗?

练习

添加轴绑定和 C++ 函数以使玩家向左和向后移动。

注意

这里有一个提示:如果你意识到向后移动只是向前移动的负值,你只需要添加轴绑定。

解决方案

通过导航到 设置 | 项目设置... | 输入,添加两个额外的轴绑定,如下截图所示:

解决方案

SA 输入的缩放比例设置为 -1.0。这将反转轴。因此,在游戏中按下 S 键将使玩家向前移动。试试看!

或者,你可以在你的 AAvatar 类中定义两个完全独立的成员函数,如下所示,并将 AS 键分别绑定到 AAvatar::MoveLeftAAvatar::MoveBack

void AAvatar::MoveLeft( float amount )
{
  if( Controller && amount )
  {
    FVector left = -GetActorRightVector();
    AddMovementInput(left, amount);
  }
}
void AAvatar::MoveBack( float amount )
{
  if( Controller && amount )
  {
    FVector back = -GetActorForwardVector();
    AddMovementInput(back, amount);
  }
}

偏航和俯仰

我们可以通过设置控制器的偏航和俯仰来改变玩家查看的方向。

在这里,我们只需添加鼠标的新轴绑定,如下截图所示:

偏航和俯仰

从 C++ 中,你需要向 AAvatar.h 添加两个新的成员函数声明:

void Yaw( float amount );
void Pitch( float amount );

这些成员函数的函数体将放在 AAvatar.cpp 文件中:

void AAvatar::Yaw( float amount )
{
  AddControllerYawInput(200.f * amount * GetWorld()- >GetDeltaSeconds());
}
void AAvatar::Pitch( float amount )
{
  AddControllerPitchInput(200.f * amount * GetWorld()- >GetDeltaSeconds());
}

然后,向 SetupPlayerInputComponent 添加两行:

void AAvatar::SetupPlayerInputComponent(class UInputComponent*  InputComponent)
{
  // .. as before, plus:
  InputComponent->BindAxis("Yaw", this, &AAvatar::Yaw);
  InputComponent->BindAxis("Pitch", this, &AAvatar::Pitch);
}

这里,请注意,我在 YawPitch 函数中将 amount 值乘以了 200。这个数字代表鼠标的灵敏度。你可以在 AAvatar 类中添加一个 float 成员,以避免硬编码这个灵敏度数字。

GetWorld()->GetDeltaSeconds()给你的是上一帧和这一帧之间经过的时间量。这并不多:GetDeltaSeconds()应该大约是 16 毫秒(0.016 秒),大多数时候(如果你的游戏以 60fps 运行)。

因此,现在我们有了玩家输入和控制。要为你的角色添加新功能,你只需做以下这些:

  1. 通过转到设置 | 项目设置 | 输入来绑定你的键或鼠标操作。

  2. 添加一个成员函数,当按下该键时运行。

  3. SetupPlayerInputComponent中添加一行,将绑定的输入名称连接到当按下该键时要运行的成员函数。

创建非玩家角色实体

因此,我们需要创建一些NPC(不可玩角色)。NPC 是游戏中的角色,帮助玩家。一些提供特殊物品,一些是商店卖家,一些有信息要告诉玩家。在这个游戏中,它们会在玩家靠近时做出反应。让我们编写一些这种行为。

首先,创建另一个Character的子类。在 UE4 编辑器中,转到文件 | 将代码添加到项目...,然后从可以创建子类的Character类中选择。将你的子类命名为NPC

现在,在 Visual Studio 中编辑你的代码。每个 NPC 都会有一个消息告诉玩家,因此我们在NPC类中添加了一个UPROPERTY() FString属性。

小贴士

FString是 UE4 版本的 C++ <string> 类型。在 UE4 中编程时,你应该使用FString对象而不是 C++ STL 的string对象。一般来说,你最好使用 UE4 的内置类型,因为它们保证了跨平台兼容性。

如何将UPROPERTY() FString属性添加到NPC类中,以下代码显示了:

UCLASS()
class GOLDENEGG_API ANPC : public ACharacter
{
  GENERATED_UCLASS_BODY()
  UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category =  Collision)
  TSubobjectPtr<class USphereComponent> ProxSphere;
  // This is the NPC's message that he has to tell us.
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  NPCMessage)
  FString NpcMessage;
  // When you create a blueprint from this class, you want to be 
  // able to edit that message in blueprints,
  // that's why we have the EditAnywhere and BlueprintReadWrite 
  // properties.
}

注意,我们将EditAnywhereBlueprintReadWrite属性放入了UPROPERTY宏中。这将使NpcMessage在蓝图中被编辑。

小贴士

所有 UE4 属性说明的完整描述可在docs.unrealengine.com/latest/INT/Programming/UnrealArchitecture/Reference/Properties/index.html找到。

重新编译你的项目(就像我们对Avatar类所做的那样)。然后,转到类查看器,右键单击你的NPC类,并从它创建一个蓝图。

你想要创建的每个 NPC 角色都可以基于NPC类创建蓝图。为每个蓝图命名一个独特的名称,因为我们将为每个出现的 NPC 选择不同的模型网格和消息,如以下截图所示:

创建非玩家角色实体

现在,打开蓝图,从添加组件中选择骨骼网格,并调整胶囊(就像我们对BP_Avatar所做的那样)。你还可以更改你新角色的材质,使其看起来与玩家不同。

创建非玩家角色实体

在你的网格属性中更改角色的材质。在渲染选项卡下,点击+图标添加一个新的材质。然后,点击小胶囊形状的项来选择要渲染的材质。

默认选项卡中,搜索NpcMessage属性。这是 C++代码和蓝图之间的连接:因为我们在一个FString NpcMessage变量上输入了UPROPERTY()函数,所以该属性在 UE4 中显示为可编辑,如下面的截图所示:

创建非玩家角色实体

现在,将BP_NPC_Owen拖入场景。你也可以创建第二个或第三个角色,并确保给他们起独特的名字、外观和消息。

创建非玩家角色实体

我基于 NPC 基类创建了两个蓝图,BP_NPC_Justin 和 BP_NPC_Owen。它们有不同的外观和为玩家提供不同的消息。

创建非玩家角色实体

场景中的贾斯汀和欧文

显示每个 NPC 对话框中的引用

要显示对话框,我们需要一个自定义的(抬头显示)HUD。在 UE4 编辑器中,转到文件 | 将代码添加到项目... 并选择创建子类的HUD类。根据你的意愿命名你的子类;我把我命名为MyHUD

在你创建了MyHUD类之后,让 Visual Studio 重新加载。我们将进行一些代码编辑。

在 HUD 上显示消息

AMyHUD类内部,我们需要实现DrawHUD()函数,以便将我们的消息绘制到 HUD 上,并初始化一个用于绘制到 HUD 的字体,如下面的代码所示:

UCLASS()
class GOLDENEGG_API AMyHUD : public AHUD
{
  GENERATED_UCLASS_BODY()
  // The font used to render the text in the HUD.
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = HUDFont)
  UFont* hudFont;
  // Add this function to be able to draw to the HUD!
  virtual void DrawHUD() override;
};

HUD 字体将在AMyHUD类的蓝图版本中设置。DrawHUD()函数每帧运行一次。为了在帧内绘制,需要在AMyHUD.cpp文件中添加一个函数:

void AMyHUD::DrawHUD()
{
  // call superclass DrawHUD() function first
  Super::DrawHUD();
  // then proceed to draw your stuff.
  // we can draw lines..
  DrawLine( 200, 300, 400, 500, FLinearColor::Blue );
  // and we can draw text!
  DrawText( "Greetings from Unreal!", FVector2D( 0, 0 ), hudFont,  FVector2D( 1, 1 ), FColor::White );
}

等等!我们还没有初始化我们的字体。为了做到这一点,我们需要在蓝图中进行设置。编译并运行你的 Visual Studio 项目。一旦你进入编辑器,转到顶部的蓝图菜单并导航到游戏模式 | HUD | + 创建 | MyHUD

在 HUD 上显示消息

创建 MyHUD 类的蓝图

我把它命名为BP_MyHUD。编辑BP_MyHUD并从HUDFont下的下拉菜单中选择一个字体:

在 HUD 上显示消息

我为我的 HUD 选择了 RobotoDistanceField 字体

接下来,编辑你的游戏模式蓝图(BP_GameModeGoldenEgg)并选择你的新BP_MyHUD(不是MyHUD类)用于HUD 类面板:

在 HUD 上显示消息

通过运行程序来测试你的程序!你应该会在屏幕上看到打印的文本。

在 HUD 上显示消息

使用 TArray

我们想要向玩家显示的每条消息都将有几个属性:

  • 一个用于消息的FString变量

  • 一个用于显示时间的float变量

  • 一个用于消息颜色的FColor变量

因此,我们编写一个小的 struct 函数来包含所有这些信息是有意义的。

MyHUD.h 的顶部插入以下 struct 声明:

struct Message
{
  FString message;
  float time;
  FColor color;
  Message()
  {
    // Set the default time.
    time = 5.f;
    color = FColor::White;
  }
  Message( FString iMessage, float iTime, FColor iColor )
  {
    message = iMessage;
    time = iTime;
    color = iColor;
  }
};

注意

本章代码包中有一个增强版的 Message 结构(带有背景颜色)。在这里我们使用了更简单的代码,以便更容易理解本章内容。

现在,在 AMyHUD 类中,我们想要添加一个这些消息的 TArrayTArray 是 UE4 定义的一种特殊类型的动态可增长 C++ 数组。我们将在下一章中详细介绍 TArray 的使用,但这个简单的 TArray 使用应该是一个很好的介绍,以激发你对游戏中使用数组的有用性的兴趣。这将被声明为 TArray<Message>

UCLASS()
class GOLDENEGG_API AMyHUD : public AHUD
{
  GENERATED_UCLASS_BODY()
  // The font used to render the text in the HUD.
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = HUDFont)
  UFont* hudFont;
  // New! An array of messages for display
  TArray<Message> messages;
  virtual void DrawHUD() override;
  // New! A function to be able to add a message to display
  void addMessage( Message msg );
};

现在,每当 NPC 有消息要显示时,我们只需调用 AMyHud::addMessage() 并传入我们的消息。该消息将被添加到要显示的消息的 TArray 中。当消息过期(经过一定时间后),它将从 HUD 中移除。

AMyHUD.cpp 文件中,添加以下代码:

void AMyHUD::DrawHUD()
{
  Super::DrawHUD();
  // iterate from back to front thru the list, so if we remove
  // an item while iterating, there won't be any problems
  for( int c = messages.Num() - 1; c >= 0; c-- )
  {
    // draw the background box the right size
    // for the message
    float outputWidth, outputHeight, pad=10.f;
    GetTextSize( messages[c].message, outputWidth, outputHeight,  hudFont, 1.f );

    float messageH = outputHeight + 2.f*pad;
    float x = 0.f, y = c*messageH;

    // black backing
    DrawRect( FLinearColor::Black, x, y, Canvas->SizeX, messageH  );
    // draw our message using the hudFont
    DrawText( messages[c].message, messages[c].color, x + pad, y +  pad, hudFont );

    // reduce lifetime by the time that passed since last 
    // frame.
    messages[c].time -= GetWorld()->GetDeltaSeconds();

    // if the message's time is up, remove it
    if( messages[c].time < 0 )
    {
      messages.RemoveAt( c );
    }
  }
}

void AMyHUD::addMessage( Message msg )
{
  messages.Add( msg );
}

AMyHUD::DrawHUD() 函数现在会绘制 messages 数组中的所有消息,并按照自上一帧以来经过的时间对 messages 数组中的每个消息进行排列。一旦消息的 time 值低于 0,过期的消息将从 messages 集合中移除。

练习

重构 DrawHUD() 函数,以便将绘制到屏幕上的消息代码放在一个名为 DrawMessages() 的单独函数中。

Canvas 变量仅在 DrawHUD() 中可用,因此您必须将 Canvas->SizeXCanvas->SizeY 保存为类级别变量。

注意

重构意味着改变代码内部的工作方式,使其更加有序或更容易阅读,但仍然对运行程序的用户提供相同的外观结果。重构通常是一种良好的实践。重构发生的原因是因为一旦开始编写代码,没有人确切知道最终代码应该是什么样子。

解决方案

请参阅本章代码包中的 AMyHUD::DrawMessages() 函数。

在接近 NPC 时触发事件

要在 NPC 附近触发事件,我们需要设置一个额外的碰撞检测体积,该体积略大于默认的胶囊形状。额外的碰撞检测体积将是每个 NPC 周围的球体。当玩家进入 NPC 球体时,NPC 会做出反应并显示消息。

在接近 NPC 时触发事件

我们将向 NPC 添加一个深红色球体,以便他能够知道玩家是否在附近。

在你的 NPC.h 类文件中,添加以下代码以声明 ProxSphere 和名为 ProxUFUNCTION

UCLASS()
class GOLDENEGG_API ANPC : public ACharacter
{
  GENERATED_UCLASS_BODY()
  // This is the NPC's message that he has to tell us.
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  NPCMessage)
  FString NpcMessage;
  // The sphere that the player can collide with to get item
  UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category =  Collision)
  TSubobjectPtr<class USphereComponent> ProxSphere;
  // The corresponding body of this function is 
  // ANPC::Prox_Implementation, __not__ ANPC::Prox()!
  // This is a bit weird and not what you'd expect,
  // but it happens because this is a BlueprintNativeEvent
  UFUNCTION(BlueprintNativeEvent, Category = "Collision")
  void Prox( AActor* OtherActor, UPrimitiveComponent* OtherComp,  int32 OtherBodyIndex, bool bFromSweep, const FHitResult &  SweepResult );
};

这看起来有点杂乱,但实际上并不复杂。在这里,我们声明了一个额外的边界球体体积 ProxSphere,用于检测玩家是否接近 NPC。

NPC.cpp 文件中,我们需要添加以下代码以完成近距离检测:

ANPC::ANPC(const class FPostConstructInitializeProperties& PCIP) : Super(PCIP)
{
  ProxSphere = PCIP.CreateDefaultSubobject<USphereComponent>(this,  TEXT("Proximity Sphere"));
  ProxSphere->AttachTo( RootComponent );
  ProxSphere->SetSphereRadius( 32.f );
  // Code to make ANPC::Prox() run when this proximity sphere
  // overlaps another actor.
  ProxSphere->OnComponentBeginOverlap.AddDynamic( this,  &ANPC::Prox );
  NpcMessage = "Hi, I'm Owen";//default message, can be edited
  // in blueprints
}
// Note! Although this was declared ANPC::Prox() in the header,
// it is now ANPC::Prox_Implementation here.
void ANPC::Prox_Implementation( AActor* OtherActor,  UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool  bFromSweep, const FHitResult & SweepResult )
{
  // This is where our code will go for what happens
  // when there is an intersection
}

当附近有东西时,让 NPC 向 HUD 显示某些内容

当玩家接近 NPC 的球体碰撞体积时,向 HUD 显示一条消息,提醒玩家 NPC 在说什么。

这是ANPC::Prox_Implementation的完整实现:

void ANPC::Prox_Implementation( AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult & SweepResult )
{
  // if the overlapped actor is not the player,
  // you should just simply return from the function
  if( Cast<AAvatar>( OtherActor ) == nullptr )
  {
    return;
  }
  APlayerController* PController = GetWorld()- >GetFirstPlayerController();
  if( PController )
  {
    AMyHUD * hud = Cast<AMyHUD>( PController->GetHUD() );
    hud->addMessage( Message( NpcMessage, 5.f, FColor::White ) );
  }
}

在这个函数中,我们首先将OtherActor(靠近 NPC 的东西)强制转换为AAvatar。当OtherActor是一个AAvatar对象时,转换成功(并且不是nullptr)。我们获取 HUD 对象(它恰好附加到玩家控制器)并将 NPC 的消息传递给 HUD。当玩家在 NPC 周围的红色边界球体内时,会显示这条消息。

当附近有东西时,让 NPC 向 HUD 显示某些内容

欧文的问候

练习

  1. 为 NPC 的名称添加一个UPROPERTY函数名,以便在蓝图中对 NPC 的名称进行编辑,类似于 NPC 对玩家说的话。在输出中显示 NPC 的名称。

  2. 为 NPC 的面部纹理添加一个UPROPERTY函数(类型UTexture2D*)。在输出中在其消息旁边绘制 NPC 的脸。

  3. 以条形(填充矩形)的形式渲染玩家的 HP。

解决方案

将此属性添加到ANPC类中:

// This is the NPC's name
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = NPCMessage)
FString name;

然后,在ANPC::Prox_Implementation中,将传递给 HUD 的字符串更改为:

name + FString(": ") + message

这样,NPC 的名称就会附加到消息上。

将此属性添加到ANPC类中:

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = NPCMessage)
UTexture2D* Face;

然后你可以在蓝图中选择要附加到 NPC 脸上的面部图标。

将纹理附加到你的struct Message

UTexture2D* tex;

要渲染这些图标,你需要添加一个调用DrawTexture()的调用,并将正确的纹理传递给它:

DrawTexture( messages[c].tex, x, y, messageH, messageH, 0, 0, 1, 1  );

在渲染之前,务必检查纹理是否有效。图标应类似于屏幕顶部所示的内容:

解决方案

这就是绘制玩家剩余健康度的条形函数的样子:

void AMyHUD::DrawHealthbar()
{
  // Draw the healthbar.
  AAvatar *avatar = Cast<AAvatar>(  UGameplayStatics::GetPlayerPawn(GetWorld(), 0) );
  float barWidth=200, barHeight=50, barPad=12, barMargin=50;
  float percHp = avatar->Hp / avatar->MaxHp;
  DrawRect( FLinearColor( 0, 0, 0, 1 ), Canvas->SizeX - barWidth -  barPad - barMargin, Canvas->SizeY - barHeight - barPad -  barMargin, barWidth + 2*barPad, barHeight + 2*barPad );
  DrawRect( FLinearColor( 1-percHp, percHp, 0, 1 ), Canvas->SizeX  - barWidth - barMargin, Canvas->SizeY - barHeight - barMargin,  barWidth*percHp, barHeight );
}

摘要

在本章中,我们介绍了大量内容。我们向您展示了如何创建角色并在屏幕上显示它,如何使用轴绑定来控制角色,以及如何创建和显示可以发布消息到 HUD 的 NPC。

在接下来的章节中,我们将通过在第十章中添加库存系统及拾取物品以及相应的代码和概念来进一步开发我们的游戏。在此之前,我们将在第九章中深入探讨一些 UE4 容器类型,模板和常用容器

第九章。模板和常用容器

在 第七章,动态内存分配中,我们讨论了如果你想要创建一个在编译时大小未知的数组时,如何使用动态内存分配。动态内存分配的形式为 int * array = new int[ number_of_elements ]

你还看到,使用 new[] 关键字进行动态分配需要你在稍后对数组调用 delete[],否则你会有一个内存泄漏。必须以这种方式管理内存是件辛苦的工作。

有没有一种方法可以创建一个动态大小的数组,并且由 C++ 自动为你管理内存?答案是肯定的。C++ 有一些对象类型(通常称为容器),可以自动处理动态内存的分配和释放。UE4 提供了几种容器类型,用于在动态可调整大小的集合中存储你的数据。

存在两种不同的模板容器组。有 UE4 容器家族(以 T* 开头)和 C++ 标准模板库STL)容器家族。UE4 容器和 C++ STL 容器之间有一些差异,但这些差异并不大。UE4 容器集合的设计考虑了游戏性能。C++ STL 容器也表现良好,它们的接口稍微一致一些(API 的一致性是你更愿意看到的)。你使用哪个容器集合取决于你。然而,建议你使用 UE4 容器集合,因为它保证当你尝试编译代码时,不会出现跨平台问题。

UE4 中的调试输出

本章(以及后续章节)中的所有代码都需要你在 UE4 项目中工作。为了测试 TArray,我创建了一个基本的代码项目,名为 TArrays。在 ATArraysGameMode::ATArraysGameMode 构造函数中,我正在使用调试输出功能将文本打印到控制台。

以下是代码的样式:

ATArraysGameMode::ATArraysGameMode(const class FPostConstructInitializeProperties& PCIP) : Super(PCIP)
{
  if( GEngine )
  {
    GEngine->AddOnScreenDebugMessage( 0, 30.f, FColor::Red,  "Hello!" );
  }
}

如果你编译并运行此项目,当你开始游戏时,你将在游戏窗口的左上角看到调试文本。你可以使用调试输出在任何时候查看程序的内部结构。只需确保在调试输出时 GEngine 对象存在。前面代码的输出如下所示:

UE4 中的调试输出

UE4 的 TArray<T>

TArrays 是 UE4 的动态数组版本。要了解 TArray<T> 变量的含义,你首先必须知道尖括号 <T> 选项代表什么。《T>选项意味着数组中存储的数据类型是变量。你想要一个int类型的数组吗?那么创建一个TArray变量。一个double类型的TArray变量?创建一个TArray` 变量。

所以,一般来说,无论哪里出现<T>,你都可以插入你选择的 C++类型。让我们继续,并通过示例来展示这一点。

使用 TArray的示例

TArray<int>变量只是一个int类型的数组。TArray<Player*>变量将是一个Player*指针的数组。数组是动态可调整大小的,并且可以在创建后向数组的末尾添加元素。

要创建TArray<int>变量,你只需要使用正常的变量分配语法:

TArray<int> array;

使用成员函数对TArray变量进行更改。有一些成员函数可以在TArray变量上使用。你需要了解的第一个成员函数是向数组添加值的方式,如下面的代码所示:

array.Add( 1 );
array.Add( 10 );
array.Add( 5 );
array.Add( 20 );

这四行代码将在内存中产生数组值,如下面的图所示:

使用 TArray的示例

当你调用array.Add(number)时,新数字会添加到数组的末尾。由于我们按照顺序将数字110520添加到数组中,这就是它们将进入数组中的顺序。

如果你想在数组的开头或中间插入一个数字,这也是可能的。你只需要使用array.Insert(value, index)函数,如下面的代码行所示:

array.Insert( 9, 0 );

此函数将数字9推入数组的0位置(即前面)。这意味着数组中的其余元素将向右偏移,如下面的图所示:

使用 TArray的示例

我们可以使用以下代码行将另一个元素插入到数组的2位置:

array.Insert( 30, 2 );

此函数将数组重新排列,如下面的图所示:

使用 TArray的示例

小贴士

如果你将一个数字插入到数组中超出边界的位置,UE4 将会崩溃。所以请小心,不要这样做。

遍历 TArray

你可以使用两种方式遍历(遍历)TArray变量的元素:要么使用基于整数的索引,要么使用迭代器。我将在下面展示这两种方法。

普通 for 循环和方括号表示法

使用整数来索引数组元素有时被称为“普通”的for循环。可以使用array[index]来访问数组元素,其中index是元素在数组中的数值位置:

for( int index = 0; index < array.Num(); index++ )
{
  // print the array element to the screen using debug message
  GEngine->AddOnScreenDebugMessage( index, 30.f, FColor::Red,  FString::FromInt( array[ index ] ) );
}

迭代器

你也可以使用迭代器逐个遍历数组的元素,如下面的代码所示:

int count = 0;	// keep track of numerical index in array
for( TArray<int>::TIterator it = array.CreateIterator(); it; ++it  )
{
  GEngine->AddOnScreenDebugMessage( count++, 30.f, FColor::Red,  FString::FromInt( *it ) );
}

迭代器是数组的指针。迭代器可以用来检查或更改数组内的值。以下图示了一个迭代器的示例:

迭代器

迭代器的概念:它是一个外部对象,可以查看并检查数组的值。执行++操作将迭代器移动到检查下一个元素。

迭代器必须适合它正在遍历的集合。要遍历 TArray<int> 变量,你需要一个 TArray<int>::TIterator 类型的迭代器。

我们使用 * 来查看迭代器后面的值。在上面的代码中,我们使用 (*it) 从迭代器中获取整数值。这被称为解引用。解引用迭代器意味着查看其值。

for 循环的每次迭代结束时发生的 ++it 操作会增加迭代器,将其移动到列表中的下一个元素。

将代码插入程序并尝试运行。以下是到目前为止我们创建的示例程序,使用 TArray(所有内容都在 ATArraysGameMode::ATArraysGameMode() 构造函数中):

ATArraysGameMode::ATArraysGameMode(const class FPostConstructInitializeProperties& PCIP) : Super(PCIP)
{
  TArray<int> array;
  array.Add( 1 );
  array.Add( 10 );
  array.Add( 5 );
  array.Add( 20 );
  array.Insert( 9, 0 );// put a 9 in the front
  array.Insert( 30, 2 );// put a 30 at index 2
  if( GEngine )
  {
    for( int index = 0; index < array.Num(); index++ )
    {
      GEngine->AddOnScreenDebugMessage( index, 30.f, FColor::Red,  FString::FromInt( array[ index ] ) );
    }
  }
}

上述代码的输出如下所示:

迭代器

在 TArray 中查找元素是否存在

在 UE4 中搜索容器非常简单。通常使用 Find 成员函数来完成。使用我们之前创建的数组,我们可以通过以下代码行找到值 10 的索引:

int index = array.Find( 10 ); // would be index 3 in image above

TSet

TSet<int> 变量存储一组整数。TSet<FString> 变量存储一组字符串。TSetTArray 之间的主要区别在于 TSet 不允许重复——TSet 内部的所有元素都保证是唯一的。TArray 变量不介意相同元素的重复。

要向 TSet 添加数字,只需调用 Add。以下是一个示例声明:

TSet<int> set;
set.Add( 1 );
set.Add( 2 );
set.Add( 3 );
set.Add( 1 );// duplicate! won't be added
set.Add( 1 );// duplicate! won't be added

这就是 TSet 在以下图中的样子:

TSet

TSet 中不允许有相同值的重复条目。注意 TSet 中的条目没有编号,就像在 TArray 中那样:你无法使用方括号来访问 TSet 数组中的条目。

遍历 TSet

为了查看 TSet 数组,你必须使用迭代器。你不能使用方括号表示法来访问 TSet 的元素:

int count = 0;	// keep track of numerical index in set
for( TSet<int>::TIterator it = set.CreateIterator(); it; ++it )
{
  GEngine->AddOnScreenDebugMessage( count++, 30.f, FColor::Red,  FString::FromInt( *it ) );
}

交集 TSet

TSet 数组有两个特殊函数,而 TArray 变量没有。两个 TSet 数组的交集基本上是它们共有的元素。如果我们有两个 TSet 数组,如 XY,并且我们将它们相交,结果将是一个新的第三个 TSet 数组,它只包含它们共有的元素。查看以下示例:

TSet<int> X;
X.Add( 1 );
X.Add( 2 );
X.Add( 3 );
TSet<int> Y;
Y.Add( 2 );
Y.Add( 4 );
Y.Add( 8 );
TSet<int> common = X.Intersect(Y); // 2

XY 之间的公共元素将是元素 2

并集 TSet

从数学上讲,两个集合的并集就是将所有元素插入到同一个集合中。由于我们在这里讨论的是集合,所以不会有任何重复。

如果我们从上一个示例中的 XY 集合创建一个并集,我们将得到一个新的集合,如下所示:

TSet<int> uni = X.Union(Y); // 1, 2, 3, 4, 8

查找 TSet

你可以通过在集合上使用 Find() 成员函数来确定一个元素是否在 TSet 中。如果元素存在于 TSet 中,TSet 将返回匹配查询的 TSet 中的条目指针;如果请求的元素不存在于 TSet 中,它将返回 NULL

TMap<T, S>

TMap<T, S> 创建了一种在 RAM 中的表格。TMap 表示将左侧的键映射到右侧的值。你可以将 TMap 视为一个两列的表格,其中键位于左侧列,值位于右侧列。

玩家物品清单

例如,假设我们想要创建一个 C++ 数据结构来存储玩家的物品清单。在表格的左侧(键),我们会有一个 FString 用于物品的名称。在右侧(值),我们会有一个 int 用于该物品的数量。

项目(键) 数量(值)
苹果 4
饼干 12
1
防护盾 2

要在代码中实现这一点,我们只需使用以下代码:

TMap<FString, int> items;
items.Add( "apples", 4 );
items.Add( "donuts", 12 );
items.Add( "swords", 1 );
items.Add( "shields", 2 );

一旦你创建了你的 TMap,你可以通过使用方括号并传递一个键到括号之间来访问 TMap 内的值。例如,在上面的代码中的 items 映射中,items[ "apples" ] 的值是 4。

提示

如果你使用方括号访问映射中尚不存在的键,UE4 将会崩溃,所以请小心!C++ STL 在这样做时不会崩溃。

迭代 TMap

为了迭代 TMap,你也需要使用迭代器:

for( TMap<FString, int>::TIterator it = items.CreateIterator(); it; ++it )
{
  GEngine->AddOnScreenDebugMessage( count++, 30.f, FColor::Red,
  it->Key + FString(": ") + FString::FromInt( it->Value ) );
}

TMap 迭代器与 TArrayTSet 迭代器略有不同。TMap 迭代器包含一个 Key 和一个 Value。我们可以使用 it->Key 访问内部的键,以及使用 it->Value 访问 TMap 内部的值。

迭代 TMap

常用容器的 C++ STL 版本

我想介绍几个容器的 C++ STL 版本。STL 是标准模板库,大多数 C++ 编译器都附带它。我想介绍这些 STL 版本的原因是它们的行为与 UE4 中相同容器的行为略有不同。在某些方面,它们的行为非常好,但游戏程序员经常抱怨 STL 存在性能问题。特别是,我想介绍 STL 的 setmap 容器。

注意

如果你喜欢 STL 的接口但想要更好的性能,有一个由电子艺界(Electronic Arts)实现的 STL 库的知名重实现,称为 EASTL,你可以使用它。它提供了与 STL 相同的功能,但实现了更好的性能(基本上是通过消除边界检查等方式)。它可在 GitHub 上找到:github.com/paulhodge/EASTL

C++ STL 集合

C++ set 是一些独特且排序的项。关于 STL set 的优点是它保持了集合元素的排序。快速排序一串值的一种简单方法是只是将它们放入同一个 set 中。set 会为您处理排序。

我们可以回到一个简单的 C++ 控制台应用程序来使用集合。要使用 C++ STL set,您需要包含 <set>,如下所示:

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

int main()
{
  set<int> intSet;
  intSet.insert( 7 );
  intSet.insert( 7 );
  intSet.insert( 8 );
  intSet.insert( 1 );

  for( set<int>::iterator it = intSet.begin(); it != intSet.end();  ++it )
  {
    cout << *it << endl;
  }
}

下面的代码是前一个代码的输出:

1
7
8

重复的 7 被过滤掉,元素在 set 中按递增顺序保持。我们遍历 STL 容器元素的方式与 UE4 的 TSet 数组类似。intSet.begin() 函数返回一个指向 intSet 头部的迭代器。

停止迭代的条件是当 it 成为 intSet.end()intSet.end() 实际上是 set 结束之后的一个位置,如下面的图所示:

C++ STL set

<set> 中查找元素

要在 STL set 中查找一个元素,我们可以使用 find() 成员函数。如果我们正在寻找的项目在 set 中,我们会得到一个指向我们正在搜索的元素的迭代器。如果我们正在寻找的项目不在 set 中,我们会得到 set.end(),如下所示:

set<int>::iterator it = intSet.find( 7 );
if( it != intSet.end() )
{
  //  7  was inside intSet, and *it has its value
  cout << "Found " << *it << endl;
}

练习

询问用户一组三个独特的名字。逐个输入每个名字,然后按顺序打印它们。如果用户重复一个名字,则要求他们输入另一个名字,直到您得到三个。

解决方案

前一个练习的解决方案可以使用以下代码找到:

#include <iostream>
#include <string>
#include <set>
using namespace std;
int main()
{
  set<string> names;
  // so long as we don't have 3 names yet, keep looping,
  while( names.size() < 3 )
  {
    cout << names.size() << " names so far. Enter a name" << endl;
    string name;
    cin >> name;
    names.insert( name ); // won't insert if already there,
  }
  // now print the names. the set will have kept order
  for( set<string>::iterator it = names.begin(); it !=  names.end(); ++it )
  {
    cout << *it << endl;
  }
}

C++ STL map

C++ STL map 对象与 UE4 的 TMap 对象非常相似。它所做的唯一一件事是 TMap 不做的是在映射内部保持排序顺序。排序引入了额外的成本,但如果您希望您的映射是排序的,选择 STL 版本可能是一个不错的选择。

要使用 C++ STL map 对象,我们需要包含 <map>。在以下示例程序中,我们使用一些键值对填充项目映射:

#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
  map<string, int> items;
  items.insert( make_pair( "apple", 12 ) );
  items.insert( make_pair( "orange", 1 ) );
  items.insert( make_pair( "banana", 3 ) );
  // can also use square brackets to insert into an STL map
  items[ "kiwis" ] = 44;

  for( map<string, int>::iterator it = items.begin(); it !=  items.end(); ++it )
  {
    cout << "items[ " << it->first << " ] = " << it->second <<  endl;
  }
}

这是前一个程序的输出:

items[ apple ] = 12
items[ banana ] = 3
items[ kiwis ] = 44
items[ orange ] = 1

注意到 STL map 的迭代器语法与 TMap 的语法略有不同:我们使用 it->first 访问键,使用 it->second 访问值。

注意到 C++ STL 还在 TMap 上提供了一些语法糖;您可以使用方括号来插入 C++ STL map。您不能使用方括号来插入 TMap

<map> 中查找元素

您可以使用 STL map 的 find 成员函数在映射中搜索一个 <key, value> 对。

练习

询问用户将五个项目和它们的数量输入到一个空的 map 中。按顺序打印结果。

解决方案

前一个练习的解决方案使用了以下代码:

#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
  map<string, int> items;
  cout << "Enter 5 items, and their quantities" << endl;
  while( items.size() < 5 )
  {
    cout << "Enter item" << endl;
    string item;
    cin >> item;
    cout << "Enter quantity" << endl;
    int qty;
    cin >> qty;
    items[ item ] = qty; // save in map, square brackets
    // notation
  }

  for( map<string, int>::iterator it = items.begin(); it !=  items.end(); ++it )
  {
    cout << "items[ " << it->first << " ] = " << it->second <<  endl;
  }
}

在这个解决方案代码中,我们首先创建 map<string, int> items 来存储我们打算取的所有项目。询问用户一个项目和数量;然后我们使用方括号符号将 item 保存到 items 映射中。

概述

UE4 的容器和 C++ STL 家族的容器都非常适合存储游戏数据。通常,通过选择合适的数据容器,编程问题可以简化很多次。

在下一章中,我们将通过跟踪玩家携带的物品并将这些信息存储在TMap对象中,实际上开始编写我们游戏的开头部分。

第十章. 背包系统和拾取物品

我们希望玩家能够从游戏世界中拾取物品。在本章中,我们将为玩家编写和设计一个背包来存储物品。当用户按下I键时,我们将显示背包中玩家所携带的物品。

作为数据表示,我们可以使用上一章中介绍的TMap<FString, int>物品映射来存储我们的物品。当玩家拾取一个物品时,我们将其添加到映射中。如果该物品已经在映射中,我们只需将其数量增加新拾取的物品的数量。

声明背包

我们可以将玩家的背包表示为一个简单的TMap<FString, int>物品映射。为了允许玩家从世界中收集物品,打开Avatar.h文件并添加以下TMap声明:

class APickupItem; //  forward declare the APickupItem class,
                   // since it will be "mentioned" in a member  function decl below
UCLASS()
class GOLDENEGG_API AAvatar : public ACharacter
{
  GENERATED_UCLASS_BODY()

  // A map for the player's backpack
  TMap<FString, int> Backpack;

  // The icons for the items in the backpack, lookup by string
  TMap<FString, UTexture2D*> Icons;

  // A flag alerting us the UI is showing
  bool inventoryShowing;
  // member function for letting the avatar have an item
  void Pickup( APickupItem *item );
  // ... rest of Avatar.h same as before
};

前向声明

AAvatar类之前,请注意我们有一个class APickupItem前向声明。当在代码文件中提及一个类时(例如,APickupItem::Pickup(APickupItem *item);函数原型),需要前向声明,但文件中实际上没有使用该类型的对象。由于Avatar.h头文件不包含使用APickupItem类型对象的可执行代码,因此我们需要前向声明。

如果没有前向声明,将会出现编译错误,因为编译器在编译class AAvatar中的代码之前没有听说过class APickupItem。编译错误将在APickupItem::Pickup(APickupItem *item);函数原型声明时出现。

我们在AAvatar类内部声明了两个TMap对象。这些对象的外观如下表所示:

FString(名称) int(数量) UTexture2D*(图像)
金蛋 2 前向声明
金属甜甜圈 1 前向声明
2 前向声明

TMap背包中,我们存储玩家持有的物品的FString变量。在Icons映射中,我们存储玩家持有的物品的图像的单个引用。

在渲染时,我们可以使用两个映射协同工作来查找玩家拥有的物品数量(在他的Backpack映射中)以及该物品的纹理资产引用(在Icons映射中)。以下截图显示了 HUD 渲染的外观:

前向声明

注意

注意,我们也可以使用一个包含FString变量和UTexture2D*struct数组来代替使用两个映射。

例如,我们可以保持TArray<Item> Backpack;与一个struct变量,如下面的代码所示:

struct Item
{
  FString name;
  int qty;
  UTexture2D* tex;
};

然后,当我们拾取物品时,它们将被添加到线性数组中。然而,要计算背包中每种物品的数量,每次查看计数时都需要通过迭代数组中的物品进行重新评估。例如,要查看你有多少把梳子,你需要遍历整个数组。这不如使用映射高效。

导入资源

你可能已经注意到了前一个屏幕截图中的 Cow 资源,它不是 UE4 在新项目中提供的标准资源集的一部分。为了使用 Cow 资源,你需要从 内容示例 项目中导入牛。UE4 使用一个标准的导入过程。

在以下屏幕截图中,我概述了导入 Cow 资料的步骤。其他资源将以相同的方法从 UE4 的其他项目中导入。按照以下步骤导入 Cow 资料:

  1. 下载并打开 UE4 的 内容示例 项目:导入资源

  2. 下载完 内容示例 后,打开它并点击 创建项目导入资源

  3. 接下来,命名你将放置 ContentExamples 的文件夹,然后点击 创建

  4. 从库中打开你的 ContentExamples 项目。浏览项目中的资源,直到找到一个你喜欢的。由于所有静态网格通常以 SM_ 开头,所以搜索 SM_ 会很有帮助。导入资源

    以 SM_ 开头的静态网格列表

  5. 当你找到一个你喜欢的资源时,通过右键单击资源然后点击 迁移... 将其导入到你的项目中:导入资源

  6. 资源报告 对话框中点击 确定导入资源

  7. 从你的项目中选择你想要添加 SM_Door 文件的 内容 文件夹。对我来说,我想将其添加到 Y:/Unreal Projects/GoldenEgg/Content,如下截图所示:导入资源

  8. 如果导入成功,你将看到如下消息:导入资源

  9. 导入你的资源后,你将看到它在你的项目资源浏览器中显示:导入资源

你可以在项目中正常使用该资源。

将动作映射附加到键上

我们需要将一个键附加到激活玩家库存显示的功能。在 UE4 编辑器中,添加一个名为 Inventory动作映射 + 并将其分配给键盘键 I

将动作映射附加到键上

Avatar.h 文件中,添加一个成员函数,当玩家的库存需要显示时运行:

void ToggleInventory();

Avatar.cpp 文件中,实现 ToggleInventory() 函数,如下代码所示:

void AAvatar::ToggleInventory()
{
  if( GEngine )
  {
    GEngine->AddOnScreenDebugMessage( 0, 5.f, FColor::Red,  "Showing inventory..." );
  }
}

然后,在 SetupPlayerInputComponent() 中将 "Inventory" 动作连接到 AAvatar::ToggleInventory():

void AAvatar::SetupPlayerInputComponent(class UInputComponent*  InputComponent)
{
  InputComponent->BindAction( "Inventory", IE_Pressed, this,  &AAvatar::ToggleInventory );
  // rest of SetupPlayerInputComponent same as before
}

基类 PickupItem

我们需要在代码中定义拾取物品的外观。每个拾取物品都将从一个公共基类派生。现在让我们为 PickupItem 类构造一个基类。

PickupItem 基类应该继承自 AActor 类。类似于我们从基础 NPC 类创建多个 NPC 蓝图的方式,我们可以从一个单一的 PickupItem 基类创建多个 PickupItem 蓝图,如下面的截图所示:

基类 PickupItem

一旦创建了 PickupItem 类,就在 Visual Studio 中打开其代码。

APickupItem 类将需要相当多的成员,如下所示:

  • 用于表示拾取物品名称的 FString 变量

  • 用于表示拾取物品数量的 int32 变量

  • 用于与拾取物品发生碰撞的球体的 USphereComponent 变量

  • 用于保存实际网格的 UStaticMeshComponent 变量

  • 用于表示物品图标的 UTexture2D 变量

  • 指向 HUD(我们将在稍后初始化)

这就是 PickupItem.h 中的代码看起来:

UCLASS()
class GOLDENEGG_API APickupItem : public AActor
{
  GENERATED_UCLASS_BODY()

  // The name of the item you are getting
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Item)
  FString Name;

  // How much you are getting
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Item)
  int32 Quantity;

  // the sphere you collide with to pick item up
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Item)
  TSubobjectPtr<USphereComponent> ProxSphere;

  // The mesh of the item
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Item)
  TSubobjectPtr<UStaticMeshComponent> Mesh;

  // The icon that represents the object in UI/canvas
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Item)
  UTexture2D* Icon;

  // When something comes inside ProxSphere, this function runs
  UFUNCTION(BlueprintNativeEvent, Category = Collision)
  void Prox( AActor* OtherActor, UPrimitiveComponent* OtherComp,  int32 OtherBodyIndex, bool bFromSweep, const FHitResult &  SweepResult );
};

所有这些 UPROPERTY() 声明的目的是使 APickupItem 可以通过蓝图完全配置。例如,拾取 类别的物品在蓝图编辑器中将显示如下:

基类 PickupItem

PickupItem.cpp 文件中,我们完成了 APickupItem 类的构造函数,如下面的代码所示:

APickupItem::APickupItem(const class FPostConstructInitializeProperties& PCIP) : Super(PCIP)
{
  Name = "UNKNOWN ITEM";
  Quantity = 0;

  // initialize the unreal objects
  ProxSphere = PCIP.CreateDefaultSubobject<USphereComponent>(this,  TEXT("ProxSphere"));
  Mesh = PCIP.CreateDefaultSubobject<UStaticMeshComponent>(this,  TEXT("Mesh"));

  // make the root object the Mesh
  RootComponent = Mesh;
  Mesh->SetSimulatePhysics(true);

  // Code to make APickupItem::Prox() run when this
  // object's proximity sphere overlaps another actor.
  ProxSphere->OnComponentBeginOverlap.AddDynamic(this,  &APickupItem::Prox);
  ProxSphere->AttachTo( Mesh ); // very important!	
}

在前两行中,我们对 NameQuantity 进行了初始化,这些值应该对游戏设计师来说显得未初始化。我使用了大写字母块,以便设计师可以清楚地看到变量之前从未被初始化过。

然后,我们使用 PCIP.CreateDefaultSubobject 初始化 ProxSphereMesh 组件。新初始化的对象可能有一些默认值被初始化,但 Mesh 将从空开始。你必须在蓝图内部稍后加载实际的网格。

对于网格,我们将其设置为模拟真实物理,以便拾取物品在掉落或移动时可以弹跳和滚动。特别注意 ProxSphere->AttachTo( Mesh ) 这一行。这一行告诉你要确保拾取物品的 ProxSphere 组件附加到 Mesh 根组件上。这意味着当网格在级别中移动时,ProxSphere 会跟随。如果你忘记了这个步骤(或者如果你是反过来的),那么当 ProxSphere 弹跳时,它将不会跟随网格。

根组件

在前面的代码中,我们将 APickupItemRootComponent 赋值给了 Mesh 对象。RootComponent 成员是 AActor 基类的一部分,因此每个 AActor 及其派生类都有一个根组件。根组件基本上是对象的核心,并定义了如何与对象碰撞。RootComponent 对象在 Actor.h 文件中定义,如下面的代码所示:

/**
 * Collision primitive that defines the transform (location,  rotation, scale) of this Actor.
 */
UPROPERTY()
class USceneComponent* RootComponent;

因此,UE4 的创建者打算让RootComponent始终是碰撞原语的引用。有时碰撞原语可以是胶囊形状,有时可以是球形,甚至可以是箱形,或者可以是任意形状,就像我们的情况一样,使用网格。然而,一个角色应该有一个箱形的根组件是很少见的,因为箱子的角可能会卡在墙上。圆形形状通常更受欢迎。RootComponent属性出现在蓝图中,在那里你可以看到并操作它。

根组件

一旦基于 PickupItem 类创建蓝图,你就可以从其蓝图编辑 ProxSphere 根组件

最后,按照以下方式实现Prox_Implementation函数:

void APickupItem::Prox_Implementation( AActor* OtherActor,  UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool  bFromSweep, const FHitResult & SweepResult )
{
  // if the overlapped actor is NOT the player,
  // you simply should return
  if( Cast<AAvatar>( OtherActor ) == nullptr )
  {
    return;
  }

  // Get a reference to the player avatar, to give him
  // the item
  AAvatar *avatar = Cast<AAvatar>(  UGameplayStatics::GetPlayerPawn( GetWorld(), 0 ) );

  // Let the player pick up item
  // Notice use of keyword this!
  // That is how _this_ Pickup can refer to itself.
  avatar->Pickup( this );

  // Get a reference to the controller
  APlayerController* PController = GetWorld()- >GetFirstPlayerController();

  // Get a reference to the HUD from the controller
  AMyHUD* hud = Cast<AMyHUD>( PController->GetHUD() );
  hud->addMessage( Message( Icon, FString("Picked up ") + FString::FromInt(Quantity) + FString(" ") + Name, 5.f, FColor::White, FColor::Black ) );

  Destroy();
}

这里有一些相当重要的提示:首先,我们必须访问一些全局变量来获取我们需要的对象。我们将通过这些函数访问三个主要对象,这些函数用于操作 HUD:控制器(APlayerController)、HUD(AMyHUD)和玩家本人(AAvatar)。在游戏实例中,每种类型的对象只有一个。UE4 已经使查找它们变得容易。

获取头像

可以通过简单地调用以下代码在任何代码位置找到player类对象:

AAvatar *avatar = Cast<AAvatar>(
  UGameplayStatics::GetPlayerPawn( GetWorld(), 0 ) );

我们通过调用之前定义的AAvatar::Pickup()函数将物品传递给他。

因为PlayerPawn对象实际上是一个AAvatar实例,我们使用Cast<AAvatar>命令将其结果转换为AAvatar类。UGameplayStatics函数族可以在代码的任何地方访问——它们是全局函数。

获取玩家控制器

超级全局变量中检索玩家控制器:

APlayerController* PController =
  GetWorld()->GetFirstPlayerController();

GetWorld()函数实际上是在UObject基类中定义的。由于所有 UE4 对象都从UObject派生,游戏中的任何对象实际上都可以访问world对象。

获取 HUD

虽然这个组织一开始可能看起来很奇怪,但实际上 HUD 是附着在玩家控制器上的。你可以按照以下方式检索 HUD:

AMyHUD* hud = Cast<AMyHUD>( PController->GetHUD() );

由于我们之前在蓝图中将 HUD 设置为AMyHUD实例,我们可以将 HUD 对象转换为类型。由于我们经常使用 HUD,我们实际上可以在我们的APickupItem类中存储一个指向 HUD 的永久指针。我们将在稍后讨论这一点。

接下来,我们实现AAvatar::Pickup,它将APickupItem类型的对象添加到头像的背包中:

void AAvatar::Pickup( APickupItem *item )
{
  if( Backpack.Find( item->Name ) )
  {
    // the item was already in the pack.. increase qty of it
    Backpack[ item->Name ] += item->Quantity;
  }
  else
  {
    // the item wasn't in the pack before, add it in now
    Backpack.Add(item->Name, item->Quantity);
    // record ref to the tex the first time it is picked up
    Icons.Add(item->Name, item->Icon);
  }
}

在前面的代码中,我们检查玩家刚刚获得的拾取物品是否已经在他的背包中。如果是,我们增加其数量。如果没有在他的背包中,我们将其添加到他的背包和Icons映射中。

要将拾取物品添加到背包中,请使用以下代码行:

avatar->Pickup( this );

APickupItem::Prox_Implementation是这个成员函数将被调用的方式。

现在,当玩家按下I键时,我们需要在 HUD 中显示背包的内容。

绘制玩家库存

在像Diablo这样的游戏中,库存屏幕具有一个弹出窗口,过去捡到的物品图标以网格形式排列。我们可以在 UE4 中实现这种行为。

在 UE4 中绘制 UI 有几种方法。最基本的方法是简单地使用HUD::DrawTexture()调用。另一种方法是使用 Slate。还有另一种方法是使用最新的 UE4 UI 功能:Unreal Motion GraphicsUMG)设计器。

Slate 使用声明性语法在 C++中布局 UI 元素。Slate 最适合菜单等。UMG 是 UE 4.5 中引入的,它使用基于蓝图的工作流程。由于我们这里的重点是使用 C++代码的练习,我们将坚持使用HUD::DrawTexture()实现。这意味着我们将在代码中管理所有与库存相关的数据。

使用HUD::DrawTexture()

我们将分两步实现这一点。第一步是在用户按下I键时将我们的库存内容推送到 HUD。第二步是以网格状方式将图标实际渲染到 HUD 上。

为了保留有关如何渲染小部件的所有信息,我们声明一个简单的结构来保存有关它使用的图标、当前位置和当前大小的信息。

这就是IconWidget结构体的样子:

struct Icon
{
  FString name;
  UTexture2D* tex;
  Icon(){ name = "UNKNOWN ICON"; tex = 0; }
  Icon( FString& iName, UTexture2D* iTex )
  {
    name = iName;
    tex = iTex;
  }
};

struct Widget
{
  Icon icon;
  FVector2D pos, size;
  Widget(Icon iicon)
  {
    icon = iicon;
  }
  float left(){ return pos.X; }
  float right(){ return pos.X + size.X; }
  float top(){ return pos.Y; }
  float bottom(){ return pos.Y + size.Y; }
};

您可以将这些结构声明添加到MyHUD.h的顶部,或者将它们添加到单独的文件中,并在使用这些结构的地方包含该文件。

注意Widget结构体上的四个成员函数,通过这些函数可以访问小部件的left()right()top()bottom()函数。我们稍后会使用这些函数来确定点击点是否在框内。

接下来,我们在AMyHUD类中声明一个函数,该函数将在屏幕上渲染小部件:

void AMyHUD::DrawWidgets()
{
  for( int c = 0; c < widgets.Num(); c++ )
  {
    DrawTexture( widgets[c].icon.tex, widgets[c].pos.X,  widgets[c].pos.Y, widgets[c].size.X, widgets[c].size.Y, 0, 0,  1, 1 );
    DrawText( widgets[c].icon.name, FLinearColor::Yellow,  widgets[c].pos.X, widgets[c].pos.Y, hudFont, .6f, false );
  }
}

应将DrawWidgets()函数的调用添加到DrawHUD()函数中:

void AMyHUD::DrawHUD()
{
  Super::DrawHUD();
  // dims only exist here in stock variable Canvas
  // Update them so use in addWidget()
  dims.X = Canvas->SizeX;
  dims.Y = Canvas->SizeY;
  DrawMessages();
  DrawWidgets();
}

接下来,我们将填充ToggleInventory()函数。这是当用户按下I键时运行的函数:

void AAvatar::ToggleInventory()
{
  // Get the controller & hud
  APlayerController* PController = GetWorld()- >GetFirstPlayerController();
  AMyHUD* hud = Cast<AMyHUD>( PController->GetHUD() );

  // If inventory is displayed, undisplay it.
  if( inventoryShowing )
  {
    hud->clearWidgets();
    inventoryShowing = false;
    PController->bShowMouseCursor = false;
    return;
  }

  // Otherwise, display the player's inventory
  inventoryShowing = true;
  PController->bShowMouseCursor = true;
  for( TMap<FString,int>::TIterator it =  Backpack.CreateIterator(); it; ++it )
  {
    // Combine string name of the item, with qty eg Cow x 5
    FString fs = it->Key + FString::Printf( TEXT(" x %d"), it- >Value );
    UTexture2D* tex;
    if( Icons.Find( it->Key ) )
      tex = Icons[it->Key];
    hud->addWidget( Widget( Icon( fs, tex ) ) );
  }
}

为了使前面的代码能够编译,我们需要在AMyHUD中添加一个函数:

void AMyHUD::addWidget( Widget widget )
{
  // find the pos of the widget based on the grid.
  // draw the icons..
  FVector2D start( 200, 200 ), pad( 12, 12 );
  widget.size = FVector2D( 100, 100 );
  widget.pos = start;
  // compute the position here
  for( int c = 0; c < widgets.Num(); c++ )
  {
    // Move the position to the right a bit.
    widget.pos.X += widget.size.X + pad.X;
    // If there is no more room to the right then
    // jump to the next line
    if( widget.pos.X + widget.size.X > dims.X )
    {
      widget.pos.X = start.X;
      widget.pos.Y += widget.size.Y + pad.Y;
    }
  }
  widgets.Add( widget );
}

我们继续使用Boolean变量inventoryShowing来告诉我们库存是否当前显示。当库存显示时,我们也会显示鼠标,以便用户知道他在点击什么。此外,当库存显示时,玩家的自由移动被禁用。禁用玩家的自由移动的最简单方法是在实际移动之前从移动函数中返回。以下代码是一个示例:

void AAvatar::Yaw( float amount )
{
  if( inventoryShowing )
  {
    return; // when my inventory is showing,
    // player can't move
  }
  AddControllerYawInput(200.f*amount * GetWorld()- >GetDeltaSeconds());
}

练习

使用if( inventoryShowing ) { return; }短路返回来检查每个移动函数。

检测库存物品点击

我们可以通过进行简单的点在框内碰撞检测来检测是否有人点击了我们的库存物品。点在框内测试是通过检查点击点与框内容的对比来完成的。

将以下成员函数添加到struct Widget中:

struct Widget
{
  // .. rest of struct same as before ..
  bool hit( FVector2D p )
  {
    // +---+ top (0)
    // |   |
    // +---+ bottom (2) (bottom > top)
    // L   R
    return p.X > left() && p.X < right() && p.Y > top() && p.Y <  bottom();
  }
};

点在框内测试如下:

检测库存项目点击

因此,如果 p.X 是以下所有情况,则视为命中:

  • 右侧为 left() (p.X > left())

  • 左侧为 right() (p.X < right())

  • 以下 top() (p.Y > top())

  • 上述 bottom() (p.Y < bottom())

记住,在 UE4(以及一般 UI 渲染)中,y 轴是反转的。换句话说,在 UE4 中 y 是向下的。这意味着 top() 小于 bottom(),因为原点((0, 0) 点)位于屏幕的左上角。

拖动元素

我们可以轻松地拖动元素。启用拖动的第一步是响应左鼠标按钮点击。首先,我们将编写当左鼠标按钮被点击时执行的函数。在 Avatar.h 文件中,向类声明中添加以下原型:

void MouseClicked();

Avatar.cpp 文件中,我们可以附加一个在鼠标点击时执行的函数,并将点击请求传递给 HUD,如下所示:

void AAvatar::MouseClicked()
{
  APlayerController* PController = GetWorld()- >GetFirstPlayerController();
  AMyHUD* hud = Cast<AMyHUD>( PController->GetHUD() );
  hud->MouseClicked();
}

然后,在 AAvatar::SetupPlayerInputComponent 中,我们必须附加我们的响应者:

InputComponent->BindAction( "MouseClickedLMB", IE_Pressed, this,  &AAvatar::MouseClicked );

以下截图显示了如何附加渲染:

拖动元素

AMyHUD 类添加一个成员:

Widget* heldWidget;  // hold the last touched Widget in memory

接下来,在 AMyHUD::MouseClicked() 中,我们开始搜索被击中的 Widget

void AMyHUD::MouseClicked()
{
  FVector2D mouse;
  PController->GetMousePosition( mouse.X, mouse.Y );
  heldWidget = NULL; // clear handle on last held widget
  // go and see if mouse xy click pos hits any widgets
  for( int c = 0; c < widgets.Num(); c++ )
  {
    if( widgets[c].hit( mouse ) )
    {
      heldWidget = &widgets[c];// save widget
      return;                  // stop checking
    }
  }
}

AMyHUD::MouseClicked 函数中,我们遍历屏幕上的所有小部件,并检查与当前鼠标位置的碰撞。您可以通过简单地查找 PController->GetMousePosition() 在任何时间从控制器中获取当前鼠标位置。

每个小部件都会与当前鼠标位置进行比对,一旦鼠标拖动,被鼠标点击的小部件将会移动。一旦我们确定了哪个小部件被点击,我们就可以停止检查,因此我们从 MouseClicked() 函数中有一个 return 值。

虽然击中小部件是不够的。我们需要在鼠标移动时拖动被击中的小部件。为此,我们需要在 AMyHUD 中实现一个 MouseMoved() 函数:

void AMyHUD::MouseMoved()
{
  static FVector2D lastMouse;
  FVector2D thisMouse, dMouse;
  PController->GetMousePosition( thisMouse.X, thisMouse.Y );
  dMouse = thisMouse - lastMouse;
  // See if the left mouse has been held down for
  // more than 0 seconds. if it has been held down,
  // then the drag can commence.
  float time = PController->GetInputKeyTimeDown(  EKeys::LeftMouseButton );
  if( time > 0.f && heldWidget )
  {
    // the mouse is being held down.
    // move the widget by displacement amt
    heldWidget->pos.X += dMouse.X;
    heldWidget->pos.Y += dMouse.Y; // y inverted
  }
  lastMouse = thisMouse;
}

不要忘记在 MyHUD.h 文件中包含声明。

拖动函数会查看鼠标位置在上一帧和当前帧之间的差异,并通过该量移动选定的小部件。一个 static 变量(具有局部作用域的全局变量)用于在 MouseMoved() 函数调用之间记住 lastMouse 位置。

我们如何将鼠标的运动与 AMyHUD 中的 MouseMoved() 函数运行联系起来?如果您记得,我们已经在 Avatar 类中连接了鼠标运动。我们使用的两个函数是 AAvatar::Pitch()(y 轴)和 AAvatar::Yaw()(x 轴)。扩展这些函数将使您能够将鼠标输入传递到 HUD。我现在将向您展示 Yaw 函数,然后您可以从那里推断出 Pitch 将如何工作:

void AAvatar::Yaw( float amount )
{
  //x axis
  if( inventoryShowing )
  {
    // When the inventory is showing,
    // pass the input to the HUD
    APlayerController* PController = GetWorld()- >GetFirstPlayerController();
    AMyHUD* hud = Cast<AMyHUD>( PController->GetHUD() );
    hud->MouseMoved();
    return;
  }
  else
  {
    AddControllerYawInput(200.f*amount * GetWorld()- >GetDeltaSeconds());
  }
}

AAvatar::Yaw() 函数首先检查库存是否显示。如果显示,输入将直接路由到 HUD,而不会影响 Avatar。如果 HUD 不显示,输入仅发送到 Avatar

练习

  1. 完成AAvatar::Pitch()函数(y 轴)以将输入路由到 HUD 而不是Avatar

  2. 将第八章中的 NPC 角色“演员与棋子”,在玩家靠近时给予玩家一个物品(例如GoldenEgg)。

摘要

在本章中,我们介绍了如何为玩家设置多个可拾取物品,以便在关卡中显示并拾取。在下一章中,我们将介绍“怪物”,玩家将能够使用魔法咒语来防御怪物。

第十一章。怪物

我们将为玩家添加一些对手。

在本章中,我所做的是给示例添加了一片风景。玩家将沿着为他雕刻出的路径行走,然后他将遇到一支军队。在他到达军队之前有一个 NPC 会提供建议。

怪物

场景:开始看起来像一款游戏

景观

我们还没有在本书中介绍如何雕刻风景,但我们将在这里介绍。首先,你必须有一个可以工作的风景。通过导航到文件 | 新建来开始一个新文件。你可以选择一个空关卡或一个带有天空的关卡。在这个例子中,我选择了没有天空的那个。

要创建风景,我们必须从模式面板开始工作。确保通过导航到窗口 | 模式来显示模式面板:

风景

显示模式面板

创建风景可以分三步完成,如下面的屏幕截图所示,然后是相应的步骤:

风景

  1. 点击模式面板中的景观图标(山脉的图片)。

  2. 点击管理按钮。

  3. 接下来,点击屏幕右下角的创建按钮。

现在,你应该有一个可以工作的风景。它将出现在主窗口中的灰色、拼贴区域:

景观

你首先想对你的风景场景做的事情是给它添加一些颜色。没有颜色的风景是什么?在你的灰色、拼贴的风景对象上右击。在右侧的详细信息面板中,你会看到它充满了信息,如下面的屏幕截图所示:

风景

滚动直到你看到景观材质属性。你可以选择M_Ground_Grass材质来创建一个看起来逼真的地面。

接下来,向场景中添加一个光源。你可能想使用一个方向光源,这样所有的地面都会有一些光照。

雕刻风景

平坦的风景可能会很无聊。我们至少会给这个地方添加一些曲线和山丘。要做到这一点,请点击模式面板中的雕刻按钮:

雕刻风景

要更改风景,请点击雕刻按钮

您画笔的强度和大小由模式窗口中的画笔大小工具强度参数决定。

点击您的风景并拖动鼠标以改变草地的海拔。一旦你对结果满意,点击播放按钮来尝试它。结果可以在以下屏幕截图中看到:

雕刻风景

在你的风景上玩一玩,创建一个场景。我所做的是降低平坦地面周围的风景,这样玩家就有了一个定义明确的平坦区域可以行走,如下面的屏幕截图所示:

雕刻风景

随意对你的景观进行任何你喜欢的操作。如果你喜欢,你可以用我在这里做的作为灵感。我建议你从ContentExamples或从StrategyGame导入资源,以便在游戏中使用。为此,请参考第十章中的导入资源部分,库存系统和拾取物品。当你完成资源导入后,我们可以继续将怪物引入你的世界。

怪物

我们将以与编程 NPC 和PickupItem相同的方式开始编程怪物。首先,我们将编写一个基类(通过从Character派生)来表示Monster类。然后,我们将为每种怪物类型派生许多蓝图。每个怪物都将有一些共同属性,这些属性决定了它的行为。这些是共同属性:

  • 一个表示速度的float变量。

  • 一个表示HitPoints值的float变量(我通常使用浮点数表示 HP,这样我们就可以轻松地模拟 HP 吸血效果,例如走过一池熔岩)。

  • 一个表示击败怪物获得的经验值的int32变量。

  • 一个用于怪物掉落物品的UClass函数。

  • 每次攻击时对BaseAttackDamage进行的float变量。

  • 一个表示攻击超时的float变量,即怪物在攻击之间的休息时间。

  • 两个USphereComponents对象:其中一个为SightSphere——他能看到多远。另一个是AttackRangeSphere,表示他的攻击能到达多远。AttackRangeSphere对象总是小于SightSphere

Character类派生以创建你的Monster类。你可以在 UE4 中通过转到文件 | 将代码添加到项目...,然后从菜单中选择你的基类Character选项来完成此操作。

Monster类中填写基础属性。确保声明UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = MonsterProperties),这样可以在蓝图上更改怪物的属性:

UCLASS()
class GOLDENEGG_API AMonster : public ACharacter
{
  GENERATED_UCLASS_BODY()

  // How fast he is
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
  float Speed;

  // The hitpoints the monster has
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
  float HitPoints;

  // Experience gained for defeating
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
  int32 Experience;

  // Blueprint of the type of item dropped by the monster
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
  UClass* BPLoot;

  // The amount of damage attacks do
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
  float BaseAttackDamage;

  // Amount of time the monster needs to rest in seconds
  // between attacking
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
  float AttackTimeout;

  // Time since monster's last strike, readable in blueprints
  UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category =  MonsterProperties)
  float TimeSinceLastStrike;

  // Range for his sight
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Collision)
  USphereComponent* SightSphere;

  // Range for his attack. Visualizes as a sphere in editor,
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Collision)
  USphereComponent* AttackRangeSphere;
};

你需要在Monster构造函数中添加一些基本的代码来初始化怪物的属性。在Monster.cpp文件中使用以下代码:

AMonster::AMonster(const class FObjectInitializer& PCIP) : Super(PCIP)
{
  Speed = 20;
  HitPoints = 20;
  Experience = 0;
  BPLoot = NULL;
  BaseAttackDamage = 1;
  AttackTimeout = 1.5f;
  TimeSinceLastStrike = 0;

  SightSphere = PCIP.CreateDefaultSubobject<USphereComponent> (this, TEXT("SightSphere"));
  SightSphere->AttachTo( RootComponent );

  AttackRangeSphere = PCIP.CreateDefaultSubobject <USphereComponent>(this, TEXT("AttackRangeSphere"));
  AttackRangeSphere->AttachTo( RootComponent );
}

编译并运行代码。打开 Unreal 编辑器,基于你的Monster类(命名为BP_Monster)创建一个蓝图。现在我们可以开始配置你的怪物属性。

对于骨骼网格,我们不会为怪物使用HeroTPP模型,因为我们需要怪物能够进行近战攻击,而HeroTPP模型并不包含近战攻击。然而,Mixamo Animation Pack文件中的一些模型包含近战攻击动画。因此,请从 UE4 市场下载Mixamo Animation Pack文件(免费)。

怪物

在包中有些相当令人厌恶的模型,我会避免使用,但也有一些相当不错

接下来,您应该将Mixamo Animation Pack文件添加到您的项目中,如下面的截图所示:

怪物

现在,基于您的Monster类创建一个名为BP_Monster的蓝图。编辑蓝图类属性并选择Mixamo_Adam(实际上在当前包的版本中写作Maximo_Adam)作为骨骼网格。同时,选择MixamoAnimBP_Adam作为动画蓝图。

怪物

选择 Maximo_Adam 骨骼网格和 MixamoAnimBP_Adam 作为动画蓝图生成的类

我们将修改动画蓝图,以便正确地结合近战攻击动画。

当您编辑BP_Monster蓝图时,将SightSphereAttackRangeSphere对象的大小调整为对您有意义的值。我将我的怪物的AttackRangeSphere对象设置得刚好足够接近手臂的长度(60 单位),而将SightSphere对象设置为大约是其 25 倍大(大约 1,500 单位)。

记住,一旦玩家进入怪物的SightSphere,怪物就会开始向玩家移动,一旦玩家在怪物的AttackRangeSphere对象内部,怪物就会开始攻击玩家。

怪物

Mixamo Adam 及其 AttackRangeSphere 对象以橙色突出显示

在您的游戏中放置一些BP_Monster实例;编译并运行。如果没有代码来驱动Monster角色移动,您的怪物应该只是在那里无所事事地站立。

基本怪物智能

在我们的游戏中,我们将只向Monster角色添加基本智能。怪物将知道如何做两件基本的事情:

  • 跟踪玩家并跟随他

  • 攻击玩家

怪物不会做其他任何事情。您还可以让怪物在第一次看到玩家时挑衅玩家,但我们将把这个留作您的练习。

移动怪物 - 导航行为

在非常基本的游戏中,怪物通常不会有复杂的运动行为。通常它们只是走向目标并攻击它。我们将在这个游戏中编程这种类型的怪物,但请注意,您可以通过让怪物在战场上占据有利位置进行远程攻击等方式获得更有趣的游戏体验。我们不会在这里编程这些,但这是一个值得思考的问题。

为了让Monster角色向玩家移动,我们需要在每个帧中动态更新Monster角色移动的方向。为了更新怪物面对的方向,我们在Monster::Tick()方法中编写代码。

Tick函数在游戏的每一帧中运行。Tick函数的签名是:

virtual void Tick(float DeltaSeconds) override;

您需要将此函数的原型添加到您的Monster类中,在您的Monster.h文件中。如果我们重写Tick,我们就可以在每个帧中放置Monster角色应该执行的自己的自定义行为。以下是一些基本的代码,它将在每个帧中将怪物移动到玩家附近:

void AMonster::Tick(float DeltaSeconds)
{
  Super::Tick( DeltaSeconds );

  // basic intel: move the monster towards the player
  AAvatar *avatar = Cast<AAvatar>(  UGameplayStatics::GetPlayerPawn(GetWorld(), 0) );
  if( !avatar ) return;

  FVector toPlayer = avatar->GetActorLocation() -  GetActorLocation();
  toPlayer.Normalize();	// reduce to unit vector

  // Actually move the monster towards the player a bit
  AddMovementInput(toPlayer, Speed*DeltaSeconds);

  // At least face the target
  // Gets you the rotator to turn something
  // that looks in the `toPlayer` direction
  FRotator toPlayerRotation = toPlayer.Rotation();
  toPlayerRotation.Pitch = 0; // 0 off the pitch
  RootComponent->SetWorldRotation( toPlayerRotation );
}

要使AddMovementInput正常工作,你必须在蓝图中的AIController Class面板下选择一个控制器,如下面的截图所示:

移动怪物 – 导航行为

如果你选择了None,对AddMovementInput的调用将没有任何效果。为了避免这种情况,请选择AIController类或PlayerController类作为你的AIController Class

上述代码非常简单。它构成了最基本形式的敌人智能:在每个帧中通过增量小量向玩家移动。

移动怪物 – 导航行为

我们不那么聪明的怪物军队正在追逐玩家

在一系列帧的结果中,怪物将跟踪并跟随玩家在关卡中移动。要理解这是如何工作的,你必须记住Tick函数平均每秒大约被调用 60 次。这意味着在每个帧中,怪物都会向玩家靠近一小步。由于怪物以非常小的步伐移动,他的动作看起来很平滑和连续(而在现实中,他在每个帧中都在进行小跳跃和跳跃)。

移动怪物 – 导航行为

跟踪的离散性:怪物在三个叠加帧中的运动

小贴士

怪物每秒移动大约 60 次的原因是由于硬件限制。典型显示器的刷新率为 60 Hz,因此它实际上限制了每秒有多少次更新是有用的。以比刷新率更快的帧率更新是可能的,但对于游戏来说,这并不一定有用,因为在大多数硬件上,你每秒只能看到一次新的画面。一些高级的物理建模模拟每秒可以更新近 1,000 次,但可以说,对于游戏来说,你不需要那么高的分辨率,你应该将额外的 CPU 时间留给玩家会喜欢的功能,比如更好的 AI 算法。一些较新的硬件声称刷新率高达 120 Hz(查找游戏显示器,但不要告诉你的父母我让你花所有钱买一个)。

怪物运动的离散性

计算机游戏本质上是离散的。在前面的叠加序列帧的截图中,玩家被看到以微小的步伐直线向上移动屏幕。怪物的运动也是以小步伐进行的。在每个帧中,怪物都会向玩家迈出一小步。当怪物在每一帧直接向玩家所在的位置移动时,他似乎在沿着一条曲线路径移动。

要将怪物移动到玩家身边,我们首先需要获取玩家的位置。由于玩家可以通过全局函数 UGameplayStatics::GetPlayerPawn 访问,我们只需使用此函数检索我们的玩家指针。接下来,我们找到从 Monster (GetActorLocation()) 函数指向玩家 (avatar->GetActorLocation()) 的向量。我们需要找到从怪物指向玩家的向量。为此,你必须从怪物的位置减去玩家的位置,如下面的截图所示:

怪物运动的离散性质

这是一个简单的数学规则,但很容易出错。要得到正确的向量,始终从目标(终点)向量中减去源(起点)向量。在我们的系统中,我们必须从 Avatar 向量中减去 Monster 向量。这是因为从系统中减去 Monster 向量将 Monster 向量移动到原点,而 Avatar 向量将位于 Monster 向量的左下角:

怪物运动的离散性质

从系统中减去怪物向量将怪物向量移动到 (0,0)

一定要尝试运行你的代码。到目前为止,怪物将会朝向你的玩家跑来,并围绕他聚集。按照前面的代码,它们不会攻击;它们只是跟随他,如下面的截图所示:

怪物运动的离散性质

怪物视野球体

目前,怪物没有注意到 SightSphere 组件。也就是说,无论玩家在世界中的哪个位置,在当前设置下,怪物都会朝向他移动。我们现在想改变这一点。

要做到这一点,我们只需要让 Monster 尊重 SightSphere 限制。如果玩家位于怪物的 SightSphere 对象内,怪物将会追逐。否则,怪物将不会注意到玩家的位置,也不会追逐玩家。

检查一个对象是否在球体内很简单。在下面的截图中,如果点 p 与质心 c 之间的距离 d 小于球体半径 r,则 p 在球体内:

怪物视野球体

当 d 小于 r 时,P 在球体内

因此,在我们的代码中,上述截图转换为以下代码:

void AMonster::Tick(float DeltaSeconds)
{
  Super::Tick( DeltaSeconds );
  AAvatar *avatar = Cast<AAvatar>(  UGameplayStatics::GetPlayerPawn(GetWorld(), 0) );
  if( !avatar ) return;
    FVector toPlayer = avatar->GetActorLocation() -  GetActorLocation();
  float distanceToPlayer = toPlayer.Size();
  // If the player is not in the SightSphere of the monster,
  // go back
  if( distanceToPlayer > SightSphere->GetScaledSphereRadius() )
  {
    // If the player is out of sight,
    // then the enemy cannot chase
    return;
  }

  toPlayer /= distanceToPlayer;  // normalizes the vector
  // Actually move the monster towards the player a bit
  AddMovementInput(toPlayer, Speed*DeltaSeconds);
  // (rest of function same as before (rotation))
}

上述代码为 Monster 角色添加了额外的智能。现在,如果玩家位于怪物的 SightSphere 对象之外,Monster 角色可以停止追逐玩家。结果将如下所示:

怪物视野球体

这里一个好的做法是将距离比较封装成一个简单的内联函数。我们可以在 Monster 头文件中提供这两个内联成员函数,如下所示:

inline bool isInSightRange( float d )
{ return d < SightSphere->GetScaledSphereRadius(); }
inline bool isInAttackRange( float d )
{ return d < AttackRangeSphere->GetScaledSphereRadius(); }

这些函数在传递的参数 d 在所讨论的球体内时返回值 true

小贴士

内联函数意味着这个函数更像是一个宏而不是一个函数。宏被复制粘贴到调用位置,而函数是通过 C++跳转到其位置并执行的。内联函数的好处是它们提供了良好的性能,同时保持了代码的易读性,并且是可重用的。

怪物对玩家的攻击

怪物可以执行几种不同的攻击类型。根据Monster角色的类型,怪物的攻击可能是近战(近距离)或远程(投射武器)。

当玩家在怪物的AttackRangeSphere内时,Monster角色会攻击玩家。如果玩家在怪物的AttackRangeSphere范围之外,但玩家在怪物的SightSphere对象内,那么怪物会移动到玩家附近,直到玩家进入怪物的AttackRangeSphere

近战攻击

“近战”的字典定义是一群混乱的人群。近战攻击是在近距离进行的攻击。想象一下一群zerglings和一群ultralisks在战斗(如果你是《星际争霸》玩家,你会知道 zerglings 和 ultralisks 都是近战单位)。近战攻击基本上是近距离的肉搏战。要进行近战攻击,你需要一个在怪物开始近战攻击时启动的近战攻击动画。为此,你需要编辑Persona中的动画蓝图,这是 UE4 的动画编辑器。

小贴士

Zak Parrish 的Persona系列是一个很好的起点,可以用来在蓝图中进行动画编程:www.youtube.com/watch?v=AqYmC2wn7Cg&list=PL6VDVOqa_mdNW6JEu9UAS_s40OCD_u6yp&index=8.

目前,我们只需编程近战攻击,然后稍后再修改蓝图中的动画。

定义近战武器

定义我们的近战武器将分为三个部分。第一部分是代表它的 C++代码。第二部分是模型,第三部分是使用 UE4 蓝图将代码和模型连接起来。

C++中的近战武器编程

我们将定义一个新的类,AMeleeWeapon(从AActor派生),来表示手持近战武器。我将在AMeleeWeapon类中附加一些蓝图可编辑的属性,AMeleeWeapon类将如下所示:

class AMonster;

UCLASS()
class GOLDENEGG_API AMeleeWeapon : public AActor
{
  GENERATED_UCLASS_BODY()

  // The amount of damage attacks by this weapon do
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MeleeWeapon)
  float AttackDamage;

  // A list of things the melee weapon already hit this swing
  // Ensures each thing sword passes thru only gets hit once
  TArray<AActor*> ThingsHit;

  // prevents damage from occurring in frames where
  // the sword is not swinging
  bool Swinging;

  // "Stop hitting yourself" - used to check if the 
  // actor holding the weapon is hitting himself
  AMonster *WeaponHolder;

  // bounding box that determines when melee weapon hit
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  MeleeWeapon)
  UBoxComponent* ProxBox;

  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  MeleeWeapon)
  UStaticMeshComponent* Mesh;

  UFUNCTION(BlueprintNativeEvent, Category = Collision)
  void Prox( AActor* OtherActor, UPrimitiveComponent* OtherComp,  int32 OtherBodyIndex, bool bFromSweep, const FHitResult &  SweepResult );
  void Swing();
  void Rest();
};

注意我如何为ProxBox使用了一个边界框,而不是边界球体。这是因为剑和斧头更适合用边界框而不是球体来近似。这个类中有两个成员函数,Rest()Swing(),它们让MeleeWeapon知道演员处于什么状态(休息或挥动)。在这个类内部还有一个TArray<AActor*> ThingsHit属性,它跟踪每次挥动时被这把近战武器击中的演员。我们正在编程,使得武器在每次挥动中只能击中每个东西一次。

AMeleeWeapon.cpp 文件将只包含一个基本的构造函数和一些简单的代码,用于当我们的剑击中敌人时向 OtherActor 发送伤害。我们还将实现 Rest()Swing() 函数来清除被击中的物品列表。MeleeWeapon.cpp 文件有以下代码:

AMeleeWeapon::AMeleeWeapon(const class FObjectInitializer& PCIP) :  Super(PCIP)
{
  AttackDamage = 1;
  Swinging = false;
  WeaponHolder = NULL;

  Mesh = PCIP.CreateDefaultSubobject<UStaticMeshComponent>(this,  TEXT("Mesh"));
  RootComponent = Mesh;

  ProxBox = PCIP.CreateDefaultSubobject<UBoxComponent>(this,  TEXT("ProxBox"));
  ProxBox->OnComponentBeginOverlap.AddDynamic( this,  &AMeleeWeapon::Prox );
  ProxBox->AttachTo( RootComponent );
}

void AMeleeWeapon::Prox_Implementation( AActor* OtherActor,  UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool  bFromSweep, const FHitResult & SweepResult )
{
  // don't hit non root components
  if( OtherComp != OtherActor->GetRootComponent() )
  {
    return;
  }

  // avoid hitting things while sword isn't swinging,
  // avoid hitting yourself, and
  // avoid hitting the same OtherActor twice
  if( Swinging && OtherActor != WeaponHolder &&  !ThingsHit.Contains(OtherActor) )
  {
    OtherActor->TakeDamage( AttackDamage + WeaponHolder- >BaseAttackDamage, FDamageEvent(), NULL, this );
    ThingsHit.Add( OtherActor );
  }
}
void AMeleeWeapon::Swing()
{
  ThingsHit.Empty();  // empty the list
  Swinging = true;
}
void AMeleeWeapon::Rest()
{
  ThingsHit.Empty();
  Swinging = false;
}

下载剑模型

要完成这个练习,我们需要一把剑放入模型的手中。我从 tf3dm.com/3d-model/sword-95782.html 的 Kaan Gülhan 的项目中添加了一把名为 Kilic 的剑。以下是你将获得免费模型的其他地方列表:

提示

秘密提示

TurboSquid.com 上,一开始可能看起来没有免费模型。实际上,秘密在于你必须搜索价格范围 $0-$0 来找到它们。$0 表示免费。

下载剑模型

TurboSquid 搜索免费剑模型

我不得不稍微编辑一下 kilic 剑网格以修复初始尺寸和旋转。你可以将任何 FilmboxFBX) 格式的网格导入到你的游戏中。kilic 剑模型位于第十一章(part0076_split_000.html#28FAO1-dd4a3f777fc247568443d5ffb917736d "第十一章. 怪物")的示例代码包中,怪物

要将你的剑导入到 UE4 编辑器中,右键单击你想要添加模型的任何文件夹。导航到 新建资产 | 导入到 | 游戏 | 模型...,然后从弹出的文件资源管理器中选择你想要导入的新资产。如果不存在 模型 文件夹,你可以通过在左侧的树视图中右键单击并选择 内容浏览器 选项卡左侧面板中的 新建文件夹 来创建一个。我选择了来自桌面的 kilic.fbx 资产。

下载剑模型

将模型导入到你的项目中

为你的近战武器创建蓝图

在 UE4 编辑器内部,创建一个基于 AMeleeWeapon 的蓝图,命名为 BP_MeleeSword。配置 BP_MeleeSword 以使用 kilic 刀刃模型(或你选择的任何刀刃模型),如下面的截图所示:

为你的近战武器创建蓝图

ProxBox 类将确定是否被武器击中,因此我们将修改 ProxBox 类,使其仅包围剑的刀刃,如下面的截图所示:

为你的近战武器创建蓝图

此外,在 碰撞预设 面板下,选择网格的 无碰撞 选项(而不是 阻止所有)非常重要。以下截图展示了这一点:

为你的近战武器创建蓝图

如果你选择BlockAll,那么游戏引擎将自动解决剑和角色之间的所有穿透问题,通过在挥剑时推开剑接触到的任何东西。结果是,每当挥剑时,你的角色看起来就像要飞起来一样。

插槽

在 UE4 中,插槽是一个骨骼网格上的用于另一个Actor的容器。你可以在骨骼网格身体上的任何位置放置插槽。在你正确放置插槽后,你可以在 UE4 代码中将另一个Actor附加到这个插槽上。

例如,如果我们想在怪物手中放置一把剑,我们只需在怪物手中创建一个插槽即可。我们可以在玩家头部创建一个插槽来为玩家戴上头盔。

在怪物手中创建骨骼网格插槽

要将插槽附加到怪物手中,我们必须编辑怪物使用的骨骼网格。由于我们为怪物使用了Mixamo_Adam骨骼网格,我们必须打开并编辑这个骨骼网格。

要这样做,在内容浏览器选项卡中双击Mixamo_Adam骨骼网格(这将显示为 T 姿势)以打开骨骼网格编辑器。如果你在内容浏览器选项卡中没有看到Mixamo Adam,请确保你已经从 Unreal Launcher 应用程序中将Mixamo Animation Pack文件导入到你的项目中。

在怪物手中创建骨骼网格插槽

通过双击 Maximo_Adam 骨骼网格对象来编辑 Maximo_Adam 网格

点击屏幕右上角的骨骼。在左侧面板中的骨骼树中向下滚动,直到找到RightHand骨骼。我们将在这个骨骼上添加一个插槽。在RightHand骨骼上右键单击并选择添加插槽,如下面的截图所示:

在怪物手中创建骨骼网格插槽

你可以保留默认名称(RightHandSocket)或者如果你喜欢的话,可以重命名插槽,如下面的截图所示:

在怪物手中创建骨骼网格插槽

接下来,我们需要在演员的手中添加一把剑。

将剑附加到模型

在打开亚当骨骼网格后,在树视图中找到RightHandSocket选项。由于亚当用右手挥剑,你应该将剑附加到他的右手。将你的剑模型拖放到RightHandSocket选项中。你应该在以下截图右侧的模型图像中看到亚当握住剑:

将剑附加到模型

现在,点击RightHandSocket并放大亚当的手。我们需要调整预览中插槽的位置,以便剑能够正确地放入其中。使用移动和旋转操纵杆调整剑的位置,使其正确地放入他的手中。

将剑附加到模型

将插槽定位在右手,使剑正确放置

提示

现实世界技巧

如果你想要在同一个RightHandSocket中切换多个剑模型,你需要确保这些剑之间有相当程度的统一性(没有异常)。

你可以通过转到屏幕右上角的动画选项卡来预览手持剑的动画。

将剑附加到模型上

为模型装备剑

然而,如果你启动游戏,亚当将不会手持剑。这是因为将剑添加到Persona中的插槽只是为了预览。

为玩家装备剑的代码

要从代码中为玩家装备剑并将其永久绑定到 actor,实例化一个AMeleeWeapon实例,并在怪物实例初始化后将其附加到RightHandSocket。我们在PostInitializeComponents()中这样做,因为在这个函数中,Mesh对象已经完全初始化。

Monster.h文件中,添加一个钩子来选择用于的近战武器的蓝图类名(UClass)。同时添加一个钩子来存储MeleeWeapon实例的变量,使用以下代码:

// The MeleeWeapon class the monster uses
// If this is not set, he uses a melee attack
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
UClass* BPMeleeWeapon;

// The MeleeWeapon instance (set if the character is using
// a melee weapon)
AActor* MeleeWeapon;

现在,在你的怪物蓝图类中选择BP_MeleeSword蓝图。

在 C++代码中,你需要实例化这个武器。为此,我们需要为Monster类声明并实现一个PostInitializeComponents函数。在Monster.h文件中,添加一个原型声明:

virtual void PostInitializeComponents() override;

PostInitializeComponents在怪物对象的构造函数完成后运行,并且所有对象组件都已初始化(包括蓝图构造)。因此,这是检查怪物是否附有MeleeWeapon蓝图以及实例化该武器的完美时机。以下代码添加到Monster.cpp实现中的AMonster::PostInitializeComponents()以实例化武器:

void AMonster::PostInitializeComponents()
{
  Super::PostInitializeComponents();

  // instantiate the melee weapon if a bp was selected
  if( BPMeleeWeapon )
  {
    MeleeWeapon = GetWorld()->SpawnActor<AMeleeWeapon>(
      BPMeleeWeapon, FVector(), FRotator() );

    if( MeleeWeapon )
    {
      const USkeletalMeshSocket *socket = Mesh->GetSocketByName(  "RightHandSocket" ); // be sure to use correct
                           // socket name!
      socket->AttachActor( MeleeWeapon, Mesh );
    }
  }
}

如果为该怪物的蓝图选择了BPMeleeWeapon,那么怪物现在将手持剑开始。

为玩家装备剑的代码

持有武器的怪物

触发攻击动画

默认情况下,我们的 C++ Monster类与触发攻击动画之间没有连接;换句话说,MixamoAnimBP_Adam类没有方法知道怪物是否处于攻击状态。

因此,我们需要更新亚当骨骼的动画蓝图(MixamoAnimBP_Adam),以包括在Monster类变量列表中的查询,并检查怪物是否处于攻击状态。我们在这本书中之前还没有处理过动画蓝图(或蓝图),但按照步骤一步一步来,你应该会看到它整合在一起。

小贴士

我在这里会温和地介绍蓝图术语,但我鼓励你查看 Zak Parrish 的教程系列,以了解蓝图的入门知识:www.youtube.com/playlist?list=PLZlv_N0_O1gbYMYfhhdzfW1tUV4jU0YxH

蓝图基础

UE4 蓝图是代码的视觉实现(不要与人们有时所说的 C++类是类实例的隐喻性蓝图混淆)。在 UE4 蓝图中,你不需要实际编写代码,而是将元素拖放到图上,并将它们连接起来以实现所需的播放。通过将正确的节点连接到正确的元素,你可以在游戏中编程任何你想要的东西。

提示

本书不鼓励使用蓝图,因为我们正在努力鼓励你编写自己的代码。然而,动画最好用蓝图来完成,因为这是艺术家和设计师所熟悉的。

让我们编写一个示例蓝图,以了解它们是如何工作的。首先,点击顶部的蓝图菜单栏,然后选择打开关卡蓝图,如下面的截图所示:

蓝图基础

关卡蓝图选项在开始关卡时自动执行。一旦你打开这个窗口,你应该会看到一个空白画布,你可以在这里创建你的游戏玩法,如下所示:

蓝图基础

在图纸上任何地方右键单击。开始键入begin,然后从出现的下拉列表中选择事件开始播放选项。确保勾选了上下文相关复选框,如下面的截图所示:

蓝图基础

立即点击事件开始播放选项后,你的屏幕上会出现一个红色框。它右侧有一个单独的白色引脚。这被称为执行引脚,如下所示:

蓝图基础

关于动画蓝图,你需要知道的第一件事是白色引脚执行路径(即白色线条)。如果你之前见过蓝图图,你一定注意到了一条穿过图的白色线条,如下面的图所示:

蓝图基础

白色引脚执行路径基本上等同于有一行行代码依次运行。白色线条决定了哪些节点将被执行以及执行顺序。如果一个节点没有连接白色执行引脚,那么该节点将根本不会执行。

事件开始播放拖出白色执行引脚。首先在可执行动作对话框中键入draw debug box。选择弹出的第一个选项(f 绘制调试框),如下所示:

蓝图基础

填写一些关于你想要盒子看起来怎样的细节。在这里,我选择了蓝色作为盒子的颜色,盒子的中心在 (0, 0, 100),盒子的大小为 (200, 200, 200),持续时间为 180 秒(务必输入足够长的时间以看到结果),如下面的截图所示:

蓝图基础

现在点击 播放 按钮以实现图表。记住,你必须找到世界原点才能看到调试框。

通过将一个金色鸡蛋放置在 (0, 0, (某个 z 值)) 来找到世界原点,如下面的截图所示:

蓝图基础

这就是盒子在关卡中的样子:

蓝图基础

在原点渲染的调试框

修改 Mixamo Adam 的动画蓝图

要集成我们的攻击动画,我们必须修改蓝图。在 内容浏览器 中打开 MixamoAnimBP_Adam

你首先会注意到图表分为两个部分:一个顶部部分和一个底部部分。顶部部分标记为 "基本角色移动...",而底部部分说 "Mixamo 示例角色动画...." 基本角色移动负责模型的行走和跑步动作。我们将工作在 带有攻击和跳跃的 Mixamo 示例角色动画 部分,该部分负责攻击动画。我们将工作在图表的后半部分,如下面的截图所示:

修改 Mixamo Adam 的动画蓝图

当你第一次打开图表时,它最初会放大靠近底部的某个部分。要向上滚动,右键单击鼠标并向上拖动。你还可以使用鼠标滚轮或按住 Alt 键和右鼠标按钮同时向上移动鼠标来缩小。

在继续之前,你可能想要复制 MixamoAnimBP_Adam 资源,以免损坏原始资源,以防你需要稍后返回并更改某些内容。这允许你在发现你在修改中犯了一个错误时,可以轻松返回并纠正,而无需在你的项目中重新安装整个动画包的新副本。

修改 Mixamo Adam 的动画蓝图

制作 MixamoAnimBP_Adam 资源的副本以避免损坏原始资产

小贴士

当从 Unreal Launcher 向项目中添加资产时,会创建原始资产的副本,因此你现在可以修改项目中的 MixamoAnimBP_Adam,并在稍后在新项目中获取原始资产的新副本。

我们将只做几件事情,让 Adam 在攻击时挥舞剑。让我们按顺序来做。

  1. 删除标记为 攻击? 的节点:修改 Mixamo Adam 的动画蓝图

  2. 重新排列节点,如下所示,将 启用攻击 节点单独放在底部:修改 Mixamo Adam 的动画蓝图

  3. 接下来,我们将处理这个动画所动画化的怪物。将图表向上滚动一点,并将尝试获取 Pawn 所有者对话框中标记为返回值的蓝色点拖动到您的图表中。当弹出菜单出现时,选择投射到怪物(确保已勾选上下文相关,否则投射到怪物选项将不会出现)。尝试获取 Pawn 所有者选项获取拥有动画的Monster实例,它只是AMonster类对象,如图所示:修改 Mixamo Adam 的动画蓝图

  4. 序列对话框中点击+,并将来自序列组的另一个执行引脚拖动到投射到怪物节点实例上,如图所示。这确保了投射到怪物实例实际上会被执行。修改 Mixamo Adam 的动画蓝图

  5. 下一步是从投射到怪物节点的As Monster端子拉出引脚,并查找是否在玩家攻击范围内属性:修改 Mixamo Adam 的动画蓝图

  6. 将来自投射到怪物节点左侧的白色执行引脚拖放到右侧的是否在玩家攻击范围内节点上:修改 Mixamo Adam 的动画蓝图

    这确保了从投射到怪物操作到是否在玩家攻击范围内节点的控制权转移。

  7. 将白色和红色引脚拖到SET节点上,如图所示:修改 Mixamo Adam 的动画蓝图

提示

上述蓝图的等效伪代码类似于以下内容:

if( Monster.isInAttackRangeOfPlayer() )
{
  Monster.Animation = The Attack Animation;
}

测试您的动画。怪物应该只在玩家范围内挥剑。

挥剑的代码

我们想在挥剑时添加一个动画通知事件。首先,在您的Monster类中声明并添加一个蓝图可调用的 C++函数:

// in Monster.h:
UFUNCTION( BlueprintCallable, Category = Collision )
void SwordSwung();

BlueprintCallable语句意味着它将可以从蓝图中进行调用。换句话说,SwordSwung()将是一个我们可以从蓝图节点调用的 C++函数,如图所示:

// in Monster.cpp
void AMonster::SwordSwung()
{
  if( MeleeWeapon )
  {
    MeleeWeapon->Swing();
  }
}

接下来,通过双击内容浏览器中的Mixamo_Adam_Sword_Slash动画来打开它(它应该在MixamoAnimPack/Mixamo_Adam/Anims/Mixamo_Adam_Sword_Slash)。将动画拖动到 Adam 开始挥剑的位置。右键单击动画栏,在添加通知...下选择新建通知,如图所示:

挥剑的代码

将通知命名为SwordSwung

挥剑的代码

通知名称应出现在您的动画时间轴上,如下所示:

挥剑的代码

保存动画后,再次打开你的MixamoAnimBP_Adam版本。在SET节点组下方,创建以下图:

挥剑的代码

当你在图中右键单击(上下文相关已打开)并开始键入SwordSwung时,会出现AnimNotify_SwordSwung节点。Cast To Monster节点再次从Try Get Pawn Owner节点中输入,正如修改 Mixamo Adam 动画蓝图部分的第 2 步中所述。最后,Sword Swung是我们AMonster类中的蓝图可调用的 C++函数。

如果你现在开始游戏,你的怪物将在它们实际攻击时执行攻击动画。当剑的边界框与你接触时,你应该看到你的生命值条下降一点(回想一下,生命值条是在第八章,演员和实体部分作为练习添加的)。

挥剑的代码

怪物攻击玩家

弹射或远程攻击

远程攻击通常涉及某种弹射物。弹射物可以是子弹,也可以是闪电魔法攻击或火球攻击等。要编程弹射攻击,你应该生成一个新的对象,并且只有当弹射物到达玩家时才对玩家造成伤害。

要在 UE4 中实现基本的子弹,我们应该派生一个新的对象类型。我从AActor类派生了一个ABullet类,如下面的代码所示:

UCLASS()
class GOLDENEGG_API ABullet : public AActor
{
  GENERATED_UCLASS_BODY()

  // How much damage the bullet does.
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  Properties)
  float Damage;

  // The visible Mesh for the component, so we can see
  // the shooting object
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Collision)
  UStaticMeshComponent* Mesh;

  // the sphere you collide with to do impact damage
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Collision)
  USphereComponent* ProxSphere;

  UFUNCTION(BlueprintNativeEvent, Category = Collision)
  void Prox( AActor* OtherActor, UPrimitiveComponent* OtherComp,  int32 OtherBodyIndex, bool bFromSweep, const FHitResult &  SweepResult );
};

ABullet类中有几个重要的成员,如下所示:

  • 一个用于子弹接触时造成的伤害的float变量

  • 子弹的Mesh变量

  • 一个用于检测子弹最终击中某物的ProxSphere变量

  • 当检测到Prox接近一个对象时要运行的功能

ABullet类的构造函数应该初始化MeshProxSphere变量。在构造函数中,我们将RootComponent设置为Mesh变量,然后将ProxSphere变量附加到Mesh变量上。ProxSphere变量将用于碰撞检测,并且应该关闭Mesh变量的碰撞检测,如下面的代码所示:

ABullet::ABullet(const class FObjectInitializer& PCIP) : Super(PCIP)
{
  Mesh = PCIP.CreateDefaultSubobject<UStaticMeshComponent>(this,  TEXT("Mesh"));
  RootComponent = Mesh;

  ProxSphere = PCIP.CreateDefaultSubobject<USphereComponent>(this,  TEXT("ProxSphere"));
  ProxSphere->AttachTo( RootComponent );

  ProxSphere->OnComponentBeginOverlap.AddDynamic( this,  &ABullet::Prox );
  Damage = 1;
}

我们在构造函数中将Damage变量初始化为1,但一旦我们从ABullet类创建蓝图,就可以在 UE4 编辑器中更改它。接下来,ABullet::Prox_Implementation()函数应该在我们与其他演员的RootComponent发生碰撞时对被击中的演员造成伤害,如下面的代码所示:

void ABullet::Prox_Implementation( AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult & SweepResult )
{
  if( OtherComp != OtherActor->GetRootComponent() )
  {
    // don't collide w/ anything other than
    // the actor's root component
    return;
  }

  OtherActor->TakeDamage( Damage, FDamageEvent(), NULL, this );
  Destroy();
}

子弹物理

要让子弹在关卡中飞行,你可以使用 UE4 的物理引擎。

基于ABullet类创建一个蓝图。我选择了Shape_Sphere作为网格。子弹的网格不应该启用碰撞物理;相反,我们将启用子弹边界球的物理。

配置子弹以正确行为稍微有些棘手,所以我们将在以下四个步骤中介绍:

  1. 组件选项卡中选择[ROOT] ProxSphereProxSphere变量应该是根组件,并且应该出现在层次结构的顶部。

  2. 详细信息选项卡中,勾选模拟物理模拟生成碰撞事件

  3. 碰撞预设下拉菜单中选择自定义…

  4. 按照以下方式检查碰撞响应框;对于大多数类型(WorldStaticWorldDynamic等)检查Block,仅对于Pawn检查Overlap子弹物理

模拟物理复选框使ProxSphere属性感受到重力和作用在其上的冲量力。冲量是一瞬间的力推,我们将用它来驱动子弹的射击。如果你不勾选模拟生成碰撞事件复选框,那么球体将掉落在地板上。BlockAll Collision Preset的作用是确保球体不能穿过任何东西。

如果你现在从内容浏览器选项卡直接拖放几个这些BP_Bullet对象到世界中,它们将简单地掉落到地板上。一旦它们在地板上,你可以踢它们一下。以下截图显示了地板上的球体对象:

子弹物理

然而,我们不想我们的子弹掉落在地板上。我们希望它们被射出。所以让我们把子弹放入Monster类中。

向怪物类添加子弹

Monster类添加一个接收蓝图实例引用的成员。这就是UClass对象类型的作用。另外,添加一个蓝图可配置的浮点属性来调整射击子弹的力,如下面的代码所示:

// The blueprint of the bullet class the monster uses
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
UClass* BPBullet;
// Thrust behind bullet launches
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =  MonsterProperties)
float BulletLaunchImpulse;

编译并运行 C++项目,打开你的BP_Monster蓝图。你现在可以在BPBullet下选择一个蓝图类,如下面的截图所示:

向怪物类添加子弹

一旦你选择了怪物射击时要实例化的蓝图类类型,你必须编写代码使怪物在玩家在其范围内时射击。

怪物是从哪里射击的?实际上,他应该从骨骼中射击。如果你不熟悉术语,骨骼只是模型网格中的参考点。模型网格通常由许多“骨骼”组成。要查看一些骨骼,双击内容浏览器选项卡中的Mixamo_Adam网格,如下面的截图所示:

向怪物类添加子弹

切换到骨骼选项卡,你将在左侧的树形视图列表中看到所有怪物的骨骼。我们想要做的是选择一个子弹将从中发射出来的骨骼。在这里,我选择了LeftHand选项。

小贴士

艺术家通常会向模型网格中插入一个额外的骨骼来发射粒子,这很可能是枪管尖端的粒子。

从基础模型网格开始,我们可以获取Mesh骨骼的位置,并在代码中让怪物从该骨骼发射Bullet实例。

可以使用以下代码获取完整的怪物TickAttack函数:

void AMonster::Tick(float DeltaSeconds)
{
  Super::Tick( DeltaSeconds );

  // move the monster towards the player
  AAvatar *avatar = Cast<AAvatar>(  UGameplayStatics::GetPlayerPawn(GetWorld(), 0) );
  if( !avatar ) return;

  FVector playerPos = avatar->GetActorLocation();
  FVector toPlayer = playerPos - GetActorLocation();
  float distanceToPlayer = toPlayer.Size();

  // If the player is not the SightSphere of the monster,
  // go back
  if( distanceToPlayer > SightSphere->GetScaledSphereRadius() )
  {
    // If the player is OS, then the enemy cannot chase
    return;
  }

  toPlayer /= distanceToPlayer;  // normalizes the vector

  // At least face the target
  // Gets you the rotator to turn something
  // that looks in the `toPlayer` direction
  FRotator toPlayerRotation = toPlayer.Rotation();
  toPlayerRotation.Pitch = 0; // 0 off the pitch
  RootComponent->SetWorldRotation( toPlayerRotation );

  if( isInAttackRange(distanceToPlayer) )
  {
    // Perform the attack
    if( !TimeSinceLastStrike )
    {
      Attack(avatar);
    }

    TimeSinceLastStrike += DeltaSeconds;
    if( TimeSinceLastStrike > AttackTimeout )
    {
      TimeSinceLastStrike = 0;
    }

    return;  // nothing else to do
  }
  else
  {
    // not in attack range, so walk towards player
    AddMovementInput(toPlayer, Speed*DeltaSeconds);
  }
}

AMonster::Attack函数相对简单。当然,我们首先需要在Monster.h文件中添加原型声明,以便在.cpp文件中编写我们的函数:

void AMonster::Attack(AActor* thing);

Monster.cpp中,我们实现Attack函数,如下所示:

void AMonster::Attack(AActor* thing)
{
  if( MeleeWeapon )
  {
    // code for the melee weapon swing, if 
    // a melee weapon is used
    MeleeWeapon->Swing();
  }
  else if( BPBullet )
  {
    // If a blueprint for a bullet to use was assigned,
    // then use that. Note we wouldn't execute this code
    // bullet firing code if a MeleeWeapon was equipped
    FVector fwd = GetActorForwardVector();
    FVector nozzle = GetMesh()->GetBoneLocation( "RightHand" );
    nozzle += fwd * 155;// move it fwd of the monster so it  doesn't
    // collide with the monster model
    FVector toOpponent = thing->GetActorLocation() - nozzle;
    toOpponent.Normalize();
    ABullet *bullet = GetWorld()->SpawnActor<ABullet>(  BPBullet, nozzle, RootComponent->GetComponentRotation());

    if( bullet )
    {
      bullet->Firer = this;
      bullet->ProxSphere->AddImpulse( 
        fwd*BulletLaunchImpulse );
    }
    else
    {
      GEngine->AddOnScreenDebugMessage( 0, 5.f, 
      FColor::Yellow, "monster: no bullet actor could be spawned.  is the bullet overlapping something?" );
    }
  }
}

我们保留实现近战攻击的代码不变。假设怪物没有持有近战武器,然后检查BPBullet成员是否已设置。如果BPBullet成员已设置,这意味着怪物将创建并发射BPBullet蓝图类的实例。

特别注意以下行:

ABullet *bullet = GetWorld()->SpawnActor<ABullet>(BPBullet,  nozzle, RootComponent->GetComponentRotation() );

这就是我们向世界中添加新演员的方法。SpawnActor()函数将你传递的UCLASS实例放置在spawnLoc,并带有一些初始方向。

在我们生成子弹后,我们调用其ProxSphere变量的AddImpulse()函数以将其向前推进。

玩家击退

为了给玩家添加击退效果,我在Avatar类中添加了一个名为knockback的成员变量。每当角色受到伤害时,就会发生击退:

FVector knockback; // in class AAvatar

为了确定玩家被击中时击退的方向,我们需要在AAvatar::TakeDamage中添加一些代码。计算攻击者指向玩家的方向向量,并将此向量存储在knockback变量中:

float AAvatar::TakeDamage(float Damage, struct FDamageEvent const&  DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
  // add some knockback that gets applied over a few frames
  knockback = GetActorLocation() - DamageCauser- >GetActorLocation();
  knockback.Normalize();
  knockback *= Damage * 500; // knockback proportional to damage
}

AAvatar::Tick中,我们将击退应用于角色的位置:

void AAvatar::Tick( float DeltaSeconds )
{
  Super::Tick( DeltaSeconds );

  // apply knockback vector
  AddMovementInput( knockback, 1.f );

  // half the size of the knockback each frame
  knockback *= 0.5f;
}

由于击退向量的大小会随着每一帧的更新而减小,因此它会随着时间的推移而变弱,除非击退向量通过另一次击中而得到更新。

摘要

在本章中,我们探讨了如何在屏幕上实例化追逐玩家并攻击他的怪物。在下一章中,我们将赋予玩家自我防御的能力,允许他施展伤害怪物的法术。

第十二章 咒语书

玩家目前还没有防御自己的手段。我们将为玩家配备一种非常实用且有趣的方法,即所谓的魔法咒语。玩家将使用魔法咒语来影响附近的怪物。

实际上,咒语将是粒子系统与表示作用范围的边界体积的组合。在每一帧中,都会检查包含在边界体积内的演员。当一个演员位于咒语的边界体积内时,该演员将受到该咒语的影响。

下面的截图显示了暴风咒和力场咒,它们的边界体积用橙色突出显示:

咒语书

暴风咒的视觉效果可以在右侧看到,有一个长方形的边界体积。推动怪物远离的力场咒的视觉效果,具有球形的边界体积,如图所示:

咒语书

在每一帧中,都会检查包含在边界体积内的演员。任何在咒语边界体积内的演员在这一帧内都将受到该咒语的影响。如果演员移动到咒语边界体积之外,该演员将不再受到该咒语的影响。记住,咒语的粒子系统仅用于可视化;粒子本身不会影响游戏演员。《第八章》(part0056_split_000.html#1LCVG1-dd4a3f777fc247568443d5ffb917736d "第八章。演员和代理")中我们创建的PickupItem类,演员和代理可以用来允许玩家捡起代表咒语的物品。我们将扩展PickupItem类,并将咒语的蓝图附加到每个PickupItem上。点击 HUD 上的咒语小部件将施展它。界面看起来可能像这样:

咒语书

玩家捡到的物品,包括四种不同的咒语

我们将首先描述如何创建我们自己的粒子系统。然后,我们将继续将粒子发射器封装成一个Spell类,并为角色编写一个CastSpell()函数,以便角色能够真正地施展咒语。

粒子系统

首先,我们需要一个地方来放置所有我们的华丽效果。在你的内容浏览器标签页中,右键单击游戏根目录,创建一个名为ParticleSystems的新文件夹。右键单击该新文件夹,然后选择新建资产 | 粒子系统,如图所示:

粒子系统

小贴士

查看这个虚幻引擎 4 粒子系统指南,了解虚幻粒子发射器的工作原理:www.youtube.com/watch?v=OXK2Xbd7D9w&index=1&list=PLZlv_N0_O1gYDLyB3LVfjYIcbBe8NqR8t

双击出现的NewParticleSystem图标,如图所示:

粒子系统

你将进入级联,粒子编辑器。以下截图显示了环境的描述:

粒子系统

这里有几个不同的面板,每个面板都显示不同的信息。它们如下所示:

  • 在左上角是视口面板。这显示了当前发射器的动画,正如它当前正在工作一样。

  • 在右侧是发射器面板。在其内部,你可以看到一个名为粒子发射器的单个对象(你可以在你的粒子系统中拥有多个发射器,但现在我们不想那样做)。粒子发射器的模块列表出现在其下方。从前面的截图,我们有必需的生成生命周期初始大小初始速度颜色随生命周期变化模块。

更改粒子属性

默认的粒子发射器发射类似十字准线的形状。我们想将其更改为更有趣的东西。点击发射器面板下的黄色必需框,然后在详细信息面板的材质下输入particles。将弹出一个包含所有可用粒子材质的列表。选择m_flare_01选项来创建我们的第一个粒子系统,如下面的截图所示:

更改粒子属性

现在,让我们改变粒子系统的行为。点击发射器面板下的颜色随生命周期变化条目。底部的详细信息面板显示了不同参数的信息,如下面的截图所示:

更改粒子属性

颜色随生命周期变化条目的详细信息面板中,我增加了X,但没有增加Y和没有增加Z。这给粒子系统带来了一种红色光泽。(X是红色,Y是绿色,Z是蓝色)。

然而,你实际上可以通过更直观的方式来更改粒子颜色。如果你点击颜色随生命周期变化条目旁边的绿色波形按钮,你将看到颜色随生命周期变化的图表在曲线编辑器选项卡中显示,如下面的截图所示:

更改粒子属性

我们现在可以更改颜色随生命周期变化参数。曲线编辑器选项卡中的图表显示了发射颜色与粒子存活时间的对比。你可以通过拖动点来调整值。按下Ctrl + 左键鼠标按钮向线条添加一个新点:

更改粒子属性

Ctrl + 点击为线条添加点。

你可以玩弄粒子发射器的设置来创建你自己的法术可视化。

雪崩法术的设置

在这一点上,我们应该将我们的粒子系统重命名为NewParticle System,改为更具描述性的名称。让我们将其重命名为P_Blizzard。你可以通过简单地点击它并按下F2来重命名你的粒子系统。

雪崩法术的设置

在内容浏览器中的对象上按下 F2 来重命名它

暴风雪法术设置

我们将调整一些设置以获得暴风雪粒子效果法术。执行以下步骤:

  1. 发射器标签页下,点击必需框。在详细信息面板中,将发射器材质更改为m_flare_01,如下所示:暴风雪法术设置

  2. 生成模块下,将生成速率更改为 200。这将增加可视化的密度,如下所示:暴风雪法术设置

  3. 生命周期模块下,将最大属性从 1.0 增加到 2.0。这会给粒子存活时间的长度带来一些变化,其中一些发射的粒子比其他粒子存活时间更长。暴风雪法术设置

  4. 初始大小模块下,将最小属性大小在XYZ方向上更改为 12.5:暴风雪法术设置

  5. 初始速度模块下,将最小/最大值更改为显示的值:暴风雪法术设置

  6. 我们让暴风雪向+X 方向吹的原因是因为玩家的前方方向最初在+X。由于法术将从玩家的手中发出,我们希望法术指向与玩家相同的方向。

  7. 颜色随生命周期变化菜单下,将蓝色(Z)值更改为 100.0。您将看到立即变为蓝色光芒的变化:暴风雪法术设置

    现在它开始看起来像魔法一样了!

  8. 右键单击颜色随生命周期变化模块下方的深色区域。选择位置 | 初始位置暴风雪法术设置

  9. 按照以下所示在起始位置 | 分布下输入值:暴风雪法术设置

  10. 您应该得到一个看起来像这样的暴风雪:暴风雪法术设置

  11. 将摄像机移动到您喜欢的位置,然后在顶部菜单栏中点击缩略图选项。这将在内容浏览器标签页中为您粒子系统生成一个缩略图图标。暴风雪法术设置

    在顶部菜单栏中点击缩略图将生成您粒子系统的迷你图标

Spell类演员

Spell类最终会对所有怪物造成伤害。为此,我们需要在Spell类演员内部包含一个粒子系统和边界框。当化身施放Spell类时,Spell对象将被实例化到关卡中并开始Tick()功能。在Spell对象的每个Tick()上,任何位于法术边界体积内的怪物都将受到该Spell的影响。

Spell类应该看起来像以下代码:

UCLASS()
class GOLDENEGG_API ASpell : public AActor
{
  GENERATED_UCLASS_BODY()

  // box defining volume of damage
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Spell)
  TSubobjectPtr<UBoxComponent> ProxBox;

  // the particle visualization of the spell
  UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =  Spell)
  TSubobjectPtr<UParticleSystemComponent> Particles;

  // How much damage the spell does per second
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Spell)
  float DamagePerSecond;

  // How long the spell lasts
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Spell)
  float Duration;

  // Length of time the spell has been alive in the level
  float TimeAlive;

  // The original caster of the spell (so player doesn't
  // hit self)
  AActor* Caster;

  // Parents this spell to a caster actor
  void SetCaster( AActor* caster );

  // Runs each frame. override the Tick function to deal damage 
  // to anything in ProxBox each frame.
  virtual void Tick( float DeltaSeconds ) override;
};

我们只需要关注实现三个函数,即ASpell::ASpell()构造函数、ASpell::SetCaster()函数和ASpell::Tick()函数。

打开 Spell.cpp 文件。添加一行以包含 Monster.h 文件,这样我们就可以在 Spell.cpp 文件中访问 Monster 对象的定义,如下面的代码行所示:

#include "Monster.h"

首先,是构造函数,它设置了咒语并初始化所有组件,如下面的代码所示:

ASpell::ASpell(const class FPostConstructInitializeProperties&  PCIP) : Super(PCIP)
{
  ProxBox = PCIP.CreateDefaultSubobject<UBoxComponent>(this,  TEXT("ProxBox"));
  Particles = PCIP.CreateDefaultSubobject<UParticleSystemComponent>(this,  TEXT("ParticleSystem"));

  // The Particles are the root component, and the ProxBox
  // is a child of the Particle system.
  // If it were the other way around, scaling the ProxBox
  // would also scale the Particles, which we don't want
  RootComponent = Particles;
  ProxBox->AttachTo( RootComponent );

  Duration = 3;
  DamagePerSecond = 1;
  TimeAlive = 0;

  PrimaryActorTick.bCanEverTick = true;//required for spells to 
  // tick!
}

特别重要的是这里的最后一行,PrimaryActorTick.bCanEverTick = true。如果您不设置它,您的 Spell 对象将永远不会调用 Tick()

接下来,我们有 SetCaster() 方法。这个方法被调用是为了让施法者被 Spell 对象所知晓。我们可以通过以下代码确保施法者不会用他的咒语伤害到自己:

void ASpell::SetCaster( AActor *caster )
{
  Caster = caster;
  AttachRootComponentTo( caster->GetRootComponent() );
}

最后,我们有 ASpell::Tick() 方法,它实际上会对所有包含的演员造成伤害,如下面的代码所示:

void ASpell::Tick( float DeltaSeconds )
{
  Super::Tick( DeltaSeconds );

  // search the proxbox for all actors in the volume.
  TArray<AActor*> actors;
  ProxBox->GetOverlappingActors( actors );

  // damage each actor the box overlaps
  for( int c = 0; c < actors.Num(); c++ )
  {
    // don't damage the spell caster
    if( actors[ c ] != Caster )
    {
      // Only apply the damage if the box is overlapping
      // the actors ROOT component.
      // This way damage doesn't get applied for simply 
      // overlapping the SightSphere of a monster
      AMonster *monster = Cast<AMonster>( actors[c] );

      if( monster && ProxBox->IsOverlappingComponent( monster- >CapsuleComponent ) )
      {
        monster->TakeDamage( DamagePerSecond*DeltaSeconds,  FDamageEvent(), 0, this );
      }

      // to damage other class types, try a checked cast 
      // here..
    }
  }

  TimeAlive += DeltaSeconds;
  if( TimeAlive > Duration )
  {
    Destroy();
  }
}

ASpell::Tick() 函数执行多项操作,具体如下:

  • 获取所有重叠 ProxBox 的演员。如果与重叠的组件是那个对象的根组件,则任何不是施法者的演员都会受到伤害。我们必须检查与根组件重叠的原因是因为如果我们不这样做,咒语可能会重叠怪物的 SightSphere,这意味着我们会从非常远的地方受到攻击,这是我们不想看到的。

  • 注意,如果我们有另一种应该受到伤害的东西,我们就必须尝试对每个对象类型进行施法。每个类类型可能有不同类型的边界体积需要与之碰撞,其他类型甚至可能没有 CapsuleComponent(它们可能有 ProxBoxProxSphere)。

  • 增加咒语存活的时间量。如果咒语超过了分配给它的施法持续时间,它将被从关卡中移除。

现在,让我们关注玩家如何获得咒语,通过为玩家可以拾取的每个咒语对象创建一个单独的 PickupItem

设计我们的咒语

编译并运行您添加了 Spell 类的 C++ 项目。我们需要为想要能够施法的每个咒语创建蓝图。在 类查看器 选项卡中,开始键入 Spell,你应该能看到你的 Spell 类出现。右键单击 Spell,创建一个名为 BP_Spell_Blizzard 的蓝图,然后双击打开它,如下面的屏幕截图所示:

设计我们的咒语

在咒语的属性中,选择 P_Blizzard 咒语作为粒子发射器,如下面的屏幕截图所示:

设计我们的咒语

滚动直到到达 Spell 类别,并将 每秒伤害持续时间 参数更新为您喜欢的值。在这里,暴风咒语将持续 3.0 秒,并且每秒造成 16.0 的总伤害。三秒后,暴风咒语将消失。

设计我们的咒语

在配置完 默认 属性后,切换到 组件 选项卡以进行一些进一步的修改。点击并更改 ProxBox 的形状,使其形状合理。箱子应该包裹粒子系统的最强烈部分,但不要过分扩大其尺寸。ProxBox 对象不应该太大,因为这样你的暴风雪法术就会影响到连暴风雪都没有触及的东西。如下截图所示,几个异常值是可以接受的。

为我们的法术创建蓝图

你的暴风雪法术现在已创建蓝图并准备好供玩家使用。

拾取法术

回想一下,我们之前编程我们的库存,当用户按下 I 时显示玩家拥有的拾取物品数量。然而,我们想做的不仅仅是这样。

拾取法术

用户按下 I 时显示的物品

为了允许玩家拾取法术,我们将修改 PickupItem 类以包含一个用于玩家施法的法术蓝图的槽位,使用以下代码:

// inside class APickupItem:
// If this item casts a spell when used, set it here
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Item)
UClass* Spell;

一旦你将 UClass* Spell 属性添加到 APickupItem 类中,重新编译并重新运行你的 C++ 项目。现在,你可以继续为你的 Spell 对象创建 PickupItem 实例的蓝图。

为施法拾取物品创建蓝图

创建一个名为 BP_Pickup_Spell_BlizzardPickupItem 蓝图。双击它以编辑其属性,如下截图所示:

为施法拾取物品创建蓝图

我将暴风雪物品的拾取属性设置为以下内容:

物品的名称是 Blizzard Spell,每个包装袋中有五个。我截取了暴风雪粒子系统的截图并将其导入到项目中,因此 图标 被选为该图像。在法术下,我选择了 BP_Spell_Blizzard 作为要施法的法术名称(不是 BP_Pickup_Spell_Blizzard),如下截图所示:

为施法拾取物品创建蓝图

我为 PickupItem 类的 Mesh 类选择了蓝色球体。对于 图标,我在粒子查看器预览中截取了暴风雪法术的截图,将其保存到磁盘,并将该图像导入到项目中(请参阅示例项目的 内容浏览器 选项卡中的图像文件夹)。

为施法拾取物品创建蓝图

在你的关卡中放置几个这样的 PickupItem。如果我们拾取它们,我们将在我们的库存中获得一些暴风雪法术。

为施法拾取物品创建蓝图

左:游戏世界中的暴风雪法术拾取物品。右:库存中的暴风雪法术拾取物品。

现在我们需要激活暴风雪。由于我们已经在 第十章 中将左键点击附加到拖动图标,让我们将右键点击附加到施法法术。

将右键点击附加到施法法术

右键点击必须经过相当多的函数调用,才能调用角色的 CastSpell 方法。调用图可能看起来像以下截图:

将右键点击附加到施法法术

在右键点击和施法之间发生了一些事情。具体如下:

  • 如我们之前所看到的,所有用户鼠标和键盘交互都通过 Avatar 对象路由。当 Avatar 对象检测到右键点击时,它将通过 AAvatar::MouseRightClicked() 将点击事件传递给 HUD

  • 回想一下 第十章,库存系统和拾取物品,在那里我们使用 struct Widget 类来跟踪玩家拾取的物品。struct Widget 只有三个成员:

    struct Widget
    {
      Icon icon;
      FVector2D pos, size;
      ///.. and some member functions
    };
    

    我们需要为 struct Widget 类添加一个额外的属性来记住它所施的法术。

    HUD 将确定点击事件是否发生在 AMyHUD::MouseRightClicked() 中的 Widget 内。

  • 如果点击的是施法 Widget,则 HUD 会通过调用 AAvatar::CastSpell() 将请求施法该法术的请求传回角色。

编写角色的 CastSpell 函数

我们将逆向实现前面的调用图。首先,我们将编写实际在游戏中施法的函数,即 AAvatar::CastSpell(),如下面的代码所示:

void AAvatar::CastSpell( UClass* bpSpell )
{
  // instantiate the spell and attach to character
  ASpell *spell = GetWorld()->SpawnActor<ASpell>(bpSpell,  FVector(0), FRotator(0) );

  if( spell )
  {
    spell->SetCaster( this );
  }
  else
  {
    GEngine->AddOnScreenDebugMessage( 1, 5.f, FColor::Yellow,  FString("can't cast ") + bpSpell->GetName() );
  }
}

你可能会发现实际调用法术非常简单。施法有两个基本步骤:

  • 使用世界对象的 SpawnActor 函数实例化施法对象

  • 将其附加到角色上

一旦 Spell 对象被实例化,其 Tick() 函数将在该法术在关卡中的每一帧运行。在每次 Tick() 中,Spell 对象将自动感知关卡内的怪物并对其造成伤害。之前提到的每一行代码都涉及很多操作,所以让我们分别讨论每一行。

实例化施法对象 – GetWorld()->SpawnActor()

要从蓝图创建 Spell 对象,我们需要从 World 对象调用 SpawnActor() 函数。SpawnActor() 函数可以接受任何蓝图并在关卡内实例化它。幸运的是,Avatar 对象(以及任何 Actor 对象)可以通过简单地调用 GetWorld() 成员函数在任何时候获取到 World 对象的句柄。

Spell 对象引入关卡的那行代码如下:

ASpell *spell = GetWorld()->SpawnActor<ASpell>( bpSpell,  FVector(0), FRotator(0) );

关于前面一行代码,有几个需要注意的点:

  • bpSpell 必须是要创建的 Spell 对象的蓝图。尖括号中的 <ASpell> 对象表示这种期望。

  • 新的 Spell 对象最初位于原点(0, 0, 0),并且没有对其应用额外的旋转。这是因为我们将 Spell 对象附加到 Avatar 对象上,该对象将为 Spell 对象提供平移和方向组件。

if(spell)

我们总是通过检查 if( spell ) 来测试 SpawnActor<ASpell>() 调用是否成功。如果传递给 CastSpell 对象的蓝图实际上不是基于 ASpell 类的蓝图,那么 SpawnActor() 函数将返回一个 NULL 指针而不是 Spell 对象。如果发生这种情况,我们将在屏幕上打印一条错误消息,表明施法过程中出了问题。

spell->SetCaster(this)

在实例化时,如果施法成功,我们将通过调用 spell->SetCaster(this) 将施法附加到 Avatar 对象上。记住,在 Avatar 类的编程上下文中,this 方法是对 Avatar 对象的引用。

那么,我们实际上如何将 UI 输入的施法连接起来,首先调用 AAvatar::CastSpell() 函数呢?我们需要再次进行一些 HUD 编程。

编写 AMyHUD::MouseRightClicked()

施法命令最终将来自用户界面(HUD)。我们需要编写一个 C++ 函数,该函数将遍历所有 HUD 小部件并测试是否点击了其中任何一个。如果点击的是 widget 对象,那么该 widget 对象应该通过施法其分配的施法来响应。

我们必须扩展我们的 Widget 对象,使其具有一个变量来保存要施放的施法蓝图。通过以下代码在您的 struct Widget 对象中添加一个成员:

struct Widget
{
  Icon icon;
  // bpSpell is the blueprint of the spell this widget casts
  UClass *bpSpell;
  FVector2D pos, size;
  Widget(Icon iicon, UClass *iClassName)
}

现在回想一下,我们的 PickupItem 之前已经附加了它所施法的施法蓝图。然而,当玩家从关卡中拾取 PickupItem 类时,PickupItem 类就会被销毁。

// From APickupItem::Prox_Implementation():
avatar->Pickup( this ); // give this item to the avatar
// delete the pickup item from the level once it is picked up
Destroy();

因此,我们需要保留每个 PickupItem 施法的施法信息。我们可以在 PickupItem 首次被拾取时做到这一点。

AAvatar 类内部,添加一个额外的映射来记住物品名称对应的施法蓝图:

// Put this in Avatar.h
TMap<FString, UClass*> Spells;

现在在 AAvatar::Pickup() 中,记住 PickupItem 类使用以下代码行实例化的施法类:

// the spell associated with the item
Spells.Add(item->Name, item->Spell);

现在,在 AAvatar::ToggleInventory() 中,我们可以拥有显示在屏幕上的 Widget 对象。通过查找 Spells 映射来记住它应该施放哪个施法。

找到创建小部件的行,并在其下方添加分配 Widget 施法的 bpSpell 对象:

// In AAvatar::ToggleInventory()
Widget w( Icon( fs, tex ) );
w.bpSpell = Spells[it->Key];

将以下函数添加到 AMyHUD 中,我们将将其设置为在右鼠标按钮点击图标时运行:

void AMyHUD::MouseRightClicked()
{
  FVector2D mouse;
  APlayerController *PController = GetWorld()- >GetFirstPlayerController();
  PController->GetMousePosition( mouse.X, mouse.Y );
  for( int c = 0; c < widgets.Num(); c++ )
  {
    if( widgets[c].hit( mouse ) )
    {
      AAvatar *avatar = Cast<AAvatar>(  UGameplayStatics::GetPlayerPawn(GetWorld(), 0) );
      if( widgets[c].spellName )
        avatar->CastSpell( widgets[c].spellName );
    }
  }
}

这与我们的左键点击功能非常相似。我们只是将点击位置与所有小部件进行比较。如果任何Widget被右键点击,并且该Widget与一个Spell对象相关联,那么将通过调用角色的CastSpell()方法施放一个法术。

激活右键点击

要将此 HUD 函数连接到运行,我们需要将事件处理程序附加到鼠标右键。我们可以通过转到设置 | 项目设置,然后从弹出的对话框中添加一个输入选项,用于右键按钮,如下面的截图所示:

激活右键点击

Avatar.h/Avatar.cpp中声明一个名为MouseRightClicked()的函数,代码如下:

void AAvatar::MouseRightClicked()
{
  if( inventoryShowing )
  {
    APlayerController* PController = GetWorld()- >GetFirstPlayerController();
    AMyHUD* hud = Cast<AMyHUD>( PController->GetHUD() );
    hud->MouseRightClicked();
  }
}

然后,在AAvatar::SetupPlayerInputComponent()中,我们应该将MouseClickedRMB事件附加到MouseRightClicked()函数:

// In AAvatar::SetupPlayerInputComponent():
InputComponent->BindAction( "MouseClickedRMB", IE_Pressed, this,  &AAvatar::MouseRightClicked );

我们终于连接了施法。试试看,游戏玩法相当酷,如下面的截图所示:

激活右键点击

创建其他法术

通过玩弄粒子系统,你可以创建各种不同的法术,产生不同的效果。

火焰法术

你可以轻松地通过将粒子系统的颜色改为红色来创建我们暴风雪法术的火焰变体:

火焰法术

颜色值变为红色

练习

尝试以下练习:

  1. 闪电法术:通过使用光束粒子创建闪电法术。遵循 Zak 的教程,了解如何创建和向某个方向发射光束的示例,请参阅www.youtube.com/watch?v=ywd3lFOuMV8&list=PLZlv_N0_O1gYDLyB3LVfjYIcbBe8NqR8t&index=7

  2. 护盾法术:护盾会反弹攻击。这对任何玩家都是必不可少的。建议实现:从ASpell派生一个子类,称为ASpellForceField。向该类添加一个边界球体,并在ASpellForceField::Tick()函数中使用它将怪物推出。

接下来是什么?我强烈建议你扩展我们的小游戏。以下是一些扩展的想法:

  • 创建更多环境,扩展地形,添加更多房屋和建筑

  • 添加来自 NPC 的任务

  • 定义更多近战武器,例如,剑

  • 为玩家定义盔甲,例如,盾牌

  • 为玩家添加出售武器的商店

  • 添加更多怪物类型

  • 实现怪物掉落物品

你面前的工作时间实际上有成千上万个小时。如果你碰巧是一个独立程序员,与其他程序员建立工作关系。你无法独自在游戏市场中生存。

独自一人去是危险的——带上一个朋友。

摘要

这就结束了这一章。你已经走了很长的路。从对 C++编程一无所知,到希望能够在 UE4 中编写一个基本的游戏程序。

posted @ 2025-10-07 17:57  绝不原创的飞龙  阅读(39)  评论(0)    收藏  举报