通过使用-Unreal4-构建游戏学习-C++(全)
通过使用 Unreal4 构建游戏学习 C++(全)
原文:
annas-archive.org/md5/1c4190d0f9858df324374dcae7b4dd27
译者:飞龙
前言
因此,您想要使用 Unreal Engine 4(UE4)编写自己的游戏。您有很多理由这样做:UE4 功能强大——UE4 提供了一些最先进、美丽和逼真的光照和物理效果,这些效果是 AAA 工作室使用的类型。
UE4 是设备无关的:为 UE4 编写的代码将在 Windows 台式机、Mac 台式机、所有主要游戏主机(如果您是官方开发人员)、Android 设备和 iOS 设备上运行(在撰写本书时——将来可能支持更多设备!)。因此,您可以使用 UE4 一次编写游戏的主要部分,然后在不经过任何麻烦的情况下部署到 iOS 和 Android 市场。当然,会有一些小问题:iOS 和 Android 应用内购买和通知将需要单独编程,还可能存在其他差异。
本书适合对象
本书适合任何想学习游戏编程的人。我们将逐步创建一个简单的游戏,因此您将对整个过程有一个很好的了解。
本书也适合任何想学习 C++,特别是 C++17 的人。我们将介绍 C++的基础知识以及如何在其中编程,并介绍最新 C++版本中的一些新功能。
最后,本书适合任何想学习 UE4 的人。我们将使用它来创建我们的游戏。我们将主要关注 C++方面,但也会涉及一些基本的蓝图开发。
本书涵盖内容
第一章,“使用 C++17 入门”,介绍了如何在 Visual Studio Community 2017 或 Xcode 中创建您的第一个 C++项目。我们将创建我们的第一个简单的 C++程序。
第二章,“变量和内存”,涵盖了不同类型的变量,C++中存储数据的基本方法,以及指针、命名空间和控制台应用程序中的基本输入和输出。
第三章,“If、Else 和 Switch”,涵盖了 C++中的基本逻辑语句,允许您根据变量中的值在代码中做出选择。
第四章,“循环”,介绍了如何运行一段代码一定次数,或者直到条件为真。它还涵盖了逻辑运算符,并且我们将看到 UE4 中的第一个代码示例。
第五章,“函数和宏”,介绍了如何设置可以从代码的其他部分调用的代码部分。我们还将介绍如何传递值或获取返回值,并涉及与变量相关的一些更高级的主题。
第六章,“对象、类和继承”,介绍了 C++中的对象,它们是将数据成员和成员函数绑定在一起形成的代码片段,称为类或结构。我们将学习封装以及如何更轻松、更高效地编程对象,使其保持自己的内部状态。
第七章,“动态内存分配”,讨论了动态内存分配以及如何为对象组在内存中创建空间。本章介绍了 C 和 C++风格的数组和向量。在大多数 UE4 代码中,您将使用 UE4 编辑器内置的集合类。
第八章,“角色和棋子”,介绍了如何创建角色并在屏幕上显示它,使用轴绑定控制角色,并创建并显示可以向 HUD 发布消息的 NPC。
第九章,“模板和常用容器”,介绍了如何在 C++中使用模板,并讨论了在 UE4 和 C++标准模板库中可用的基于模板的数据结构。
第十章,库存系统和拾取物品,我们将为玩家编写和设计一个背包来存放物品。当用户按下I键时,我们将显示玩家携带的物品。我们将学习如何为玩家设置多个拾取物品。
第十一章,怪物,介绍了如何添加一个景观。玩家将沿着为他们雕刻出的路径行走,然后他们将遇到一支军队。您将学习如何在屏幕上实例化怪物,让它们追逐玩家并攻击他们。
第十二章,使用高级人工智能构建更智能的怪物,介绍了人工智能的基础知识。我们将学习如何使用 NavMesh、行为树和其他人工智能技术,使你的怪物看起来更聪明。
第十三章,魔法书,介绍了如何在游戏中创建防御法术,以及用于可视化显示法术的粒子系统。
第十四章,使用 UMG 和音频改进 UI 反馈,介绍了如何使用新的 UMG UI 系统向用户显示游戏信息。我们将使用 UMG 更新您的库存窗口,使其更简单、更美观,并提供创建自己 UI 的技巧。还介绍了如何添加基本音频以增强游戏体验。
第十五章,虚拟现实及更多,概述了 UE4 在 VR、AR、过程式编程、附加组件和不同平台上的能力。
要充分利用本书
在本书中,我们不假设您具有任何编程背景,因此如果您是完全初学者,也没关系!但是最好了解一些关于计算机的知识,以及一些基本的游戏概念。当然,如果您想编写游戏,那么您很可能至少玩过几款游戏!
我们将运行 Unreal Engine 4 和 Visual Studio 2017(或者如果您使用 Mac,则是 Xcode),因此您可能希望确保您的计算机是最新的、性能较强的计算机(如果您想进行 VR,则确保您的计算机已准备好 VR)。
另外,请做好准备!UE4 使用 C++,您可以很快学会基础知识(我们将在这里学到),但要真正掌握这门语言可能需要很长时间。如果您正在寻找一个快速简单的方式来创建游戏,还有其他工具可供选择,但如果您真的想学习能够带来编程游戏职业技能,这是一个很好的起点!
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packt.com/support注册并直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择“SUPPORT”选项卡。
-
单击“Code Downloads & Errata”。
-
在搜索框中输入书名并按照屏幕上的说明操作。
下载文件后,请确保使用最新版本的解压软件解压文件夹:
-
Windows 系统使用 WinRAR/7-Zip
-
Mac 系统使用 Zipeg/iZip/UnRarX
-
Linux 系统使用 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-Cpp-by-Building-Games-with-Unreal-Engine-4-Second-Edition
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/
上找到。快去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788476249_ColorImages.pdf
。
本书使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:"我们看到的第一件事是一个#include
语句。我们要求 C++复制并粘贴另一个 C++源文件的内容,名为<iostream>
。"
代码块设置如下:
#include <iostream>
using namespace std;
int main()
{
cout << "Hello, world" << endl;
cout << "I am now a C++ programmer." << endl;
return 0;
}
当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:
string name;
int goldPieces;
float hp;
粗体:表示一个新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中出现。这是一个例子:"打开 Epic Games Launcher 应用程序。选择启动 Unreal Engine 4.20.X。"
警告或重要说明看起来像这样。
提示和技巧看起来像这样。
第一章:开始使用 C++17
学术界经常在理论上描述编程概念,但喜欢把实现留给别人,最好是来自行业的人。在这本书中,我们将涵盖所有内容:我们将描述 C++概念的理论,并实现我们自己的游戏。如果您是第一次编程,您有很多东西要学习!
我首先建议您做练习。仅仅通过阅读是学不会编程的。您必须在练习中应用理论,才能吸收并将来能够使用它。
我们将从编写非常简单的 C++程序开始。我知道您现在想要开始玩您完成的游戏。但是,您必须从头开始才能达到目标(如果您真的想要,可以跳到第十三章, 咒语书,或打开一些示例来感受我们的方向)。
在本章中,我们将涵盖以下主题:
-
设置一个新项目(在 Visual Studio 或 Xcode 中)
-
您的第一个 C++项目
-
如何处理错误
-
什么是构建和编译?
设置我们的项目
我们的第一个 C++程序将在 UE4 之外编写。首先,我将为 Xcode 和 Visual Studio 2017 提供步骤,但在本章之后,我将尝试只讨论 C++代码,而不涉及您是使用 Microsoft Windows 还是 macOS。
在 Windows 上使用 Microsoft Visual Studio
在本节中,我们将安装一个允许您编辑 Windows 代码的集成开发环境(IDE),即微软的 Visual Studio。如果您使用的是 Mac,请跳到下一节。
下载和安装 Visual Studio
首先,下载并安装 Microsoft Visual Studio Community 2017。
Visual Studio 的 Community 版本是微软在其网站上提供的免费版本。前往www.visualstudio.com/downloads/
进行下载,然后开始安装过程。
您可以在这里找到完整的安装说明:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio?view=vs-2017
。当您到达“工作负载”部分时,您将需要选择“使用 C++进行桌面开发”。
安装了 Visual Studio Community 2017 后,打开它。软件的图标如下所示:
在 Visual Studio 中开始一个新项目
按照以下步骤进行,直到您能够实际输入代码:
- 从“文件”菜单中,选择“新建 | 项目...”,如下截图所示:
- 您将会得到以下对话框:
请注意底部有一个带有“解决方案名称”文本的小框。一般来说,Visual Studio 解决方案可能包含许多项目。但是,本书只使用单个项目,但有时您可能会发现将许多项目集成到同一个解决方案中很有用。
-
现在有五件事情要处理,如下所示:
-
从左侧面板选择“在线 | 模板 | Visual C++”
-
从右侧面板选择“控制台应用程序(通用)项目模板”
-
命名您的应用(我使用了
MyFirstApp
) -
选择一个文件夹保存您的代码
-
点击“确定”按钮
-
如果您以前从未使用过此模板,它将打开 VSIX 安装程序并显示此对话框:
- 点击“修改”。它将安装并关闭 Visual Studio。如果您看到此对话框,您可能需要点击“结束任务”:
-
然后,它将为您安装项目模板。这将需要很长时间,但您只需要做一次。完成后,点击“关闭”并重新启动 Visual Studio。
-
您需要从文件|新建|项目...重新开始之前的步骤。这次,在已安装的项目下,Visual C++将显示出来:
- 选择空项目,您可以将名称从 Project1 更改为您想要的任何名称,在我的案例中是 MyFirstApp。
现在,您已经进入了 Visual Studio 2017 环境。这是您将进行所有工作和编码的地方。
然而,我们需要一个文件来写入我们的代码。因此,我们将通过在“解决方案资源管理器”中右键单击项目名称并选择添加|新项目来向我们的项目添加一个 C++代码文件,如下截图所示:
按照以下截图所示,添加您的新的 C++(.cpp
)源代码文件:
Source.cpp
现在已经打开并准备好让您添加代码。跳转到创建您的第一个 C++程序部分并开始。
在 Mac 上使用 Xcode
在这一部分,我们将讨论如何在 Mac 上安装 Xcode。如果您使用 Windows,请跳转到下一节。
下载和安装 Xcode
Xcode 可以在 Apple 应用商店上的所有 Mac 电脑上免费获取。
如果可能的话,您应该获取最新版本。截至目前为止,它是 Xcode 10,但至少需要 macOS Sierra 或(最好是)High Sierra。如果您的 Mac 较旧并且运行较旧的操作系统,您可以免费下载操作系统更新,只要您使用的机器足够新来支持它。
只需在 Apple 应用商店上搜索 Xcode,如图所示:
只需点击获取按钮,等待下载和安装。
在 Xcode 中开始一个新项目
- 安装完 Xcode 后,打开它。然后,要么选择在打开的启动画面上创建一个新的 Xcode 项目,要么从屏幕顶部的系统菜单栏中导航到文件|新建|项目...,如下截图所示:
- 在新项目对话框中,在屏幕顶部的 macOS 下的应用程序部分中,选择命令行工具。然后,点击下一步:
- 在下一个对话框中,命名您的项目。确保填写所有字段,否则 Xcode 将不允许您继续。确保项目的类型设置为 C++,然后点击下一步按钮,如图所示:
- 接下来的弹出窗口将要求您选择一个位置以保存您的项目。在硬盘上选择一个位置并将其保存在那里。Xcode 默认情况下为您创建每个项目的 Git 存储库。您可以取消选中创建 git 存储库,因为我们在本章中不涉及 Git,如下截图所示:
Git 是一个版本控制系统。这基本上意味着 Git 会定期(每次提交到存储库时)获取并保留项目中所有代码的快照。其他流行的源代码控制管理(SCM)工具包括 Mercurial、Perforce 和 Subversion。当多人在同一个项目上合作时,SCM 工具具有自动合并和复制其他人对存储库的更改到您的本地代码库的能力。
好了!您已经准备好了。在 Xcode 的左侧面板中点击main.cpp
文件。如果文件没有出现,请确保首先选择左侧面板顶部的文件夹图标,如下截图所示:
创建您的第一个 C++程序
我们现在要编写一些 C++源代码。我们称之为源代码有一个非常重要的原因:它是构建二进制可执行代码的源头。相同的 C++源代码可以在 Mac、Windows 和移动平台等不同平台上构建,并且理论上在每个相应的平台上执行相同操作的可执行代码应该是一样的。
在不太久远的过去,在引入 C 和 C++之前,程序员为他们单独针对的每台特定机器编写代码。他们用一种称为汇编语言的语言编写代码。但现在,有了 C 和 C++,程序员只需编写一次代码,就可以通过使用不同的编译器构建相同的源代码,将其部署到许多不同的机器上。
实际上,Visual Studio 的 C++版本和 Xcode 的 C++版本之间存在一些差异,但这些差异主要出现在处理高级 C++概念(如模板)时。在处理多个平台时,UE4 非常有帮助。
Epic Games 付出了大量的工作,以使相同的代码在 Windows 和 Mac 上以及许多其他平台(如移动平台和游戏机)上运行。
现实世界的提示
使代码在所有机器上以相同的方式运行非常重要,特别是对于联网游戏或允许诸如可共享的重放之类的游戏。这可以通过标准来实现。例如,IEEE 浮点标准用于在所有 C++编译器上实现十进制数学。这意味着诸如 200 * 3.14159 之类的计算结果应该在所有机器上相同。没有标准,不同的编译器可能(例如)以不同的方式四舍五入数字,而在有许多计算且代码需要精确时,这可能会导致不可接受的差异。
在 Microsoft Visual Studio 或 Xcode 中编写以下代码:
#include <iostream>
using namespace std;
int main()
{
cout << "Hello, world" << endl;
cout << "I am now a C++ programmer." << endl;
return 0;
}
为了解释发生了什么,这里是相同的代码,但添加了注释(在//
之后的任何内容都将被编译器忽略,但可以帮助解释发生了什么)。
#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(或使用 Debug | Start Without Debugging 菜单)在 Visual Studio 中运行上述代码,或按Command + R(Product | Run)在 Xcode 中运行。在 Visual Studio 中第一次按Ctrl + F5时,您会看到此对话框:
如果您不想每次运行程序时都看到这个对话框,请选择是并且不再显示此对话框。
以下是在 Windows 上应该看到的内容:
这是在 Mac 上的情况:
如果您在 Windows 上,您可能会注意到当您运行它时窗口会自动关闭,因此您无法看到结果。有各种方法可以解决这个问题,包括更改设置以暂停并让您按键继续。您可以在这里获取更多信息:stackoverflow.com/questions/454681/how-to-keep-the-console-window-open-in-visual-c/1152873#1152873
您可能首先想到的是“哎呀!一大堆胡言乱语!”
实际上,您很少看到井号(#)符号的使用(除非您使用 Twitter)和花括号对{``}
在正常的英文文本中。但是,在 C++代码中,这些奇怪的符号随处可见。您只需习惯它们。
因此,让我们解释一下这个程序,从第一行开始。
这是程序的第一行:
#include <iostream> // Import the input-output library
这行有两个重要的要点需要注意:
-
我们看到的第一件事是一个
#include
语句。我们要求 C++将另一个 C++源文件的内容,称为<iostream>
,直接复制粘贴到我们的代码文件中。<iostream>
是一个标准的 C++库,处理所有让我们将文本打印到屏幕上的代码。 -
我们注意到的第二件事是一个
//
注释。如前所述,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++程序知道从哪里开始的方式。
如果你没有一个int main()
程序标记,或者main
拼写错误,那么你的程序就不会工作,因为程序不知道从哪里开始。
下一行是一个你不经常看到的字符:
{
这个{
字符不是一个侧面的胡须。它被称为花括号,表示程序的起点。
接下来的两行将文本打印到屏幕上:
cout << "Hello, world" << endl;
cout << "I am now a C++ programmer." << endl;
cout
语句代表控制台输出。双引号之间的文本将以与引号之间的内容完全相同的方式输出到控制台。你可以在双引号之间写任何你想写的东西,除了双引号,它仍然是有效的代码。另外,请注意endl
告诉cout
添加一个换行(回车)字符,这对于格式化非常有用。
要在双引号之间输入双引号,你需要在你想要放在字符串中的双引号字符前面加上一个反斜杠(\),如下所示:
cout << "John shouted into the cave \"Hello!\" The cave echoed."
\"
符号是转义序列的一个例子。还有其他转义序列可以使用;你会发现最常见的转义序列是\n
,它用于将文本输出跳转到下一行。
程序的最后一行是return
语句:
return 0;
这行代码表示 C++程序正在退出。你可以把return
语句看作是返回到操作系统。
最后,你的程序的结束由闭合的花括号表示,这是一个相反的侧面胡须:
}
分号
分号(;)在 C++编程中很重要。请注意在前面的代码示例中,大多数代码行都以分号结束。如果你不在每行末尾加上分号,你的代码将无法编译,如果发生这种情况,你的雇主将不会很高兴(当然,一旦你做了一段时间,你会在他们发现之前找到并修复这些问题)。
处理错误
如果你在输入代码时犯了一个错误,那么你将会有一个语法错误。面对语法错误,C++会大声尖叫,你的程序甚至不会编译;而且,它也不会运行。
让我们试着在之前的 C++代码中插入一些错误:
警告!这段代码清单包含错误。找到并修复所有错误是一个很好的练习!
作为练习,试着找到并修复这个程序中的所有错误。
请注意,如果你对 C++非常陌生,这可能是一个很难的练习。然而,这将向你展示在编写 C++代码时需要多么小心。
修复编译错误可能是一件麻烦的事情。然而,如果你将这个程序的文本输入到你的代码编辑器中并尝试编译它,它将导致编译器向你报告所有的错误。逐个修复错误,然后尝试重新编译(从列表中的第一个开始,因为它可能导致后面的一些错误)。一个新的错误将弹出,或者程序将正常工作,如下面的屏幕截图所示:
当你尝试编译代码时,你的编译器会显示代码中的错误(尽管如果你使用 Visual Studio,它会询问你是否要先运行之前成功的构建)。
我展示这个示例程序的原因是鼓励以下工作流程,只要你是 C++的新手:
-
始终从一个可工作的 C++代码示例开始。你可以从创建 你的第一个 C++程序部分分叉出一堆新的 C++程序。
-
在小步骤中进行代码修改。当你是新手时,每写一行新代码后进行编译。不要一两个小时编码,然后一次性编译所有新代码。
-
你可能需要几个月的时间才能写出第一次就能正常运行的代码。不要灰心。学习编码是有趣的。
C++中的警告
编译器会标记它认为可能是错误的东西。这些是另一类编译器通知,称为警告。警告是你代码中的问题,你不必修复它们才能运行你的代码,但编译器建议修复。警告通常是代码不够完美的指示,修复代码中的警告通常被认为是良好的做法。
然而,并非所有的警告都会在你的代码中引起问题。一些程序员喜欢禁用他们认为不是问题的警告(例如,警告 4018 警告有符号/无符号不匹配,你很可能以后会看到)。
什么是构建和编译?
你可能听说过一个计算机进程术语叫做编译。编译是将你的 C++程序转换为可以在 CPU 上运行的代码的过程。构建你的源代码意味着与编译相同的事情。
看,你的源代码code.cpp
文件实际上不会在计算机上运行。它必须首先进行编译才能运行。
这就是使用 Microsoft Visual Studio Community 或 Xcode 的全部意义。Visual Studio 和 Xcode 都是编译器。你可以在任何文本编辑程序中编写 C++源代码,甚至在记事本中。但是你需要一个编译器在你的机器上运行它。
每个操作系统通常都有一个或多个可以在该平台上运行的 C++编译器。在 Windows 上,你有 Visual Studio 和 Intel C++ Studio 编译器。在 Mac 上,有 Xcode,在 Windows、Mac 和 Linux 上都有GNU 编译器集合(GCC)。
我们编写的相同的 C++代码(源代码)可以使用不同的编译器在不同的操作系统上编译,并且理论上它们应该产生相同的结果。在不同平台上编译相同的代码的能力称为可移植性。一般来说,可移植性是一件好事。
示例输出
这是你的第一个 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++编程语言编写我们的第一个程序。这是一个简单的程序,但是你应该把编译和运行你的第一个程序视为你的第一次胜利。在接下来的章节中,我们将组合更复杂的程序,并开始在我们的游戏中使用虚幻引擎。
第二章:变量和内存
为了编写你的 C++游戏程序,你需要让你的计算机记住很多东西,比如玩家在世界的位置,他们有多少生命值,还剩下多少弹药,世界中物品的位置,它们提供的增益效果,以及组成玩家屏幕名字的字母。
你的计算机实际上有一种叫做内存或 RAM 的电子素描板。从物理上讲,计算机内存是由硅制成的,看起来与下面的照片相似:
这块 RAM 看起来像停车场吗?因为这就是我们要使用的隐喻。
RAM 是随机存取存储器的缩写。它被称为随机存取,因为你可以随时访问它的任何部分。如果你还有一些 CD 在身边,它们就是非随机存取的例子。CD 是按顺序读取和播放的。我还记得很久以前在 CD 上切换曲目需要很长时间!然而,跳跃和访问 RAM 的不同单元并不需要太多时间。RAM 是一种快速存储器访问的类型,称为闪存存储器。
RAM 被称为易失性闪存存储器,因为当计算机关闭时,RAM 的内容被清除,除非它们首先保存到硬盘上,否则 RAM 的旧内容将丢失。
对于永久存储,你必须把你的数据保存到硬盘上。有两种主要类型的硬盘:
-
基于盘片的硬盘驱动器(HDDs)
-
固态硬盘(SSD)
SSD 比基于盘片的 HDD 更现代,因为它们使用 RAM 的快速访问(闪存)存储原理。然而,与 RAM 不同,SSD 上的数据在计算机关闭后仍然存在。如果你能得到一个 SSD,我强烈建议你使用它!基于盘片的驱动器已经过时了。
当程序运行时,访问存储在 RAM 中的数据比从 HDD 或 SSD 中访问要快得多,所以我们需要一种方法来在 RAM 上保留空间并从中读取和写入。幸运的是,C++使这变得容易。
变量
在计算机内存中保存的位置,我们可以读取或写入,称为变量。
变量是一个值可以变化的组件。在计算机程序中,你可以把变量看作是一个容器,可以在其中存储一些数据。在 C++中,这些数据容器(变量)有类型和名称,你可以用来引用它们。你必须使用正确类型的数据容器来保存你的程序中的数据。
如果你想保存一个整数,比如 1、0 或 20,你将使用int
类型的容器。你可以使用 float 类型的容器来携带浮点(小数)值,比如 38.87,你可以使用字符串变量来携带字母字符串(把它想象成一串珍珠,其中每个字母都是一颗珍珠)。
你可以把你在 RAM 中保留的位置看作是在停车场预留一个停车位:一旦我们声明了我们的变量并为它获得了一个位置,操作系统就不会把那块 RAM 的其他部分分配给其他程序(甚至是在同一台机器上运行的其他程序)。你的变量旁边的 RAM 可能未被使用,也可能被其他程序使用。
操作系统的存在是为了防止程序相互干扰,同时访问计算机硬件的相同位。一般来说,文明的计算机程序不应该读取或写入其他程序的内存。然而,一些类型的作弊程序(例如,地图黑客)会秘密访问你程序的内存。像 PunkBuster 这样的程序被引入来防止在线游戏中的作弊。
声明变量——触摸硅
使用 C++在计算机内存中保留一个位置很容易。我们想要用一个好的、描述性的名字来命名我们将在其中存储数据的内存块。
例如,假设我们知道玩家的生命值(hp)将是一个整数(整数)数字,例如 1、2、3 或 100。为了让硅片在内存中存储玩家的hp
,我们将声明以下代码行:
int hp; // declare variable to store the player's hp
这行代码保留了一小块 RAM 来存储称为hp
的整数(int
是整数的缩写)。以下是我们用来存储玩家hp
的 RAM 块的示例。这在内存中为我们保留了一个停车位(在所有其他停车位中),我们可以通过其标签(hp
)引用内存中的这个空间:
在内存中的所有其他空间中,我们有一个地方来存储我们的 hp 数据。
当您命名变量时,有一些规则。变量名称不能以数字开头,编译器不允许使用某些“保留字”(通常是因为它们被 C++本身使用)。随着您学习更多的 C++,您将学到这些,或者您可以在网上寻找保留字列表。
请注意,变量空间在此图中标记为int
,如果它是双精度或其他类型的变量空间。C++不仅通过名称记住您在内存中为程序保留的空间,还通过变量的类型记住它。
请注意,我们还没有把任何东西放在 hp 的盒子里!我们稍后会这样做——现在,hp
变量的值尚未设置,因此它将具有上一个占用者(也许是另一个程序留下的值)留在那个停车位上的值。告诉 C++变量的类型很重要!稍后,我们将声明一个变量来存储十进制值,例如 3.75。
读取和写入内存中保留的位置
将值写入内存很容易!一旦有了hp
变量,您只需使用=
符号写入它:
hp = 500;
哇!玩家有 500 hp。
读取变量同样简单。要打印变量的值,只需输入以下内容:
cout << hp << endl;
这将打印存储在hp
变量中的值。cout
对象足够聪明,可以弄清楚它是什么类型的变量,并打印内容。如果您更改hp
的值,然后再次使用cout
,将打印最新的值,如下所示:
hp = 1200;
cout << hp << endl; // now shows 1200
数字和数学
标题说明了一切;在本节中,我们将深入探讨 C++中数字和数学的重要性。
数字就是一切
开始计算机编程时,你需要习惯的一件事是,令人惊讶的是,许多东西可以仅以数字形式存储在计算机内存中。玩家的生命值?正如我们在前一节中所看到的,生命值可以只是一个整数。如果玩家受伤,我们减少这个数字。如果玩家获得健康,我们增加这个数字。
颜色也可以存储为数字!如果您使用标准的图像编辑程序,可能会有滑块指示颜色使用了多少红色、绿色和蓝色,例如 Pixelmator 的颜色滑块,如果您使用过的话。Photoshop 没有滑块,但会显示数字,并允许您直接编辑以更改颜色。然后,颜色由三个数字表示。以下截图中显示的紫色是(R:127
,G:34
,B:203
):
正如您所看到的,Photoshop 允许您使用其他数字来表示颜色,例如 HSB(色调、饱和度、亮度),这是表示颜色的另一种方式,或者 CMYK(青色、品红色、黄色、黑色),用于印刷,因为专业印刷机使用这些颜色油墨进行印刷。对于在计算机显示器上查看,您通常会坚持使用 RGB 颜色表示,因为这是显示器使用的颜色。
世界几何呢?这些也只是数字;我们所要做的就是存储一组 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 分配的空间。
当您运行程序时,您应该看到以下内容:
一般来说,您应该始终尝试将正确类型的数据存储在正确类型的变量中。如果您存储了错误类型的数据,您的代码可能会表现异常。例如,意外地将浮点数存储到int
变量中将使您丢失小数点,并且将字符的值存储在int
中将给出 ASCII 值,但不再将其视为字母。有时,甚至没有任何类型的自动类型转换,因此它将不知道如何处理该值。
C++中的数学运算
C++中的数学运算很容易;+
(加)、-
(减)、*
(乘)、/
(除)都是常见的 C++操作,将遵循正确的括号、指数、除法、乘法、加法和减法(BEDMAS)顺序。例如,我们可以按照以下代码中所示的方式进行:
int answer = 277 + 5 * 4 / 2 + 20;
当然,如果你想要绝对确定顺序,使用括号总是一个好主意。你可能还不熟悉的另一个运算符是%(取模)。取模(例如,10 % 3)找到x
(10)除以y
(3)时的余数。请参考以下表格中的示例:
运算符(名称) | 示例 | 答案 |
---|---|---|
+ (plus) | 7 + 3 | 10 |
- (minus) | 8 - 5 | 3 |
* (times) | 5*6 | 30 |
/ (division) | 12/6 | 2 |
% (modulus) | 10 % 3 | 1(因为 10/3 是 3,余数=1)。 |
然而,我们通常不希望以这种方式进行数学运算。相反,我们通常希望按一定计算的数量更改变量的值。这是一个更难理解的概念。假设玩家遇到一个小恶魔并受到 15 点伤害。
以下代码将用于减少玩家的hp
15
(信不信由你):
hp = hp - 15; // probably confusing :)
你可能会问为什么。因为在右侧,我们正在计算 hp 的新值(hp-15
)。找到 hp 的新值(比以前少 15),然后将新值写入hp
变量。
将hp
视为墙上特定位置的绘画。-15
告诉您在绘画上画上胡须,但保持在原地。新的、留着胡须的绘画现在是hp
。
陷阱
未初始化的变量具有在内存中保存的位模式。声明变量不会清除内存。
因此,假设我们使用以下行代码:
int hp;
hp = hp - 15;
第二行代码将 hp 从其先前的值减少 15。如果我们从未设置hp = 100
或其他值,那么它的先前值是多少?它可能是 0,但并非总是如此。
最常见的错误之一是在未初始化变量的情况下继续使用变量。
以下是进行此操作的简写语法:
hp -= 15;
除了-=
,您还可以使用+=
将一定数量添加到变量,*=
将变量乘以一定数量,/=
将变量除以一定数量。
如果您使用int
并希望将其增加(或减少)1,可以缩短语法。您不需要编写以下内容:
hp = hp + 1;
hp = hp - 1;
您也可以执行以下任何操作:
hp++;
++hp;
hp--;
--hp;
将其放在变量之前会在使用变量之前递增或递减变量(如果您在较大的语句中使用它)。将其放在后面会在使用变量后更新变量。
练习
执行以下操作后写下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或+。它以 ASCII 存储为-127 到 127 的数字值,ASCII 是一种为每个字符分配特定数字值的标准。 |
---|---|
Short |
从-32,767 到+32,768 的整数。 |
Int |
从-2,147,483,647 到+2,147,483,648 的整数。 |
Long |
从-2,147,483,647 到+2,147,483,648 的整数。 |
Float |
从约-1x10³⁸ 到1x10³⁸ 的任何小数值。 |
Double |
从约-1x10³⁰⁸ 到1x10³⁰⁸ 的任何小数值。 |
Bool |
真或假。 |
在前面的表中提到的每种变量类型都有无符号版本(当然,Bool 除外,这实际上没有什么意义)。无符号变量可以包含自然数,包括 0(x >= 0)。例如,无符号short
的值可能在0
和65535
之间。如果需要,您还可以使用long long
或long long int
获得更大的整数。
变量的大小有时在不同的编译器中可能会有所不同,或者取决于您是为 32 位还是 64 位操作系统进行编译。如果您将来发现自己在处理不同的东西,请记住这一点。
在这种情况下,我们关注的是 Visual Studio 或 Xcode 和(很可能)64 位。
如果你对浮点数和双精度之间的区别感兴趣,请随时在互联网上查找。我只会解释用于游戏的最重要的 C++概念。如果你对这个文本未涵盖的内容感到好奇,请随时查找。
高级变量主题
C++的更新版本添加了一些与变量相关的新功能,还有一些尚未提及的功能。以下是一些你应该记住的事情。
自动检测类型
从 C++ 11 开始,有一种新的变量类型,可以用于你可能不确定期望得到的类型的情况。这种新类型叫做auto
。它的意思是它将检测你首先分配给它的任何值的类型,然后使用它。比如你输入以下内容:
auto x = 1.5;
auto y = true;
如果你这样做,x
将自动成为一个浮点数,y
将成为一个布尔值。一般来说,如果你知道变量的实际类型(大多数情况下你会知道),最好避免使用它。然而,你应该能够在看到它时识别它,并且在最终需要它的情况下了解它。
枚举
枚举类型已经存在很长时间了,但是从 C++ 11 开始,你可以更好地控制它们。枚举的想法有时是你想要在游戏中跟踪不同类型的东西,你只是想要一种简单的方法来给每个值,告诉你它是什么,以及你以后可以检查它。枚举看起来像下面这样:
enum weapon {
sword = 0;
knife,
axe,
mace,
numberOfWeaponTypes,
defaultWeapon = mace
}; // Note the semicolon at the end
这将创建每种武器类型,并通过为每种武器类型加 1 来分配每种武器类型一个唯一的值,因此刀将等于 1,斧头将等于 2,依此类推。请注意,你不需要将第一个设置为 0(它会自动设置),但如果你想从不同的数字开始,你可以这样做(不仅仅是第一个可以设置为特定的值)。你还可以将任何enum
成员分配给另一个不同的成员,它将具有相同的值(在这个例子中,defaultWeapon
具有与mace
相同的值:3)。在枚举列表中的任何地方分配特定值时,列表中之后添加的任何类型将从该值开始递增 1。
枚举类型一直包含一个 int 值,但是从 C++ 11 开始,你可以指定一个变量类型。例如,你可能想做类似以下的事情:
enum isAlive : bool {
alive = true,
dead = false
}
虽然你可以用 0 和 1 来做到这一点,但在某些情况下,你可能会发现这更方便。
常量变量
有时你会有一个值,你不希望在游戏过程中改变。你不希望像生命值、最大生命值、达到特定级别所需的经验值或移动速度这样的东西改变(除非你的角色确实达到了那个级别,在这种情况下,你可能会切换到另一个常量值)。
在某些情况下,enum
可以解决这个问题,但对于单个值,更容易创建一个新变量并声明它为const
。这里有一个例子:
const int kNumLives = 5;
在变量类型前面放置const
告诉程序永远不要允许该值被更改,如果你尝试,它会给你一个错误。在变量名前面放置k
是const
变量的常见命名约定。许多公司会坚持要求你遵循这个标准。
构建更复杂的类型
事实证明,这些简单的数据类型本身可以用来构建任意复杂的程序。怎么做? 你会问。仅仅使用浮点数和整数来构建 3D 游戏难吗?
从float
和int
构建游戏并不是真的很困难,但更复杂的数据类型会有所帮助。如果我们使用松散的浮点数来表示玩家的位置,编程将会很乏味和混乱。
对象类型 - 结构
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 STRUCT DEFINITION
{
float x, y, z; // x, y, and z positions all floats
}; // END Vector STRUCT DEFINITION.
这告诉计算机Vector
是什么(它是三个浮点数,所有这些都被声明为坐在内存中的相邻位置)。在前面的图中显示了Vector
在内存中的样子。
接下来,我们使用我们的Vector
对象定义来创建一个名为v
的 Vector 实例:
Vector v; // Create a Vector instance called v
一旦您有了Vector
的实例,您就可以使用我们称之为点语法来访问其中的变量。您可以使用v.x
在 Vector v
上访问变量x
。struct
Vector 定义实际上并不创建 Vector 对象,它只是定义了对象类型。您不能做Vector.x = 1
。您在谈论哪个对象实例?C++编译器会问。您需要首先创建一个 Vector 实例,例如 Vector v
。这将创建一个 Vector 的实例并将其命名为v
。然后,您可以对v
实例进行赋值,例如v.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
对象定义一个 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;
me.position.x = me.position.y = me.position.z=0;
}
行me.position.x = me.position.y = me.position.z=0;
意味着me.position.z
设置为0
,然后将该值传递给me.position.y
设置为 0,然后传递并设置me.position.x
为0
。
struct Player
定义告诉计算机如何在内存中布置Player
对象。
我希望您注意到了结构声明末尾的必需分号。结构对象声明需要在末尾加上分号,但函数不需要(我们稍后会讨论函数)。这只是一个必须记住的 C++规则。
在Player
对象内部,我们声明了一个字符串用于玩家的名称,一个浮点数用于他们的 hp,以及一个Vector
对象用于他们完整的x
,y
和z
位置。
当我说对象时,我的意思是 C++结构(稍后我们将介绍术语类)。
等等!我们把一个 Vector 对象放在一个 Player 对象里!是的,你可以这样做。只要确保 Vector 在同一个文件中定义。
在定义了Player
对象内部的内容之后,我们实际上创建了一个名为me
的Player
对象实例,并为其分配了一些值。
指针
一个特别棘手的概念是指针的概念。指针并不难理解,但可能需要一段时间才能牢固掌握。指针基本上包含一个对象存储的内存地址,因此它们在内存中“指向”对象。
假设我们在内存中声明了一个Player
类型的变量:
Player me;
me.name = "William";
me.hp = 100;
我们现在声明一个指向Player
的指针:
Player* ptrMe; // Declaring a pointer to
// a Player object
*
改变了变量类型的含义。*
是使ptrMe
成为Player
对象的指针而不是常规Player
对象的原因。
我们现在想要将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
,如下图所示:
您可能会想为什么前面的数字包含一个F
。地址是十六进制(基数 16),因此在 9 之后数字位用完了,但实际上 1 中无法容纳两个数字,因此将值设置为 10-15 分别为 A-F。因此 A = 10,B = 11,在这种情况下 F = 15。
现在,创建并将指针变量分配给x
的地址:
int *px;
px = &x;
我们在这里做的是将x
的内存地址存储在px
变量中。因此,我们用另一个不同的变量px
来指向x
变量。这可能看起来类似于以下图示中所示的内容:
在这里,px
变量中包含了x
变量的地址。换句话说,px
变量是对另一个变量的引用。对px
进行解引用意味着访问px
引用的变量。解引用使用*
符号进行:
cout << *px << endl;
使用 nullptr
nullptr
变量是一个值为0
的指针变量。一般来说,大多数程序员喜欢在创建新指针变量时将指针初始化为nullptr
(0
)。一般来说,计算机程序无法访问内存地址0
(它是保留的),因此如果尝试引用空指针,程序将崩溃。
Pointer Fun with Binky是一个关于指针的有趣视频。请查看www.youtube.com/watch?v=i49_SNt4yfk
。
智能指针
指针可能很难管理。一旦我们在本书的后面开始创建和删除新对象,我们可能不知道所有指向特定对象的指针在哪里。删除仍在使用的另一个指针指向的对象可能太容易(导致崩溃),或者停止指向对象的唯一指针并使其漂浮在内存中而没有任何引用(这称为内存泄漏,并会减慢计算机的速度)。
智能指针跟踪特定对象存在多少引用,并将随着代码中的变化自动增加或减少这个数字。这使得更容易控制发生的事情,在实际编程中,尽可能使用普通指针更可取。
人们过去必须编写自己的智能指针,但自从 C++ 11 以来就不再需要了。现在有一个shared_ptr
模板可用(我们稍后会讨论模板和 STL)。这将自动跟踪指向对象的指针,并在没有其他引用它时自动删除该对象,防止内存泄漏。这就是为什么最好使用智能指针而不是指针,因为普通指针可能最终指向已在代码的其他地方被删除的对象。
输入和输出
在编程中,您不断需要向用户传递信息,或者从用户那里获取信息。对于我们将要开始的简单情况(以及后来查找错误的许多情况),您需要输入和输出标准文本和数字。C++使这变得很容易。
cin 和 cout 对象
我们已经在之前的例子中看到了cout
的工作原理。cin
对象是 C++传统上从用户输入程序中获取输入的方式。cin
对象易于使用,因为它查看将值放入的变量类型,并使用该类型来确定放入其中的类型。例如,假设我们想要询问用户的年龄并将其存储在int
变量中。我们可以这样做:
cout << "What is your age?" << endl;
int age;
cin >> age;
运行此代码时,它将打印What is your age?
并等待您的回答。输入一个回答并按Enter进行输入。您可能想尝试输入除int
变量之外的其他内容,以查看会发生什么!
printf()函数
尽管到目前为止我们已经使用cout
打印变量,但您还应该了解另一个常用函数,用于打印到控制台。这个函数称为printf
函数,最初来自 C。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 );
}
下载示例代码
您可以从您在www.packtpub.com
的帐户中下载示例代码文件,用于您购买的所有 Packt 图书。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support
并注册,以便直接通过电子邮件接收文件。
我们从一个格式字符串开始。格式字符串就像一个画框,变量将被插入到格式字符串中%
的位置。然后,整个东西被倾倒到控制台上。在前面的例子中,整数变量将被插入到格式字符串中第一个%
(%d
)的位置,字符将被插入到格式字符串中第二个%
(%c
)的位置,如下面的屏幕截图所示:
您必须使用正确的格式代码才能使输出正确格式化;请看下表:
数据类型 | 格式代码 |
---|---|
Int |
%d |
Char |
%c |
String |
%s |
要打印 C++字符串,您必须使用string.c_str()
函数:
string s = "Hello"; printf( "string %s\n", s.c_str() );
s.c_str()
函数访问字符串的 C 指针,这是printf
所需要的。
如果您使用错误的格式代码,输出将不会正确显示,或者程序可能会崩溃。
您可能还会发现需要使用这种类型的格式来设置字符串的情况,所以了解这一点是很好的。但是,如果您更喜欢避免记住这些不同的格式代码,只需使用cout
。它会为您找出类型。只要确保您使用您最终工作的公司所偏好的标准。在大多数编程事情中,这通常是一个好主意。
练习
询问用户姓名和年龄,并使用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;
//Change to printf:
cout << "Hello " << name << " I see you have attained " << age
<< " years. Congratulations." << endl;
}
字符串实际上是一种对象类型。在内部,它只是一堆字符!
命名空间
到目前为止,我们已经在std
的情况下看到了命名空间,并且大多数情况下通过在文件顶部放置以下内容来避免这个问题:
using namespace std;
但是,您应该知道这对未来意味着什么。
命名空间是将相关代码分组在一起的方式,它允许您在不同的命名空间中使用相同的变量名称而不会出现任何命名冲突(当然,除非您在顶部为两者都使用了using namespace
,这就是为什么许多人更喜欢不使用它的原因)。
您可以像这样在 C++文件中创建自己的命名空间:
namespace physics {
float gravity = 9.80665;
//Add the rest of your your physics related code here...
}
一旦您创建了命名空间,您就可以像这样访问该代码:
float g = physics::gravity;
或者,您可以在顶部放入一个使用语句(只要确保该名称没有用于其他用途)。但是,一般来说,您不希望在更复杂的程序中使用这个,因为命名空间允许您在不同的命名空间中重用相同的变量名称,因此如果您将其与一个包含当前命名空间中具有相同名称的变量的命名空间一起使用,并尝试访问它,编译器将不知道您指的是哪一个,这将导致冲突。
总结
在本章中,我们讨论了变量和内存。我们谈到了关于变量的数学运算,以及它们在 C++中是多么简单。
我们还讨论了如何使用这些更简单的数据类型(如浮点数、整数和字符)的组合来构建任意复杂的数据类型。这样的构造被称为对象。在下一章中,我们将开始讨论我们可以用这些对象做什么!
第三章:If,Else 和 Switch
在上一章中,我们讨论了内存的重要性以及如何将数据存储在计算机内部。我们谈到了如何使用变量为程序保留内存,并且我们可以在变量中包含不同类型的信息。
在本章中,我们将讨论如何控制程序的流程以及如何通过控制流语句分支代码。在这里,我们将讨论不同类型的控制流,如下所示:
-
If
语句 -
如何使用
==
运算符检查事物是否相等 -
else
语句 -
如何测试不等式(即,如何使用
>
,>=
,<
,<=
和!=
运算符检查一个数字是否大于或小于另一个数字) -
使用逻辑运算符(如非(
!
),和(&&
),或(||
)) -
分支超过两种方式:
-
else if
语句 -
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
表示true
,0
表示false
。如果您希望在1
和0
之外显示true
或false
,可以在cout
代码行中使用boolalpha
流操纵器,如下所示:
cout << "Is x equal to y? C++ says: " << boolalpha <<
(x == y) << endl;
==
运算符是一种比较运算符。C++使用==
来检查相等性的原因是,我们已经使用了=
符号作为赋值运算符!(请参阅第二章中的关于变量的更多信息部分)。如果使用单个=
符号,C++将假定我们要用y
覆盖x
,而不是比较它们。
编写 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
。
首先,我们从一个名为isHungry
的bool
变量开始,然后将其设置为true
。
然后,我们使用if
语句,如下所示:
if( isHungry == true )
if
语句就像是守卫下面的代码块(记住,代码块是在{
和}
中的一组代码):
只有当isHungry==true
时,您才能阅读{
和}
之间的代码。
只有当isHungry==true
时,您才能访问大括号内的代码。否则,您将被拒绝访问并被迫跳过整个代码块。
基本上,任何可以作为布尔值进行评估的东西都可以放在if(boolean)
中。因此,我们可以通过简单地编写以下代码行来实现相同的效果:
if(isHungry)//只有在 isHungry 为 true 时才会到这里
这可以用作以下内容的替代:
if(isHungry==true)
人们可能使用if(isHungry)
形式的原因是为了避免出错的可能性。意外写成if(isHungry = true)
会使isHungry
在每次命中if
语句时都设置为 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 语句
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
块。它总是一个或另一个:
如果 isHungry
不等于 true,则else
语句是您将要执行的方式。
您可以将if
/else
语句视为将人们引导到左侧或右侧的守卫。每个人都会朝着食物走(当isHungry==true
时),或者他们会远离食物(当isHungry==false
时)。
使用其他比较运算符(>,>=,<,<=和!=)进行不等式测试
C++中可以很容易地进行其他逻辑比较。 >
和 <
符号的意思与数学中一样。它们分别表示大于(>
)和小于(<
)。>=
在数学中与 ≥
符号具有相同的含义。<=
是 C++中 ≤
的代码。由于键盘上没有 ≤
符号,我们必须在 C++中使用两个字符来编写它。!=
是 C++中表示“不等于”的方式。因此,例如,假设我们有以下代码行:
int x = 9;
int y = 7;
我们可以询问计算机是否 x > y
或 x < 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
。
使用逻辑运算符
逻辑运算符允许您进行更复杂的检查,而不仅仅是检查简单的相等或不相等。例如,要获得进入特殊房间的条件需要玩家同时拥有红色和绿色钥匙卡。我们想要检查两个条件是否同时成立。为了进行这种复杂的逻辑语句检查,我们需要学习三个额外的构造:非(!
)、和(&&
)和或(||
)运算符。
非(!)运算符
!
运算符很方便,可以颠倒boolean
变量的值。以以下代码为例:
bool wearingSocks = true;
if( !wearingSocks ) // same as if( false == wearingSocks )
{
cout << "Get some socks on!" << endl;
}
else
{
cout << "You already have socks" << endl;
}
这里的if
语句检查您是否穿袜子。然后,您会收到一个命令来穿上一些袜子。!
运算符将boolean
变量中的值取反。
我们使用一个称为真值表的东西来显示在boolean
变量上使用!
运算符的所有可能结果,如下所示:
wearingSocks |
!wearingSocks |
---|---|
true |
false |
false |
true |
因此,当wearingSocks
的值为true
时,!wearingSocks
的值为false
,反之亦然。
练习
-
当
wearingSocks
的值为true
时,您认为!!wearingSocks
的值将是多少? -
在运行以下代码后,
isVisible
的值是多少?
bool hidden = true;
bool isVisible = !hidden;
解决方案
-
如果
wearingSocks
是true
,那么!wearingSocks
就是false
。因此,!!wearingSocks
再次变为true
。这就像在说“我不饿”。双重否定,所以这句话意味着我实际上是饿了。 -
第二个问题的答案是
false
。hidden
的值是true
,所以!hidden
是false
。然后false
的值被保存到isVisible
变量中。但hidden
本身的值仍然是true
。
!
运算符有时在口语中被称为感叹号。前面的双重感叹号操作(!!
)是双重否定和双重逻辑反转。如果您对bool
变量进行双重否定,那么变量不会有任何变化。
当然,您可以在int
上使用这些,如果int
设置为零,! int
将是true
,如果大于零,! int
将是false
。因此,如果您对该int
变量进行双重否定,且int
值大于零,则它将简化为true
。如果int
值已经是 0,则它将简化为false
。
和(&&)运算符
假设我们只想在两个条件都为true
时运行代码的一部分。例如,只有在我们穿袜子和衣服时才算穿好衣服。您可以使用以下代码来检查:
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 = false;
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;
}
您可能会注意到我在levelCompleteTime < maxTimeForBonus
周围添加了括号。尽管优先级规则可能让您在没有它们的情况下添加更长的语句,但我发现如果有任何疑问,最好还是添加它们。小心总比后悔好(对于稍后查看的其他人来说可能更清晰)。
练习
到目前为止,您应该已经注意到提高编程能力的最佳方法是通过实践。您必须经常练习编程才能显著提高。
创建两个整数变量,称为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
语句是一种编写超过两个可能分支方向的方法。在下面的代码示例中,代码将根据玩家持有的Coin
、Key
或Sanddollar
对象的不同方式进行运行:
#include <iostream>
using namespace std;
int main()
{
enum Item // This is how enums come in handy!
{
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;
}
请注意,前面的代码只会按三种不同的方式之一进行!在if
、else
和else if
系列检查中,我们只会进入一个代码块。
练习
使用 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...
)的位置写一些新的代码行:
-
检查
x
和y
是否相等。如果它们相等,打印x and y are equal
。否则,打印x and y are not equal
。 -
一个关于不等式的练习:检查
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 are very useful with switch!
{
// 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
语句内的代码将继续运行(逐行),直到遇到break;
语句。break
语句会跳出switch
语句。如果省略break;
语句,它将继续运行下一个 case 语句内的代码,并且直到遇到break;
或者switch
结束才会停止。如果你想尝试,可以尝试去掉所有的break;
语句,看看会发生什么!看一下下面的图表,了解switch
的工作原理:
-
首先检查
Food
变量。它有什么值?在这种情况下,它里面有Fish
。 -
switch
命令跳转到正确的 case 标签。(如果没有匹配的 case 标签,switch
将被跳过)。 -
cout
语句被执行,控制台上出现Here fishy fishy fishy
。 -
检查变量并打印用户响应后,
break
语句被执行。这使我们停止运行switch
中的代码行,并退出switch
。接下来要运行的代码行就是如果switch
根本不存在的话,否则将是程序中的下一行代码(在switch
语句的结束大括号之后)。是return 0
退出程序。
switch
语句与if
语句
开关类似于之前的if
/ else if
/ else
链。但是,开关可以比if
/ else if
/ else if
/ else
链更快地生成代码。直观地说,开关只会跳转到适当的代码部分以执行。if
/ else if
/ else
链可能涉及更复杂的比较(包括逻辑比较),这可能需要更多的 CPU 时间。您将使用if
语句的主要原因是,如果您要检查的内容比仅比较特定值集合中的内容更复杂。
enum
的一个实例实际上是一个int
。要验证这一点,请打印以下代码:
`cout << "Fish=" << Fish <<
" Bread=" << Bread <<
" Apple=" << Apple <<`
"Orange=" << Orange << endl;
您将看到enum
的整数值-只是让您知道。
有时,程序员希望在相同的开关case
标签下分组多个值。假设我们有一个如下所示的enum
对象:
enum Vegetables { Potato, Cabbage, Broccoli, Zucchini };
程序员希望将所有绿色物品分组在一起,因此他们编写了一个如下所示的switch
语句:
Vegetable veg = Zucchini;
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 标签中。为了防止穿透,您必须记住在每个case
标签后插入显式的break
语句。
我们可以编写另一个版本的相同开关,它不会让 Zucchini 掉下来,而是在开关中明确使用break
关键字:
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;
}
请注意,即使它是最后一个列出的情况,break
default
case 也是良好的编程实践。
练习
完成以下程序,其中有一个enum
对象,其中有一系列可供选择的坐骑。编写一个switch
语句,为所选的坐骑打印以下消息:
Horse |
这匹骏马是勇敢而强大的。 |
---|---|
Mare |
这匹母马是白色和美丽的。 |
Mule |
你被给了一匹骡子骑。你对此感到愤慨。 |
Sheep |
咩!这只羊几乎无法支撑您的重量。 |
Chocobo |
Chocobo! |
请记住,enum
对象实际上是一个int
语句。enum
对象中的第一个条目默认为0
,但您可以使用=
运算符为enum
对象指定任何起始值。enum
对象中的后续值是按顺序排列的ints
。
解决方案
上一个练习的解决方案显示在以下代码中:
#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;
// Describe what happens
// when you mount each animal in the switch below
switch( mount )
{
default:
cout << "Invalid mount" << endl;
break;
}
return 0;
}
位移的枚举
在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
涉及使用按位 AND
进行检查:
// bitwise AND checks to see if wp is Modal
if( wp & Modal )
{
cout << "You are looking at a modal window" << endl;
}
位移是一种略微超出本书范围的技术,但我包含了这个提示,只是让您知道它。
我们在虚幻引擎中的第一个示例
我们需要开始使用虚幻引擎。
警告:当您打开第一个虚幻项目时,您会发现代码看起来非常复杂。不要灰心。只需专注于突出显示的部分。在您作为程序员的职业生涯中,您经常需要处理包含您不理解的部分的非常庞大的代码库。然而,专注于您理解的部分将使本节变得富有成效。
首先,您需要下载启动器以安装引擎。转到www.unrealengine.com/en-US/what-is-unreal-engine-4
,当您单击立即开始或下载时,您必须在下载启动器之前创建一个免费帐户。
下载启动器后,打开 Epic Games Launcher 应用程序。选择启动虚幻引擎 4.20.X(到您阅读此内容时可能会有新版本),如下截图所示:
如果您没有安装引擎,您需要转到虚幻引擎选项卡并下载一个引擎(~7 GB)。
一旦引擎启动(可能需要几秒钟),你将进入虚幻项目浏览器屏幕,就像下面的截图中所示的那样:
现在,在 UE4 项目浏览器中选择“新项目”标签页。选择 C++标签页并选择 Puzzle 项目。这是一个比较简单的项目,代码不是太多,所以很适合入门。我们稍后会转到 3D 项目。
在这个屏幕上有几件事情要注意:
-
确保你在“新项目”标签页中。
-
当你点击 Puzzle 时,确保它是 C++标签页上的一个,而不是蓝图标签页上的一个。
-
在“名称”框中输入项目名称
Puzzle
(这对我稍后给你的示例代码很重要)。 -
如果你想更改存储文件夹(比如更改到另一个驱动器),点击文件夹旁边的...按钮,这样浏览窗口就会出现。然后,找到你想要存储项目的目录。
完成所有这些后,选择创建项目。
注意:如果它告诉你无法创建项目,因为你没有安装 Windows 8.1 SDK,你可以从developer.microsoft.com/en-us/windows/downloads/sdk-archive
下载它。
Visual Studio 2017 将打开你的项目代码,以及虚幻编辑器,就像下面的截图中所示的那样:
看起来复杂吗?哦,天哪,它确实复杂!我们稍后会探索一些工具栏中的功能。现在,只需选择播放,就像前面的截图中所示的那样。
这将启动游戏。它应该是这个样子的:
现在,尝试点击方块。一旦你点击一个方块,它就会变成橙色,这会增加你的分数。你可以通过点击“停止”或在键盘上按Esc来结束你的游戏会话。
我们要做的是找到这个部分并稍微改变一下行为。
找到并打开PuzzleBlock.cpp
文件。在 C++类|拼图下找到 PuzzleBlock,双击它以在 IDE 中打开它。
在 Visual Studio 中,项目中的文件列表位于“解决方案资源管理器”中。如果你的“解决方案资源管理器”被隐藏了,只需点击顶部菜单中的“查看/解决方案资源管理器”。
在这个文件中,向下滚动到底部,你会找到一个以以下单词开头的部分:
void APuzzleBlock::BlockClicked(UPrimitiveComponent* ClickedComp, FKey ButtonClicked)
APuzzleBlock
是类名(我们稍后会介绍类),BlockClicked
是函数名。每当一个拼图块被点击时,从起始{
到结束}
的代码部分就会运行。希望这发生的方式稍后会更有意义。
这在某种程度上有点像if
语句。如果点击了一个拼图块,那么这组代码就会为该拼图块运行。
我们将逐步介绍如何使方块在被点击时翻转颜色(因此,第二次点击将把方块的颜色从橙色改回蓝色)。
以最大的小心进行以下步骤:
- 打开
PuzzleBlock.h
文件。在包含以下代码的行之后:
/** Pointer to blue material used on inactive blocks */
UPROPERTY()
class UMaterialInstance* BlueMaterial;
/** Pointer to orange material used on active blocks */
UPROPERTY()
class UMaterialInstance* OrangeMaterial;
- 现在,打开
PuzzleBlock.cpp
文件。查找以下代码:
BlueMaterial = ConstructorStatics.BlueMaterial.Get();
OrangeMaterial = ConstructorStatics.OrangeMaterial.Get()
- 在
PuzzleBlock.cpp
中,用以下代码替换 voidAPuzzleBlock::BlockClicked
代码部分的内容:
void APuzzleBlock::BlockClicked(UPrimitiveComponent* ClickedComp, FKey ButtonClicked)
{
// --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, FKey ButtonClicked)
语句内部。
不要替换以void APuzzleBlock::BlockClicked
开头的那一行。你可能会出现错误(如果你没有将项目命名为Puzzle
)。如果是这样,你可以通过使用正确的名称创建一个新项目来重新开始。
按下播放按钮,看看你的更改生效了!所以,让我们分析一下。这是第一行代码:
bIsActive = !bIsActive; // flip the value of bIsActive
这行代码只是翻转了bIsActive
的值。bIsActive
变量是一个bool
变量(它在APuzzleBlock.h
中创建),用于跟踪方块是否处于活动状态并且应该显示为橙色。这就像翻转开关一样。如果bIsActive
为true
,!bIsActive
将为false
。因此,每当这行代码被执行(通过点击任何方块时会发生),bIsActive
的值就会被反转(从true
到false
或从false
到true
)。
让我们考虑下一段代码:
if ( bIsActive )
{
BlockMesh->SetMaterial(0, OrangeMaterial);
}
else
{
BlockMesh->SetMaterial(0, BlueMaterial);
}
我们只是改变了方块的颜色。如果bIsActive
为true
,那么方块就会变成橙色。否则,方块就会变成蓝色。
总结
在本章中,您学会了如何分支代码。分支使代码可以朝不同的方向发展,而不是一直向下执行。
在下一章中,我们将继续讨论一种不同类型的控制流语句,它将允许您返回并重复执行一行代码一定次数。重复执行的代码部分将被称为循环。
第四章:循环
在上一章中,我们讨论了if
语句。if
语句使您能够对一块代码的执行设置条件。
在本章中,我们将探讨循环,这些是代码结构,使您能够在某些条件下重复执行一块代码。一旦条件变为 false,我们就停止重复执行该代码块。
在本章中,我们将探讨以下主题:
-
while 循环
-
do/while 循环
-
for 循环
-
虚幻引擎中实际循环的简单示例
while 循环
while
循环用于重复运行代码的一部分。如果您有一组必须重复执行以实现某个目标的操作,这将非常有用。例如,以下代码中的while
循环重复打印变量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
循环括号内的条件时(在上面的例子中为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 = x + 1;
}
cout << "End of program" << endl;
上面的代码示例将只打印x is 1
。因此,while
循环与if
语句完全相同,只是它具有自动重复自身直到while
循环括号内的条件变为 false 的特殊属性。
我想用一个视频游戏来解释while
循环的重复。如果您不了解 Valve 的Portal,您应该玩一下,即使只是为了理解循环。查看www.youtube.com/watch?v=TluRVBhmf8w
以获取演示视频。
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)。
只需单击窗口上的 x 按钮即可关闭程序。
以下练习将使用前几章中的所有概念,例如+=
和递减操作。如果您忘记了某些内容,请返回并重新阅读前几节。
练习
让我们来看几个练习:
-
编写一个
while
循环,将打印数字1
到10
-
编写一个
while
循环,将倒序打印从 10 到 1 的数字 -
编写一个
while
循环,将打印 2 到 20 的数字,每次增加 2(例如 2、4、6、8) -
编写一个
while
循环,将打印数字 1 到 16 及其平方
以下是练习 4 的示例程序输出:
1 |
1 |
---|---|
2 |
4 |
3 |
9 |
4 |
16 |
5 |
25 |
解决方案
前面练习的代码解决方案如下:
while
循环打印从1
到10
的数字的解决方案如下:
int x = 1;
while( x <= 10 )
{
cout << x << endl;
x++;
}
while
循环的解决方案,倒序打印从10
到1
的数字如下:
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
}
while
循环打印从2
到20
的数字,每次增加2
的解决方案如下:
int x = 2;
while( x <= 20 )
{
cout << x << endl;
x+=2; // increase x by 2's
}
while
循环的解决方案,打印从1
到16
的数字及其平方如下:
int x = 1;
while( x <= 16 )
{
cout << x << " " << x*x << endl; // print x and it's
square
x++;
}
do/while 循环
do
/while
循环与while
循环几乎相同。以下是一个等效于我们检查的第一个while
循环的do
/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
循环如果第一次进入时条件为 false,则可以完全跳过)。
这里有一个例子:
int val = 5;
while (val < 5)
{
cout << "This will not print." << endl;
}
do {
cout << "This will print once." << endl;
} while (val < 5);
for 循环
for
循环的解剖略有不同于while
循环,但两者都非常相似。
让我们比较for
循环的解剖和等效的while
循环。以以下代码片段为例:
for 循环 |
等效的while 循环 |
---|---|
for( int x = 1; x <= 5; x++ ) | int x = 1;while( x <= 5 ) |
for
循环在其括号内有三个语句。让我们按顺序检查它们。
for
循环的第一个语句(int x = 1;
)只在我们第一次进入for
循环体时执行一次。它通常用于初始化循环的计数变量的值(在本例中是变量x
)。for
循环括号内的第二个语句(x <= 5;
)是循环的重复条件。只要x <= 5
,我们必须继续留在for
循环的体内。for
循环括号内的最后一个语句(x++;
)在每次完成for
循环体后执行。
以下一系列图表解释了for
循环的进展:
练习
让我们来看一些练习:
-
编写一个
for
循环,将收集从1
到10
的数字的总和 -
编写一个
for
循环,将打印6
到30
的6
的倍数(6、12、18、24 和 30) -
编写一个
for
循环,将以2
的倍数打印 2 到 100 的数字(例如,2、4、6、8 等) -
编写一个
for
循环,将打印数字1
到16
及其平方
解决方案
以下是前面练习的解决方案:
- 打印从
1
到10
的数字的总和的for
循环的解决方案如下:
int sum = 0;
for( int x = 1; x <= 10; x++ )
{
sum += x;
}
cout << sum << endl;
- 打印从
6
到30
的6
的倍数的for
循环的解决方案如下:
for( int x = 6; x <= 30; x += 6 )
{
cout << x << endl;
}
- 打印从
2
到100
的数字的2
的倍数的for
循环的解决方案如下:
for( int x = 2; x <= 100; x += 2 )
{
cout << x << endl;
}
- 打印从
1
到16
的数字及其平方的for
循环的解决方案如下:
for( int x = 1; x <= 16; x++ )
{
cout << x << " " << x*x << endl;
}
使用虚幻引擎进行循环
在您的代码编辑器中,从第三章打开您的虚幻Puzzle
项目,If, Else, and Switch。
有几种方法可以打开您的虚幻项目。在 Windows 上,最简单的方法可能是导航到Unreal Projects
文件夹(默认情况下位于用户的Documents
文件夹中),然后在 Windows 资源管理器中双击.sln
文件,如下截图所示:
在 Windows 中,打开.sln
文件以编辑项目代码。您也可以直接打开 Visual Studio,它会记住您最近使用过的项目,并显示它们,这样您就可以从中点击打开。您还需要从 Epic Games Launcher 中打开 Unreal Editor 中的项目进行测试。
现在,打开PuzzleBlockGrid.cpp
文件。在这个文件中,向下滚动到以下语句开头的部分:
void APuzzleBlockGrid::BeginPlay()
请注意,这里有一个for
循环来生成最初的九个方块,如下面的代码所示:
// Loop to spawn each block
for( int32 BlockIndex=0; BlockIndex < NumBlocks; BlockIndex++ )
{
// ...
}
由于NumBlocks
(用于确定何时停止循环)计算为Size*Size
,我们可以通过改变Size
变量的值来轻松改变生成的方块数量。转到PuzzleBlockGrid.cpp
的第 24 行,将Size
变量的值更改为4
或5
。然后,再次运行代码(确保您在 Unreal Editor 中按下编译按钮以使用更新后的代码)。
您应该看到屏幕上的方块数量增加(尽管您可能需要滚动才能看到它们全部),如下截图所示:
将大小设置为14
会创建更多的方块。
摘要
在本章中,您学会了如何通过循环代码来重复执行代码行,从而使您可以多次运行它。这可以用于重复使用相同的代码行以完成任务。想象一下,如果不使用循环,打印从1
到10
(或 10,000!)的数字会是什么样子。
在下一章中,我们将探讨函数,这是可重复使用代码的基本单元。
第五章:函数和宏
在编写代码时,你会发现自己需要多次运行相同的代码。 你最不想做的事情就是在许多不同的地方复制和粘贴相同的代码(毕竟,如果你需要做出改变会发生什么?)。 只写一次然后多次调用会不会更容易? 这就是我们在本章中要讨论的内容。 我们将要涵盖的主题包括以下内容:
-
函数
-
带参数的函数
-
返回值的函数
-
初始化列表
-
更多关于变量
-
宏
-
Constexpr
函数
有些事情需要重复。 代码不是其中之一。 函数是一束可以被调用任意次数的代码,你希望多频繁就多频繁。
类比是很好的。 让我们探讨一个涉及服务员、厨师、披萨和函数的类比。 在英语中,当我们说一个人有一个功能时,我们的意思是这个人执行一些非常具体(通常非常重要)的任务。 他们可以一遍又一遍地做这个任务,每当他们被要求这样做时。
以下漫画展示了服务员(调用者)和厨师(被调用者)之间的互动。 服务员想要他的桌子上的食物,所以他叫厨师准备等待桌子所需的食物。 厨师准备食物,然后将结果返回给服务员:
在这里,厨师执行他烹饪食物的功能。 厨师接受了关于要烹饪什么类型的食物(三个意大利辣香肠披萨)的参数。 厨师然后离开,做了一些工作,然后带着三个披萨回来。 请注意,服务员不知道也不关心厨师如何烹饪披萨。 厨师为服务员抽象出了烹饪披萨的过程,所以对于服务员来说,烹饪披萨只是一个简单的单行命令。 服务员只是希望他的要求得到满足,并且披萨被送回给他。
当一个函数(厨师)被一些参数(要准备的披萨类型)调用时,函数执行一些操作(准备披萨)并可选地返回一个结果(实际完成的披萨)。
库函数的一个例子 - 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 )
。 所以,sqrt()
可以找到任何给定数字的数学平方根。
你知道如何找到一个像 5 这样的难题的平方根吗? 这并不简单。 一个聪明的灵魂坐下来写了一个可以找到各种类型数字的平方根的函数。 你必须理解如何找到 5 的平方根的数学原理才能使用sqrt(5)
函数调用吗? 当然不! 就像服务员不必理解如何烹饪披萨就能得到披萨一样,C++库函数的调用者不必完全理解库函数的内部工作原理就能有效地使用它。
使用函数的优点如下:
-
函数将复杂的任务抽象成一个简单的可调用例程。 这使得为了烹饪披萨所需的代码对于调用者(通常是你的程序)来说只是一个单行命令。
-
函数避免了不必要的代码重复。 假设我们有大约 20 行代码,可以找到一个双精度值的平方根。 我们将这些行代码包装成一个可调用的函数;而不是重复地复制和粘贴这 20 行代码,我们只需在需要根时简单地调用
sqrt
函数(带有要开方的数字)。
以下图表显示了找到平方根的过程:
编写我们自己的函数
假设我们想写一些代码,打印出一条道路,如下所示:
cout << "* *" << endl;
cout << "* | *" << endl;
cout << "* | *" << endl;
cout << "* *" << endl;
现在,假设我们想要连续打印两条道路,或者三条道路。或者说我们想要打印任意数量的道路。我们将不得不重复产生第一条道路的四行代码,以每条道路一次的方式。
如果我们引入自己的 C++命令,允许我们在调用命令时打印一条道路,那将是什么样子:
void printRoad()
{
cout << "* *" << endl;
cout << "* | *" << endl;
cout << "* | *" << endl;
cout << "* *" << endl;
}
这是函数的定义。C++函数具有以下结构:
void
表示它不返回任何值,并且由于括号内没有任何内容,它不需要任何参数。我们稍后会讨论参数和返回值。使用函数很简单:我们只需通过名称调用要执行的函数,后面跟着两个圆括号()
。例如,调用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
语句,程序就结束了。
以下是对上述程序执行的逐行跟踪:
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
以下是对上述代码的逐行解释:
-
程序的执行从
main()
的第一行开始,输出program begin!
。 -
接下来运行的代码行是对
printRoad()
的调用。这样做的作用是将程序计数器跳转到printRoad()
的第一行。然后按顺序执行printRoad()
的所有行(第 3-6 行)。 -
对
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
的方式:
printRoad(4)
将把值 4 赋给 numSegments 变量的示例
所以,numSegments
被赋予了调用中括号内传递的值
printRoad()
。
返回值的函数
函数的一个返回值的例子是sqrt()
函数。sqrt()
函数接受括号内的单个参数(要开方的数字),并返回该数字的实际平方根。
以下是使用sqrt
函数的示例:
cout << sqrt( 4 ) << endl;
sqrt()
函数做的事情类似于厨师准备比萨时所做的事情。
作为函数的调用者,你不关心sqrt()
函数内部发生了什么;那些信息是无关紧要的,因为你只想要传递的数字的平方根的结果。
让我们声明一个简单的返回值函数,如下面的代码所示:
int sum(int a, int b)
{
return a + b;
}
以下截图显示了带有参数和返回值的函数的解剖结构:
sum
函数非常基本。它只是取两个int
数,a
和b
,将它们加在一起,并返回一个结果。你可能会说我们甚至不需要一个完整的函数来只是加两个数字。你是对的,但请稍等片刻。我们将使用这个简单的函数来解释返回值的概念。
你将以这种方式使用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)
返回的值就放在那里。
换句话说,在评估sum(5,6)
函数调用后,这是cout
实际看到的代码行:
cout << "The sum of 5 and 6 is " << 11 << endl;
从sum(5,6)
返回的值实际上是在函数调用点剪切和粘贴的。如果函数承诺返回一个值(如果函数的返回类型不是void
),则必须始终返回一个值。
练习
-
编写一个
isPositive
函数,当传递给它的双精度参数确实为正时返回true
。 -
完成以下函数定义:
// 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
}
-
编写一个
getGrade()
函数,接受一个整数值(100 分制的分数)并返回等级(A
、B
、C
、D
或F
)。 -
数学函数的形式为
f(x) = 3x + 4
。编写一个返回f(x)
值的 C++函数。
解决方案
isPositive
函数接受一个双精度参数并返回一个布尔值:
bool isPositive( double value )
{
return value > 0;
}
- 以下是完成的
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;
}
getGrade()
函数在以下代码中给出:
char getGrade( int grade )
{
if( grade >= 90 )
{
return 'A';
}
else if( grade >= 80 )
{
return 'B';
}
else if( grade >= 70 )
{
return 'C';
}
else if( grade >= 60 )
{
return 'D';
}
else
{
return 'F';
}
}
- 这个程序是一个简单的程序,应该能让你娱乐一下。C++中的函数名实际上来自数学世界,如下面的代码所示:
double f( double x )
{
return 3*x + 4;
}
初始化列表
有时,你可能不知道要传递给数组多少个项目。C++的新版本添加了一种简单的方法,即初始化列表。这允许你在大括号内传递任意数量的项目,并用逗号分隔,就像这样:
{ 1, 2, 3, 4 }
为了设置这个,你需要使用initializer_list
作为类型:
#include <initializer_list>
using namespace std;
int sum(initializer_list<int> list) {
int total = 0;
for (int e : list) { // Iterate through the list
total += e;
}
return total;
}
这是一个模板,我们稍后会详细介绍,但现在你只需要知道放在列表中的对象类型在尖括号内,像这样:<int>
。这也可以是另一种类型,比如float
或char
。
要调用这个函数,你可以像这样传入值:
sum({ 1, 2, 3, 4 });
对于这种情况,结果将是10
。
变量重温
现在你更深入地了解了 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 = 0; // 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()
中使用,但不能在其他代码文件中使用。要想在多个代码文件中使用单个全局变量,你需要一个外部变量。 -
arg
(func()
的参数):这可以在func()
的第一行(在开大括号{
后)到最后一行(在闭大括号}
前)使用。 -
fx
:这可以在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
循环声明的圆括号内或在for
循环声明下面的代码块中使用。c
变量将在声明它的for
循环的闭大括号后消失。如果希望c
变量在for
循环的大括号外继续存在,需要在for
循环之前声明c
变量,如下所示:
int c;
for( c = 0; c < 5; c++ )
{
cout << c << endl;
} // c does not die here
静态局部变量
static
局部变量具有局部作用域,但当退出函数时不会消失,而是记住调用之间的值,如下面的代码所示:
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
两次都输出Ran this function 1 time
。作为局部变量,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
的值——这是一个常量变量。
常量和函数
const
可以以多种方式使用,其中一些涉及函数。有时,您将一个变量传递到函数中,但您不希望函数对该值进行任何更改。您可能会认为,好吧,我可以确保我不改变它,不是吗?在您自己的项目中可能是这样,但如果您在一个有多个程序员的大团队中呢?您可以添加注释,但通常最好确保将参数标记为const
。为此,您可以编写以下函数:
int sum(const int x, const int y)
{
return x + y;
}
现在,如果您尝试更改这些值中的任何一个,将会导致错误。例如,这样不起作用:
int sum(const int x, const int y)
{
x = x + y; //ERROR!
return x;
}
你还可以通过将其更改为以下内容之一来返回一个常量值:
const int returnConst()
只需确保将函数返回的值保存在一个也标记为const
的变量中,否则将会出错。
函数原型
函数原型是函数的签名,不包括函数体。例如,让我们从以下练习中原型化isPositive
,absEqual
和getGrade
函数:
bool isPositive( double value );
bool absEqual( int a, int b );
char getGrade( int grade );
请注意,函数原型只是函数需要的返回类型、函数名称和参数列表。函数原型不包含函数体。函数的主体通常放在.cpp
文件中。
.h 和.cpp 文件
将函数原型放在.h
文件中,将函数的主体放在.cpp
文件中是典型的。这样做的原因是您可以在一堆.cpp
文件中包含您的.h
文件,而不会出现多重定义错误。
以下屏幕截图向您展示了.h
和.cpp
文件的清晰图像,显示了主代码和函数的.cpp
文件,以及保存函数原型的.h
文件:
在这个 Visual C++项目中,我们有三个文件:
prototypes.h
prototypes.h
文件包含函数原型。我们稍后将解释extern
关键字的作用:
// 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 );
funcs.cpp
以下是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 >= 90 )
{
return 'A';
}
else if( grade >= 80 )
{
return 'B';
}
else if( grade >= 70 )
{
return 'C';
}
else if( grade >= 60 )
{
return 'D';
}
else
{
return 'F';
}
}
main.cpp
以下是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
文件(其中包含实际函数的文件)称为实现。
对于一些程序员来说,最初令人困惑的部分是,如果我们只#include
原型,C++如何知道isPositive
和getGrade
函数体在哪里?如果我们只#include
原型,main.cpp
中也应该#include
funcs.cpp
文件吗?
答案是魔法。您只需要在main.cpp
和funcs.cpp
中都#include
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++程序员在可能的情况下更喜欢使用内联函数而不是带参数的宏。
正常的函数调用执行涉及jump
指令到函数,然后执行函数。内联函数是指其代码行被复制到函数调用点,不发出跳转。通常情况下,使用内联函数是有意义的,因为它们是非常小的、简单的函数,没有很多代码行。例如,我们可能会内联一个简单的函数max
,找到两个值中的较大值:
inline int max( int a, int b )
{
if( a > b ) return a;
else return b;
}
每次使用max
函数时,函数体的代码将被复制和粘贴到函数调用点。不必跳转到函数可以节省执行时间,使内联函数实际上类似于宏。
使用内联函数有一个限制。内联函数的函数体必须完全包含在.h
头文件中。这样编译器才能进行优化,并在使用的地方实际内联函数。通常将函数设置为内联是为了提高速度(因为不需要跳转到代码的另一个部分来执行函数),但代价是代码膨胀。
以下是内联函数优于宏的原因:
-
宏容易出错:宏的参数没有类型。
-
宏必须写在一行中,否则您将看到它们使用转义:
\
newline characters \
like this \
which is hard to read \
-
如果宏没有仔细编写,将导致难以修复的编译器错误。例如,如果您没有正确地使用括号括起参数,您的代码将是错误的。
-
大型宏很难调试。
应该说的是,宏确实允许您执行一些预处理编译的魔术。正如您将在后面看到的那样,UE4 大量使用带参数的宏。
Constexpr
还有一种新的方法,您也可以在编译时执行某些操作,而不是在运行时,那就是使用constexpr
。与宏一样,您可以创建变量和函数,编译器会自动将它们复制到它们被使用的地方。因此,您可以像这样创建变量:
constexpr float pi = 3.14129f;
您还可以像这样将constexpr
添加到要在编译时运行的函数中:
constexpr int increment(int i)
{
return i + 1;
}
您还可以在if
语句中使用constexpr
来在编译时评估某些内容。因此,如果您想在编译时为游戏的演示版本执行不同的操作,可以像这样做:
if constexpr (kIsDemoVersion) {
//use demo version code here
} else {
//use regular version code here
}
当我们谈论模板时,您会发现更多用途。
总结
函数调用允许您重用基本代码。代码重用对许多原因都很重要,主要是因为编程很困难,应尽量避免重复劳动。编写sqrt()
函数的程序员的工作不需要其他想解决同样问题的程序员重复。
第六章:对象、类和继承
在上一章中,我们讨论了函数作为捆绑一堆相关代码行的方式。我们谈到了函数如何抽象出实现细节,以及sqrt()
函数不需要您了解其内部工作原理就可以使用它来查找根。这是一件好事,主要是因为它节省了程序员的时间和精力,同时使查找平方根的实际工作变得更容易。当我们讨论对象时,这种抽象原则将再次出现。
在本章中,我们将涵盖:
-
什么是对象?
-
结构体
-
类与结构体
-
获取器和设置器
-
构造函数和析构函数
-
类继承
-
多重继承
-
将您的类放入头文件
-
面向对象的编程设计模式
-
可调用对象和调用
本章包含许多关键字,可能一开始很难理解,包括virtual
和abstract
。
不要让本章中更困难的部分拖住你。我包括了许多高级概念的描述以确保完整性。但请记住,您不需要完全理解本章的所有内容才能编写在 UE4 中工作的 C++代码。理解一切是有帮助的,但如果有些东西不合理,不要陷入困境。阅读一下,然后继续。可能会发生的情况是,一开始你可能不明白,但在编码时记住相关概念的参考。然后,当您再次打开这本书时,哇!它就会有意义了。
什么是对象?
简而言之,对象将方法(另一个词是函数)及其相关数据绑定到一个结构中。这个结构称为类。使用对象的主要思想是为游戏中的每个事物创建一个代码表示。代码中表示的每个对象都将具有操作该数据的数据和相关函数。因此,您将有一个对象来表示您的Player
和相关函数,使Player
可以jump()
、shoot()
和pickupItem()
。您还将有一个对象来表示每个怪物实例和相关函数,如growl()
、attack()
,可能还有follow()
。
对象是变量类型,对象将在内存中保留,只要您保留它们。当您的游戏中的事物创建时,您创建一个实例或特定表示对象的实例,并在表示的事物死亡时销毁对象实例。
对象可以用来表示游戏中的事物,但也可以用来表示任何其他类型的事物。例如,您可以将图像存储为对象。数据字段将是图像的宽度,高度和其中的像素集合。C++字符串也是对象。
结构对象
在 C++中,对象基本上是由一堆更简单的类型组成的任何变量类型。C++中最基本的对象是struct
。我们使用struct
关键字将一堆较小的变量粘合成一个大变量。如果您回忆起来,我们在第二章 变量和内存中简要介绍了struct
。让我们回顾一下那个简单的例子:
struct Player
{
string name;
int hp;
};
这是定义Player
对象的结构。Player
有一个name
的string
和一个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;
}
};
成员函数只是在struct
或class
定义内声明的 C++函数。
这里有一个有趣的想法,所以我会直接说出来。struct Player
的变量对struct Player
内部的所有函数都是可访问的。在struct Player
的每个成员函数内部,我们实际上可以访问name
和hp
变量,就好像它们是函数内部的局部变量一样。换句话说,struct Player
的name
和hp
变量在struct Player
的所有成员函数之间是共享的。
this 关键字
在一些 C++代码(在后面的章节中),你会看到更多关于this
关键字的引用。this
关键字是一个指针,指向当前对象。例如,在Player::damage()
函数内部,我们可以显式地写出对this
的引用:
void damage( int amount )
{
this->hp -= amount;
}
this
关键字只在成员函数内部有意义。我们可以在成员函数内部显式地包含this
关键字的使用,但是不写this
时,暗示着我们正在谈论当前对象的hp
。因此,虽然在大多数情况下这并不是严格必要的,但这可能是个人或公司的偏好,并且可以使代码更易读。
字符串是对象吗?
是的,字符串是对象!每次你过去使用string
变量时,你都在使用一个对象。让我们尝试一些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();
调用成员函数的对象在点的左边。要调用的成员函数在点的右边。成员函数调用总是在圆括号()
后面,即使没有参数传递给括号。
因此,在程序中怪物攻击的部分,我们可以按如下方式减少player
的hp
值:
player.damage( 15 ); // player takes 15 damage
这比以下更可读吗?
player.hp -= 15; // player takes 15 damage
当成员函数和对象有效地使用时,你的代码将更像散文或诗歌,而不是一堆操作符符号拼在一起。
除了美观和可读性,编写成员函数的目的是什么?在Player
对象之外,我们现在可以用一行代码做更多事情,而不仅仅是减少hp
成员 15。当player
受到伤害时,我们还可以做其他事情,比如考虑player
的护甲,检查玩家是否无敌,或者在Player
受到伤害时发生其他效果。玩家受到伤害时发生的事情应该由damage()
函数抽象出来。
现在,想象一下Player
有一个armorClass
。让我们为struct Player
添加一个armorClass
字段:
struct Player
{
string name;
int hp;
int armorClass;
};
我们需要减少Player
的护甲等级所受到的伤害。因此,我们需要输入一个公式来减少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;
}
};
练习
-
在上述代码中
Player
的damage
函数中存在一个细微的错误。你能找到并修复它吗?提示:如果造成的伤害小于Player
的armorClass
会发生什么? -
只有一个装甲等级的数字并不能提供关于装甲的足够信息!装甲的名字是什么?它是什么样子?为
Player
的装甲设计一个struct
函数,其中包括name
、armorClass
和durability
等字段。
解决方案
第一个练习的解决方案在下一节“私有和封装”中列出的struct Player
代码中。
对于第二个问题,如何使用以下代码?
struct Armor
{
string name;
int armorClass;
double durability;
};
然后在struct Player
内放置一个Armor
的实例:
struct Player
{
string name;
int hp;
Armor armor; // Player has-an Armor
};
这意味着Player
有一套装甲。记住这一点——我们将在以后探讨“有一个”与“是一个”关系。
到目前为止,所有变量名称都以小写字符开头。这是 C++代码的一个良好约定。你可能会发现一些特定团队或其他语言更喜欢使用大写字符来开始变量名称的情况,在这种情况下,最好只做你的公司的人们期望你做的事情。
私有和封装
所以现在我们定义了一些成员函数,其目的是修改和维护我们的Player
对象的数据成员,但有些人提出了一个论点。
论点如下:
- 对象的数据成员应该只能通过其成员函数访问,而不是直接访问。
这意味着你不应该直接从对象外部访问对象的数据成员,换句话说,直接修改player
的hp
:
player.hp -= 15 - player.armorClass; // bad: direct member access
这应该是被禁止的,类的用户应该被强制使用正确的成员函数来改变数据成员的值:
player.damage( 15 ); // right: access through member function
这个原则被称为封装。封装是每个对象都应该只通过其成员函数进行交互的概念。封装表示不应直接访问原始数据成员。
封装背后的原因如下:
-
使类自包含:封装背后的主要思想是,对象在被编程时最好是这样的,即它们管理和维护自己的内部状态变量,而不需要类外部的代码来检查该类的私有数据。当对象以这种方式编码时,使对象更容易使用,即更容易阅读和维护。要使
Player
对象跳跃,你只需调用player.jump()
;让Player
对象管理其y-height
位置的状态变化(使Player
跳跃!)。当对象的内部成员未暴露时,与该对象的交互变得更加容易和高效。只与对象的公共成员函数交互;让对象管理其内部状态(我们将在下一节中解释关键字private
和public
)。 -
为了避免破坏代码:当类外部的代码只与该类的公共成员函数(类的公共接口)交互时,对象的内部状态管理可以自由更改,而不会破坏任何调用代码。这样,如果对象的内部数据成员因任何原因而更改,只要成员函数的签名(名称、返回类型和任何参数)保持不变,所有使用对象的代码仍然有效。
那么,我们如何防止程序员做错事并直接访问数据成员?C++引入了访问修饰符的概念,以防止访问对象的内部数据。
以下是我们如何使用访问修饰符来禁止从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
数据成员,并且不封装他们的对象。尽管这是一种偏好,但被认为是不良的面向对象编程实践。
然而,在 UE4 中的类有时会使用public
成员。这是一个判断;数据成员应该是public
还是private
,这真的取决于程序员。
通过经验,您会发现,有时,当您将应该是private
的数据成员变为public
时,您需要进行相当多的重构(修改代码)。
类关键字与结构体
您可能已经看到了使用class
关键字而不是struct
来声明对象的不同方式,如下面的代码所示:
class Player // we used class here instead of struct!
{
string name;
//
};
C++中的class
和struct
关键字几乎是相同的。class
和struct
之间只有一个区别,那就是struct
关键字内部的数据成员将默认声明为public
,而在class
关键字内部,类内部的数据成员将默认声明为private
。(这就是我使用struct
引入对象的原因;我不想莫名其妙地将public
作为class
的第一行。)
一般来说,struct
更适用于不使用封装、没有许多成员函数并且必须向后兼容 C 的简单类型。类几乎在任何其他地方都可以使用。
从现在开始,让我们使用class
关键字而不是struct
。
获取器和设置器
您可能已经注意到,一旦我们在Player
类定义中加入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
的名称,我们将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;
}
};
因此,现在可以读取player
的name
信息。我们可以使用以下代码语句来实现:
cout << player.getName() << endl;
获取器用于检索private
成员,否则您将无法从类外部访问。
现实世界提示-const 关键字
在类内部,您可以在成员函数声明中添加const
关键字。const
关键字的作用是向编译器承诺,对象的内部状态不会因运行此函数而改变。附加const
关键字看起来像这样:
string getName() const
{
返回名称;
}
在标记为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;
}
};
因此,我们仍然可以从类函数外部更改private
变量,但只能通过设置函数来实现。
但是获取/设置操作有什么意义呢?
因此,当新手程序员第一次遇到对private
成员进行获取/设置操作时,脑海中首先出现的问题是,获取/设置不是自相矛盾吗?我的意思是,当我们以另一种方式公开相同数据时,隐藏对数据成员的访问有什么意义呢?这就像说,“你不能吃巧克力,因为它们是私有的,除非你说请* getMeTheChocolate()
。*然后,你可以吃巧克力。”
一些专家程序员甚至将获取/设置函数缩短为一行,就像这样:
string getName(){ return name; }
void setName( string newName ){ name = newName; }
让我们来回答这个问题。获取/设置对暴露数据会破坏封装吗?
答案是双重的。首先,获取成员函数通常只返回被访问的数据成员的副本。这意味着原始数据成员的值保持受保护,并且不能通过get()
操作进行修改。
set()
(mutator 方法)操作有点反直觉。如果 setter 是一个passthru
操作,比如void setName( string newName ) { name=newName; }
,那么拥有 setter 可能看起来毫无意义。使用 mutator 方法而不是直接覆盖变量的优势是什么?
使用 mutator 方法的论点是在变量分配之前编写额外的代码,以防止变量采用不正确的值。
例如,我们为hp
数据成员创建一个 setter,它将如下所示:
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;
}
mutator 方法应该防止内部的hp
数据成员采用负值。您可能认为 mutator 方法有点事后诸葛亮。调用代码应该在调用setHp( -2 )
之前检查它设置的值,而不是只在 mutator 方法中捕获。您可以使用public
成员变量,并将确保变量不采用无效值的责任放在调用代码中,而不是在 setter 中。
这是使用 mutator 方法的核心原因。mutator 方法的理念是,调用代码可以将任何值传递给setHp
函数(例如setHp( -2 )
),而无需担心传递给函数的值是否有效。然后,setHp
函数负责确保该值对于hp
变量是有效的。
一些程序员认为直接的 mutator 函数,如getHp()
/setHp()
是一种代码异味。代码异味通常是一种糟糕的编程实践,人们通常不会明显注意到,只是会有一种不太优化的感觉。他们认为可以编写更高级别的成员函数来代替 mutators。例如,我们应该有public
成员函数heal()
和damage()
,而不是setHp()
成员函数。关于这个主题的文章可以在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
名称的行:Player named 'Diplo'
。为什么Player
被命名为 Diplo?因为这是在Player()
构造函数中分配的名称。
最后,在程序结束时,Player
析构函数被调用,我们看到Player object destroyed
。当Player
对象在main()
结束时(在main
的}
处)超出范围时,Player
对象被销毁。
那么,构造函数和析构函数有什么好处?确切地说,它们的作用是设置和销毁对象。构造函数可用于初始化数据字段,析构函数可调用delete
释放任何动态分配的资源(我们还没有涵盖动态分配的资源,所以不用担心这一点)。
类继承
当您想要基于现有代码类创建一个新的、更功能强大的代码类时,您使用继承。继承是一个棘手的话题。让我们从派生类(或子类)的概念开始。
派生类
考虑继承的最自然的方式是通过与动物王国的类比。生物的分类如下图所示:
这个图表的意思是Dog、Cat、Horse和Human都是哺乳动物。这意味着它们都共享一些共同的特征,比如拥有共同的器官(带有新皮质的大脑、肺、肝脏和雌性子宫),而在其他方面完全不同。它们的行走方式不同。它们的交流方式也不同。
如果你在编写生物代码,那意味着你只需要编写一次共同的功能。然后,你会为Dog
、Cat
、Horse
和Human
类中的每个不同部分专门实现代码。
前面图表的一个具体例子如下:
#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? I 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();
}
所有的Dog
、Cat
和Human
都继承自class Mammal
。这意味着dog
、cat
和human
都是哺乳动物,还有更多。
继承的语法
继承的语法非常简单。让我们以Human
类定义为例。以下屏幕截图是典型的继承语句:
冒号(:)左边的类是新的派生类,冒号右边的类是基类。
继承的作用是什么?
继承的目的是让派生类继承基类的所有特征(数据成员和成员函数),然后通过更多功能来扩展它。例如,所有哺乳动物都有一个breathe()
函数。通过从Mammal
类继承,Dog
、Cat
和Human
类都自动获得了breathe()
的能力。
继承减少了代码的重复,因为我们不必为Dog
、Cat
和Human
重新实现共同的功能(比如.breathe()
)。相反,这些派生类中的每一个都可以重用class Mammal
中定义的breathe()
函数。
然而,只有Human
类有attack()
成员函数。这意味着在我们的代码中,只有Human
类会攻击。cat.attack()
函数会引发编译错误,除非你在class Cat
(或class Mammal
)中编写一个attack()
成员函数。
is-a 关系
继承通常被称为is-a
关系。当Human
类从Mammal
类继承时,我们说人类是哺乳动物:
人类继承了哺乳动物的所有特征。
但是,如果Human
对象内部包含一个Mammal
对象,如下所示?
class Human
{
Mammal mammal;
};
在这个例子中,我们会说人类身上有一个Mammal
(如果人类怀孕或者以某种方式携带哺乳动物,这是有意义的):
这个Human
类实例身上有一个哺乳动物
请记住,我们之前给Player
一个Armor
对象内部吗?Player
对象继承Armor
类是没有意义的,因为说Player
是一种 Armor是没有意义的。在代码设计中决定一个类是否从另一个类继承时(例如,Human
类是否从Mammal
类继承),你必须始终能够自如地说Human
类是Mammal
。如果是语句听起来不对,那么很可能继承是那对对象的错误关系。
在前面的例子中,我们引入了一些新的 C++关键字。第一个是protected
。
受保护的变量
protected
成员变量与public
或private
变量不同。这三类变量在定义它们的类内部都是可访问的。它们之间的区别在于对类外部的可访问性。public
变量在类内部和类外部都是可访问的。private
变量在类内部是可访问的,但在类外部不可访问。protected
变量在类内部和派生子类内部是可访问的,但在类外部不可访问。因此,class Mammal
的hp
和speed
成员在派生类Dog
、Cat
、Horse
和Human
中是可访问的,但在这些类的外部(例如main()
)是不可访问的。
虚函数
虚函数是一个成员函数,其实现可以在派生类中被覆盖。在这个例子中,talk()
成员函数(在class Mammal
中定义)被标记为virtual
。这意味着派生类可能会选择实现自己的talk()
成员函数的版本,也可能不选择。
纯虚函数
纯virtual
函数(和抽象类)是指你必须在派生类中覆盖其实现的函数。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
的派生实例,只要派生类实现了所有的纯虚成员函数。
你可能会想为什么要使用其中之一。好吧,你真的认为你会想在游戏中创建一个Mammal
对象吗?不,你会想创建一个从Mammal
派生的类型的对象,比如Cat
或Dog
。这样,你就不会意外地创建一个Mammal
,这对Player
来说会非常令人困惑!
多重继承
并不是所有的多重继承都像听起来那么好。多重继承是指派生类从多个基类继承。通常,如果我们从完全不相关的多个基类继承,这通常可以顺利进行。
例如,我们可以有一个从SoundManager
和GraphicsManager
基类继承的Window
类。如果SoundManager
提供了一个成员函数playSound()
,GraphicsManager
提供了一个成员函数drawSprite()
,那么Window
类将能够毫无问题地使用这些额外的功能:
Game Window 从 Sound Man 和 Graphics Man 继承意味着 Game Window 将拥有两组功能
然而,多重继承可能会产生负面后果。假设我们想创建一个从Donkey
和Horse
类派生的Mule
类。然而,Donkey
和Horse
类都继承自Mammal
基类。我们立即遇到了问题!如果我们调用mule.talk()
,但mule
没有覆盖talk()
函数,应该调用哪个成员函数,Horse
还是Donkey
的?这是模棱两可的。
私有继承
C++中很少谈到的一个特性是private
继承。每当一个类公开地继承另一个类时,所有代码都知道它属于哪个父类,例如:
class Cat : public Mammal
这意味着所有的代码都将知道Cat
是Mammal
的一个对象,并且将能够使用基类Mammal*
指针指向Cat*
实例。例如,以下代码将是有效的:
Cat cat;
Mammal* mammalPtr = &cat; // Point to the Cat as if it were a
// Mammal
将一个类的对象放入父类类型的变量中称为转换。如果Cat
公开继承自Mammal
,则前面的代码是正确的。私有继承是指Cat
类外部的代码不允许知道父类:
class Cat : private Mammal
在这里,外部调用的代码将不会“知道”Cat
类是从Mammal
类派生的。当继承是私有的时候,编译器不允许将Cat
实例转换为Mammal
基类。当你需要隐藏某个类是从某个父类派生时,使用私有继承。
然而,私有继承在实践中很少使用。大多数类都使用公共继承。如果你想了解更多关于私有继承的信息,请参阅stackoverflow.com/questions/406081/why-should-i-avoid-multiple-inheritance-in-c
。
将你的类放入头文件
到目前为止,我们的类都只是被粘贴到了main()
之前。如果你继续以这种方式编程,你的代码将全部在一个文件中,并且看起来会像一个大杂乱的混乱。
因此,将你的类组织到单独的文件中是一个很好的编程实践。当项目中有多个类时,这样做可以更轻松地单独编辑每个类的代码。
拿class Mammal
和它的派生类来说。我们将把之前的例子正确地组织到单独的文件中。让我们分步骤来做:
-
在你的 C++项目中创建一个名为
Mammal.h
的新文件。将整个Mammal
类剪切并粘贴到该文件中。请注意,由于Mammal
类包含了对cout
的使用,我们在该文件中也写入了#include <iostream>
语句。 -
在你的
Source.cpp
文件顶部写入"#include``Mammal.h"
语句。
这是一个示例,如下截图所示:
当代码编译时,发生的情况是整个Mammal
类被复制并粘贴(#include
)到包含main()
函数的Source.cpp
文件中,其余的类都是从Mammal
派生的。由于#include
是一个复制和粘贴的功能,代码的功能将与之前完全相同;唯一的区别是它将更加有组织和易于查看。在此步骤中编译和运行你的代码,以确保它仍然有效。
经常检查你的代码是否能够编译和运行,特别是在重构时。当你不知道规则时,你很容易犯很多错误。这就是为什么你应该只在小步骤中进行重构。重构是我们现在正在做的活动的名称 - 我们正在重新组织源代码,使其对我们代码库的其他读者更有意义。重构通常不涉及太多的重写。
接下来你需要做的是将Dog
,Cat
和Human
类分别放入它们自己的文件中。为此,创建Dog.h
,Cat.h
和Human.h
文件,并将它们添加到你的项目中。
让我们从Dog
类开始,如下截图所示。
如果你使用这个设置并尝试编译和运行你的项目,你会看到“Mammal”:'class'类型重定义错误,如下截图所示:
这个错误的意思是Mammal.h
已经在你的项目中被包含了两次,一次在Source.cpp
中,然后又在Dog.h
中。这意味着,在编译代码中实际上添加了两个版本的Mammal
类,C++不确定使用哪个版本。
有几种方法可以解决这个问题,但最简单的方法(也是虚幻引擎使用的方法)是#pragma once
宏,如下截图所示:
我们在每个头文件的顶部写上#pragma once
。这样,第二次包含Mammal.h
时,编译器不会再次复制和粘贴它的内容,因为它已经被包含过了,它的内容实际上已经在编译组的文件中。
对Cat.h
和Human.h
做同样的事情,然后在您的Source.cpp
文件中包含它们,您的main()
函数位于其中:
包含所有类的屏幕截图
现在,我们已经将所有类包含到您的项目中,代码应该可以编译和运行。
使用.h
和.cpp
文件
组织的下一个级别是将类声明留在头文件(.h
)中,并将实际函数实现体放在一些新的.cpp
文件中。同时,保留class Mammal
声明中的现有成员。
对于每个类,执行以下操作:
- 删除所有函数体(在
{
和}
之间的代码),并用分号替换它们。对于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;
};
- 创建一个名为
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
)。
面向对象的编程设计模式
如果您一直在研究编程,您可能已经遇到了设计模式这个术语。设计模式很重要,因为它们是可以应用于许多编程项目的标准做事方式。如果您想了解更多,经典书籍设计模式是很重要的(www.goodreads.com/book/show/85009.Design_Patterns
)。一旦您熟悉它们,您将在整个职业生涯中发现许多用途。并非所有都与对象有关,但以下是一些与对象有关的例子。
单例
有时,您只想要一个对象的实例。比如你在做一个王国模拟器。你只想要有一个国王。否则,你就会面临权力的游戏类型的情况,到处都是阴谋和红色婚礼,这不是你想要的游戏类型,对吧?(当然,你可能会记住这一点,用在另一个游戏中。)但对于这个特定的游戏,你只想要一个国王来管理一切。
那么,您如何确保其他国王不会到处出现?您可以使用单例。单例是一个保留对象实例的类,您想在任何地方使用它时,而不是创建一个新对象,您调用一个函数,该函数会给您访问对象实例的方法,然后您可以在其上调用函数。为了确保只创建一个对象实例,它在类内部的静态变量中保留了自身的副本(注意:我们将在下一节中更多地讨论静态类成员),当您调用GetInstance()
时,它会检查您是否已经创建了对象的实例。如果有,它使用现有的实例。如果没有,它会创建一个新的。这里有一个例子:
//King.h
#pragma once
#include <string>
using namespace std;
class King
{
public:
~King();
static King* getInstance();
void setName(string n) { name = n; };
string getName() const { return name; };
//Add more functions for King
private:
King();
static King* instance;
string name;
};
这是cpp
的代码:
//King.cpp
#include "King.h"
King* King::instance = nullptr;
King::King()
{
}
King::~King()
{
}
King* King::getInstance()
{
if (instance == nullptr)
{
instance = new King();
}
return instance;
}
构造函数在代码的private:
部分中列出。这很重要。如果你这样做,构造函数将无法从类外部访问,这意味着其他程序员,可能意识不到这是一个单例,就不能开始创建新的King
对象并在游戏中造成混乱。如果他们尝试,他们会得到一个错误。因此,这强制了这个类只能通过getInstance()
函数访问。
要使用这个新的单例类,你可以这样做:
King::getInstance()->setName("Arthur");
cout << "I am King " << King::getInstance()->getName();
一旦你设置了名称,它将输出“我是亚瑟王”,无论你从代码的哪个位置调用它(只需确保在文件顶部添加#include "King.h"
)。
工厂
当你想到术语“工厂”时,你会想到什么?可能是一个大量生产物体的地方,比如汽车、鞋子或计算机。在代码中,工厂
的工作方式也是一样的。工厂是一个可以创建其他类型对象的类。但它更加灵活,因为它可以创建不同类型的对象。
我们之前看到,哺乳动物可以是狗、猫、马或人类。因为所有四种类型都是从“哺乳动物”派生出来的,一个“工厂”对象可以有一个函数,你告诉它你想要哪种类型的“哺乳动物”,它就会创建一个该类型的对象,进行任何必要的设置,并返回它。由于一个叫做多态性的原则,你可以得到一个类型为“哺乳动物”的对象,但当你调用任何虚函数时,它知道要使用为“猫”、“狗”或“人类”创建的函数,取决于创建的对象类型。你的 C++编译器知道这一点,因为它在幕后维护一个虚函数表,它保留了你真正想要使用的每个虚函数的版本的指针,并将它们存储在每个对象中。
对象池
假设你正在创建大量对象,比如用于显示烟花的粒子系统,并且你不断需要在屏幕上创建新的烟花动画。过一段时间,你会注意到事情变慢了,甚至可能会耗尽内存并崩溃。幸运的是,有一个解决方法。
你可以创建一个对象池,它基本上是一组对象,应该足够大,以便在任何给定时间屏幕上包含每一个对象。当一个对象完成其动画并消失时,你不需要创建一个新的对象,而是将它扔回到池中,当你需要另一个对象时,你可以将它拿出来并重用它(你可能需要先更改颜色或其他设置)。从池中重用对象比不断创建新对象要快得多,处理时间也更短。它还有助于避免内存泄漏。
静态成员
正如我们在单例示例中看到的,类可以有静态成员。类的静态成员对于类的所有实例只存在一次,而不是对于每个实例都不同。你通常像我们为单例所做的那样访问它们:
King::getInstance()->setName("Arthur");
静态变量也常用于与类相关的常量。但它们也可以用于跟踪某些东西,比如你有多少个对象的实例,通过在构造函数中递增静态变量,然后在析构函数中递减它。这类似于智能指针如何跟踪对象的引用数量。
可调用对象和调用
另一个新的 C++特性是可调用对象。这是一个高级话题,所以不要太担心在这一点上理解它,但我会给你一个简要的概述。但要解释它,首先,我需要提到另一个话题——运算符重载。
你可能认为你不能改变诸如+
、-
、*
和/
这样的运算符的含义。实际上,在 C++中,你可以。你可以添加一个名为operator(symbol)
的函数。因此,如果你有一个字符串类,你可以创建一个operator+
函数,使字符串被连接起来,而不是试图弄清楚如何添加两个实际上不是数字的对象。
可调用对象通过重载()
与operator()
更进一步。因此,你可以拥有一个可以作为对象调用的类。C++ 17 添加了一个新函数invoke()
,它可以让你调用带参数的可调用对象。
总结
在本章中,你学习了 C++中的对象;它们是将数据成员和成员函数绑定在一起形成的一组代码,称为class
或struct
。面向对象编程意味着你的代码将充满各种东西,而不仅仅是int
、float
和char
变量。你将拥有一个代表Barrel
的变量,另一个代表Player
的变量,以此类推,也就是说,一个变量代表游戏中的每个实体。你可以通过继承来重用代码;如果你需要编写Cat
和Dog
的实现,你可以在基类Mammal
中编写通用功能。我们还讨论了封装以及如何更轻松、更高效地编写对象,使它们保持自己的内部状态。我们还介绍了一些对象的设计模式(你会发现还有许多其他设计模式)。
在下一章中,我们将讨论如何动态分配内存,以及数组和向量。
第七章:动态内存分配
在上一章中,我们讨论了类的定义以及如何设计自己的自定义类。我们讨论了通过设计自定义类,可以构造代表游戏或程序中实体的变量。
在这一章中,我们将讨论动态内存分配以及如何为对象组创建内存空间。让我们看看本章涵盖的主题:
-
构造函数和析构函数重访
-
动态内存分配
-
常规数组
-
C++风格的动态大小数组(new[]和 delete[])
-
动态 C 风格数组
-
向量
构造函数和析构函数重访
假设我们有一个简化版本的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
对象的析构函数在玩家对象的作用域结束时被调用。由于变量的作用域是在代码的三行中定义的块内,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! Note: new always returns a pointer
Player *player = new Player();
} // NO automatic deletion!
这个程序的输出如下:
Player born
玩家不会死!我们如何杀死玩家?我们必须明确调用player
指针上的delete
。
删除关键字
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 对象的 2GB RAM。
请注意,没有人打算写一个存在这种问题的程序!内存泄漏问题是意外发生的。你必须小心你的内存分配,并且delete
不再使用的对象。
常规数组
在 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[ ] = {6, 0, 5, 19};
你也可以传入int
变量来使用存储在那里的值。
数组语法
那么,如何访问数组中的一个int
值?要访问数组的各个元素,我们使用方括号,如下行代码所示:
array[ 0 ] = 10;
这与最初创建数组的语法非常相似。上一行代码将更改数组的槽0
中的元素为10
:
通常情况下,要访问数组的特定槽,您将编写以下内容:
array[ slotNumber ] = value to put into array;
请记住,数组槽始终从0
开始索引(有些语言可能从1
开始,但这是不寻常的,可能会令人困惑)。要进入数组的第一个槽,请使用array[0]
。数组的第二个槽是array[1]
(而不是array[2]
)。前一个数组的最后一个槽是array[4]
(而不是array[5]
)。array[5]
数据类型超出了数组的边界!(在前面的图中没有索引为 5 的槽。最高索引为 4。)
不要超出数组的边界!有时可能会起作用,但其他时候您的程序将崩溃并显示内存访问违规(访问不属于您的程序的内存)。通常情况下,访问不属于您的程序的内存将导致您的应用程序崩溃,如果不立即崩溃,那么您的程序中将会有一个隐藏的错误,只会偶尔引起问题。索引数组时必须始终小心。
数组内置于 C++中,也就是说,您无需包含任何特殊内容即可立即使用数组。您可以拥有任何类型的数据数组,例如int
、double
、string
,甚至您自己的自定义对象类型(Player
)的数组。
练习
-
创建一个包含五个字符串的数组,并在其中放入一些名称(虚构或随机 - 这无关紧要)。
-
创建一个名为
temps
的双精度数组,其中包含三个元素,并将过去三天的温度存储在其中。
解决方案
- 以下是一个包含五个字符串数组的示例程序:
#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;
}
- 以下只是数组:
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
}
我们得到一个错误。问题在于编译器希望分配数组的大小。然而,除非变量大小标记为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[数组中的元素数量]
。 -
使用
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++ )
{
//At this point the syntax is the same as with regular arrays.
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()
来释放。
向量
还有一种创建本质上是数组的方式,这种方式是最容易使用的,也是许多程序员首选的方式——使用向量。想象一下,在以前的任何例子中,当你向数组中添加新项时,程序正在运行时突然用完了空间。你会怎么做?你可以创建一个全新的数组,把所有东西都复制过去,但是你可能会猜到,这是很多额外的工作和处理。那么,如果你有一种类型的数组,在幕后为你处理这样的情况,而你甚至都没有要求呢?
这就是向量的作用。向量是标准模板库的成员(我们将在接下来的几章中介绍模板,所以请耐心等待),就像其他例子一样,你可以在尖括号(<>
)内设置类型。你可以像这样创建一个向量:
vector<string> names; // make sure to add #include <vector> at the top
这基本上表示你正在创建一个名为 names 的字符串向量。要向向量添加新项,可以使用push_back()
函数,就像这样:
names.push_back("John Smith");
这将把你传入的项添加到向量的末尾。你可以调用push_back()
任意次数,每当向量用完空间时,它都会自动增加大小,而你无需做任何事情!所以,你可以随意添加任意数量的项(在合理范围内,因为最终可能会用完内存),而不必担心内存是如何管理的。
向量还添加了其他有用的函数,比如size()
,它告诉你向量包含多少项(在标准数组中,你必须自己跟踪这一点)。
一旦你创建了一个向量,你可以像访问标准数组一样对待它,使用[]
语法来访问:
//Make it unsigned int to avoid a signed/unsigned mismatch error
for (unsigned int i = 0; i < names.size(); i++)
{
//If you get an error about << add #include <string> at the top
cout << names[i] << endl; //endl tells it to go to the next line
}
总结
本章向你介绍了 C 和 C++风格的数组和向量。在大多数 UE4 代码中,你将使用 UE4 编辑器内置的集合类(TArray<T>
),它们类似于向量。然而,你需要熟悉基本的 C 和 C++风格的数组,才能成为一个非常优秀的 C++程序员。
我们现在已经涵盖了足够的基本 C++知识,可以继续学习下一个章节,关于 UE4 的演员和棋子。
第八章:演员和兵
现在,我们将真正深入 UE4 代码。起初,它看起来会让人望而生畏。UE4 类框架非常庞大,但不用担心:框架很大,所以你的代码不必如此。你会发现,你可以用更少的代码完成更多的工作并将更多内容显示在屏幕上。这是因为 UE4 引擎代码如此广泛和精心编写,以至于他们使得几乎任何与游戏相关的任务都变得容易。只需调用正确的函数,你想要看到的东西就会出现在屏幕上。整个框架的概念是设计让你获得想要的游戏体验,而不必花费大量时间来处理细节。
本章的学习成果如下:
-
演员与兵
-
创建一个放置演员的世界
-
UE4 编辑器
-
从头开始
-
向场景添加一个演员
-
创建一个玩家实体
-
编写控制游戏角色的 C++代码
-
创建非玩家角色实体
-
显示每个 NPC 对话框中的引用
演员与兵
在本章中,我们将讨论演员和兵。虽然听起来兵会比演员更基本,但实际情况恰恰相反。UE4 演员(Actor
类)对象是可以放置在 UE4 游戏世界中的基本类型。为了在 UE4 世界中放置任何东西,你必须从Actor
类派生。
兵是一个代表你或计算机的人工智能(AI)可以在屏幕上控制的对象。Pawn
类派生自Actor
类,具有直接由玩家或 AI 脚本控制的额外能力。当一个兵或演员被控制器或 AI 控制时,就说它被该控制器或 AI 所控制。
把Actor
类想象成一个戏剧中的角色(尽管它也可以是戏剧中的道具)。你的游戏世界将由一堆演员组成,它们一起行动以使游戏运行。游戏角色、非玩家角色(NPC)甚至宝箱都将是演员。
创建一个放置演员的世界
在这里,我们将从头开始创建一个基本的关卡,然后把我们的游戏角色放进去。UE4 团队已经很好地展示了世界编辑器如何用于创建 UE4 中的世界。我希望你花点时间按照以下步骤创建自己的世界:
- 创建一个新的空白 UE4 项目以开始。要做到这一点,在虚幻启动器中,点击最近的引擎安装旁边的启动按钮,如下截图所示:
这将启动虚幻编辑器。虚幻编辑器用于可视化编辑你的游戏世界。你将花费大量时间在虚幻编辑器中,所以请花些时间进行实验和尝试。
我只会介绍如何使用 UE4 编辑器的基础知识。然而,你需要让你的创造力流淌,并投入一些时间来熟悉编辑器。
要了解更多关于 UE4 编辑器的信息,请查看入门:UE4 编辑器简介播放列表,网址为www.youtube.com/playlist?list=PLZlv_N0_O1gasd4IcOe9Cx9wHoBB7rxFl
。
- 你将看到项目对话框。以下截图显示了需要执行的步骤,数字对应着需要执行的顺序:
-
执行以下步骤创建一个项目:
-
在屏幕顶部选择新项目标签。
-
点击 C++标签(第二个子标签)。
-
从可用项目列表中选择基本代码。
-
设置项目所在的目录(我的是 Y:Unreal Projects)。选择一个有很多空间的硬盘位置(最终项目大小约为 1.5GB)。
-
命名您的项目。我把我的称为 GoldenEgg。
-
单击“创建项目”以完成项目创建。
完成此操作后,UE4 启动器将启动 Visual Studio(或 Xcode)。这可能需要一段时间,进度条可能会出现在其他窗口后面。只有几个源文件可用,但我们现在不会去碰它们。
- 确保从屏幕顶部的配置管理器下拉菜单中选择“开发编辑器”,如下截图所示:
如下截图所示,虚幻编辑器也已启动:
UE4 编辑器
我们将在这里探索 UE4 编辑器。我们将从控件开始,因为了解如何在虚幻中导航很重要。
编辑器控件
如果您以前从未使用过 3D 编辑器,那么在编辑模式下,控件可能会很难学习。这些是在编辑模式下的基本导航控件:
-
使用箭头键在场景中移动
-
按Page Up或Page Down垂直上下移动
-
左键单击+向左或向右拖动以更改您所面对的方向
-
左键单击+向上或向下拖动以移动(将相机向前或向后移动,与按上/下箭头键相同)
-
右键单击+拖动以更改您所面对的方向
-
中键单击+拖动以平移视图
-
右键单击和W、A、S和D键用于在场景中移动
播放模式控制
单击顶部工具栏中的播放按钮,如下截图所示。这将启动播放模式:
单击“播放”按钮后,控件会改变。在播放模式下,控件如下:
-
W、A、S和D键用于移动
-
使用左右箭头键分别向左或向右查看
-
鼠标的移动以改变您所看的方向
-
按Esc键退出播放模式并返回编辑模式
在这一点上,我建议您尝试向场景中添加一堆形状和对象,并尝试用不同的材料着色它们。
向场景添加对象
向场景添加对象就像从内容浏览器选项卡中拖放它们一样简单,如下所示:
- 内容浏览器选项卡默认情况下停靠在窗口底部。如果看不到它,只需选择“窗口”,然后导航到“内容浏览器”即可使其出现:
确保内容浏览器可见,以便向您的级别添加对象
-
双击
StarterContent
文件夹以打开它。 -
双击“道具”文件夹以查找可以拖放到场景中的对象。
-
从内容浏览器中拖放物品到游戏世界中:
- 要调整对象的大小,请在键盘上按R(再次按W移动它,或按E旋转对象)。对象周围的操作器将显示为方框,表示调整大小模式:
- 要更改用于绘制对象的材料,只需从内容浏览器窗口中的材料文件夹内拖放新材料即可:
材料就像油漆。您可以通过简单地将所需的材料拖放到要涂抹的对象上,为对象涂上任何您想要的材料。材料只是表面深度;它们不会改变对象的其他属性(如重量)。
开始一个新级别
如果要从头开始创建级别,请执行以下步骤:
- 单击“文件”,导航到“新建级别...”,如下所示:
- 然后可以在默认、VR-Basic 和空级别之间进行选择。我认为选择空级别是个好主意:
- 新的级别一开始会完全黑暗。尝试再次从内容浏览器选项卡中拖放一些对象。
这次,我为地面添加了一个调整大小的形状/shape_plane(不要使用模式下的常规平面,一旦添加了玩家,你会穿过它),并用 T_ground_Moss_D 进行了纹理处理,还有一些道具/SM_Rocks 和粒子/P_Fire。
一定要保存你的地图。这是我的地图快照(你的是什么样子?):
- 如果你想要更改编辑器启动时打开的默认级别,转到编辑 | 项目设置 | 地图和模式;然后,你会看到一个游戏默认地图和编辑器启动地图设置,如下面的截图所示:
一定要确保你先保存当前场景!
添加光源
请注意,当你尝试运行时,你的场景可能会完全(或大部分)黑暗。这是因为你还没有在其中放置光源!
在之前的场景中,P_Fire 粒子发射器充当光源,但它只发出少量光线。为了确保你的场景中的一切都看起来被照亮,你应该添加一个光源,如下所示:
- 转到窗口,然后点击模式,确保灯光面板显示出来:
- 从模式面板中,将一个灯光对象拖入场景中:
-
选择灯泡和盒子图标(看起来像蘑菇,但实际上不是)。
-
点击左侧面板中的灯光。
-
选择你想要的灯光类型,然后将其拖入你的场景中。
如果你没有光源,当你尝试运行时(或者场景中没有物体时),你的场景将完全黑暗。
碰撞体积
到目前为止,你可能已经注意到,相机在播放模式下至少穿过了一些场景几何体。这不好。让我们让玩家不能只是在我们的场景中走过岩石。
有几种不同类型的碰撞体积。通常,完美的网格-网格碰撞在运行时成本太高。相反,我们使用一个近似值(边界体积)来猜测碰撞体积。
网格是对象的实际几何形状。
添加碰撞体积
我们首先要做的是将碰撞体积与场景中的每个岩石关联起来。
我们可以从 UE4 编辑器中这样做:
-
点击场景中要添加碰撞体积的对象。
-
在世界大纲选项卡中右键单击此对象(默认显示在屏幕右侧),然后选择编辑,如下面的截图所示:
你会发现自己在网格编辑器中。
- 转到碰撞菜单,然后点击添加简化碰撞胶囊:
- 成功添加碰撞体积后,碰撞体积将显示为一堆围绕对象的线,如下面的截图所示:
默认碰撞胶囊(左)和手动调整大小的版本(右)
-
你可以调整(R)大小,旋转(E),移动(W),并根据需要更改碰撞体积,就像你在 UE4 编辑器中操作对象一样。
-
当你添加完碰撞网格后,保存并返回到主编辑器窗口,然后点击播放;你会注意到你再也不能穿过你的可碰撞对象了。
将玩家添加到场景中
现在我们已经有了一个运行中的场景,我们需要向场景中添加一个角色。让我们首先为玩家添加一个角色,包括碰撞体积。为此,我们将不得不从 UE4 的GameFramework
类中继承,比如Actor
或Character
。
为了创建玩家的屏幕表示,我们需要从虚幻中的ACharacter
类派生。
从 UE4 GameFramework 类继承
UE4 使得从基础框架类继承变得容易。你只需要执行以下步骤:
-
在 UE4 编辑器中打开你的项目。
-
转到文件,然后选择新的 C++类...:
导航到文件|新的 C++类...将允许你从任何 UE4 GameFramework 类中派生
- 选择你想要派生的基类。你有 Character、Pawn、Actor 等,但现在我们将从 Character 派生:
-
选择你想要派生的 UE4 类。
-
点击下一步,会弹出对话框,你可以在其中命名类。我将我的玩家类命名为
Avatar
:
- 点击 Create Class 在代码中创建类,如前面的截图所示。
如果需要,让 UE4 刷新你的 Visual Studio 或 Xcode 项目。从解决方案资源管理器中打开新的Avatar.h
文件。
UE4 生成的代码看起来有点奇怪。记得我在第五章中建议你避免的宏吗,函数和宏?UE4 代码广泛使用宏。这些宏用于复制和粘贴样板启动代码,让你的代码与 UE4 编辑器集成。
Avatar.h
文件的内容如下所示:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Avatar.generated.h"
UCLASS()
class GOLDENEGG_API AAvatar : public ACharacter
{
GENERATED_BODY()
public:
// Sets default values for this character's properties
AAvatar();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// Called to bind functionality to input
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
};
让我们来谈谈宏。
UCLASS()
宏基本上使你的 C++代码类在 UE4 编辑器中可用。GENERATED_BODY()
宏复制并粘贴了 UE4 需要的代码,以使你的类作为 UE4 类正常运行。
对于UCLASS()
和GENERATED_BODY()
,你不需要真正理解 UE4 是如何运作的。你只需要确保它们出现在正确的位置(在生成类时它们所在的位置)。
将模型与 Avatar 类关联
现在,我们需要将模型与我们的角色对象关联起来。为此,我们需要一个模型来操作。幸运的是,UE4 市场上有一整套免费的示例模型可供使用。
下载免费模型
要创建玩家对象,请执行以下步骤:
- 从市场选项卡下载 Animation Starter Pack 文件(免费)。找到它的最简单方法是搜索它:
-
从 Unreal Launcher 中,点击市场,搜索 Animation Starter Pack,在撰写本书时是免费的。
-
一旦你下载了 Animation Starter Pack 文件,你就可以将它添加到之前创建的任何项目中,如下图所示:
- 当你点击 Animation Starter Pack 下的 Add to project 时,会弹出这个窗口,询问要将包添加到哪个项目中:
- 只需选择你的项目,新的艺术作品将在你的内容浏览器中可用。
加载网格
一般来说,将你的资产(或游戏中使用的对象)硬编码到游戏中被认为是一种不好的做法。硬编码意味着你编写 C++代码来指定要加载的资产。然而,硬编码意味着加载的资产是最终可执行文件的一部分,这意味着在运行时更改加载的资产是不可修改的。这是一种不好的做法。最好能够在运行时更改加载的资产。
因此,我们将使用 UE4 蓝图功能来设置我们的Avatar
类的模型网格和碰撞胶囊。
从我们的 C++类创建蓝图
让我们继续创建一个蓝图,这很容易:
- 通过导航到窗口|开发者工具,然后点击 Class Viewer 来打开 Class Viewer 选项卡,如下所示:
- 在“类查看器”对话框中,开始输入你的 C++类的名称。如果你已经正确地从 C++代码中创建并导出了这个类,它将会出现,就像下面的截图所示:
如果你的Avatar
类没有显示出来,关闭编辑器,然后在 Visual Studio 或 Xcode 中重新编译/运行 C++项目。
- 右键点击你想要创建蓝图的类(在我的例子中,是 Avatar 类),然后选择“创建蓝图类...”。
这是我的 Avatar 类),然后选择“创建蓝图类...”。
-
给你的蓝图起一个独特的名字。我把我的蓝图叫做 BP_Avatar。BP_ 标识它是一个蓝图,这样以后搜索起来更容易。
-
新的蓝图应该会自动打开以供编辑。如果没有,双击 BP_Avatar 打开它(在你添加它之后,它会出现在“类查看器”选项卡下的 Avatar 之下),就像下面的截图所示:
- 你将会看到新的 BP_Avatar 对象的蓝图窗口,就像这样(确保选择“事件图”选项卡):
从这个窗口,你可以在视觉上将模型附加到Avatar
类。同样,这是推荐的模式,因为通常是艺术家设置他们的资产供游戏设计师使用。
- 你的蓝图已经继承了一个默认的骨骼网格。要查看它的选项,点击左侧的 CapsuleComponent 下的 Mesh(Inherited):
- 点击下拉菜单,为你的模型选择 SK_Mannequin:
-
如果 SK_Mannequin 没有出现在下拉菜单中,请确保你下载并将动画起始包添加到你的项目中。
-
碰撞体积呢?你已经有一个叫做 CapsuleComponent 的了。如果你的胶囊没有包裹住你的模型,调整模型使其合适。
如果你的模型最终像我的一样,胶囊位置不对!我们需要调整它。
- 点击 Avatar 模型,然后点击并按住向上的蓝色箭头,就像前面的截图所示。将他移动到合适的位置以适应胶囊。如果胶囊不够大,你可以在详细信息选项卡下调整它的大小,包括 Capsule Half-Height 和 Capsule Radius:
你可以通过调整 Capsule Half-Height 属性来拉伸你的胶囊。
- 让我们把这个 Avatar 添加到游戏世界中。在 UE4 编辑器中,从“类查看器”选项卡中将 BP_Avatar 模型拖放到场景中:
我们的 Avatar 类已经添加到场景中
Avatar 的姿势是默认的姿势。你想要他动起来,是吧!好吧,那很容易,只需按照以下步骤进行:
-
在蓝图编辑器中点击你的 Mesh,你会在右侧的详细信息下看到 Animation。注意:如果你因为任何原因关闭了蓝图并重新打开它,你将看不到完整的蓝图。如果发生这种情况,点击链接打开完整的蓝图编辑器。
-
现在你可以使用蓝图来进行动画。这样,艺术家可以根据角色的动作来正确设置动画。如果你从
AnimClass
下拉菜单中选择 UE4ASP_HeroTPP_AnimBlueprint,动画将会被蓝图(通常是由艺术家完成的)调整,以适应角色的移动:
如果你保存并编译蓝图,并在主游戏窗口中点击播放,你将会看到空闲动画。
我们无法在这里覆盖所有内容。动画蓝图在第十一章中有介绍,怪物。如果你对动画真的感兴趣,不妨花点时间观看一些 Gnomon Workshop 关于 IK、动画和绑定的教程,可以在gnomonworkshop.com/tutorials找到。
还有一件事:让 Avatar 的相机出现在其后面。这将为您提供第三人称视角,使您可以看到整个角色,如下截图所示,以及相应的步骤:
-
在 BP_Avatar 蓝图编辑器中,选择 BP_Avatar(Self)并单击添加组件。
-
向下滚动以选择添加相机。
视口中将出现一个相机。您可以单击相机并移动它。将相机定位在玩家的后方某处。确保玩家身上的蓝色箭头面向相机的方向。如果不是,请旋转 Avatar 模型网格,使其面向与其蓝色箭头相同的方向:
模型网格上的蓝色箭头表示模型网格的前进方向。确保相机的开口面向与角色的前向矢量相同的方向。
编写控制游戏角色的 C++代码
当您启动 UE4 游戏时,您可能会注意到相机没有改变。现在我们要做的是使起始角色成为我们Avatar
类的实例,并使用键盘控制我们的角色。
使玩家成为 Avatar 类的实例
让我们看看我们如何做到这一点。在虚幻编辑器中,执行以下步骤:
- 通过导航到 文件 | 新建 C++类... 并选择 Game Mode Base 来创建 Game Mode 的子类。我命名为
GameModeGoldenEgg
:
UE4 GameMode 包含游戏规则,并描述了游戏如何在引擎中进行。我们稍后将更多地使用我们的GameMode
类。现在,我们需要对其进行子类化。
创建类后,它应该自动编译您的 C++代码,因此您可以创建GameModeGoldenEgg
蓝图。
- 通过转到顶部的菜单栏中的蓝图图标,单击 GameMode New,然后选择+ Create | GameModeGoldenEgg(或者您在步骤 1 中命名的 GameMode 子类)来创建 GameMode 蓝图:
- 命名您的蓝图;我称之为
BP_GameModeGoldenEgg
:
-
您新创建的蓝图将在蓝图编辑器中打开。如果没有打开,您可以从类查看器选项卡中打开 BP_GameModeGoldenEgg 类。
-
从默认 Pawn Class 面板中选择 BP_Avatar 类,如下截图所示。默认 Pawn Class 面板是将用于玩家的对象类型:
- 启动您的游戏。您可以看到一个背面视图,因为相机放置在玩家后面:
您会注意到您无法移动。为什么呢?答案是因为我们还没有设置控制器输入。接下来的部分将教您如何准确地进行操作。
设置控制器输入
以下是设置输入的步骤:
- 要设置控制器输入,转到 设置 | 项目设置...:
- 在左侧面板中,向下滚动直到在引擎下看到输入:
-
在右侧,您可以设置一些绑定。单击+以添加新的绑定,然后单击 Axis Mappings 旁边的小箭头以展开它。开始添加两个轴映射,一个称为 Forward(连接到键盘字母W),另一个称为 Strafe(连接到键盘字母D)。记住您设置的名称;我们将在 C++代码中查找它们。
-
关闭项目设置对话框。打开您的 C++代码。在
Avatar.h
构造函数中,您需要添加两个成员函数声明,如下所示:
UCLASS()
class GOLDENEGG_API AAvatar : public ACharacter
{
GENERATED_BODY()
public:
// Sets default values for this character's properties
AAvatar();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// Called to bind functionality to input
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
// New! These 2 new member function declarations
// they will be used to move our player around!
void MoveForward(float amount);
void MoveRight(float amount);
};
请注意,现有的函数SetupPlayerInputComponent
和Tick
是虚函数的重写。SetupPlayerInputComponent
是APawn
基类中的虚函数。我们还将向这个函数添加代码。
- 在
Avatar.cpp
文件中,您需要添加函数主体。在Super::SetupPlayerInputComponent(PlayerInputComponent);
下面的SetupPlayerInputComponent
中,添加以下行:
check(PlayerInputComponent);
PlayerInputComponent->BindAxis("Forward", this,
&AAvatar::MoveForward);
PlayerInputComponent->BindAxis("Strafe", this, &AAvatar::MoveRight);
这个成员函数查找我们刚刚在虚幻编辑器中创建的前进和横向轴绑定,并将它们连接到this
类内部的成员函数。我们应该连接到哪些成员函数呢?为什么,我们应该连接到AAvatar::MoveForward
和AAvatar::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++函数以将玩家向左和向后移动。
这里有个提示:如果你意识到向后走实际上就是向前走的负数,那么你只需要添加轴绑定。
解决方案
通过导航到设置|项目设置...|输入,添加两个额外的轴绑定,如下所示:
通过将 S 和 A 输入乘以-1.0 来缩放。这将反转轴,因此在游戏中按下S键将使玩家向前移动。试试看!
或者,您可以在AAvatar
类中定义两个完全独立的成员函数,如下所示,并将A和S键分别绑定到AAvatar::MoveLeft
和AAvatar::MoveBack
(并确保为这些函数添加绑定到AAvatar::SetupPlayerInputComponent
):
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(UInputComponent* PlayerInputComponent)
{
// .. as before, plus:
PlayerInputComponent->BindAxis("Yaw", this, &AAvatar::Yaw);
PlayerInputComponent->BindAxis("Pitch", this, &AAvatar::Pitch);
}
在这里,注意我如何将Yaw
和Pitch
函数中的amount
值乘以 200。这个数字代表鼠标的灵敏度。您可以(应该)在AAvatar
类中添加一个float
成员,以避免硬编码这个灵敏度数字。
GetWorld()->GetDeltaSeconds()
给出了上一帧和这一帧之间经过的时间。这不是很多;GetDeltaSeconds()
大多数时候应该在 16 毫秒左右(如果您的游戏以 60fps 运行)。
注意:您可能会注意到现在俯仰实际上并不起作用。这是因为您正在使用第三人称摄像头。虽然对于这个摄像头可能没有意义,但您可以通过进入 BP_Avatar,选择摄像头,并在摄像头选项下勾选使用 Pawn 控制旋转来使其起作用:
因此,现在我们有了玩家输入和控制。要为您的 Avatar 添加新功能,您只需要做到这一点:
-
通过转到设置|项目设置|输入,绑定您的键盘或鼠标操作。
-
添加一个在按下该键时运行的成员函数。
-
在
SetupPlayerInputComponent
中添加一行,将绑定输入的名称连接到我们希望在按下该键时运行的成员函数。
创建非玩家角色实体
因此,我们需要创建一些NPC(非玩家角色)。NPC 是游戏中帮助玩家的角色。一些提供特殊物品,一些是商店供应商,一些有信息要提供给玩家。在这个游戏中,他们将在玩家靠近时做出反应。让我们在一些行为中编程:
-
创建另一个 Character 的子类。在 UE4 编辑器中,转到文件 | 新建 C++类...,并选择可以创建子类的 Character 类。将您的子类命名为
NPC
。 -
在 Visual Studio 中编辑您的代码。每个 NPC 都会有一条消息告诉玩家,因此我们在
NPC
类中添加了一个UPROPERTY() FString
属性。
FString
是 UE4 中 C++的<string>
类型。在 UE4 中编程时,应该使用FString
对象而不是 C++ STL 的string
对象。一般来说,应该使用 UE4 的内置类型,因为它们保证跨平台兼容性。
- 以下是如何向
NPC
类添加UPROPERTY() FString
属性:
UCLASS()
class GOLDENEGG_API ANPC : public ACharacter
{
GENERATED_BODY()
// 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.
public:
// Sets default values for this character's properties
ANPC();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// Called to bind functionality to input
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
};
请注意,我们将EditAnywhere
和BlueprintReadWrite
属性放入了UPROPERTY
宏中。这将使NpcMessage
在蓝图中可编辑。
所有 UE4 属性说明符的完整描述可在docs.unrealengine.com/latest/INT/Programming/UnrealArchitecture/Reference/Properties/index.html
上找到。
-
重新编译您的项目(就像我们为
Avatar
类所做的那样)。然后,转到类查看器,在您的NPC
类上右键单击,并从中创建蓝图类。 -
您想要创建的每个 NPC 角色都可以是基于
NPC
类的蓝图。为每个蓝图命名一个独特的名称,因为我们将为每个出现的 NPC 选择不同的模型网格和消息,如下面的屏幕截图所示:
- 打开蓝图并选择 Mesh(继承)。然后,您可以在骨骼网格下拉菜单中更改您的新角色的材质,使其看起来与玩家不同:
通过从下拉菜单中选择每个元素,更改您的角色在网格属性中的材质
- 在组件选项卡中选择蓝图名称(self),在详细信息选项卡中查找
NpcMessage
属性。这是我们在 C++代码和蓝图之间的连接;因为我们在FString NpcMessage
变量上输入了UPROPERTY()
函数,该属性在 UE4 中显示为可编辑,如下面的屏幕截图所示:
- 将 BP_NPC_Owen 拖入场景中。您也可以创建第二个或第三个角色,并确保为它们提供独特的名称、外观和消息:
我已经为基于 NPC 基类的 NPC 创建了两个蓝图:BP_NPC_Jonathan 和 BP_NPC_Owen。它们对玩家有不同的外观和不同的消息:
场景中的 Jonathan 和 Owen
显示每个 NPC 对话框中的引用
为了显示对话框,我们需要一个自定义的悬浮显示(HUD)。在 UE4 编辑器中,转到文件 | 新建 C++类...,并选择从中创建子类的HUD
类(您需要向下滚动以找到它)。按您的意愿命名您的子类;我命名为MyHUD
。
创建MyHUD
类后,让 Visual Studio 重新加载。我们将进行一些代码编辑。
在 HUD 上显示消息
在AMyHUD
类中,我们需要实现DrawHUD()
函数,以便将我们的消息绘制到 HUD 上,并使用以下MyHUD.h
中的代码初始化 HUD 的字体绘制:
UCLASS()
class GOLDENEGG_API AMyHUD : public AHUD
{
GENERATED_BODY()
public:
// 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!
const FVector2D ViewportSize = FVector2D(GEngine->GameViewport->Viewport->GetSizeXY());
DrawText("Greetings from Unreal!", FLinearColor::White, ViewportSize.X/2, ViewportSize.Y/2, hudFont);
}
等等!我们还没有初始化我们的字体。让我们现在做这个:
- 在蓝图中设置它。在编辑器中编译您的 Visual Studio 项目,然后转到顶部的蓝图菜单,导航到 GameMode | HUD | + Create | MyHUD:
创建 MyHUD 类的蓝图
- 我称我的为
BP_MyHUD
。找到Hud Font
,选择下拉菜单,并创建一个新的字体资源。我命名为MyHUDFont
:
- 在内容浏览器中找到 MyHUDFont 并双击以编辑它:
在随后的窗口中,您可以点击+ Add Font
创建一个新的默认字体系列。您可以自行命名并单击文件夹图标选择硬盘上的字体(您可以在许多网站免费找到.TTF 或 TrueType 字体 - 我使用了找到的 Blazed 字体);当您导入字体时,它将要求您保存字体。您还需要将 MyHUDFont 中的 Legacy Font Size 更改为更大的大小(我使用了 36)。
- 编辑您的游戏模式蓝图(BP_GameModeGoldenEgg)并选择您的新
BP_MyHUD
(而不是MyHUD
)类作为 HUD Class 面板:
编译并测试您的程序!您应该在屏幕上看到打印的文本:
练习
您可以看到文本并没有完全居中。这是因为位置是基于文本的左上角而不是中间的。
看看你能否修复它。这里有一个提示:获取文本的宽度和高度,然后从视口宽度和高度/2 中减去一半。您将需要使用类似以下的内容:
const FVector2D ViewportSize = FVector2D(GEngine->GameViewport->Viewport->GetSizeXY());
const FString message("Greetings from Unreal!");
float messageWidth = 0;
float messageHeight = 0;
GetTextSize(message, messageWidth, messageHeight, hudFont);
DrawText(message, FLinearColor::White, (ViewportSize.X - messageWidth) / 2, (ViewportSize.Y - messageHeight) / 2, hudFont);
使用 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;
}
};
现在,在AMyHUD
类内,我们要添加一个这些消息的TArray
。TArray
是 UE4 定义的一种特殊类型的动态增长的 C++数组。我们将在第九章中详细介绍TArray
的使用,但这种简单的TArray
使用应该是对游戏中数组的有用性的一个很好的介绍。这将被声明为TArray<Message>
:
UCLASS()
class GOLDENEGG_API AMyHUD : public AHUD
{
GENERATED_BODY()
public:
// 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);
};
还要在文件顶部添加#include "CoreMinimal.h"
。
现在,每当 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()
的单独函数中。您可能希望创建至少一个样本消息对象,并调用addMessage
以便您可以看到它。
Canvas
变量仅在DrawHUD()
中可用,因此您将不得不将Canvas->SizeX
和Canvas->SizeY
保存在类级变量中。
重构意味着改变代码的内部工作方式,使其更有组织或更容易阅读,但对于运行程序的用户来说,结果看起来是一样的。重构通常是一个好的实践。重构发生的原因是因为没有人在开始编写代码时确切地知道最终的代码应该是什么样子。
当玩家靠近 NPC 时触发事件
要在 NPC 附近触发事件,我们需要设置一个额外的碰撞检测体积,它比默认的胶囊形状稍宽。额外的碰撞检测体积将是每个 NPC 周围的一个球体。当玩家走进 NPC 的球体时,NPC(如下所示)会做出反应并显示一条消息:
我们将向 NPC 添加深红色的球体,以便它可以知道玩家是否附近。
在NPC.h
类文件中,添加#include "Components/SphereComponent.h"
到顶部,并添加以下代码:
UCLASS() class GOLDENEGG_API ANPC : public ACharacter {
GENERATED_BODY()
public:
// The sphere that the player can collide with tob
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category =
Collision)
USphereComponent* ProxSphere;
// This is the NPC's message that he has to tell us.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category =
NPCMessage)
FString NpcMessage; // 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(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
// You shouldn't need this unless you get a compiler error that it can't find this function.
virtual int Prox_Implementation(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
// Sets default values for this character's properties
ANPC(const FObjectInitializer& ObjectInitializer);
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// Called to bind functionality to input
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
};
这看起来有点凌乱,但实际上并不复杂。在这里,我们声明了一个额外的边界球体积,称为ProxSphere
,它可以检测玩家是否靠近 NPC。
在NPC.cpp
文件中,我们需要添加以下代码以完成接近检测:
ANPC::ANPC(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
ProxSphere = ObjectInitializer.CreateDefaultSubobject<USphereComponent>(this,
TEXT("Proximity Sphere"));
ProxSphere->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
ProxSphere->SetSphereRadius(32.0f);
// 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.
int ANPC::Prox_Implementation(UPrimitiveComponent* OverlappedComponent, 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
return 0;
}
当玩家附近的 NPC 向 HUD 显示内容
当玩家靠近 NPC 的球体碰撞体积时,向 HUD 显示一条消息,提醒玩家 NPC 在说什么。
这是ANPC::Prox_Implementation
的完整实现:
int ANPC::Prox_Implementation(UPrimitiveComponent* OverlappedComponent, 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 -1;
}
APlayerController* PController = GetWorld()->GetFirstPlayerController();
if( PController )
{
AMyHUD * hud = Cast<AMyHUD>( PController->GetHUD() );
hud->addMessage( Message( NpcMessage, 5.f, FColor::White ) );
}
return 0;
}
还要确保在文件顶部添加以下内容:
#include "Avatar.h"
#include "MyHud.h"
在这个函数中,我们首先将OtherActor
(靠近 NPC 的物体)转换为AAvatar
。当OtherActor
是AAvatar
对象时,转换成功(且不为nullptr
)。我们获取 HUD 对象(它恰好附加到玩家控制器上),并将 NPC 的消息传递给 HUD。每当玩家在 NPC 周围的红色边界球体内时,消息就会显示出来:
乔纳森的问候
练习
尝试这些以进行更多练习:
-
为 NPC 的名称添加一个
UPROPERTY
函数名称,以便在蓝图中可编辑 NPC 的名称,类似于 NPC 对玩家的消息。在输出中显示 NPC 的名称。 -
为 NPC 的面部纹理添加一个
UPROPERTY
函数(类型为UTexture2D*
)。在输出中,将 NPC 的面部显示在其消息旁边。 -
将玩家的 HP 渲染为一条条形图(填充矩形)。
解决方案
将以下属性添加到ANPC
类中:
// This is the NPC's name
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = NPCMessage)
FString name;
然后,在ANPC::Prox_Implementation
中,将传递给 HUD 的字符串更改为这样:
name + FString(": ") + NpcMessage
这样,NPC 的名称将附加到消息上。
为ANPC
类添加this
属性:
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>(
b UGameplayStatics::GetPlayerPawn(GetWorld(), 0));
float barWidth = 200, barHeight = 50, barPad = 12, barMargin = 50;
float percHp = avatar->Hp / avatar->MaxHp;
const FVector2D ViewportSize = FVector2D(GEngine->GameViewport->Viewport->GetSizeXY());
DrawRect(FLinearColor(0, 0, 0, 1), ViewportSize.X - barWidth -
barPad - barMargin, ViewportSize.Y - barHeight - barPad -
barMargin, barWidth + 2 * barPad, barHeight + 2 * barPad); DrawRect(FLinearColor(1 - percHp, percHp, 0, 1), ViewportSize.X
- barWidth - barMargin, ViewportSize.Y - barHeight - barMargin,
barWidth*percHp, barHeight);
}
您还需要将Hp
和MaxHp
添加到 Avatar 类中(现在可以为测试设置默认值),并将以下内容添加到文件顶部:
#include "Kismet/GameplayStatics.h"
#include "Avatar.h"
总结
在这一章中,我们涉及了很多材料。我们向您展示了如何创建一个角色并在屏幕上显示它,如何使用轴绑定来控制您的角色,以及如何创建和显示可以向 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
-
TSet 和 TMap
-
常用容器的 C++ STL 版本
在 UE4 中调试输出
本章中的所有代码(以及后面的章节)都需要您在 UE4 项目中工作。为了测试TArray
,我创建了一个名为TArrays
的基本代码项目。在ATArraysGameMode::ATArraysGameMode
构造函数中,我使用调试输出功能将文本打印到控制台。
以下是TArraysGameMode.cpp
中的代码:
#include "TArraysGameMode.h"
#include "Engine/Engine.h"
ATArraysGameMode::ATArraysGameMode(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 30.f, FColor::Red,
TEXT("Hello!"));
}
}
确保您还将函数添加到.h
文件中。如果编译并运行此项目,您将在启动游戏时在游戏窗口的左上角看到调试文本。您可以使用调试输出随时查看程序的内部。只需确保在调试输出时GEngine
对象存在。上述代码的输出显示在以下截图中(请注意,您可能需要将其作为独立游戏运行才能看到):
模板和容器
模板是一种特殊类型的对象。模板对象允许您指定它应该期望的数据类型。例如,很快您将看到,您可以运行一个TArray<T>
变量。这是一个模板的例子。
要理解TArray<T>
变量是什么,首先必须知道尖括号之间的<T>
选项代表什么。<T>
选项表示数组中存储的数据类型是一个变量。您想要一个int
数组吗?然后创建一个TArray<int>
变量。double
的TArray
变量?创建一个TArray<double>
变量。
因此,通常情况下,无论何时出现<T>
,您都可以插入您选择的 C++数据类型。
容器是用于存储对象的不同结构。模板对此特别有用,因为它们可以用于存储许多不同类型的对象。您可能希望使用 int 或 float 存储数字,字符串或不同类型的游戏对象。想象一下,如果您必须为您想要存储的每种对象类型编写一个新类。幸运的是,您不必这样做。模板让一个类足够灵活,可以处理您想要存储在其中的任何对象。
你的第一个模板
创建模板是一个高级主题,您可能多年不需要创建自己的模板(尽管您会一直使用标准模板)。但是,看看一个模板是什么样子可能有助于您了解幕后发生了什么。
想象一下,您想创建一个数字模板,让您可以使用 int、float 或其他类型。您可以做类似于这样的事情:
template <class T>
class Number {
T value;
public:
Number(T val)
{
value = val;
}
T getSumWith(T val2);
};
template <class T>
T Number<T>::getSumWith(T val2)
{
T retval;
retval = value + val2;
return retval;
}
第一部分是类本身。正如您所看到的,您想在模板中的任何地方使用类型,您制作类并使用T
而不是指定特定类型。您还可以使用模板来指定发送到函数的值。在这种情况下,最后一部分允许您添加另一个数字并返回总和。
您甚至可以通过重载+运算符来简化事情,以便您可以像使用任何标准类型一样添加这些数字。这是通过一种称为运算符重载的东西。
UE4 的 TArray
TArrays 是 UE4 的动态数组版本,使用模板构建。与我们讨论过的其他动态数组一样,您无需担心自己管理数组大小。让我们继续并通过一个示例来看看这个。
使用 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 );
以下四行代码将产生内存中的数组值,如下图所示:
当您调用array.Add(number)
时,新数字将添加到数组的末尾。由于我们按顺序向数组添加了数字1、10、5和20,因此它们将按照这个顺序进入数组。
如果要在数组的前面或中间插入一个数字,也是可能的。您只需使用array.Insert(value, index)
函数,如下面的代码所示:
array.Insert( 9, 0 );
此函数将数字9
推入数组的位置0
(在前面)。这意味着数组的其余元素将向右偏移,如下图所示:
我们可以使用以下代码将另一个元素插入到数组的位置2
:
array.Insert( 30, 2 );
此函数将重新排列数组,如下图所示:
如果在数组中插入一个超出边界的位置的数字(它不存在),UE4 将崩溃。所以,要小心不要这样做。您可以使用Add
来添加一个新项目。
迭代 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( -1, 30.f, FColor::Red,
FString::FromInt( array[ index ] ) );
}
迭代器
您还可以使用迭代器逐个遍历数组的元素,如下面的代码所示:
for (TArray<int>::TIterator it = array.CreateIterator(); it; ++it)
{
GEngine->AddOnScreenDebugMessage(-1, 30.f, FColor::Green, FString::FromInt(*it));
}
迭代器是数组中的指针。迭代器可用于检查或更改数组中的值。迭代器的示例如下图所示:
迭代器是一个外部对象,可以查看和检查数组的值。执行++it
将迭代器移动到检查下一个元素。
迭代器必须适用于它正在遍历的集合。要遍历TArray<int>
变量,您需要一个TArray<int>::TIterator
类型的迭代器。
我们使用*
来查看迭代器后面的值。在上述代码中,我们使用(*it)
从迭代器中获取整数值。这称为解引用。解引用迭代器意味着查看其值。
for
循环的每次迭代结束时发生的++it
操作会递增迭代器,将其移动到指向列表中的下一个元素。
将代码插入程序并立即尝试。以下是我们迄今为止使用TArray
创建的示例程序(全部在ATArraysGameMode::ATArraysGameMode()
构造函数中):
ATArraysGameMode::ATArraysGameMode(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
if (GEngine)
{
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>
变量存储一组字符串。TSet
和TArray
之间的主要区别在于,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
中的条目没有编号,就像TArray
中一样;您不能使用方括号来访问TSet
数组中的条目。
迭代 TSet
要查看TSet
数组,必须使用迭代器。您不能使用方括号表示法来访问TSet
的元素:
for( TSet<int>::TIterator it = set.CreateIterator(); it; ++it )
{
GEngine->AddOnScreenDebugMessage( -1, 30.f, FColor::Red,
FString::FromInt( *it ) );
}
交集 TSet 数组
TSet
数组有两个TArray
变量没有的特殊函数。两个TSet
数组的交集基本上是它们共有的元素。如果我们有两个TSet
数组,比如X
和Y
,并且我们对它们进行交集运算,结果将是一个第三个新的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
X
和Y
之间的共同元素将只是元素2
。
并集 TSet 数组
从数学上讲,两个集合的并集是指将所有元素插入到同一个集合中。由于我们在这里讨论的是集合,所以不会有重复项。
如果我们从前面的示例中获取X
和Y
集合并创建一个并集,我们将得到一个新的集合,如下所示:
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
表示该物品的数量,如下表所示:
项目(键) | 数量(值) |
---|---|
apples |
4 |
donuts |
12 |
swords |
1 |
shields |
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( -1, 30.f, FColor::Red,
it->Key + FString(": ") + FString::FromInt( it->Value ) );
}
TMap
迭代器与TArray
或TSet
迭代器略有不同。TMap
迭代器包含Key
和Value
。我们可以使用it->Key
访问键,并使用it->Value
访问TMap
中的值。
这里有一个例子:
TLinkedList/TDoubleLinkedList
当你使用 TArray 时,每个项目都有一个按数字顺序排列的索引,数组数据通常以相同的方式存储,因此每个条目在内存中都紧邻前一个条目。但是,如果你需要在中间某个位置放置一个新项目(例如,如果数组中填充了按字母顺序排列的字符串),该怎么办呢?
由于项目是相邻的,旁边的项目将不得不移动以腾出空间。但是为了做到这一点,旁边的那个也将不得不移动。这将一直持续到数组的末尾,当它最终到达可以在不移动其他东西的内存时。你可以想象,这可能会变得非常慢,特别是如果你经常这样做的话。
这就是链表派上用场的地方。链表没有任何索引。链表有包含项目并让你访问列表上第一个节点的节点。该节点有指向列表上下一个节点的指针,你可以通过调用Next()
来获取。然后,你可以在那个节点上调用Next()
来获取它后面的节点。它看起来像这样:
你可能会猜到,如果你在列表末尾寻找项目,这可能会变得很慢。但与此同时,你可能并不经常搜索列表,而是可能在中间添加新项目。在中间添加项目要快得多。比如,你想在Node 1和Node 2之间插入一个新节点,就像这样:
这次不需要在内存中移动东西来腾出空间。相反,要在另一个项目后插入一个项目,获取Next()
指向的节点从Node 1(Node 2)开始。将新节点设置为指向该节点(Node 2)。然后,将 Node 1 设置为指向新节点。现在它应该看起来像这样:
然后,你就完成了!
那么,如果你将花费更多时间查找列表末尾的项目怎么办?这就是TDoubleLinkedList
派上用场的地方。双向链表可以给你列表中的第一个节点或最后一个节点。每个节点还有指向下一个节点和上一个节点的指针。你可以使用GetNextLink()
和GetPrevLink()
来访问这些。因此,你可以选择向前或向后遍历列表,甚至两者兼而有之,最终相遇在中间。
现在,你可能会问自己,“为什么要在我可以只使用 TArray 而不用担心它在幕后做什么的情况下?”首先,专业的游戏程序员总是要担心速度。计算机和游戏机的每一次进步都伴随着更多和更好的图形以及其他使事情变得更慢的进步。因此,优化速度总是很重要的。
另外,还有另一个实际的原因:我可以告诉你,根据我的经验,这个行业中有些人会在面试中拒绝你,如果你不使用链表。程序员都有自己偏好的做事方式,所以你应该熟悉可能出现的任何事情。
常用容器的 C++ STL 版本
现在,我们将介绍几种容器的 C++ STL 版本。STL 是标准模板库,大多数 C++编译器都附带。我想介绍这些 STL 版本的原因是它们的行为与相同容器的 UE4 版本有些不同。在某些方面,它们的行为非常好,但游戏程序员经常抱怨 STL 存在性能问题。特别是,我想介绍 STL 的set
和map
容器,但我也会介绍常用的vector
。
如果您喜欢 STL 的接口但希望获得更好的性能,有一个由艺电重新实现的 STL 库,名为 EASTL,您可以使用。它提供与 STL 相同的功能,但实现了更好的性能(基本上是通过消除边界检查等方式)。它可以在 GitHub 上找到github.com/paulhodge/EASTL
。
C++ STL set
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
头部的迭代器。
停止迭代的条件是当它变为intSet.end()
。intSet.end()
实际上是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
中。
在
您可以使用 STL map 的find
成员函数在 map 中搜索<key
,value
>对。通常通过key
进行搜索,它会给您该key
的值。
练习
要求用户输入五个项目及其数量到空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
映射中。
C++ STL Vector
Vector
是 STL 中TArray
的等价物。它基本上是一个在幕后管理一切的数组,就像TArray
一样。在使用 UE4 时可能不需要使用它,但了解它是很好的,以防其他人在项目中使用它。
摘要
UE4 的容器和 C++ STL 容器系列都非常适合存储游戏数据。选择合适的数据容器类型可以大大简化编程问题。
在下一章中,我们将通过跟踪玩家携带的物品并将这些信息存储在TMap
对象中,实际开始编写游戏的开头部分。
第十章:库存系统和拾取物品
我们希望玩家能够从游戏世界中拾取物品。在本章中,我们将为玩家编写和设计一个背包来存放物品。当用户按下I键时,我们将显示玩家携带的物品。
作为数据表示,我们可以使用上一章中介绍的TMap<FString, int>
来存储我们的物品。当玩家拾取物品时,我们将其添加到地图中。如果物品已经在地图中,我们只需增加其值,即新拾取的物品的数量。
在本章中,我们将涵盖以下主题:
-
声明背包
-
PickupItem 基类
-
绘制玩家库存
声明背包
我们可以将玩家的背包表示为一个简单的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_BODY()
public:
// 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
类型对象的可执行代码,我们需要前向声明。虽然包含一个.h 文件可能更容易,但有时最好避免这样做,否则可能会出现循环依赖(两个类互相包含可能会导致问题)。
缺少前向声明将导致编译错误,因为编译器在编译class AAvatar
中的代码之前不知道class APickupItem
。编译器错误将出现在APickupItem::Pickup( APickupItem *item );
函数原型声明处。
我们在AAvatar
类中声明了两个TMap
对象。如下表所示:
FString (名称) |
int (数量) |
UTexture2D* (im) |
---|---|---|
GoldenEgg |
2 |
![]() |
MetalDonut |
1 |
![]() |
Cow |
2 |
![]() |
在TMap
背包中,我们存储玩家持有的物品的FString
变量。在图标
映射中,我们存储玩家持有物品的图像的单个引用。
在渲染时,我们可以使用两个地图一起工作,查找玩家拥有的物品数量(在他的背包
映射中),以及该物品的纹理资产引用(在图标
映射中)。以下屏幕截图显示了 HUD 的渲染效果:
请注意,我们还可以使用一个包含FString
变量和UTexture2D*
的struct
数组,而不是使用两个地图。
例如,我们可以使用TArray<Item> Backpack;
和一个struct
变量,如下面的代码所示:
struct Item
{
FString name;
int qty;
UTexture2D* tex;
};
然后,当我们拾取物品时,它们将被添加到线性数组中。然而,计算我们在背包中每种物品的数量将需要通过遍历整个数组来进行不断的重新评估。例如,要查看您有多少个发夹,您需要遍历整个数组。这不如使用地图高效。
导入资产
您可能已经注意到前面屏幕截图中的 Cow 资产,这不是 UE4 在新项目中提供的标准资产集的一部分。为了使用 Cow 资产,您需要从内容示例项目中导入 cow。UE4 使用标准的导入过程。
在下面的屏幕截图中,我已经概述了导入 Cow 资产的过程。其他资产将使用相同的方法从 UE4 中的其他项目导入。
执行以下步骤导入 Cow 资产:
- 下载并打开 UE4 的 Content Examples 项目。在 Epic Game Launcher 的 Learn 下找到它,如下所示:
- 下载 Content Examples 后,打开它并单击
创建项目:
-
接下来,命名您将放置
ContentExamples
的文件夹,然后单击创建。 -
从库中打开您的
ContentExamples
项目。浏览项目中可用的资产,直到找到您喜欢的资产。按照惯例,搜索SM_
将有所帮助,因为所有静态网格通常以SM_
开头:
项目中可用的资产
- 当您找到喜欢的资产时,通过右键单击资产,然后单击 Asset Actions > Migrate...将其导入到您的项目中:
- 在 Asset Report 对话框中单击确定:
- 从您要将 SM_Toy_Cow 文件添加到的项目的 Content 文件夹中选择。我们将把它添加到
/Documents/Unreal Projects/GoldenEgg/Content
,如下面的截图所示:
- 如果导入成功完成,您将看到以下消息:
- 一旦您导入资产,您将在项目内的资产浏览器中看到它显示出来:
然后您可以在项目中正常使用该资产。
将动作映射附加到键
我们需要附加一个键来激活玩家库存的显示。在 UE4 编辑器中,按照以下步骤操作:
-
添加一个名为
Inventory
的 Action Mappings+ -
将其分配给键盘键I,如下所示:
- 接下来,在
Avatar.h
文件中,添加一个成员函数,以在需要显示玩家库存时运行:
void ToggleInventory();
- 在
Avatar.cpp
文件中,实现ToggleInventory()
函数,如下面的代码所示:
void AAvatar::ToggleInventory()
{
if( GEngine )
{
GEngine->AddOnScreenDebugMessage( -1, 5.f, FColor::Red,
"Showing inventory..." );
}
}
- 然后,在
SetupPlayerInputComponent()
中将"Inventory"
动作连接到AAvatar::ToggleInventory()
:
void AAvatar::SetupPlayerInputComponent(class UInputComponent*
InputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
check(PlayerInputComponent);
PlayerInputComponent->BindAction("Inventory", IE_Pressed, this,
&AAvatar::ToggleInventory);
// rest of SetupPlayerInputComponent same as before
}
拾取物品基类
我们需要在代码中定义拾取物品的外观。每个拾取物品将从一个共同的基类派生。现在让我们构造一个PickupItem
类的基类。
PickupItem
基类应该继承自AActor
类。类似于我们如何从基础 NPC 类创建多个 NPC 蓝图,我们可以从单个PickupItem
基类创建多个PickupItem
蓝图,如下面的截图所示:
此截图中的文本不重要。此图让您了解如何从单个PickupItem
基类创建多个PickupItem
蓝图
创建PickupItem
类后,打开其代码在 Visual Studio 中。
APickupItem
类将需要相当多的成员,如下所示:
-
一个用于被拾取物品名称的
FString
变量 -
一个用于被拾取物品数量的
int32
变量 -
一个用于碰撞的球体的
USphereComponent
变量,以便拾取物品 -
一个用于保存实际
Mesh
的UStaticMeshComponent
变量 -
一个用于表示物品的图标的
UTexture2D
变量 -
一个 HUD 的指针(稍后我们将初始化)
PickupItem.h
中的代码如下:
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"
#include "PickupItem.generated.h"
UCLASS()
class GOLDENEGG_API APickupItem : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
APickupItem(const FObjectInitializer& ObjectInitializer);
// 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)
USphereComponent* ProxSphere;
// The mesh of the item
UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category = Item)
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(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
virtual int Prox_Implementation(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
};
所有这些UPROPERTY()
声明的目的是使APickupItem
完全可由蓝图配置。例如,Pickup 类别中的项目将在蓝图编辑器中显示如下:
在PickupItem.cpp
文件中,完成APickupItem
类的构造函数,如下面的代码所示:
APickupItem::APickupItem(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
Name = "UNKNOWN ITEM";
Quantity = 0;
// initialize the unreal objects
ProxSphere = ObjectInitializer.CreateDefaultSubobject<USphereComponent>(this,
TEXT("ProxSphere"));
Mesh = ObjectInitializer.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->AttachToComponent(Mesh, FAttachmentTransformRules::KeepWorldTransform); // very important!
}
在前两行中,我们对Name
和Quantity
进行了初始化,使其值在游戏设计师看来是未初始化的。我们使用大写字母,以便设计师可以清楚地看到该变量以前从未被初始化过。
然后,我们使用ObjectInitializer.CreateDefaultSubobject
初始化ProxSphere
和Mesh
组件。新初始化的对象可能已经初始化了一些默认值,但Mesh
将为空。您将不得不稍后在蓝图中加载实际的网格。
对于网格,我们将其设置为模拟真实物理,以便如果放下或移动,捡起物品会弹跳和滚动。特别注意ProxSphere->AttachToComponent(Mesh, FAttachmentTransformRules::KeepWorldTransform);
这一行。这行告诉您确保捡起物品的ProxSphere
组件附加到Mesh
根组件。这意味着当网格在级别中移动时,ProxSphere
会跟随移动。如果忘记了这一步(或者反过来做了),那么ProxSphere
在弹跳时将不会跟随网格。
根组件
在上述代码中,我们将APickupItem
的RootComponent
分配给了Mesh
对象。RootComponent
成员是AActor
基类的一部分,因此每个AActor
及其派生类都有一个根组件。根组件基本上是对象的核心,并且还定义了您与对象的碰撞方式。RootComponent
对象在Actor.h
文件中定义,如下面的代码所示:
/** Collision primitive that defines the transform (location, rotation, scale) of this Actor. */
UPROPERTY(BlueprintGetter=K2_GetRootComponent, Category="Utilities|Transformation")
USceneComponent* RootComponent;
因此,UE4 的创建者打算RootComponent
始终是对碰撞原语的引用。有时,碰撞原语可以是胶囊形状,其他时候可以是球形甚至是盒形,或者可以是任意形状,就像我们的情况一样,具有网格。然而,角落的盒子可能会被卡在墙上,因此很少有角色应该有盒状的根组件。通常更喜欢圆形。RootComponent
属性显示在蓝图中,您可以在那里查看和操作它:
创建基于 PickupItem 类的蓝图后,可以从其蓝图中编辑 ProxSphere 根组件
最后,Prox_Implementation
函数得到实现,如下所示:
int APickupItem::Prox_Implementation(UPrimitiveComponent* OverlappedComponent, 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 -1;
}
// 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)
);
Destroy();
return 0;
}
此外,请确保在文件顶部添加以下内容:
#include "Avatar.h"
#include "MyHUD.h"
#include "Kismet/GameplayStatics.h"
这里有一些非常重要的提示:首先,我们必须访问一些全局对象来获取我们需要的对象。通过这些函数,我们将访问三个主要对象,这些对象操作 HUD:
-
控制器 (
APlayerController
) -
HUD (
AMyHUD
) -
玩家本身(
AAvatar
)
游戏实例中只有这三种类型的对象中的一个。UE4 使得找到它们变得很容易。
此外,为了编译这个,您还需要在MyHud.h
中的Message
结构中添加另一个构造函数。您需要一个可以让您像这样传递图像的构造函数:
Message(UTexture2D* img, FString iMessage, float iTime, FColor iColor)
{
tex = img;
message = iMessage;
time = iTime;
color = iColor;
}
要编译,您还需要向结构体添加另一个变量UTexture2D* tex;
。您还需要在 Avatar 中实现 Pickup 函数。
获取 avatar
player
类对象可以通过简单调用以下代码从代码的任何地方找到:
AAvatar *avatar = Cast<AAvatar>(
UGameplayStatics::GetPlayerPawn( GetWorld(), 0 ) );
然后我们通过调用之前定义的AAvatar::Pickup()
函数将物品传递给玩家。
因为 PlayerPawn 对象实际上是一个 AAvatar 实例,所以我们将结果转换为 AAvatar 类,使用 Cast
获取玩家控制器
检索玩家控制器也可以通过全局函数完成:
APlayerController* PController =
GetWorld()->GetFirstPlayerController();
GetWorld()
函数实际上是在UObject
基类中定义的。由于所有 UE4 对象都派生自UObject
,因此游戏中的任何对象实际上都可以访问world
对象。
获取 HUD
尽管这种组织可能一开始看起来很奇怪,但 HUD 实际上是附加到玩家的控制器上的。您可以按如下方式检索 HUD:
AMyHUD* hud = Cast<AMyHUD>( PController->GetHUD() );
我们对 HUD 对象进行转换,因为我们之前在蓝图中将 HUD 设置为AMyHUD
实例。由于我们将经常使用 HUD,我们实际上可以在APickupItem
类内部存储一个永久指针指向 HUD。我们稍后会讨论这一点。
接下来,我们实现AAvatar::Pickup
,它将一个APickupItem
类型的对象添加到 Avatar 的背包中:
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);
}
}
还要确保在文件顶部添加#include "PickupItem.h"
。
在前面的代码中,我们检查玩家刚刚获得的捡起物品是否已经在他的背包中。如果是,我们增加它的数量。如果不在他的背包中,我们将其添加到他的背包和Icons
映射中。
要将捡起物品添加到背包中,请使用以下代码行:
avatar->Pickup( this );
APickupItem::Prox_Implementation
是调用该成员函数的方式。
现在,当玩家按下I键时,我们需要在 HUD 中显示背包的内容。
绘制玩家库存
像暗黑破坏神这样的游戏中的库存屏幕会显示一个弹出窗口,其中过去捡起的物品的图标排列在一个网格中。我们可以在 UE4 中实现这种行为。
在 UE4 中绘制 UI 有许多方法。最基本的方法是简单地使用HUD::DrawTexture()
调用。另一种方法是使用 Slate。还有一种方法是使用最新的 UE4 UI 功能:虚幻运动图形(UMG)设计师。
Slate 使用声明性语法在 C++中布局 UI 元素。Slate 最适合菜单等。UMG 自 UE 4.5 以来一直存在,并使用基于蓝图的工作流程。由于我们这里的重点是使用 C++代码的练习,我们将坚持使用HUD::DrawTexture()
实现,但我们将在后面的章节中介绍 UMG。这意味着我们将不得不在我们的代码中管理所有与库存有关的数据。
使用 HUD::DrawTexture()
HUD::DrawTexture()
是我们将在此时用来将库存绘制到屏幕上的方法。我们将分两步实现这一点:
-
当用户按下I键时,我们将库存的内容推送到 HUD。
-
然后,我们以网格方式将图标渲染到 HUD 中。
为了保存有关小部件如何渲染的所有信息,我们声明了一个简单的结构来保存有关它使用的图标、当前位置和当前大小的信息。
这是Icon
和Widget
结构的样子:
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
类中声明将小部件渲染到屏幕上的函数。首先,在MyHud.h
中,添加一个数组来保存小部件,以及一个向量来保存屏幕尺寸:
// New! An array of widgets for display
TArray<Widget> widgets;
//Hold screen dimensions
FVector2D dims;
- 还要添加一行
void DrawWidgets();
。然后,将其添加到MyHud.cpp
中:
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);
}
}
- 应该在
DrawHUD()
函数中添加对DrawWidgets()
函数的调用,并且您可能希望将当前的消息处理代码移动到一个单独的DrawMessages
函数中,以便您可以随后获取这一点(或者只是保留原始代码):
void AMyHUD::DrawHUD()
{
Super::DrawHUD();
// dims only exist here in stock variable Canvas
// Update them so use in addWidget()
const FVector2D ViewportSize = FVector2D(GEngine->GameViewport->Viewport->GetSizeXY());
dims.X = ViewportSize.X;
dims.Y = ViewportSize.Y;
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 );
}
void AMyHUD::clearWidgets()
{
widgets.Empty();
}
同样,确保在.h
文件中添加以下内容:
void clearWidgets();
void addWidget(Widget widget);
- 我们继续使用
inventoryShowing
中的Boolean
变量,以告诉我们库存当前是否显示。当显示库存时,我们还显示鼠标,以便用户知道他点击的是什么。此外,当显示库存时,玩家的自由运动被禁用。禁用玩家的自由运动的最简单方法是在实际移动之前从移动函数中返回。以下代码是一个示例:
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; }
,这样当库存显示时,它将阻止所有移动。
检测库存项目点击
我们可以通过简单的测试来检测是否有人点击了我们的库存项目,以查看点是否在对象的rect
(矩形)内。通过检查点击点与包含要测试区域的rect
的内容,可以进行此测试。
要针对rect
进行检查,向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();
}
};
针对rect
的测试如下:
因此,如果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
中,我们必须附加我们的响应者:
PlayerInputComponent->BindAction( "MouseClickedLMB", IE_Pressed, this, &AAvatar::MouseClicked );
以下屏幕截图显示了如何设置绑定:
- 向
AMyHUD
类添加一个成员,以及两个新的函数定义:
Widget* heldWidget; // hold the last touched Widget in memory
void MouseClicked();
void MouseMoved();
- 接下来,在
AMyHUD::MouseClicked()
中,我们开始搜索命中的Widget
:
void AMyHUD::MouseClicked()
{
FVector2D mouse;
APlayerController* PController = GetWorld()->GetFirstPlayerController();
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;
APlayerController* PController = GetWorld()->GetFirstPlayerController();
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;
}
拖动函数查看鼠标位置在上一帧和本帧之间的差异,并移动所选小部件相应的距离。一个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
。
确保你在文件顶部添加了#include "MyHUD.h"
,这样才能正常工作。
练习
-
完成
AAvatar::Pitch()
函数(y 轴)以将输入路由到 HUD 而不是Avatar
。 -
从第八章中的 NPC 角色,角色和棋子中获取,并在玩家靠近它们时给予玩家一个物品(比如
GoldenEgg
)。
把事情放在一起
现在你有了所有这些代码,你会想把它们放在一起并看到它们运行。使用你复制过来的 Meshes 创建新的蓝图,方法是在类查看器中右键单击PickupItem
类并选择创建蓝图类,就像我们之前做的那样。设置值(包括 Mesh),然后将对象拖入游戏中。当你走进它们时,你会收到一个被拾取的消息。此时,你可以按I键查看你的库存。
总结
在本章中,我们介绍了如何为玩家设置多个拾取物品,以便在关卡中显示并拾取。我们还在屏幕上显示了它们,并添加了拖动小部件的功能。在第十一章中,怪物,我们将介绍怪物以及如何让它们跟随并攻击玩家。
第十一章:怪物
在本章中,我们将为玩家添加对手。我们将创建一个新的景观供其漫游,并且当怪物足够接近以侦测到它们时,它们将开始朝玩家走去。一旦它们进入玩家的射程范围,它们还将发动攻击,为您提供一些基本的游戏玩法。
让我们来看看本章涵盖的主题:
-
景观
-
创建怪物
-
怪物对玩家的攻击
景观
我们在本书中尚未涵盖如何雕刻景观,所以我们将在这里进行。首先,您必须有一个景观可供使用。要做到这一点,请按照以下步骤进行:
-
通过导航到文件|新建级别...开始一个新文件。您可以选择一个空的级别或一个带有天空的级别。在这个例子中,我选择了没有天空的那个。
-
要创建景观,我们必须从模式面板中工作。确保通过导航到窗口|模式显示模式面板:
- 景观可以通过三个步骤创建,如下面的屏幕截图所示:
三个步骤如下:
-
- 单击模式面板中的景观图标(山的图片)
-
单击管理按钮
-
单击屏幕右下角的创建按钮
-
现在您应该有一个景观可以使用。它将显示为主窗口中的灰色瓷砖区域:
您在景观场景中要做的第一件事是为其添加一些颜色。没有颜色的景观算什么?
- 在您的灰色瓷砖景观对象的任何位置单击。在右侧的详细信息面板中,您将看到它填充了信息,如下面的屏幕截图所示:
-
向下滚动,直到看到景观材料属性。您可以选择 M_Ground_Grass 材料,使地面看起来更逼真。
-
向场景添加光。您可能应该使用定向光,以便所有地面都有一些光线。我们在第八章中已经介绍了如何做到这一点,演员和棋子。
雕刻景观
一个平坦的景观可能会很无聊。我们至少应该在这个地方添加一些曲线和山丘。要这样做,请执行以下步骤:
- 单击模式面板中的雕刻按钮:
您的刷子的强度和大小由模式窗口中的刷子大小和工具强度参数确定。
-
单击您的景观并拖动鼠标以改变草皮的高度。
-
一旦您对您所拥有的内容感到满意,请单击播放按钮进行尝试。结果输出如下屏幕截图所示:
- 玩弄您的景观并创建一个场景。我所做的是将景观降低到一个平坦的地面平面周围,以便玩家有一个明确定义的平坦区域可以行走,如下面的屏幕截图所示:
随意处理您的景观。如果愿意,您可以将我在这里所做的作为灵感。
我建议您从 ContentExamples 或 StrategyGame 导入资产,以便在游戏中使用它们。要做到这一点,请参考第十章中的导入资产部分,库存系统和拾取物品。导入资产完成后,我们可以继续将怪物带入我们的世界。
创建怪物
我们将以与我们编程 NPC 和PickupItem
相同的方式开始编程怪物。我们将编写一个基类(通过派生自 character)来表示Monster
类,然后为每种怪物类型派生一堆蓝图。每个怪物都将有一些共同的属性,这些属性决定了它的行为。以下是共同的属性:
-
它将有一个用于速度的
float
变量。 -
它将有一个用于
HitPoints
值的float
变量(我通常使用浮点数来表示 HP,这样我们可以轻松地模拟 HP 流失效果,比如走过一片熔岩池)。 -
它将有一个用于击败怪物所获得的经验值的
int32
变量。 -
它将有一个用于怪物掉落的战利品的
UClass
函数。 -
它将有一个用于每次攻击造成的
BaseAttackDamage
的float
变量。 -
它将有一个用于
AttackTimeout
的float
变量,这是怪物在攻击之间休息的时间。 -
它将有两个
USphereComponents
对象:其中一个是SightSphere
——怪物能看到的距离。另一个是AttackRangeSphere
,这是它的攻击范围。AttackRangeSphere
对象始终小于SightSphere
。
按照以下步骤进行操作:
-
从
Character
类派生你的Monster
类。你可以在 UE4 中通过转到文件 | 新建 C++类...,然后从菜单中选择你的基类的 Character 选项来完成这个操作。 -
填写
Monster
类的基本属性。 -
确保声明
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = MonsterProperties)
,以便可以在蓝图中更改怪物的属性。这是你应该在Monster.h
中拥有的内容:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Components/SphereComponent.h"
#include "Monster.generated.h"
UCLASS()
class GOLDENEGG_API AMonster : public ACharacter
{
GENERATED_BODY()
public:
AMonster(const FObjectInitializer& ObjectInitializer);
// 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)
USph.ereComponent* SightSphere;
// Range for his attack. Visualizes as a sphere in editor,
UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =
Collision)
USphereComponent* AttackRangeSphere;
};
- 你需要在
Monster
构造函数中添加一些最基本的代码,以初始化怪物的属性。在Monster.cpp
文件中使用以下代码(这应该替换默认构造函数):
AMonster::AMonster(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
Speed = 20;
HitPoints = 20;
Experience = 0;
BPLoot = NULL;
BaseAttackDamage = 1;
AttackTimeout = 1.5f;
TimeSinceLastStrike = 0;
SightSphere = ObjectInitializer.CreateDefaultSubobject<USphereComponent>
(this, TEXT("SightSphere"));
SightSphere->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
AttackRangeSphere = ObjectInitializer.CreateDefaultSubobject
<USphereComponent>(this, TEXT("AttackRangeSphere"));
AttackRangeSphere->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
}
-
编译并运行代码。
-
打开虚幻编辑器,并基于你的
Monster
类派生一个蓝图(称之为BP_Monster
)。 -
现在,我们可以开始配置我们怪物的
Monster
属性。对于骨骼网格,我们不会使用相同的模型,因为我们需要怪物能够进行近战攻击,而相同的模型没有近战攻击。然而,Mixamo 动画包文件中的一些模型具有近战攻击动画。 -
因此,从 UE4 市场(免费)下载 Mixamo 动画包文件:
包中有一些相当恶心的模型,我会避免使用,但其他一些模型非常好。
- 你应该将 Mixamo 动画包文件添加到你的项目中。它已经有一段时间没有更新了,但你可以通过勾选显示所有项目并从下拉列表中选择 4.10 版本来添加它,如下面的截图所示:
- 编辑
BP_Monster
蓝图的类属性,并选择 Mixamo_Adam(实际上在包的当前版本中是 Maximo_Adam)作为骨骼网格。确保将其与胶囊组件对齐。同时,选择 MixamoAnimBP_Adam 作为动画蓝图:
我们将稍后修改动画蓝图,以正确地包含近战攻击动画。
在编辑BP_Monster
蓝图时,将SightSphere
和AttackRangeSphere
对象的大小更改为你认为合理的值。我让我的怪物的AttackRangeSphere
对象足够大,大约是手臂长度(60 个单位),他的SightSphere
对象是这个值的 25 倍大(大约 1500 个单位)。
记住,一旦玩家进入怪物的SightSphere
,怪物就会开始朝玩家移动,一旦玩家进入怪物的AttackRangeSphere
对象,怪物就会开始攻击玩家:
在游戏中放置一些BP_Monster
实例;编译并运行。没有任何驱动Monster
角色移动的代码,你的怪物应该只是闲置在那里。
基本怪物智能
在我们的游戏中,我们只会为Monster
角色添加基本智能。怪物将知道如何做两件基本的事情:
-
追踪玩家并跟随他
-
攻击玩家
怪物不会做其他事情。当玩家首次被发现时,你可以让怪物嘲讽玩家,但我们会把这留给你作为练习。
移动怪物-转向行为
非常基本的游戏中的怪物通常没有复杂的运动行为。通常,它们只是朝着目标走去并攻击它。我们将在这个游戏中编写这种类型的怪物,但你可以通过让怪物在地形上占据有利位置进行远程攻击等方式获得更有趣的游戏体验。我们不会在这里编写,但这是值得考虑的事情。
为了让“怪物”角色朝向玩家移动,我们需要在每一帧动态更新“怪物”角色移动的方向。为了更新怪物面对的方向,我们在Monster::Tick()
方法中编写代码。
Tick
函数在游戏的每一帧中运行。Tick 函数的签名如下:
virtual void Tick(float DeltaSeconds) override;
你需要在Monster.h
文件中的AMonster
类中添加这个函数的原型。如果我们重写了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);
}
你还需要在文件顶部添加以下包含:
#include "Avatar.h"
#include "Kismet/GameplayStatics.h"
为了使AddMovementInput
起作用,你必须在蓝图中的 AIController 类面板下选择一个控制器,如下图所示:
如果你选择了None
,对AddMovementInput
的调用将不会产生任何效果。为了防止这种情况发生,请选择AIController
类或PlayerController
类作为你的 AIController 类。确保你对地图上放置的每个怪物都进行了检查。
上面的代码非常简单。它包括了敌人智能的最基本形式-每一帧向玩家移动一小部分:
如果你的怪物面向玩家的反方向,请尝试在 Z 方向上将网格的旋转角度减少 90 度。
经过一系列帧后,怪物将跟踪并围绕关卡追随玩家。要理解这是如何工作的,你必须记住Tick
函数平均每秒调用约 60 次。这意味着在每一帧中,怪物都会离玩家更近一点。由于怪物以非常小的步伐移动,它的动作看起来平滑而连续(实际上,它在每一帧中都在做小跳跃):
跟踪的离散性-怪物在三个叠加帧上的运动
怪物每秒移动约 60 次的原因是硬件限制。典型显示器的刷新率为 60 赫兹,因此它作为每秒有用的更新次数的实际限制器。以高于刷新率的帧率进行更新是可能的,但对于游戏来说并不一定有用,因为在大多数硬件上,你每 1/60 秒只能看到一张新图片。一些高级的物理建模模拟几乎每秒进行 1,000 次更新,但可以说,你不需要那种分辨率的游戏,你应该将额外的 CPU 时间保留给玩家会喜欢的东西,比如更好的 AI 算法。一些新硬件宣称刷新率高达 120 赫兹(查找游戏显示器,但不要告诉你的父母我让你把所有的钱都花在上面)。
怪物运动的离散性
计算机游戏是离散的。在前面的截图中,玩家被视为沿着屏幕直线移动,以微小的步骤。怪物的运动也是小步骤。在每一帧中,怪物朝玩家迈出一个小的离散步骤。怪物在移动时遵循一条明显的曲线路径,直接朝向每一帧中玩家所在的位置。
将怪物移向玩家,按照以下步骤进行:
-
我们必须获取玩家的位置。由于玩家在全局函数
UGameplayStatics::GetPlayerPawn
中可访问,我们只需使用此函数检索指向玩家的指针。 -
我们找到了从
Monster
函数(GetActorLocation()
)指向玩家(avatar->GetActorLocation()
)的向量。 -
我们需要找到从怪物指向 avatar 的向量。为此,您必须从怪物的位置中减去 avatar 的位置,如下面的截图所示:
这是一个简单的数学规则,但往往容易出错。要获得正确的向量,始终要从目标(终点)向量中减去源(起点)向量。在我们的系统中,我们必须从Monster
向量中减去Avatar
向量。这是因为从系统中减去Monster
向量会将Monster
向量移动到原点,而Avatar
向量将位于Monster
向量的左下方:
确保尝试你的代码。此时,怪物将朝向你的玩家奔跑并围拢在他周围。通过上述代码的设置,它们不会攻击,只会跟随他,如下面的截图所示:
Monster SightSphere
目前,怪物并未注意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
角色添加了额外的智能。Monster
角色现在可以在玩家超出怪物的SightSphere
对象范围时停止追逐玩家。结果如下:
在这里要做的一个好事情是将距离比较封装到一个简单的内联函数中。我们可以在Monster
头文件中提供这两个内联成员函数,如下所示:
inline bool isInSightRange( float d )
{ return d < SightSphere->GetScaledSphereRadius(); }
inline bool isInAttackRange( float d )
{ return d < AttackRangeSphere->GetScaledSphereRadius(); }
这些函数在传递的参数d
在相关的球体内时返回值true
。
内联函数意味着该函数更像是一个宏而不是函数。宏被复制并粘贴到调用位置,而函数则由 C++跳转并在其位置执行。内联函数很好,因为它们能够提供良好的性能,同时保持代码易于阅读。它们是可重用的。
怪物对玩家的攻击
怪物可以进行几种不同类型的攻击。根据Monster
角色的类型,怪物的攻击可能是近战或远程攻击。
Monster
角色将在玩家进入其AttackRangeSphere
对象时攻击玩家。如果玩家超出怪物的AttackRangeSphere
对象的范围,但玩家在怪物的SightSphere
对象中,则怪物将向玩家靠近,直到玩家进入怪物的AttackRangeSphere
对象。
近战攻击
melee的词典定义是一群混乱的人。近战攻击是在近距离进行的攻击。想象一群zerglings与一群ultralisks激烈战斗(如果你是星际争霸玩家,你会知道 zerglings 和 ultralisks 都是近战单位)。近战攻击基本上是近距离的肉搏战。要进行近战攻击,您需要一个近战攻击动画,当怪物开始近战攻击时,它会打开。为此,您需要在 UE4 的动画编辑器中编辑动画蓝图。
Zak Parrish 的系列是学习在蓝图中编程动画的绝佳起点:www.youtube.com/watch?v=AqYmC2wn7Cg&list=PL6VDVOqa_mdNW6JEu9UAS_s40OCD_u6yp&index=8
。
现在,我们只会编写近战攻击,然后担心以后在蓝图中修改动画。
定义近战武器
我们将有三个部分来定义我们的近战武器。它们如下:
-
代表它的 C++代码
-
模型
-
连接代码和模型的 UE4 蓝图
用 C++编写近战武器
我们将定义一个新类AMeleeWeapon
(派生自AActor
),代表手持战斗武器(您现在可能已经猜到,A 会自动添加到您使用的名称中)。我将附加一些蓝图可编辑的属性到AMeleeWeapon
类,并且AMeleeWeapon
类将如下所示:
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Components/BoxComponent.h"
#include "MeleeWeapon.generated.h"
class AMonster;
UCLASS()
class GOLDENEGG_API AMeleeWeapon : public AActor
{
GENERATED_BODY()
public:
AMeleeWeapon(const FObjectInitializer& ObjectInitializer);
// 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(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
// You shouldn't need this unless you get a compiler error that it can't find this function.
virtual int Prox_Implementation(UPrimitiveComponent* OverlappedComponent, 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
文件包含以下代码:
#include "MeleeWeapon.h"
#include "Monster.h"
AMeleeWeapon::AMeleeWeapon(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
AttackDamage = 1;
Swinging = false;
WeaponHolder = NULL;
Mesh = ObjectInitializer.CreateDefaultSubobject<UStaticMeshComponent>(this,
TEXT("Mesh"));
RootComponent = Mesh;
ProxBox = ObjectInitializer.CreateDefaultSubobject<UBoxComponent>(this,
TEXT("ProxBox"));
ProxBox->OnComponentBeginOverlap.AddDynamic(this,
&AMeleeWeapon::Prox);
ProxBox->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
}
int AMeleeWeapon::Prox_Implementation(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
// don't hit non root components
if (OtherComp != OtherActor->GetRootComponent())
{
return -1;
}
// avoid hitting things while sword isn't swinging,
// avoid hitting yourself, and
// avoid hitting the same OtherActor twice
if (Swinging && OtherActor != (AActor *) WeaponHolder &&
!ThingsHit.Contains(OtherActor))
{
OtherActor->TakeDamage(AttackDamage + WeaponHolder->BaseAttackDamage, FDamageEvent(), NULL, this);
ThingsHit.Add(OtherActor);
}
return 0;
}
void AMeleeWeapon::Swing()
{
ThingsHit.Empty(); // empty the list
Swinging = true;
}
void AMeleeWeapon::Rest()
{
ThingsHit.Empty();
Swinging = false;
}
下载一把剑
要完成这个练习,我们需要一把剑放在模型的手中。我从Kaan Gülhan添加了一个名为Kilic的剑到项目中。以下是您可以获得免费模型的其他地方的列表:
秘诀
乍看之下,在TurboSquid.com上似乎没有免费模型。实际上,秘诀在于您必须在价格下选择免费:
我不得不稍微编辑 kilic 剑网格,以修复初始大小和旋转。您可以将任何Filmbox(FBX)格式的网格导入到您的游戏中。kilic 剑模型包含在本章的示例代码包中。要将您的剑导入 UE4 编辑器,请执行以下步骤:
-
右键单击要将模型添加到的任何文件夹
-
导航到新资产|导入到(路径)...
-
从弹出的文件资源管理器中,选择要导入的新资产。
-
如果 Models 文件夹不存在,您可以通过在左侧的树视图上右键单击并在内容浏览器选项卡的左侧窗格中选择新文件夹来创建一个。
我从桌面上选择了kilic.fbx
资产:
为近战武器创建蓝图
创建近战武器蓝图的步骤如下:
-
在 UE4 编辑器中,创建一个基于
AMeleeWeapon
的蓝图,名为BP_MeleeSword
。 -
配置
BP_MeleeSword
以使用 kilic 刀片模型(或您选择的任何刀片模型),如下截图所示:
ProxBox
类将确定武器是否击中了某物,因此我们将修改ProxBox
类,使其仅包围剑的刀片,如下截图所示:
- 在碰撞预设面板下,对于网格(而不是 BlockAll),选择 NoCollision 选项非常重要。如下截图所示:
- 如果选择 BlockAll,则游戏引擎将自动解决剑和角色之间的所有相互穿透,通过推开剑触碰到的物体。结果是,每当挥动剑时,您的角色将似乎飞起来。
插座
在 UE4 中,插座是一个骨骼网格上的插座,用于另一个Actor
。您可以在骨骼网格身上的任何地方放置插座。在正确放置插座后,您可以在 UE4 代码中将另一个Actor
连接到此插座。
例如,如果我们想要在怪物的手中放一把剑,我们只需在怪物的手上创建一个插座。我们可以通过在玩家的头上创建一个插座,将头盔连接到玩家身上。
在怪物的手中创建一个骨骼网格插座
要将插座连接到怪物的手上,我们必须编辑怪物正在使用的骨骼网格。由于我们使用了 Mixamo_Adam 骨骼网格用于怪物,我们必须打开并编辑此骨骼网格。为此,请执行以下步骤:
-
双击内容浏览器选项卡中的 Mixamo_Adam 骨骼网格(这将显示为 T 形),以打开骨骼网格编辑器。
-
如果在内容浏览器选项卡中看不到 Mixamo Adam,请确保已经从 Unreal Launcher 应用程序将 Mixamo 动画包文件导入到项目中:
-
单击屏幕右上角的 Skeleton。
-
在左侧面板的骨骼树中向下滚动,直到找到 RightHand 骨骼。
-
我们将在此骨骼上添加一个插座。右键单击 RightHand 骨骼,然后选择 Add Socket,如下截图所示:
- 您可以保留默认名称(RightHandSocket),或者根据需要重命名插座,如下截图所示:
接下来,我们需要将剑添加到角色的手中。
将剑连接到模型
连接剑的步骤如下:
-
打开 Adam 骨骼网格,找到树视图中的 RightHandSocket 选项。由于 Adam 用右手挥舞,所以应该将剑连接到他的右手上。
-
右键单击 RightHandSocket 选项,选择 Add Preview Asset,并在出现的窗口中找到剑的骨骼网格:
- 您应该在模型的图像中看到 Adam 握着剑,如下截图所示:
-
现在,点击 RightHandSocket 并放大 Adam 的手。我们需要调整预览中插座的位置,以便剑能正确放入其中。
-
使用移动和旋转操作器或手动更改详细窗口中的插座参数,使剑正确放入他的手中:
一个现实世界的提示
如果您有几个剑模型,想要在同一个RightHandSocket
中切换,您需要确保这些不同的剑之间有相当的一致性(没有异常)。
- 您可以通过转到屏幕右上角的动画选项卡来预览手中拿着剑的动画:
然而,如果您启动游戏,Adam 将不会拿着剑。这是因为在 Persona 中将剑添加到插槽仅用于预览目的。
给玩家装备剑的代码
要从代码中为玩家装备一把剑并将其永久绑定到角色,需要在怪物实例初始化后实例化一个AMeleeWeapon
实例,并将其附加到RightHandSocket
。我们在PostInitializeComponents()
中执行此操作,因为在这个函数中,Mesh
对象已经完全初始化。
在Monster.h
文件中,添加一个选择要使用的近战武器的Blueprint
类名称(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)
AMeleeWeapon* MeleeWeapon;
此外,请确保在文件顶部添加#include "MeleeWeapon.h"
。现在,在怪物的蓝图类中选择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 = GetMesh()->GetSocketByName(
FName("RightHandSocket")); // be sure to use correct
// socket name!
socket->AttachActor(MeleeWeapon, GetMesh());
MeleeWeapon->WeaponHolder = this;
}
}
}
此外,请确保在文件顶部添加#include "Engine/SkeletalMeshSocket.h"
。如果为怪物的蓝图选择了BPMeleeWeapon
,那么怪物现在将会从一开始就拿着剑:
触发攻击动画
默认情况下,我们的 C++ Monster
类与触发攻击动画之间没有连接;换句话说,MixamoAnimBP_Adam
类无法知道怪物何时处于攻击状态。
因此,我们需要更新 Adam 骨骼的动画蓝图(MixamoAnimBP_Adam
),以包括在Monster
类变量列表中查询并检查怪物是否处于攻击状态。我们在本书中之前没有使用过动画蓝图(或者一般的蓝图),但是按照这些说明一步一步来,你应该能够看到它的实现。
我会在这里温和地介绍蓝图术语,但我鼓励您去看一下 Zak Parrish 的教程系列,了解蓝图的初步介绍。
蓝图基础知识
UE4 蓝图是代码的视觉实现(不要与有时人们说 C++类是类实例的比喻蓝图混淆)。在 UE4 蓝图中,您不需要实际编写代码,而是将元素拖放到图表上并连接它们以实现所需的播放。通过将正确的节点连接到正确的元素,您可以在游戏中编写任何您想要的东西。
本书不鼓励使用蓝图,因为我们试图鼓励您编写自己的代码。然而,动画最好使用蓝图,因为这是艺术家和设计师所熟悉的。
让我们开始编写一个示例蓝图,以了解它们的工作原理:
- 单击顶部的蓝图菜单栏,选择“打开级别蓝图”,如下图所示:
级别蓝图选项在开始级别时会自动执行。打开此窗口后,您应该看到一个空白的画布,可以在上面创建游戏玩法,如下图所示:
-
在图纸上的任何位置右键单击。
-
开始键入
begin
,然后从下拉列表中选择“事件开始播放”选项。
确保选中上下文敏感复选框,如下图所示:
- 在单击“事件开始播放”选项后,屏幕上会出现一个红色框。右侧有一个白色引脚。这被称为执行引脚,如下所示:
关于动画蓝图,您需要了解的第一件事是白色引脚执行路径(白线)。如果您以前见过蓝图图表,您一定会注意到白线穿过图表,如下图所示:
白色引脚执行路径基本上相当于将代码排成一行并依次运行。白线确定了将执行哪些节点以及执行顺序。如果一个节点没有连接白色执行引脚,那么该节点将根本不会被执行。
-
将白色执行引脚拖出“事件开始播放”。
-
首先在“可执行操作”对话框中键入
draw debug box
。 -
选择弹出的第一项(fDraw Debug Box),如下图所示:
- 填写一些关于盒子外观的细节。在这里,我选择了蓝色的盒子,盒子的中心在(0, 0, 100),盒子的大小为(200, 200, 200),持续时间为 180 秒(请确保输入足够长的持续时间,以便您可以看到结果),如下图所示:
-
现在,单击“播放”按钮以实现图表。请记住,您必须找到世界原点才能看到调试框。
-
通过在(0, 0,(某个 z 值))放置一个金色蛋来找到世界原点,如下图所示,或者尝试增加线条粗细以使其更加可见:
这是在级别中盒子的样子:
修改 Mixamo Adam 的动画蓝图
要集成我们的攻击动画,我们必须修改蓝图。在内容浏览器中,打开MixamoAnimBP_Adam
。
你会注意到的第一件事是,图表在事件通知部分上方有两个部分:
-
顶部标有“基本角色移动...”。
-
底部显示“Mixamo 示例角色动画...”。
基本角色移动负责模型的行走和奔跑动作。我们将在负责攻击动画的 Mixamo 示例角色动画部分进行工作。我们将在图表的后半部分进行工作,如下图所示:
当您首次打开图表时,它会首先放大到靠近底部的部分。要向上滚动,右键单击鼠标并向上拖动。您还可以使用鼠标滚轮缩小,或者按住Alt键和右键同时向上移动鼠标来缩小。
在继续之前,您可能希望复制 MixamoAnimBP_Adam 资源,以防需要稍后返回并进行更改而损坏原始资源。这样可以让您轻松返回并纠正问题,如果发现您在修改中犯了错误,而无需重新安装整个动画包的新副本到您的项目中:
当从虚幻启动器向项目添加资产时,会复制原始资产,因此您现在可以在项目中修改 MixamoAnimBP_Adam,并在以后的新项目中获得原始资产的新副本。
我们要做的只是让 Adam 在攻击时挥动剑。让我们按照以下顺序进行:
- 删除说“正在攻击”的节点:
- 重新排列节点,如下所示,使 Enable Attack 节点单独位于底部:
- 我们将处理此动画正在播放的怪物。向上滚动一点图表,并拖动标有 Try Get Pawn Owner 对话框中的 Return Value 的蓝点。将其放入图表中,当弹出菜单出现时,选择 Cast to Monster(确保已选中上下文敏感,否则 Cast to Monster 选项将不会出现)。Try Get Pawn Owner 选项获取拥有动画的
Monster
实例,这只是AMonster
类对象,如下图所示:
- 单击 Sequence 对话框中的+,并从 Sequence 组将另一个执行引脚拖动到 Cast to Monster 节点实例,如下图所示。这确保了 Cast to Monster 实例实际上被执行:
- 下一步是从 Cast to Monster 节点的 As Monster 端口拉出引脚,并查找 Is in Attack Range 属性:
为了显示这一点,您需要回到Monster.h
并在 is in Attack Range 函数之前添加以下行,并编译项目(稍后将对此进行解释):
UFUNCTION(BlueprintCallable, Category = Collision)
- 应该自动从左侧 Cast to Monster 节点的白色执行引脚到右侧 Is in Attack Range 节点有一条线。接下来,从 As Monster 再拖出一条线,这次查找 Get Distance To:
- 您需要添加一个节点来获取玩家角色并将其发送到 Get Distance To 的 Other Actor 节点。只需右键单击任何位置,然后查找 Get Player Character:
- 将 Get Player Character 的返回值节点连接到 Other Actor,将 Get Distance To 的返回值连接到 Is In Attack Range 的 D:
- 将白色和红色引脚拖到 SET 节点上,如图所示:
前面蓝图的等效伪代码类似于以下内容:
if( Monster.isInAttackRangeOfPlayer() )
{
Monster.Animation = The Attack Animation;
}
测试您的动画。怪物应该只在玩家范围内挥动。如果不起作用并且您创建了副本,请确保将animBP
切换到副本。此外,默认动画是射击,而不是挥动剑。我们稍后会修复这个问题。
挥动剑的代码
我们希望在挥动剑时添加动画通知事件:
- 声明并向您的
Monster
类添加一个蓝图可调用的 C++函数:
// in Monster.h:
UFUNCTION( BlueprintCallable, Category = Collision )
void SwordSwung();
BlueprintCallable
语句意味着可以从蓝图中调用此函数。换句话说,SwordSwung()
将是一个我们可以从蓝图节点调用的 C++函数,如下所示:
// in Monster.cpp
void AMonster::SwordSwung()
{
if( MeleeWeapon )
{
MeleeWeapon->Swing();
}
}
-
双击 Content Browser 中的 Mixamo_Adam_Sword_Slash 动画(应该在 MixamoAnimPack/Mixamo_Adam/Anims/Mixamo_Adam_Sword_Slash 中)打开。
-
找到 Adam 开始挥动剑的地方。
-
右键单击 Notifies 栏上的那一点,然后在 Add Notify...下选择 New Notify,如下截图所示:
- 将通知命名为
SwordSwung
:
通知名称应出现在动画的时间轴上,如下所示:
-
保存动画,然后再次打开您的 MixamoAnimBP_Adam 版本。
-
在 SET 节点组下面,创建以下图表:
-
当您右键单击图表(打开上下文敏感)并开始输入
SwordSwung
时,将出现 AnimNotify_SwordSwung 节点。Monster 节点再次从 Try Get Pawn Owner 节点中输入,就像修改 Mixamo Adam 动画蓝图部分的第 2 步一样。 -
Sword Swung 是
AMonster
类中可调用的蓝图 C++函数(您需要编译项目才能显示)。 -
您还需要进入 MaximoAnimBP_Adam 的 AnimGraph 选项卡。
-
双击状态机以打开该图表。
-
双击攻击状态以打开。
-
选择左侧的 Play Mixamo_Adam Shooting。
-
射击是默认动画,但显然这不是我们想要发生的。因此,删除它,右键单击并查找 Play Mixamo_Adam_Sword_Slash。然后,从一个人的小图标拖动到最终动画姿势的结果:
如果现在开始游戏,您的怪物将在实际攻击时执行它们的攻击动画。如果您还在AAvatar
类中重写TakeDamage
以在剑的边界框与您接触时减少 HP,您将看到您的 HP 条减少一点(请回忆,HP 条是在第八章的最后添加的,Actors and Pawns,作为一个练习):
投射或远程攻击
远程攻击通常涉及某种抛射物。抛射物可以是子弹之类的东西,但也可以包括闪电魔法攻击或火球攻击之类的东西。要编写抛射物攻击,您应该生成一个新对象,并且只有在抛射物到达玩家时才对玩家造成伤害。
要在 UE4 中实现基本的子弹,我们应该派生一个新的对象类型。我从AActor
类派生了一个ABullet
类,如下所示:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Components/SphereComponent.h"
#include "Bullet.generated.h"
UCLASS()
class GOLDENEGG_API ABullet : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
ABullet(const FObjectInitializer& ObjectInitializer);
// 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(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
// You shouldn't need this unless you get a compiler error that it can't find this function.
virtual int Prox_Implementation(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult); };
ABullet
类中有一些重要的成员,如下所示:
-
一个
float
变量,用于表示子弹接触时造成的伤害 -
一个
Mesh
变量,用于表示子弹的主体 -
一个
ProxSphere
变量,用于检测子弹最终击中物体的情况 -
当
Prox
检测到靠近物体时运行的函数
ABullet
类的构造函数应该初始化Mesh
和ProxSphere
变量。在构造函数中,我们将RootComponent
设置为Mesh
变量,然后将ProxSphere
变量附加到Mesh
变量上。ProxSphere
变量将用于碰撞检查。应该关闭Mesh
变量的碰撞检查,如下所示:
ABullet::ABullet(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
Mesh = ObjectInitializer.CreateDefaultSubobject<UStaticMeshComponent>(this,
TEXT("Mesh"));
RootComponent = Mesh;
ProxSphere = ObjectInitializer.CreateDefaultSubobject<USphereComponent>(this,
TEXT("ProxSphere"));
ProxSphere->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
ProxSphere->OnComponentBeginOverlap.AddDynamic(this,
&ABullet::Prox);
Damage = 1;
}
我们在构造函数中将Damage
变量初始化为1
,但一旦我们从ABullet
类创建蓝图,可以在 UE4 编辑器中更改这个值。接下来,ABullet::Prox_Implementation()
函数应该在我们与其他角色的RootComponent
碰撞时对角色造成伤害。我们可以通过代码实现这一点:
int ABullet::Prox_Implementation(UPrimitiveComponent* OverlappedComponent, 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 -1;
}
OtherActor->TakeDamage(Damage, FDamageEvent(), NULL, this);
Destroy();
return 0;
}
子弹物理
要使子弹飞过关卡,您可以使用 UE4 的物理引擎。
创建一个基于ABullet
类的蓝图。我选择了 Shape_Sphere 作为网格,并将其缩小到更合适的大小。子弹的网格应启用碰撞物理,但子弹的包围球将用于计算伤害。
配置子弹的行为是有点棘手的,所以我们将在四个步骤中进行介绍,如下所示:
-
在组件选项卡中选择 Mesh(继承)。
ProxSphere
变量应该在 Mesh 下面。 -
在详细信息选项卡中,勾选模拟物理和模拟生成碰撞事件。
-
从碰撞预设下拉列表中选择自定义....
-
从碰撞启用下拉菜单中选择碰撞启用(查询和物理)。同时,勾选碰撞响应框,如图所示;对于大多数类型(WorldStatic、WorldDynamic 等),勾选 Block,但只对 Pawn 勾选 Overlap:
模拟物理复选框使ProxSphere
属性受到重力和对其施加的冲量力的影响。冲量是瞬时的力量推动,我们将用它来驱动子弹的射击。如果不勾选模拟生成碰撞事件复选框,那么球体将掉到地板上。阻止所有碰撞的作用是确保球体不能穿过任何物体。
如果现在直接从内容浏览器选项卡将几个BP_Bullet
对象拖放到世界中,它们将简单地掉到地板上。当它们在地板上时,你可以踢它们。下面的截图显示了地板上的球体对象:
然而,我们不希望子弹掉在地板上。我们希望它们被射出。因此,让我们把子弹放在Monster
类中。
将子弹添加到怪物类
让我们逐步来看一下如何做到这一点:
- 向
Monster
类添加一个接收蓝图实例引用的成员。这就是UClass
对象类型的用途。此外,添加一个蓝图可配置的float
属性来调整射出子弹的力量,如下所示:
// 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
实例。
可以使用以下代码获得完整的怪物Tick
和Attack
函数:
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 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(
toOpponent*BulletLaunchImpulse );
}
else
{
GEngine->AddOnScreenDebugMessage( 0, 5.f,
FColor::Yellow, "monster: no bullet actor could be spawned.
is the bullet overlapping something?" );
}
}
}
还要确保在文件顶部添加#include "Bullet.h"
。我们将实现近战攻击的代码保持不变。假设怪物没有持有近战武器,然后我们检查BPBullet
成员是否已设置。如果BPBullet
成员已设置,则意味着怪物将创建并发射BPBullet
蓝图类的实例。
特别注意以下行:
ABullet *bullet = GetWorld()->SpawnActor<ABullet>(BPBullet,
nozzle, RootComponent->GetComponentRotation() );
这就是我们向世界添加新角色的方式。SpawnActor()
函数将UCLASS
的一个实例放在您传入的spawnLoc
中,并具有一些初始方向。
在我们生成子弹之后,我们调用AddImpulse()
函数来使其ProxSphere
变量向前发射。
还要在 Bullet.h 中添加以下行:
AMonster *Firer;
玩家击退
为了给玩家添加击退效果,我在Avatar
类中添加了一个名为knockback
的成员变量。每当 avatar 受伤时就会发生击退:
FVector knockback; // in class AAvatar
为了弄清楚击中玩家时将其击退的方向,我们需要在AAvatar::TakeDamage
中添加一些代码。这将覆盖AActor
类中的版本,因此首先将其添加到 Avatar.h 中:
virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;
计算从攻击者到玩家的方向向量,并将该向量存储在knockback
变量中:
float AAvatar::TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser)
{
// add some knockback that gets applied over a few frames
knockback = GetActorLocation() - DamageCauser->GetActorLocation();
knockback.Normalize();
knockback *= DamageAmount * 500; // knockback proportional to damage
return AActor::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
}
在AAvatar::Tick
中,我们将击退应用到 avatar 的位置:
void AAvatar::Tick( float DeltaSeconds )
{
Super::Tick( DeltaSeconds );
// apply knockback vector
AddMovementInput( -1*knockback, 1.f );
// half the size of the knockback each frame
knockback *= 0.5f;
}
由于击退向量会随着每一帧而减小,所以随着时间的推移它会变得越来越弱,除非击退向量在受到另一次打击时得到更新。
为了使子弹起作用,您需要将 BPMelee Weapon 设置为 None。您还应该增加 AttackRangeSphere 的大小,并调整子弹发射冲量到一个有效的值。
摘要
在本章中,我们探讨了如何在屏幕上实例化怪物,让它们追逐玩家并攻击他。我们使用不同的球体来检测怪物是否在视线范围或攻击范围内,并添加了具有近战或射击攻击能力的能力,具体取决于怪物是否有近战武器。如果您想进一步实验,可以尝试更改射击动画,或者添加额外的球体,并使怪物在移动时继续射击,并在攻击范围内切换到近战。在下一章中,我们将通过研究先进的人工智能技术来进一步扩展怪物的能力。
第十二章:用先进的 AI 构建更聪明的怪物
到目前为止,我们所拥有的怪物并没有做很多事情。他们站在一个地方,直到他们能看到你,然后他们会朝你走去,根据你设置的情况,进行近战攻击或射击攻击。在一个真正的游戏中,你希望你的角色做的事情比这多得多,这样他们看起来更真实。这就是人工智能(AI)的作用。
AI 是一个庞大的主题,有整本书专门讨论它,但我们将介绍一些 UE4 支持的使 AI 编程更容易的方法,这样你就可以轻松地创建更真实的怪物。我们将快速概述以下主题:
-
导航 - 路径查找和 NavMesh
-
行为树
-
环境查询系统
-
群集
-
机器学习和神经网络
-
遗传算法
如果你对此感兴趣,并且想了解更多,那么有很多优秀的书籍可以供你深入了解 AI 的其他方面。
导航 - 路径查找和 NavMesh
目前,我们创建的怪物只能朝一个方向移动——直线朝着你的位置。但是如果有山、建筑、树木、河流或其他物体挡住了怪物的路怎么办?在许多情况下,直线是不可能的。目前,如果怪物撞到墙上,它就会停在那里,这并不是很现实。这就是路径查找的作用。
什么是路径查找?
路径查找是一种找到路径(通常是最短和/或最容易的路径)到达目的地的方法。将整个环境想象成一个网格,每个单元格中都有一个数字,表示导航的难度。因此,一个有墙挡住去路的单元格将具有非常高的值,而陡峭的路径可能比容易的路径具有更高的值。路径查找的目标是找到所有沿着该路径的单元格的总值最低的路径。
有不同的算法或方法来处理路径查找。最知名的是称为 A(发音为A 星*)的算法。
什么是 A*?
我们这里不会使用 A,但是如果你打算在未来进行 AI 编程,你至少应该对它有所了解,所以我会做一个简要的概述。A基本上搜索围绕角色的单元格,优先考虑成本最低的单元格。它计算到目前为止路径的成本(通过累加到该点的成本)加上一个启发式,即从该点到目标的成本的猜测。
有很多计算启发式的方法。它可以是直接到目标的距离(你可能会说,像乌鸦飞一样简单)。如果启发式实际上比实际成本要低,那么结果会更好,所以这样做效果很好。
一旦找到成本最低的单元格,然后再向前一步,查看周围的单元格。一直持续到达目标。如果你发现自己到达了以前去过的单元格,并且这种方式的总路径成本更低,你可以用更低成本的路径替换它。这有助于你获得更短的路径。一旦到达目标,你可以沿着路径向后走,你就会得到一条完整的通往目标的路径。
你可以在网上或人工智能书籍中找到更多关于 A*和其他路径查找算法的信息。如果你在更复杂的项目中进行这项工作,你需要了解它们,但对于这个,UE4 有一个更简单和更容易的方法:使用NavMesh
。
使用 NavMesh
NavMesh
是 UE4 中的一个对象,你可以将其放置在世界中,告诉它你希望角色能够导航的环境的哪些部分。要做到这一点,请执行以下步骤:
- 添加一些障碍。你可以添加立方体、圆柱体或其他任何你想要添加的东西来阻挡移动,就像这样:
- 一旦你按照自己的意愿设置了级别,在模式窗口中,转到体积,找到 Nav Mesh Bounds Volume,将其拖放到级别上,并缩放以覆盖你希望怪物能够导航的整个区域。
如果您现在尝试,您仍然会看到怪物走进墙壁然后停下来。这是因为我们需要改变移动的方式。我们将通过创建自己的AIController
类来实现这一点。
创建一个 AIController 类
让我们按步骤来做这个:
- 创建一个新的 C++类。在这种情况下,您需要勾选“显示所有类”复选框并搜索找到
AIController
:
- 将类命名为
MonsterAIController
。您的MonsterAIController.h
应该如下所示:
UCLASS()
class GOLDENEGG_API AMonsterAIController : public AAIController
{
GENERATED_BODY()
public:
//Start following the player
void StartFollowingPlayer();
};
MonsterAIController.cpp
应该实现以下函数:
void AMonsterAIController::StartFollowingPlayer()
{
AActor *player = Cast<AActor>(
UGameplayStatics::GetPlayerPawn(GetWorld(), 0));
FVector playerPos = player->GetActorLocation();
MoveToLocation(playerPos);
}
还要确保在文件顶部添加#include "Kismet/GameplayStatics.h"
。
- 返回
Monster.cpp
中的Tick()
函数。在else
子句中找到以下行:
AddMovementInput(toPlayer, Speed*DeltaSeconds);
删除这一行,用这个替换:
if (GetController() != nullptr)
{
Cast<AMonsterAIController>(GetController())-
>StartFollowingPlayer();
}
还在文件顶部添加#include "MonsterAIController.h"
,并进入BP_Monster
,将 Ai Controller 类更改为MonsterAIController
。现在怪物可以绕过墙壁找到你。如果它们不动,检查确保NavMesh
覆盖了该区域并且足够高以覆盖角色。
行为树
现在,控制怪物的所有逻辑都在Monster.cpp
的Tick()
函数中。但到目前为止,您所做的事情相当简单。在大型复杂的游戏中,怪物将有更多的行为。它们可以在一个区域巡逻,直到看到您,甚至与您交流,只有在对话不顺利时才会攻击。所有这些逻辑将变得过于复杂,无法将所有内容都放在一个函数中,甚至在AMonster
类中。
幸运的是,UE4 还有另一种管理复杂任务的方法,那就是行为树。行为树让您可以直观地设置一系列任务,以便更容易管理。由于我们在这里专注于 C++,我们将以这种方式创建任务本身,但总体树似乎更容易在蓝图中管理。
行为树主要由两种不同类型的节点控制:
-
选择器:选择器将从左到右运行其子节点,直到一个成功,然后返回树。将其视为一个“或”语句——一旦找到一个真实的参数,该“或”本身就是真的,所以它完成了。
-
序列:序列会从左到右依次遍历子节点,直到有一个失败为止。这更像是一个“和”语句,会一直执行直到出现假的情况,使整个语句变为假。
因此,如果您想运行多个步骤,您将使用序列,而如果您只想成功运行一个并停止,您将使用选择器。
设置行为树
首先,您需要进入您的库(将其放在一个有意义的文件夹名称中,这样您将记得在哪里找到它,或者蓝图也可以工作),然后从“添加新内容”中选择“人工智能|行为树”:
我将其命名为MonsterBT
。您还需要创建一个黑板。这将存储您将在行为树中使用的数据,并允许您在 AI Controller 和行为树之间轻松传输。您可以通过转到“添加新内容”,然后选择“人工智能|黑板”来创建它。我将其命名为MonsterBlackboard
:
设置黑板值
接下来,您需要在刚刚创建的黑板中设置值。您可以通过选择新键,然后选择类型(在这种情况下是 Bool)来完成此操作。对于此操作,我添加了两个,IsInAttackRange 和 IsInFollowRange:
您还可以为每个添加一个描述其用途的描述。
设置 BTTask
我们将创建一个 C++任务来处理跟随玩家。要做到这一点,执行以下步骤:
- 添加一个新的 C++类,并以 BTTaskNode 为基础(您需要查看所有类并搜索它):
我命名了新类BTTask_FollowPlayer
- 在
BTTaskFollowPlayer.h
中,添加以下内容:
UCLASS()
class GOLDENEGG_API UBTTask_FollowPlayer : public UBTTaskNode
{
GENERATED_BODY()
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void OnGameplayTaskActivated(UGameplayTask& Task) override {}
};
我们不会使用OnGameplayTaskActivated
,但是,如果没有声明它,你的代码可能无法编译(如果你收到关于它不存在的投诉,那就是原因)
- 在
BTTaskFollowPlayer.cpp
中,添加以下内容:
#include "BTTask_FollowPlayer.h"
#include "MonsterAIController.h"
EBTNodeResult::Type UBTTask_FollowPlayer::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AMonsterAIController* Controller = Cast<AMonsterAIController>(OwnerComp.GetAIOwner());
if (Controller == nullptr)
{
return EBTNodeResult::Failed;
}
Controller->StartFollowingPlayer();
return EBTNodeResult::Succeeded;
}
一旦你做到了这一点,你可以回去创建另一个BTTask
来处理攻击,以及你可能想要的任何其他行为。
设置行为树本身
一旦你设置好了任务,就该设置树本身了:
- 双击它以打开蓝图:
-
点击 Root 底部的黄色区域并拖动以创建一个新的节点(它是黑色的,但当鼠标滚动到它上面时会变成黄色)。
-
从弹出的菜单中选择类型(我们将使用选择器):
中心标签中的选择器图标
- 你应该有以下内容:
如前所述,选择器将按从左到右的顺序遍历节点,直到一个成功为止,然后停止。在这种情况下,我们有三种可能的状态:在攻击范围内,在视野范围内,以及两者都不满足(忽略玩家)。首先,你需要检查自己是否足够接近进行攻击,这意味着你需要在你的黑板中检查 IsInAttackRange。
不要先进行跟随,因为攻击范围在技术上仍然在跟随范围内,但你不想使用跟随功能,所以选择器在检查跟随范围后就会停止,因为这是它进行的第一个检查,所以它永远不会检查攻击范围(这才是它真正应该检查的)。
要检查它需要处于哪种状态,你需要检查黑板值,这可以通过使用装饰器来实现。为此,点击选择器的底部并向左拖动一个新的节点,就像你创建那个节点时所做的那样,并选择一个复合选择器节点。这个节点允许你右键单击;选择添加装饰器...,确保你选择了黑板类型。添加后,你可以选择顶部的蓝色装饰器。你应该能够检查 Key Query IsSet 并选择你想要检查的值,这种情况下是 IsInAttackRange(如果它没有显示出来,请确保 MonsterBlackboard 在详细信息中设置为黑板;通常情况下应该自动设置):
攻击节点最终会转到一个攻击任务,但现在,我只是放了一个等待作为占位符(一个内置任务,允许你指定等待时间(以秒为单位))。
在它的右侧,你还需要添加另一个复合节点,带有一个检查 IsInFollowRange 的装饰器。这将使用你创建的新任务(如果它没有显示出来,请确保你已经编译了你的代码,并且没有任何错误)。
在那之后,我在事件中添加了一个等待任务,以防两种情况都失败。完成后,你应该有类似这样的东西:
现在你可以回去修改你现有的代码来使用所有这些。
更新 MonsterAIController
现在你将为你的AIController
类添加更多功能来支持行为树:
- 你的新
MonsterAIController.h
应该是这样的:
UCLASS()
class GOLDENEGG_API AMonsterAIController : public AAIController
{
GENERATED_BODY()
public:
AMonsterAIController(const FObjectInitializer& ObjectInitializer);
virtual void Possess(class APawn* InPawn) override;
virtual void UnPossess() override;
UBehaviorTreeComponent* BehaviorTreeCmp;
UBlackboardComponent* BlackboardCmp;
//Start following the player
void StartFollowingPlayer();
void SetFollowRange(bool val);
void SetAttackRange(bool val);
};
还要确保在文件顶部添加#include "BehaviorTree/BehaviorTreeComponent.h"
。在这里,你重写了构造函数以及Possess
和UnPossess
类。SetFollowRange
和SetAttackRange
函数是新的,让你设置黑板值。
- 在
MonsterAIController.cpp
中添加以下函数:
AMonsterAIController::AMonsterAIController(const class FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
BehaviorTreeCmp = ObjectInitializer.CreateDefaultSubobject<UBehaviorTreeComponent>(this, TEXT("MonsterBT"));
BlackboardCmp = ObjectInitializer.CreateDefaultSubobject<UBlackboardComponent>(this, TEXT("MonsterBlackboard"));
}
void AMonsterAIController::Possess(class APawn* InPawn)
{
Super::Possess(InPawn);
AMonster* Monster = Cast<AMonster>(InPawn);
if (Monster)
{
if (Monster->BehaviorTree->BlackboardAsset)
{
BlackboardCmp->InitializeBlackboard(*Monster->BehaviorTree->BlackboardAsset);
}
BehaviorTreeCmp->StartTree(*Monster->BehaviorTree);
}
}
void AMonsterAIController::UnPossess()
{
Super::UnPossess();
BehaviorTreeCmp->StopTree();
}
void AMonsterAIController::SetFollowRange(bool val)
{
BlackboardCmp->SetValueAsBool("IsInFollowRange", val);
}
void AMonsterAIController::SetAttackRange(bool val)
{
BlackboardCmp->SetValueAsBool("IsInAttackRange", val);
}
还要在文件顶部添加以下行:
#include "Monster.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardComponent.h"
StartFollowingPlayer
保持不变,所以这里不列出来,但确保你留下它!现在是时候更新你的Monster
类了(在这之前你无法编译)。
更新 Monster 类
我们将在Monster
类中进行以下更新:
- 在
Monster.h
中,您唯一要做的更改是添加以下代码行:
UPROPERTY(EditDefaultsOnly, Category = "AI")
class UBehaviorTree* BehaviorTree;
- 在
Monster.cpp
中,您将对Tick()
函数进行一些重大更改,因此这是完整版本:
// Called every frame
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();
AMonsterAIController* controller = Cast<AMonsterAIController>(GetController());
// 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
if (controller != nullptr)
{
controller->SetAttackRange(false);
controller->SetFollowRange(false);
}
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))
{
if (controller != nullptr)
{
controller->SetAttackRange(true);
}
// 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);
if (controller != nullptr)
{
controller->SetAttackRange(false);
controller->SetFollowRange(true);
}
}
}
更改是设置攻击和跟随范围的值。攻击代码仍然存在,但是如果将 TimeSinceLastStrike 和 AttackTimeout 移入黑板,您可以使用它将所有功能移入BTTask
。现在确保一切都编译完成。
- 一旦编译完成,您需要打开
BP_Monster
蓝图,并设置行为树如下(如果您希望它们不同,也可以在单个怪物上设置):
还要确保 AI 控制器设置为 MonsterAIController。如果此时运行游戏,功能应该是相同的,但是行为树将控制玩家的跟随。
如果您想了解更多,请查看将Attack
代码移入BTTask
类,并查看在您不在范围内时怪物可以做什么(阅读下一节可能有所帮助)。
环境查询系统
环境查询系统(EQS)是新的,仍在试验阶段。它允许您在行为树中创建一个查询,以搜索级别中的项目,并找到最符合您设置的条件的项目。也许您希望怪物在玩家超出范围时在设置的路径点之间徘徊,而不是站在原地。您可以设置一个查询来寻找最接近的路径点,或使用其他一些条件。EQS 允许您这样做。
您需要在设置中启用此功能才能使用它们。要执行此操作,请执行以下步骤:
- 进入编辑|编辑器首选项:
- 在实验|AI 下,勾选环境查询系统:
- 通过转到添加新|人工智能来添加新查询。环境查询现在将出现在行为树和黑板下:
您还需要在蓝图中创建上下文
和生成器
(生成器
将获取特定类型的所有项目,例如路径点)。要实际运行查询,您需要在行为树中创建一个运行 EQS 查询任务节点。有关环境查询系统的工作原理的更多信息,请参阅docs.unrealengine.com/en-us/Engine/AI/EnvironmentQuerySystem
中的虚幻文档。
集群
如果屏幕上有很多怪物同时移动,您希望它们以看起来真实的方式移动。您不希望它们互相撞到,或者朝不同的方向走开。
AI 研究人员已经研究过这个问题,并提出了处理这个问题的算法。它们被称为集群算法,因为它们基于鸟群的行为。
在一起移动时,怪物不仅要考虑到达相同目标,还要考虑与其一起移动的怪物。他们必须确保不要离其周围的怪物太近,也不应该移动得太远,否则它们会分散开来。
在许多情况下,有一个怪物被选为领导者。该怪物朝着目标前进,其他怪物专注于跟随该领导者。
在线上有很多关于集群的好参考资料。它没有内置到 UE4 中,但您可以购买扩展或编写自己的集群系统。
机器学习和神经网络简介
机器学习和神经网络是一个巨大的话题,所以我在这里只会做一个简要介绍。机器学习是如何教导程序去找出如何回应某事情的方法,而不仅仅是给它规则。有许多不同的算法可以做到这一点,但它们都需要大量的样本数据。
基本上,你给学习程序大量的例子(越多越好),和每个案例的最佳结果。你可以用不同的方式对它们进行评价。通过观察这么多案例,它可以根据它过去看到的结果对类似案例做出最佳猜测。通过足够的训练数据,结果可以非常好,尽管你仍然可能遇到它不适用的情况。
由于这需要如此多的数据(更不用说处理能力),除了在罕见的情况下,这是在游戏公司在游戏发售前完成的(如果有的话——这种事情往往会因为截止日期而被取消)。训练是离线完成的,程序已经学会了该做什么。
神经网络是一种特定类型的机器学习,旨在模拟大脑处理数据的方式。有工作像神经元的节点。可以有多层节点,每一层处理前一层的结果。
数据被发送到多个节点,每个节点根据一定的阈值调整数据。只有数据可以被传递回(或向前)到节点,然后调整这些阈值以获得更准确的训练数据结果。一旦它们被训练过,这些阈值就可以用于未来的决策。
虽然我们离真正的人工智能还有很长的路要走,但神经网络已经被用于产生有趣的结果。神经网络已经在特定流派的音乐上进行了训练,然后生成了非常令人印象深刻(和原创的)音乐,听起来类似于它接受训练的流派。我也听说过神经网络被编写来尝试写书。不过我认为我们离一个可以编写 UE4 程序的神经网络还有很长的路要走!
遗传算法
回想一下你高中学的生物学;你可能学过遗传学。来自两个不同父母的染色体结合在一起,创造一个结合了两个父母 DNA 的孩子,而随机的基因突变也可以引起变化。遗传算法基于相同的原则。
就像达尔文的适者生存一样,你可以在代码中做类似的事情。遗传算法有三个基本原则:
-
选择: 你选择那些有最佳结果的例子,它们是下一代的基础。
-
交叉: 选择的两个例子然后结合在一起,创造一个同时具有两者特点的孩子,就像在生物学中一样。
-
引入随机基因突变: 可能有一些好的特征是旧的没有的,或者被其他特征淹没了而被抛弃。这意味着你不会错过一些潜在的优秀特征,只是因为它们不在原始种群中。
总结
正如你所看到的,人工智能是一个巨大的话题,我们在这里只是触及了基础知识。我们已经了解了基础的寻路(使用 NavMesh)、行为树、环境查询系统、群集、机器学习和神经网络以及遗传算法。如果你想了解更多,还有整整一本书,以及许多网站,比如aigamedev.com/
,和www.gamasutra.com
上的文章。
在下一节中,我们将学习施展咒语来保护你的玩家免受怪物的侵害。
第十三章:法术书
玩家目前还没有自卫手段。我们现在将为玩家配备一种非常有用和有趣的方式,称为魔法法术。玩家将使用魔法法术来影响附近的怪物,因此现在可以对它们造成伤害。
我们将从描述如何创建自己的粒子系统开始本章。然后,我们将把粒子发射器包装到一个Spell
类中,并为角色编写一个CastSpell
函数,以便实际CastSpells
。
本章将涵盖以下主题:
-
什么是法术?
-
粒子系统
-
法术类角色
-
将右键单击附加到
CastSpell
-
创建其他法术
什么是法术?
实际上,法术将是粒子系统与由边界体积表示的影响区域的组合。每一帧都会检查边界体积中包含的角色。当一个角色在法术的边界体积内时,那么该角色就会受到该法术的影响。
以下是暴风雪法术的截图,其中突出显示了边界体积:
暴风雪法术有一个长方形的边界体积。在每一帧中,都会检查边界体积中包含的角色。法术边界体积中包含的任何角色在该帧中都将受到该法术的影响。如果角色移出法术的边界体积,那么该角色将不再受到该法术的影响。请记住,法术的粒子系统仅用于可视化;粒子本身不会影响游戏角色。
我们在第八章中创建的PickupItem
类,角色和卫兵,可用于允许玩家拾取代表法术的物品。我们将扩展PickupItem
类,并附加一个法术的蓝图以施放每个PickupItem
。从 HUD 中点击法术的小部件将施放它。界面将看起来像这样:
设置粒子系统
首先,我们需要一个放置所有华丽特效的地方。为此,我们将按照以下步骤进行:
-
在您的内容浏览器选项卡中,右键单击内容根目录,创建一个名为
ParticleSystems
的新文件夹。 -
右键单击该新文件夹,然后选择 New Asset | Particle System,如下截图所示:
查看此虚幻引擎 4 粒子系统指南,了解虚幻粒子发射器的工作原理:www.youtube.com/watch?v=OXK2Xbd7D9w&index=1&list=PLZlv_N0_O1gYDLyB3LVfjYIcbBe8NqR8t
。
- 双击出现的 NewParticleSystem 图标,如下截图所示:
完成上述步骤后,您将进入 Cascade,粒子编辑器。环境如下截图所示:
这里有几个不同的窗格,每个窗格显示不同的信息。它们如下:
-
左上角是视口窗格。这显示了当前发射器的动画,因为它当前正在工作。
-
右侧是
Emitters
面板。在其中,您可以看到一个名为 Particle Emitter 的单个对象(您的粒子系统中可以有多个发射器,但我们现在不需要)。粒子发射器的模块列表显示在其下。从前面的截图中,我们有Required
、Spawn
、Lifetime
、Initial Size
、Initial Velocity
和Color Over Life
模块。
更改粒子属性
默认粒子发射器会发射类似十字准星的形状。我们想要将其更改为更有趣的东西。要做到这一点,请按照以下步骤进行:
- 单击
Emitters
面板下的黄色Required
框,然后在Details
面板中打开Material
下拉菜单。
将弹出所有可用的粒子材料列表(您可以在顶部输入particles
以便更容易找到您想要的材料)。
- 选择 m_flare_01 选项来创建我们的第一个粒子系统,如下截图所示:
- 现在,让我们更改粒子系统的行为。单击发射器窗格下的 Color Over Life 条目。底部的详细信息窗格显示了不同参数的信息,如下截图所示:
- 在 Color Over Life 条目的详细信息窗格中,我增加了 R,但没有增加 G 和 B。这给了粒子系统一种红色的发光效果。(R 是红色,G 是绿色,B 是蓝色)。您可以在条上看到颜色。
然而,您可以通过更直观地更改粒子颜色来编辑原始数字。如果您点击发射器下的 Color Over Life 条目旁边的绿色锯齿按钮,您将看到 Color Over Life 的图表显示在曲线编辑器选项卡中,如下截图所示:
现在我们可以更改颜色随生命周期变化的参数。在曲线编辑器选项卡中的图表显示了发射的颜色与粒子存活时间的关系。您可以通过拖动点来调整数值。按住Ctrl键+鼠标左键可以在线条上添加新的点(如果不起作用,请点击黄色框取消选择 AlphaOverLife,确保只选择 ColorOverLife):
您可以调整粒子发射器设置,创建自己的法术可视化效果。
暴风雪法术的设置
此时,我们应该将粒子系统从 NewParticleSystem 重命名为更具描述性的名称。让我们将其重命名为P_Blizzard
。
您可以通过单击粒子系统并按下*F2 来重命名您的粒子系统,如下所示:
我们将调整一些设置,以获得暴风雪粒子效果法术。执行以下步骤:
-
返回 P_Blizzard 粒子系统进行编辑。
-
在 Spawn 模块下,将生成速率更改为
200.0
。这会增加可视化效果的密度,如下所示:
- 在 Lifetime 模块下,将 Max 属性从
1.0
增加到2.0
,如下截图所示。这会使发射的粒子的存活时间产生一些变化,一些发射的粒子的存活时间会比其他的长:
- 在 Initial Size 模块下,将 Min 属性大小更改为
12.5
,如下截图所示:
- 在 Initial Velocity 模块下,将 Min / Max 值更改为以下数值:
-
我们之所以让暴风雪向+X 方向吹,是因为玩家的前进方向从+X 开始。由于法术将来自玩家的手,我们希望法术指向与玩家相同的方向。
-
在 Color Over Life 菜单下,将蓝色(B)值更改为
100.0
。同时将 R 更改回1.0
。您会立即看到蓝色发光的变化:
现在它开始看起来有点神奇了!
- 右键单击 Color Over Life 模块下方的黑色区域。选择 Location | Initial Location,如截图所示:
- 按照以下截图所示,在 Start Location | Distribution 下输入数值:
- 您应该看到一个如此的暴风雪:
- 将相机移动到你喜欢的位置,然后点击顶部菜单栏中的缩略图选项。这将在内容浏览器选项卡中为你的粒子系统生成一个缩略图图标,如下截图所示:
法术类角色
Spell
类最终会对所有怪物造成伤害。为此,我们需要在Spell
类角色中包含粒子系统和边界框。当角色施放Spell
类时,Spell
对象将被实例化到关卡中并开始Tick()
功能。在Spell
对象的每个Tick()
上,任何包含在法术边界体积内的怪物都将受到影响。
Spell
类应该看起来像以下代码:
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Components/BoxComponent.h"
#include "Runtime/Engine/Classes/Particles/ParticleSystemComponent.h"
#include "Spell.generated.h"
UCLASS()
class GOLDENEGG_API ASpell : public AActor
{
GENERATED_BODY()
public:
ASpell(const FObjectInitializer& ObjectInitializer);
// box defining volume of damage
UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =
Spell)
UBoxComponent* ProxBox;
// the particle visualization of the spell
UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category =
Spell)
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
文件。在Spell.h
的包含行下面,添加一行包括Monster.h
文件的代码,这样我们就可以在Spell.cpp
文件中访问Monster
对象的定义(以及其他一些包括),如下代码所示:
#include "Monster.h"
#include "Kismet/GameplayStatics.h"
#include "Components/CapsuleComponent.h"
首先是构造函数,它设置了法术并初始化了所有组件,如下代码所示:
ASpell::ASpell(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
ProxBox = ObjectInitializer.CreateDefaultSubobject<UBoxComponent>(this,
TEXT("ProxBox"));
Particles = ObjectInitializer.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->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
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;
RootComponent->AttachToComponent(caster->GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
}
最后,我们有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(Cast<UPrimitiveComponent>(monster->GetCapsuleComponent())))
{
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
(它们可能有ProxBox
或ProxSphere
)。 -
它增加了法术存在的时间。如果法术超过了分配的施法时间,它将从关卡中移除。
现在,让我们专注于玩家如何获得法术,通过为玩家可以拾取的每个法术对象创建一个单独的PickupItem
。
蓝图化我们的法术
编译并运行刚刚添加的Spell
类的 C++项目。我们需要为我们想要施放的每个法术创建蓝图。要做到这一点,请按照以下步骤进行:
-
在 Class Viewer 选项卡中,开始输入
Spell
,你应该看到你的 Spell 类出现 -
右键单击 Spell,创建一个名为 BP_Spell_Blizzard 的蓝图,如下截图所示:
-
如果它没有自动打开,请双击打开它。
-
在法术的属性中,选择 P_Blizzard 法术作为粒子发射器,如下截图所示:
如果找不到它,请尝试在组件下选择 Particles (Inherited)。
选择 BP_SpellBlizzard(self),向下滚动直到到达法术类别,并更新每秒伤害和持续时间参数为你喜欢的值,如下截图所示。在这里,暴风雪法术将持续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;
一旦你为APickupItem
类添加了UClass* Spell
属性,重新编译并重新运行你的 C++项目。现在,你可以继续为你的Spell
对象制作PickupItem
实例的蓝图。
创建施放法术的 PickupItems 的蓝图
创建一个名为 BP_Pickup_Spell_Blizzard 的 PickupItem 蓝图,如下截图所示:
它应该自动打开,这样你就可以编辑它的属性。我将暴风雪物品的捡起属性设置如下:
物品的名称是暴风雪法术,每个包装中有5
个。我拍摄了暴风雪粒子系统的截图,并将其导入到项目中,因此图标被选为该图像。在法术下,我选择了 BP_Spell_Blizzard 作为要施放的法术的名称(而不是 BP_Pickup_Spell_Blizzard),如下截图所示:
我为PickupItem
类的Mesh
类选择了一个蓝色的球(你也可以使用 M_Water_Lake 材质来获得有趣的效果)。对于图标,我在粒子查看器预览中拍摄了暴风雪法术的截图,保存到磁盘,并将该图像导入到项目中,如下截图所示(在示例项目的内容浏览器选项卡中查看images
文件夹):
在你的关卡中放置一些PickupItem
。如果我们捡起它们,我们的库存中将有一些暴风雪法术(如果你捡不起来,请确保你的 ProxSphere 足够大):
现在,我们需要激活暴风雪。由于我们已经在第十章中将左键单击附加到拖动图标的库存系统和捡起物品,让我们将右键单击附加到施放法术。
将右键单击附加到 CastSpell
在调用角色的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()
向 avatar 发出施放该法术的请求。
编写 avatar 的 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() ); }
}
还要确保将该函数添加到Avatar.h
中,并在该文件的顶部添加#include "Spell.h"
。
您可能会发现实际施放法术非常简单。施放法术有两个基本步骤:
-
使用世界对象的
SpawnActor
函数实例化法术对象 -
将其附加到 avatar
一旦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;
//...
};
现在回想一下,我们的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];
hud->addWidget(w);
将以下函数添加到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].bpSpell)
avatar->CastSpell(widgets[c].bpSpell);
}
}
}
这与我们的左键单击功能非常相似。我们只需检查点击位置是否与所有小部件相交。如果任何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():
PlayerInputComponent->BindAction("MouseClickedRMB", IE_Pressed, this,
&AAvatar::MouseRightClicked);
我们终于连接了施法。试一试;游戏玩起来非常酷,如下面的屏幕截图所示:
创建其他法术
通过玩弄粒子系统,您可以创建各种不同的法术,产生不同的效果。您可以创建火焰、闪电或将敌人推开的法术。在玩其他游戏时,您可能已经遇到了许多其他可能的法术。
火焰法术
通过将粒子系统的颜色更改为红色,您可以轻松创建我们暴风雪法术的火焰变体。这是我们暴风雪法术的火焰变体的外观:
颜色的输出值更改为红色
练习
尝试以下练习:
-
闪电法术:使用光束粒子创建闪电法术。按照 Zak 的教程示例,了解如何创建光束并朝一个方向发射,网址为
www.youtube.com/watch?v=ywd3lFOuMV8&list=PLZlv_N0_O1gYDLyB3LVfjYIcbBe8NqR8t&index=7
。 -
力场法术:力场将使攻击偏转。对于任何玩家来说都是必不可少的。建议实现:派生
ASpell
的子类称为ASpellForceField
。向该类添加一个边界球,并在ASpellForceField::Tick()
函数中使用它将怪物推出。
摘要
现在您知道如何在游戏中创建防御法术。我们使用粒子系统创建了可见的法术效果,并且可以用来对任何在其中的敌人造成伤害的区域。您可以扩展所学知识以创建更多内容。
在下一章中,我们将探讨一种更新且更容易的构建用户界面的方法。
第十四章:通过 UMG 和音频改进 UI 反馈
在游戏中,用户反馈非常重要,因为用户需要了解游戏中发生的情况(得分、生命值、显示库存等)。在以前的章节中,我们创建了一个非常简单的 HUD 来显示文本和库存中的物品,但是如果您想要一个看起来专业的游戏,您将需要一个比那更好得多的用户界面(UI)!
幸运的是,现在有更简单的方法来使用虚幻动态图形 UI 设计师(UMG)构建 UI,这是 UE4 附带的系统,专门用于此目的。本章将向您展示如何使用它来接管我们之前所做的工作,并制作看起来更好并具有更多功能的东西。我们将开始更新库存窗口,并我将提出您可以继续该过程并更新其余 UI 的建议。
通过音频提供反馈的另一种方法是,无论是在游戏本身还是通过 UI 与其交互时,我们还将介绍如何播放声音。
我们将要涵盖的主题如下:
-
UMG 是什么?
-
更新库存窗口
-
布局您的 UI
-
更新您的 HUD 并添加生命条
-
播放音频
UMG 是什么?
您可能已经注意到,我们用来在屏幕上绘制的代码非常复杂。每个元素都需要手动放置在屏幕上。您可能会问自己是否有更简单的方法。有!那就是虚幻动态图形 UI 设计师,或者 UMG。
UMG 通过使用特殊蓝图简化了创建 UI 的过程,允许您以可视方式布局界面。这也可以让您让精通技术的艺术家为您设计布局,而您则将一切连接起来。我们将使用这个,但由于这是一本 C++书,我们将在 C++中处理大部分幕后功能。
要使用 UMG,首先需要在 Visual Studio 项目中找到GoldenEgg.Build.cs
文件。.cs
文件通常是 C#,而不是 C++,但您不必担心,因为我们只会对此文件进行轻微更改。找到这一行:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });
并将以下内容添加到列表中:
, "UMG", "Slate", "SlateCore"
您可能需要在这样做后重新启动引擎。然后您将准备好在 UMG 中编码!
更新库存窗口
我们将从更新库存窗口开始。我们现在拥有的不是一个真正的窗口,只是在屏幕上绘制的图像和文本,但现在您将看到如何轻松创建看起来更像真正窗口的东西——带有背景和关闭按钮,代码将更简单。
WidgetBase 类
要为 UMG 小部件创建 C++类,您需要基于UserWidget
创建一个新类。在添加新的 C++类时,需要检查显示所有类并搜索它以找到它:
将您的类命名为WidgetBase
。这将是您派生任何其他小部件类的基类。这使您可以在此类中放置将在许多不同小部件中重复使用的功能。在这种情况下,我在那里放置了CloseButton
的功能。并非所有小部件都需要一个,但是如果您想要一个标准窗口,通常是一个好主意。
这是WidgetBase.h
的代码:
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "UMG/Public/Components/Button.h"
#include "WidgetBase.generated.h"
/**
* WidgetBase.h
*/
UCLASS()
class GOLDENEGG_API UWidgetBase : public UUserWidget
{
GENERATED_BODY()
public:
UPROPERTY(meta = (BindWidgetOptional))
UButton* CloseButton;
bool isOpen;
bool Initialize();
void NativeConstruct();
UFUNCTION(BlueprintCallable)
void CloseWindow();
};
这将设置允许您使用按钮关闭窗口的所有代码。CloseButton
将是我们在设计蓝图中创建的按钮的名称。
行UPROPERTY(meta = (BindWidgetOptional))
应自动将CloseWindow
变量链接到稍后将在蓝图中创建的具有相同名称的Button
对象。如果您知道小部件将始终存在,则可以改用UPROPERTY(meta = (BindWidget))
,但在这种情况下,可能存在不需要关闭窗口的按钮的情况。
这里是WidgetBase.cpp
:
#include "WidgetBase.h"
#include "Avatar.h"
#include "Kismet/GameplayStatics.h"
bool UWidgetBase::Initialize()
{
bool success = Super::Initialize();
if (!success) return false;
if (CloseButton != NULL)
{
CloseButton->OnClicked.AddDynamic(this, &UWidgetBase::CloseWindow);
}
return true;
}
void UWidgetBase::NativeConstruct()
{
isOpen = true;
}
void UWidgetBase::CloseWindow()
{
if (isOpen)
{
AAvatar *avatar = Cast<AAvatar>(
UGameplayStatics::GetPlayerPawn(GetWorld(), 0));
avatar->ToggleInventory();
isOpen = false;
}
}
如果本章中包含的 UMG 对您不起作用,您可能需要在路径前面添加Runtime/
。但它们应该像这样工作(并且在我的项目中确实工作)。
以下行是将OnClicked
事件设置为调用特定函数的内容:
CloseButton->OnClicked.AddDynamic(this, &UWidgetBase::CloseWindow);
我们不再需要像以前那样在输入设置中设置所有内容,因为 UMG 按钮已经设置好处理OnClicked
,您只需要告诉它要调用哪个函数。如果由于某种原因它不起作用,我将向您展示如何通过稍后在蓝图中设置OnClicked
来解决问题。由于CloseButton
是可选的,您确实需要检查它以确保它未设置为NULL
以避免错误。
isOpen
变量用于处理常见的 UI 问题,有时点击(或按键)会注册多次,导致函数被调用多次,这可能会导致错误。通过将isOpen
设置为 true,第一次调用OnClicked
函数时,您确保它不会运行多次,因为它只会在值为 false 时运行。当然,您还需要确保在重新打开窗口时重置该值,这就是NativeConstruct()
函数的作用。
库存小部件类
现在,您将要创建一个专门处理库存小部件的类,该类派生自WidgetBase
。如果由于某种原因找不到WidgetBase
以通常的方式创建类,则在过滤器下取消选中仅限于演员。将其命名为InventoryWidget
。
创建了该类后,您可以开始添加代码。首先,这是InventoryWidget.h
:
#include "CoreMinimal.h"
#include "WidgetBase.h"
#include "UMG/Public/Components/Image.h"
#include "UMG/Public/Components/TextBlock.h"
#include "UMG/Public/Components/Button.h"
#include "InventoryWidget.generated.h"
/**
*
*/
UCLASS()
class GOLDENEGG_API UInventoryWidget : public UWidgetBase
{
GENERATED_BODY()
public:
const int kNumWidgets = 2;
//image widgets
UPROPERTY(meta = (BindWidget))
UImage* InventoryImage1;
UPROPERTY(meta = (BindWidget))
UImage* InventoryImage2;
//text widgets
UPROPERTY(meta = (BindWidget))
UTextBlock* InventoryText1;
UPROPERTY(meta = (BindWidget))
UTextBlock* InventoryText2;
//Invisible Buttons
UPROPERTY(meta = (BindWidget))
UButton* InventoryButton1;
UPROPERTY(meta = (BindWidget))
UButton* InventoryButton2;
bool Initialize();
void HideWidgets();
void AddWidget(int idx, FString name, UTexture2D* img);
UFUNCTION(BlueprintCallable)
void MouseClicked1();
UFUNCTION(BlueprintCallable)
void MouseClicked2();
};
这个文件要复杂得多。我们再次使用BindWidget
来在蓝图中设置对象。虽然您可以像以前一样在代码中布置小部件(但您应该能够创建包括图像、文本和按钮的子小部件),但为了保持简单,我只在屏幕上布置了两个小部件,并分别引用它们。您随时可以自己添加更多以供练习。
因此,在这种特殊情况下,我们为两个图像、两个文本块和两个按钮设置了小部件。有一个Initialize
函数来设置它们,以及用于添加小部件、隐藏所有小部件以及每个按钮的鼠标点击处理程序的函数。
然后,我们需要编写InventoryWidget.cpp
。首先,在文件顶部添加包含:
#include "InventoryWidget.h"
#include "MyHUD.h"
#include "Runtime/UMG/Public/Components/SlateWrapperTypes.h"
然后设置Initialize
函数:
bool UInventoryWidget::Initialize()
{
bool success = Super::Initialize();
if (!success) return false;
if (InventoryButton1 != NULL)
{
InventoryButton1->OnClicked.AddDynamic(this, &UInventoryWidget::MouseClicked1);
}
if (InventoryButton2 != NULL)
{
InventoryButton2->OnClicked.AddDynamic(this, &UInventoryWidget::MouseClicked2);
}
return true;
}
此函数为按钮设置了OnClicked
函数。然后添加处理小部件的函数:
void UInventoryWidget::HideWidgets()
{
InventoryImage1->SetVisibility(ESlateVisibility::Hidden);
InventoryText1->SetVisibility(ESlateVisibility::Hidden);
InventoryImage2->SetVisibility(ESlateVisibility::Hidden);
InventoryText2->SetVisibility(ESlateVisibility::Hidden);
}
void UInventoryWidget::AddWidget(int idx, FString name, UTexture2D* img)
{
if (idx < kNumWidgets)
{
switch (idx)
{
case 0:
InventoryImage1->SetBrushFromTexture(img);
InventoryText1->SetText(FText::FromString(name));
InventoryImage1->SetVisibility(ESlateVisibility::Visible);
InventoryText1->SetVisibility(ESlateVisibility::Visible);
break;
case 1:
InventoryImage2->SetBrushFromTexture(img);
InventoryText2->SetText(FText::FromString(name));
InventoryImage2->SetVisibility(ESlateVisibility::Visible);
InventoryText2->SetVisibility(ESlateVisibility::Visible);
break;
}
}
}
HideWidgets
隐藏窗口中的所有小部件,因此如果没有任何内容,它们将不会显示出来。AddWidget
接受索引、名称和图像本身的纹理,然后为该索引设置小部件。文本小部件具有SetText
函数,允许您传递FText
(FText::FromString
将其从FString
转换为FText
)。图像小部件具有SetBrushFromTexture
,用于设置图像。
最后,您需要设置MouseClicked
函数:
void UInventoryWidget::MouseClicked1()
{
// Get the controller & hud
APlayerController* PController = GetWorld()->GetFirstPlayerController();
AMyHUD* hud = Cast<AMyHUD>(PController->GetHUD());
hud->MouseClicked(0);
}
void UInventoryWidget::MouseClicked2()
{
// Get the controller & hud
APlayerController* PController = GetWorld()->GetFirstPlayerController();
AMyHUD* hud = Cast<AMyHUD>(PController->GetHUD());
hud->MouseClicked(1);
}
这些只是使用 HUD 的MouseClicked
函数调用按钮的索引(提示:在更新 HUD 函数以接受索引之前,这不会编译)。如果您想进一步实验,稍后可以研究另一种根据单击的按钮获取索引的方法,以便您可以为所有按钮使用相同的函数。
设置小部件蓝图
接下来,您需要设置蓝图。由于这是一种特殊类型的蓝图,因此使用其自己的类设置一个蓝图有点棘手。您不能只创建该类的蓝图,否则您将没有设计蓝图。相反,您必须首先创建设计蓝图,然后更改父级。
要做到这一点,请进入内容浏览器,选择要放置的目录,然后选择添加新项|用户界面|小部件蓝图:
将其重命名为BP_InventoryWidget
,然后双击打开它。您应该会看到类似于这样的东西:
在中心,您将直观地布置屏幕,方框代表您所瞄准的理论屏幕的边缘。在左侧,调色板向您展示了可以添加到屏幕上的基本 UI 对象。您将看到许多常见的对象,例如图像、文本字段、进度条、按钮、复选框和滑块。这基本上是您免费获得的许多功能。一旦您开始为游戏设置设置窗口,其中许多功能将派上用场。
但首先,我们需要更改此处的父类。在右上角选择图表,顶部工具栏上的类设置,然后在详细信息下查找类选项,并选择父类旁边的下拉菜单。在那里选择 InventoryWidget:
现在我们要回到设计师,开始布置屏幕!
屏幕上应该已经有一个画布面板。您可以单击右下角并拖动以使其成为所需的大小。画布通常应该是整个屏幕的大小。所有其他 UI 小部件都将放在画布内。当您拖动时,它将显示您所瞄准的各种分辨率。您将要选择与您所瞄准的分辨率类似的分辨率。
然后在调色板下选择边框,并将其拖出到屏幕上。这将是窗口的背景。您可以单击角落并将其拖动到所需的大小。您还可以在右侧找到颜色条(在详细信息下的外观>刷子颜色旁边),单击它以打开颜色选择器选择背景的颜色:
您还可以在详细信息下重命名对象。一旦您完成了这些操作,点击并拖动一个按钮到屏幕上,并将其定位在背景的右上角。如果它试图填满整个边框对象,请确保您在层次结构中选择了画布面板,或者将其拖到边框对象之外,然后将其拖到其上方。确保将其命名为CloseButton
。如果您想使其看起来更像关闭按钮,还可以在其中放置一个带有字母 X 的文本对象。您应该在详细信息中的行为下取消选中“已启用”,以便它不会阻止鼠标点击。
接下来,您将要定位两个图像对象和两个文本对象(稍后可以添加更多)。确保名称与您在代码中使用的名称完全匹配,否则它们将无法工作。在文本字段中,设置字体会更容易。在详细信息|外观下,您将找到字体选项,就像您在任何文字处理器中习惯的那样,并且您可以使用计算机上已有的字体(尽管,如果您仍然想要下载字体,没有任何阻止您的东西)。您还可以使用之前添加的字体。
另外,对于OnClicked
,您将要添加一个按钮。您可以只在下面添加一个,但我使用了一种常见的 UI 方法:隐形按钮。拖动一个按钮出来,让它覆盖一个图像和一个文本。然后进入背景颜色并将 alpha(A)设置为0
。Alpha 是颜色透明度的度量,0
表示您根本看不到它。
如果以后点击按钮时遇到麻烦,可能会有其他对象挡住了。尝试将它们拖到按钮后面,或者研究一下如何禁用这些对象上的点击。
最后,您应该有类似于这样的东西:
还要仔细注意在选择边框时右侧的内容选项。这是您可以设置水平和垂直对齐的地方。始终尝试设置这些,因此如果您希望某些内容始终位于屏幕的左上角,对齐将设置为水平对齐左侧和垂直对齐顶部。如果您没有为每个对象设置对齐,不同屏幕分辨率下的结果可能是不可预测的。稍后我会更详细地介绍这一点。
但现在,这将是你的库存窗口。它不一定要看起来和我的一样,所以玩得开心,尝试一下视觉布局!尽管记住,你可能不希望它占据整个屏幕,这样你就可以在点击后看到施放的咒语(尽管你可以在以后点击咒语时关闭窗口)。
AMyHUD 更改
但这还不是全部!我们仍然需要修改我们现有的类来支持这个新的小部件,首先是AMyHud
类。为了简化事情,我们不会在这里复制所有以前的功能。相反,我们将设置OnClicked
函数来施放咒语,因为在游戏中这将比在屏幕上拖动物品更有用。右键点击不会被 UMG 自动处理,但如果你想以后添加它,你可以自己更深入地了解一下,你也可以查看以前的点击和拖动功能,所以如果你认为以后可能会用到它,你可能会想注释掉旧的代码而不是删除它。
目前,MouseMoved
和MouseRightClicked
函数已经消失,MouseClicked
函数现在接受一个int
索引。我们还有新的函数OpenInventory
和CloseInventory
,所以MyHUD.h
现在应该是这样的:
void MouseClicked(int idx);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Widgets")
TSubclassOf<class UUserWidget> wInventory;
UInventoryWidget* Inventory;
void OpenInventory();
void CloseInventory();
还要在文件顶部添加#include "InventoryWidget.h"。一些其他函数也将被修改。所以,现在我们将看一下AMyHUD.cpp
,你将看到新版本的函数有多么简单。以下是处理小部件的新函数:
void AMyHUD::DrawWidgets()
{
for (int c = 0; c < widgets.Num(); c++)
{
Inventory->AddWidget(c, widgets[c].icon.name, widgets[c].icon.tex);
}
}
void AMyHUD::addWidget(Widget widget)
{
widgets.Add(widget);
}
void AMyHUD::clearWidgets()
{
widgets.Empty();
}
我们还需要将MouseClicked
函数更新为这样:
void AMyHUD::MouseClicked(int idx)
{
AAvatar *avatar = Cast<AAvatar>(
UGameplayStatics::GetPlayerPawn(GetWorld(), 0));
if (widgets[idx].bpSpell)
{
avatar->CastSpell(widgets[idx].bpSpell);
}
}
这将根据传入的索引施放咒语。然后有新的函数来打开和关闭库存:
void AMyHUD::OpenInventory()
{
if (!Inventory)
{
Inventory = CreateWidget<UInventoryWidget>(GetOwningPlayerController(), wInventory);
}
Inventory->AddToViewport();
Inventory->HideWidgets();
}
void AMyHUD::CloseInventory()
{
clearWidgets();
if (Inventory)
{
Inventory->HideWidgets();
Inventory->RemoveFromViewport();
}
}
主要部分是向Viewport
添加或删除新的小部件。我们还希望在视觉上隐藏小部件,以防止空的小部件显示,并在关闭窗口时清除所有小部件。
我们还改变了struct Widget
以删除所有的定位信息。对它的任何引用都应该被删除,但如果你以后遇到任何错误(在你修改 Avatar 类之前,你将无法编译),确保MouseMoved
和MouseRightClicked
已经消失或被注释掉,并且没有其他东西在引用它们。新的、更简单的小部件应该看起来像这样:
struct Widget
{
Icon icon;
// bpSpell is the blueprint of the spell this widget casts
UClass *bpSpell;
Widget(Icon iicon)
{
icon = iicon;
}
};
AAvatar 更改
在AAvatar
中,我们主要将修改ToggleInventory
函数。新的函数将如下所示:
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->CloseInventory();
inventoryShowing = false;
PController->bShowMouseCursor = false;
return;
}
// Otherwise, display the player's inventory
inventoryShowing = true;
PController->bShowMouseCursor = true;
hud->OpenInventory();
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];
Widget w(Icon(fs, tex));
w.bpSpell = Spells[it->Key];
hud->addWidget(w);
}
}
hud->DrawWidgets();
}
正如你所看到的,许多相同的 HUD 函数被重用,但现在从这里调用OpenInventory
和CloseInventory
的新函数,所以 HUD 可以在添加小部件之前显示窗口,并在关闭窗口时删除窗口。
还要从Yaw
和Pitch
函数中删除以下行:
AMyHUD* hud = Cast<AMyHUD>(PController->GetHUD());
hud->MouseMoved();
还要从MouseRightClicked
中删除以下行(或删除该函数,但如果你这样做,请确保你也从SetupPlayerInputComponent
中删除它):
AMyHUD* hud = Cast<AMyHUD>(PController->GetHUD());
hud->MouseRightClicked();
最后,从MouseClicked
中删除这些行(因为你不希望在点击不属于库存的地方时意外触发一个咒语):
AMyHUD* hud = Cast<AMyHUD>(PController->GetHUD());
hud->MouseClicked();
现在你应该能够编译了。一旦你这样做了,进入 BP_MyHUD 并将类默认值>小部件>W 库存下拉菜单更改为 BP_InventoryWidget。
关于 OnClicked 的说明
可能你的OnClicked
函数可能无法正常工作(我自己遇到了这个问题)。如果你找不到解决办法,你可以通过蓝图绕过,这就是为什么我把所有的鼠标点击函数都设置为蓝图可调用的原因。
如果这种情况发生在你身上,进入你的小部件蓝图的设计师,对于每个按钮点击它并找到详细信息下的事件,然后点击旁边的绿色+按钮。这将为该按钮添加OnClicked
到图表并切换到该按钮。然后,从节点中拖出并添加你想要的功能。它应该看起来像这样:
布局您的 UI
当您布局 UI 时,有一些重要的事情需要牢记,UMG 有工具可以让这更容易。其中最重要的一点是,您的游戏不会总是以相同的分辨率运行。如果您在做移动游戏,可能会有许多不同分辨率的设备,您希望您的游戏在所有设备上看起来基本相同。即使是游戏机也不再免于这个问题,因为 Xbox One 和 PS4 现在都有 4K 选项。因此,您的游戏需要以一种可以实现这一点的方式设置。
如果您将所有的小部件都设置为特定的像素大小,然后在分辨率更高的情况下运行,它可能会变得非常小,看起来难以阅读,按钮可能也很难点击。在较低的分辨率下,它可能太大而无法适应屏幕。所以,请记住这一点。
您之前设置的画布面板将直观地显示您所追求的大小。但是对于所需大小的变化,您需要牢记几件事情。
首先,始终使用锚点。在详细信息下,您将看到一个锚点的下拉列表。打开它,您应该会看到类似这样的内容:
蓝线左上角的九个选项是用来对齐对象的。行对齐到屏幕的顶部、中部和底部,而列对齐到屏幕的左侧、中部和右侧。因此,如果您希望某些内容始终出现在屏幕的左上角(比如得分或生命条),您将选择左上角的选项。如果您希望其他内容水平和垂直居中,请选择第二行、第二列。小白色方块基本上显示了您的定位。
剩下的选项可以让您在整个屏幕上拉伸某些内容(无论大小如何)。因此,如果您希望在顶部、中部或底部水平拉伸某些内容,请查看右列。对于垂直方向,请查看底部行。如果您希望窗口横跨整个屏幕,请查看右下角的选项。
您还可以从调色板中添加一个比例框,如果您希望其中的所有内容都按比例缩放以适应屏幕大小。虽然如果您有一些希望保持固定大小的东西,比如一张图片,您可以勾选“大小自适应内容”来防止它自动调整大小。
如果您想要更高级一点,您可以添加代码来检查屏幕大小并交换部分或整个 UI,但这超出了本书的范围,所以如果您想在自己的项目中稍后尝试,请记住这一点!
您的 UI 的另一个重要事项是本地化。如果您希望在自己国家之外的任何地方发布游戏,您将需要进行本地化。这意味着您不仅要习惯于不直接编写文本,而是使用内置的本地化系统来添加您设置的字符串 ID,而不是直接编写文本。代码将寻找特定的 ID,并将其替换为相应的本地化文本。您可以在这里了解内置的本地化系统:docs.unrealengine.com/en-us/Gameplay/Localization
。
这也会影响您布局 UI 的方式。当您第一次将游戏本地化为德语时,您会发现一切都变成了两倍长!虽然您可能能让翻译人员想出更短的表达方式,但您可能会希望使文本块比您认为需要的更长,或者考虑找到使文本收缩以适应或滚动的方法。
更新您的 HUD 并添加生命条
我不会在这里给出完整的说明,但以下是一些关于更新 HUD 的提示。一旦您这样做,它将进一步简化您的代码!
创建一个 HUD 类
您需要创建一个从 WidgetBase 派生的新类,用于您的新 HUD。在这种情况下,您将需要 Canvas Panel,但不需要背景。确保所有内容都会延伸到整个屏幕。
您将希望将大部分 UI 放在角落里,因此您可以在屏幕的左上角添加一个进度条小部件来显示健康。此外,考虑添加一个文本小部件来告诉它是什么,和/或在屏幕上显示实际数字。
对于消息,您可以将文本小部件对齐到屏幕的顶部中间,并使用它们来显示文本。
添加健康条
如果您已添加了推荐的进度条小部件,您会发现绘制健康条现在更容易了。您需要像其他小部件一样获取对它的引用。然后,您所需要做的就是调用SetPercent
来显示当前的健康值(并在健康值改变时重置它)。
您不再需要自己绘制整个东西,但是您可以使用SetFillColorAndOpacity
来自定义外观!
播放音频
我们将回到您的代码,做最后一件真正有助于您的游戏反馈的事情,但是在创建游戏时,这似乎总是最后一个人会考虑到的事情:音频。
音频可以真正增强您的游戏,从在单击按钮时播放声音到添加音效、对话、背景音乐和环境音效。如果您在夜晚独自在树林中行走,蟋蟀的鸣叫声、您自己的脚步声和不祥的音乐可以真正营造氛围。或者,您可以播放鸟鸣和愉快的音乐,营造完全不同的氛围。这都取决于您!
我们将在你施放暴风雪咒语时添加一个声音。因此,请寻找一个免费的风声。有很多网站提供免版税的声音文件。其中一些要求您在使用它们时在您的制作人员名单中提到他们。对于这个,我在一个名为SoundBible.com的网站上找到了一个公共领域的声音,这意味着任何人都可以使用它。但是请寻找您喜欢的声音。
有些网站可能会要求您注册以下载声音。如果您感到有雄心壮志,甚至可以自己录制一个!
我使用了.wav 文件,这是一种标准格式,尽管其他格式可能也有效。但是对于小声音,您可能希望坚持使用.wav,因为 MP3 使用了压缩,这可能会稍微减慢游戏速度,因为它需要对其进行解压缩。
一旦您找到喜欢的文件,请为声音创建一个文件夹,并从文件管理器将声音文件拖入其中。然后在同一文件夹中右键单击并选择 Sounds | Sound Cue:
将其重命名为 WindCue,然后双击它以在蓝图编辑器中打开它。它应该看起来像这样:
声音提示是我们设置声音的地方。首先,右键单击任何位置,然后选择 Wave Player 添加一个:
然后,选择 Wave Player。在详细信息中,您将看到一个名为 Sound Wave 的选项。选择下拉列表并搜索您添加的.wav 文件以选择它:
然后,从 Wave Player 的输出处拖动并放入输出(带有小扬声器图像)。这将连接它。要测试它,您可以选择播放提示,然后您应该听到声音,并且看到线条变成橙色,表示声音被传输到输出:
如果您不喜欢声音的方式,请尝试详细信息下的选项。我使用的声音对我想要的效果太安静了,所以我增加了音量倍增器使其响亮得多。
现在我们已经设置好声音,是时候将其添加到代码中了。在这种情况下,我们将更新AMyHUD
类。首先,在MyHUD.h
的顶部添加以下行:
#include "Sound/SoundCue.h"
此外,在同一文件中添加以下内容:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Sound")
USoundCue* audioCue;
您将希望将SoundCue
引用存储在蓝图中以便于更新。
现在,转到MyHUD.cpp
并在MouseClicked
函数中的CastSpell
调用之后添加以下行:
UGameplayStatics::PlaySound2D(this, audioCue);
这将实际播放声音。确保在该文件中包含#include "Kismet/GameplayStatics.h"
才能正常工作。对于这种情况,因为它在玩家附近每当您施放它时,2D 声音就可以了。如果您希望环境中的事物(如怪物)发出自己的声音,您将需要研究 3D 声音。UE4 将让您做到这一点!
现在,返回编辑器并编译所有内容,然后返回 HUD 蓝图。您需要将创建的SoundCue
添加到蓝图中。
您可以从下拉列表中选择它,并像这样搜索它:
现在,保存、编译并运行游戏。四处奔跑,直到您捡起了一个暴风雪咒语并按I打开库存。点击暴风雪咒语。您不仅应该看到咒语施放,还应该听到它!
摘要
现在,您已经深入了解了如何使用 UMG 创建用户界面,以及如何添加音频以进一步增强您的体验!还有很多工作要做,但请考虑练习!
我们已经完成了这部分的代码,但书还没有完成。接下来,我们将看看如何将我们所拥有的内容在虚拟现实中查看!我会给你一些建议,然后我们将以 UE4 中的一些其他高级功能概述结束。
第十五章:虚拟现实及更多内容
除非你一直住在山洞里,你可能已经听说过虚拟现实(VR)。VR 是目前游戏界最热门的趋势之一,还有增强现实(AR),这将在本章后面进行介绍。由于诸如超便宜的谷歌 Cardboard 和类似设备的创新,让你可以在最新的智能手机上查看基本的 VR,所以很容易获得 VR 技术的访问权限。
无论你只有一个谷歌 Cardboard,还是你有一个更高端的设备,比如 Oculus Rift 或 HTC VIVE,UE4 都可以轻松为 VR 编程。当然,如果你有 PlayStation VR,你需要成为索尼的官方开发者才能为其编程(就像为 PlayStation 编程其他内容一样),所以除非你在一家正在开发 PSVR 标题的公司工作,否则你可能无法做到这一点。
在这里,你将获得关于 VR 和 UE4 的概述,这应该可以帮助你入门。以下是我们将要涵盖的内容:
-
为 VR 做好准备
-
使用 VR 预览和 VR 模式
-
VR 中的控制
-
VR 开发的技巧
我还将介绍 UE4 的一些更高级的功能。我们将首先看看目前的另一个热门技术 AR,然后转向其他技术。以下是我们将要涵盖的内容:
-
增强现实
-
过程式编程
-
使用插件和附加组件扩展功能
-
移动,游戏机和其他平台
为 VR 做好准备
现在是一个激动人心的时刻,开始进行 VR 开发。也许你正在尝试进入最新的热门技术。或者,就像我一样,你在威廉·吉布森、尼尔·斯蒂芬森、威尔海尔米娜·贝尔德和布鲁斯·贝思克等作家的赛博朋克书籍中读到 VR 几十年,现在它终于出现了。无论哪种情况,以下是你可以为进入 VR 编程之旅做好准备的方法。
要开始使用 Oculus Rift 或 HTC Vive 进行 VR,首先你需要一台 VR-ready 的电脑。Oculus 在他们的网站上有一个免费的程序可以下载ocul.us/compat-tool
,或者去他们的支持页面,它会告诉你是否有图形卡的问题。
即使你有一台最新的电脑,除非你专门购买了一个标记为 VR-ready 的电脑,你很可能需要一张新的显卡。VR 需要极高的图形性能,因此需要一张相当高端(通常也相当昂贵)的显卡。
当然,如果你只想在手机上进行 VR,你可能可以不用它,但你将不得不在手机上进行所有测试,并且无法使用 UE4 的许多很酷的功能,比如 VR 编辑。
一旦你有一台可以处理的电脑,你可能会想要购买 Oculus Rift 或 HTC Vive(或者两者都有,如果你真的很认真并且有足够的钱投入其中,因为两者都不便宜)。无论你选择哪种设备,都会在设置过程中安装你所需的所有驱动程序。
然后,进入 UE4,转到编辑|插件,并确保你拥有你所拥有设备的插件(你可以搜索它们)。根据你的 VR 硬件,它应该看起来像这样:
另外,请确保你的 VR 软件正在运行(当你打开 UE4 时,它可能会自动启动,这取决于你的 VR 硬件)。
使用 VR 预览和 VR 模式
如果你想在 VR 中查看某些内容,好消息是你不需要编写任何新内容!只需进入现有项目,点击播放按钮旁边的箭头,然后选择 VR 预览:
现在,只需戴上你的 VR 头盔,你就可以在 VR 中看到游戏了!
一旦你运行游戏,你就可以看到游戏世界。你无法四处移动(在 VR 中看不到键盘或鼠标),但你可以转动头部四处观看。
如果你容易晕动病,一定要非常小心。这在 VR 中是一个严重的问题,尽管有方法可以减轻游戏中的影响,我们稍后会谈到。在你习惯了它并知道它对你的影响之前,你可能不想在 VR 模式下待太久。
UE4 还有另一个工具可以帮助你,那就是 VR 模式。这允许你实际在 VR 中查看和编辑游戏,这样你就可以在进行更改时看到它们的效果。这可能非常有帮助,因为许多东西在 VR 中看起来与非 VR 游戏中不一样。
要激活 VR 模式,可以在工具栏中点击 VR 模式,或者按下Alt + V:
你可以四处张望,在 VR 模式下,你将能够使用你的运动控制器
在游戏中,你可能想在开始之前查找你需要的控制方式。
第一次进入 VR 模式。在 Unreal 网站上有关 VR 模式和你可以在其中使用的控制的详细说明:docs.unrealengine.com/en-us/Engine/Editor/VR
。
如果你想进一步,通过为特定的 VR 系统编程,比如 Oculus Rift、Vive、Steam VR 或其他系统,Unreal 网站上有许多不同 VR 系统的详细说明。你可以在这里找到它们:docs.unrealengine.com/en-us/Platforms/VR
。
VR 中的控制
你可能会注意到,在 VR 模式下,通常的控制方式不起作用。你甚至看不到戴着 VR 头显的键盘和鼠标,这使得使用它们非常困难。幸运的是,高端设备有自己的控制器可用,UE4 有一个运动控制器组件,你可以添加到你的玩家角色中,这样你就可以用它指向东西,而不是用鼠标。
如果你从一开始就知道你的目标是 VR,UE4 有专门针对 VR 的类和模板可供使用,这将自动添加一些你需要的功能。还有一个非常有用的 VR 扩展插件,如果你不是一个庞大的开发团队,你真的应该考虑一下。你可以在这里找到它:forums.unrealengine.com/development-discussion/vr-ar-development/89050-vr-expansion-plugin
在 VR 中,用户界面非常棘手,许多人仍在努力找出最佳的方法。你最好的选择可能是玩很多现有的游戏,看看你认为哪种方式最适合你。而且一定要尽可能多地进行实验,因为这是了解什么方法有效的最佳方式!
VR 开发的一些建议
VR 是一项新的令人兴奋的技术。人们仍在摸索有效的方法,因此有很多的实验空间,也有很多实验正在进行。但你仍然需要牢记一些最佳实践,因为你不希望玩你的游戏的人有糟糕的体验,甚至在玩你的游戏时感到恶心。如果他们这样做了,他们可能不会再玩这个游戏,并且不太可能购买你的下一个游戏。所以,你希望每个人的体验都是好的。
VR 最大的问题是模拟晕动病(或晕动病)。有些人受到的影响比其他人更大,但如果你不小心,即使平时不容易晕动病的人也会有问题。因此,非常重要要小心。而且一定要让其他人测试你的游戏,因为虽然你可能习惯了,但这并不意味着其他人不会有麻烦。
最重要的考虑之一是保持非常高的帧率。不同的设备对于最低帧率有不同的建议,如果低于这些帧率,人们可能会开始出现问题。
总的来说,保持尽可能高的质量非常重要。任何看起来虚假或糟糕的东西都可能使人感到不适,并引起晕动病。因此,如果您尝试实现的任何效果看起来不如您预期的那样,可以尝试做其他事情。
您可能会注意到许多 VR 游戏在游戏中几乎不让玩家移动,或者让他们坐在移动的车辆中。这是避免模拟晕动病的另一种方式。移动是最大的问题,特别是垂直移动,比如跳跃,或者通过控制器旋转而不是只转动头部。基本上,您的大脑认为您在移动,但您的身体得到了矛盾的信息,因为它没有感受到移动。如果您认为自己坐在车上,您的身体就不会期望感受到移动,所以这就是为什么它似乎效果更好。尽管如此,如果玩家在玩游戏时站着,他们可能会遇到更少的问题。
关于 VR 和最佳实践的信息在网上有很多。Unreal 网站上有一篇关于最佳实践的页面,其中包含一些非常好的 UE4 特定信息。我建议在开始项目之前先阅读一遍,因为从一开始就牢记最佳实践比在项目结束时发现一些事情不起作用或效果不好要好得多。
正如我之前所说,让人们来测试它非常重要。VR 技术是如此新颖
您需要确保它能够尽可能地适用于更多的人。
AR
AR 与 VR 类似,只是在这种情况下,您看到的是放置在真实世界中的虚拟物体(通过摄像头查看)。这可以通过头戴式设备实现,例如微软的 HoloLens 或 Magic Leap。但由于这些设备都是新的,目前只能作为面向开发人员的昂贵设备,因此您主要会通过移动设备看到 AR。
移动设备上流行的 AR 游戏包括 Pokemon Go,您可以在其中捕捉 Pokemon 并在您周围的世界中查看它们。在 AR 模式下,您必须四处张望,直到找到 Pokemon(它会显示需要转向的方向)并捕捉它。您甚至可以拍照,这会产生一些有趣的图像。它的前身 Ingress 让您在游戏中去真实世界的地点,但 Pokemon Go 真的扩展了这一点。
由于该游戏的成功,移动 AR 游戏现在非常受欢迎。由于您正在处理无法控制的现实世界物体,这可能涉及一些复杂的计算机视觉,但幸运的是,UE4 具有内置功能来帮助您。
UE4 支持的两种主要移动 AR 系统是 iOS 的 ARKit 和 Android 的 ARCore。您可以在 Unreal 网站上找到有关 AR 编程和每种类型的先决条件的更详细信息。要启动任何一个,您都需要使用手持 AR 模板创建一个新项目:
如前面的屏幕截图所示,您的设置应该是移动/平板电脑,可扩展的 3D 或 2D,没有初始内容。创建项目后,您可以将手机连接到计算机,如果完全设置好(取决于您的手机,您可能需要在计算机上安装软件才能看到它),当您单击“启动”旁边的箭头时,您应该会在设备下看到它。否则,您仍然可以在播放下使用移动预览 ES2(PIE)。
虽然您可能不会很快为 Magic Leap 编程,但 Unreal 网站上提供了早期访问文档:docs.unrealengine.com/en-us/Platforms/AR/MagicLeap
。
程序化编程
最近,游戏中的过程式编程非常受欢迎。如果您玩过《Minecraft》、《无人之境》或《孢子》,您就玩过过程式游戏。过程游戏的历史可以追溯到几十年前,到旧的基于文本的游戏,如 Moria、Angband 和 NetHack。类似 Rogue 的游戏(以最初的 Rogue 命名)仍然是一种使用过程技术生成随机关卡的流行游戏类型,因此每次玩都会得到完全不同的游戏。因此,过程式编程增加了难以通过手工建造关卡获得的可重复性。
过程式编程可以让您通过代码中的规则和算法创建游戏的部分,无论是环境、关卡,甚至是音频。基本上,代码会为您设置每一个细节,而不是由人类设置。
结果可能是不可预测的,特别是在 3D 中,这比在 2D 文本字符中绘制房间和路径要复杂得多。因此,有时,过程级别是提前创建的,以便设计人员可以在将它们添加到游戏之前选择他们喜欢的级别。
有许多不同的技术可以帮助进行过程式编程。其中之一是使用体素,它可以让您以一种简单的方式引用 3D 空间中的点,基于它们与其他体素的关系。体素已经在许多项目中使用,包括现在已经停止运营的游戏 Landmark(我曾参与其中),并且原本计划在现在取消的 EverQuest Next 中使用。UE4 通过插件支持体素,例如 Voxel Plugin(voxelplugin.com/
)。
过程式编程也可以用于音乐。有一些项目已经对特定类型的音乐进行了神经网络训练,并以类似风格创作了一些非常出色的音乐。您还可以根据游戏中发生的情况修改播放的音乐。Spore 在这方面做了一些非常令人印象深刻的事情。
如果您有兴趣了解更多信息,请查找 David Cope,他是一位研究人员,已经撰写了几本关于这个主题的书。或者,您可以查看 Unreal 的开发人员在这里对过程音频所做的工作:proceduralaudionow.com/aaron-mcleran-and-dan-reynolds-procedural-audio-in-the-new-unreal-audio-engine/
。您还可以找到 UE4 的附加组件,例如我过去使用过的过程 MIDI 插件。
通过插件和附加组件扩展功能
我们已经看到了一些插件和其他附加组件的示例,以及它们如何可以扩展 UE4,从为您特定的 VR 头显添加 VR 功能到添加支持体素或过程音乐功能。但是还有很多其他可用的插件。
对于插件,您可以转到编辑|插件,并按类别查看所有已经可用的内容:
这些是内置插件。
但是,如果您想了解更多信息,您需要查看 Epic Games Launcher 中的市场:
虽然您将看到的大部分是图形和模型,但有很多可用的功能可以添加。其中一些是免费的,而另一些则需要付费。例如,这是对过程式的搜索:
UE4 是一个非常受欢迎的游戏引擎,所以如果有任何您需要的东西,很有可能其他人已经为其开发了附加组件。您还可以在互联网上找到许多项目,其中许多是开源的,开发人员乐意帮助您实施。但是这可能需要额外的工作来实施,并且您需要小心并确切知道您正在下载和安装的内容。
移动、控制台和其他平台
正如我们提到 AR 时所看到的,你可以在 UE4 中为移动设备开发,并在计算机或手机上预览你的游戏。UE4 的一个很棒的特点是它支持许多不同的平台。
许多 AAA 游戏工作室使用 UE4,因此它绝对支持所有主要游戏主机(Xbox One、PS4、Switch,甚至包括 3DS 和 Vita 等移动主机)。对于这些主机的技巧是,通常你不能只是为它们开发游戏——你需要成为授权开发者,并且通常需要在 DevKit 上花费大量资金(DevKit 是专门用于开发的主机版本,可以让你在主机上进行调试)。
幸运的是,随着主机独立游戏市场的发展,现在开发者获取权限的门槛比过去低得多。但在你开始研究这个之前,你可能还需要更多的经验和已发布的游戏标题。
与此同时,你的游戏还有许多不同的选择和平台。一旦你为一个平台开发了游戏,将这个游戏移植到另一个平台就会变得更容易(UE4 使这一切变得非常容易!)。
主要的区别将是控制方式,因为你可能会使用触摸屏、控制器、运动控制器(在 VR 中)或键盘和鼠标。每种方式都有不同的要求,会稍微改变游戏玩法。但只要你从一开始就记住你要瞄准的平台,你就能够规划你的游戏,使其适用于所有平台。
总结
在这本书中,我们涵盖了很多内容,但现在我们已经到了尽头。我们学习了 C++的基础知识,并在 UE4 中创建了一个非常简单的游戏,包括一些基本的人工智能、部分 UI 包括库存,以及使用粒子系统施放法术的能力。我们还了解了 VR、AR 和其他新兴技术,UE4 可以帮助你应对这些技术。
你现在已经学到了足够的知识来开始制作自己的游戏。如果你需要更多关于特定主题的信息,还有许多其他高级书籍和网站可以供你参考,但是现在你应该对你正在研究的内容有了更清晰的认识。
希望你们喜欢这次的旅程。祝你们未来的项目好运!
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库