精通虚幻-4-游戏开发第二版-全-

精通虚幻 4 游戏开发第二版(全)

原文:zh.annas-archive.org/md5/4c638b6408ef7fafab5d6fe525dfef3c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Unreal Engine 4 (UE4) 是一套极其强大的技术,现在任何人都可以免费获得。从学生到大型团队,无论他们正在开发何种游戏和平台,它都提供了一种将各种应用和游戏带给公众的手段。本书的宗旨是增强使用者的信心,并将开发者的水平提升到大师级别,使任何项目中的任何问题和挑战都能得到解决。

本书面向的对象

对于熟悉 UE4 开发的开发者来说,这本书应被视为最佳实践、实际示例和技术主要系统的参考,它将有助于培养领导技能和信心。

本书涵盖的内容

第一章,为第一人称射击游戏创建 C++项目,是本书的起点,以及其对应的 GitHub 上的 UE4 项目。我们将从安装引擎并在 C++中构建它开始,然后添加 Unreal 为我们提供的第一个人称射击游戏模板,并在 C++中构建它,接着添加一个新的玩家类和控制。

第二章,玩家的库存和武器,介绍了一些游戏基础知识,并使你熟悉在编辑器中添加 C++类并将它们连接到蓝图。到本章结束时,玩家将拥有一个完全功能性的库存系统,包括拾取物品和使用新控制进行循环。

第三章,蓝图审查和何时使用 BP 脚本,是我们详细检查 UE4 的蓝图脚本系统及其优缺点的地方,包括在我们游戏地图中的实际应用。

第四章,UI 需求、菜单、HUD 以及加载/保存,迅速深入到一些更深入的主题:首先设置我们的 UI 和 HUD,并将其连接到我们的库存后,我们将深入探讨 UE4 的文件系统和其它类,以实现一个可在任何地方保存和加载的系统,并通过 UI 进行连接。

第五章,添加敌人!,介绍了如何导入新角色,构建并连接其整个 AI 系统,以便注意到、动画到并攻击我们的玩家。

第六章,改变关卡、流式传输和保留数据,解释了在 UE4 中切换地图(关卡)或利用其流式传输选项是所有游戏的必备条件。我们将花时间熟悉 Unreal 的选项,然后转到在切换地图时保留数据。我们将调整我们的加载/保存系统,以便我们的玩家可以在关卡之间持久化库存,同时保留每个地图的状态,就像它被留下时一样。

第七章,在游戏中添加音频,讨论了音频是游戏经常被忽视的方面,它可以决定游戏的沉浸感!在对 UE4 的主要音频系统进行概述后,我们将深入探讨基于材料的影响声音和环境效果等主题。

第八章,着色器编辑与优化技巧,解释了材料和它们创建的着色器可能是 Unreal 最重要的系统。在 UE4 中可能实现的视觉效果几乎是无限的,但当你使用它们时,你需要了解它们的限制和成本。我们将通过一些创建新材料和对其进行分析的实用示例,来熟悉如何优化你的着色器并适应不同的平台。

第九章,使用 Sequencer 添加游戏内场景,向读者介绍了 Sequencer,这是现在主要在 UE4 中使用和支持的游戏内场景工具。在制作了包含玩家和敌人 AI 的游戏场景后,我们将讨论 Sequencer 的一些替代方案。

第十章,打包游戏(PC、移动),解释了如果游戏不能在其目标平台上安装和运行,那么它就不完整!在这里,我们将通过一些打包游戏和安装的示例,并讨论 UE4 在设备上启动和进行独立构建之间的差异。

第十一章,体光贴图、雾和预计算,解释了 UE4 提供了一大堆令人惊叹的图形系统,但它的照明在业界享有盛誉,被认为是顶级的。本章探讨了可用的某些高级照明,并解释了如何将其添加到我们的游戏中。它还涵盖了添加和修改大气和体积雾。

第十二章,场景中的视频和视觉效果,探讨了 UE4 的媒体框架以及它提供的一些酷炫功能。具体来说,我们将拍摄游戏早期场景的一些视频,并在游戏中作为视频文件(MP4)播放。我们还将探索 Unreal 的粒子系统和物理粒子,将这些添加到我们的投射物影响中。

第十三章,UE4 中的虚拟现实与增强现实,涵盖了 Unreal 不断增长的平台列表中的两个最新主要新增功能:VR 和 AR。在本章的最后,我们将创建两个独立的项目(每个一个),并修改和实现它们各自独特的功能,包括将我们的投射物从主项目移植到 AR 项目中。

为了充分利用这本书

对使用虚幻引擎 4(Unreal Engine 4)的基本舒适度是一个重要的起点,但这并非强制要求。本书的目标是帮助那些与这项技术合作的人达到一个水平,使他们能够足够熟悉所有方面,成为项目中的技术领导者与推动者。虽然 UE4 是一个多平台的技术集合,但 Windows PC 与 Visual Studio 是主要的开发平台。MacBook Pro 和 XCode 也被频繁使用,Android 手机(包括 GearVR)和 iOS 设备也得到了代表。

下载示例代码文件

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

您可以通过以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件夹:

  • Windows 版的 WinRAR/7-Zip

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788991445_ColorImages.pdf

使用的约定

本书使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

/** Muzzle offset */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Projectile)
class USceneComponent* MuzzleLocation;

任何命令行输入或输出都应如下所示:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 请通过发送电子邮件至 feedback@packt.com 并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过发送电子邮件至 questions@packt.com 与我们联系。

勘误: 尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上发现任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至 copyright@packt.com 与我们联系。

如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需更多关于 Packt 的信息,请访问 packt.com

第一章:为第一人称射击游戏创建 C++ 项目

简介

欢迎来到精通 Unreal Engine 4!本书的目标是将熟悉 UE4 和 C++ 开发的个人全面提升到下一个层次。虽然一些章节将深入探讨特定系统实现和最佳实践,但其他章节可能会提供一个更广泛的视角,了解内容创作团队成员经常使用的庞大 UE4 系统。到那时,你应该有一个坚实的基础,可以就使用 UE4 技术的任何范围或平台的项目做出最佳决策,并且你将能够引导整个团队将项目进行到底。你可以在许多领域直接将这些系统应用于游戏项目,但本书的整体目标是能够从技术方向解决任何需求,为你提供一个高于单纯编写代码的人的知识基础。

在本章中,我们将启动一个基本战斗游戏项目,这样我们就有了一个基础,在前进时可以添加和开发更高级的功能。虽然其中一些是自动从 UE4 的游戏模板中管理的,但我们将通过所有必要的步骤来确保本书中引用的核心设置被添加、构建和测试,并且一些新的游戏玩法系统被实现和展示。

本章剩余部分将涵盖的主要主题如下:

  • 设置和创建新的第一人称射击游戏项目

  • 覆盖现有的 UE4 类

  • 为它们添加和实现简单的 C++ 函数

  • 快速回顾构建和运行选项

技术要求

对于本章,你需要以下组件:

  • Visual Studio 2015 或 2017(任何版本)

  • 从源代码构建的 Unreal Engine 4.18.3 或更高版本

关于平台和安装的一些快速说明:上述组件假设你将使用 Windows 10 PC,但没有任何理由表明运行当前版本 Xcode 的 Mac 不能从本书中探索的所有相同工作中受益并执行所有相同的工作。由于展示的工作将来自 VS 并引用其一些功能,这是我们推荐的工作环境,随着我们进入章节,但这不是具体要求。样本将不时进行测试,并在后面的章节中主要在 Mac 上构建,以 iOS 为目的,但本书不会专注于 IDE 的具体细节,除了提供步骤(通常以 Visual Studio 术语)和提示。代码示例可能反映了来自 wholeTomato.com 的 Visual Assist 工具的格式,我强烈推荐 Visual Studio 用户使用该工具,但他们的工具不会影响构建或结果。

你可以在本书所有章节中找到引用的所有源代码,请访问 github.com/mattedmonds404/Mastering,其中包含每个章节中展示的工作的修订历史。

对于本章及其工作,请务必选择名为Chapter1的分支,位于 GitHub 网页界面上方的左侧分支下拉菜单,或者使用本章分支的直接链接github.com/mattedmonds404/Mastering/tree/Chapter1

关于直接从 GitHub 使用项目的两个注意事项:您仍然需要从源代码本地安装引擎,以便您可以右键单击项目(Mastering/Mastering.uproject)并点击选择虚幻引擎版本,您还需要选择您的安装文件,这将也会构建适当的项目文件。在 VS 中启动Mastering.sln文件后,请按照以下步骤操作:

  1. 在解决方案资源管理器中右键单击Mastering游戏项目,并选择设置为启动项目,这样当从 VS 运行时,您将直接进入该项目的编辑器。

  2. 将配置设置为开发编辑器,平台设置为 Win64。

  3. 可能需要在解决方案资源管理器中直接右键单击 UE4 项目并点击构建。有时,在构建游戏项目时,它可能不会获取构建引擎所需的全部依赖项,如果这些依赖项尚未单独完成。

我们将使用的是 4.19.0 版本的引擎。

构建 FPS C++项目

在本节中,我们将通过 Unreal 项目浏览器从头开始创建新项目的步骤。对于那些已经熟悉这些步骤的人来说,这个过程应该相对快速且直接。对于任何新加入这个设置的人来说,这可能是团队成员加入已经全面开发的项目时的典型情况,有一些必要的步骤来开始。由于我们将使用 C++ 和 Visual Studio 进行工作,我将在这里快速说明没有源代码的引擎用户:由于本书将努力不直接修改引擎源代码,因此您仍然需要构建 C++ 项目,因为本书将专注于这一点。可以不构建源代码就创建仅蓝图的项目,这在第三章,“蓝图审查和何时使用 BP 脚本”,中探讨了其优缺点,但再次强调,这并不是本书在大多数实现案例中呈现信息的方式。还值得注意的是,Epic Games 对其技术用户发现的问题或改进非常开放,如果提供适当的调试信息,他们通常会在他们的答案论坛上更迅速地回应这些提出的问题,例如answers.unrealengine.com。此外,如果您的项目需要在 Epic Games 提供帮助之前立即修复或更改引擎代码,您需要熟悉调试和构建引擎。最后,如果您想提交 Epic Games 将要集成的更改或修复类型的拉取请求,您还希望将其绑定到他们的 GitHub 项目上。因此,我们将继续前进,就像我们在一个全新的计算机上安装引擎和项目,并具有从源代码完全重建的能力一样。

在下一节中,我们将对游戏模式和玩家进行一些修改,重新编译这些修改,并在游戏中查看结果。对于那些希望跳过的人来说,所有将要展示的工作都可以在 GitHub 仓库的Chapter1分支中找到,如前所述。

要启动一个全新的项目,有三个主要步骤:

  1. 下载并安装 UE4 源代码并编译它。

  2. 首次运行编辑器到项目浏览器并选择一个模板。

  3. 构建并运行该项目。

安装和构建 UE4

我们的第一步是下载 UE4 源代码并构建它。这可以通过多种方式完成。如果您是第一次这样做,最简单的方法是访问他们的 GitHub 网站,在github.com/EpicGames/UnrealEngine获取引擎。

要使上面的链接正常工作,您必须登录到 GitHub,并已申请成为 Unreal 开发者。有关详细信息,请参阅wiki.unrealengine.com/GitHub_Setup

点击“克隆或下载”按钮以查看你的选项。从这里,最简单的解决方案是选择下载项目作为 ZIP 文件,并在你的硬盘上任何你喜欢的地方解压缩,如下面的截图所示:

图片

使用 Git 网站始终是一个可行的选项。虽然我个人不是 GitHub 桌面应用程序的粉丝,但它也是在这个过程中可以探索的可能性之一。虽然我会说 SourceTree 有一些用户体验问题,但它是一个免费的应用程序,我推荐用于管理 GitHub 项目。对于那些习惯于命令行工作的人来说,有许多选项,以及一个可以打开的终端,以便在 SourceTree 中使用这些命令。目前的重要部分是安装 UE4 树,这样我们就可以开始构建了!

当下载新版本的引擎时,无论是更新还是全新安装,首先始终在主安装文件夹中运行Setup.bat(或在 Mac 上的setup命令)进行其他任何步骤之前。确保弹出窗口正在获取你使用的所有平台,确保它拉取了与同一文件夹中的README.md文件中描述的平台所需的文件。

一旦Setup.bat/setup命令完成,运行GenerateProjectFiles.bat文件,一个UE4.sln文件将出现在同一文件夹中。关于 UE4 生成的解决方案和 VS 2015 以及 VS 2017 的状态的快速说明:UE4 默认生成 VS 2015 项目文件。你可以指定-2017作为批处理文件参数。目前不需要为 2017 年构建,2015 年的项目文件在 VS 2015 和 VS 2017 中打开、构建和运行都非常完美。然而,如果你安装了这两个版本的 VS,那么默认情况下它将尝试在 VS 2015 中打开它们,这可能会非常令人烦恼。截至撰写本书时,使用任何版本的 Visual Studio 都应该提供相同的结果,这一点在这里已经得到了测试,但向前看,GitHub 上的内容将迫使你使用 VS 2017 在编辑器中构建。关于为什么以及如何设置这一点的讨论将在本章的“修改我们的游戏以 C++”部分的“覆盖角色类”部分中稍后进行。

我们需要执行的步骤来构建引擎现在非常直接:

  1. 双击.sln文件,在 VS 中打开它。

  2. 现在,在解决方案资源管理器中右键单击 UE4 项目,并选择“设置为启动项目”。

  3. 选择“开发编辑器”作为配置(或“调试游戏”;关于这一点稍后会有更多说明),以及“Win64”作为平台。

  4. 构建项目。这取决于你的硬件配置,可能需要一个小时。本节末尾列出了一些构建建议。

运行编辑器并选择模板

我们下一步是运行编辑器。通过按 F5 在 VS 中启动引擎。如果没有在解决方案中添加游戏或应用程序项目,这将直接带您到虚幻项目浏览器。您也可以在任何时候通过在解决方案资源管理器中右键单击 UE4 项目并直接调试或运行它来轻松完成此操作。我还建议简单地创建一个快捷方式到您的 UE4 安装文件夹的 /Engine/Binaries/Win64/UE4Editor.exe 文件,因为有时在编程 IDE 之外快速启动它可能有益。从项目浏览器中,按照以下步骤操作:

  1. 点击新建项目选项卡,然后在其下方点击 C++ 选项卡。

  2. 选择第一人称图标作为我们的类型,以创建我们的第一人称射击FPS)基础。

  3. 选择一个目标文件夹和一个项目名称,然后点击创建项目。

  4. 如果您选择的项目名称不是 Mastering,请阅读以下信息框。

对于桌面/控制台、质量和起始内容的选择,可以保留它们的默认设置,但请随意将鼠标悬停在其上方并点击下拉箭头,以查看每个选项的选项以及它们的功能简要描述。起始内容实际上是一个虚幻内容包,我们将在后面的章节中手动添加它。

由于这里展示的项目在 GitHub 上设置为 Mastering,因此本书中将使用该名称来引用项目名称。虚幻的模板也使用它来创建添加到项目中的几个基本文件。例如,当提到 MasteringCharacter.h 时,如果您选择了另一个名称,请从模板构建的 (您的项目名称)Character.h 中引用。为了简单起见,建议您直接使用相同的名称。

在此阶段,UE4 将关闭项目浏览器,生成游戏的项目文件,并尝试在 VS 中打开它。自然地,在这个时候,简单地关闭仅引擎的 IDE 会话是个好主意,因为引擎项目也在项目解决方案中打开。正如您所看到的,您命名的项目现在应该是启动项目,并且应该包含几个用于 C++ 模板的源文件。

构建和运行游戏项目

现在,我们终于可以构建并运行我们的游戏了。构建 FPS 示例项目应该会非常快,除非你更改到另一个配置或平台,否则它不会再次需要构建任何引擎代码。一般来说,构建用于测试的 DebugGame 版本是个好主意。使用它,你将获得一些额外的运行时信息和项目代码的安全检查,但通常在测试时不会对性能产生重大影响。所以在这种情况下,我建议使用 DebugGame 编辑器,即使我们是在开发中构建的引擎。DebugGame 编辑器作为一个独立配置(DebugGame),在你的 PC 上运行,将仅在调试模式下构建游戏项目代码,但将继续使用引擎的更快运行的开发配置。例如,将配置更改为 Debug Editor,将迫使引擎完全在调试模式下构建。在调试构建中,引擎在某些区域运行得相当慢,同时维护引擎的调试和开发版本既耗时又通常不必要,除非你实际上正在直接调试引擎代码。一旦项目构建完成,只需使用F5运行它,就像我们之前在仅引擎解决方案会话中做的那样。这将启动编辑器,并将你的游戏作为游戏项目运行。在 UE4 中,所有开发者,包括程序员,在构建游戏或应用时都会在编辑器中进行大量的工作和测试。"在编辑器中播放"(PIE)是 Unreal 的强大功能之一,同样,在编辑器中工作时的游戏库的热重载也是。随着项目的复杂性增加,游戏流程可能从简单的关卡开始,热重载和 PIE 本身可能并不总是测试的有效选项,但在这个部分的我们的工作中,它完美地展示了其优势。一般来说,当你在游戏玩法系统或调试新代码时,PIE 将是你的最佳伙伴。

所以,你说,“听起来很棒!”你可以自由地尝试编辑器默认布局顶部右侧的播放按钮。立即,你会注意到你可以使用传统的 WASD FPS 键盘控制移动,发射武器(当它在关卡中的方块上击中时,会有一些物理影响),甚至可以用空格键跳跃。

在这个早期阶段,考虑你的控制方式总是一个好主意。始终建议为任何游戏类型维护可行的 PC 控制,以便任何团队在编辑器和 PIE 中工作时使用。即使游戏或应用不会使用 PC 作为其原生平台,例如使用移动或 VR 的,PIE 中测试的速度和便捷性使得维护并行 PC 控制非常有价值。实际上,如果你访问游戏项目并打开MasteringCharacter.cpp文件,浏览一些输入代码,你会注意到它专门支持两种转向方法,以对应控制杆或移动设备上的虚拟控制杆,以及直接轴输入,例如鼠标。此外,还有一些注释掉的代码,用于支持在触摸屏设备上的一触移动和转向,例如手机或平板电脑。在下一节中,我们将添加一个新的输入。欢迎你浏览现有的输入,看看已经为各种平台设置和绑定的是什么。只需记住,在这种情况下,通常在稍后为整个平台添加控制比在早期开始维护跨平台控制要容易得多。

使用 C++修改我们的游戏

在本节中,我们将探讨一些快速添加新功能和游戏玩法的方法,通过为这款 FPS 游戏添加一个新的机制:潜行。我们将通过覆盖模板中的一些现有类,并添加新的输入和一些新代码来实现这一点。在本节的结尾,我们将运行游戏,测试我们的代码是否确实按照我们的意愿执行,并看到当输入按下时角色蹲下的游戏结果。我们将按照以下步骤进行:

  1. 从编辑器添加一个新的 C++类。

  2. 修改此类并让它热重载回运行中的编辑器。

  3. 添加一个新的输入和游戏玩法机制,并观察其效果。

覆盖角色类

为了方便我们未来的工作并开始一些良好的实践,我们将通过子类化提供的现有MasteringCharacter本地(用 C++实现)类来在此处添加一些专门的游戏代码。这可以直接在 Visual Studio 中手动完成,但 Epic Games 再次为我们提供了 Unreal 中的快捷方式,我们将从编辑器中使用。因此,让我们从打开到我们的项目,就像我们在上一节中留下的一样开始编辑器。

我们将从内容浏览器窗口开始,该窗口默认情况下通常停靠在编辑器的底部区域。如果您还没有打开,或者由于某些原因已经关闭,只需点击顶部的“窗口”并滚动到内容浏览器,然后是内容浏览器 1,它将作为一个独立窗口重新打开。我会将它停靠在编辑器中您感到舒适的位置,或者,当然,它也可以是一个独立窗口。然而,重要的是左侧的内容。就在“添加新内容”下拉菜单下方,有一个带有三条线和一个小箭头的图标。点击这个图标以打开资源面板,我发现这对于在编辑器中导航内容非常有帮助。在那里,在“内容”下,有一个名为 FirstPersonCPP 的文件夹,在该文件夹中有一个 Blueprints 文件夹。点击该文件夹,您应该在右侧面板中看到一个 FirstPersonCharacter 项。这是当游戏开始时我们正在扮演的角色蓝图表示,由于这个 C++ FPS 模板的方式,游戏要正常运行,必须在地图中有一个实例。这是在 C++ FPS 模板中使用的唯一蓝图之一,但让我们打开它,看看从我们的本地 C++ 代码中的 MasteringCharacter.h/.cpp 文件可以获取哪些内容,这些内容在这里以蓝图表示的形式出现在编辑器中。目前,它看起来就像是一系列变量,顶部有一行以 NOTE: 开头,以一个蓝色链接到“打开完整蓝图编辑器”结尾。关于蓝图及其类以及与 C++ 的交互的更深入讨论将在第三章中进行,蓝图回顾和何时使用 BP 脚本。现在,只需点击蓝色链接,这样我们就可以看到这些变量是如何定义我们在游戏中的角色的。模板的严格 C++ 风格几乎不使用这个蓝图;它实际上只是一系列用于模板的变量。但如果你现在点击顶部的“视图”选项卡,你就可以看到其中一些变量是如何工作的。例如,在右侧的“详细信息”选项卡中,应该有一个名为“相机”的展开菜单。在这个下面,第一个变量是 Base Turn Rate,其值为 45.0,灰色显示。在代码中,这个值用于确定我们的角色可以旋转多快,但它不能被编辑。让我们去看看为什么。

稍微切换回 Visual Studio 查看 MasteringCharacter.h。在类的公共部分之一,您应该看到以下行:

/** Base turn rate, in deg/sec. Other scaling may affect final turn rate. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=Camera)
float BaseTurnRate;

UPROPERTY宏是将 C++变量绑定到蓝图类中的方法,在那里它可以被看到和/或编辑。在这种情况下,VisibleAnywhere BlueprintReadOnly宏中指定的标志是我们可以在蓝图中查看但无法编辑它的原因,而Category=Camera是它位于 Camera 展开菜单下的原因。变量会根据其从 C++中的驼峰命名自动在蓝图上显示,所以每个大写字母在蓝图表示中都是一个单词,例如我们的BaseTurnRate。在MasteringCharacter.cpp文件中,你可以在构造函数中看到BaseTurnRate = 45.f被指定,因此它的值在编辑器中显示。再次强调,所有这些都会越来越多地被使用,但我们需要对即将进行的更改进行快速概述。

我们接下来的步骤将在编辑器中创建我们的游戏构建,因此我们将再次提到同时拥有 VS 2015 和 VS 2017 可能引起的问题。如果你没有安装 VS 2015 并且只使用 VS 2017,请自由跳过本段之后的步骤。

要强制所有内容都在 Visual Studio 2017 中打开,请遵循两个步骤。首先,在 GitHub 的项目readme.md文件中,你会看到如何制作批处理文件的说明,或者使用相同的行通过在命令提示符中复制粘贴来生成你的 VS 2017 项目文件。此外,当编辑器生成这些文件时,如前所述,它将默认为 VS 2015,如果你安装了这两个 IDE,这可能会非常令人烦恼,因为你可能在 VS 2017 中工作,然后编辑器会进行构建并尝试在 VS 2015 中打开你的项目!你将想要转到编辑器顶部的设置选项卡,打开项目设置,然后在平台下滚动到 Windows 展开菜单,如下面的截图所示:

图片

选择 Visual Studio 2017 作为编译器,如前一个截图所示。这应该可以一次性解决不想要的 VS 2015 项目文件问题。这是提交到 GitHub 的内容,如果你只使用 VS 2015 或不想有这种行为,只需转到相同的 Compiler Version 行,将其设置为默认或明确地设置为 2015。

因此,回到主编辑窗口,我们将通过以下步骤添加我们的新类,该类是从MasteringCharacter派生出来的:

  1. 在顶部菜单栏中,点击文件并选择新建 C++类。

  2. 在选择父类窗口中,点击右上角的 Show All Classes 框。

  3. 在搜索框中,开始输入MasteringCh,直到它只显示 MasteringCharacter,然后点击此选项。

  4. 确保 Selected Class 字段显示为 Mastering Character,然后点击下一步。

  5. 将其名称从 MyMasteringCharacter 更改为 StealthCharacter,然后点击创建类。

在步骤 1 中,请注意,你也可以通过在正常内容浏览器窗口中右键单击来从弹出菜单访问“新 C++ 类”选项。此外,在步骤 2 中,随着时间的推移,你将养成立即点击显示所有类框的习惯。Epic Games 默认为新手过滤了一些有用的类,但项目增长后,你通常使用自己的自定义类比这些类多得多。

然后,编辑器应该会告诉你它正在编译新的 C++ 代码,并在底部右端通知你编译成功。关于这个热重载概念的一个重要注意事项是,如果你现在返回 Visual Studio,它将想要重新加载游戏项目解决方案。这将提示你停止调试,如果你选择是,它将关闭编辑器!我经常使用的一种技术是,根据需要将调试器附加到或从编辑器断开连接。在 VS 中,在调试菜单下是断开所有命令(我喜欢将其绑定到 Ctrl + D)。这允许编辑器继续,你可以安全地根据需要多次重新加载解决方案。一旦你想调试一些代码,只需将其重新附加到正在运行的编辑器即可。要做到这一点,回到调试并选择附加到进程(我喜欢将其绑定到 Alt + D)。点击进程的大字段,轻按 U 键,查找 UE4Editor.exe。双击此进程,你就可以立即回到调试状态。

在 VS 中编辑我们的类并热重载编辑器

因此,现在我们的类已经添加了 MasteringCharacter 作为其父类,让我们用 C++ 编辑它,并在编辑器中查看我们的更改。我建议按照上一段描述的方式断开调试器,但如果你决定这样做,停止调试并重新启动编辑器也没有问题。如果你断开了连接,请注意,你需要在解决方案资源管理器中右键单击 Mastering 项目,选择卸载项目,然后再次右键单击它并点击重新加载项目,以确保一切与当前状态匹配(这比关闭和重新打开 VS 快得多)。在解决方案资源管理器中,你现在可以在 Source/Mastering 下找到 StealthCharacter.h.cpp 文件。打开这些文件。目前它们的内容不多,但让我们快速添加一个新变量,这样我们就可以在编辑器中稍后查看它。在 StealthCharacter.h 文件中 GENERATED_BODY() 行之后添加以下行:

public:
        /** Modifier to our turn and pitch rate when in stealth mode */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
        float StealthPitchYawScale = 0.5f;

回到 Visual Studio,我们将在之前添加的 StealthPitchYawScale 变量之后添加以下内容到 StealthCharacter.h 文件中:

public:
        virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;

        virtual void AddControllerPitchInput(float Val) override;
        virtual void AddControllerYawInput(float Val) override;

        void Stealth();
        void UnStealth();

protected:
        bool bIsStealthed = false;

在这里,我们遵循了来自 MasteringCharacter 的模式,你可以进一步学习,但简而言之,我们将一个新的输入绑定到两个函数(StealthUnStealth),然后覆盖基类的函数,使用偏航和俯仰输入来通过我们的比例减慢这些动作。我们将通过在 StealthCharacter.cpp 中添加以下代码来实现这一点:

void AStealthCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
        // Bind jump events
        PlayerInputComponent->BindAction("Stealth", IE_Pressed, this, &AStealthCharacter::Stealth);
        PlayerInputComponent->BindAction("Stealth", IE_Released, this, &AStealthCharacter::UnStealth);

        Super::SetupPlayerInputComponent(PlayerInputComponent);
}

void AStealthCharacter::AddControllerPitchInput(float Val)
{
        const float fScale = bIsStealthed ? StealthPitchYawScale : 1.0f;

        Super::AddControllerPitchInput(Val * fScale);
}

void AStealthCharacter::AddControllerYawInput(float Val)
{
        const float fScale = bIsStealthed ? StealthPitchYawScale : 1.0f;

        Super::AddControllerYawInput(Val * fScale);
}

void AStealthCharacter::Stealth()
{
        bIsStealthed = true;
        Super::Crouch();
}

void AStealthCharacter::UnStealth()
{
        bIsStealthed = false;
        Super::UnCrouch();
}

对于有经验的 UE4 C++程序员来说,这些内容应该都很清楚,但请注意,我们为StealthUnStealth重写的函数调用了我们从其间接派生的ACharacter类中的现有函数。这使用了现有的机制来蹲下和站起我们的角色,从而节省了我们自己制作这些功能的麻烦。你现在可以构建项目,或者添加以下输入后,这会导致编辑器重启,所以请确保你确实保存了我们所做的编辑器更改!

添加我们的新输入绑定有两种方法。最好的方法是再次在主编辑器窗口的设置选项卡中打开项目设置。从这里,滚动到展开菜单中的引擎和输入,你将在右侧看到一个绑定部分,其下是动作映射。点击动作映射右侧的小加号符号,它将在其下方显示一行新内容。我们将将其添加的NewActionMapping重命名为 Stealth,然后点击我们新 Stealth 行右侧的小加号符号。点击无下拉菜单并向下滚动到以下截图所示的左 Shift:

截图

我们现在已将名为 Stealth 的操作绑定到键盘的左Shift键。这将向游戏的/Config/DefaultInput.ini文件中添加一行,并同时更新正在运行的编辑器版本。如果你要手动添加此行,直接在[/Script/Engine.InputSettings]之后(即,+ActionMappings=(ActionName="Stealth", Key= Left Shift))并保存该文件,则引擎不会自动重新加载.ini文件,这需要重新启动编辑器以获取更改,如果你以这种方式修改它!所以,始终记得尝试从设置窗口编辑设置,如果你确实在.ini文件中修改了东西,始终记得重新启动任何正在运行的编辑器或 PC 上的独立游戏版本以获取这些更改。

我们需要在我们的FirstPersonCharacter蓝图中进行最后一次修改,以便将所有内容整合在一起,因此再次在完整的蓝图编辑器窗口中打开它。在左下角,在其组件选项卡下,是CharacterMovement继承的)。点击它。现在右侧有许多属性,但向下滚动直到你看到导航移动展开菜单并打开它。在顶部,有移动能力展开菜单,在那里我们需要勾选Can Crouch复选框,使其为真。注意,靠近左上角的编译按钮从带有绿色勾选标记变为带有橙色问号。点击编译按钮以更新蓝图中的此更改,并再次按Ctrl + S保存。

现在,当运行游戏时,请注意当你按住Shift键时,玩家的视角会向下移动一小段距离,这是由几个其他现有的蓝图父类变量设置的,并且我们的转向和俯仰速度会通过我们的StealthPitchYawScale减慢。成功了!现在,你可以自由地修改俯仰和偏航比例值,即使游戏正在运行,看看我们在潜行时速度提高了多少,减慢了多少。这也是在函数中设置一些断点的好时机,并逐步了解 C++侧的工作方式,但在这个阶段,我们的机制已经到位并得到了验证。

摘要

在本章中,我们从可能没有引擎、没有源代码和没有项目,发展到拥有本地构建的 UE4 引擎和我们的 FPS 项目,并添加了代码和覆盖函数来添加新的游戏玩法。这是一个很好的开始,帮助我们克服了许多制作游戏的障碍,并为后续章节提供了坚实的基础。

接下来,我们将更深入地探讨控制和改进,我们将在这个章节中提到的基本内容上实现。我们还将学习如何添加更多游戏功能,包括库存和武器拾取。之后,将更深入地讨论蓝图及其为我们做什么,为什么它们如此有价值,以及它们何时可能成为问题。在本节初步工作的最后,我们将查看 UI、加载和保存,以及添加一个 AI 生物,然后在接下来的章节中快速进入几个更高级和多样化的主题!

问题

通过以下问题来测试你所学到的内容:

  1. 从源代码构建引擎有哪些优点?

  2. UE4 的源代码在哪里可以找到?

  3. 在构建任何内容之前,获取任何 UE4 更新版本后,总是需要执行哪个步骤?

  4. 在 C++中声明的蓝图变量是如何暴露的?

  5. 我们如何快速添加和测试我们的功能,而无需在编辑器中创建新的蓝图?

  6. 为什么在开发期间使用 DebugGame 作为配置是一个好的选择?

  7. 为什么要求修改.ini文件以添加新功能是一个不好的选择?

  8. 在更改蓝图属性之前,你必须在保存之前执行什么步骤?

进一步阅读

docs.unrealengine.com/en-us/Programming/Introduction

第二章:玩家的库存和武器

简介

欢迎来到我们的下一章。在这一章中,我们将基于到目前为止的 Mastering 项目,引入一个全新的系统来处理库存和更换武器。尽可能的情况下,任何硬编码的系统类型也将在此处被移除,例如从 C++中通过名称引用资产。在做出这样的改变时,通常会有一个关于为什么这对特定项目很重要的讨论。然而,到这一章结束时,我们应该能够以易于修改的方式引用所有我们的类和资产,并且拥有非常快的迭代时间,比如添加新的武器和武器拾取。我们将依次介绍以下主题:

  • 添加Weapon

  • C++中的武器库存

  • 创建和使用WeaponPickup

  • 使用新的控制绑定进行自行车武器

技术要求

在第一章中找到的所有要求,包括其输出 Mastering 项目或处于类似开发阶段的任何项目,都是本章所必需的。

按照我们章节进度分支的主题,本章在 GitHub 上完成的工作可以在这里找到:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-2

使用的引擎版本:4.19.0。

添加武器和库存类

我们本节的目标是将这两个新类添加到我们的游戏中,并将为我们玩家制作的硬编码武器模板转换为新的武器类,并添加到我们新的库存类中。我们将从运行中的编辑器开始,花点时间检查现有的武器,看看它在模板中是如何制作的,以此作为收集设计和新武器类实现所需信息的一种方式。

创建我们的武器类

虽然 FPS 模板为类似的项目提供了一个很好的起点,但它从多个方面来看都是非常有限的。它的目的是提供一个最最小化和无差别的实现,使我们,即开发者,能够按照所需的方向构建。作为我们改进和扩展这个游戏项目的主题,随着我们对新系统和功能的需求出现,这也是我们工作的动力。虽然模板实现的非常简单的武器演示了 FPS 武器的所有核心组件,但它并不容易修改,因此我们需要一个新的类。在一个典型的 FPS 游戏中,你经常会在一个角色之间切换多个独特的武器,所以我们将逐步实现这一点。

要首先了解现有武器的制作方式,我们需要在内容浏览器中再次打开 Content | FirstPersonCPP | Blueprints | FirstPersonCharacter,如果你正在重新打开它,请再次单击“打开完整蓝图编辑器”选项。在主窗口中,点击视口选项卡,当你点击这些其他项目时,你可以看到它们在蓝图中的当前显示或表示方式。但是,首先需要点击的是组件选项卡中的第一个可选项,即 FirstPersonCharacter(self)。这是我们当前使用的整体类,如果你从上一章的结尾继续,它目前设置为StealthCaracter类,当然是从我们的MasteringCharacter派生出来的。在组件选项卡中选择它后,你可以在右侧的详细信息选项卡中看到在游戏玩法和弹体飞出下有几个变量,我们希望将它们移动到新的武器类中:

图片

枪偏移、射击声音、射击动画和弹体类都应该移动到我们的新类中,因为这些当然会根据不同的武器而自然变化。回顾一下组件选项卡,你还可以看到有一个 FP_Gun(继承)组件,以及其下的 FPMuzzleLocation(继承)组件。这些分别是骨骼网格组件和简单的场景组件,但它们也属于武器,而不是直接属于我们的角色。

因此,回到主编辑窗口和内容浏览器,让我们使用第一章中提到的快捷键,为第一人称射击制作 C++项目,然后在主窗口中右键单击以获取包含“新建 C++类”的弹出菜单,并将其添加到顶部。在这种情况下,我们希望父类简单地是 Actor,因此我们不需要点击“显示所有类”选项。选择它,然后像之前一样点击“下一步”。在这里,我们将类命名为MasteringWeapon,然后点击“创建类”。然后我们还会得到一个热重载。一旦完成,就回到 Visual Studio 中添加之前提到的所有变量,这些变量是我们新武器所需的。

转换现有枪支

打开MasteringWeapon.h.cpp文件,让我们再次添加一些变量作为UPROPERTY项,我们的目标现在是在我们的武器中复制MasteringCharacter中当前所做的操作,然后从角色类中删除这些项。作为提醒,我们的武器需要包含以下内容:

  • 枪偏移 (FVector)

  • 射击声音 (USoundBase)

  • 火焰动画 (UAnimMontage)

  • 弹体类 (TSubclassOf<class AMasteringProjectile>)

  • 武器网格 (USkeletalMeshComponent)

  • 枪口位置 (USceneComponent)

在这一点上,由于我们有一些代码工作要做,并且不想频繁地热重载,我建议在准备好添加新的 MasteringWeapon 实例到游戏之前关闭编辑器。那么,让我们开始将这些变量添加到我们的新 .h 文件中。这前四个变量你可以直接从 MasteringCharacter.h 文件中剪切(不是复制,因为我们正在移除它们)并粘贴到在 GENERATED_BODY() 下的公共部分之后,同时在这里添加构造函数的声明:

public:
        AMasteringWeapon();

        /** Gun muzzle's offset from the characters location */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
        FVector GunOffset = FVector(100.0f, 0.0f, 10.0f);

        /** Projectile class to spawn */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Projectile)
        TSubclassOf<class AMasteringProjectile> ProjectileClass;

        /** Sound to play each time we fire */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
        class USoundBase* FireSound;

        /** AnimMontage to play each time we fire */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
        class UAnimMontage* FireAnimation;

然而,请注意,ProjectileClass'UPROPERTY 行已经被设置为与其他行匹配,而在 MasteringCharacter.h 中之前并没有这样做。现在我们只需要我们的骨骼网格和喷口位置;我们将添加以下内容:

/** Muzzle offset */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Projectile)
class USceneComponent* MuzzleLocation;

现在,请记住,我们正在从模板的硬核 C++ 实现中移除所有这些游戏对象,以便我们可以转向一个更合理且更有帮助的混合使用蓝图。因此,由于我们的所有武器都将是这个类的实例,现在让我们先给 GunOffset 赋予它目前相同的默认值(这个变量现在并不很重要),通过将其在 .h 文件中的行更改为以下内容:

FVector GunOffset = FVector(100.0f, 0.0f, 10.0f);

关于蓝图/C++ 平衡的话题将在 第三章 进行更深入的讨论,蓝图审查和何时使用 BP 脚本,但就目前而言,你至少可以认为类蓝图实例是数据的好容器,特别是那些在游戏设计期间将进行调整的事物,例如这里的武器属性。我们现在有了我们将在武器中使用到的所有变量,并且可以为我们的新枪添加蓝图,但到目前为止它不会做任何事情,而且,通过从我们的 MasteringCharacter 类中移除这些变量,当然,现在它将无法编译。因此,我们最好的做法是继续前进,使我们的代码处于更好的位置。回到 MasteringCharacter.h,找到并移除 FP_GunFP_MuzzleLocation 变量。然后,搜索并移除所有对这些变量以及我们从 MasteringCharacter.cpp 文件迁移到 MasteringWeapon.h 的四个变量的引用。我们还可以现在移除 VR_GunVR_MuzzleLocation 变量,因为我们将在最终解决 VR 游戏时创建一个全新的项目,所以这些目前并不重要(但你可以想象在类似的 VR 游戏中它们可能以其他方式转换)。

作为我在这里工作的小贴士,因为我知道我需要在稍后在我的武器类中复制当前在角色类中使用的相同功能,我只是注释掉了这些当前在角色中使用的部分,然后将在每个部分现在由武器处理时完全移除它们。

我们现在也可以从 MasteringCharacter.cpp 中移除这一行,并且我们可以确信我们将在 MasteringWeapon.cpp 中需要它。同样,在 MasteringWeapon.h include 之后剪切并粘贴这一行:

#include "MasteringProjectile.h"

接下来,为了让我们的组件显示在我们的蓝图实例中,我们需要在.cpp文件中的构造函数中添加它们,就像它们在角色的构造函数中创建的那样,并且我们还需要添加一些必需的头文件,从上一行之后开始:

#include "Runtime/Engine/Classes/Components/SkeletalMeshComponent.h"
#include "Runtime/Engine/Classes/Animation/AnimInstance.h"
#include "Kismet/GameplayStatics.h"

AMasteringWeapon::AMasteringWeapon()
{
        // Create a gun mesh component
        WeaponMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponMesh"));
        WeaponMesh->SetOnlyOwnerSee(true); // only the owning player will see this mesh
        WeaponMesh->bCastDynamicShadow = false;
        WeaponMesh->CastShadow = false;
        WeaponMesh->SetupAttachment(RootComponent);

        // Our muzzle offset object
        MuzzleLocation = CreateDefaultSubobject<USceneComponent>(TEXT("MuzzleLocation"));
        MuzzleLocation->SetupAttachment(WeaponMesh);
}

到目前为止,如果我们以这种方式玩游戏,我们的角色将会非常糟糕,因为他们将没有武器,但因为我们需要做一些编辑器工作(添加蓝图实例和另一个新类),这是一个构建游戏并重新启动编辑器的好时机。在此阶段,如果需要检查内容是否匹配,GitHub 的Chapter 2分支上有一个中间提交可用。

创建库存并添加默认枪支

再次回到编辑器中,我们可以着手完成剩下的两个部分,以便回到我们开始时的武器功能级别,同时将功能抽象到新类中:一个基本的库存系统,以及一个实际的武器蓝图实例,就像我们从MasteringCharacter类中移除的那样。

首先,让我们将新的MasteringWeapon添加到内容浏览器中。再次在浏览器的主窗口中右键单击并选择蓝图类:

图片

就像我们创建一个新的 C++类一样,在这里,我们需要点击底部的所有类展开,搜索并单击 MasteringWeapon,然后点击底部的选择按钮。将内容浏览器中的项目重命名为 BallGun,并在 BP 编辑器中打开它。再次,我发现在这里点击完整的蓝图编辑器来查看视口选项卡中的更改是最好的。在“组件”选项卡中,选择 WeaponMesh(继承)并在其详细信息中打开武器网格展开,向下到网格展开,在那里骨骼网格变量有一个可以点击的下拉菜单。可供选择的不多,所以在这里简单地选择SK_FPGun资产。回到“组件”选项卡,接下来选择 WeaponMesh 下的 MuzzleLocation(继承),在“喷嘴位置和变换”展开中,编辑相对位置向量为我们之前硬编码的相同值:0.248.4-10.6

现在,回到“组件”选项卡,在我们刚刚编辑的两个组件上方,再次选择基类,即顶部的 BallGun(self)。在右侧,我们有游戏玩法展开,其中包含我们的声音和动画变量。从下拉菜单中选择它们作为我们唯一可以使用的资产,即 FirstPersonTemplateWeaponFire02 和 FirstPersonFire_Montage。在“弹道”展开中,从下拉菜单中选择 FirstPersonProjectile,并为这把枪构建蓝图现在完成!点击编译按钮,然后保存:

图片

最后,我们只需要设置这个非常基础的库存,然后我们可以回到编写武器使用的代码。再次在内容浏览器中右键单击并点击新建 C++类。我们将通过选择父类为 ActorComponent 来创建一个非常简单的UActorComponent,无论是在过滤组中还是在所有类中搜索中输入。对于名称,使用 MasteringInventory,点击创建类,然后我们再次重建。在MasteringInventory.h中,首先我们可以将此函数标记为未使用(并在.cpp文件中删除实现):

public:  
        // Called every frame
        virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

我们需要添加两个变量和一些函数,就像在BeginPlay()之后添加的那样:

protected:
        // Called when the game starts
        virtual void BeginPlay() override;

public:
        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        TSubclassOf<class AMasteringWeapon> DefaultWeapon;

        /** Choose the best weapon we can of those available */
        void SelectBestWeapon(class AMasteringCharacter *Player);

        /** Select a weapon from inventory */
        void SelectWeapon(class AMasteringCharacter *Player, TSubclassOf<class AMasteringWeapon> Weapon);

        /** Add a weapon to the inventory list */
        void AddWeapon(TSubclassOf<class AMasteringWeapon> Weapon);

        /** Add any default weapon we may have been set with */
        void AddDefaultWeapon();

        /** Get the currently selected weapon */
        FORCEINLINE TSubclassOf<class AMasteringWeapon> GetCurrentWeapon() const { return CurrentWeapon; }

protected:
        TArray<TSubclassOf<class AMasteringWeapon> > WeaponsArray;
        TSubclassOf<class AMasteringWeapon> CurrentWeapon;

注意这里,我们的库存组件将只处理类类型,而不是实际的游戏角色。它们将由MasteringCharacter类在装备时生成。我们将在前述函数的实现被添加到MasteringInventory.cpp之后立即这样做,直接在#include MasteringInventory.h行之后:

#include "MasteringCharacter.h"

// Sets default values for this component's properties
UMasteringInventory::UMasteringInventory()
{
        PrimaryComponentTick.bCanEverTick = true;
}

// Called when the game starts
void UMasteringInventory::BeginPlay()
{
        Super::BeginPlay();

        if (DefaultWeapon != nullptr)
        {
                AddWeapon(DefaultWeapon);
        }
}

void UMasteringInventory::SelectBestWeapon(class AMasteringCharacter *Player)
{
        for (auto WeaponIt = WeaponsArray.CreateIterator(); WeaponIt; ++WeaponIt)
        {
                //TODO: add criteria for selecting a weapon
                {
                        SelectWeapon(Player, *WeaponIt);
                        break;
                }
        }
}

void UMasteringInventory::SelectWeapon(class AMasteringCharacter *Player, TSubclassOf<class AMasteringWeapon> Weapon)
{
        Player->EquipWeapon(Weapon);
}

void UMasteringInventory::AddWeapon(TSubclassOf<class AMasteringWeapon> Weapon)
{
        WeaponsArray.AddUnique(Weapon);
}

MasteringCharacter.h中,我们需要添加三件事。在每个以下添加中,我还在放置新行上方现有的代码行中包含了代码,这样你可以找到放置它们的位置。对于第一个,在uint32 bUsingMotionControllers : 1之后添加UPROPERTY,这个已经在.h文件中存在。然后,在TouchItem之后添加AMasteringWeapon指针。最后,在GetFirstPersonCameraComponent()之后的第三个块的末尾添加两个函数原型:

uint32 bUsingMotionControllers : 1;

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
class UMasteringInventory *Inventory;

class AMasteringWeapon* EquippedWeaponActor;

FORCEINLINE class UCameraComponent* GetFirstPersonCameraComponent() const { return FirstPersonCameraComponent; }
FORCEINLINE class UCameraComponent* GetFirstPersonCameraComponent() const { return FirstPersonCameraComponent; }

/** Equip a weapon */
void EquipWeapon(TSubclassOf<class AMasteringWeapon> Weapon);

/** Get the currently equipped weapon */
FORCEINLINE class AMasteringWeapon* GetEquippedWeapon() const { return EquippedWeaponActor; };

现在,在.cpp文件中,我们需要新的头文件:

#include "XRMotionControllerBase.h" // for FXRMotionControllerBase::RightHandSourceId
#include "MasteringInventory.h"
#include "MasteringWeapon.h"

我们需要在构造函数的底部添加一行,现在我们可以删除之前注释掉的装备和射击旧枪的代码:

Inventory = CreateDefaultSubobject<UMasteringInventory>(TEXT("Inventory"));

我们的BeginPlay()现在也非常简单:

void AMasteringCharacter::BeginPlay()
{
        // Call the base class  
        Super::BeginPlay();

        // Equip our best weapon on startup
        if (Inventory != nullptr)
        {
                Inventory->SelectBestWeapon(this);
        }
}

OnFire()看起来也更简洁;如你所见,它非常紧凑且易于阅读:

void AMasteringCharacter::OnFire()
{
        // try and fire a projectile
        if (GetEquippedWeapon() != nullptr)
        {
                UAnimInstance* AnimInstance = Mesh1P->GetAnimInstance();
                GetEquippedWeapon()->Fire(GetControlRotation(), AnimInstance);
        }
}

最后,在文件底部是装备武器的实现:

void AMasteringCharacter::EquipWeapon(TSubclassOf<class AMasteringWeapon> Weapon)
{
        UWorld *World = GetWorld();
        if (World == nullptr)
                return;

        if (EquippedWeaponActor != nullptr)
        {
                World->DestroyActor(EquippedWeaponActor);
        }

        const FRotator SpawnRotation = GetActorRotation();
        const FVector SpawnLocation = GetActorLocation();
        FActorSpawnParameters ActorSpawnParams;
        ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
        ActorSpawnParams.Owner = this;

        EquippedWeaponActor = Cast<AMasteringWeapon>(World->SpawnActor(Weapon, &SpawnLocation, &SpawnRotation, ActorSpawnParams));
        if (EquippedWeaponActor != nullptr)
        {
                //Attach gun mesh component to skeleton
                EquippedWeaponActor->AttachToComponent(Mesh1P, FAttachmentTransformRules(EAttachmentRule::SnapToTarget, true), TEXT("GripPoint"));
        }
}

注意,实际的生成位置和旋转实际上并不重要,因为我们立即将成功生成的武器附加到我们的网格上,但通常有一个合理的默认值是安全的。

到目前为止,我们回到MasteringWeapon文件,首先是.h 文件,我们在类底部附近添加这一行:

public:
        /** Fire the weapon */
        void Fire(FRotator ControlRotation, class UAnimInstance* AnimInst);

然后在.cpp文件中按照如下方式实现,以完成我们之前在角色中做的所有工作:

void AMasteringWeapon::Fire(FRotator ControlRotation, class UAnimInstance* AnimInst)
{
        // try and fire a projectile
        if (ProjectileClass != nullptr)
        {
                UWorld* const World = GetWorld();
                if (World != nullptr)
                {
                        // MuzzleOffset is in camera space, so transform it to world space before offsetting from the character location to find the final muzzle position
                        const FVector SpawnLocation = ((MuzzleLocation != nullptr) ? MuzzleLocation->GetComponentLocation() : GetActorLocation()) + ControlRotation.RotateVector(GunOffset);

                        //Set Spawn Collision Handling Override
                        FActorSpawnParameters ActorSpawnParams;
                        ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButDontSpawnIfColliding;

                        // spawn the projectile at the muzzle
                        World->SpawnActor<AMasteringProjectile>(ProjectileClass, SpawnLocation, ControlRotation, ActorSpawnParams);
                }
        }

        // try and play the sound if specified
        if (FireSound != nullptr)
        {
                UGameplayStatics::PlaySoundAtLocation(this, FireSound, GetActorLocation());
        }

        // try and play a firing animation if specified
        if (FireAnimation != nullptr)
        {
                // Get the animation object for the arms mesh
                if (AnimInst != nullptr)
                {
                        AnimInst->Montage_Play(FireAnimation, 1.f);
                }
        }
}

现在,我们只剩下添加一个默认武器到我们特定玩家的库存组件中,这样我们就可以重新开始工作了。在编辑器中再次打开 FirstPersonCharacter BP。在其组件选项卡的底部现在是我们继承的 Inventory(库存)。选择它,在细节选项卡下,在游戏玩法、库存和 Mastering Inventory 展开项下,点击默认武器下拉菜单并选择 BallGun。现在,玩游戏应该看起来和之前一样,但是有一个可以接受各种新武器的系统,只需通过添加更多的 MasteringWeapon 蓝图资产并将它们添加到我们的库存中进行选择即可,这正是我们下一节要关注的内容。

这是个很好的时机,花点时间在这些新函数中设置一些断点并逐步执行,确保在调试器中一切看起来都正确。注意,如果你尝试逐步执行一些函数,它却直接跳过了它们,检查你是否在开发编辑器中构建游戏,并将其设置为 DebugGame 编辑器。通常,这些较小的函数在开发过程中会被优化掉。

添加一个 WeaponPickup 类

现在我们已经有一个工作状态的库存(即使是最基本的),以及一个用于创建新武器的武器类,我们还需要一个关键的部分:一种将这些新物品添加到库存中的方法。目前已经有函数准备好接收这些内容,但如何以有意义的方式将这些内容引入游戏呢?嗯,根据游戏类型和设计需求,这里有多种方法。在以下情况下,我们将坚持一种相当传统的 FPS 风格游戏,并添加一个可以在关卡设计期间或动态地添加到我们的游戏世界中的武器拾取。

创建一个新的 actor 类

如前所述,不同游戏在这个领域有不同的设计需求。如果你的游戏,例如,只有武器物品的槽位(许多流行的 3D 动作游戏就是这样),那么我们实际上并不需要一个库存类,也不需要一个武器拾取类。你可以通过在你的玩家类中添加几个AMasteringWeapon指针来节省一些麻烦,并在那个武器中重写AAcotr 的 Tick(float DeltaSeconds)函数,以赋予它在地面上的行为,并在它被存储和隐藏或被玩家装备时停止该行为。在这种情况下,我们有一个非常灵活和开放的系统,可以用于同时存储大量不同类型的武器。在这里添加拾取的原因是为了展示当物品在地面上的不同行为,并添加一些有助于扩展我们库存有用性的额外数据。正如许多动作 FPS 游戏一样,我们将给拾取物品一个在地面适当高度上的可见旋转,设置其碰撞设置以便玩家可以拾取它,并给我们的武器添加弹药计数和功率级别,这样我们就可以在可用时自动装备最好的物品,或者当装备的物品变得不可用时。当然,拾取物品也会引用它所代表的 MasteringWeapon,所以我们将快速构建一个新的,使用一个新的 MasteringProjectile 来区分它,因为我们目前正在处理有限的资源。

对于好奇的人来说,是的,这本书将在未来的章节中深入探讨一些来自市场的一些奇妙但免费的 UE4 资源,以解决高级视觉效果,并在我们的样本游戏中为角色提供一些多样性。然而,目前,我们只是简单地使用 FPS 模板中已有的资源,快速建立这些核心游戏概念。

在这个阶段,创建一个新的类已经相当熟悉了,所以希望我们可以稍微加快步伐。如果任何内容看起来不正确或者不符合预期,自然地,快速浏览一下之前关于创建新类的步骤,然后开始着手其功能性的开发是个好主意。因此,回到编辑器中,让我们再添加一个新的 C++类,以简单的 Actor 作为父类。我们将它命名为MasteringWeaponPickup,与之前我们处理过的更复杂的类相比,其实施应该会相当迅速。

我们需要决定实际拾取功能在哪里实现。在这个阶段,让我们花点时间回顾一下封装的概念。对于可能经历过 Unreal Engine 3 的读者来说,大多数人都有类似的经历,就是查看 Actor.h 和 Actor.cpp 文件,觉得自己的眼睛都要看花了。这些文件,无意中提到的,是史诗级的文件,包含成千上万行代码,为游戏中的每个演员(无论多么简单)实现了无数可能的在游戏中的行为。这些文件仍然有点大,但多亏了 Epic 在 UE3 和 UE4 之间所做的巨大努力,他们成功地将大量专用代码移动到组件和特定插件中,大大减少了此类广泛使用的类的代码量和变量数。让拾取功能工作最简单的方法就是将其直接放入我们的MasteringCharacter类中,并让我们的角色与拾取物碰撞来完成所有工作。然而,如果你开始为添加到中型或大型项目中的所有系统这样做,你的角色类就会发现自己处于 UE3 的 actor 文件那样的状态,新团队成员将很难弄清楚功能在哪里,并且经常需要熟悉整个庞大的类才能熟练地进行修改。如果我们把需要的功能封装到通常“拥有”那种游戏行为的物品中,那么学习项目会容易得多,修改的风险也会降低,对于团队中的新开发者来说,范围管理也会更加容易。

因此,在这种情况下,让我们早点开始这种做法,尽可能地将拾取功能放入我们的MasteringWeaponPickup类本身。以下是我们在头文件主体中需要的函数和变量集:

public:  
        // Sets default values for this actor's properties
        AMasteringWeaponPickup();

protected:
        // Called when the game starts or when spawned
        virtual void BeginPlay() override;

        virtual void NotifyActorBeginOverlap(AActor* OtherActor) override;

public:       
        // Called every frame
        virtual void Tick(float DeltaTime) override;

        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        TSubclassOf<class AMasteringWeapon> WeaponClass;

        /** How fast the pickup spins while on the ground, degress/second */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
        float RotationSpeed = 30.0f;

        /** How much ammunition is provided for this weapon on pick-up */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
        uint8 Ammunition = 10;

        /** This weapon's relative weapon power to compare it to others */
        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Gameplay)
        uint8 WeaponPower = 1;

关于末尾的uint8项的快速说明:长期以来,已知只有这一种类型被蓝图中的公开类所支持。添加uint16uint32类型将需要 Epic 在多个系统上做大量的工作,因此这从来不是他们功能列表中的高优先级(工作量与对开发者的好处不成比例)。在这种情况下,这没有问题,因此我们可以利用 8 位版本的优势,强制所有蓝图实例中的值始终在 0-255 之间。如果你需要一个更大的范围,你被迫使用正常大小的(但带符号的)int,然后检查这些蓝图的设计师是否输入了无效值的负担就落在了代码的一边。因此,本书中完成的所有工作中将贯穿的一个主题是,根据你的设计调整你的需求。这里的武器功率只是一个任意值,因此我们可以区分哪些武器比其他武器更好。根据武器的数量,这样的基本和抽象的东西可能非常适合你的游戏,或者可能应该从各种伤害和速度等因素计算得出。

作为顶级开发者,良好的沟通能力至关重要,与设计师一起讨论一个系统,并且(根据项目规模)将这些内容放入维基页面或其他文档存储源中,这是关键。一个夸张的例子是:如果我们确信我们的游戏永远不会使用超过一种武器,那么所有这些库存和武器抽象在很大程度上都是不必要的。由于我想展示我们如何使其能够扩展到大量武器,这就是我们尽早并尽可能封装地设置这些系统的动机。

那么,接下来,这里是在拾取项的.cpp文件中的实现:

#include "MasteringCharacter.h"
#include "MasteringInventory.h"

// Sets default values
AMasteringWeaponPickup::AMasteringWeaponPickup()
{
   // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
        PrimaryActorTick.bCanEverTick = true;

}

// Called when the game starts or when spawned
void AMasteringWeaponPickup::BeginPlay()
{
        Super::BeginPlay();

}

void AMasteringWeaponPickup::NotifyActorBeginOverlap(AActor* OtherActor)
{
        AMasteringCharacter *player = Cast<AMasteringCharacter>(OtherActor);

        if (player == nullptr)
        {
                return;
        }

        UMasteringInventory *Inventory = player->GetInventory();

        Inventory->AddWeapon(WeaponClass, Ammunition, WeaponPower);

        // here we automatically select the best weapon which may have changed after adding the above,
        // NOTE: this should probably be an option the player can turn on and off via UI
        Inventory->SelectBestWeapon();

        // and now that we've done our job, destroy ourselves
        Destroy();
}

// Called every frame
void AMasteringWeaponPickup::Tick(float DeltaTime)
{
        Super::Tick(DeltaTime);

        FRotator rotationAmount(0.0f, DeltaTime * RotationSpeed, 0.0f);

        AddActorLocalRotation(rotationAmount);
}

如您所见,这相当简单。它的大部分功能是在地面上以由RotationSpeed指定的速度旋转,允许玩家拾取,然后自行销毁。我们将在设置实例时讨论其碰撞过滤,但请注意,我们将 OtherActor 指针强制转换为 mastering character 类型:这当然适用于子类型(例如我们的StealthCharacter类),但任何其他Pawn类将无法进行转换,返回 null,因此(如下一行所示)我们现在可以忽略它们。

设置我们的蓝图

接下来,我们需要一个全新的武器。由于同样地,目前我们在艺术资产方面几乎没有什么可以利用的,所以我们将使用那些相同的艺术资产进行创造性的展示。在“BallGun”武器蓝图上右键点击并选择“Duplicate”(复制)。将复制的副本重命名为“BigBallGun”,然后重复同样的操作,复制“FirstPersonProjectile”蓝图并将其重命名为“BigFirstPersonProjectile”。我们将快速编辑这些蓝图,使它们准备好可以被添加到玩家的库存中并使用。

打开它们的完整蓝图。在投射物的组件中,选择“投射物移动”(继承),在右侧你会看到投射物飞出。将速度设置为大约是其当前值的两倍,即 6,000,以便与其它投射物相比,真正给这个投射物一些冲击力。在其“碰撞组件”(继承)下的“变换飞出”中,将其比例设置为 2。我更喜欢尽可能点击右侧的锁定图标,因为我们在这里进行的是通用缩放,所以你只需要输入一次数字,而不是在所有三个组件中输入。然后,点击其“碰撞组件”(继承)下的“StaticMesh1”组件,在这里我们进行一个小小的视觉更改:在其“静态网格材质”(飞出)下,选择下拉菜单并将其设置为 M_FPGun。这种材质的纹理显然是为枪支设置的,而不是球体,但它使我们的新子弹变成银色,真正有助于区分它,并让我们知道我们正在发射哪种枪(因为在这个阶段,枪支本身看起来是相同的)。这两个组件都设置好后,现在回到 BigBallGun 蓝图窗口,点击其类(BigBallGun(self)),在组件下。现在,在“投射物飞出”下,我们自然将其设置为我们的新 BigFirstPersonProjectile。

武器和投射物设置好后,现在我们终于可以设置我们的武器拾取了。在内容浏览器中,创建一个新的蓝图类,显示所有类,并选择我们的 MasteringWeaponPickup。将其命名为 BigBallGunPickup,双击它进行检查。点击 BigBallGunPickup(self),在游戏玩法下,你可以随意更改这个拾取的旋转速度或弹药数量;然而,请确保至少将武器功率更改为 1 或更大,以便它可以在代码中添加的默认枪支之上“排序”。

目前应该只包含(自我)组件和一个场景组件。我们将保留场景组件,现在直接使用一些蓝图工作作为下一章进一步蓝图讨论的准备。当然,我们可以像模板至今所做的那样,在构造函数中添加我们即将使用的这些组件,但在这里在蓝图中进行定制要快得多也容易得多。但再次强调,根据您项目的需求,C++的方式可能是一个更好的选择。按照这里的方式操作不需要编写代码,但每次创建新的拾取时,都需要手动添加这些组件。

这是项目管理中专家开发者会知道如何平衡的另一个领域:团队的工作带宽与项目目标相比如何?例如,如果游戏有许多系统设计师,他们舒适地在蓝图上工作,那么简单地允许他们多次执行重复工作,在蓝图添加这些组件,而不需要编码帮助,可能是有意义的。另一方面,如果程序员自己可能需要设置这些组件,且他们的时间有限(提示:总是如此!),那么最好将这些组件添加到构造函数中,以减少制作这些拾取所需的设置时间。在承诺此类工作流程之前,始终牢记您团队的能力。

回到拾取,我已在此处重命名场景组件,以便我知道它实际上只是为了从地面创建偏移。现在单击那个场景组件(顶部组件)并单击顶部的添加组件按钮。选择骨骼网格作为类型(您可以将其重命名为 BallGunMesh)。

注意:在大型游戏中,很可能会在这里使用单独的艺术资产,因此您会在拾取上使用静态网格,这是一个简化版本,而不是手持和装备的动画角色,例如枪;但再次强调,我们使用我们拥有的。

在网格的详细信息面板中,找到网格展开项并将其设置为SK_FPGun。同样,将其材质设置为M_FPGun。这样,当放置在地面上时,它实际上不会完全平放在地面上,在其变换展开项下,将位置设置为大约 50.0 个 Z 单位,这样它就离地面半米高。现在,我们需要单击网格并为其添加一个子组件,以便整个拾取可以被,嗯,拾取!再次单击绿色的添加按钮,这次添加一个球体碰撞组件。出于良好的实践,我们只需更改其中的一件事:在其碰撞展开项下,将下拉菜单中的碰撞预设更改为 OverlapOnlyPawn:

图片

这样做允许碰撞系统在决定是否发生碰撞(或,具体到我们的案例,重叠,这意味着物品可以穿过它)时忽略所有对象,但会接收到重叠发生的事件,"击中"事件是阻止移动的事件。这样,像我们的投射物这样的东西就不必由碰撞系统考虑,只需考虑四处走动的 NPC,这始终非常重要,尽可能限制这些交互以保持性能。最后,将我们的 BigBallGunPickup(内容浏览器中的图标)拖放到主级别编辑器窗口中,其中显示环境(箱子墙壁)并将它放在地面上。如果您喜欢,可以放置几个,这样我们就可以四处走动并拾取它们。

回到代码以完成

在编辑器和级别中设置好一切后,我们还需要进行一些代码更改。首先,我们在MasteringInventory.h中做一些更改:

USTRUCT()
struct FWeaponProperties
{
        GENERATED_USTRUCT_BODY()

public:

        UPROPERTY()
        TSubclassOf<class AMasteringWeapon> WeaponClass;

        UPROPERTY()
        int WeaponPower;

        UPROPERTY()
        int Ammo;
};

我们将这个 struct 添加到文件的顶部,紧接在 #include 头部部分之后。我们将从现在开始使用这个 struct 在库存列表中引用武器,而不是直接引用 MasteringWeapon 类(但,当然,现在它是 struct 中的一个成员,供我们使用)。我们使用 struct 来跟踪与武器相关联的两个新属性:弹药和武器功率。

接下来,在 DefaultWeapon 变量之后,修改前三个函数(我们移除了传递玩家以选择武器的操作,并在添加武器时添加了弹药和功率):

   /** Choose the best weapon we can of those available */
        void SelectBestWeapon();

        /** Select a weapon from inventory */
        void SelectWeapon(TSubclassOf<class AMasteringWeapon> Weapon);

        /** Add a weapon to the inventory list */
        void AddWeapon(TSubclassOf<class AMasteringWeapon> Weapon, int AmmoCount, uint8 WeaponPower);

        /** Get the currently selected weapon */
        FORCEINLINE TSubclassOf<class AMasteringWeapon> GetCurrentWeapon() const { return CurrentWeapon; }

        /** Change a weapon's ammo count, can't go below 0 or over 999 */
        void ChangeAmmo(TSubclassOf<class AMasteringWeapon> Weapon, const int ChangeAmount);

protected:
        TArray<FWeaponProperties> WeaponsArray;
        TSubclassOf<class AMasteringWeapon> CurrentWeapon;
        int CurrentWeaponPower = -1;
        class AMasteringCharacter* MyOwner;

MasteringInventory.cpp 文件中,添加以下内容:

#define UNLIMITED_AMMO -1

// Sets default values for this component's properties
UMasteringInventory::UMasteringInventory()
{
        PrimaryComponentTick.bCanEverTick = true;

        MyOwner = Cast<AMasteringCharacter>(GetOwner());
        check(GetOwner() == nullptr || MyOwner != nullptr);
}

// Called when the game starts
void UMasteringInventory::BeginPlay()
{
        Super::BeginPlay();

        if (DefaultWeapon != nullptr)
        {
                // NOTE: since we don't use a pick-up for the default weapon, we always give it a power of 0
                AddWeapon(DefaultWeapon, UNLIMITED_AMMO, 0);
        }
}

在顶部,紧接我们的 #include 部分下方,注意这里使用的 #define,它清楚地表明当我们看到这个定义时,一个武器具有无限弹药。这比直接使用 -1 更安全,因为新团队成员可能不理解在其他代码区域看到 -1 的神奇属性。在构造函数中,你还会注意到我们进行了一个小技巧,因为我们知道这个对象是一个组件,因此在使用时必须有一个拥有者(actor),我们将这个拥有者保存为 MyOwner。如果你在那里设置一个断点,你会注意到当关卡编辑器启动时,你会得到一个拥有空拥有者的实例。这是正在构建的默认对象,因此无需担心。然而,请注意 check。它,嗯,检查那个情况,但随后会断言是否有人试图将库存组件添加到不是 MasteringCharacter 的任何东西上,只是为了确保安全。这个检查非常少(每次创建库存时只进行一次),所以作为一个普通的 check 是可以的。下面,有一个 checkSlow,我将仅为了对比进行解释。这行代码保存了一个已经转换为正确类型的拥有者指针,这在我们稍后添加武器时节省了一些时间,并且我们也不必将这些函数中的 MasteringCharacter 指针传递来传递去,因为,再次强调,我们知道每个玩家只有一个库存,每个库存都是玩家的一部分。我们还添加了默认武器,具有无限弹药和 0 的武器功率。在此之后,我们基本上替换了旧的 SelectBestWeaponSelectWeaponAddWeapon,使它们现在看起来像这样:

void UMasteringInventory::SelectWeapon(TSubclassOf<class AMasteringWeapon> Weapon)
{
        MyOwner->EquipWeapon(Weapon);
}

void UMasteringInventory::AddWeapon(TSubclassOf<class AMasteringWeapon> Weapon, int AmmoCount, uint8 WeaponPower)
{
        for (auto WeaponIt = WeaponsArray.CreateIterator(); WeaponIt; ++WeaponIt)
        {
                FWeaponProperties &currentProps = *WeaponIt;
                if (currentProps.WeaponClass == Weapon)
                {
                        checkSlow(AmmoCount >= 0);
                        currentProps.Ammo += AmmoCount;
                        return; // our work is done if we found the gun already in inventory, just update ammo
                }
        }

        FWeaponProperties weaponProps;
        weaponProps.WeaponClass = Weapon;
        weaponProps.WeaponPower = WeaponPower;
        weaponProps.Ammo = AmmoCount;

        WeaponsArray.Add(weaponProps);
}

void UMasteringInventory::ChangeAmmo(TSubclassOf<class AMasteringWeapon> Weapon, const int ChangeAmount)
{
        for (auto WeaponIt = WeaponsArray.CreateIterator(); WeaponIt; ++WeaponIt)
        {
                FWeaponProperties &currentProps = *WeaponIt;
                if (currentProps.WeaponClass == Weapon)
                {
                        if (currentProps.Ammo == UNLIMITED_AMMO) // unlimited ammo gun, we're done
                                return;

                        currentProps.Ammo = FMath::Clamp(currentProps.Ammo + ChangeAmount, 0, 999);
                        if (currentProps.Ammo == 0) // gun is now empty!
                        {
                                CurrentWeaponPower = -1; // force us to select any better weapon that does have ammo
                                SelectBestWeapon();
                        }
                        return; // our work is done if we found the gun already in inventory, just update ammo
                }
        }
}

注意,更改弹药使用的是非 const 迭代器和它迭代的结构的引用。这样,我们就可以直接修改数组中存在的结构中的弹药计数。始终要小心,因为它很容易简单地使用迭代器中的局部变量,进行更改,但不会像你希望的那样在数组中保存!也请注意 checkSlow。添加武器并不经常发生,所以它完全可以用作正常的 check 以提高性能。我只是把它放在那里提醒自己讨论它们。checkSlow 只在调试构建中使用,所以如果内容开发者使用开发构建,它们永远不会断言(如果发生,可能会让艺术家和设计师感到烦恼,因为它基本上会导致崩溃,他们对此无能为力)。所以请明智地使用它们。

在任何可能发生在实际构建中并且可能非常严重的检查情况下,始终使用 checkf 变体,因为这将也将你格式化到其字符串输出的任何内容输出到游戏的 .log 文件中。这样,如果一个玩家有游戏的实际版本并且遇到问题,或者发布构建崩溃,你可以查看那个日志,它可能会立即给你一个关于发生了什么的线索。

最后,继续我们的封装主题,当武器消耗弹药时(正如枪支在射击时,我们的游戏中的库存负责跟踪弹药),我们将让武器通知库存。当枪支耗尽弹药时,库存将自动尝试选择下一个最佳选项,由于我们的默认武器弹药无限,我们总是知道至少有一样东西可用。当然,这在不同类型的游戏中可能会有很大的不同,当弹药耗尽并尝试射击时会有“干火”声音,或者当弹药耗尽时从枪支切换到近战攻击。在这里,我们将添加这个参数到我们的射击函数中:

void Fire(FRotator ControlRotation, class UAnimInstance* AnimInst, class UMasteringInventory* Inventory);

我们将在 Fire 函数的底部按照如下方式在 .cpp 中实现它:

// reduce ammo by one
Inventory->ChangeAmmo(GetClass(), -1);

就这样!我们现在可以在我们的关卡中四处走动,捡起大枪,并看着它的弹丸真的让那些箱子飞起来...直到它耗尽弹药,我们回到默认枪支。

利用我们的库存

别担心,所有困难的部分都已经完成了!但我们仍然有一个最后的重要任务,虽然挑战性不大,那就是让这个库存更像游戏中可行的普通库存:交换武器。我们需要一个输入来循环切换它们,记住我们目前会在拾取时自动装备你当时拾取的最佳武器,目前还没有选择其他武器的办法。

添加控制以循环切换武器

就像之前添加潜行输入并将其绑定到左 Shift 键时一样,让我们回到编辑器的项目属性和引擎/输入/绑定飞出菜单。在这里,我们将使用 + 按钮添加两个动作映射,并将它们命名为 InventoryUp 和 InventoryDown。我喜欢将它们分别绑定到鼠标滚轮向上和向下,但您可以选择任何您喜欢的输入。我们现在有了循环的手段,只需要像我们的潜行机制一样在我们的角色中再次设置它。

为我们的角色添加武器交换功能

首先,我们需要在我们的库存中添加循环功能。有几种方法可以实现这一点,例如,您可以根据物品进入时的功率对数组进行排序并存储当前武器的数组条目。这样做存在一些风险,但会使武器切换非常快速且清晰。这里的实现可能有些粗糙,但并不是至关重要的。提前了解系统将被广泛使用对于长期健康发展非常重要。如果一个系统有一个非常粗糙但完全功能性的实现,并且不太可能在整个项目生命周期中被其他代码区域更改或重用,那么就让它保持原样。我们都希望始终编写完美的代码,但代码往往来自外部贡献者或经验较少的开发者。虽然坚持最佳实践始终是鼓励的,但了解何时不需要过度设计有限范围或用途的东西也很重要;只需让它工作并继续前进。游戏开发依赖于速度,而且没有问题,可以回顾在特定要求(例如“游戏将只会有最多两把武器”)下编写的代码(改为“我们现在需要为玩家提供 10 把或更多的武器!”)。尽可能地为它们所做的工作进行清理和构建。这正是许多开发者常见的经典缺陷,即没有意识到某些东西已经足够好,即使它不是完美的,或者不是我们理想中的样子。所以,有了这个巨大的免责声明,以下是本节结束时的粗糙库存循环代码:

int UMasteringInventory::FindCurrentWeaponIndex() const
{
        int currentIndex = 0;
        for (auto WeaponIt = WeaponsArray.CreateConstIterator(); WeaponIt; ++WeaponIt, ++currentIndex)
        {
                const FWeaponProperties &currentProps = *WeaponIt;
                if (currentProps.WeaponClass == CurrentWeapon)
                        break;
        }

        checkSlow(currentIndex < WeaponsArray.Num());

        return currentIndex;
}

void UMasteringInventory::SelectNextWeapon()
{
        int currentIndex = FindCurrentWeaponIndex();

        if (currentIndex == WeaponsArray.Num() - 1) // we're at the end
        {
                SelectWeapon(WeaponsArray[0].WeaponClass);
        }
        else
        {
                SelectWeapon(WeaponsArray[currentIndex + 1].WeaponClass);
        }
}

void UMasteringInventory::SelectPreviousWeapon()
{
        int currentIndex = FindCurrentWeaponIndex();

        if (currentIndex > 0) // we're not at the start
        {
                SelectWeapon(WeaponsArray[currentIndex - 1].WeaponClass);
        }
        else
        {
                SelectWeapon(WeaponsArray[WeaponsArray.Num() - 1].WeaponClass); // select the last
        }
}

将功能的原型添加到您的 .h 文件中。然后,在 AMasteringCharacter::SetupPlayerInputComponent 中的所有先前输入绑定下方添加以下这些行:

// Cycling inventory
PlayerInputComponent->BindAction("InventoryUp", IE_Pressed, this, &AMasteringCharacter::SelectNextWeapon);
PlayerInputComponent->BindAction("InventoryDown", IE_Pressed, this, &AMasteringCharacter::SelectPreviousWeapon);

在角色中添加以下这些函数(它们的原型靠近其他控制处理函数在头文件中):

void AMasteringCharacter::SelectNextWeapon()
{
        Inventory->SelectNextWeapon();
}

void AMasteringCharacter::SelectPreviousWeapon()
{
        Inventory->SelectPreviousWeapon();
}

如果您要测试类似的事情,它看起来部分工作然后又失败了。在代码中逐步执行,之前不重要的某一行缺失了,但看到 CurrentWeapon 总是 null,很容易在 SelectWeapon 中添加最后一行。保持这些调试技能始终处于良好状态总是很棒的!

void UMasteringInventory::SelectWeapon(TSubclassOf<class AMasteringWeapon> Weapon)
{
        MyOwner->EquipWeapon(Weapon);
        CurrentWeapon = Weapon;
}

将所有内容整合在一起

现在,我们的库存和拾取都已经完成。如果你启动编辑器,你可以逐步执行循环上下代码,以确保一切正常工作。如果默认武器是我们唯一的武器,我们会重复装备它,但角色中的EquipWeapon函数已经处理了尝试从武器切换到相同武器类并返回顶部的操作。我们现在有一个完全功能的库存,我们可以用它来循环我们的武器,尽管只有两个,上下都可以。任务完成!

摘要

我们从硬编码的武器开始,通过将一些组件和输入固定到角色上。现在我们有一个系统来制作无限独特的武器类型,为它们制作一个酷炫的拾取物品,将它们添加到库存中,切换到最佳武器,跟踪弹药,当一种武器弹药耗尽时自动切换到另一种武器,并按我们的需求循环这些武器。这只是许多将在小型游戏项目中实现的游戏系统的一个例子,但它已经展示了几个设计(从编码的角度来看)以及游戏设计和它们如何结合在一起的基本原理。永远不要过度设计你不需要的东西,但始终为未来做计划,并确保你的所有需求都得到满足。我们还直接在蓝图及其编辑器上做了一些工作。在下一章中,我们将深入探讨蓝图能做什么,并更深入地了解它是如何工作的。讨论的一个主要点将是游戏如何(并且确实!)仅使用蓝图脚本而没有项目 C++代码来构建。这种方法有很多风险,但对于小型项目或与有限资源合作以展示某些内容,它可以是一个巨大的好处,而且这些好处甚至在我们这样的 C++项目中也存在,如果你知道何时使用它们的话。

问题

  1. 我们有没有合理的办法可以在角色上交换武器,就像模板最初设置的那样?

  2. 为什么要把射击功能从角色移到我们的武器类中?

  3. 为什么我们只存储 actor 类,而不是 actor 本身在库存中?

  4. 武器拾取类的目的是什么?为什么不用武器直接作为拾取使用?

  5. 在代码中向类添加组件与在蓝图实例中添加组件相比有什么优势?

  6. checkcheckSlowcheckf变体之间有什么区别?

  7. 哪一个应该始终用于实时游戏构建以检查关键代码?

  8. 为什么在它自己的函数中查找当前武器索引?

进一步阅读

docs.unrealengine.com/en-us/Programming/Assertions

第三章:蓝图审查及何时使用 BP 脚本

简介

欢迎来到我们的下一章。这将是我们第一次从专注于 C++代码的焦点中跳出来,但不是最后一次。Unreal 拥有许多具有强大功能且无需编写代码即可从编辑器访问的系统。最灵活且与实际编写代码最紧密相关的是在这里探索的,深入探讨 UE4 的蓝图系统和其功能与局限性。到目前为止,我们的大部分工作都是用 C++实现事物,并尽可能展示这些是如何作为编辑器中的蓝图对象进行交互的。蓝图的功能远不止于此,包括制作整个游戏。在本章中,我们将涵盖:

  • 蓝图审查/概述

  • 仅使用蓝图的游戏,优缺点

  • 使用蓝图编写对象(电梯)

  • 蓝图技巧与窍门

技术要求

如同上次,本章的工作将通过利用第二章,玩家的库存和武器中完成的内容来完成。然而,这并不是严格必要的,这里的内容应该可以使用第一章,为第一人称射击游戏创建 C++项目的项目或甚至是一个新的 UE4 模板来实现。

如同往常,本章的 GitHub 分支如下:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-3

使用的引擎版本:4.19.0。

蓝图审查和仅使用蓝图的游戏

合理地提出问题:究竟什么是“蓝图”?实际上有两个主要领域需要关注。第一个我们在之前已经涉及并使用过:将 C++类与 UE4 编辑器集成。在编辑器中使用的类通常在某个级别上派生自UObject,这使得它们可以利用诸如UPROPERTYUFUNCTION宏等特性,从而允许这些类通过蓝图实例访问,例如StealthCharacter。除了少数基本几何形状和其他基本游戏对象外,几乎所有直接放置在关卡中的内容,无论是你自己、设计师还是艺术家,都将是一个蓝图类的实例,例如现在关卡中的我们的角色。另一个需要关注的是每个项目都应该根据其需求评估的领域是蓝图可视化脚本BVS)系统。几乎所有你能在 C++中想到的游戏玩法概念在蓝图脚本中都是可能的。它还集成了你可以在运行时使用断点调试的调试器。在这里,我们将更深入地探讨这些概念,并查看脚本的优势和局限性。

蓝图概述

正如我们在前几章中多次展示的那样,在许多相同类类型的实例中公开将要迭代的变量或使变量独特化的优势相当明显:这些变量可以在编辑器运行时被没有任何编程知识的创作者修改。特别是系统设计师依赖于公开正确的变量,以便他们可以快速调整和构建系统的变体,并同样快速地进行测试。这意味着从 C++作为 FPS 模板开始,几乎完全这样做,改变这些值在最好的情况下需要编译并在编辑器中执行热重载游戏,在最坏的情况下则需要关闭编辑器、构建并重新启动它。这与下一段讨论的蓝图脚本类似,蓝图在游戏中的许多公开和利用最终都取决于你团队和项目的需求。如果你的游戏类的设计几乎肯定在整个项目过程中不会改变或只会发生非常小的变化,并且你有足够的 C++程序员作为资源,那么以像 FPS 模板中为我们启动的角色那样非常直接的方式完成任务可能是有意义的。然而,如果你有设计师(系统级或其他)急于开始构建各种游戏对象,并且不断更改它们,添加新的变体,并在关卡中快速测试这些对象,那么你能给予他们的蓝图灵活性越多,就越好!如前所述,在最基本层面上,蓝图类是优秀的数据容器:即使设计师或艺术家永远不会使用它们,程序员也能直接受益于在编辑器中访问并经常修改尽可能多的数据,同时保持编辑器中的上下文。迭代时间是瞬间的,你总是可以检查你的值,而无需从编辑器切换到 Visual Studio 或其他窗口。当然,有一些敏感的、关键的价值你可能不希望任何人随意从蓝图中进行修改,但使用UPROPERTY标志,如BlueprintReadOnly,你可以轻松地让人们查看当前设置的属性,同时防止某人意外修改它并保存一个不良的值。希望这里已经充分说明了这一点,即除了确保你让没有经验的人不要更改关键数据之外,公开任何可以在编辑器中以UPROPERTY类型查看的有趣变量几乎没有任何缺点。

现在,让我们转向视觉脚本。到目前为止,在我们的项目中这部分内容完全未被触及,但在下一节(蓝图脚本和性能)中,我们将通过一个充分利用的示例来改变这一状况。对于那些刚开始使用蓝图脚本的人来说,即使完全不使用它,也可以制作整个游戏,所以请不要担心,在那一节中会有很多截图和直接的工作内容。不过,现在,我们只是从高层次上讨论使用脚本的能力和缺点。首先,在蓝图脚本中你能做哪些主要的事情呢?

  • 游戏逻辑:包括for/while循环、使用大多数类型的局部和类变量,以及访问现有 UE4 类中的大量函数。

  • 游戏机制:世界中对象之间的交互、碰撞响应、移动,甚至路径查找。

  • 访问可共享的蓝图函数库:这些是你可以用 C++或蓝图脚本编写的无状态/静态实用函数组,你可以与不同的团队或项目共享。

  • 可以轻松地与 UMG 编辑器中的 UI 集成,这对于 UI/UX 设计师的工作流程通常至关重要。

  • 无需重新构建任何可执行文件或代码,并且可以轻松地将一个对象引用到另一个对象:正如将在紧接着本节之后的章节中讨论的,你可以(并且人们确实这样做)仅使用脚本进行逻辑来制作整个游戏。

听起来很棒,对吧!?好吧,在你认为接下来会有一个非常大的但是出现之前,让我们先明确一点:蓝图脚本确实很棒,而且功能强大。以下是一些需要注意的缺点列表,但与那些投入时间和精力去熟悉和适应它的团队相比,这些缺点总体上非常小:

  • 性能成本可能相当显著:通常可以最小化这些成本,但分析它们并确定问题区域更困难,并且需要与分析 C++代码以解决问题不同的技能集。

  • 无法访问某些数据类型:C++只依赖于你的平台编译器设置,但蓝图必须能够在 UE4 支持的所有平台上(32 位和 64 位、移动、网页等)使用和访问。

  • 调试可能会...出现错误:通常,你到达断点的上下文信息可能缺失,或者当你悬停在变量上时,某些变量可能不可用。调试器很棒,但当它不起作用时,你就得自己解决了。

  • 任何人都可以修改或向蓝图添加内容:任何人!这意味着你的团队需要对谁可以修改哪些类型的内容有非常明确的角色定义,以防止没有专业知识的人引入难以找到的错误。

  • 在 C++代码和蓝图脚本之间来回切换可能会分散注意力:通常,为了全面了解正在发生的事情,你需要从两者中获取数据,而切换上下文可能会减慢开发速度。

首先,简要讨论一些这些点以及如何减轻它们。对于性能分析,编辑器内置了一个非常好的工具,如果你还不熟悉,花点时间看看它能做什么。这是会话前端窗口中的 Profiler,可以通过开发工具下的窗口工具栏访问:

图片

如果一个团队从一开始就注重跟踪性能,那么通常更容易追踪到发生了什么变化并导致了问题。但再次提醒,即使是改变一个变量的值也可能对性能产生不利影响,如果你使用了编译蓝图,可能没有简单的方法在版本跟踪历史中搜索这个变化。两种缓解这种状况的策略是将源代码控制集成到编辑器中,以及“本地化”蓝图。如果你有一个受支持的源代码控制包(这本书的项目使用 Git),你可以在编辑器中通过右键单击任何蓝图资产来启用源代码控制集成,在弹出窗口的底部有连接到源代码控制选项。

一旦设置好,你再次可以右键单击任何蓝图资产,例如 FirstPersonCharacter 及其不同版本:

图片

有时寻找数据变化可能很困难(仔细看看,你会在上一张截图的 Can Crouch 处看到变化),但当它正确工作时,将脚本变化以视觉图表的形式并排显示,在追踪新变化可能引起的问题的地方非常有帮助。

在本章的“进一步阅读”部分可以找到关于为 Git 设置此功能的教程。在项目设置 | 打包 | 蓝图下可以启用蓝图本地化。这会将编辑器中的所有蓝图类转换为中间 C++类,并将它们打包到你的项目中。你可能有一些这样做的原因。如果你的项目在理解为什么某些蓝图没有按预期工作时有困难,这是一个可以实验的选项,但在这里我们不会进一步讨论这个选项。

最后,关于数据类型丢失的问题,使用蓝图函数库是完全可能的。你可以使用平台的本地(编译器)类型,并以蓝图可以使用的数据类型返回结果。例如,如果你有一些使用现有 UE4 FDateTime值的 UI,并想要它们之间的秒数差,在蓝图直接中这是不可行的,因为这些日期时间structs的值是本地int64格式。因此,你可以轻松地创建一个这样的函数,它从蓝图接收两个日期,本地进行数学运算(ToUnixTimestamp返回int64),然后以int32返回结果,蓝图可以访问(因此 UI 可以显示):

UCLASS()
class UDateTime : public UBlueprintFunctionLibrary
{
        GENERATED_BODY()

public:

        UFUNCTION(BlueprintPure, Category = "Date and Time")
        static int32 SecondsBetweenDateTimes(FDateTime time1, FDateTime time2);

然后是简单的 C++实现:

int32 UDateTime::SecondsBetweenDateTimes(FDateTime time1, FDateTime time2)
{
        return time2.ToUnixTimestamp() - time1.ToUnixTimestamp();
}

因此,在 C++中,我们使用一种不允许在蓝图直接使用的类型,但这一点在所有现代 C++编译器中都适用。当然,如果日期相差非常远,在这个计算/截断过程中可能会丢失一些数据,但只要可以假设没有日期会接近这种分离程度的情况,这便是一个简单的解决方案,用于现在可以从蓝图中的任何位置访问的int64计算。

在之前提到的缺点列表之前,提到这些问题总体来说非常小,但请记住,这是在考虑蓝图赋予你的能力的情况下说的。例如,你可以设置一个多人游戏会话,让其他玩家搜索并连接到它,然后开始一起玩游戏,所有这些都可以通过仅使用 UE4 为你提供的几个现有蓝图节点来完成。这非常强大,对于一个不熟悉该工作流程的程序员来说,可能需要几天甚至几周的时间才能正确使用FAsyncTask任务和OnlineSubsystem调用,所有这些都是从GameMode或类似事件的正确触发中开始的。那么,这是 100%正确的做法吗?我们是否应该忘记整个 C++业务,只为你的项目使用这种方法?这就是我们接下来要讨论的。

仅蓝图的游戏——这适合你吗?

我们已经确定,任何游戏在某种程度上都需要蓝图,而蓝图脚本是一种非常强大的工具,在实现系统或可重用的组件时,它可以节省大量时间,否则这些组件可能需要用成百上千行 C++代码编写。我们现在也已经讨论了使用蓝图脚本的风险和缺点,但请记住,在一个仅使用蓝图的项目中,你必须接受这些选择。一旦遇到看似无法解决的问题,不得不向项目中添加多达一个新 C++类,那么,从后视镜来看,你或许从一开始就应该从基本的 C++项目开始,这是一个非常常见的结局。那些非常熟悉蓝图脚本及其局限性并在项目开始时就能在设计阶段就规避这些问题的团队可以提前规划好这些问题。如果你不确定在项目开始时是否能够完成游戏设计所需的所有工作,那么就使用 C++,然后你可以根据团队带宽和开发者的偏好,尽可能多地使用蓝图和 C++。但是,将一个非常大的仅使用蓝图的项目转换为 C++将在项目后期的工作流程中需要更多的工作。如果你在开发过程中更深入,添加 C++构建可能会对习惯于从未需要执行这些任务的团队造成很大的干扰和分心,所以,如果你不确定,就只从你的流程中开始使用 C++功能,并在两种系统之间以最优的方式平衡你的工作量。一个在一段时间内几乎没有任何 C++代码更改或添加的团队会发现,他们的工作流程与仅使用蓝图的项目一样,主要是快速迭代,但同时也拥有所有基础设施和工作流程,以便在需要时添加 C++。

iOS 开发者!如果你考虑长期(甚至整个项目)仅使用蓝图,有一个很大的原因:UE4 可以从 Windows PC 打包、运行和测试 iOS 设备构建,前提是它们仅使用蓝图。这可以快速证明 iOS 概念,或者在快速迭代游戏玩法想法时大幅减少构建时间。你最终仍然需要至少一台可以运行 Xcode 版本的 Mac,以及一个 Apple 开发者许可证,才能最终提交给苹果,但仅使用蓝图是一个快速证明你的游戏在 iPhone 或 iPad 上能做什么的好方法。

按照惯例,通常有两种类型的团队会希望仅使用蓝图:

  • 没有人可以处理困难的 C++开发、问题或设置工作流程

  • 对于设计非常简单,或者有一组非常经验丰富的蓝图开发者,他们知道其局限性

常常独立游戏团队会陷入这些问题的其中一些,再次强调,快速在蓝图下原型化一个游戏并没有什么错误,如果你发现你以后必须切换,这也是可以做到的。对于那些在几个不同团队中有些经验的人来说,始终要权衡前面提到的话题,并在尽可能的情况下明智地决定开始。

在进行了一些(甚至不是那么重要/重大的)C++工作之后,再回到仅使用蓝图的游戏可能会非常困难,甚至可以说是完全不切实际的过程。这也是一个很好的经验法则:一旦你打开了 C++的盒子,就不要有任何期望你会再次关闭它。

蓝图脚本和性能

到目前为止,我相信你一定可以想象到:我们确实谈了很多关于蓝图脚本的好处和坏处,以及仅使用蓝图的问题,但我们还没有坐下来实际制作一些东西来看看它如何运作,以及我们如何可以分析其性能。现在让我们这样做,并构建一个真实的游戏系统,我们可以用它来调查:在这种情况下,是一个游戏中的经典移动平台。

蓝图脚本示例 - 移动平台和电梯

如同你想象的那样,在所有这些讨论之后,这个类以及实现其游戏玩法的工作几乎完全在编辑器中完成(你始终有方法可以将 C++连接到蓝图,这将在结尾附近讨论)。所以,首先打开编辑器,进入内容浏览器标签,转到我们的内容 | FirstPersonCPP | Blueprints,然后右键单击,就像我们之前做的那样,创建一个新的类:

图片

将蓝图作为父类,命名为 MovingPlatform。现在,我们有一个可以放置在我们世界中的完全裸骨演员,但当然它没有任何几何形状。让我们快速解决这个问题。在水平面上四处查看,你会看到两个灰色矩形框(不是白色框,但那些也可以正常工作):点击一个,然后右键点击它并选择浏览资源 (Ctrl + B) (注意,你还可以在编辑大多数资源时在菜单栏下的资源中找到这个选项),现在你应该在内容 | 几何 | 网格下,并且 1M_Cube 被选中。所以,既然我们已经知道了如何找到这个简单的几何形状,让我们回到我们的 MovingPlatform 并双击它。你可能注意到,因为我们从一开始就将其作为蓝图类创建,你将自动获得完整的蓝图编辑器,而不是通常用于不使用脚本的本地类的那种最小界面,就像我们以前经常做的那样。对于我们的平台对象,我们现在需要添加一个组件。请注意,你只需添加一个简单的立方体或平面作为静态网格组件。这些功能可用真是太好了,再次强调,特别是对于快速原型设计。但是,为了熟悉工作流程,这是更典型的专业游戏的工作流程,我们将使用我们刚才查看的静态网格立方体。所以,在组件下拉菜单中输入以过滤或滚动以找到静态网格,并添加一个。现在注意在层次结构中,这个组件是如何添加到默认场景组件(命名为 DefaultSceneRoot)下的。一旦在我们的案例中有一个演员组件,它可以是我们的演员对象的根组件,默认设置实际上只是一个占位符。所以,将网格组件重命名为 Platform 并将其拖到场景组件的顶部以替换它。

现在,我们将进行两个快速操作来使这个平台看起来正确,点击网格组件,在其属性中的右侧,在静态网格 | 静态网格下选择 1M_Cube,这是我们之前找到的,然后点击变换 | 缩放右侧的解锁图标,事情应该就会开始整合:

图片

我们现在可以将这些拖入我们的场景,但当然,除了阻挡我们的玩家之外,它不会做任何事情,因为步高太高,无法处理碰撞。你也会注意到它需要编译和保存,每当你在一个蓝图上取得进展时,这都是一个好的步骤,尽管如果它的脚本还没有准备好成功编译,显然要等到它准备好了。你可以保存并使用带有损坏脚本的蓝图,但它们的警告信息可能会非常分散注意力,并让人们注意不到其他可能至关重要的损坏蓝图问题。

然而,在我们真正使用和脚本化这个平台之前,我们还需要添加一个组件。虚幻引擎将(碰撞)和(接触)碰撞分开处理,在这种情况下,我们需要两者都有一点。因此,点击添加组件,这次我们只需从列表中选择一个立方体。注意它应该成为平台的父级,因此由于平台的比例传播到我们新的立方体,其大小是相同的。现在,通过在视图中沿 Z 轴拖动或在其变换中输入一个值将其抬起几厘米,直到你可以看到它已经在上方,但仍然基本上接触到我们的原始平台(我发现 Z 值为 50 适用于 5 厘米,因为我们的 Z 比例是 0.1)。现在,向下滚动直到你看到碰撞展开,并确保已选中生成重叠事件。在预设下拉菜单中点击,选择重叠 OnlyPawn,在渲染展开中,取消选中可见,因为我们不希望看到这个部件,因为它只是为了检测我们的角色是否在它上面行走。在平台组件的碰撞事件中,你可以将其保留为 BlockAllDynamic,并注意生成重叠事件是不相关的,因为碰撞事件优先于重叠,所以当项目或玩家撞击平台时,你会收到事件,但我们永远不会通过这些过滤器收到所需的重叠事件。

图片

这就是点击此截图底部可见框之前一切应该看起来的样子。

最后,进行一些蓝图脚本编写!点击到事件图标签页,你会看到现有的事件被灰色显示,并带有有关如何启用它们的说明。在我们的情况下,我们想要重叠框。点击并从蓝色其他演员引脚(从现在起,我们只称之为从引脚拉出),你会看到一个我们可以添加的许多东西的列表,这些列表是按上下文排序的(只要复选框保持选中状态),并且可以接受演员作为输入,就像事件中的引脚那样作为输出。过滤GetClass,你会看到它已经被添加了。对于熟悉蓝图脚本的人来说,只需查看以下截图以了解进度。以下是添加GetClass后的步骤:

  1. 拉取Get Class的返回值,并在列表中找到Is Child Of。现在它们应该已经连接。在Is Child Of框中,在其下拉菜单中选择我们的MasteringCharacter类作为类型,这样只有我们的玩家在这里给出真实的结果。

  2. 从子组件中拉出红色结果引脚,然后过滤或查找分支,然后从演员开始重叠事件的白色三角形输出引脚中连接到分支的输入白色引脚。

  3. 从分支的真实结果中拉出白色输出(指向右侧)引脚,并添加一个移动组件到块。

  4. MoveComponentTo块的蓝色组件引脚向左拉,并输入以过滤GetPlatform(对我们根组件的引用)。

  5. 从目标相对位置黄色图钉向左拉,并输入+或以其他方式在列表中过滤/滚动以找到向量+向量。

  6. 从其左上角图钉拉出,并输入/过滤以获取 GetActorLocation,你可以将输入图钉留在该节点上为 self(这是我们想要的)。

  7. 将底部向量的目的地设置为所需的值。在我的测试案例中,Z 值为 300 使我们与地图周围的其他大型灰色方块齐平。

TargetRelativeLocation 变量似乎命名得不太好,因为它想要的适当运动是一个世界位置。最后,在 MoveToLocation 节点上,将时间设置为任何你喜欢的值:这里设置为 4 秒,因为有点慢且无聊,但非常适合演示这一切都能正常工作:

不要忘记编译并保存。

注意,在编辑器模式下运行游戏将始终尝试在运行之前编译任何已编辑的蓝图(如果有错误,将在输出日志中给出错误),但它不会保存这些资产!你仍然需要在蓝图和级别编辑窗口中使用“全部保存”或 Ctrl + S 来保存!如果你直接通过 Visual Studio 关闭你的编辑器,甚至不会提示你保存任何未保存的级别或资产,所以当在编辑器中工作时,通常最好通过其窗口的 X 按钮关闭。

现在要展示这个功能,只需将我们的移动平台拖到地板上的某个位置,然后走过去踩上它。你就可以飞起来了!注意,如果你再次跳上跳下地踩它,它会带你飞得更高。我们很快会把这个东西做得更好,但现在,这是 GitHub 版本项目的良好检查点。在这里,你可以看到它在书籍项目地图中的位置(再次,通过将移动平台蓝图图标拖入主级别窗口本身):

现在我们可以用类似的东西做很多很多其他的事情。可以添加各种逻辑,包括其他组件,使这个平台能够进行路径寻找导航,或者向世界中添加一个平台在级别蓝图中使用(从主编辑窗口,点击蓝图 | 打开编辑蓝图以访问可以相互引用的级别中的各个蓝图)。在这些领域可以完成大量非常有价值的工作。现在,我们将使平台在玩家离开时返回其起点,同样,当它到达运动的顶部时也是如此:

注意,这使用了添加到类中的几个蓝图变量,其中简短参考在进一步阅读部分中添加。简而言之,在左侧的 My Blueprint 标签页下。有添加新内容,在其下是变量。一旦添加一个,你就可以更改类型和默认值。在这里,我们在 My Blueprint | 变量下添加了一个重命名为 StartPosition 的变量,在右侧的详细信息下,将其类型设置为 vector,并添加 GoingHome 作为布尔值。你总是可以在蓝图脚本窗口中的 get/set 块中访问这些类型,就像 C++公开的变量一样。注意,除非你创建一个特定的访问器(这里有一个示例,它不是使用,但你可以实现),否则这些变量不能在 C++中访问:

UFUNCTION(BlueprintImplementableEvent)
FVector GetStartPosition();

将其制作为一个蓝图可实现的事件,这意味着对于混合 C++/蓝图类,你可以在蓝图中将此作为事件类型函数添加,然后简单地让它返回 StartPosition。这样,仅定义在蓝图中的变量就可以被 C++访问。同样,为了使 C++中的本地函数能够像我们之前那样执行工作,请务必记住将 BlueprintCallable 作为UFUNCTION关键字,因为它们可以在任何你处于或使用实现它的类的实例时被蓝图访问。预告:在下一章中,我们将做很多这样的事情,第四章,U.I. 必需品:菜单、HUD 和加载/保存,类似于以下内容:

UFUNCTION(BlueprintCallable, Category="Appearance")
void SetColorAndOpacity(FSlateColor InColorAndOpacity);

这些函数在 C++中可以工作,但可以直接由蓝图调用。注意,从 C++到蓝图以及相反方向的调用有一个相当大的调用栈开销。在之前的硬件上,这是一个主要性能问题,但为了节省你的时间,在大多数平台上,这些开销现在实际上是非常小的。记住,如果你经常在这两者之间切换,但不再有 UE3 类似系统中引起的那种同样水平的工作压力。

好的,所以关于如何从 C++到蓝图以及相反方向调用的这些旁白就到这里,让我们回到我们的快速逻辑,以使这个电梯达到完成状态。正如你所看到的,有几个步骤,为了简洁起见,我们在这里按顺序列出它们:

  1. 首先,我们需要两个新变量,所以如前所述,在 My Blueprint 标签页中,点击两次添加新内容,然后从列表中选择变量。

  2. 对于第一个,将其重命名为 GoingHome,其类型可以保持不变,作为一个布尔值。

  3. 对于第二个,也如前所述,命名为 StartPosition,并给它一个 vector 的类型。

  4. 现在,从脚本窗口中的 BeginPlay 事件中拉取,并过滤到设置起始位置。

  5. 从其矢量向左拉,就像我们之前做的那样,再次将 self 作为对象。现在,当我们开始播放时,这个平台将标记其初始位置,并将其保存在我们刚刚创建的蓝图变量中。

  6. 现在,从我们现有的 MoveComponentTo 节点拉取并筛选到 Set Going Home,并勾选它的框为 true。

  7. 从那个节点拉取,并创建一个新的 MoveComponentTo 节点,类似于我们之前所做的那样。再次使用 Platform 作为输入组件,但作为 TargetRelativeLocation,从它那里拖动并筛选到 GetStartPosition。

  8. 从新的 MoveComponentTo 节点的输出拉取并筛选到 SetGoingHome,确保这次节点未被勾选,因此设置为 false。

  9. 我们需要一个新的事件:在任意位置右键点击并筛选到 ActorEndOverlap。

  10. 从它的输出拖动并添加另一个分支。拉取那个分支的条件并执行我们之前所做的相同逻辑,从重叠结束的 OtherActor 拉取到 GetClass 节点,然后从那里返回到 ClassIsChildOf MasteringCharacter,或者只需复制并粘贴这些节点,使用多选或 shift/control 选择从 begin-overlap 开始,这样就可以节省一些麻烦。

  11. 当那个分支为真时(这意味着我们的玩家角色不再在平台上),从真分支拉取,筛选到 SetGoingHome,并勾选它的框。

  12. 从那里拉取到一个分支,从它的条件拉取并筛选到 GetGoingHome。

  13. 从分支的真值拉取,并添加与之前相同的逻辑集,使用 MoveComponentTo(到 StartPosition,在完成后清除 GoingHome 为 false)。

你现在应该有的内容应该与前面的截图以及 GitHub 上 chapter 3 分支的最终提交相匹配。这个电梯垫现在应该能够可靠地将你带到下一层,但到达那里或被遗弃后也会返回到起点。理想情况下,在自动返回之前,到达顶部之前可能应该有一个计时器,而且大多数游戏没有理由不能一般性地仅依赖重叠结束路径来重置平台。再次强调,这只是为了展示蓝图能为你做什么的一个提示,并且是一个很好的进一步实验的起点!

蓝图技巧、窍门和性能影响

关于蓝图,最后一个真正的问题是如何看到你的性能问题可能出现在哪里,以及什么可以使生活变得更轻松。正如已经提到的,你可以使用内置的 UE4 性能分析工具。这是一个很好的开始地方。在本节的末尾,还列出了额外的阅读材料,包括关于性能分析工具的 Stack Overflow 讨论。我强烈推荐 Intel 的 VTune,如果你有一个兼容的硬件设备,NVIDIA Visual Profiler 也是一个出色的工具。然而,请注意,这些工具会显示在 C++类中哪些是热点。当你从 C++中看到 K2 类(或者对于旧类型,名字中带有Kismet的类)时,你可以确信这些是在蓝图中进行工作并花费时间的,但其他事情,如路径查找、物理或碰撞可能不那么明显。你可能需要逆向工作来找到这些在蓝图中的含义。使用蓝图测试性能的一个更快但更粗糙的方法是简单地断开连接(使蓝图的部分不被调用)并比较之前的性能分析结果与当前的性能分析结果(或者甚至只是看看你的每秒帧数!)。可能有一些明显的蓝图区域正在导致你的性能问题,修复它们或更改它们将减轻问题。不过,特别要注意的是,就像在可能性的例子中:路径查找或修改 NavMesh 完全是可以通过蓝图对象实现的。如果我们电梯平台修改了 NavMesh(这是一个相当简单的设置,但超出了本章的范围),那么在移动时可能会对性能产生重大影响!了解你的工具并使用最适合的工具,但始终关注性能。每个游戏都必须在目标平台上流畅运行才能成功。

本章的最后一条经验法则:如果你可以用蓝图实现,首先用蓝图来实现。快速证明一个机制是可行的并且很有趣。让玩家通过蓝图节点一起加入并开始多人游戏,而不是编写大量的新 C++代码。利用现有的资源,在你编写自己的 C++版本之前,找到它最终达到你要求极限的时刻。蓝图不能做所有事情,但它可以非常高效地完成大量事情,对于那些了解它的人来说,几乎不需要付出太多努力。

摘要

在这一章中,我们学习了如何进行蓝图脚本编写,看到了一些示例,以及它带来的某些局限性和性能问题。与编写 C++代码相比,它非常强大且易于学习,但在复杂项目的后期使用它可能会带来很大的麻烦。了解你的团队,了解你的选择,了解你的局限性:这些是在涉及蓝图和你的项目时做出正确决策的关键。此外,这种蓝图知识在进行甚至基本的 UI 工作(如下一章所述)时也将非常有价值!UMG(UE4 主要使用的编辑界面)在蓝图工作中根深蒂固,蓝图从游戏集成回 UI 相对容易。这些,就像 UE4 中的大多数事情一样,并不是必须一起工作才能实现,但现在有了坚实的基础和对蓝图优势的理解,快速实现 UI 并达到期望规格的好处将很容易看到!

问题

  1. 蓝图是从 UE3 中的哪两个系统发展而来的,为什么大多数 C++类都使用 K2_ 前缀?

  2. 在编辑器中拥有源代码控制集成有什么巨大优势?

  3. 为什么UFUNCTIONSUPROPERTIES如此有价值?

  4. 添加通过 C++进行会话加入的大致需要多长时间?

  5. 使用哪些工具来分析在蓝图中所做工作的性能?

  6. 在蓝图而不是 C++中构建游戏玩法有哪些缺点?

  7. 为什么在某些层面上,蓝图对于任何 UE4 项目都是绝对必需的?

  8. 如果你让运行 PIE 游戏编译蓝图,你还需要做些什么?

进一步阅读

UE4 编辑器中的 Git 集成:

wiki.unrealengine.com/Git_source_control_(Tutorial)

蓝图在线会话节点的快速概述。如果你不熟悉它们,请务必阅读有关在线子系统的链接:

docs.unrealengine.com/en-us/Engine/Blueprints/UserGuide/OnlineNodes

蓝图变量概述:

docs.unrealengine.com/en-us/Engine/Blueprints/UserGuide/Variables

C++性能分析工具:

stackoverflow.com/questions/67554/whats-the-best-free-c-profiler-for-windows

第四章:UI 必需品、菜单、HUD 和加载/保存

简介

在本章中,我们将着手处理任何游戏的基本需求之一:我们的 UI,作为一个典型的例子,我们将向我们的游戏添加加载和保存游戏状态的功能。Unreal 为这两项功能提供了出色的工具,特别是 UMG 用于创建 UI,我们将在本节中探讨。当涉及到加载和保存游戏时,几乎每个游戏都以某种形式使用该系统,但没有一个是以相同的方式,其复杂性将完全由你的设计和期望的玩家体验所驱动。我们首先将使我们的库存显示在我们的 HUD 上。在下一节中,我们将讨论基于不同游戏类型的保存策略,然后我们将解决 Unreal 最困难的问题之一:在地图中保存并在游戏过程中恢复到精确点。在本章中,我们将:

  • 使用自动屏幕截图级别制作库存图标

  • 将图标集成到屏幕上的玩家 HUD 中

  • 将库存与 HUD/UI 同步

  • 从任何地方保存和加载完整游戏状态

  • 构建加载和保存的 UI

技术要求

如同往常,建议从第三章达到的进度点开始,即蓝图回顾和何时使用 BP 脚本,正如 GitHub 项目中所做的那样:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-4

虽然第三章,蓝图回顾和何时使用 BP 脚本的内容不是特别必需的,第二章,玩家的库存和武器将被大量使用和参考,因此这些类应被视为强制性,以体现这项工作的价值。

使用的引擎版本:4.19.0。

将 UMG 集成到我们的玩家 HUD 类中

在第二章,玩家的库存和武器中,我们成功为我们的玩家创建了一个库存系统,一种拾取新武器的方法,以及一个在它们之间切换的输入方案。然而,除了视觉上看到发射了哪种弹丸外,我们没有实际参考我们手中有什么。因此,现在我们将为玩家提供一个显示,让他们可以看到他们正在使用什么,作为其中的一部分,我们最终将添加一些来自免费 Unreal 市场的新的艺术资产。

使用屏幕截图构建库存图标

为了使这个练习更有意义,更接近现实场景,我们需要更多的艺术作品。然而,正如之前所述,这本书并不是关于生成艺术或大多数 UE4 内容的。话虽如此,作为一个试图证明可以和不可以做什么的技术开发者,并且不需要你,读者,像制作游戏那样在 Marketplace 内容或外包艺术工作室上花钱,我们将使用一些免费替代的艺术作品。所以首先要做的是打开 Epic Games Launcher。正如在第一章中首次提到的,“为第一人称射击游戏创建 C++项目”,通常最好的做法是直接创建一个指向你的 UE4 编辑器可执行文件的快捷方式。如果你忘记了,这里就是我推荐“创建一个指向你的 UE4 安装文件夹的/Engine/Binaries/Win64/UE4Editor.exe”的句子,或者当然你也可以手动点击它来启动它。

这将启动编辑器而没有游戏,会弹出一个你可以打开的游戏项目列表,但在右上角的 Unreal 项目浏览器中有一个 Marketplace 按钮,所以让我们去那里。在最顶部,确保你选择了 Unreal Engine,然后在左侧点击 Marketplace。在内容行顶部,你会找到一个对于想要使用酷炫内容进行原型设计或基于一些惊人的 Epic 发布资产制作游戏的团队来说可能非常有用的朋友,那就是免费标签。滚动到 Infinity Blade: Weapons 并添加到购物车(然后点击右上角的购物车结账):

图片

当你选择左侧 Marketplace 下面的库项目时,这些内容将会显示出来。所以现在,我们只需要将它们添加到我们的项目中,以便访问所有这些精彩内容。现在,看看这个包提供的内容,这些都是近战武器,但遗憾的是,在撰写本文时,没有免费的长距离(枪械)武器包,所以我们只能将就使用我们已有的。点击库,滚动到宝库部分,找到 Infinity Blade: Weapons 并点击添加到项目。从这里,你需要点击顶部的显示所有项目框,并选择我们的 Mastering 项目。它可能会抱怨这些资产与其它引擎版本不兼容,所以从下拉菜单中选择 4.19(或者本地引擎构建的最新版本)然后添加到项目。可能还需要下载这些资产。当然,等待这个过程完成。

现在,在编辑器中打开我们的项目,如果您打开了源代码面板,您将在内容浏览器中看到一个新文件夹,/Content/InfinityBladeWeapons,其中包含几个角色(请注意,其中一些是静态网格,但正如我们的原始枪和拾取物所期望的,我们将坚持使用一些角色武器)。您可以自由打开,例如,/Content/InfinityBladeWeapons/Weapons/Blunt/Blunt_Ravager,并打开SK_Blunt_Ravager骨骼网格来查看这个有趣的带刺锤子武器。我们将使用其中的一些来为我们的游戏制作一些新武器。由于我们已经在第二章中经历了这个过程,玩家的库存和武器,我将快速列出我在这里创建一些新物品所使用的步骤:

  1. 在我们的/Content/FirstPersonCPP/Blueprints文件夹中,我将右键单击 BigBallGun 并复制它。

  2. 我将对拾取物(BigBallGunPickup)做同样的事情。

  3. 对于破坏者武器,我现在将把这些蓝图重命名为 RavagerGun(是的,我们正在用锤子制作一把枪,但再次强调,艺术只是我们目前可用的),以及 RavagerGunPickup,其他物品(如弹丸)与 BigBallGun 保持相同:

  4. 在完整的 BP 编辑器中打开 RavagerGun,并选择其 WeaponMesh 组件,我现在可以将其设置为刚刚查看过的 SK_Blunt_Ravager。

  5. 同样,我将 BallGunPickup 网格组件设置为使用相同的骨骼网格,并将组件重命名为 PickupMesh,这样在将来复制它们时就有了一个通用的名称:

图片

最后,为了区分它,我在 RavagerGunPickup(self)的详细信息中将武器功率设置为 2,并在其 Mastering Weapon Pickup 下拉菜单中选择 RavagerGun。当然,您可以将武器的弹丸设置为任何您想要的,或者创建一个新的,调整其在玩家手中的位置等;但到目前为止,我们有一个可以发射斧头的武器!

将其中一个拾取物添加到关卡中并快速测试是一个很好的检查点,可以将项目添加到 GitHub。

从现在开始拉取 GitHub 项目将需要更多的时间,因为这些资产下载(比到目前为止项目的其余部分都要大!),但这种延迟只会发生在第一次拉取时。

我将重复这个过程,添加武器和拾取两次,直到此时总共有五把可以区分的武器。为了组织现在超过几个蓝图的情况,我将所有这些武器、弹丸和拾取移动到一个新的/Content/FirstPersonCPP/Blueprints/Weapons文件夹。我们的下一步是从这些模型中生成一些图标。有些项目会有艺术家想要手动绘制这些图标;其他游戏可能没有艺术家,使用这种技术或直接将演员绘制到渲染目标纹理中以显示这些物品。渲染目标的想法在附加阅读部分中进一步探讨,但就现在而言,让我们专注于以自动化方式从截图生成图标。

为了实现高质量 UI 使用的出色外观、alpha 掩码输出图标,我们需要按以下顺序做几件事情:

  1. 创建一个新的 Camera Actor 来自动化我们的过程并获取我们的截图。实现这个相机将包括几个部分,包括我们武器演员的放置和方向、获取深度掩码截图,并将这些截图作为纹理导入到我们的演员中。

  2. 创建一个新的关卡,向其中添加我们新的相机实例,并添加一个 SkyLight。

  3. 将导入的纹理重新连接到我们的拾取中,以便传递给 UI。

  4. 在 UMG 中创建一个图标小部件,在创建时获取武器的纹理。

  5. 在 UMG 中构建一个列表并将其添加到 HUD 中。将库存类连接到列表小部件,并使其更新以反映玩家的武器选择。

对于像这样的大多数重要游戏功能,有一系列步骤,每个步骤都有不同复杂程度;但如果你能以这种方式排序,以便在实施和测试每个步骤时,就像前面的过程所设计的那样,那么从 A 到 B 并拥有你的完成系统只是时间问题。所以,让我们开始新的相机类。在编辑器中的/Content/Blueprints级别,我将添加一个新的 C++类,命名为 MasteringIconCamera,并从 CameraActor 派生。这个相机的全部工作就是打开武器拾取,并以一种优雅的方式将其放在它前面,然后进行截图,我们将使用这些截图作为图标。

这是一个展示使用蓝图可以节省实际时间的例子。直接在关卡中放置相机,并通过一些工作,使其视口能够截图,这是绝对可能的。如果时间允许,并且有人决心或非常熟悉这些 C++系统,这可以通过努力实现。或者,你可以在蓝图内创建一个游戏模式,创建一个新的 Pawn 蓝图,并且不需要 C++原生类。因为我们之前已经创建了蓝图类,所以我将只列出本节中采取的步骤,并且,如往常一样,如果出现问题,这个进度可以在 GitHub 上检查:

  1. 在 Blueprints 文件夹中,创建一个名为 IconMaker 的文件夹,并在其中创建一个基于 Pawn 的新蓝图,命名为 IconPawn。

  2. 基于 GameModeBase 创建一个游戏模式。将其命名为 IconGameMode。

  3. 在游戏模式中,取消选中 Allow Tick Before Begin Play(设置为 false),将 HUD Class 设置为 None,并将 Default Pawn Class 设置为 IconPawn。

  4. 在 IconPawn 中,在其 DefaultSceneRoot 下,添加一个 ChildActor 组件并将其 Child Actor Class 设置为 MasteringIconCamera。

  5. 在那个子相机中,取消选中 Camera Component 的 Camera Settings 中的 Constrain Aspect Ratio(这避免了在使用自定义深度场截图时经常断言的检查)。

  6. 可选地设置 FOV(出于实验目的,我将它设置为 45,因为这使一些数学计算更容易测试)。从小到大 90+ 的任何范围都行。通常,为了简化这类事情,我想要使用正交相机,但 UE4 的正交渲染在灯光方面已经存在很多问题多年了。我们将在代码中处理 FOV,如后面所示。

这就是蓝图类的内容。现在我们需要一个新的关卡来使用它们。因此,在编辑器中,请确保保存你的蓝图,然后转到文件 > 新建关卡,选择空关卡模板并将其命名为 IconScreenshotMap。将 Icon Pawn 拖入关卡,并在选择它的情况下,确保其位置和旋转设置为 000。我们还可以从 FPS 模板的示例地图中借用一个技巧,在我们的关卡 pawn 属性下,将 Auto Possess Player 设置为 Player 0。这样,当游戏开始时,它会将本地默认玩家直接放入这个 pawn,而不是从玩家生成中创建一个。另一个提示:你可以从 FirstPersonExampleMap 复制所有照明对象(或整个照明文件夹)并将其粘贴到这个地图中。

在任何情况下,你肯定需要天空光,可能还想像 GitHub 版本那样使用方向光,但关于改变武器外观的问题,这有点主观,因为我们在截图时可能会觉得 GitHub 版本有点太亮,使得渲染效果略显模糊;但再次强调,这很主观,并不是本章的重点。

现在我们有了关卡和照明,让我们在主编辑器标签页中修复一些 World Settings。将 GameMode Override 设置为 IconGameMode,在物理部分,我们想要勾选 Override World Gravity 复选框并将其设置为 0.0(我们不希望我们的 pawn 立即或永远掉落)。为了完整性,我添加了一个默认球枪拾取,并给大球枪拾取添加了一个缩放,以便稍后区分。所以现在我们的关卡和截图 pawn 都已设置好;我们现在需要的只是让关卡中的相机真正做些事情!

我们将从其 BeginPlay 函数开始,回到 C++。我们的头文件中有几个成员变量和许多我们需要自动化的函数来抓取屏幕截图:

UCLASS()
class MASTERING_API AMasteringIconCamera : public ACameraActor
{
        GENERATED_BODY()

public:
        virtual void BeginPlay() override;

protected:

        virtual void TakeShot();
        virtual void SpawnAndPlaceNextActor();
        virtual FVector ComputeActorLocation();
        virtual void OnFinishedLoadingAssets();

        UPROPERTY(Transient)
        TArray<FSoftObjectPath> WeaponBlueprintSoftRefs;
        UPROPERTY(Transient)
        TArray<class UBlueprint*> WeaponBlueprints;

        UPROPERTY(Transient)
        class UBlueprint* CurrentWeaponBlueprint = nullptr;
        UPROPERTY(Transient)
        class AMasteringWeaponPickup* CurrentWeaponPickup = nullptr;
        UPROPERTY(Transient)
        class UCameraComponent* CameraComp;
        UPROPERTY(Transient)
        bool bInitialied = false;

        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        FString WeaponsPath = "FirstPersonCPP/Blueprints/Weapons";

        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        float ShotDelay = 0.4f;
        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        int ScreenshotResolutionX = 256;
        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        int ScreenshotResolutionY = 256;

        int CurrentWeaponIndex = 0;
};

为了在发布进度时保持简洁,我在这里省略了很多在专业环境中通常伴随这些内容的正常间距和注释。希望前几章中更传统和正式的例子已经提供了一些很好的总体指导原则,但如果您注意到这里缺少这些内容,那是因为我们需要覆盖的内容很多,而不是出于坏习惯。通常情况下,当有新概念时,它们将与代码一起在这里进行讨论。在这种情况下,有一个新的UPROPERTY标签,Transient。这告诉引擎这些属性永远不会与对象一起保存:它们在其生命周期内使用,可以像任何其他UPROPERTY一样使用,但对其更改不会使对象“变脏”而需要保存,并且内容永远不会与对象的实例进行序列化和反序列化。关于底部附近的属性简要说明:ShotDelay是在游戏中加载新的武器拾取并截图之间暂停的时间量。这主要是为了给游戏一些帧的时间来生成对象,然后更新它们以处于正确的 MIP 地图细节级别。我们将异步加载所有武器拾取的蓝图,但这个延迟仍然是必需的,因为即使有加载的资产,对象通常仍然以最低的 MIP 级别进入,如果我们当时截图,质量是最差的:

void AMasteringIconCamera::BeginPlay()
{
        if (bInitialied)
        {
                return; // BeginPlay will get called multiple times at 
                level start
        }

        bInitialied = true;

        CameraComp = GetCameraComponent();

        UWorld* World = GetWorld();
        check(World != nullptr);
        APlayerController* Player = World->GetFirstPlayerController();

        Player->SetCinematicMode(true, true, true, true, true);

        Player->SetViewTarget(this);

        FString contentPath = FString("/Game/") + WeaponsPath;

        static UObjectLibrary* ObjectLibrary = nullptr;
        ObjectLibrary = UObjectLibrary::CreateLibrary(AMasteringWeaponPickup::StaticClass(), false, GIsEditor);
        ObjectLibrary->AddToRoot();
        ObjectLibrary->bHasBlueprintClasses = true;

        ObjectLibrary->LoadBlueprintAssetDataFromPath(contentPath);

        TArray<FAssetData> AssetDatas;
        ObjectLibrary->GetAssetDataList(AssetDatas);

        for (auto itr : AssetDatas)
        {
                FSoftObjectPath assetPath(itr.ObjectPath.ToString());
                WeaponBlueprintSoftRefs.Add(assetPath);
        }

        // Here we stream in the assets found that are weapon pick-ups and when done, will call the OnFinished function
        FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
        Streamable.RequestAsyncLoad(WeaponBlueprintSoftRefs, FStreamableDelegate::CreateUObject(this, &AMasteringIconCamera::OnFinishedLoadingAssets));
}

这里有一些有趣的事情需要讨论。首先,在顶部有注释说明BeginPlay可以在单个对象(通常是两个)上多次调用,并且我们只需要或想要在这里执行一次操作。因此,首先,我们将玩家设置为电影模式,关闭移动和 HUD 以及我们超级基本的 pawn 不需要的其他任何东西,但在这种一般领域,这是一个好主意。我们将这个摄像头设置为视图目标并获取我们的路径,该路径默认为武器、拾取和投射物蓝图移动到的位置:/Game/FirstPersonCPP/Blueprints/Weapons。然而,这可以在单个图标摄像头中编辑,指向任何特定的文件夹,因为它是一个暴露的UPROPERTY(就像之前提到的截图分辨率和延迟一样)。接下来,使用UObjectLibrary类在路径中批量查找我们的拾取对象。我们快速遍历这些对象的列表,对这些对象进行软引用。这并不是严格必要的,但就像本章中的其他几个主题一样,它旨在具有指导性,并在你思考如何引用对象时开始建立良好的习惯。在 PC 上,通常你可以始终为给定级别加载所有必要的资产,并在游戏结束后才释放它们。在移动和其他平台上,内存可能非常宝贵,因此拥有在后台加载资产的工具,不停止游戏,并且在它们不再需要时通过垃圾回收确保它们被释放是很好的。一旦制作出拾取对象的列表,我们将它发送到StreamableManager以批量流式传输我们需要的蓝图。在这个请求中,我们使用FStreamableDelegateCreateUObject添加一个回调给自己(这创建了一个与UObject绑定的回调,在大多数情况下使用this指针)。当所有这些蓝图都加载到内存中时,它将调用OnFinishedLoadingAssets,我们将在下一节中查看。

为了加快此类工作(你只需要打开一个级别,让它运行,然后退出)的测试速度,你可以在解决方案资源管理器中右键单击 Mastering 项目,并将地图名称和-game添加到你的命令参数行,使其看起来像这样:"$(SolutionDir)$(ProjectName).uproject" IconScreenshotMap -game -skipcompile -debug。这告诉 DebugGame 编辑器构建直接进入游戏,但作为一个编辑器构建。它仍然会使用未烹饪的内容。如果你构建了 DebugGame,如果你没有同时烹饪你的内容,启动时将会出现错误。

因此,一旦我们的蓝图全部加载完毕,我们需要将这些蓝图中的每一个作为演员实例化到世界中,然后进行拍摄,接着销毁该实例并移动到下一个。我们将使用计时器和 lambda 函数(这是我们第一次使用,但肯定不是最后一次)来完成这项工作。看看我们到目前为止做了什么:

void AMasteringIconCamera::OnFinishedLoadingAssets()
{
        UWorld* World = GetWorld();

        for (auto itr = WeaponBlueprintSoftRefs.CreateIterator(); itr; 
        ++itr)
        {
                UBlueprint *BPObj = CastChecked<UBlueprint>((*itr).ResolveObject());
                WeaponBlueprints.Add(BPObj);
        }

        SpawnAndPlaceNextActor(); // this spawns our first pickup and increments CurrentWeaponIndex to 1

        static FTimerHandle ScreenShotTimer;
        World->GetTimerManager().SetTimer(ScreenShotTimer, [=] {
                        if (CurrentWeaponIndex == 0) // only way we come in at index 0 is if we're done
                        {
                                World->GetTimerManager().ClearTimer(ScreenShotTimer);
                                                                if (APlayerController* Player = UGameplayStatics::GetPlayerController(World, 0))
                                {
                                        Player->ConsoleCommand(TEXT("Exit"), true);
                                        return;
                                }
                        }

                        TakeShot();
                },
                ShotDelay, true, ShotDelay);
}

在这里,我们首先将我们的软引用(FSoftObjectPath 项的 TArray)转换为硬引用(在这种情况下,是一个简单的 UPROPERTY TArrayUBlueprint 指针)。如果你发现自己遇到了内存泄漏或内存不足的问题,请始终记住,UE4 中的 UPROPERTY 指针将被视为指向的对象的硬引用,直到你将该指针置为空(或指向另一个对象),或者拥有该指针的对象被销毁时,该对象才会被释放。你可以通过遍历 UObjectOuter 指针链来找出最终拥有任何其他 UObject 的对象,但现在是我们要强制所有这些蓝图保持在内存中,这就是为什么我们将软引用转换为硬引用的原因。之后,我们通过调用 SpawnAndPlaceNextActor 来安排第一个拾取物被射击,我们稍后会讨论这个函数。

对于在过去 5 年左右学习 C++ 的程序员来说,lambda 函数相当常见。对于那些早年学习 C++ 的人来说,这可能是新事物,但它们非常有用,并且得到了 UE4 许多领域的支持。我们在游戏计时器管理器的一个简单计时器中使用了它:我们将 ShotDelay 成员时间设置为初始延迟,以及计时器将触发的速率,因为我们将其设置为循环,只有在满足特殊条件时才会中断这个循环。CurrentWeaponIndex 为 0 表示我们已经完成并且没有拾取物可以捕获。停止循环(或任何活动的非循环)计时器的方法是让计时器管理器根据你设置计时器时传递的句柄清除该计时器。现在,每隔 ShotDelay 间隔,我们将调用 TakeShot,这也会在完成时安排下一次射击。

接下来要讨论的是名为 TakeShot 的函数,让我们来看看它:

void AMasteringIconCamera::TakeShot()
{
        UWorld* World = GetWorld();

        check(CurrentWeaponPickup != nullptr);

        UMeshComponent* Mesh = Cast<UMeshComponent>(CurrentWeaponPickup->GetComponentByClass(UMeshComponent::StaticClass()));
        check(Mesh != nullptr);

        Mesh->bForceMipStreaming = true;

        Mesh->SetRenderCustomDepth(true);

        GScreenshotResolutionX = ScreenshotResolutionX;
        GScreenshotResolutionY = ScreenshotResolutionY;

        GetHighResScreenshotConfig().SetHDRCapture(true);
        GetHighResScreenshotConfig().bMaskEnabled = true;
        World->GetGameViewport()->Viewport->TakeHighResScreenShot();

        // this timer is here to wait just one frame (hence the tiny time) and then destroy the current actor
        // and spawn the next one: if you destroy the actor the same frame as the screenshot it may not appear
        FTimerHandle SpawnNextTimer;
        World->GetTimerManager().SetTimer(SpawnNextTimer, [this] {
                if (CurrentWeaponIndex >= WeaponBlueprints.Num())
                {
                        CurrentWeaponIndex = 0; // we have finished, this will break our timer loop on its next trigger
                }
                else
                {
                        SpawnAndPlaceNextActor();
                }
        },
        0.001f, false);
}

你会注意到在这些函数中,在直接调用这些项目上的函数之前,比如 Mesh 指针,都有检查。鉴于这个功能的自动化程度,如果内容创作者在构建合适的拾取物品时遇到麻烦,你会在任何重要的地方收到警告,但如果这是一个问题,你可能会希望以不会使编辑器崩溃的方式处理这些设置错误(连接到 Visual Studio 等的人可以始终通过设置下一个语句等方式跳过检查断言)。但再次强调,为了简洁,并且作为在简单崩溃之前警告问题的最小保障,这些检查至少在这里。因此,获取那个 Mesh 后,我们将其设置为适当的截图,确保我们的游戏的全局截图分辨率设置为我们的设置,设置截图的属性,并使用游戏视口进行截图。这是最容易访问的,这也是为什么我们在这里从玩家角色视角进行截图的原因。然后我们设置一个故意很短的计时器,以便在下一次帧中移动到下一个演员,或者通过重置武器索引向之前的计时器发送完成信号。正如注释中所述,如果你销毁一个演员(如SpawnAndPlaceNextActor所做的那样),那么在截图解析时它可能不会显示出来,但如果你等待一帧让拍摄完成,就没有问题。

现在你已经看过它几次了;让我们看看SpawnAndPlaceNextActor

void AMasteringIconCamera::SpawnAndPlaceNextActor()
{
        if (CurrentWeaponPickup != nullptr)
                CurrentWeaponPickup->Destroy();

        CurrentWeaponBlueprint = WeaponBlueprints[CurrentWeaponIndex];
        check(CurrentWeaponBlueprint != nullptr); // anything not a blueprint should never find its way into our list

        UWorld* World = GetWorld();

        FRotator Rot(0.0f);
        FVector Trans(0.0f);

        FTransform Transform(Rot, Trans);
        FActorSpawnParameters ActorSpawnParams;
        ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
        CurrentWeaponPickup = World->SpawnActor<AMasteringWeaponPickup>(CurrentWeaponBlueprint->GeneratedClass, Transform, ActorSpawnParams);
        CurrentWeaponPickup->RotationSpeed = 0.0f; // the ones we use for screenshots we don't want spinning!
        check(CurrentWeaponPickup != nullptr);

        FVector Pos = ComputeActorLocation();
        CurrentWeaponPickup->SetActorLocation(Pos);

        CurrentWeaponIndex++;
}

希望这个函数更加直接,不需要太多关注。我们销毁任何当前存在的演员,获取我们索引设置为蓝图的那个演员,在世界中使用该蓝图的自定义类GeneratedClass创建一个,停止它旋转,修复其位置,并增加我们的索引。

那么,我们如何修复那个位置呢?通过最终使用一点基本的 3D 数学:

FVector AMasteringIconCamera::ComputeActorLocation()
{
        check(CurrentWeaponPickup != nullptr);
        UMeshComponent* Mesh = Cast<UMeshComponent>(CurrentWeaponPickup->GetComponentByClass(UMeshComponent::StaticClass()));

        FVector InPos;
        FVector BoxExtent;
        CurrentWeaponPickup->GetActorBounds(false, InPos, BoxExtent);

        // uncomment these to view the actor bounding generated for our pick-ups
        /*FVector CurrentPosition = CurrentWeaponPickup->GetActorLocation();
  FColor fcRandom(FMath::RandRange(64, 255), FMath::RandRange(64, 255), FMath::RandRange(64, 255));
  DrawDebugLine(World, CurrentPosition, CurrentPosition + InPos, fcRandom, false, 20.0f);
  DrawDebugBox(World, CurrentPosition + InPos, 0.5f * BoxExtent, FQuat(ForceInitToZero), fcRandom, false, 20.0f);*/

        // uncomment these to view the mesh bounding imported with the 
assets
        /*FBoxSphereBounds bsMesh = Mesh->Bounds;
  DrawDebugLine(World, CurrentPosition, bsMesh.Origin, fcRandom, false, 20.0f);
  DrawDebugBox(World, bsMesh.Origin, 0.5f * bsMesh.BoxExtent, FQuat(ForceInitToZero), fcRandom, false, 20.0f);*/

        const float fX = BoxExtent.X;
        const float fY = BoxExtent.Y;
        const float fZ = BoxExtent.Z;

        if (fX > fY)
        {
                FRotator YawRot(0.0f, 90.0f, 0.0f);
                CurrentWeaponPickup->SetActorRotation(YawRot);
        }

        const float fLongestBoxSide = FMath::Max(fX, FMath::Max(fY, 
        fZ));

        // FOV is the whole frustum FOV, to make a right triangle down its middle, we use half this angle
        const float FOVhalf = 0.5f * CameraComp->FieldOfView;
        const float FOVradians = FOVhalf * PI / 180.0f;

        const float FOVtan = FMath::Tan(FOVradians);

        float XDistance = fLongestBoxSide / FOVtan;

        FVector Positioning(XDistance, 0.0f, 0.0f);

        return CurrentWeaponPickup->GetActorLocation() + Positioning - InPos;
}

如果你对注释掉的边界框/偏移绘制块感到好奇,可以随意启用一个或两个,看看它们会显示什么。我之所以使用它们并保留它们,是因为SkeletalMeshActors主要在从外部工具(如 3D Studio MAX、Maya 等)导入资产时获取边界信息,这是由艺术家设置的。由于我发现《无尽之刃》武器的一些边界设置有些奇怪,所以我使用这个方法来确保资产确实是按照这种方式制作的,并且没有数学或其他编程错误。

在这个函数中,我们获取角色的边界范围,找到它的最长维度(X、Y 或 Z),并将其推回,直到最长边刚好在我们的视锥边缘。如果发现一个武器比它长更宽,我们将较大的侧面旋转以面向我们的摄像机。在拍摄之前,确定我们的角色在 X 轴上移动多远以最好地填充/适应屏幕,这只是一个简单的三角计算。我们可以获取摄像机视锥的视野,如果我们考虑一个从上到下的视图,将视锥从中间分开成两个直角三角形,我们知道要使最长边适应,我们使用视锥角度的一半的正切值来处理一个三角形。根据定义,这个正切是相对边长与相邻边长的比值,我们通过长边来除以知道现在要将对象推多远。我们还减去边界框本身的相对位置偏移(InPos),应该有一个相当居中的位置返回。

现在运行我们的图标地图应该会在项目的 Saved 文件夹中为每个拾取生成一个截图。这是一个好的 GitHub 检查点,我们将使用这些截图来最终制作一些 UI 元素。

使用 UMG 在屏幕上显示库存图标

在上一节的大量代码之后,我们将回到本节主要在蓝图和编辑器中工作。一个快速的旁白:当在编辑器中移动资产时,它会在你移动的资产后面留下一个重定向器.uasset文件。这仅仅是指向任何寻找旧文件的东西都指向新文件。你可以运行一个 Fix-Up Redirectors 命令行工具,它会搜索你的内容文件夹中所有这些以及任何引用它们的对象,将它们正确地指向新位置,并删除重定向器。这也可以通过在内容浏览器中手动完成,通过找到 Other Filters | Show Redirectors 过滤器设置,你可以在内容浏览器中右键单击它们并选择 Fix Up 来删除它们。我在这个点上做了这件事,以保持事情整洁。

现在在 FirstPersonCPP 下,我接下来创建一个 Textures 文件夹,并在内容浏览器中点击导入按钮:浏览到截图被添加的位置(/Saved/Screenshots/Windows)。在这里选择生成的.png文件(我的情况下是五个)并将它们全部导入为纹理。当项目变得更大时,有一个命名约定来搜索蓝图是很有用的,所以对于所有这些纹理,我简单地命名为T_(武器名称)。当然,在 UE4 的 C++中使用FileManager进行一些努力,我们可以巧妙地自动重命名.png文件,但将它们导入游戏内容作为纹理则要复杂一些——在这里批量选择它们并手动重命名对我们来说就足够了,因为我们接下来要做的任务是绘制它们在库存 UI 中。

同步您的库存和 HUD

实际绘制图标并循环它们是本书的这一部分,其中不会完全展示所有实现步骤。所有的工作始终可在 GitHub 上找到,并建议查看这个 Chapter 4 分支提交中的每个更改,但出于讨论的目的,重点将放在后续的新概念和决策上。毕竟,这是一本精通书籍,所以期待工作节奏和复杂性的提升。首先,做一些基本的整理,因为这种复杂性确实在增加:随着项目进入更成熟的状态,源文件的数量往往会增加,因此最好尽早开始将这些文件管理到逻辑目录中。实际上,在项目层次结构中组织项目的方式有两种:按功能或按系统。按功能分组源文件类似于(在我们的当前重组中)所有 UI 小部件类,可能还有为复杂专用类型设置的子文件夹。按系统分组将类似于(如我所做的那样)所有与库存相关的事物。虽然这些可能看起来像是微不足道的决定,或者使用现代 IDE 时可能感觉将每个类留在单一的扁平层次结构中是可行的,但项目规模和开发团队规模应该驱动你的决策。重要的是要尽早做出这样的架构决策,并在整个项目中坚持这些决策,以保持团队的连贯性。

好的,接下来是更有趣的部分!我们需要在 C++ 中创建的主要新类是 UUserWidget,如下所示:

UCLASS()
class MASTERING_API UMasteringInventoryDisplay : public UUserWidget
{
        GENERATED_BODY()

public:
        virtual void Init(class UMasteringInventory* Inventory);

        UFUNCTION(BlueprintImplementableEvent, Category = Inventory)
        void WeaponSelected(FWeaponProperties Weapon);

        UFUNCTION(BlueprintImplementableEvent, Category = Inventory)
        void WeaponAdded(FWeaponProperties Weapon);

        UFUNCTION(BlueprintImplementableEvent, Category = Inventory)
        void WeaponRemoved(FWeaponProperties Weapon);
};

现在,由于他们是这本书的新手,请注意 BlueprintImplementableEvent 的关键字:这些函数实际上不是在 C++ 中实现的,而是在头文件中声明的。它们的功能实际上来自于它们生成的蓝图事件。我们稍后会谈到这些,但现在让我们看看它们是如何被使用的,然后我们将追踪到它们是如何被触发的:

void UMasteringInventoryDisplay::Init(class UMasteringInventory* Inventory)
{
        Inventory->OnSelectedWeaponChanged.AddUObject(this, &UMasteringInventoryDisplay::WeaponSelected);
        Inventory->OnWeaponAdded.AddUObject(this, &UMasteringInventoryDisplay::WeaponAdded);
        Inventory->OnWeaponRemoved.AddUObject(this, &UMasteringInventoryDisplay::WeaponRemoved);
}

这里发生的事情是我们正在挂钩到 Inventory 类中的一个事件,该事件将在事件发生时向所有监听器(我们的绑定)广播,并将武器属性传递到我们的蓝图实现中。那么,我们是如何做到这一点的呢?首先,我们将这些事件添加到我们的库存类中:

DECLARE_EVENT_OneParam(UMasteringInventory, FSelectedWeaponChanged, FWeaponProperties);
FSelectedWeaponChanged OnSelectedWeaponChanged;

DECLARE_EVENT_OneParam(UMasteringInventory, FWeaponAdded, FWeaponProperties);
FSelectedWeaponChanged OnWeaponAdded;

DECLARE_EVENT_OneParam(UMasteringInventory, FWeaponRemoved, FWeaponProperties);
FSelectedWeaponChanged OnWeaponRemoved;

.cpp 文件中,经过大量的重构(以及一些我不太愿意承认的几个错误修复)后,我们会有这样的行:

void UMasteringInventory::SelectWeapon(FWeaponProperties Weapon)
{
        OnSelectedWeaponChanged.Broadcast(Weapon);

        MyOwner->EquipWeapon(Weapon.WeaponClass);
        CurrentWeapon = Weapon.WeaponClass;
}

类似地,为了添加武器以及新实施的武器移除功能(这是在弹药耗尽时进行的)。

对于这样的游戏,有一个设计思路来展示玩家的库存以及知道是否可能实现诸如弹药拾取而不是武器拾取这样的功能是很重要的。当然,如果你没有弹药,你甚至可以没有武器,所以我们将其从显示中移除。当然,你也可以将其变灰或类似操作,但再次强调,在所有时候,你的设计应该驱动你的实现决策。

因此,我们现在正在通过 C++将库存更改时的事件传递给我们的库存显示对象。让我们花点时间看看这个概念在蓝图方面的样子:

图片

这里有一些需要注意的事项,但将会简要说明:注意左侧的函数和变量。这些是仅通过+按钮添加的蓝图专用内容,以使所有这些工作。这就是为什么一些神秘的东西会保留下来,除非你前往 GitHub 查看所有实现。希望这些函数和变量的功能从命名中可以清楚地看出。随着库存物品的增加,小部件将被添加到 ScrollBox 小部件中。布局将随之而来,但重要概念是我们将跟踪武器类和图标小部件的并行数组,以便我们可以为选择和删除进行映射。这个函数展示了为什么不会直接在这里显示所有函数:

图片

这甚至不是一个特别复杂的函数,但已经有点紧凑,无法一次性完成。注意在打印字符串节点处有一个重定向节点。它们可以通过从任何执行引脚(白色引脚)拖动来添加,然后拖动以帮助有时解决视觉脚本可能创建的意大利面式代码。希望你也熟悉输入和输出变量(在左下角可见),蓝图函数对于快速实现非常有用,并且在与 UMG 小部件一起工作时几乎是必需的。一些 UI/UX 设计师可能对实现自己的功能感到舒适,但大多数情况下,这些角色将与右上角的“设计器”标签更相关。让我们快速看一下我们将绘制的这个滚动库存小部件,目前位于屏幕顶部:

图片

在右上角值得注意的是,ScrollBox 小部件在此情况下被标记为变量(你可以在蓝图脚本中看到它的引用),以及左侧 HorizontalBox 小部件的略微奇怪的排列,这基本上将 ScrollBox 夹在中间的 Canvas 小部件中,该 Canvas 小部件的大小是我们想要的。要正确设置所有这些,需要很多小部件设置,包括一个基于图像的小部件名为 InventoryItem,可以在 GitHub 项目中查看。寻找修改了什么以使事情按预期工作,始终只是搜索那些黄色循环箭头(如果你知道,点击会恢复值)以指示已修改的内容。

现在,为了使所有这些工作,还需要为 MasteringGameMode 和 MasteringHUD 添加蓝图,以便前者可以将后者设置为要使用的 HUD,并且主级别编辑窗口中的世界设置可以设置为使用该模式:

图片

注意,我没有将十字准星纹理硬编码,但将此类事物暴露给蓝图意味着需要添加额外的代码来处理它们是否设置正确。

因此,最终结果是我们可以现在滚动浏览我们的武器,看到哪个被选中(因为它是我们列表中唯一的全 alpha 对象),当我们循环手中的物品时,我们的 ScrollBox 中的图标也会循环,移除任何耗尽弹药的图标!

图片

如果你发现自己在编译小部件时遇到错误,请确保注意Mastering.Build.cs中的更改。这是你可以包含游戏可能需要的源模块的地方(到目前为止,我们不需要 UMG):

+ PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "UMG" });

最后一点(对于经验丰富的 GitHub 用户来说可能很熟悉):我提交了我的更改,然后移动我的文件来重新组织它们。GitHub 将移动视为旧文件的删除和新文件的添加,因此你会丢失更改历史,但可以在与这项工作相关的两个提交中看到。

现在库存已经同步并准备就绪!

使用 UMG 和游戏保存槽位

UE 4.19+为我们提供了一个很好的类,可以实际保存和加载数据块,用于我们想要保存的对象。在我们的案例中,这将是我们现在可以改变其状态或位置的每个演员,目前并不多。但随着游戏的发展,如果希望在场景内保存,尽早开始这个过程也非常重要。我们最大的挑战将是我们在第三章中实现的那个类,蓝图审查和何时使用 BP 脚本,其中几乎全部功能都是在蓝图端实现的。创建一个同时适用于原生 C++类和蓝图解决方案将是本节的全局目标。UMG UI 将比本章上一节更轻量。

创建保存槽位的控件

虽然本节的大部分工作将是实际实现各种演员类的加载和保存,但我们当然需要一个界面来向玩家展示这些信息,这正是我们将要做的。接下来的步骤是一个 UMG 小部件,我们可以通过按钮来激活它。因此,回到编辑器中,我们需要一个具有一些与 C++代码交互点的控件,这样我们就可以完成大部分工作。为了简化这个过程,我们将基于UUserWidget创建一个新的 C++类,命名为 MainMenuWidget,并将其添加到 UI 文件夹中。然后,就像之前一样,我们创建一个新的蓝图资产,并在其设计中添加 4 个按钮,如下所示:

图片

注意我们将它的初始可见性设置为隐藏,并在 ButtonBox 级别,锚点设置为屏幕中心,X 和 Y 对齐为 0.5。就像往常一样,请随时参考 GitHub 上的版本,以解决这里未具体讨论的所有 UMG/蓝图问题。

接下来,我们为每个按钮绑定点击事件。点击每个按钮,在其详情标签页底部会有输入事件,旁边有一个大绿色的+按钮。点击该按钮为每个按钮的OnClicked事件添加,你将被带到该事件处的蓝图图中,我们将添加这些函数到Widget类中:

UCLASS()
class MASTERING_API UMainMenuWidget : public UUserWidget
{
        GENERATED_BODY()

public:
        UFUNCTION(BlueprintCallable)
        void LoadGame(FName SaveFile);

        UFUNCTION(BlueprintCallable)
        void SaveGame();

        UFUNCTION(BlueprintCallable)
        void Open();

        UFUNCTION(BlueprintCallable)
        void Close();
};

将保存事件连接到SaveGame函数,并将加载事件连接到LoadGame函数,当然。Open是通过输入调用的:我们需要在玩家设置中绑定一个,就像我们过去做的那样。我将它设置为F10,因为在许多游戏中这是常见的,但当然可以是任何键、触摸或手势。在MasteringCharacter中,我将这个输入绑定到一个简单的透传函数,如下所示,并调用该 HUD 上具有相同名称的函数:

AMasteringHUD* HUD = Cast<AMasteringHUD>(CastChecked<APlayerController>(GetController())->GetHUD());

void AMasteringHUD::ToggleMainMenu()
{
        if (MainMenu != nullptr)
        {
                if (MainMenu->GetVisibility() == ESlateVisibility::Visible)
                        MainMenu->Close();
                else
                        MainMenu->Open();
        }
}

Widget类中,OpenClose函数值得一看,但这里只列出了Open,因为Close本质上是在Open的基础上反向操作,将输入模式设置为FInputModeGameOnly

void UMainMenuWidget::Open()
{
        checkSlow(GetVisibility() == ESlateVisibility::Hidden); // only want to open from closed
        SetVisibility(ESlateVisibility::Visible);

        UWorld* World = GetWorld();
        if (World != nullptr)
        {
                APlayerController* playerController = World->GetFirstPlayerController();
                if (playerController)
                {
                        playerController->bShowMouseCursor = true;
                        FInputModeUIOnly InputMode;
                        playerController->SetInputMode(InputMode);
                        UGameplayStatics::SetGamePaused(this, true);
                }
        }
}

现在,使用F10,在游戏中,我们的主菜单出现,当点击返回游戏按钮时,其事件只是现在在 widget 上调用关闭,这将暂停游戏并返回鼠标控制到我们的正常玩家输入。最后一个特殊事件,标记为退出游戏的按钮,有一个简单的蓝图节点调用,用于退出游戏(并退出独立模式),使用Execute Console Command节点,命令为exit

在项目后期,这被更改为退出游戏节点,因为当控制台命令可能不可用(发布版本、某些平台等)时,这会起作用。退出游戏节点也很不错,因为在移动平台上,它可以将您的应用程序发送到后台而不是完全结束其执行。请记住,iOS 和 Android 可以在操作系统决定需要其资源时有效地结束后台应用程序的执行;但再次强调,至少退出游戏节点在所有平台上都有效,并允许您选择尝试仅将其发送到后台。

目前菜单部分就到这里。现在我们需要实际保存我们的游戏,最终!

创建一个保存游戏文件

如本节顶部所述,我们的实际游戏和所有动态演员的状态是通过三个主要步骤完成的:

  1. 为所有需要保存的演员添加一个接口。这涉及到对我们移动平台的几个修改,我们将尽量保持其简单性。

  2. 将所有演员希望序列化的变量序列化到FArchive中,通过标记我们的UPROPERTIES

  3. 将此写入一个文件,然后我们可以从该文件反序列化所有内容。

对于非常简单的保存操作(例如玩家统计数据和当前关卡),请务必查看章节末尾的进一步阅读部分中的USaveGame文档链接。现在,让我们继续探讨相对复杂的版本。

首先,我们需要一个接口,我们将将其添加到所有我们关心的需要保存的演员中,这是我们第一次在编辑器外创建 C++类。

当从文件创建新的 C++ 项目时,通常最简单的方法是右键点击 Visual Studio 顶部的选项卡,打开包含文件夹,复制粘贴一个 .h.cpp 文件到上一级文件夹,根据需要重命名它们,然后将它们复制回正确的文件夹,然后通过右键点击 .uproject 或使用 第一章 中提到的批处理文件样式生成项目文件,为第一人称射击游戏创建 C++ 项目。当然,文件的内容需要替换。

.h 文件的头部应该看起来像这样:

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Serialization/ObjectAndNameAsStringProxyArchive.h"
#include "Inventory/MasteringInventory.h"
#include "SavedActorInterface.generated.h"

/**
 * 
 */
USTRUCT()
struct FActorSavedData
{
        GENERATED_USTRUCT_BODY()

        FString MyClass;
        FTransform MyTransform;
        FVector MyVelocity;
        FName MyName;
        TArray<uint8> MyData;

        friend FArchive& operator<<(FArchive& Ar, FActorSavedData& SavedData)
        {
                Ar << SavedData.MyClass;
                Ar << SavedData.MyTransform;
                Ar << SavedData.MyVelocity;
                Ar << SavedData.MyName;
                Ar << SavedData.MyData;
                return Ar;
        }
};

USTRUCT()
struct FInventoryItemData
{
        GENERATED_USTRUCT_BODY()

        FString WeaponClass;
        int WeaponPower;
        int Ammo;
        FString TextureClass;

        friend FArchive& operator<<(FArchive& Ar, FInventoryItemData& InvItemData)
        {
                Ar << InvItemData.WeaponClass;
                Ar << InvItemData.WeaponPower;
                Ar << InvItemData.Ammo;
                Ar << InvItemData.TextureClass;
                return Ar;
        }
};

USTRUCT()
struct FInventorySaveData
{
        GENERATED_USTRUCT_BODY()

        FString CurrentWeapon;
        int CurrentWeaponPower = -1;
        TArray<FInventoryItemData> WeaponsArray;

        friend FArchive& operator<<(FArchive& Ar, FInventorySaveData& InvData)
        {
                Ar << InvData.CurrentWeapon;
                Ar << InvData.CurrentWeaponPower;
                Ar << InvData.WeaponsArray;
                return Ar;
        }
};

USTRUCT()
struct FGameSavedData
{
        GENERATED_USTRUCT_BODY()

        FDateTime Timestamp;
        FName MapName;
        FInventorySaveData InventoryData;
        TArray<FActorSavedData> SavedActors;

        friend FArchive& operator<<(FArchive& Ar, FGameSavedData& GameData)
        {
                Ar << GameData.MapName;
                Ar << GameData.Timestamp;
                Ar << GameData.InventoryData;
                Ar << GameData.SavedActors;
                return Ar;
        }
};

struct FSaveGameArchive : public FObjectAndNameAsStringProxyArchive
{
        FSaveGameArchive(FArchive& InInnerArchive)
                : FObjectAndNameAsStringProxyArchive(InInnerArchive, true)
        {
                ArIsSaveGame = true;
        }
};

UINTERFACE(BlueprintType)
class USavedActorInterface : public UInterface
{
        GENERATED_UINTERFACE_BODY()
};

class ISavedActorInterface
{
        GENERATED_IINTERFACE_BODY()

public:
        UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Load-Save")
        void ActorLoaded();
};

BlueprintNativeEvent 的优点在于我们可以从 C++ 中触发这些事件,但它们在蓝图中被执行。我们需要做一些新工作的类是我们的移动平台,它再次,仅存在于蓝图定义中。将接口 BlueprintType 化意味着我们可以轻松地将它添加到我们的平台蓝图类中。因此,前往该类,以下是我们需要执行以正确保存到存档的步骤。打开移动平台类,点击顶部主菜单栏中的“类设置”,在右侧,你会看到“实现接口”,我们可以点击“添加”并选择“保存演员接口”以在蓝图侧添加此功能。一旦我们编译了蓝图,我们就可以添加一个事件,当演员被加载时。为了正确设置它到正确的状态,我们需要在左侧的“我的蓝图”选项卡上点击其两个变量,并在它们的“详细信息”选项卡中,点击向下箭头以显示其余选项,并勾选“保存游戏”选项,对于“回家”和“起始位置”蓝图变量。现在,当我们将平台序列化到存档时,这些将被保存和加载。理想情况下,我们会“套索”选择一组节点,右键点击并选择“折叠到函数”,但我们不能在这里这样做,因为像 MoveComponentTo 这样的异步节点必须保持在事件图层。但让我们为接口的“演员加载”添加一个事件,然后复制粘贴一些移动节点,确保如果平台需要移动,它移动的方向是正确的(基于“回家”变量)。告诉平台去它已经所在的地方没有害处,所以我们将设置在“回家”设置为移动到起始位置的情况下。之前修复的 on-actor-overlap 事件也略有改进。它将移动到 Z 轴上的起始位置 + 300,而不是当前位置。这样,我们就解决了有争议的最难案例,即该组的蓝图仅类。让我们将接口添加到我们的其他类中,并给他们一个通用的保存功能以及一些特定的功能(例如我们的 MasteringCharacter)。

MyData 将由所有我们用 SaveGame 标记的 UPROPERTY 项组成。目前,我们真正需要添加的这些之一是玩家的库存;但由于它有类引用和直接引用纹理和类的结构体数组,我们将自定义处理库存。

直接保存类和资产引用不适用于标记了UPROPERTIES的情况。如果一个对象引用了在关卡加载时将被创建的另一个对象,这可能有效,或者为了安全起见,你可以在修复过程中通过名称查找放置在世界的演员。大多数时候,你将类保存为字符串,然后重新生成该对象,正如我们在这里以及我们的存货特殊情况保存中将要广泛做的那样。

如果我们有其他基本类型(例如移动平台上的蓝图变量),只需在它们的定义中添加UPROPERTY(SaveGame),它们就会自动与演员数据一起序列化和反序列化。为了使存货加载和保存正常工作,我们需要一些新的结构体以及它们之间的序列化和反序列化,我们将在下一节中演示。由于它不是一个演员类,将其结构体放在与演员保存那些结构体相同的位置有点令人烦恼,但在这个复杂性的级别上,这似乎是最好的地方。所以现在,我们如何使用这个菜单、一些新的 UI 以及大量的保存和加载代码来保存我们关卡中任何时刻可能改变的所有内容,并正确加载回来?让我们现在就深入研究这个问题!

从我们的菜单保存和加载

保存我们的数据相对直接,但与所有加载/保存系统一样,在加载方面会稍微困难一些。这个功能几乎完全实现在MainMenuWidget类中,尽管我看到这个类可能会增长,可以将其移动到SavedActorInterface或类似的位置;但现在让我们继续进行已经完成的工作:

UCLASS()
class MASTERING_API UMainMenuWidget : public UUserWidget
{
        GENERATED_BODY()

public:
        UFUNCTION(BlueprintCallable)
        void LoadGame(FString SaveFile);

        UFUNCTION(BlueprintCallable)
        void SaveGame();

        UFUNCTION(BlueprintCallable)
        void Open();

        UFUNCTION(BlueprintCallable)
        void Close();

        UFUNCTION(BlueprintCallable)
        void PopulateSaveFiles();

        void OnGameLoadedFixup(UWorld* World);
        static TArray<uint8> BinaryData;

protected:
        UPROPERTY(BlueprintReadOnly)
        TArray<FString> SaveFileNames;
};

在保存方面,这里有很多事情在进行,其中一些只是基本的文件 I/O,但其他部分可能不太直观,这里将进行讨论:

void UMainMenuWidget::SaveGame()
{
        FGameSavedData SaveGameData;

        SaveGameData.Timestamp = FDateTime::Now();

        UWorld *World = GetWorld();
        checkSlow(World != nullptr);

        FString mapName = World->GetMapName();

        mapName.Split("_", nullptr, &mapName, ESearchCase::IgnoreCase, ESearchDir::FromEnd);

        SaveGameData.MapName = *mapName;

        TArray<AActor*> Actors;
        UGameplayStatics::GetAllActorsWithInterface(GetWorld(), USavedActorInterface::StaticClass(), Actors);

        TArray<FActorSavedData> SavedActors;
        for (auto Actor : Actors)
        {
                FActorSavedData ActorRecord;
                ActorRecord.MyName = FName(*Actor->GetName());
                ActorRecord.MyClass = Actor->GetClass()->GetPathName();
                ActorRecord.MyTransform = Actor->GetTransform();
                ActorRecord.MyVelocity = Actor->GetVelocity();

                FMemoryWriter MemoryWriter(ActorRecord.MyData, true);
                FSaveGameArchive Ar(MemoryWriter);
                AMasteringCharacter* Mast = Cast<AMasteringCharacter>(Actor);

                Actor->Serialize(Ar);

                if (Mast != nullptr)
                {
                        UMasteringInventory* Inv = Mast->GetInventory();
                        SaveGameData.InventoryData.CurrentWeapon = Inv->GetCurrentWeapon()->GetPathName();
                        SaveGameData.InventoryData.CurrentWeaponPower = Inv->GetCurrentWeaponPower();
                        for (FWeaponProperties weapon : Inv->GetWeaponsArray())
                        {
                                FInventoryItemData data;
                                data.WeaponClass = weapon.WeaponClass->GetPathName();
                                data.WeaponPower = weapon.WeaponPower;
                                data.Ammo = weapon.Ammo;
                                data.TextureClass = weapon.InventoryIcon->GetPathName();

                                SaveGameData.InventoryData.WeaponsArray.Add(data);
                        }
                }

                SavedActors.Add(ActorRecord);
        }

        FBufferArchive BinaryData;

        SaveGameData.SavedActors = SavedActors;

        BinaryData << SaveGameData;

        FString outPath = FPaths::ProjectSavedDir() + SaveGameData.Timestamp.ToString() + TEXT(".sav");

        FFileHelper::SaveArrayToFile(BinaryData, *outPath);

        BinaryData.FlushCache();
        BinaryData.Empty();

        APlayerController* playerController = World->GetFirstPlayerController();
        if (playerController)
        {
                playerController->bShowMouseCursor = false;
                FInputModeGameOnly InputMode;
                playerController->SetInputMode(InputMode);
                UGameplayStatics::SetGamePaused(this, false);
        }

        Close();
}

我们保存时间戳,然后使用_字符分割地图名称。这有点风险,因为你当然希望向关卡设计师明确指出不要将此字符添加到他们的地图名称中。在这种情况下,当我们正在玩并在 PIE 中进行测试时,它会在地图名称左侧附加一些以_结尾的东西,这样从末尾分割就可以得到例如FirstPersonExampleMap,而不包含在编辑器中玩游戏时最终出现在那里的 PIE 前缀。然后我们获取实现我们保存接口的所有演员的列表并迭代它们。然后我们总是保存相关演员的瞬态数据,同时也查看是否找到了我们的主角色以进行一些存货工作。如前所述,我们的存货保存(和加载)需要使用FInventoryItemData结构体,而不是直接使用FWeaponProperties结构体存货,因为后者直接引用了一个类和纹理(我们需要通过它们的路径/名称保存它们,以便正确序列化和然后从它们重新加载)。

我们设置所有相关信息,然后将其序列化为二进制输出并保存文件,文件名使用我们使用的时戳。当然,你也可以让用户选择名称,或者以其他方式保存,但至少在加载时可以清楚地显示这个存档是在什么时间保存的。

在我们保存后,我们将鼠标光标设置回隐藏,暂停游戏,并关闭主菜单。

在加载时,我们首先需要添加一些 UI 以允许玩家选择他们想要加载的存档(或取消):

注意切换器 UMG 项目。这使我们基本上可以从该层次结构向下切换(当然)小部件显示的内容。我们通过主菜单蓝图中的节点设置此选项,例如当我们在这里从加载按钮的点击事件打开新的加载部分时:

我们随后调用本地函数以获取所有存档文件:

void UMainMenuWidget::PopulateSaveFiles()
{
        FString dir = FPaths::ProjectSavedDir();
        FString fileExt = TEXT("sav");

        IFileManager::Get().FindFiles(SaveFileNames, *dir, *fileExt);
}

然后我们为每个项目(以主菜单作为成员)创建并添加一个按钮到加载游戏列表中。我们快速创建一个具有简单按钮和文本布局的用户界面蓝图,如下所示,它有一个任务要在点击时执行:

对于那些仔细观察的人来说,有几个点可以以不同的方式处理。我们将在本书的后面部分讨论它们。我想指出的是,我们加载(或重启)存档的地图,加载其数据,然后将其存储在静态成员变量中,该变量在地图重新加载后仍然存在,并由 HUD 的 BeginPlay 触发。我们将在第六章 Chapter 6,改变关卡,流式传输和保留你的数据中探讨如何在关卡之间切换并传递数据,所以如果你还有疑问,不要担心。此外,目前删除存档文件的唯一方法是转到你的内容文件夹,但鉴于我们在这里所做的所有工作,将删除功能添加到你的菜单中应该不会是问题。最后一点:以多种方式将一个小部件连接到另一个小部件也是可能的。在这种情况下,我只是当菜单创建时将菜单传递给按钮。使用巧妙的技术没有问题,但请记住,有时候选择最简单的解决方案可以节省时间,这对于每个游戏来说,在接近准备向公众发布时都是必需的。

好了,这就完成了!检查 GitHub,有一个带有一些更改的新关卡,但现在它只是用来证明从关卡到关卡以及再次返回的加载-保存功能。祝你在测试中玩得开心。

摘要

这一章将工作复杂性提升到接近精通水平的程度。经过一些热身工作后,确实到了深入研究这一层次细节并开始真正学习那些可以使游戏开发成功或失败的小技巧的时候了。这里的工作应该为多个层次的 UI 工作提供一个坚实的基础:我们制作了许多新武器,使用拾取物生成它们的截图图标,将这些图标添加回拾取物和我们的库存中,利用这个库存构建并同步武器滚动条,并为所有对象制作了菜单和整个任意保存系统,哇!与最初我们开始时的裸骨模板相比,项目终于开始更像一个真正的游戏了,但有一件事还缺失:敌人。但不用担心,我们将在下一章的结尾拥有它们。

问题

  1. 为什么在将文件移动到新文件夹位置之前,将任何更改提交到 GitHub 很重要?

  2. 创建一个在启动空关卡时不会移动或掉落的 NPC 需要哪些步骤?

  3. 在 UE4 中清理重定向器的两种方法是什么,为什么有时这样做很重要?

  4. 在父窗口中放置和间距其他小部件时,可以使用哪些 UMG 子部件?

  5. 使用BlueprintImplementableEvents在 C++与 UMG 之间通信的优势是什么?

  6. 在保存类变量时,哪些类型的UPROPERTIES绝对不能尝试序列化?

  7. 在这里,为了保存和恢复这些特殊类型,使用了哪种保存属性的替代方法?

  8. 使用几行代码实现匹配扩展名的文件列表构建的系统是哪个?

进一步阅读

使用USaveGame的简单保存:

docs.unrealengine.com/en-US/Gameplay/SaveGame/Code

第五章:添加敌人!

简介

对于大多数涉及战斗的游戏,通常都存在一种或多种形式的非玩家角色(NPCs)。NPCs 为游戏增添了真实感和互动性,控制它们的 AI 使它们看起来可信(或不可信),因此正确地处理这一点非常重要。充分利用 UE4 的 BehaviorTree 是制作具有吸引力的敌人和朋友的游戏的重要步骤。在本章中,我们将制作一个基本的近战敌人,它将使用来自世界的信息来更新其在 BehaviorTree 中的选择,并适当地对玩家做出反应。我们还将深入调试树及其数据。我们在这里的主要目标将是:

  • 将新角色和骨骼网格导入到游戏中

  • 创建一个新的 AIController 类

  • 构建一个基本的 Behavior Tree 大脑

  • 通过 C++添加感知功能

  • 将行为连接到 Animation Blueprint

技术要求

和往常一样,建议跟随所有前面的章节进度;然而,本章的大部分内容将作为一个独立项目工作,但有少数例外。

本章的 GitHub 分支,如往常一样,在这里:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-5

使用的引擎版本:4.19.2。

创建一个 AI 控制器和一个基本的大脑

就像在上一章添加新武器一样,我们希望为我们的 AI 敌人添加一些有趣的新视觉效果,所以让我们回到市场看看有什么免费提供的。天选者项目(该项目已被取消)由 Epic 发布了大量免费资产,所以让我们导入一个或多个他们的角色,我们可以用这些角色作为我们的敌人。然后我们给他们一个控制器,在我们的关卡中生成它们,看看它们的思维是如何做出决策,最终成为玩家战斗的挑战性对手!

验证基础

在市场中,为了验证我们的概念,GitHub 项目将首先以与第四章开头相同的方式导入 Paragon: Countess 资产,UI 需求 – 菜单、HUD 和加载/保存。请注意,为了节省大约 200 MB 的约 2 GB 的高清角色下载,GitHub 中移除了 Tier 2 皮肤,但当然,您可以使用 Paragon 项目发布的许多令人惊叹的高清角色中的任何一种。一旦角色被添加到项目中,要使其在游戏中做有意义的事情,有三个主要步骤:

  1. 创建一个新的AIController类并创建它的蓝图实例(在这里,它被称为MeleeAIController)。

  2. 为角色添加一个 AnimationBlueprint 并将其连接到新的角色。

  3. 向控制器添加一个带有一些节点的 BehaviorTree,使其开始,嗯,表现良好。

从行为树获取一些基本功能需要一些额外的基础知识,但让我们一步一步快速地通过这些,以获得游戏中的基本功能。

在创建 AI 控制器时,目前只需添加一个变量:

public:

        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Targeting")
        float HearingRadius = 300.0f;

在 FirstPersonCPP/AI 中创建一个蓝图实例,命名为 CountessController。

在 ParagonCountess | Characters | Global 中,创建一个新的动画 | 动画蓝图类。当你这样做时,它会要求选择一个父类和一个骨架:分别使用 AnimInstance 和 S_Countess_Skeleton。一旦创建,将其命名为ABP_Countess,双击它以打开其编辑器。如果你点击动画图标签页,你可以看到一个简单的节点,从其输入拖动到状态机并添加一个新的节点,双击它。从入口拖动并添加一个状态。命名为 Idle,双击它,我们就完成了一个非常临时的动画蓝图,只有一个快速节点,如下所示:

图片

要使用和测试我们的角色,首先在 FirstPersonCPP/AI 中基于 Character 创建一个新的蓝图类,命名为BP_Countess。我们将将其设置如下:

图片

注意胶囊根组件用于碰撞目的,以及网格的位置和旋转偏移,以在游戏中使事物看起来正确地放在地面上。此外,点击顶部的(self)组件,并将 AIController 设置为 CountessController。

现在是最相关的一部分,但请注意,目前这也会处于一个非常简化的状态,因为我们只是简单地阻塞出所有我们的类和需求。如前所述,每当有可能在基本级别上使事物工作并检查到源控制作为检查点时,即使整体工作发生变化(或类甚至被删除),这也是一个好主意。这为出错时提供了一个安全的回退点,并且当你在团队中工作时,它允许轻松共享你工作的状态,所以一旦我们的基本树就绪,我们将这样做。在我们的 AI 文件夹中,右键单击,在人工智能下添加一个行为树和一个黑板。分别命名为 MeleeTree 和 MeleeBoard。打开黑板,我们将使用编辑器右上角的添加按钮添加两个键。将一个设置为对象类型,另一个设置为向量,并在编辑器的属性右侧将它们命名为 Target 和 TargetLocation。接下来,打开行为树,我们首先点击顶部附近的一个按钮以添加一个新的服务。这将为您在内容浏览器中的同一文件夹级别创建它。将其重命名为BTService_FindTarget并双击它。这个服务作为可以在行为树中使用的蓝图脚本的片段。这是我们目前要检查的唯一的复杂部分,构建其图看起来如下:

图片

希望到现在为止,这已经很直接了:任何行为树服务的拥有者都是 AI 控制器。然后我们从那里获取我们的听觉半径,并对任何 MasteringCharacter pawn 进行球面扫描,如果我们找到了一个,我们就设置目标和目标位置变量(通过名称!)以更新到玩家位置。正如其名所示,黑板是一个可以发布来自各种外部位置的数据的地方,然后行为树可以直接内部访问。只需把它们想象成使用键/值对的变量持有者。

现在我们已经完成了这些,我们可以创建一个超级简单的行为树。首先,确认根的详细信息中的黑板设置为 MeleeBoard。接下来,在其主要编辑区域,向下拖动行为树的根并添加一个选择节点。关于选择器和顺序器的快速思考方式是,两者都执行一系列任务(当然,可以有子选择器和顺序器),但选择器从左到右运行其子节点,直到有一个成功(然后它返回控制权到树的上层)。选择器从左到右运行,直到其子节点之一失败。在这个选择器上,右键单击其节点并点击“添加服务”。当然,我们将选择我们的查找目标服务并点击它,这样我们就可以在其详细窗口的属性中直接删除随机时间间隔(设置为 0.0),使其每半秒运行一次。最后,从选择器拖动,选择一个任务并使用 MoveTo,在其详细窗口中设置其黑板键为 TargetLocation,并将其可接受半径设置为 100.0,这样她就不会太靠近而感到不舒服:

关于导航的说明:要生成这里行为使用的 nav-mesh,我们需要在级别中添加一个 NavMesh 体积,并使其覆盖你希望 AI 能够穿越的所有地方。从现在开始,添加这些体积(或在更详细的区域添加多个)应该是标准做法。将在 进一步阅读 部分添加一个关于 NavMesh 体积的快速链接。

图片

就这样!直接将 BP_Countess 拖动到你的等级中,无需进行适当的动画或平滑转向,她应该会跟随你在地图周围移动!我们在这个工作中已经达到了一个重要的检查点,这将在 GitHub 项目中得到体现。

将 C++ 决策添加到行为树中

下一个过程涉及将一些逻辑和感知从蓝图移出,进入 C++。这有几个原因可能很有价值。虽然黑板概念运行得很好,但它可能难以管理,有时甚至比典型的蓝图调试更难调试。如果一个变量突然不是你期望的值,并不总是有明显的追踪原因的方法。因此,在 C++ 中尽可能多地拥有合理的逻辑总是有帮助的。在这个例子中,我们实际上是将听觉检测和目标设置从前面的任务移到了 C++。

总是有利和不利的一面。例如,我们在这里的工作中不会真正获得新的功能,但随着复杂性的增加,我们获得了更可扩展的性能和更易于调试。如果不需要进一步的复杂性,那么最好在我们上一次停止的地方结束,并称之为一天。一个主要问题出现在同步两个层面,这是你必须做的。作为一个迭代开发的例子,我的第一个本能是使用OnTargetChange蓝图事件简单地设置我们的黑板变量,然后让黑板装饰者从目标对象中提取演员的位置。我记得有一次我这样做时遇到了问题:当时间播放开始时,黑板被告知开始,在同一个 tick 中,现在添加到玩家身上的球体将进行初始碰撞查询。当尝试使用边缘驱动的事件来设置目标时,它会在第一帧失败,因为没有黑板来设置变量,并且它将永远不会自行纠正,直到玩家离开听觉球体半径并重新进入。因此,这里最终实施了一个更拉动的混合解决方案,如下所示:

public:

        AMeleeAIController(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

        UFUNCTION(BlueprintImplementableEvent)
        void OnTargetChange(class AMasteringCharacter* Target);

        UFUNCTION(BlueprintCallable)
        class AMasteringCharacter* GetTarget();

        virtual void BeginPlay() override;

        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Targeting")
        class USphereComponent* HearingSphere;

        UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Targeting")
        float HearingRadius = 1000.0f;

protected:
    UFUNCTION()
    void OnHearingOverlap(UPrimitiveComponent* OverlappedComp, AActor* Other, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

    UPROPERTY()
    AMasteringCharacter* CurrentTarget = nullptr;
AMeleeAIController::AMeleeAIController(const FObjectInitializer& ObjectInitializer)
        : Super(ObjectInitializer)
{
        HearingSphere = CreateDefaultSubobject<USphereComponent>(TEXT("HearingSphere"));
        HearingSphere->InitSphereRadius(HearingRadius);
        HearingSphere->SetCollisionObjectType(ECC_Pawn);
        HearingSphere->SetCollisionProfileName("Trigger");

        HearingSphere->OnComponentBeginOverlap.AddDynamic(this, &AMeleeAIController::OnHearingOverlap);

        bAttachToPawn = true;
}
class AMasteringCharacter* AMeleeAIController::GetTarget()
{
        return CurrentTarget;
}

void AMeleeAIController::BeginPlay()
{
        Super::BeginPlay();

        HearingSphere->AttachComponentTo(GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
}

void AMeleeAIController::OnHearingOverlap(UPrimitiveComponent* OverlappedComp, AActor* Other, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
        AMasteringCharacter *Target = Cast<AMasteringCharacter>(Other);
        if (Target != nullptr && CurrentTarget != Target)
        {
                CurrentTarget = Target;
                OnTargetChange(CurrentTarget);
        }
}

现在这个工作的另一个优点是我们的蓝图 FindTarget 服务简化了很多,因此我们可以每0.1秒(或如果需要,每帧)调用它:

图片

现在你可以看到这个服务只是一个中继器,它接受控制器的目标,并注意它现在也使用 Nav Agent Location,因为这对于导航来说更准确(它通常将目标位置放置在试图导航的代理所在的同一平面上,因此足够近的距离现在是二维而不是三维测量,这更加直观)。

虽然这只是一个相对简单的改动,但早期设定这样的先例可以在后期节省大量时间。此外,请注意,在 CountessController 蓝图更改 Hearing Radius 值不会立即改变蓝图中的听觉球体值,但当一个新实例被生成时,构造函数确实会使用这个新值来为新实例设置正确的半径。为了在默认级别进行快速测试,现有的伯爵夫人被移动到最右边,现在只有当她距离玩家 6 米时才会被警报。

攻击玩家

这一节听起来似乎很简单,但在设计过程中,你需要填补几个缺失的部分:

  • 一个详细的动画蓝图,可以在移动和攻击时切换敌人的状态

  • 敌人被伤害和击杀的能力

  • 决定如何以及在哪里解决攻击成功(武器边界,即在前方敌人面前进行一帧锥形测试,也就是说,击中玩家需要有多真实?)

第二步通常涉及更多我们 UI 部分的工 作,玩家也应该能够被杀死并重生,但这些应该在所有其他课程之后才能完成,这里不会深入探讨。关于在敌人头上显示健康条等内容,请参阅章节末尾“进一步阅读”部分中关于 3D 小部件的链接。第三步对游戏复杂度相当主观:这将是一个玩家同时对抗多个敌人,还是 NPC 相互战斗,或者更多的是 1-3 个敌人一次对抗玩家的情况,给玩家带来绝对的真实感是关键?

因此,作为对 GitHub 上这次整体工作的增量检查,以下是一些需要注意的点,为我们的最终版本做好准备:伯爵夫人的动画蓝图现在设置为在移动时奔跑,她移动到攻击半径,如果被拖得太久没有达到目标,现在会回到起始位置,并且为视觉和潜行角色的极大减少听觉半径添加了新的感知模式:

void AMeleeAIController::OnHearingOverlap(UPrimitiveComponent* OverlappedComp, AActor* Other, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
        AStealthCharacter* StealthChar = Cast<AStealthCharacter>(Other);
        if (StealthChar != nullptr)
        {
                if (StealthChar->IsStealthed())
                {
                        return; // we let the stealthed sphere deal with these
                }
        }

        SetPotentialTarget(Other);
}

void AMeleeAIController::OnStealthHearingOverlap(UPrimitiveComponent* OverlappedComp, AActor* Other, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
        SetPotentialTarget(Other);
}

void AMeleeAIController::OnSightOverlap(UPrimitiveComponent* OverlappedComp, AActor* Other, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
        APawn* Owner = GetPawn();

        if (Owner == Other)
        {
                return;
        }

        FVector ToTarget = Other->GetActorLocation() - Owner->GetActorLocation();
        FVector Facing = GetPawn()->GetActorForwardVector();

        if (SightAngle > 90.0f)
        {
                UE_LOG(LogTemp, Error, TEXT("Sight Angles of 90+ degrees not supported, please use hearing for this detection!"));
                SightAngle = 90.0f;
        }

        if (FVector::DotProduct(ToTarget, Facing) < 0.0f)
        {
                return;
        }

        float DotToTarget = FVector::DotProduct(ToTarget.GetSafeNormal(), Facing.GetSafeNormal());
        float RadiansToTarget = FMath::Acos(DotToTarget);
        float AngToTarget = RadiansToTarget * 180.0f / PI;

        if (AngToTarget < SightAngle)
        {
                SetPotentialTarget(Other);
        }
}

void AMeleeAIController::SetPotentialTarget(AActor* Other)
{
        AMasteringCharacter* Target = Cast<AMasteringCharacter>(Other);
        if (Target != nullptr && CurrentTarget != Target)
        {
                CurrentTarget = Target;
                OnTargetChange(CurrentTarget);
        }
}

一个可能的改进是也对玩家进行视线测试。这不是一个困难的任务,但玩家可能会期望或可能会在将来没有添加它时将其视为一个错误。注意以下变化:

void AMeleeAIController::BeginPlay()
{
        Super::BeginPlay();

        HomeLocation = GetPawn()->GetNavAgentLocation();

    HearingSphere->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
    StealthHearingSphere->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
    SightSphere->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);

        OnReturnedHome();
}

OnReturnedHome将所有球体的半径设置为它们的设置变量长度,在构造函数中调用SetReturningHome,将它们全部减少到零。我在测试中注意到,如果在BeginPlay中的Attach调用之前让它们执行碰撞,那么在游戏的第一帧,如果你让它们执行碰撞,你会在世界原点与球体发生碰撞。这两个函数也用于一个新的行为树任务:

图片

为移动到目标添加了一个非常类似的新任务,因此我们不再需要担心蓝图或更新它(并且可以完全删除旧的任务查找服务),如我们新树中内置的 5 秒 kiting 流程所示:

图片

在默认地图中测试 kiting-timeout 的最简单方法就是吸引无数敌人的注意,跑到之前添加的移动平台上,并在之后保持在箱子顶部。一旦 5 秒内没有达到玩家通过(如果你小心的话,也可以通过从地图的一边跑到另一边来做这件事),你就会看到流程切换到移动到家的状态!

现在需要的最后部分是切换伯爵到攻击状态并对玩家造成伤害的方法,即使那些伤害的结果尚未处理。为了完成这个任务,我们需要在我们的动画实例中添加一个变量,并从行为树的任务中设置该变量。我们还需要一种方法来说明伯爵可以攻击,我们将在行为树中创建一个等待节点。最后需要的是快速处理伤害的方法,所以我们将向她使用的攻击动画中添加一个事件,当该事件触发时,我们检查玩家是否在她前面,如果是这样,她就会击中玩家。

将 C++类与所需状态同步的最直接方法是在控制器类中放置一个枚举,并使其具有对动画实例的引用,并在其上设置变量。这种方法有缺点,即这些更新在不同的线程上。而且,这些更新甚至不是确定的,所以你可能会有一个 C++控制器更新,动画实例更新,动画实例再次更新,然后是 C++控制器再次更新,反之亦然。所以如果你选择让 C++逻辑决定动画蓝图的状态,请注意,在想要改变和另一个认识到它之间可能会有多达 2 帧的延迟。如果你直接从动画蓝图更新那个状态,确保始终有一个更新点,通常是控制器,并且强烈建议将其作为一个队列,在帧之间同步,然后继续到下一个队列条目。

由于我们的 AI 相当简单,我们将在其蓝图上添加一个攻击布尔变量,然后我们需要一个新的行为树任务,就像之前一样,看起来是这样的:

图片

注意,它只是将我们设置为动画蓝图中的攻击状态,等待一段时间,然后清除它(这样在它完成后我们不会立即重新进入)。并且树有一个相当简单的更新,如下所示:

图片

攻击后的等待时间是冷却时间,所以你会看到我们的伯爵在攻击玩家时等待那个时间,如果她在范围内,或者如果她不在范围内,她将移动到目标节点后延迟移动。

当然,动画蓝图必须反映这些,所以再次添加一个攻击布尔蓝图变量,并使转换看起来像这样(我们等待攻击动画接近结束时返回空闲状态,如果是从空闲或移动状态,当行为树设置我们的攻击变量时,我们进入攻击状态):

图片

因此,AI 攻击的剩余部分只是查看我们是否击中了角色。再次强调,由于篇幅原因,玩家死亡和 UI 在此处未涵盖,但在完成这项工作后应该相当简单。我们现在需要的只是一个攻击动画的通知事件,一个处理此事件的动画蓝图事件,以及控制器上的一点点逻辑,所有这些都在蓝图上。所以,首先转到控制器并添加此蓝图函数:

如您所见,当玩家在伯爵夫人前方 1.5 米或更近时,这将在屏幕上显示,因此被击中,以及受到多少伤害。再次强调,将此转换为游戏以杀死并重新生成玩家应该在这个阶段相当简单。接下来,转到动画(在动画蓝图中的攻击节点中设置的是Primary_Attack_A_Normal)并右键单击时间轴,在底部单击“添加新通知”,并命名为:

然后转到动画蓝图的事件图,右键单击以添加一个通知事件:

因此,我们的 AI 追逐玩家,在间隔时间内攻击,当被风筝时跑回家,并且在击中完成时传递受到的伤害。恭喜,我们有一个功能齐全的 AI 敌人(在下一段中添加一些改进)!而且,像往常一样,这些最新的更改都在 GitHub 上,所以请随意在那里详细检查所有这些更改。

更精致的战斗 - 生成点、击中反应和死亡

因此,我们现在有一个喜欢追逐我们的玩家并试图伤害他们的 AI,这很好。但在这个阶段,我们的玩家几乎无法与那个敌人互动。我们将通过从生成点演员生成敌人而不是直接在级别中放置它们来使事情更加专业(这样我们就可以生成多个敌人,等待它们被触发,等等),当它们被射击时让它们做出反应(这不应该打断它们的其它活动,而只是通过混合动画叠加在它们上面),当然,能够杀死它们(并且当加载/保存时反映所有这些)。

敌人放置的生成点

生成点是在处理大多数射击游戏时非常有用的工具。在多人游戏中,玩家在重生时使用它们,AI 通常以波或固定数量冲向前方,对玩家构成挑战。在这里,我们将快速简单地创建一个生成点,将实例放入默认级别,并测试一切是否正常。首先,在 AI 文件夹中,右键单击并创建一个新的蓝图类,这里命名为 Countess Spawner。

这里的逻辑是,当玩家重叠盒子时,我们通过使用一个简单的场景组件和一个附加的横幅(设置为仅编辑器使用并在游戏中隐藏)在想要的位置生成我们的伯爵夫人实例。然后我们有一些逻辑来生成 BP_Countess 类型,并保持已生成实例的数组,当它们死亡时更新该数组,并且只有当数组低于并发计数变量时才会生成:

如地图所示,这个缩放版本被放置在一些盒子之间,其生成位置移动到地图的角落,以便当玩家穿过这些盒子时,在角落出现一个伯爵夫人,但最多只能有三个,直到其中一个被杀死,然后另一个被允许。注意,为了做到这一点,我们必须对我们的控制器进行一些关键更改。首先,删除BeginPlay函数,并用Possess(APawn* InPawn)替换它(保持内容不变)。

这是因为BeginPlay可能在拥有生成的兵卒的过程之前被调用(并且将会被调用)。同样,在OnSightOverlap中也添加了一个空检查,出于同样的原因(组件可能在设置拥有者兵卒之前测试击中)。接下来,在 BP_Countess 本身中,将其自动拥有属性设置为放置在世界中或已生成,以便 AI 控制器实际上可以运行。注意,还有一个特定的从类蓝图节点,类似于生成演员,但它以稍微不同的方式做事,并且由于我们的大部分工作已经由我们的控制器完成,因此自动处理是这里最好的选择。

这应该就是我们的生成点。当然,它们可以通过波生成、随机生成位置、计时器或任何对游戏有意义的逻辑和有趣的方式变得更加复杂。但重要的是,现在这可以在任何地方重用,理论上可以生成大量的有趣遭遇,涉及各种 AI。

击中反应和死亡

到目前为止,你可以随意射击我们的伯爵夫人,但没有任何有趣的事情发生。我们将通过修改我们的投射物的一些代码以及一些可以捆绑到近战控制器中的显著逻辑来解决这个问题。快速浏览一下,我们将向近战控制器添加一个健康参数,如下所示:

UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Health")
float Health = 100.0f;

然后修改投射物的击中效果,如下所示:

void AMasteringProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
        // Only add impulse and destroy projectile if we hit a physics
        if ((OtherActor != NULL) && (OtherActor != this) && (OtherComp != NULL))
        {
                AController* controller = GetInstigatorController();
                AActor* damager = GetInstigator();
                FDamageEvent DamEvt;

                OtherActor->TakeDamage(DamageAmount, DamEvt, controller, damager != nullptr ? damager : this);

                if (OtherComp->IsSimulatingPhysics())
                {
                        OtherComp->AddImpulseAtLocation(GetVelocity() * 100.0f, GetActorLocation());
                }
                else if (Cast<APawn>(OtherActor) != nullptr)
                {
                        Destroy();
                }
        }
}

在武器中生成投射物时,添加以下行:

ActorSpawnParams.Instigator = Cast<APawn>(GetOwner());

然后为了让我们的伯爵夫人对击中做出反应并死亡,我们需要在控制器蓝图和动画蓝图中添加一些东西。首先,让我们来做控制器:

这样,当兵卒受到攻击并受到伤害时,控制器会收到通知,受到伤害函数(通过创建逻辑,然后右键单击所有这些逻辑节点并选择“折叠到函数”来创建)看起来是这样的:

Get ABP 函数只是获取我们的受控 NPC,将其转换为 BP_Countess,获取其动画实例,并在输出(返回)节点中将它转换为 ABP_Countess。Hit Reacting(浮点数)和 Dying(布尔值)变量也直接添加到 ABP_Countess 中,并按以下方式使用:

HitReact 状态机只有一个状态,它所做的只是播放伯爵夫人的击中反应(正向)动画。将 APply Additive 混合插入我们的根图中,并将此变量作为权重,将其混合进去。这是一个加性动画,所以你可以看到在这个阶段当她受到伤害时,她的头部会猛地一跳,但她的其他动画仍然正常进行,完美。在动画的末尾,添加了一个新的通知,并在事件图中为这个事件清除混合到0.0

以及死亡:

注意单向转换——一旦死亡就无法返回。这个主图节点只播放伯爵夫人的死亡动画,并在其结束时添加了另一个通知。在事件图中,当该事件被触发时,尝试获取拥有者 NPC 的行为会被销毁。现在,伯爵夫人受到伤害,做出反应,然后死亡,这是我们希望 AI 能够做到的大部分事情!

加载/保存说明

加载和保存 AI,在这种情况下,不是一个简单的选择。在这种情况下,我将大部分逻辑移到了生成器,所以如果它们直接放置在级别中,损坏的 AI(例如)的保存将不起作用。你可以通过在 BP_Countess 中设置演员保存接口来撤销这一点,但这样,就没有办法正确修复生成器以使其与它们生成的 AI 有链接。

作为一般规则,就像这里所做的那样,如果你能让你的 AI 从它在世界中的出生点恢复其状态,你将比试图保存它在保存时的每一个细节要好得多。当然,这可能会导致滥用(例如,一个 AI 在保存时即将发动一次非常强大的攻击,然后加载时又回到空闲状态并开始正常攻击),但随着 AI 复杂性的增加,加载/保存的复杂性可能会变得非常糟糕。如果可能的话,将 AI 放入世界并让它自己决定接下来应该做什么,就像我们在我们的生成器类中所做的那样。

我们的伯爵生成器添加了两个新的变量,一个位置数组和一个健康值数组。这些在保存时存储,并在生成器的蓝图中将它们设置为 SaveGame 变量,就像我们之前为移动平台的状态所做的那样,并在加载时重新生成和恢复。这确实需要将演员保存接口添加到生成器中(再次,就像我们对平台所做的那样),并标记这两个变量,但还需要向保存的演员接口添加一个新的本地事件:

UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Load-Save")
void ActorSaved();

然后,我们在保存时调用它,就像我们为加载版本所做的那样,目前是在MainMenuWidget.cppSaveGame函数的大约第 82 行:

ISavedActorInterface::Execute_ActorSaved(Actor);

现在在伯爵生成器中,触发这些事件(在类设置中添加界面,就像之前一样),我们可以添加两个新功能,一个用于加载伯爵,一个用于保存。首先,让我们看看保存:

图片

然后我们有加载事件:

图片

哼!我们的伯爵夫人现在在正确的位置,拥有正确的健康状态,但她们可能不在我们保存她们时 AI 的精确步骤上,但她们会很快调整。这里的一个已知问题是,如果 AI 在死亡时被保存,它需要再次受到伤害才能再次开始死亡,但通过一些特殊的工作,这也可以得到解决。但总的来说,我们现在有一个完全功能的 AI,它会追逐玩家,伤害玩家,可以受到伤害并对打击做出反应,死亡,并且可以合理地加载和保存。

摘要

因此,这又是一个充满许多许多变化和新内容的风暴章节。从我们之前的位置来看,我们现在有一个功能齐全、外观出色的敌人,其表现正如我们在精良的游戏中预期的那样,并且还能保存我们的进度。这是在制作严肃游戏方面的一次又一次的飞跃。在所有这些之后,有些人可能会问:Epic 有没有内置的 AI 感知系统?是的,他们确实有,你完全可以探索;但大多数情况下,需要一个自定义和可调试的版本,我们在这里已经详细地实现了。尽管如此,如果内置的感知及其事件对你的项目来说非常完美,那么确实可以考虑它们。但在制作 AI 的整体工作量中,编写自己的感知代码可能是要求较低(而且说实话更有趣!)的部分。所以,现在我们有了坚实的游戏核心,我们如何策略性地从一级跳到另一级,或者通过一个比这些测试级别有更多内容的大型世界呢?嗯,这就是下一个问题!

问题

  1. 制作 UE4 中的适当 AI 需要哪三个主要类?

  2. 黑板在 AI 逻辑中有什么作用?

  3. 目标选择是如何从 C++引入我们的行为树的?

  4. 行为树中序列器和选择器之间的区别是什么?

  5. 行为树是如何得知它应该攻击并停止向玩家移动的?

  6. 随着蓝图逻辑复杂性的增长,有什么好方法来划分逻辑的各个部分?

  7. AI 生成器为游戏设计带来了哪些优势?

  8. 完美的 AI 状态加载/保存与简单的加载/保存和逻辑恢复状态之间的权衡是什么?

进一步阅读

导航网格信息:

docs.unrealengine.com/en-us/Resources/ContentExamples/NavMesh

3D 小部件:

docs.unrealengine.com/en-us/Engine/UMG/HowTo/InWorldWidgetInteraction

第六章:改变层级、流式传输和保留数据

简介

在 UE4 中切换层级有两种类型。一种是非常简单的:卸载旧层级,加载新层级,完成(如果这就是你需要的,这一章可能非常简短!)。第二种方法是在连续流式传输层级进出的过程中具有更多挑战,但也可以提供更丰富的用户体验。在这两种情况下,问题仍然是:在加载这些层级时,哪些游戏元素或玩家数据会与你一起保留?拥有这些数据持久化可能是一个真正的挑战,我们在这里也会探索一些解决方案,以便无论选择哪种方法,最终玩家都能有一个连续的游戏体验,希望这对那些从事关卡设计的人来说不会太有压力!我们的目标如下:

  • 涵盖 UE4 中地图切换的基础知识

  • 为过渡到新层级添加一个新层级

  • 将加载/保存系统调整为在地图转换之间持久化状态

  • 创建从持久层级进行层级流式传输的实用示例

技术要求

如往常一样,建议从第五章的结尾“添加敌人!”开始,以保持连贯性,但这绝对不是必需的。这里的所有课程都应能够应用于任何 UE4 项目在任何开发阶段的任何项目。

对应的 GitHub 项目可以在以下位置找到:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-6

使用的引擎版本:4.19.2。

传统层级加载

改变层级(地图)一直是虚幻引擎自诞生以来的固有系统。因此,自然地,随着时间的发展,已经出现了许多与之相关的工具和选项。话虽如此,我们已经有所有进行基本层级转换所需的工具,但了解这些工具仍然是有益的,我们将在本节中更新我们的加载和保存,以保持在这些切换期间的游戏进度和状态。首先,快速回顾一下 UE4 中层级转换的基础。

基础知识

蓝图和 C++都内置了改变层级的方式。在我们这里快速使用的蓝图示例中,它是可以从蓝图中的大多数地方访问的 Open Level 节点。在 C++中,我们已经在加载/保存时使用过它(尽管请注意,对于基于服务器的游戏,你通常会传递所需的层级作为第一个参数;这里,我们只是使用它来使用"?Restart"特殊字符串重新启动现有层级):

if (mapName == currentMapName)
{
        World->ServerTravel("?Restart", true);
}
else
{
        UGameplayStatics::OpenLevel(World, *mapName, true);
}

因此,在游戏中触发这些操作大约是最简单不过的了。在我们的默认 FirstPersonExampleMap 中,我添加了一些方块,以模拟一个位于我们之前添加的移动平台顶部的传送门。

如果你查看 GitHub,会发现有一个用于修复移动平台的 Stash,因为它们可以在移动回下方的过程中将玩家(或 NPC)挤压出关卡。这不是一个完美的解决方案,但它简单地禁用了平台移动回下方的碰撞,并在它到达底部时重新启用(还修复了一些平台上的其他小逻辑)。然而,由于这并不是本章的具体目标,所以它被存档而不是直接提交到 GitHub 项目。

在这个过程中,我添加了一个简单的触发器体积,来自左侧的体积集(甚至不需要新的蓝图类或实例):

图片

选中它后,通过点击主菜单栏顶部的蓝图按钮,选择打开关卡蓝图。在里面,当体积被选中并且你右键点击时,在最顶部你可以访问该特定对象在关卡中的特定选项。我们将使用它的重叠事件,检查是否是玩家,如果是,就像这里所示打开 MasteringTestLevel:

图片

就这些。真的。只需走进触发器,我们就会被发射到另一个关卡。如果这就是你项目的需求,那么最好在这里停下来,保持生活简单!

使用加载/保存进行过渡

然而,大多数游戏,尤其是那些有基于故事的单人模式或类似模式的游戏,都希望玩家离开后的状态以及关卡本身的状态能够跨这个过渡过程持续存在。如果我们像上一节那样简单地创建一个返回到FirstPersonExampleMap的传送门,你会在切换回来和去的时候注意到,那些关卡中的玩家、物品、NPC 等似乎是我们刚刚打开的,而不是我们离开时的状态,玩家的库存也被重新加载。所以现在我们实际上需要两个不同的东西:

  • 在任何关卡加载过程中保持玩家数据完整

  • 在切换时加载和保存该关卡及其动态对象的状态

幸运的是,我们已经在实现动态的任意时间加载和保存,所以我们将只修改这一点,并利用它来处理关卡状态。但是,我们需要一些新的方法来跨地图变化持久化数据。这里有几种选择(一些肯定比其他更安全或更干净),但针对我们的游戏,我们将创建一个新的UGameInstance C++类,并像单例一样使用它来保存将在游戏运行整个生命周期内持续的数据和信息。

任何引入静态或全局数据的时候,您都必须非常小心,因为它可能会对诸如在编辑器中的游戏等事物产生不可预见的影响,尽管您停止了游戏,只要编辑器没有关闭和重新打开,任何静态数据都会持续存在,因此您不能指望在启动和重新启动 PIE 与游戏的独立版本(例如,在设备或其他平台上)之间有相同的行为。我们将为我们的示例添加一些安全措施,但尽可能谨慎并最小化危险数据存储!

我们将命名新的游戏实例为MasteringGameInstance并给它一些单例功能:

UCLASS()
class MASTERING_API UMasteringGameInstance : public UGameInstance
{
        GENERATED_BODY()

public:
        UFUNCTION(BlueprintCallable, Category = "Mastering Game Instance")
        static UMasteringGameInstance* GetInstance();

        virtual void Init() override;
        virtual void BeginDestroy() override;
        virtual void FinishDestroy() override;

        bool ShouldPersistInventory() const
        {
                return bPersistPlayerInventory;
        }

        void SetPersistInventory(const bool bPersist = true)
        {
                bPersistPlayerInventory = bPersist;
        }

        void SetPlayerInventory(class UMasteringInventory* Inv);

        FORCEINLINE class UMasteringInventory* GetInventory() const
        {
                return PlayerInv;
        }

        void SetPlayerSafeLocation(const FVector& InLoc)
        {
                PlayerSafeLocation = InLoc;
        }

        FORCEINLINE FVector GetPlayerSafeLocation() const
        {
                return PlayerSafeLocation;
        }

        void ClearData();

protected:

        UPROPERTY()
        class UMasteringInventory* PlayerInv;

        bool bPersistPlayerInventory;
        FVector PlayerSafeLocation;
};

如您所见,这为我们提供了一些我们希望能够在加载和保存周期中持续存在的持久数据。

接下来,一些辅助函数来使所有这些工作正常,并使设置玩家的库存和清理游戏实例中的我们自己的状态变得简单:

static UMasteringGameInstance* Instance = nullptr;

UMasteringGameInstance* UMasteringGameInstance::GetInstance()
{
        checkf(Instance != nullptr, TEXT("Someone is trying to use the game instance before it has initialized!"));

        return Instance;
}

void UMasteringGameInstance::Init()
{
        Super::Init();

        Instance = this;

        AddToRoot();
}

void UMasteringGameInstance::BeginDestroy()
{
        ClearData();
        Super::BeginDestroy();
}

void UMasteringGameInstance::FinishDestroy()
{
        Super::FinishDestroy();
}

void UMasteringGameInstance::SetPlayerInventory(class UMasteringInventory* Inv)
{
        if (PlayerInv == nullptr)
        {
                PlayerInv = NewObject<UMasteringInventory>(this, TEXT("PlayerInventory"));
        }

        PlayerInv->CopyFromOther(Inv);
}

void UMasteringGameInstance::ClearData()
{
        bPersistPlayerInventory = false;
        PlayerInv = nullptr;
        PlayerSafeLocation = FVector::ZeroVector;
}

在这一点上,BeginDestroyFinishDestroy可能不需要都在这里;但通常对于像这样的静态数据持有者来说,同时拥有它们是个好主意。在FinishDestroy中,如果没有其他事情,您可以添加一些安全措施来确保在会话之后没有代码使用这个GameInstance的同一实例而不先调用Init,并清理任何剩余的问题(如果您想更加安全,甚至可以将Instance设置回nullptr)。作为参考,BeginDestroy是立即清除任何其他类可能会查找并使用的数据的地方。一旦我们开始认真进行加载/保存任务,我们将有几个这样的地方(例如,您绝对不希望在加载/保存周期中退出游戏,然后在重新启动时变量设置为加载/保存开始时的设置)。确保这一点指向设置,项目设置,然后在地图和模式中,将游戏实例设置为MasteringGameInstance

接下来,为了使我们的游戏加载代码更加精简和抽象,我们将创建一个新的蓝图函数库 C++类(根据定义,这是一个静态函数的集合,这正是我们在这里需要的,即使它不一定从蓝图中调用):

class MASTERING_API LoadSaveLibrary
{
public:
        LoadSaveLibrary();
        ~LoadSaveLibrary();

        UFUNCTION(BlueprintCallable)
        static void LoadGameFile(FString SaveFile, UWorld* World);

        UFUNCTION(BlueprintCallable)
        static void SaveGameFile(FString SaveFile, UWorld* World);

        static void OnGameLoadedFixup(UWorld* World);

        static void FixupPlayer(UWorld* World, class AMasteringCharacter* Char);

protected:
        static TArray<uint8> BinaryData;
};

并且重构MainMenuWidget类,将大量代码移动到这里,使其在未来的使用中更加合适,使这个 UI 类更加专注和具体。此外,我们现在有一些额外的修复代码,它调整了玩家的位置,并考虑到在加载周期中持续当前库存,就像我们返回之前访问过的关卡时想要的那样:

TArray<uint8> LoadSaveLibrary::BinaryData;

LoadSaveLibrary::LoadSaveLibrary()
{
}

LoadSaveLibrary::~LoadSaveLibrary()
{
}

void LoadSaveLibrary::LoadGameFile(FString SaveFile, UWorld* World)
{
        FString outPath = FPaths::ProjectSavedDir() + SaveFile;
        if (!FFileHelper::LoadFileToArray(BinaryData, *outPath))
        {
                UE_LOG(LogTemp, Warning, TEXT("%s"), *(FString("Game Load Failed: ") + outPath));
                return;
        }

        checkSlow(World != nullptr);
        FMemoryReader FromBinary = FMemoryReader(BinaryData, true);
        FromBinary.Seek(0);

        FGameSavedData SaveGameData;
        FromBinary << SaveGameData;

        FromBinary.FlushCache();
        FromBinary.Close();

        UMasteringGameInstance* gameInst = UMasteringGameInstance::GetInstance();
        FVector playerSafeLoc = SaveGameData.PlayerSafeLocation;
        gameInst->SetPlayerSafeLocation(playerSafeLoc);

        FString mapName = SaveGameData.MapName.ToString();

        FString currentMapName = World->GetMapName();

        currentMapName.Split("UEDPIE_0_", nullptr, &currentMapName);

        if (mapName == currentMapName)
        {
                World->ServerTravel("?Restart", true);
        }
        else
        {
                UGameplayStatics::OpenLevel(World, *mapName);
        }
}

在上方,您可以看到加载方法,如果我们打算进入我们已经在的世界,我们基本上告诉世界重新启动,否则,我们打开新关卡。我们还将玩家的当前位置保存为“安全”,因为我们有信心这是一个玩家可以占据的有效位置,如果我们稍后需要返回这个关卡,我们可能需要它。

void LoadSaveLibrary::SaveGameFile(FString SaveFile, UWorld* World)
{
        checkSlow(World != nullptr);
        FGameSavedData SaveGameData;
        FString outPath = FPaths::ProjectSavedDir() + SaveFile;

        SaveGameData.Timestamp = FDateTime::Now();

        FString mapName = World->GetMapName();

        mapName.Split("UEDPIE_0_", nullptr, &mapName);

        SaveGameData.MapName = *mapName;

        UMasteringGameInstance* gameInst = UMasteringGameInstance::GetInstance();
        SaveGameData.PlayerSafeLocation = gameInst->GetPlayerSafeLocation();
        gameInst->SetPlayerSafeLocation(FVector::ZeroVector); // so this will not be valid in future saves unless set again

        TArray<AActor*> Actors;
        UGameplayStatics::GetAllActorsWithInterface(World, USavedActorInterface::StaticClass(), Actors);

        TArray<FActorSavedData> SavedActors;
        for (auto Actor : Actors)
        {
                FActorSavedData ActorRecord;
                ActorRecord.MyName = FName(*Actor->GetName());
                ActorRecord.MyClass = Actor->GetClass()->GetPathName();
                ActorRecord.MyTransform = Actor->GetTransform();
                ActorRecord.MyVelocity = Actor->GetVelocity();

                FMemoryWriter MemoryWriter(ActorRecord.MyData, true);
                FSaveGameArchive Ar(MemoryWriter);
                AMasteringCharacter* Mast = Cast<AMasteringCharacter>(Actor);

                ISavedActorInterface::Execute_ActorSaved(Actor);

                Actor->Serialize(Ar);

                if (Mast != nullptr)
                {
                        UMasteringInventory* Inv = Mast->GetInventory();
                        SaveGameData.InventoryData.CurrentWeapon = Inv->GetCurrentWeapon()->GetPathName();
                        SaveGameData.InventoryData.CurrentWeaponPower = Inv->GetCurrentWeaponPower();
                        for (FWeaponProperties weapon : Inv->GetWeaponsArray())
                        {
                                FInventoryItemData data;
                                data.WeaponClass = weapon.WeaponClass->GetPathName();
                                data.WeaponPower = weapon.WeaponPower;
                                data.Ammo = weapon.Ammo;
                                data.TextureClass = weapon.InventoryIcon->GetPathName();

                                SaveGameData.InventoryData.WeaponsArray.Add(data);
                        }
                }

                SavedActors.Add(ActorRecord);
        }

        FBufferArchive SaveData;

        SaveGameData.SavedActors = SavedActors;

        SaveData << SaveGameData;

        FFileHelper::SaveArrayToFile(SaveData, *outPath);

        SaveData.FlushCache();
        SaveData.Empty();
}

保存是真正的工作所在,遵循流程可能有点棘手;但如果您折叠上述演员循环的实现,其余部分就相当直接了,获取安全位置,清除安全位置(为了清晰),然后迭代这些演员,序列化到我们的输出,并将其保存为文件。

void LoadSaveLibrary::FixupPlayer(UWorld* World, class AMasteringCharacter* Char)
{
        UMasteringGameInstance* gameInst = UMasteringGameInstance::GetInstance();

        // Assuming we found our player character and saved out some inventory, this is where we do its custom serialization and fix-up
        if (Char != nullptr)
        {
                if (!gameInst->GetPlayerSafeLocation().IsZero())
                {
                        Char->SetActorLocation(gameInst->GetPlayerSafeLocation());
                }

                if (gameInst->ShouldPersistInventory())
                {
                        UMasteringInventory* NewInv = NewObject<UMasteringInventory>(Char, TEXT("PlayerInventory"), RF_Transient);

                        checkf(gameInst->GetInventory() != nullptr, TEXT("Game Instance is trying to persist inventory with no inventory setup!"));
                        NewInv->CopyFromOther(gameInst->GetInventory(), Char);

                        Char->SetInventory(NewInv);
                        NewInv->SetupToCurrent();
                }
                else if (BinaryData.Num() > 0)
                {
                        FMemoryReader FromBinary = FMemoryReader(BinaryData, true);
                        FromBinary.Seek(0);

                        FGameSavedData SaveGameData;
                        FromBinary << SaveGameData;

                        UMasteringInventory* NewInv = NewObject<UMasteringInventory>(Char, TEXT("PlayerInventory"), RF_Transient);

                        Char->SetInventory(NewInv);

                        FWeaponProperties propsEquipped;
                        for (FInventoryItemData ItemData : SaveGameData.InventoryData.WeaponsArray)
                        {
                                FWeaponProperties props;
                                props.WeaponClass = FindObject<UClass>(ANY_PACKAGE, *ItemData.WeaponClass);
                                props.InventoryIcon = FindObject<UTexture2D>(ANY_PACKAGE, *ItemData.TextureClass);
                                props.WeaponPower = ItemData.WeaponPower;
                                props.Ammo = ItemData.Ammo;

                                if (ItemData.WeaponClass == SaveGameData.InventoryData.CurrentWeapon)
                                        propsEquipped = props;

                                NewInv->AddWeapon(props);
                        }

                        Char->GetInventory()->SelectWeapon(propsEquipped);
                }
        }
}

修复玩家是一个特殊情况,需要大量细致的工作,因此在此以完整形式包含。将玩家的所有属性放回到保存时想要的位置相当棘手,但请花时间通读(并在您的 IDE 中使用断点逐步执行)上述代码几次。正确地完成这项工作,并从这种加载/保存系统开始时就是正确的,这是至关重要的。花时间理解它,并在实现它时正确地实现它,测试,测试,再测试!

需要修改菜单小部件类的更改应该是显而易见的,但如果需要,可以在 GitHub 项目的检查点中看到。这使我们能够利用从任何地方(基本上是 C++或甚至蓝图)加载和保存的代码。

现在,我们真正缺少的只是一个具有少量特殊 C++代码的新触发盒类型,调整我们的加载/保存过程以忽略在保存时保存的库存,并在层级切换时恢复库存。所以,两个快速步骤:创建一个新的触发盒子子类,并将玩家的库存和体积的安全位置添加到游戏实例中。因此,将我们的新体积命名为LevelTransitionVolume,并给它添加一些功能,通过向游戏实例添加变量和函数,我们现在可以过渡到带有玩家库存的,其余从我们要去的地方的保存游戏中加载,如果我们只是切换层级,我们保留玩家的当前库存(以及如果需要,任何其他信息)。如果我们正在进行完整加载,当然我们需要恢复保存的内容。

这个体积的逻辑,我们将替换之前用于测试的两个层级中的两个体积,看起来是这样的:

void ALevelTransitionVolume::NotifyActorBeginOverlap(AActor* OtherActor)
{
        AMasteringCharacter* Mast = Cast<AMasteringCharacter>(OtherActor);
        UWorld* World = GetWorld();

        if (Mast != nullptr)
        {
                UMasteringGameInstance* gameInst = UMasteringGameInstance::GetInstance();
                gameInst->SetPersistInventory();

                AMasteringCharacter* Char = Cast<AMasteringCharacter>(World->GetFirstPlayerController()->GetPawn());
                check(Char != nullptr);

                gameInst->SetPlayerInventory(Char->GetInventory());

                FString currentMapName = World->GetMapName();
                currentMapName.Split("UEDPIE_0_", nullptr, &currentMapName); // strip out PIE prefix if in PIE
                FString toMapName = TransitionLevel;

                FString fromFile = currentMapName + TEXT("_to_") + toMapName + TEXT(".sav");
                FString toFile = toMapName + TEXT("_to_") + currentMapName + TEXT(".sav");

                gameInst->SetPlayerSafeLocation(GetPlayerSafeLocation());

                // always save on our way out so we can restore the state on the way back
                LoadSaveLibrary::SaveGameFile(fromFile, World);

                if (FPaths::FileExists(FPaths::ProjectSavedDir() + toFile))
                {
                        LoadSaveLibrary::LoadGameFile(toFile, World);
                }
                else
                {
                        UGameplayStatics::OpenLevel(World, *toMapName);
                }
        }
}

因此,我们现在正在过渡到新层级,并保存了我们离开当前层级时的位置。

void LoadSaveLibrary::OnGameLoadedFixup(UWorld* World)
{
        if (BinaryData.Num() == 0)
        {
                checkSlow(World->GetFirstPlayerController() != nullptr);
                AMasteringCharacter* charPawn = Cast<AMasteringCharacter>(World->GetFirstPlayerController()->GetPawn());

                FixupPlayer(World, charPawn);
                return;
        }

        FMemoryReader FromBinary = FMemoryReader(BinaryData, true);
        FromBinary.Seek(0);

        FGameSavedData SaveGameData;
        FromBinary << SaveGameData;

        FromBinary.FlushCache();
        BinaryData.Empty();
        FromBinary.Close();

        TArray<AActor*> Actors;
        UGameplayStatics::GetAllActorsWithInterface(World, USavedActorInterface::StaticClass(), Actors);

        TArray<FActorSavedData> ActorDatas = SaveGameData.SavedActors;

        AMasteringCharacter* Char = nullptr; // if ever more than one, we'll need an array and a map to their inventory

        // iterate these arrays backwards as we will remove objects as we go, can also use iterators, but RemoveAt is simpler here for now
        for (int i = Actors.Num() - 1; i >= 0; --i)
        {
                AActor* Actor = Actors[i];

                for (int j = ActorDatas.Num() - 1; j >= 0; --j)
                {
                        FActorSavedData ActorRecord = ActorDatas[j];

                        // These are actors spawned into the world that we also found in our save data (TODO: use TArray intersection?)
                        if (ActorRecord.MyName == *Actor->GetName())
                        {
                                FMemoryReader MemoryReader(ActorRecord.MyData, true);
                                FSaveGameArchive Ar(MemoryReader);

                                AMasteringCharacter* Mast = Cast<AMasteringCharacter>(Actor);
                                if (Mast != nullptr)
                                {
                                        Char = Mast;
                                }

                                Actor->Serialize(Ar);
                                Actor->SetActorTransform(ActorRecord.MyTransform);
                                ISavedActorInterface::Execute_ActorLoaded(Actor);

                                APawn* pawn = Cast<APawn>(Actor);
                                if (pawn != nullptr)
                                {
                                        // controller needs the rotation too as this may set the pawn's rotation once play starts
                                        AController* controller = pawn->GetController();
                                        controller->ClientSetRotation(ActorRecord.MyTransform.Rotator());
                                }

                                ActorDatas.RemoveAt(j);
                                Actors.RemoveAt(i);
                                break;
                        }
                }
        }

我们将在这一点上处理玩家,然后完成找到的任何已保存数据的演员(通常是我们在其中创建的投射物等):

        FixupPlayer(World, Char);

        // These are actors in our save data, but not in the world, spawn them
        for (FActorSavedData ActorRecord : ActorDatas)
        {
                FVector SpawnPos = ActorRecord.MyTransform.GetLocation();
                FRotator SpawnRot = ActorRecord.MyTransform.Rotator();
                FActorSpawnParameters SpawnParams;
                // if we were in a space when saved, we should be able to spawn there again when loaded, but we could also
                // be overlapping an object that loaded, but will be subsequently destroyed below as it was there at level start
                // but not there at save time
                SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
                SpawnParams.Name = ActorRecord.MyName;
                UClass* SpawnClass = FindObject<UClass>(ANY_PACKAGE, *ActorRecord.MyClass);
                if (SpawnClass)
                {
                        AActor* NewActor = GWorld->SpawnActor(SpawnClass, &SpawnPos, &SpawnRot, SpawnParams);
                        FMemoryReader MemoryReader(ActorRecord.MyData, true);
                        FSaveGameArchive Ar(MemoryReader);
                        NewActor->Serialize(Ar);
                        NewActor->SetActorTransform(ActorRecord.MyTransform);
                        ISavedActorInterface::Execute_ActorLoaded(NewActor);
                }
        }

        // These are actors in the world that are not in our save data, destroy them (for example, a weapon pickup that was, well, picked up)
        for (auto Actor : Actors)
        {
                Actor->Destroy();
        }
}

在替换了旧的触发体积并在层级中的新盒子上设置正确的过渡层级名称后,当然我们也可以在层级蓝图中去掉旧的逻辑。所以,如之前所见,如果我们已经有了与即将进行的过渡到/从的匹配的保存文件,就加载那个保存文件。无论如何,我们仍然会保留玩家的库存,因为游戏实例中靠近这个函数顶部的这些行设置了这一点,并在离开之前以我们的从/到形式保存状态。

为了使我们的库存容易转移,我们可以添加几个实用函数。注意:也可以在级别加载期间持久化UObject实例(除了游戏实例之外),但这通常会对在级别加载期间可能被销毁的引用其他对象的实例产生不可预见的影响。因此,在这种情况下,我们只是使库存对象的复制变得简单:

void UMasteringInventory::CopyFromOther(UMasteringInventory *Other, class AMasteringCharacter* ownerOverride /* = nullptr */)
{
        Reset();

        TArray<FWeaponProperties>& otherProps = Other->GetWeaponsArray();
        for (auto prop : otherProps)
        {
                WeaponsArray.Add(prop);
        }

        DefaultWeaponPickup = Other->DefaultWeaponPickup;
        CurrentWeapon = Other->GetCurrentWeapon();
        CurrentWeaponPower = Other->GetCurrentWeaponPower();
        MyOwner = ownerOverride == nullptr ? Other->GetOwningCharacter() : ownerOverride;
}

void UMasteringInventory::Reset()
{
        WeaponsArray.Empty();
        CurrentWeapon = nullptr;
        CurrentWeaponPower = -1;
        MyOwner = nullptr;
}

void UMasteringInventory::SetupToCurrent()
{
        for (auto WeaponIt = WeaponsArray.CreateConstIterator(); WeaponIt; ++WeaponIt)
        {
                const FWeaponProperties &currentProps = *WeaponIt;
                OnWeaponAdded.Broadcast(currentProps);
                if (currentProps.WeaponClass == CurrentWeapon)
                {
                        SelectWeapon(currentProps);
                }
        }
}

现在最后要做的就是制作一个新的触发盒的实际蓝图,并给它一个合适的安放位置,以便我们可以放置玩家。如果我们不这样做,玩家将不断被放置在保存的位置(当它们与盒子相交时),并且玩家将在两个级别之间无限弹跳。对于那些仔细观察的人,你会在构造脚本中看到碰撞最初是禁用的,然后这里的计时器重新启用它。这是因为我们需要游戏世界的一个 tick 来将玩家移动到更新后的位置,如果我们不暂时禁用重叠事件,当我们尝试移动时,就会得到前面提到的 ping-pong 行为:

图片

到目前为止,玩家可以在我们的两个可玩级别之间来回切换,库存保持在当前级别,被放置在安全位置,这样他们就不会立即回到另一个级别,每个级别都像正常加载保存一样保存和恢复其状态。现在唯一缺少的一般功能可能是在加载过程中有一个旋转图标或淡入黑色和进度条。UE4 当然允许这些类型的事情,它们在主游戏线程 tick 之外更新,但在这里不会具体实现,因为我们还有另一个主要主题要深入研究:流式传输级别。

有可能进行流式传输吗?

到目前为止,我们已经使用了触发元素来加载特定级别。流式传输,正如其通常所知,是另一种按需加载级别的的方法,它允许在不必须加载所有包围演员的情况下拥有更大的游戏区域。虚幻引擎以两种不同的方式允许这样做,但它们可以相互补充:流式体积和世界组成。我们在这里简要探讨这些内容,但它们是提供巨大游戏区域并保持任何给定时刻加载内存处于合理水平的好方法。当然,这种方法也有一些缺点。

流式传输的优点和缺点

流式传输的优势可能很明显。正如之前提到的,有较大的空间和可管理的内存负载。其缺点可能不太明显,但通过我们在 GitHub 项目中的第一个检查点可以清楚地说明:基本上是将MasteringTestMap复制(除去天空盒和光照)到两个与之偏移的其他级别,并使用流式体积进行流式传输。为此,首先转到主编辑窗口菜单并选择级别。已经添加了两个新级别,并带有它们的偏移量,每个级别周围都有一个 NavMesh 体积来生成适当的网格。当级别加载时,这些网格将组合在一起。

然而,当级别被流式传输出去时,其中的所有演员都将被销毁,当它被流式传输回来时,它们将全部重新创建,就像新加载一样。所以在这个例子中,有两个新级别,MasteringTestMapW1 和 W2。MasteringTestMap 将是我们的持久级别。这是最顶层的级别,它总是被加载。所有流式传输体积也都需要放置在这个级别中。所以,正如这里所看到的,以粗略的演示方式,当开始 MasteringTestMap 并左转 90 度跑动时,你会看到流进 W1 的体积有短短几米的重叠,同样,W1 的地图和流进 W2 的体积也有小范围的重叠。通常你希望这些体积包括玩家可以看到的任何区域,但在这个情况下,我们想展示流式传输出去:所以如果你跑向左边,在碰到 MasteringTestMap 的边缘之前,W1 就会流进。如果你继续向 W2 前进,你会看到当玩家离开 W1 的体积时,W1 就会流出去。同样,当你通过 W1 返回时,你可以看到 W2 迅速流出去。现在我们遇到了问题。在 W2 中有一个体积用于生成更多的敌人。如果你慢慢走,小心行事,他们可以把你赶回 W1,但如果你跑得快,W2 就会流出去,女伯爵们就会从世界中掉落。此外,当流式传输 W1 回来时,之前收集到的任何武器拾取现在都会返回。

动态对象和此类流式传输是最大的协调问题:你如何通知新加载的关卡它不应该重新生成放置在地面上的武器拾取?女伯爵们如何处理她们的主关卡流式传输出去?这完全破坏了她们的回家行为,因为她们实际上无法这样做(它们的路径查找失败)。当然,当流式传输的关卡流式传输出去时,可以在持久关卡中以小的方式保存(例如,简单地存储角色的位置和状态)或在游戏实例中,就像我们在上一节中加载关卡时跨关卡加载玩家的库存一样。然而,这需要大量的维护,因此,像这样的许多功能一样,如果你打算使用流式传输,请务必尽早决定,并教育任何其他团队成员如何正确设置这些事情,以便你的玩家感觉这是一个无缝的世界。但是首先,让我们快速看一下这两种流式传输方法的区别,并观察提到的困难。

示例流式传输和最佳实践

要流式传输这些关卡,在“关卡”窗口中,你首先必须点击顶部的“关卡”下拉菜单,然后点击“添加现有”。

添加 W1 和 W2 使我们能够选择它们,然后将它们的加载状态连接到此处显示的体积:

添加了这些,并将体积添加到 MasteringTestMap 中,它应该看起来像这样:

在“关卡”窗口中,点击体积数组上的加号按钮,然后我们可以使用吸管工具将它们拖入世界并选择正确的体积,或者简单地使用下拉菜单选择我们想要与该关卡关联的体积。再次注意这些小的重叠。为了避免玩家注意到流式传输,通常最好制作一个更大的重叠区域,并可能引入雾距离,这样实际的加载就不会那么明显。在我们的教学案例中,我们再次希望它在这里是明显的。现在,有了这些组件,你可以运行 West,看到关卡随着玩家(确切地说,是摄像机)进入这些区域并离开而进入和消失。再次,通过几次测试运行,应该很明显,如果没有考虑到这些提到的缺点,当关卡流式传输进和出时,它们会明显可见。女伯爵是否永远不应该离开她的主关卡?当她的主关卡流式传输出去时,她应该消失吗?拾取物是否应该以某种方式知道它们之前已经被拾取,并且要么不生成,要么像我们在加载存档游戏时拾取的拾取物那样销毁自己?这些都是每个项目必须决定的问题,权衡维护此类系统的复杂性以及玩家体验和避免破坏他们的沉浸感。

使用体积可以是一种手动控制精确细节水平的好方法。例如,你可以使用一个非常大的体积来流式传输基本的物理和少量的静态网格几何形状,以避免任何世界掉落问题,比如伯爵遇到的问题,同时还有一个额外的级别,体积更小,只有当玩家靠近时才加载,这会加载大量不影响游戏玩法的外观几何形状。然而,基于体积的流式传输的一个限制是,级别只能从持久级别的起点扩展到WORLD_MAX,大约是 2 公里(或者从起点开始,东西方向 1 公里,南北方向 1 公里)。在大多数游戏中,这已经足够了。对于在这个尺寸限制内的开放世界(别忘了你仍然可以硬切换到其他持久级别,比如室内区域!),我们发现砖块状拼接最适合重叠级别加载。因此,当你处于中心级别时,按照这种模式围绕它的级别将会被加载:

图片

这样你还可以与级别设计师合作,确保这七个级别的总内存始终低于某个特定的上限。例如,你可以有一些低细节区域和中心的高细节区域,但当然,随着你的移动和这个模式的重复,任何七个加载级别的总和应该保持在商定的上限以下。而且,令人高兴的是,基于服务器的多人游戏已经尊重这种流式传输,并且没有问题跟踪客户端之间的玩家。如果你的世界需要一个更大或几乎无界的游戏空间,世界组成值得探索。

世界组成是创建自动化的、基于网格的拼接的好方法,它内置在 UE4 中。这对于非常大的世界非常有用,玩家可以在任何给定方向上几乎无限地旅行,但通常需要艺术家或级别设计师的大量工作来制作这些部分,并确保它们在边界处完全匹配。世界组成的完美应用是一个单人开放世界游戏,其中探索和真正广阔的游戏区域是目标。由于这是一个相当小众的领域,Epic 本身已经对功能做了很好的文档记录,将在进一步阅读部分添加链接。

摘要

从本章的开始到结束,我们现在已经展示了如何在传统的关卡加载中独立持久化关卡状态和玩家状态,以及玩家移动时流式传输关卡的基础知识。这些对于几乎所有的 UE4 可想象的游戏都是基本的,现在应该成为任何团队和项目向前发展的最佳策略的基础。始终记住:尽早做出这些决定!例如,将假设硬关卡加载的关卡改造为流式模型几乎是不可能的,但如果你从第一天就采用流式模型并帮助你的团队坚持其限制,那么这将是你在项目周期后期不会感到困扰的一个担忧。

问题

  1. 在 UE4 中可以使用哪些现有的类或对象来实现无需新更改的关卡加载?

  2. 仅使用这些方法有哪些局限性?

  3. 游戏实例对象的生命周期范围是什么?

  4. 当打开新关卡或加载现有关卡的状态时,游戏实例是如何用来持久化数据的?

  5. 将关卡加载/保存代码抽象到蓝图函数库的目的是什么?

  6. 为什么玩家的库存被复制而不是将UObject范围限定在游戏实例中?

  7. 基于体积的流式传输的主要缺点是什么?

  8. 哪些类型的游戏肯定应该使用世界组成?

进一步阅读

UE4 中的世界组成:

docs.unrealengine.com/en-US/Engine/LevelStreaming/WorldBrowser

第七章:在游戏中获取音频

简介

音频是游戏中经常被忽视的组件,通常只有在做得不好时才会被注意到。虽然不像图形和游戏玩法那样突出,但考虑到它可能会破坏体验或增强体验,所以最好追求后者。 想想一个动作或恐怖电影,当音量被静音时,你就能感受到音频有多么重要! 在本章中,我们将涵盖:

  • UE4 音频的基本组件

  • 从动画触发音频

  • 基于材质的音频(包括弹丸和玩家各种脚步声的独特冲击声)

  • 环境 FX(通过音量实现的混响)

技术要求

本章将广泛使用第五章“添加敌人!”中添加的伯爵角色资产中的音频资源,但使用类似资产也可以跟随这些课程,无需这些特定的资源。

GitHub 章节从这里开始:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-7

使用的引擎版本:4.19.2。

通过动画的基本声音和触发

正如本书的忠实读者所明显的那样,我们已经在游戏中添加了一个声音,它在这里,并且从第一章“为第一人称射击制作 C++项目”以来就一直在这里,我们的 FPS 模板(尽管在第二章“玩家的库存和武器:”中从AMasteringCharacter移动到了AMasteringWeapon)。

// try and play the sound if specified
if (FireSound != nullptr)
{
        UGameplayStatics::PlaySoundAtLocation(this, FireSound, GetActorLocation());
}

这是一个播放声音最基本的方式的绝佳示例,而且是从 C++开始的,但我们将尝试在本节中拓展几个新领域,并在下一节中添加基于材质的声音。我们还将添加一个来自 Epic 的专业级别,以展示这些概念。您可以从 Epic 这里找到它(无尽之刃:冰地;我们将使用其包中的冰湾):

它的大小(接近 2 GB)与伯爵内容的大小相似,所以如果您正在跟进 GitHub 项目,现在开始下载可能最好。

对于 GitHub 用户的一个重要提示!截至本地图上传时,项目设置为使用 GitHub LFS(大型文件系统),如果您不熟悉,您现在不仅需要从 GitHub 进行拉取,还需要执行pull lfs content。您会在启动时注意到如果虚幻表示地图文件格式错误的问题。关于 Git LFS 的更多信息请参考附加阅读。

声音、提示、通道、对话、FX 音量等!

UE4 提供了一系列基于音频的类,这些类可以从简单的USoundBase到复杂的分支对话和特殊应用的 FX 组合。

让我们先浏览一下主要类,以确保清楚已经存在哪些功能。如果您只是右键单击添加资产并在声音飞出菜单上悬停,您将看到如下列表:

图片

这本身可能会让人感到害怕,所以在我们开始一些更实际的例子之前,让我们快速概述每个效果及其目的。

声音波:这是游戏中声音的最基本级别。虽然它不在“声音”下拉菜单中,但这是通过导入按钮而不是“声音”将声音带入游戏的方式。您将原始声音(例如.wav 文件)导入到您的项目中,并得到一个声音波。这些可以具有各种属性,或用于制作声音提示并进一步设置属性。所有后续项目的一个总结可以像这样:所有进一步的声音设计或音频工程实际上都取决于您游戏的复杂性和需求。然而,声音波是您最基本的起点,并且对于在 UE4 中播放任何音频都是必需的。

对话声音:这些只是指定说话者类型和说话者性别。将声音视为其名称:谁在说话,他们使用什么类型的嗓音?

对话波:这些基本上是指示一个说话者应该如何与另一个说话者互动的指标,通过 DialogContext 对象定义,您可以从一个说话者添加到另一个说话者,以及添加屏幕上的口语文本和字幕覆盖(以及标记内容为成熟)。在下一节中,我们将通过我们的女伯爵在战斗中进行挑衅的演示来快速展示这一版本。对话系统非常适合两个可交互角色之间的简单对话(玩家可以是其中一个或几个),但这实际上只适用于基本的交互。如果游戏设计只需要这些,这是一个完美的选择,应该被利用。关于对话系统的更多信息可以在进一步阅读部分找到。

反射效果:反射效果应该由声音设计师利用,但它们的功能是向播放和听到的声音添加基于环境的效果。例如,它们可以用来在洞穴环境中产生一点回声,或者在车辆的内部,为您的声音添加专业和真实的声音效果。这些效果附加到您世界中的音频音量上,告诉环境您的声音应该如何被听到。

声音衰减:与混响效果类似,您可以定义各种衰减设置。环境声音(我们将在后面进一步讨论)可以引用这些对象之一,或者通常可以单独指定。衰减实际上是声音的衰减属性:基于听者的位置,声音如何衰减?例如,在一个有蜿蜒走廊的区域,您会希望声音衰减得更快,以反映声音传播到听者时振幅的损失。再次强调,音频工程师和声音设计师通常非常熟悉这些概念,并且可以将它们应用于产生声音的气氛效果(来自远处的令人毛骨悚然的声音可能播放得很小声,但当靠近玩家时,声音会变得很大)。

声音类别:这是根据声音的预期听感和应用方式来组织声音类型的一种不错的方法,并且它覆盖了所有其他属性。例如,声音可以被设置为忽略混响、忽略衰减(将其设置为 UISound!),或者更改其他属性,如它们的立体声(左右声道)输出,并通过其他声音类别应用于层次结构中。

声音并发:在此对象添加之前,可以通过最大计数和其他属性来限制声音,这些属性将在我们工作时找到。例如,您不会有 20 个 NPC 同时播放脚步声,即使有 20 个 NPC 同时行走,您也会将其限制为 6 个。声音并发对象基于声音的播放者提供更精细的控制。例如,对于前面的例子,您可能希望播放特定类型敌人的所有攻击声音,但只想在任意时刻听到来自另一种类型敌人的有限集。与许多之前的描述一样,这是音频设计师可以使用的伟大工具,但它仅在游戏或游戏区域中真正需要,在这些区域中,声音可能会对玩家造成压倒性影响,并且某些声音应该具有明确的优先级。这些可以像其他属性一样指定给声音波对象。

声音提示:这些有点像播放声音的蓝图。您可以将效果和局部修改器组合到输出中。想象一下,这些就像实际的声音输入(枪声、对话片段等)并详细指定它应该如何组合成最终输出。许多这些选项在此阶段可能看起来有很多重叠,但请考虑它们作为修改任何声音的方式,而这一项则是我们修改特定声音的方式。声音提示通常很简单,只是将输入直接输出,当然,这个输出本身仍然可以通过全局环境效果设置(或者,再次强调,只为这个声音设置)进行修改。

音频混音:音频混音可以相互推送和弹出,并可以叠加使用,但控制更全局的设置,例如音高、均衡器(从高频到低频的过滤)以及如何将音量应用于例如 5.1 声道中的中心通道或其他高级混音选项。

从动画触发声音

在游戏中,最常见的使用情况是在播放动画时触发音频。我们将为我们的女伯爵 NPC 设置几个此类事件,在下一节中,为我们的玩家设置基于材料的脚步声(尽管我们的玩家角色模型没有脚!)。要开始,让我们打开我们的 ABP_Countess 动画蓝图,再次查看她的攻击状态:

图片

我们现在将使用一个事件来处理 Primary_Attack_A_Normal,使她的攻击不那么令人毛骨悚然。双击攻击动画节点,就像我们之前为尝试击中事件所做的那样,我们将在她的动画中稍早添加另一个事件:

图片

在时间轴底部,我们可以将小红条拖动到我们想要的位置,然后在“通知”时间轴中,右键单击并添加一个播放声音通知。然后,在右侧,过滤并选择我们喜欢的 Countess_Effort_Attack 声音。现在,有两个注意事项:为她设置的声音提示无效,因为项目没有游戏中的全部角色集,并且它们依赖于上一节中提到的对话玩家。所以它们不会播放任何声音。第二个注意事项:声音波对象当然可以单独工作,但如果我们想混合它们,我们可以像之前为攻击击中事件所做的那样,创建一个正常的动画通知,设置一些蓝图逻辑以随机选择一个声音波,并播放它。现在,我们选择了 attack_01,所以现在她在游戏中制造噪音,我们可以在我们的关卡中测试它。

对于跟随教程的人来说,在这个阶段项目有一些清理工作:重命名以更好地反映我们资产当前的状态,并将我们的一些其他类更新到最新。如果你现在正在同步,你可能会注意到《无尽之刃》武器现在有自己的投射物,看起来就像它们的实际武器,并赋予它们更多投掷武器的手感,有助于在未来的工作中进一步区分它们。

如果你好奇这些投射物的静态网格是如何制作的,打开我们制作的任何这些武器的骨骼网格演员,在主工具栏顶部有一个“制作静态网格”按钮。当你不想导入单独的模型或没有访问原始资产但需要静态网格版本时,这非常方便,就像这里一样。

如果你在这个时候玩游戏,你会注意到我们的伯爵夫人的攻击声音变得非常重复,而且整体感觉还是有点基础。鉴于这个角色包含的大量精彩资产,我们现在将随机化她的攻击,为每个攻击分配其自己的声音(我们也可以在每次播放相同的攻击时随机化声音),并且给她一些偶尔针对玩家的嘲讽对话。让我们从打开我们的 ABP_Countess 动画蓝图开始。在这里,在攻击状态中,我们将从播放单个动画改为通过随机序列播放器播放。如右图所示,你可以添加你喜欢的任意数量的动画序列,设置它们被选中的相对概率,开启随机模式,设置循环播放,等等:

图片

然而,一旦我们这样做,就不能再依赖特定动画的播放时间作为我们的退出过渡了,我们需要一种新的方式来从这个状态退出。我们需要在左下角添加一个新的变量:TargetOutOfRange。这个变量在这里用来让我们从持续攻击序列中退出,并移动去追逐玩家(或者如果我们花费的时间太长,就跑回家),就像正常一样:

图片

这需要我们修改攻击行为树任务,在 MoveTo 成功后添加此函数:

图片

被调用的函数相当简单,所以这里省略了。它只是从 pawn 进行所有 case 处理,获取动画蓝图,然后将其超出范围变量设置为传入的值。正如 GitHub 项目中所见,每次我们进入移动到行为任务时,这个变量都会被标记为 true,所以只有在我们的 AI MoveTo 成功(除非玩家移出接受半径)的每次函数调用中才会将其设置为 false。作为额外的奖励,你可以在使用的三个动画序列中的最后一个看到,这里选择的声音提示有三个对话条目,并随机选择一个。所以,我们有三个随机攻击,每次使用时第三个攻击都会从三个随机声音中选择。这是我们选项的一个完美例子。

最后,我们将快速制作一个嘲讽,有部分概率会播放。注意,它可以连续播放多次,但有很多工具可以防止这种情况。为了让伯爵夫人有 10%的概率播放嘲讽,我们在攻击行为任务的末尾添加了这个对话设置(并且请注意,为我们的潜行角色添加了一个具有默认设置的 DialogVoice):

图片

注意,无论选择哪个对话波形(在这种情况下是 Countess_Emote_Taunt_010_Dialogue),你都需要打开它,并确保说话者设置为正确的类型(我使用的是非 Vamp 版本),并添加潜行角色(在左下角的数组中指定)作为听众。就这样:随机的攻击,新的声音,随机的对话,和随机的嘲讽!

环境和声音

到目前为止,我们的声音对它们播放的位置或原因并不敏感。现在我们将迅速为几种我们的投射物可能击中的物体类型添加声音,以及一些玩家的脚步声,只是为了证明我们的物理材料方法正在起作用。与在 Unreal Marketplace 中免费提供的某些惊人的视觉资产相比,找到类似地免费共享的常见声音库要困难一些,但我们会充分利用我们所拥有的资源,并意识到在一个完整制作的游戏中,许多声音将由专门的声音设计师制作,或者至少希望有预算来购买现有的商业库。

击中不同的表面

使用我们的投射物击中物体是最容易快速设置的声音,但随后,我们需要在我们的投射物类中添加新的功能,以确保每个投射物在击中每种表面时都知道播放什么声音。随着表面类型(物理材料)和具有独特声音的投射物数量的增加,维护这项工作可能会变得相当繁重,但这也是一种细致入微的工作,它将商业成功的游戏与不那么专业的演示或独立游戏区分开来,后者没有花时间添加这些细节。

我们将从项目中需要解决的最基本的问题开始:设置材质类型。在这里的工作中,我们只将设置两种类型,但当然,你可以遵循这个模式,为每种类型创建尽可能多的类型。首先,打开项目设置,在 Engine/Physics 下,我们将添加雪和石头,如下所示:

图片

在 FirstPerson/Audio 部分,我添加了几个声音提示,但它们只是项目中已有的声音的 FX 修改版本,因为正如所注,在 Unreal Marketplace 中没有具体的明显免费下载:

图片

重要提示!如果在当前阶段(或任何未来的阶段)这些声音令人烦恼(它们对我而言就是这样!),请前往 GitHub,并从本章(将用于所有后续版本)中 cherry-pick 7ff24f7 次提交(其中包含本章中所有冲击的相对较好的打击声)。拥有合成器和引入一些.wav 样本是一个很大的优势,但请记住:在任何情况下,如果你不确定,你必须检查任何此类资产上的许可。即使是生成声音的硬件,如果直接使用,也可能在商业产品中包含一些许可!

接下来,对于投射物,我们需要一种方法来匹配材质类型和被击中的表面。目前,我们将在投射物头文件中创建一个结构体。我们将添加这两个属性:

USTRUCT(BlueprintType)
struct FPhysSound
{
        GENERATED_USTRUCT_BODY()

        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        TEnumAsByte<EPhysicalSurface> SurfaceType;

        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        class USoundCue* SoundCue;
};

当然,我们的投射物还需要这些的数组:

UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FPhysSound> ImpactSounds;

正如所述,当物理表面类型和投射物的复杂性越来越大时,维持这样一个系统可能会变得难以控制。即使在这里的小集合中,如果早期做出的决策意味着在进展过程中进行更改,而不是将更改反装到大量单个资产上,那么尽早做出这些决策总是更好的。目前的复杂度是可管理的,但如果它增加到显著更大的数量,即将要做的工作将会非常痛苦,错误的可能性也会增加。在这种情况下,我建议也许可以在 Unreal 中创建一个 DataTable,并在.csv 电子表格或类似文件中跟踪大型更改。关于这方面的更多信息可以在进一步阅读部分找到。不过,现在,让我们逐一查看我们的投射物,并开始设置默认表面为默认冲击噪音,抛射物体的雪变为抛射雪冲击,等等:

图片

一旦所有这些都设置好了,那么就只是投射物OnHit函数中的一点代码问题:

#include "Sound/SoundCue.h"
EPhysicalSurface surfType = SurfaceType_Default;
if (OtherComp->GetBodyInstance() != nullptr && OtherComp->GetBodyInstance()->GetSimplePhysicalMaterial() != nullptr)
{
        surfType = OtherComp->GetBodyInstance()->GetSimplePhysicalMaterial()->SurfaceType;
}

USoundCue* cueToPlay = nullptr;
for (auto physSound : ImpactSounds)
{
        if (physSound.SurfaceType == surfType)
        {
                cueToPlay = physSound.SoundCue;
                break;
        }
}

const float minVelocity = 400.0f;
if (cueToPlay != nullptr && GetVelocity().Size() > minVelocity)
{
        UGameplayStatics::PlaySoundAtLocation(this, cueToPlay, Hit.Location);
}

不幸的是,在我们的案例中,FHitResult就是投射物。此外,请注意硬编码的最小速度:这是为了防止在投射物速度/寿命的末尾向游戏中发送大量非常小的弹跳,但你当然可以用许多其他或更灵活的方式处理这个问题。如果你未来想要从那个角度播放声音,这里有一个很好的访问器,可以让你轻松获取表面类型:

UGameplayStatics::GetSurfaceType(Hit);

默认地图中最明显的面对表面已被设置为石质类型,用于测试,现在可以展示不同的表面和不同的投射物,并改变它们的冲击声音!下一节将简要介绍我们新的(终于视觉高质量的)地图,用于脚步声和基于声音音量的混响设置。

玩家脚步声和环境音效

现在玩家终于得到了他们自己的脚步声。再次,我们将使用我们已有的,但我们可以希望找到几种类型来至少证明系统的可行性,这样在未来的工作中帮助队友应该会容易得多。脚步声的工作几乎可以与投射物碰撞的工作完全相同,但我们将在玩家的(尽管非常有限)移动动画中的特定时刻触发它。这些将简单地触发射线投射,我们将获取材质,一旦我们有了它,工作将使脚步声在我们所到之处都能工作。

首先,MasteringCharacter 需要与我们的 projectile 刚刚得到的相同的 struct。一般来说,假设更多的事情将使用这个共享功能,我会将 struct 和一些这种行为移动到 actor 组件中,但鉴于它对我们这里的课程不是特别有信息量,我们现在就原谅自己一点复制粘贴吧,首先从我们的.h 文件顶部开始:

USTRUCT(BlueprintType)
struct FFootstepSounds
{
        GENERATED_USTRUCT_BODY()

        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        TEnumAsByte<EPhysicalSurface> SurfaceType;

        UPROPERTY(EditAnywhere, BlueprintReadWrite)
        class USoundCue* SoundCue;
};
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FFootstepSounds> FootstepSounds;
UFUNCTION(BlueprintCallable)
void PlayFootstepSound();

接下来,我们在.cpp 文件中这样做:

#include "Sound/SoundCue.h"
#include "PhysicalMaterials/PhysicalMaterial.h"

代码片段

void AMasteringCharacter::PlayFootstepSound()
{
        FVector startPos = GetActorLocation();
        FVector endPos = startPos - FVector(0.0f, 0.0f, 200.0f); // 2m down

        FCollisionQueryParams queryParams;
        queryParams.AddIgnoredActor(this);
        queryParams.bTraceComplex = true;
        queryParams.bReturnPhysicalMaterial = true;
        FHitResult hitOut;

        bool bHit = GetWorld()->LineTraceSingleByProfile(hitOut, startPos, endPos, TEXT("IgnoreOnlyPawn"));

        if (bHit)
        {
                EPhysicalSurface surfHit = SurfaceType_Default;

                if (hitOut.Component->GetBodyInstance() != nullptr && hitOut.Component->GetBodyInstance()->GetSimplePhysicalMaterial() != nullptr)
                {
                        surfHit = hitOut.Component->GetBodyInstance()->GetSimplePhysicalMaterial()->SurfaceType;
                }

                if (hitOut.PhysMaterial != nullptr)
                {
                        surfHit = hitOut.PhysMaterial->SurfaceType;
                }
                USoundCue* cueToPlay = nullptr;
                for (auto physSound : FootstepSounds)
                {
                        if (physSound.SurfaceType == surfHit)
                        {
                                cueToPlay = physSound.SoundCue;
                                break;
                        }
                }

                if (cueToPlay != nullptr)
                {
                        UGameplayStatics::PlaySoundAtLocation(this, cueToPlay, hitOut.Location);
                }
        }
}

注意,我们将自己进行光线追踪以查看我们下面的情况,并使用这些结果来确定在哪里和玩什么。注意,根据我的经验,即使我们在静态网格上与物理材质发生碰撞,我们仍然需要手动挖掘它,因为即使在查询中指定返回物理材质,碰撞结果总是返回nullptr

要触发这些,我们需要在简约的第一人称跑步动画中添加一个新的事件:

图片

然后,我们将从 FirstPerson_AnimBP 中调用我们的 pawn 的新函数:

图片

类似地,编辑器中添加了新的脚步声提示,并将其添加到我们的隐形角色数组中,就像武器一样。现在我们有一些奇怪的脚步声!

终于打开了 FrozenCove 关卡,我们将选择几个看起来像石头或冰的表面,你可以轻松地从那里跳到它们的材质。你还可以看到,在 InfinityBladeIceLands/Environments/SurfaceTypes 中下载的关卡中已经存在一个石头的物理材质。

点击那些静态网格的材质以打开它们,我们将设置它们的一些物理材质:

图片

要完全做到这一点,我们将创建一个新的雪物理材质,复制石头材质(实际上设置为表面类型 2,但既然我们已经通过名称将其添加到我们的项目中,现在应该明确设置为石头),命名为 PhysicalMaterial_Snow,并在其属性中将其设置为雪表面类型。整个材质列表的变化可以在 GitHub 上看到,但一些例子是(所有在 InfinityBladeIceLands/Environments/Ice 下)Ice_Fortress/Materials/M_Ice_FortFloor_1_SnowPaint 和 Ice_Castle/Materials/M_IceT3_Fort_Floor_05。

最后,我们右键单击并在 InfinityBladeIceLands/Effects 中添加一个名为 WallReverb 的新 Sounds/Reverb 对象,并给它一些相当极端的参数,以便在使用时很明显。它是如何使用的?简单!在关卡中,在左侧的基本类型下,在 Volumes 中是 AudioVolume,在这种情况下,添加到从玩家开始的全长走廊的左侧:

图片

将其混响对象设置为刚刚创建的那个,当你进入那里并发出任何声音时,你一定能感觉到变化。这种类型的工作有大量的可能性可以探索,如果你立刻感到好奇,绝对可以允许整个类别的声音忽略效果(而且如果设置为 UI 类型,默认情况下已经这样做了)。

摘要

虽然音频通常被游戏视为事后考虑的事情,但这里所做的工作应该有望展示在 UE4 中可以做到多少,以及为什么它很重要。正确处理声音并能够告诉你的设计师和其他团队成员如何做这些事情(或者甚至表明这些事情是容易实现的!)对于许多试图制作类似游戏的、经验较少的团队来说是一个巨大的优势。不要在这方面被冷落:你的观众会感谢你的!

问题

  1. 在游戏过程中快速播放声音的绝对最简单的方法是什么?

  2. 对话系统的主要组成部分是什么?

  3. 对话系统在哪些情况下足够使用,而在哪些情况下则需要在其之上再添加一个管理系统?

  4. 我们如何从动画中播放一个简单的声音?

  5. 播放更复杂和多样化的动画声音的推荐途径是什么?

  6. 我们如何在碰撞中找到被击中的材质,以及为什么它永远不会像它看起来那么明显?

  7. 表面类型在哪里定义,它们在游戏中是从哪里应用的?

  8. 级别或声音设计师如何快速设置级别中的区域以区分那里的音频属性?

进一步阅读

Git LFS:

help.github.com/articles/installing-git-large-file-storage/

Unreal Dialog 系统:

docs.unrealengine.com/en-us/Engine/Audio/Dialogue

环境声音:

docs.unrealengine.com/en-us/Engine/Audio/SoundActors

通过.csv 驱动的 DataTable 维护:

docs.unrealengine.com/en-us/Gameplay/DataDriven

第八章:着色器编辑和优化技巧

简介

着色器(以及它们在 UE4 中构建的材质)负责游戏中我们看到的所有内容。有些非常简单,根本不需要用户输入(例如大多数 UI 工作),但最终,任何制作现代 3D 游戏的团队都可能需要一些定制解决方案,如果不是大量的话,超出了基本游戏和模板所包含的内容。了解如何创建和修改它们,如何在运行时高效地使用(和重用)它们,以及它们提供的一些强大功能,是非常重要的。幸运的是,UE4 通过其材质编辑器使这些操作比其他平台更容易,为团队节省了时间和资源,但当然这也带来了不当使用的风险,这可能会对性能造成灾难性的影响。在本章中,我们将提供一些防止这种情况发生的技巧,同时最大限度地利用系统和工具。记住,我们的目标不是成为每个主题的专家,而是要全面掌握 UE4 的主要系统,并拥有引导团队或项目达到最佳效果的信心的能力。材质和着色器的精通可以(并且确实!)填满整本书,但在这章之后,你应该有信心对 UE4 中材质的能力和限制给出有根据的回答。我们主要讨论的主题包括:

  • 创建和编辑材质的基础

  • 编辑材质网络和编辑时性能技巧

  • 优化着色器的运行时技巧

  • 在各种平台上适配着色器

技术要求

本章将使用以下 GitHub 链接中的项目。虽然这些课程可以应用于任何项目,但将给出使用游戏项目直接资产的具体示例,因此当然建议同步:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-8

使用的引擎版本:4.19.2。

了解和构建材质

假设大多数读者都熟悉着色器的概念:在运行时发送到硬件的特定渲染代码,告诉硬件如何将给定项目绘制到屏幕上。新手可以从许多地方开始学习它们,但这里的假设是读者至少对着色器的工作原理有基本了解:编译、上传到 GPU 或软件渲染器,并在那里执行。由于这不是图形入门教程,我们将跳过这部分,直接进入 UE4 生成和跨平台传输着色器的通用方法:材质。

材料概述、材质实例创建和使用

在材料方面,没有一个完美的起点。然而,一个明显但并非每个人都知道的是,存在引擎材料。如果你打开编辑器并转到内容浏览器,你会在右下角看到一个视图选项的下拉菜单。选择显示引擎内容将在左侧的源浏览器中显示一个新文件夹,选择此文件夹,然后从过滤器下拉菜单中添加一个材料和材料实例的过滤器,就像这里一样,将显示一个现有的内置材料的大量列表:

图片

以日光环境立方体贴图作为快速示例,将打开材料编辑器并看起来像这样:

图片

对于那些刚开始编辑材料的人来说,你可以看到它看起来非常像蓝图编辑器和 UE4 中的其他基于节点的编辑,这有助于在不同编辑器窗口之间提供熟悉感。简要描述这里显示的内容,左上角是材料的预览(始终映射到中间的球体上)。左下角是所选节点的详细信息。当然,中心是主要的编辑面板。底部是着色器复杂性的摘要(稍后会有更多介绍),右侧是可以放入的节点列表(与在主面板上右键单击相同)。浏览引擎材料时,你会看到许多是由编辑器本身使用的。你也会注意到(考虑到过滤器),有几个材料实例。它们有什么区别?动态材料实例有什么不同?让我们在这里快速列出一些,并继续一些更具体的例子:

  • 材料是最基本类型,通常是材料实例的父类,当使用时,它由材料(和编译的着色器)将要执行的基本流程组成

  • 通过添加参数节点(关于这一点很快也会有更多介绍),材料实例可以从父级主材料继承并赋予个别属性,因此整个材料不需要重做以更改,例如,输入纹理

  • 动态材料实例是在运行时创建的材料实例,可以接受实时参数并在如演员蓝图等地方更改它们

我们将逐一介绍每个例子,在过程中创建我们自己的,并查看我们项目内容中已经构建的。但重要的是在开始工作之前考虑材料的用法。这是一个将以非常具体的方式使用且不需要派生子材料的材料吗?那么它可能只需保留并直接作为材料引用。意图是有几个基于一组核心着色器逻辑的变体吗?那么你可能会想要一个基础材料,以及一组材料实例子材料。你想要在运行时接受参数(类似于 C++中函数接受参数)并基于这些参数产生不同结果的东西吗?你将需要创建一个材料,一个或多个材料实例,然后在运行时创建一个动态材料实例(通常是在对象的蓝图构造函数中),并传递给它参数,要么在启动时,要么在整个对象的生命周期中。所以让我们在下一节中逐一考虑这些,看看每个案例涉及什么,并养成第一次就正确构建材料的好习惯。

在编辑器时间工作在材料网络和性能技巧

虽然我们可以轻松地从零开始创建一个材料(如果你不熟悉,强烈建议花些时间玩玩能做什么),但让我们从一个现有的材料开始,看看它做了什么,然后开始将其修改为在测试地图中更有用的内容。

为了快速举例说明我们如何更改材料实例并将其应用到世界中,让我们从 Content/InfinityBladeIceLands/Environments/Misc/Exo_Deco01/Materials 开始,我们将使用 M_Exo_Crate_Open 材料作为我们的起点。现在请随意选择它并在材料编辑器中打开它。

为了让演示开始运转,我们将快速对这个基础材料做一些操作。从发射颜色插针拖动并过滤时间或类似的内容,以放置 TimeWithSpeedVariable 节点在这里。从其速度拖动到左侧并添加一个常数。我们将确保这个常数默认为 0.0,因此不会改变在这个箱子材料中其他地方(在冰洞关卡中)的基础行为。最后,右键单击这个常数节点,选择转换为参数,并将其命名为 EmissiveSpeed,如图所示:

图片

对于那些密切注意的人,你会注意到底部的移动纹理采样器目前是 2/8。一旦你保存材料,它的当前图就应用到材料资产以及关卡中的任何实例上,你会看到它从 2 跳到 6/8!诚然,在运行时将我们的纹理采样增加三倍并不好,但在移动平台上,那个神奇的 8 数字可以是一个非常显著的限制(关于这一点将在本章后面详细说明)。但请记住:保存以完全应用更改!

一切都完成后,回到内容浏览器中,右键单击箱子材料,在顶部是创建材料实例的选项。我们将使用它来激活实例中的发射色:

就像在基本材料中一样,始终在编辑器的顶部点击你需要的任何统计信息,这样你就可以立即看到材料的复杂性概述:基本和顶点指令以及纹理采样器。小的更改可以对这些数字产生巨大的影响,并且让艺术家以及任何与材料工作的人熟悉你平台上的合理数字是早期和始终执行的关键!

注意,在编辑材料实例时,着色器的逻辑消失了。你无法在实例中修改它,但你可以修改参数(这几乎是它们的主要目的!)。在右上角,勾选允许编辑 EmissiveSpeed 的框,并将其设置为 1.0。现在,请记住,这个纹理及其相应的法线贴图是为特定模型制作的,而不是这些立方体,但我们在这里演示的是概念,而不是制作即将发货的艺术品。我们现在可以将这个(内容浏览器中的实例图标)拖放到关卡中的一个立方体上并播放它。在这个屏幕截图中,我将方向光的强度降低到大约 0.6,并将天空光的颜色调整为深灰色,但你不必做这些。这只是为了使这种脉冲发射的光照独立性质更加明显:

所以虽然它还不完美(我们稍后会改进它!)但你现在可以看到许多游戏用来突出玩家在游戏中应该捡起的项目的小技巧。它们通常有一个闪烁的光芒,使它们从附近的静态背景中脱颖而出,这样玩家就不会错过这些有价值的物品。要测试其脉冲,你可以从编辑器的播放按钮下拉菜单中选择模拟,尽管对于那些在 GitHub 项目中密切参与的人来说,你会注意到这暴露了我对玩家控制器类型的检查。所以,每当发现严重的错误时,你应立即检查并修复。但现在让我们查看那个框(无论是通过正常播放关卡,还是同步提交 ce0da7c,或者本地进行以下修复):

void AMasteringCharacter::InitializeInventoryHUD()
{
        APlayerController* player = Cast<APlayerController>(GetController());

        if (player != nullptr) // function is called with a non-player controller in simulation in editor
        {
                AMasteringHUD* HUD = Cast<AMasteringHUD>(player->GetHUD());

                if (HUD != nullptr)
                {
                        HUD->InitializeInventory(Inventory);
                }
        }
}

现在你可以看到对材料进行一些快速更改的结果。让我们快速改进一下,然后继续。首先,我们将使脉冲跟随正弦输出而不是线性输出,并对其幅度进行限制,默认设置为 0.5:

现在如果你转到材料实例。由于 MaxEmissive 现在也是一个参数,你可以看到它并将其设置为其他值(比如 0.25,使其更加微妙),然后查看我们的结果。

要创建一个动态材料实例,我们需要编辑测试级别中物理盒子对象的蓝图,选择其中的任何一个,然后在它的详细信息面板中,选择编辑蓝图并打开编辑器。在这里我们可以添加这样的逻辑,但请注意:这将改变级别中所有盒子的逻辑,甚至将覆盖(正如我们放在槽位 0 中)之前正在工作的盒子上的未修改材料实例。添加这个并查看结果:

图片

现在我们所有的盒子上都有一款真正快速脉冲的材料。如果你只想在少数几个盒子上使用,可以通过在层级蓝图中进行引用或者创建单独的盒子类型(而不是所有盒子都实例化的那个类型)来实现,但现在,希望创建具有可由蓝图事件在运行时设置的参数的动态实例的价值已经很明显了。你甚至可以在被击中时将材料改回默认的立方体材料。这里的灵活性非常强大!但请记住,如果你在运行时动态替换材料,会丢失一些特定的预计算可能性,但对于大多数游戏和项目来说,实时光照和效果是赋予动态材料实例的任何事物上的主要特性。

不要忘记静态网格(和其他网格)也可以有多个材料:只需看看地图“冰冻海湾”中的某些部件!所有这些材料在渲染演员时都会被应用。第一个材料通常是用于设置物理材料以及对象最基本/最基础的渲染的那个材料,但如果你选择一个静态网格对象(包括这个测试级别的 1M_Cube),你会看到它的材料只是一个没有硬性限制的数组(只有性能和管理方面的实际限制)。

运行时和各个平台上的材料

现在编辑材料已经变得熟悉,我们简要地提到了一个看似微小的变化如何极大地提高了材料的某个统计数据,现在是时候更深入地了解可用于运行时分析的工具体验了。有无数的工具可以用来分析场景的图形性能,其中一些内置在 UE4 中,还有一些是免费的,你可以去探索。NVIDIA 提供了一套出色的工具,对于 Android 开发者来说是一大救星,可以在附加阅读部分找到。现在,我们将首先关注我们可以在 Unreal 和编辑器内部做什么,以及我们的主要工具和早期测试点。

快速迭代着色器的运行时工具和技术

在你的工具库中,对于着色器性能来说,最直接有用的工具之一就是着色器复杂度视图模式。在任何编辑器中的视口窗口中,你可以通过点击灯光下拉菜单(位于视口左上角视角下拉菜单旁边)来找到它。在屏幕截图上,这些通常分别是“光照”和“视角”。在那里,在优化视图模式下是着色器复杂度(在 PC 上,快捷键是Alt+8,要回到光照模式,按Alt+4)。然而,在我们的测试级别中,没有什么可看的,所以让我们快速转到冻结海湾地图,那里有更多有趣的东西。在运行时,在启用了控制台的所有平台上(PC 上的~键,大多数移动设备上的四指轻触),你也可以通过显示 ShaderComplexity 命令来访问它,由于这里最有趣,让我们看看在冻结海湾开始时我们得到了什么:

图片

哇!从底部的颜色刻度可以看出,中间似乎有些严重的问题,当然,很明显是吹拂的雪花/云朵穿过地图的中心。你通常会在大多数平台上发现,从性能角度来看,你最讨厌的敌人是过度绘制。大多数开发者至少对这个词很熟悉,但这就是 GPU 被迫在屏幕上多次绘制和重绘相同像素的情况。半透明通常是最主要的过度绘制原因,这里也是一样。从渲染的角度来看,我们有一大堆巨大的粒子,所有这些粒子都具有 alpha 半透明,它们堆叠在一起(从我们的摄像机视角来看)。因此,为了将它们全部混合,那些重叠的像素被重新绘制了无数次,形成了那些明亮的白色着色器工作区域。在性能良好的 PC 上,这都没问题,即使是未优化的构建,这个级别的帧率也能达到 50+ fps,但在其他平台(尤其是移动设备)上,这很快就会变成一个噩梦场景,将在下一节中进一步讨论。

同时,由于我们在这本书中没有专门关于优化的章节,希望一个有用的工具概述和一些进一步阅读的链接就足够了。首先,了解你的 stat ... 命令行快捷方式。这些可以在游戏中提供大量的实时数据,并且熟悉其中的一些总是被推荐。话虽如此,我最推荐的是 stat CPU、stat GPU(在这里你可以轻松看到透明度的成本)、stat Game 和 stat FPS,后者在几乎每个非发布版本中可能默认开启。另外值得注意的是,stat Memory 也非常有帮助,但它是针对内存消耗,而不是直接性能。当然,找出你的游戏在哪里受限是了解如何优化以提高帧率的钥匙。再次强调,这可能是大多数开发者中相当普遍的知识;但这仅仅意味着:你的主线程、渲染线程或 GPU 在每一帧中成本最高。你的速度取决于这个群体中最慢的那个成员,Session Frontend 窗口中的 Profiler 在早期就被讨论过了。这是一个获取非常详细信息的良好工具,关于前两个(主/游戏线程和渲染线程)以查看你是否使用了过多的 CPU。检查stat GPU命令是检查这是否是你的瓶颈的最快方式,但为了获取如 Profiler 中的详细信息,你可以使用 stat startfile 和 stat stopfile 来生成详细的信息,并在前端窗口中打开它。在其他平台或从不同角度,再次,请参阅 NVIDIA 的工具(前面提到过)或探索每个平台通常提供给开发者的许多选项。关于 UE4 选项的更多细节,进一步阅读部分添加了一些链接。值得注意的是,即使在进行模拟或运行关卡时,你也可以开始编辑材质(并记得保存它们以传播更改!)并直接立即看到结果。那么现在让我们探索一些选项,以这些混合粒子作为示例。

了解你的平台以及如何调整着色器!

了解你正在为哪个平台开发显然是任何性能优化的关键,尤其是在图形和着色器部门。例如,假设我们旨在将中等性能的 Android 手机和平板电脑作为一个平台。测试在那里事物可能看起来如何的最快方法是转到主编辑菜单的设置下拉菜单,然后预览渲染级别,选择移动/HTML5,并选择 Android 预览。在这个时候,你可能看到这个:

图片

我们现在必须为这个新平台重建大约 8,000 多个着色器,但正如可能已经注意到的,这第一次在 PC 上打开这个级别时也发生了。这必须只做一次,然后较小的更改将导致较小的着色器重新编译,但最初,这可能会占用几分钟的严重 CPU 利用率,因为它正在构建所有这些着色器。只需知道,一旦它们为这种预览模式缓存,在这个级别中就不会再发生这种情况。

一旦完成,让我们再次开始并再次展示着色器复杂性,现在我们在视图中进行 OpenGL ES2 模拟时:

图片

哎呀!现在过度绘制变得严重了。我们该怎么办?

好吧,最明显的方法是,这些只是外观上的功能(除了稍微影响可见性外,并不旨在影响游戏玩法),我们简单地告诉粒子发射器不要在特定质量级别上运行。这一节不是关于粒子特效(但稍后会有一个),所以我们只是简要测试这个理论,以证明我们的 ES2 过度绘制问题确实来自它们。在级别中搜索发射器,最终 P_Snow_BlowingLarge_particulates 脱颖而出,实际上就是罪魁祸首。打开它,我们可以点击带有大型旋转云粒子的发射器,将其详细模式设置为中等,如图所示:

图片

但是,当然,在我们的预览中,除非我们也更改可用的性能级别,否则没有任何变化。在这里,我们将想要修改我们用来在编辑器中预览的可扩展性设置:

图片

所以,如图所示,我们将把当前的特效级别设置为低,哇,不再有粒子导致我们最大的过度绘制性能打击:

图片

在游戏中不用担心光照重建警告,因为在 ES2 模拟下,你无法在编辑器中构建预计算的光照(你方便地可以看到它在右下角是开启的),它还指出这个级别使用的顶点雾在这个模式下根本无法工作。这是有用的信息,但始终在可能的情况下确保在实际设备上进行测试。模拟是快速迭代几个想法的好方法,但直到你实际上在一个适当的设备上测试这些更改,并且它的库和操作系统更新到适当的水平,并且它使用自己的驱动程序,你才能确信你的结果会与这里可能希望得到的最佳模拟相匹配。

虽然如果我们不在高端 Android 设备(使用 ES3+或其他渲染)上使用这个关卡,还有很多事情要担心,但最大的问题已经被移除了。但你可能会问,“这一章不是关于修改材质以适应你的需求吗?”是的,当然是的。让我们找到这些粒子中使用的材质。从发射器的属性中追踪,它是一个烟雾状的材质实例,其父级是不透明材质。从高端渲染到 ES2 OpenGL 或低端渲染的典型调整是将那些不透明材质转换为遮罩,而不是向透明度通道发送渐变 alpha,你只需发送一个 1 位的遮罩,就像这里在地图起点附近的草地材质中一样:

图片

注意输入纹理的 alpha 通道连接到着色器的遮罩输入(风节点也很有趣,它在运行时用于移动草地,也请随意实验)。现在,当然,我们对此有一些优点和缺点。优点:在可以看到纹理的地方没有过度绘制,因为遮罩告诉渲染器一开始就不要绘制像素!缺点:遮罩处会有硬边,而不是平滑、微妙的渐变边缘。我们之前在游戏中都见过这种情况,但请看看预览窗口中的草地:

图片

这对于植物或表面上的实际孔洞等东西来说效果不错,但对于像这里这样的雪云效果来说,最多看起来像业余水平。那么,我们可以通过材质做些什么来解决这个问题呢?让我们先熟悉一下到目前为止最有用的节点,即质量开关。以下是关卡附带的不透明材质:

图片

现在我们来看看如何插入质量节点,将低质量 FX 设置为在这些设备上不执行这种 alpha 混合操作:

图片

如果我们现在将粒子系统设置回 >= 低细节模式(使其始终尝试发射),但保持我们的编辑器效果缩放设置为低,粒子也将停止绘制,不再对我们的 GPU 性能造成拖累。这可以通过在保存更改之前播放关卡来实时展示(或者简单地将 DIFF 的 alpha 直接连接到乘法节点的 A 通道)。通过将其连接到质量开关,保存材质,然后:雪云的飘渺消失。

所以,这里还有一个最后要点。在编辑器中,这一切都很好,但这些设置是如何针对每个平台,甚至每个设备来设置的?配置(.ini)文件。记住,在项目的 Config 文件夹中,你可以为每个相关平台创建一个子文件夹(例如 Config/Android、Config/Windows、Config/iOS 等),并且对于基础文件夹中的每个 Default... .ini,你可以在其文件夹中创建一个特定平台的配置文件(例如,DefaultEngine.ini 在 Android 设备上被Config/Android/AndroidEngine.ini覆盖)。配置文件总是从最具体到最不具体地应用,所以如果某个属性在AndroidEngine.ini、游戏的Config/DefaultEngine.ini以及引擎的Config/BaseEngine.ini中,那么AndroidEngine.ini中的属性将是最后应用的,并在运行时使用。话虽如此,如果你正在处理大量设备,你可能会想在游戏的Config/DefaultDeviceProfiles.ini级别添加这些属性。以下是在那里可以做的事情的简要概述:

[DeviceProfiles]
+DeviceProfileNameAndTypes=Android_Adreno4xx_High,Android

在这里,我们定义了一个特殊命名的设备和其类型(Android)。

[/Script/AndroidDeviceProfileSelector.AndroidDeviceProfileMatchingRules]
+MatchProfile=(Profile="Android_Adreno4xx_High",Match=((SourceType=SRC_GpuFamily,CompareType=CMP_Regex,MatchString="Adreno \\(TM\\) 4[3-9][0-9]")))

这部分内容告诉引擎如何确定它启动的设备是否正在运行该设备类型(通过搜索 GPU 类型):

[Android DeviceProfile]
 +CVars=r.BloomQuality=1
 +CVars=r.Shadow.MaxResolution=1024
 +CVars=r.MaterialQualityLevel=2

这里我们为 Android 设备提供了一些默认值(除非有更具体的覆盖):

[Android_High DeviceProfile]
 +CVars=r.MaterialQualityLevel=1
 +CVars=r.MobileContentScaleFactor=2.0
 +CVars=r.ShadowQuality=5
 +CVars=r.DetailMode=2

这是一个用于高性能 Android 属性的配置文件:

[Android_Adreno4xx_High DeviceProfile]
 DeviceType=Android
 BaseProfileName=Android_High

最后,我们只需将特定的 Adreno4xx_High 类型设置为 Android_High 类型,但在此点我们也可以将任何我们想要的属性设置为不同的级别。对于我们的材质,在配置文件中我们希望雪云不要显示,所以我们只需设置以下内容:

+r.DetailMode=0

以更通用的方式,你可以将(Platform)Scalability.ini 的 sg.EffectsQuality 设置为 0,这在 Epic 在进一步阅读部分的扩展链接中有详细说明。

一个警告:在本章 GitHub 项目的末尾,由于一些非常大的资源,.uasset 文件类型被添加到 Git 的 LFS 系统中。跳回早期项目将任何后续更改的.uasset 指针转换回.uasset 数据对象,并可能使 LFS 困惑,这可能需要你通过执行硬重置到 LFS 更改之前(提交 c24d2db)来修复你的本地分支,然后如果你无法丢弃.uasset 更改因为它说它无法解析指针(认为它应该是一个 LFS 指针,而不是二进制.uasset),那么你可以执行硬重置到你想工作的更改列表或分支。所有未来的维护分支(第九章、master 和新的 In-Progress)都将设置为正确跟踪它们的.uassets。这仅当跳回时才是一个问题,但希望这些提示可以帮助任何遇到这个问题的人。

摘要

对于那些已经熟悉 UE4 材质系统的用户,希望这能是一个良好的复习,并且在过程中获得了一些新的信息或指导。对于那些之前没有经验的用户,现在你们应该有了坚实的基础,可以在此基础上推动团队和项目向前发展,并使用 UE4 提供的最常用的强大工具做出正确的决策。总有更多东西要学习,但在这种领域达到一定水平是能够将项目提升到更高层次并展示给团队管理这样一个复杂且强大的世界(UE4 的材质为每个项目提供)所需技能和知识所必需的。最后,正如承诺的那样,对于那些熟悉直接编写 HLSL 或 GLSL 的用户,并且想知道 UE4 中这看起来是什么样子,请查看引擎的 Shaders/Private 文件夹中的内容,例如 VolumetricFog.usf。对于那些想要开始这条道路或对 Unreal 自己编译着色器的方式新手的用户,进一步阅读部分中还有一个很好的链接。

问题

  1. 材质和材质实例之间的区别是什么?

  2. 材料实例只有在向材质添加一个非常具体的节点类型时才有意义。这是什么类型?

  3. 从播放下拉菜单中模拟可以很有帮助。它在本章中揭示了哪些内容?

  4. 动态材质实例的目的是什么,它们可以在哪里/什么时候创建?

  5. 在地图区域中寻找导致 GPU 性能下降的着色器的关键工具是什么?

  6. 哪些命令行选项对于分析任何和所有性能问题最有帮助?

  7. 当尝试在各种平台上调整材质时,哪个节点是你的好朋友?

  8. 如何设置几乎所有重要性能因素的平台特定设置?

进一步阅读

NVIDIA PerfKit(Android 和 PC):

developer.nvidia.com/nvidia-perfkit

UE4 CPU 性能分析:

docs.unrealengine.com/en-us/Engine/Performance/CPU

UE4 GPU 性能分析:

docs.unrealengine.com/en-US/Engine/Performance/GPU

Unreal 可扩展性参考:

docs.unrealengine.com/en-us/Engine/Performance/Scalability/ScalabilityReference

Unreal 着色器开发 (.usf,包括 HLSL/GLSL 交叉编译解释):

docs.unrealengine.com/en-us/Programming/Rendering/ShaderDevelopment

第九章:使用 Sequencer 添加游戏内过场动画

引言

许多使用 UE4 开发的游戏因其出色的引擎内和游戏内过场动画而闻名。传统上,这些动画是通过名为 Matinee 的工具/系统制作的。如今,Sequencer 是创建这些场景的新一代改进系统,并且正在被现代 UE4 游戏广泛采用。本章的最后部分将简要介绍 Matinee,即为什么它不再被使用。许多年前的游戏使用 Matinee 也做得很好,它仍然是一个可行的工具;但首先,让我们来了解 Sequencer 及其功能以及与之合作的一些情况。

  • Sequencer 基础

  • 添加场景

  • 轨迹编辑

  • Sequencer 的替代方案

  • 对话系统,蓝图脚本

  • Matinee

技术要求

本章将使用 GitHub 项目过程中添加的特定资产。为了跟随实际示例,请从 第九章 GitHub 分支开始:github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-9

使用的引擎版本:4.19.2。

Sequencer – UE4 的最新过场动画工具

Sequencer 大约在 UE4 4.12 版本中推出,旨在取代(或者说,继承)Matinee,如果内容开发者或团队没有 Matinee 的经验,即使你有,这也是最佳的工作场所。它与任何其他基于关键帧轨道的工具的经验足够相似,以至于可以轻松使用,并且将是 Epic 将来使用和改进的技术。熟悉其功能对于任何希望将高质量过场动画融入游戏的游戏来说至关重要,无论是玩家可以被动观看的角色在关卡中的简单排列,还是用于传达叙事和故事的完全控制的电影级场景。因此,让我们看看它能够做什么。

为什么使用 Sequencer?

如前所述,Sequencer 是未来要使用的技术,并且为熟练用户提供了大量的选项和功能。由于这是为了帮助提供对引擎功能的整体了解,而不是内容创作专业课程,我们可能只会触及这个工具可能性的表面;但了解它能够做什么,以及它如何帮助你,对于在内容管道中做出早期决策以及向观众提供最佳体验至关重要。

顺便说一句,对于那些密切关注 GitHub 项目的人来说,你会在本章的开头注意到一些提交,这些提交使它能够为 HTML5 构建和运行(在 PC/Chrome 和 Mac/Safari 上进行了测试——后者似乎有一些稳定性问题,但现在可以进行测试)。在代码方面,这些大多数只是构建时没有作为 Windows 构建那样被捕获的标题,这在各种平台上相当常见,但像这样的事情:

#include "Engine/World.h"

MasteringInventory.cpp 中对 Windows 构建无害,因为它已经在其他地方获取了头文件。但回到序列器。

虽然序列器添加的历史及其持续和未来的支持足以成为使用它的理由,但对其功能的快速概述也是必要的。它做什么?有没有它做不到的事情?让我们列出其一些更常见的功能和它们的用途:

  • 级别序列:这些是序列器电影中大部分工作的地方,可以是单个端到端场景,也可以是子序列的层次结构(更多级别序列对象)。

  • 主序列:通常用于管理组或动态级别序列和拍摄对象,但也可以作为独立级别序列的薄包装(添加一些选项)。

  • 拍摄轨道:这是一个可以添加到序列中的轨道,但也可以作为一个独立的资产保存,这是最基本的轨道,通常控制相机、焦点等。

  • 拍摄对象:在拍摄轨道中安排多个拍摄后,可以创建一个拍摄对象,即拍摄轨道的特定序列,拍摄对象可以相互交换,以便快速迭代拍摄的感受和流程。

  • 演员到序列器轨道:这些用于将级别中的单个演员添加到序列中,这是如何在序列中移动和动画化世界中的事物的方式。

虽然有许多其他组件,但我们可以使用这些主要组件立即熟悉序列器的基本和最常用的功能。然而,首先我们还需要在我们的 FirstPersonExampleMap 中做一些背景更改以做准备。在 GitHub 项目中,您会注意到一些没有太多直接效果的改变:灯光再次变亮,除了特殊的一个盒子外,其他盒子都不使用它们的特殊闪烁(分散注意力)材料,并在我们的主地板下方添加了一个“后台”区域:

复制对象的一个超级方便的方法是在按住 Alt 键(Mac 上的 command 键)的同时将其变换拖动到某个方向;这将创建一个副本并将其移动到您想要的方向——在这种情况下,复制的地板直接位于我们现有的地板下方。

一旦我们有一个平面来放置场景中的演员,我们就可以添加一些新项目来使场景更有趣。首先,我们将前往内容浏览器的“添加新”按钮,在最上方是添加功能或内容包...的选项,我们将使用这个选项两次。请确保首先点击内容浏览器中的根目录,内容文件夹,以确保所有内容都放置在正确的层级;你可以根据你或你的团队的需要组织资产;但这样可以使它匹配 GitHub 中的内容;如果你足够聪明,可以节省 GitHub 下载时间(因为从获取引擎完整源代码开始,第一章,制作第一人称射击游戏的 C++项目,添加它只是从引擎的内容文件夹复制到你的项目中)。我们将从蓝图功能选项卡添加第三人称包,并从内容包选项卡添加起始内容:

图片

这些包含了一些我们可能不会使用的资产,这增加了 GitHub 的整体大小;但除非直接由另一个资产(或级别本身)引用或在强制烹饪列表中添加,否则不会增加游戏包的大小在平台上!这将在下一章中详细介绍!

因此现在我们可以将我们的新资产添加到后台区域。在制作这样的后台区域时,请记住两点:

  • 玩家永远不应该能看到它或访问它

  • 它应该有与放置演员的地方相似的照明,这样当它们在序列中添加到另一个区域时,照明就不会明显地“弹出”。

注意,你还可以让序列中的角色“根据提示”生成,这样它们就根本不可见(我们将演示这一点);但这样你也在做的时候可能需要支付同步加载的成本(如果角色之前没有加载),所以请注意。我们的后台现在是这样的:

图片

现在请注意,这是一个普通的骨骼网格,甚至不是一个 pawn,而且重要的是,它在其右侧使用的是“动画模式”:使用动画资产而不是使用我们在其他地方制作的动画蓝图,因为这会覆盖我们在序列中尝试做的动画。人偶角色,大多数读者可能都熟悉 Epic 的模板,也设置为不使用 AnimBP,以利于我们的序列。你还可以在这里看到,我们可以从顶部主菜单的“电影”按钮添加主级别序列。我更喜欢在包含地图的内容文件夹中添加这些,因为它们通常紧密相关;但当然可以创建一个单独的电影文件夹或类似的东西:

图片

由于我在拍摄这个镜头时已经添加了场景,你可以在可编辑的电影中看到它,它被命名为 CountessIntro 并保存在 FirstPersonExampleMap 中。从这里,我们可以继续制作和触发一个剪辑场景的乐趣!

添加场景并触发它

现在我们已经在我们地图中添加了一个空的骨架级别序列,但我们如何使用它呢?让我们快速浏览一下触发方面的事情,然后我们可以开始制作一个相当基本的场景并观看它的播放。然后我们将提高质量并使用一些有趣选项来详细说明。然而,在处理此类场景时,首先要做的事情是为自己提供一个合适的视角来进行操作,因此,在视口下拉菜单中,选择这里看到的“双面板”布局:

图片

确保在使用电影预览窗口时,您已选择电影摄像机,很容易忘记,但当然这是场景中将要使用的那个。

在左侧面板选择的情况下,将其(在左上角的相同下拉箭头中)设置为电影预览。现在有了专门的电影预览面板,就可以快速对场景本身进行工作了。但在我们开始之前,还有一步——我们需要一个可以使用并且可以触发体积的空间:

图片

还要注意,在 GitHub 项目中,场景中开始于空中的平台只是我们级别中的一个平面缩放的盒子,并且在其属性中 Simulate Physics 和 Enable Gravity 都没有勾选。触发体积被设置为仅检测重叠的 pawns,并且在级别蓝图中所见,一旦被一个掌握角色触发,就会自我销毁:

图片

尽管为了表明这个蓝图需要分成两个镜头,但你可以看到触发体积随后立即被销毁,然后我们在场景结束时创建了一个级别序列播放器并做了一两个聪明的事情:

图片

在这个阶段,我们的序列实际上已经开始播放,但直到我们让它执行某些操作之前,它应该只是立即结束并摧毁平台,然后生成 BP_Countess。所以在我们讨论场景的“如何”之前,让我们先跳到场景的“是什么”:当玩家走进这个角落时,伯爵夫人会骑在平台上降落到地面,播放她的原地转身动画以面向外侧,在底部暂停片刻后,然后在她面前播放嘲讽动画。这一切当然都是来自我们的后台骨骼网格伯爵夫人,所以这个角色不能受到伤害,也没有移动 AI。在序列的末尾(由我们绑定到该事件的标记),我们摧毁了她骑的平台,骨骼网格自动返回后台,并在相同的变换(由于我们在 BP_Countess 类的网格中设置了 90 度旋转)处替换为一个完全功能的 AI 版本。

接下来是基本序列本身:

图片

首先,我们需要将这个基本场景的两个演员添加到序列中,所以双击 CountessIntro 打开它,然后点击顶部附近的+Track 按钮。如果你已经在世界中选择了演员(在这个例子中是我们的平台和伯爵夫人骨骼网格实例),那么它们将出现在顶部以便添加,而不是搜索。一旦添加,最好在它们当前的位置添加一个变换关键帧(假设这是每个演员的期望起点)。你可以通过点击整个变换来实现,或者对于我们的平台来说,你实际上只需要选择它的位置。当轨道被选中,时间滑块(顶部红色凹槽处的线)在开始位置(点击底部的<||图标将其发送到那里)时,按Enter键,或者你可以点击轨道选择器部分中左右箭头之间的微小+图标来添加一个。大多数习惯于任何类型的关键帧界面(主要软件包中的动画、其他电影工具)的内容开发者应该很快就能掌握变换和关键帧的更改。

对于最简单的例子,选择这个平台,将时间滑块向前滑动到 125,将平台移动到地面水平,并在序列编辑器中添加另一个关键帧,现在你可以在这个序列中前后滑动,或者从开始播放,看到平台在关键帧之间移动。你会注意到默认情况下关键帧是一个橙色圆圈;在这种情况下,我右击了起始关键帧,将其插值设置为线性(绿色三角形),这样在从起始关键帧移动到结束关键帧的过程中就没有加速度。同样,对于伯爵夫人,你会在-1 单位标记处看到一个正方形,它会将她移动到平台上站立。此外,右击伯爵夫人实例名称/区域本身,将其设置为“转换为可生成”,这可以防止从地下到平台顶部的超快但可见的运动。对此有几种替代方案;但由于后台伯爵夫人总是在关卡开始时生成,因此对关键帧生成没有加载时间的影响。所以,在时间 0 时,取消勾选“已生成”框,并在“已生成”轨道上添加一个关键帧。在时间 1 时,勾选它,并添加另一个关键帧。当然,你也会看到在时间零时有一个常量类型的关键帧(蓝色框),代表她的平移和旋转(简单地将其设置为这些值)。现在她干净利落地出现在场景的开始处(你可以添加一个很好的效果,或者任何其他符合你设计逻辑的效果)。为了使平移与平台完全匹配,我实际上为它们添加了关键帧,选择它们的同时将它们的平移轨道设置为一起移动两个演员到地面,并在时间零设置第一个关键帧,然后滑动到 125,将他们两个都向下移动,并设置两个演员的第二关键帧(并且再次,都是线性运动)。然后我进行了一个曲线旋转,从面对地图内部,转向面对地图内部,但在那种情况下,我只选择了她的旋转轨道,因为我将初始旋转设置为 0,将其默认设置为立方(自动)转换类型,然后在时间 125 时,将她旋转到面对地图并设置第二个关键帧。

如果你把它当作现状来看待,你可以随意走动,一旦触发场景就会看到场景的播放,然后在完成之后与“正常”版本的伯爵夫人互动(这应该基于她的视觉感知,通常紧随玩家之后)。目前她没有动画,所以从她的实例的 Track+按钮(她的名字右侧,SM_Countess_Instance)添加,你会在截图和 GitHub 上注意到,你可以将多个动画添加到一个动画轨道上,或者多个动画轨道。在这里,每种都做了一次,选择她的转身动画直到她倒在地上,在她空闲动画上稍微重叠一点,以便在两者之间没有可见的突兀感,然后添加一个播放她挑衅的轨道,并调整其开始时间,直到它也稍微重叠空闲状态。在许多游戏中,这正是所需的“电影级”体验水平。我们将为这个场景添加一些音频,以增加真实感;这是一个相当传统的“游戏内”场景,而不是一个专门的“电影级”剪辑场景,后者通常会剥夺玩家的控制权并直接操纵摄像机。我们将使用上述音频快速处理这个问题。

当与 Sequencer 中的大多数轨道一起工作时,双击会将在你的编辑器窗口中调整该轨道的大小以匹配序列的长度,再次双击会将它恢复到通常更缩放的级别。当你想要查看整个序列时,这非常棒。

在 Content/FirstPersonCPP/Maps 下,向其中添加一个额外的 LevelSequence 对象(如果你在内容浏览器中右键单击,可以在动画下找到它):IntroShot1。开篇镜头 1,它并不很有帮助,因为它没有“附加”到我们的序列中,但为了快速制作多个镜头,专注于不需要像我们那样进行关键帧动画的演员,这可以是一个非常有用的工具,使得许多关卡序列在单个序列中用作镜头。为了参考,我在实验这个场景时确实使用了几个镜头(而且,我们在这里是在学习技术和其功能,而不是证明艺术才能!),但最终发现使用单个镜头中的一些精选关键帧类型效果最好,所以这是唯一一个提交到 GitHub 的。然而,使用多个镜头,将它们组合成 Take,并快速在单个序列中切换这些镜头,Epic 在“进一步阅读”部分提供了一个非常方便的链接,供感兴趣的人参考。Sequencer 所能实现的功能深度和广度可能会让人感到不知所措,但再次强调:了解你项目的期望水平和你团队的能力,你将每次都成功,而其他团队则会失败。

现在,让我们继续完成我们的屏幕;在伯爵介绍序列中,使用顶部的+按钮添加一个轨道,并选择镜头轨道。使用+镜头按钮,添加我们的 IntroShot1。仅仅为了学习的目的,公平地说,我在导入之前,已经在关卡中为这条轨道布置了大部分的序列,但之后不得不进行一些调整,这在当时因为直接在视图中操作 CineCameraActor 而变得有些困难,尽管你总是可以专门为该相机演员分配一个视口来进行关键帧动画。观察镜头,你会看到随着伯爵下楼梯,线性关键帧,我使用一对恒定关键帧作为跳切(而不是交替镜头,但正如所提到的,这始终是一个选项),然后随着相机拉远,还有一些立方关键帧:

注意:你的子轨道只存在于它们在父序列中设置的时间范围内!在许多情况下,你可能需要将它们在父序列中的结束时间拖动,以使其与自身的结束对齐,以便在序列编辑器中编辑该子轨道。另一个方法是,你可以右键单击许多轨道,允许在电影播放后保持最终结果,这对于移动实体并在序列结束时精确地恢复游戏位置非常有用,例如。

回到介绍序列本身,接下来只需在合适的时间添加一个带有嘲讽音轨(当然,场景还需要更多的音轨)。最后,将模特添加到场景中,让玩家对自己的位置有一个视角上的感知,但请注意,这是一个固定位置,因为我们再次将骨骼网格实例化到过场动画中,因为玩家的第一人称角色看起来甚至更不吸引人。如果你将场景的第一个版本(玩家从自己的视角观看)与这个最终版本进行比较,差异应该非常明显——一旦意识到可用的工具,专业的内容创作者可以做到更加令人印象深刻的事情!

CineCamera Actors 本身非常强大。现在我们在这个场景中有一个,请随意探索它能做什么,并查看进一步阅读部分中 Epic 提供的链接,以获取更多信息。

当相机在场景中时,让我们进行最后的清理工作,隐藏我们的准星。通过触发和结束蓝图事件,我们隐藏整个 HUD,但这是游戏中唯一可见的部分:

就这样!没有太多的痛苦,我们已经完成了过场动画的基本要素,并且可以通过更多的实验和投入的时间来构建所需的专业水平。

不要忘记,当与嵌入序列一起工作时,你将经常需要保存镜头、包含镜头的序列以及包含序列的水平。在创建工作流程时,请考虑水平更改的频率和版本控制。完全有可能创建一个副本水平,让电影艺术家在这里工作,而水平设计师则在“真实”的水平上工作,这样他们就不会与无法合并的二进制资产发生冲突;但当然,这也意味着需要额外的工作来确保他们各自的工作保持同步,因为他们都在发展自己的作品!

序列器的替代方案

本章的主要目标是使读者熟悉并建立对序列器的信心。话虽如此,如果一个团队没有时间(或需求!)来生成这样的电影级场景,还有什么其他选择呢?大多数游戏都需要一定程度的脚本场景来帮助玩家学习游戏(教程)或在某些时刻提供故事阐述、沉浸感和情感冲击。但鉴于 UE4 提供的所有功能和我们已经涉及到的这些内容,进一步深入研究以确保使用正确的工具来完成这项工作是非常有价值的。

快速且简单的游戏场景

在没有展示在上一节中的基于序列器的完整电影级场景的情况下,考虑游戏场景有几个重要方式。以下是一个快速选项列表和常见用途:

  • 对话系统:这在第七章中已有详细讨论,即在游戏中添加音频,并提供额外的阅读材料以帮助那些正在使用它的人进一步了解。对话非常适合角色在游戏中与音频(以及潜在的动画)互动,具有定制的本地化文本和可调整的音频,这些音频可以在不同角色类型之间进行切换(例如,对男性角色的对话可以与女性角色的对话不同,或者场景中的朋友与敌人之间的对话)。尽管如此,对话的流程、播放的动画以及玩家的控制程度主要取决于设计这些交互的设计师,对话更多地是一个音频和流程控制工具,而不是一个完整的场景创建设备,因此我们的下一个可能性是。

  • 蓝图:只要付出足够的努力,你几乎可以做到你想象中的任何事情(包括在第三章中提到的,蓝图审查和何时使用 BP 脚本,创建整个游戏),在蓝图里都可以实现。最终,这只是一个管理复杂性的问题;但移除玩家控制、设置特定的相机演员、触发沿样条曲线播放运动、延迟播放带有音频的动画和特殊效果,所有这些都可以通过蓝图实现。对于你想要了解更多信息的任何蓝图子部分,也有大量的信息,如果你遇到任何问题,社区支持也非常好。最大的缺点是,完全使用蓝图有一个陡峭的学习曲线,而且对于没有深入训练的团队成员来说,使用时存在巨大的错误风险,而且维护这些庞大的逻辑图可能会变得令人难以承受。不断地将重复使用的逻辑块减少到函数库,并由高度技术的人员进行监督(或创建内容)通常可以缓解这些问题,但在向不知情的艺术家推出之前要小心!

  • 简单序列:我们上一节中的开场场景只需通过本课程就能在数小时内轻松完成,即使没有对序列器有任何熟悉度。如果只是定位演员、播放一些动画,或者加入一些酷炫效果,那么即使是技术和非技术类型的人也能通过最少量的培训来管理这些。

那么关于 Matinee 在顶部提到的内容是什么?继续阅读!

日场

到现在为止,可能已经反复强调过,基本上 Epic 之前保持的任何工具,出于遗留目的,序列器都可以做到。话虽如此,可能会出现某些情况,比如有人对 Matinee 有丰富的经验,或者可能有一个外包团队仍在使用它,他们希望快速将其应用于项目。除了已经提到的序列器是未来支持的工具这一原因外,没有特定的理由不能继续使用 Matinee。Matinee 因其能够在平滑的抛物线曲线中环绕建筑物的飞行汽车而成为经典。它是许多经典 UE3 和所有早期 UE4 游戏电影制作的主要支柱。刚刚完成序列器工作的任何人应该会立即认出 Matinee 编辑器中打开的许多熟悉主题:

图片

虽然主要的不同之处在于 Matinee 的前端曲线编辑工具,但不必担心,所有这些控件和一些新功能在序列器中也是可用的(查看序列器顶部的工具按钮,将其视为曲线,并选择一个带有关键帧的变换或其他轨道):

图片

无论最终哪种方法最有效,了解可用的选项是使你的项目成功的关键,而且很可能会使用一些组合中的任何数量的选项;但到目前为止,至少风险和可能性应该从一开始就很清楚。

摘要

序列编辑器是一个惊人的工具,如今人们实际上正在使用 Epic 提供的技术实时制作电影级的电影,这是一件奇妙的事情。就像 UE4 的许多方面一样,这些都是深入的话题,每个话题都可能(并且很可能)有该领域的专家;但作为一个团队的技术领导者和推动者,能够舒适地使用它们,并了解它们如何以及如何相互作用,这一点被高度重视。序列编辑器和其他可用工具可以增强任何游戏,并将其推向渴望更多内容的玩家。说到这一点,我们下一章将介绍如何实际上触及那些玩家,并使用你的项目。

问题

  1. 为什么熟悉序列编辑器并继续使用它是一个主要的原因?

  2. 在序列编辑器中最常用的轨道有哪些?

  3. 你如何将一个演员放入轨道中?

  4. 为什么不在序列编辑器中直接使用带有动画蓝图的傀儡?

  5. 使用关卡序列镜头的目的是什么?它有什么好处?它与轨道有何关联?

  6. 在你的地图关卡中嵌入序列的主要风险是什么?

  7. 如果蓝图脚本可以做到几乎任何序列可以做到的事情,那么使用它来制作过场动画有什么风险?

  8. 你如何在序列编辑器中微调关键帧的曲线?

进一步阅读

多个镜头和拍摄:

docs.unrealengine.com/en-US/Engine/Sequencer/HowTo/TracksShot

电影摄像机演员:

docs.unrealengine.com/en-US/Engine/Sequencer/HowTo/CineCameraActors

第十章:游戏打包(PC、移动)

简介

成功地将你的游戏推广给受众可能是 UE4 用户最被低估但绝对必要的技能。在本章中,我们将探讨几个这些途径。每个团队和每个项目都需要探索它们各自的平台,但过程在每个平台之间相当相似,当然,每个平台都有自己的特定之处。对于一些人来说,将游戏推广给受众可能比其他人更复杂,但希望到本章结束时,你将有一个很好的想法,了解将产品带到几乎所有市场的期望。在本章中,我们将涵盖:

  • 为 PC 打包游戏

  • 安卓和 iOS 设置

  • 何时以及如何制作游戏的独立安装版本

  • 在 Android 和 iOS 上构建和运行

  • 比较 UE4 的播放与打包构建

  • 避免构建中断的技巧

技术要求

将在 GitHub 分支第十章中提供具体示例和参考,并进行测试,但如往常一样,描述的原则和过程可以应用于任何项目。此外,游戏将构建并测试在至少两个设备上:安装了 Windows 和 Visual Studio 的 Samsung Galaxy Note 8 用于 Android,以及用于构建和安装 iPad Mini 3 的 macOS 和 Xcode。虽然这里的教学不需要这些设置或平台,但希望它们为那些需要的人提供有价值的见解和信息。最后,对于任何数量的免费浏览器(主要是 Mac 和 PC 上的 Chrome),将创建并部署 HTML5 构建:github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-10

引擎版本:4.19.2。

了解你的平台(们)

当然,在不同的平台上打包游戏是非常不同的。为 iOS 构建、提交和发布游戏与 PS4 或 PC(例如通过 Steam)是完全不同的体验和过程。显然,首先要问的问题是:“我们的目标受众是谁,我们将提供哪些平台?”UE4 的伟大之处在于,对于你可能会发布的任何平台,它都有一条路径,并且可以构建在该平台上运行的版本。虽然 Xbox 和 PS4 是不同的生物,因为它们需要它们的主机(分别是微软和索尼)的直接参与,但这里的平台通常限制较少。我们将在这里通过几个例子来讲解。

与有经验的人在项目部署的最后阶段合作总是好的。例如,微软为 Xbox 和其他领域设有开发者账户经理(DAMs),他们与中型和大型项目合作,这通常由工作室的生产团队负责建立联系并促进与开发团队的沟通。话虽如此,对于独立开发者来说,这可能听起来很显然,但如果你能与其他在平台上取得成功的独立开发者合作,那就更好了。这些领域的社区通常非常包容和支持。每个人有时都需要一点帮助,而参与并成为这些关系的一部分是游戏开发中另一个被忽视的优势。

设置可安装的 PC 版本和通用设置

对于 UE4 来说,最简单的打包平台可能是 PC(或 Mac):你已经在上面运行了,而且你很可能在 PIE 中完成并测试的工作,在打包构建时在 PC 上“直接”就能工作。尽管如此,我们仍将探讨为最流行的移动平台 Android(可安装的.APK)和 iOS(类似的.IPA)制作版本所需准备的工作。请注意,对于后者(iOS),除非制作的是蓝图仅游戏,否则实际上构建和正确测试需要苹果开发者许可证,截至本文撰写时,一年的订阅费用约为 99 美元。如果你的目标平台之一是它,通常最好是尽早获取该许可证;但对于预算有限的爱好者和独立开发者,请注意,使用本项目在此展示的部署和测试 Android 和 HTML5 完全可行且免费。

对于特定平台用户,请注意,用于构建、测试和部署 Android 的是 Windows 10 PC 和 Visual Studio(Mac 无法使用),用于构建、测试和部署 iOS 的是配备 Xcode 的 MacBook Pro(没有 Mac 的某些类型,代码更改已经应用到我们的项目中,将无法在 PC 上工作)。理论上,可以在网络中用 Mac 从 PC 构建 iOS 构建,但这种情况通常在专业中型工作室和团队中,以及本书的家用开发中都会遇到问题。如果你发现这很容易且可靠,恭喜你,但设置这些步骤的说明将仅在进一步阅读部分中提供。

在深入 Android 构建之前,这里有一些关于本节中讨论的移动平台的通用注意事项。首先,对于那些好奇的人来说,下一节将具体说明在设备/平台组合的编辑器中使用播放按钮与本章其他部分讨论的打包之间的差异,所以如果在这个阶段看起来缺失,请耐心等待。以下是我们的项目在烹饪和打包所有平台时应设置的通用设置,让我们在这里查看项目/打包下的设置:

图片

例如,由于 FrozenCove 在我们的默认地图的关卡过渡音量中仅以轻描淡写的方式提及名称。我们将它(以及类似地 MasteringTestMap)添加到上面始终包含的列表中:

图片

此外,由于她可以在没有硬引用的情况下动态生成,我们在 Countess 的 Meshes 文件夹中处理(实际上它只是作为一个例子,说明当你有资产可能加载并使用但不在地图中直接硬引用时你可能需要做什么)。如果你在项目设置的“平台”部分下浏览每个平台的选项,你会注意到它们都有可以设置的启动画面纹理,许多都有分辨率或方向选项,你的团队可以(并且应该)根据需要设置。

Android 设置

处理完这些事情后,我们继续为 Android 构建。首先,前往你的引擎安装目录下的/Extras/AndroidWorks/Win64文件夹并启动可执行文件。这将自动引导你完成成功构建 UE4 Android 项目所需的所有步骤,以及 Visual Studio 的基本集成。在本地 Android 构建和发布到公共 Android 构建之间有几个差异,涉及到商店密钥(或上传密钥,我推荐使用),而有助于避免我简单地将所有这些 URL 复制到“进一步阅读”中的是,Epic 直接将链接集成到 Android 平台项目设置中所需的信息,请参见这里:

图片

那些蓝色链接应带您到那些主题的最新 Android 页面。在那里,您可以阅读有关将构建提交到 Google Play 商店的各种方法的全部内容,以及一个注册密钥存储的链接。由于该过程是针对特定项目和团队选择的(链接提供了良好的背景,这里不需要重复),以下是一些快速的一般性说明,将引导到更技术性的下一个主题:对于那些还不知道的人来说,在 Play 商店中,您可以通过排除某些类型的硬件、特定手机或设备来限制您的标题,仅向具有特定 Android 版本的用户显示(这应该与您在这里 Epic 项目设置中设置的版本相匹配!),并且在其端有大量选项来决定您的游戏展示给用户的具体内容或限制内容。通常,项目经理或其他专注于此的团队成员将在项目的 Play 网络界面上设置这些具体细节,但从技术角度来看,了解这些选项的含义以及它们与您生成和上传的构建的兼容性是至关重要的。以下是一个明显的具体示例:

Android 最基本的配置之一是使用 OpenGL ES2 和 ETC2 压缩构建,前者在项目设置中(稍后会有更多介绍),后者在此处选择,其中一个选择是可调的多配置:

图片

现在,ETC2 是 Android 上非常广泛分布和支持的纹理压缩,而 ES2 是在大量旧手机和平板电脑上使用的 OpenGL ES 的基础版本。如果你使用这两个设置构建,当尝试编译着色器时构建将失败。如果你简单地改为较新的压缩程序,ASTC,你会注意到构建成功,但 Countess 将显示默认的灰色格子纹理。在 ASTC 中,她的材质不会导致构建失败,但在 ES2 中也无法正确渲染。如果你检查这些材质中的一个,问题就很容易追踪:

图片

在这里,ES2 并不是最佳选择,最明显的解决方案是将我们的 Android 构建改为 ES3.1,正如它所推荐的:

图片

在这种情况下,我们的构建成功构建并打包,Countess 在这里使用的 Galaxy Note 8 上看起来很棒(但截至本文写作时,这是一款高端手机,相当昂贵)。那么,如果你的团队希望游戏在旧设备上运行会发生什么?嗯,这就是第八章中提到的质量开关发挥作用的地方,着色器编辑和优化 技巧

图片

如果你双击该节点(MF_CharacterEffects),你会注意到我们只跳过材质的一个特定方面(死亡渐变);但这通常是情况,如果你制作一个需要在大量设备和功能上工作的项目。ES2 构建会完全跳过有问题的功能,现在 ETC2/ES2 构建成功并运行良好,尽管这当然需要保存受影响的几个材质并重新构建大量着色器,因此要小心这些风险!注意,拥有可以交付到不同硬件集的独立“SKU”(想想产品型号)是完全可接受的,并且相当常见。一个游戏不需要为可能安装的所有可能的硬件组合制作,但如果你能这样做,特别是对于多人游戏来说,这是理想的。如果维护高端和低端潜在平台的这种兼容性变得不合理,为高端设置一个 SKU,为低端设置另一个 SKU,并且每次需要发布更新时都准备好加倍(或以其他方式乘以)你的构建及其测试时间。

最后一点,你会注意到 Android 有一个选项可以将资源打包到.apk文件中——对于一个非常小的项目来说,这可能可以接受,但对于大多数项目(包括这个小型项目)来说,建议不要使用这个选项。这将生成一个.obb 文件,Play 商店知道如何处理下载和安装,这在需要更改小.apk文件(如错误修复)而没有对项目资源进行更改时尤其方便。人们可以测试和运行,如果你不包含每个构建中数百 MB 或 1+GB 的资产,而只是使用典型的数十 MB 大小的.apk,你可以更快地提交构建。

iOS 设置

与 Android 的 Play Store Key 类似,苹果有一个相当复杂但定义明确的流程来生成密钥并为其.ipa 包签名,以便部署到 iOS 设备进行测试。最终,你所使用的应该看起来像这样,到处都有“有效”的部分,包括开发签名配置文件和证书,以及各种 Ad-hoc 或其他生产配置文件/证书组合:

图片

所有关于苹果公司这一步骤的链接都列在这里,因为 Epic 没有像 Android 那样直接提供链接。注意,你需要一个有效的苹果开发者账户才能访问这些链接中的大多数。

  1. 首先,你需要一个 AppID,确保如果你使用类似于“com.mastering.*”这样的格式,在你的项目设置中不要将其设置为“com.mastering”,否则将无法找到,它需要是“com.mastering.dev”或其他格式,以便与配置文件匹配你的证书(更多关于这一点,请参考苹果公司的说明):developer.apple.com/account/ios/identifier/bundle

  2. 至少设置一个设备(例如测试 iPad 或 iPhone):developer.apple.com/account/ios/device/ipad

  3. 你需要熟悉制作开发(你只能获得一个)和 Ad-hoc/Store 分发证书。如果你是第一次使用苹果构建流程,建议从开发证书开始:developer.apple.com/account/ios/certificate/create

  4. 然后同样,你将想要熟悉制作配置文件——通常为你的团队制作一个单独的开发配置文件,然后为 Ad-hoc/Store 分发制作一个或多个:developer.apple.com/account/ios/profile/

通过点击以下链接下载这些内容,并将它们导入 UE4。希望一切顺利,但就我所知,在多年的开发中,我从未见过有人第一次尝试这个过程就成功。幸运的是,有成千上万的其他人可能遇到过你将会遇到的相同问题,经过一番搜索,通常可以在网上找到解决任何阻止你在这里顺利、成功设置的问题的解决方案。

如何构建、测试和部署

现在所有设置都已完成,这一部分应该能让我们迅速通过在移动(或 HTML5 和 PC)平台上的正确设备测试。正如在iOS部分中提到的,如果事情没有立即正确工作,请不要沮丧,每个团队在最初设置和正确工作这些事情时都会遇到困难,但一旦完成,通常维护和修改这些构建设置和流程的过程会变得更加顺畅。所以,让我们开始吧!

UE4 的播放选项与打包项目

好的,在我们实际进行一些构建并在目标平台上测试之前,还有一个最后的打扰:你可能想知道,“使用编辑器中的播放按钮直接将内容上传到我的设备,与这个打包业务相比,两者之间有什么区别?”嗯,非常粗略、非常简短地说:播放按钮会构建、编译并将内容部署到平台,并且做得相当不错,这对于快速测试非常有帮助。然而,当安装时,它可能将文件放在与你的独立构建不同的位置,并使用一些特定的设置。例如,可能需要能够从 Mac 上的 iOS 外部调试器“附加”到游戏。通常必须在你的设置中使用开发者证书/配置文件,当使用播放按钮时,并且特别不要/不能在您上传到苹果公司以部署给受众的产品上设置该权限设置。所以,再次强调,这里要运用常识:如果你只是进行快速修改并想在硬件上进行简单测试,如果播放按钮对你的项目和平台有效,请随意使用它。当你期望将内容实际交付给商店进行部署时,请创建独立构建并使用这些进行测试。

何时以及如何在设备上构建和测试

当你的团队觉得需要在各种平台上开始测试时,这实际上取决于你整个的开发哲学和流程。大多数主要工作室已经建立了这些流程,并且有特定的里程碑关卡需要通过,其中一些可能特别需要游戏将在其上运行的平台(或平台组合)上的独立构建,而其他阶段可能只需要展示游戏玩法和艺术资源,这不需要这样做。许多独立开发者可能只想尽可能缩短迭代时间,以验证基本概念(就像这本书到目前为止所做的那样),这在 PC 或 Mac 上使用 PIE 编辑器进行测试时是最简化的。然而,如果你在阅读这一节,这意味着你最终需要在设备上运行,并且这里最好的建议是,如果可能的话,从开发的第一天开始就使这些构建工作,并定期进行构建、部署和测试。这是开发中经常被忽视或长期处于休眠状态的一个方面,然后在最需要和最使用时,会引发不可预见的问题。当然,不花时间制作独立构建(甚至使用播放按钮)进行日常开发要容易得多;但一个团队不做这些的时间越长,当它们最终做的时候出现问题的风险就越大,这些问题通常是完全阻止发布的问题。所以,尽可能经常地进行构建、部署和测试!

制作独立构建并安装它们

Windows:由于实际上没有什么复杂的,制作 PC 包就像 文件 -> 包含项目 | Windows | Win32 (或 Win64)一样简单,对于 Mac 构建也是如此。这会给你一个可执行文件和你的构建资源文件夹,在这个阶段,上传到产品,如 Steam 进行交付应该非常简单。鉴于这一点,我们不会在这个问题上花费太多时间;但如果你只是想测试构建过程,这是最快、最简单、最简单的方法。如果你在项目设置 | 平台 | Windows 下检查,你也会注意到选项非常少,所以我们不再赘述,接下来我们将转向 Android 和 iOS 的打包。

Android:在“文件”|“包项目”|“Android”下选择你偏好的纹理压缩类型(参见设置部分下拉菜单的图片),这将生成一个.apk 文件,默认是一个.obb 文件,以及两个.bat 文件到你在点击开始构建时指定的文件夹。.bat文件标签非常清晰:一个用于卸载项目,另一个用于安装它(注意,后者将执行前者以确保每次都获得具有相同 AppID 的项目的干净安装)。假设你已经将设备设置为调试模式,如developer.android.com/studio/run/device所示,并且可以使用“adb devices”命令行测试它是否被识别,那么那个.bat 文件将自动直接安装到该设备(如果你只有一个设备,这通常是情况),

iOS:选择“文件”|“包项目”|“iOS”将在你指定的文件夹中为你生成一个.ipa 文件。通常,然后只需将其添加到 XCode 的设备窗口中,使用设备窗口中的+按钮即可。

HTML5 会将所有 JavaScript 以及它使用的其他内容打包成一个可以拖入浏览器中启动的包。你如何从网络分发角度向用户交付这个包取决于你,这里不再进一步探讨。

一旦这些构建在设备上,它们就可以像其他传统安装一样运行,通过从各自的商店下载。幸运的是,苹果和谷歌都提供了测试上传构建的临时区域,因此可以在提交/批准之前完全测试这些平台的整个流程。

避免在发布前附近的平台上的重建地狱

最后一点提醒是在考虑如何测试时要使用常识。你的更改是否可能影响不同平台上的图形(如材质编辑、复杂的新着色器更改或更改可能影响此类功能的项目设置)?那么请在代表设备上测试它们。你的更改是否仅限于游戏玩法(请注意,使用文件系统等事物在不同平台上可能会有问题,在 GitHub 项目的这个阶段,确实存在我希望能尽快解决的加载/保存设备上的错误,例如)?通常最好在开发平台上快速迭代这些更改;但最终完成时,至少要过夜,为实际平台构建,并确保没有意外。在一个大型项目中,每次你想测试任何小的更改时都花费一个小时来处理资产是愚蠢的,但忽视你实际的目标硬件平台则过于粗心。

请记住,现在有一些非常好的工具和技术可以自动化许多这些流程,并在单个高性能机器上集中构建和部署,这样整个团队都可以随时轻松访问和使用。例如,请参阅“进一步阅读”部分中的 Jenkins 和 HockeyApp。对于像 Jenkins 这样的构建自动化平台,设置通常可以简单到在通过 Unreal 编辑器打包时观察输出窗口,并将它使用的每条执行行复制到那里的步骤中。如果你有一个大型项目、大型团队或大量目标平台,这种自动化类型非常推荐。

摘要

虽然离游戏开发中最华丽或最激动人心的领域还远,但构建和部署对于任何希望触及除自己桌面之外受众的项目来说绝对是至关重要的。编写代码和构建着色器可能是游戏开发中更直接令人满意的部分,但如果你不能正确地构建和发布到平台,那么所有努力都将付诸东流。了解如何设置和维护这些流程是掌握 UE4 开发的绝对关键步骤。但接下来,我们将回到 UE4 提供的一些美丽视觉效果上!

问题

  1. 为什么确定目标平台是任何项目的必要步骤?

  2. 为什么将地图和内容文件夹添加到始终烹饪列表中是有原因的?

  3. 添加它们可能存在哪些风险?

  4. 从最低到最高的设置复杂度顺序是 iOS、PC 和 Android 吗?

  5. 拥有一个单一“SKU”的产品有什么优势?拥有多个 SKU 解决了哪些问题?

  6. 为了构建 iOS,需要哪些四个要素?

  7. 在着色器中,对修复(如质量级别开关)的一些优缺点是什么?

  8. 如何减轻大型项目的构建和迭代时间?

进一步阅读

在网络上的 Mac 上从 Windows 构建 iOS:

docs.unrealengine.com/en-us/Platforms/iOS/Windows

Jenkins 构建自动化平台:

jenkins.io/doc/

HockeyApp 设备部署:

hockeyapp.net/

第十一章:体积光图、雾和预计算

简介

UE4 提供了开发者希望拥有的最前沿的大量图形功能。好消息是,对于许多平台上的许多游戏,有了良好的关卡设计师团队,这将被大部分自动处理。然而,对系统工作原理的普遍无知可能会给团队带来两个主要缺点:未能利用内置的高级引擎技术,或者你可能遇到构建时间或构建大小问题,却不知道从何开始解决。虽然 Unreal 中可用的实际技术和光照物理本身可以填满一本书(确实如此!),但我们在本章中再次关注的是了解选项和使用方法。到本章结束时,将确保对如何最佳使用光照以及如何解决由此产生的问题有信心,以帮助制作现代美观的游戏并指导团队遵循最佳实践。本章涵盖:

  • 体积光图

  • 大气雾和体积雾

  • 光照质量(设置和预览工具)

  • 光照质量分析

技术要求

如同惯例,本章将使用 GitHub 上我们项目第十一章分支的示例,但概念和技术适用于任何项目:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-11

使用的引擎版本:4.19.2。

体积光图、光照质量和雾

在本节中,我们将简要介绍标题中列出的三个主题,这些主题对于新或业余游戏制作者来说往往令人困惑:

  1. 体积光图是一组预先计算的基于体积的颜色,可以快速用于确定地图中任何给定区域的反弹光照。对于那些熟悉 4.18 之前 UE4 的用户,这是通过间接光照缓存完成的,但它有一个固定的采样大小。新的光照质量使用更动态的采样以增加细节。这些与普通光照质量不同,普通光照质量是直接烘焙到场景中的颜色,通常作为混合纹理应用到对象上。另一种思考体积光图的方式是将其视为空间样本中复杂颜色信息的快速查找表。

  2. 光照质量是 Unreal 中确定光照属性的系统名称,包括如何(以及是否)计算体积光图。为了防止光照时间过长,通常你将添加一个或多个光照质量重要性体积(一个实际的 Unreal 基本体积类型),以告诉引擎和编辑器你关心这些计算的位置。由于光和体积结合的方式和次数很多,难怪它很难理解。

  3. 大气雾是一种视觉效果,模拟阳光(或其他光线)穿越行星大气层的过程。这是通过地图中的大气雾对象以及影响它的灯光设置来实现的。与之相关但独特的是,体积雾是一种视觉效果,雾似乎填充了指定区域内的体积,并且适当地受到光照的影响。虽然在这个部分中又使用了“体积”这个词,但它是在地图中通过指数高度雾对象全局定义的,或者在局部区域通过粒子定义的。像大气雾一样,它也主要是由影响它的灯光来定义的。

使用光量体积添加体积光图

如前文所述,当添加光量重要性体积时,该体积内部区域将默认生成体积光图。我们的 FrozenCove 地图提供了这些内容的优秀概述。让我们首先检查这个体积及其在关卡中的位置:

图片

从实际的海湾/洞穴可玩区域拉远视角,你可以看到这个体积完全包围了该可玩区域,但除此之外没有太多其他内容。这正是你应该尝试采用的做法,以降低灯光构建时间,减少保存出的光图的总数(从而减少内存使用),同时给玩家提供他们在直接观看和交互区域的最佳视觉体验。从那个高度环顾四周,你可以看到实际上在这些周围的岩石悬崖中有很多建模出的几何形状。然而,它们对玩家场景的光照贡献并不相关,而且除了可能在我们的游戏中添加的幸运的长距离射击外,没有动态对象打算在这些区域被近距离看到。

这些事情为什么很重要?让我们快速概述一下在这个例子中预置灯光能给我们带来什么,以及这些光照贴图对我们有什么好处。首先也是最重要的就是漫反射。这是对光子质量主要用途的技术术语:获取一个非常详细的光线如何从物体上反射的模型,并在运行时预先计算成可用的形式。光子质量以两种不同的方式完成这项工作:首先是通过构建场景中所有静态物体的光照贴图,这些光照贴图在运行时直接与几何形状一起渲染;其次是通过体积光照贴图,这是运行时动态移动物体的反射颜色的快速查找表。不深入探讨涉及的计算,让我们关注这个概念为什么很重要:在构建你的游戏时花费一些时间预先计算这些信息,可以在运行时以最小的性能成本为观众生成一个更加真实和视觉上令人愉悦的场景。这种漫反射概念,即颜色从一个物体向其附近的另一个物体渗透,可能看起来有点多余,但花点时间查找一些应用了和不应用这种技术的相同场景的例子,你的大脑会立即选择使用这种技术的场景,认为它更真实、渲染质量更高。同样,光子质量会计算各种阴影、环境遮蔽(一种在角落和缝隙中遮蔽光线的技巧)以及更多。在本章的第二部分,我们将深入探讨光子质量设置的更多具体细节以及如何最好地利用它们。本节主要介绍了将光子质量添加到你的游戏中的最基本方法(通过添加重要性体积)以及为什么添加这个重要性体积是,嗯,很重要的!

使用大气雾

Epic 多年来(特别是更新到 4.16)一直在努力使更复杂的雾技术更容易为开发者所用,目前它们相对容易添加,至少与过去相比是这样。在我们的洞穴地图示例中,首先我们将检查它内部放置的大气雾对象的使用,以及天空及其对应的方向性光照属性。对于希望实现一种低空雾效果的玩家,在游戏中低海拔处雾变得更浓、更明显,请参阅下一小节关于指数高度雾(体积雾)的内容。对于希望使用局部体积雾来增强(甚至模糊)某个区域、为局部区域赋予特殊属性以及/或移动/改变动态体积雾的读者,下一小节也将简要介绍这一主题,并且这两个小节在进一步阅读部分都有链接。

但是,现在让我们转到这里的例子。虽然当你跳入这个级别时可能没有立即注意到,如果你移除效果或夸大它(我们将在两个镜头中做后者),你很容易看到当我们从洞穴中靠近太阳看时,它产生了多大的差异:

图片

希望顶部岩石周围的雾霾足够清晰,如果你看看场景的其他部分,当太阳出现在视野中时,可以看到的天空区域会明显变亮,就像这里一样。让我们看看产生这种效果的设置。首先是天空球体,如下所示:

图片

注意特别的是,它指定了一个方向光演员。如果我们跟随它到屏幕上我们看到的那一个,这里是其属性:

图片

当然,这些属性的核心是大气/雾太阳光框,它让我们的大气雾属性知道这是创建体积雾时的太阳光来源:

图片

最后,让我们展示如何修改大气雾对象的一些设置(该对象位于左侧模式面板中的指数高度雾对象中):

图片

现在它非常明显,你不可能错过!我强烈建议你尝试所有这些设置。这里有很多可以探索的,看到所有不同的参数如何实时影响结果是非常有趣的。与许多光量效果和设置不同,大气雾不需要通过顶部的构建按钮进行构建来更新,并且非常容易快速地玩弄。

使用体积雾

体积雾是一种非常棒的视觉效果,目前它非常具体地设计用于玩家可能在一个相对平坦的地图上行走的地方,或者他们可能从某个高度变化处向上或向下看的地方。鉴于计算的难度,目前它不支持其他情况(例如,根据相机视角变化),但它仍然是一个非常强大的工具,并且是一个很好的系统,可以舒适地向团队解释其重要性。首先,让我们再次看看 FrozenCove 中已经存在的内容,然后我们稍作修改:

图片

这些设置产生了一种微妙的雾气,这种雾气只在高度雾对象放置位置相当远的地方才开始。因此,为了演示目的,我们再次将其设置得更极端一些(而且更细心的观察者会注意到前景中的粒子,我们将在下一部分讨论):

图片

这个级别使用传统的粒子,我们之前讨论过,因为它们的混合性能成本较高,用于模拟雪堆;但正如你所看到的,我们也可以使用体积雾粒子发射器来完成这项工作,你可以在地图中查看它。然而,为了让这个发射器工作,我们需要将高度雾的体积雾设置切换为真(目前复选框未勾选)。请注意,虽然我们稍后会讨论如何优化你的 Lightmass 输出,如果需要的话。如果你以这种方式使用体积雾,你应该意识到潜在的性能成本,并使用第八章中的技术,着色器编辑和优化技巧。那么,让我们看看我们可以添加到粒子系统中的材料,以获得动态变化和着色的体积雾:

图片

现在,以这个材料目前的状态,添加到场景中的发射器,你会得到一个相当引人注目的蓝色(这是原始意图),如下所示:

图片

结果表明,如果你断开发射色,这将是一个更加微妙的效果,与雾的自然颜色相匹配。这就是 GitHub 项目目前的内容:

图片

体积雾,无论是局部还是全局,都可以真正地决定场景的沉浸感,所以虽然这里的主要目的是自信地使用它们,但有一些经验丰富的环境艺术家在旁边确保它们在场景美学上不会被过度使用,这已经讨论了可能的性能成本。

Lightmass 工具

现在基础知识已经介绍完毕,我们也看到了体积雾的强大功能和它如何与一般光照结合,是时候更深入地了解 Lightmass 本身了。有无数的对象、变量以及两者之间无尽的组合可以探索,但为了保持我们的技术信心,我们将关注几个关键区域:

  • 漫反射互反射设置

  • 动态间接光照光照贴图及其可视化与调整方法

  • 漫反射和它的优点和缺点

  • 影响光照构建时间的一些额外因素

  • 质量模式

  • 光照贴图,它们的位置,以及如何分析你对它们的用法

我们在这里的第二个小节将专门讨论光照贴图,因此对于我们的第一个部分,我们将检查 Lightmass 定义属性的一个固态子集,以及质量模式的影响。

学习 Lightmass 设置和预览工具

首先,让我们看看全局 Lightmass 设置,它位于“世界设置”中:

图片

前五个选项对间接光照影响最大。如果你的光照时间非常长,将静态光照级别设置为大于 1 可以通过缩放采样大小显著减少光照构建时间。如果各种级别或整个游戏的光照构建时间成为问题,并且你的游戏和团队可以接受光照准确性的降低,请首先尝试这种方法。反弹次数同样可以产生重大影响。间接光照的目的是模拟现实世界,其中一部分光子(我们眼睛感知的光的粒子)从物体上被反射或折射,而另一部分被吸收。反弹次数意味着在计算空间中的每个点时,还需要计算一定量的反弹光照。自然地,反弹次数越多,工作量就越大(但同时也更真实准确)。接下来的三个选项稍微有些晦涩,但你可以自由地鼠标悬停在上面以了解它们的内容。例如,光平滑处理在特定类型的地图上可以大有帮助,但正如所述,间接阴影细节的成本需要权衡。不幸的是,与之前讨论的许多雾设置不同,所有这些都需要构建以充分欣赏它们带来的变化,这可能相当耗时。理论上,如果一个游戏有一个固定的风格,可以在早期进行一些实验,选择首选值,然后在整个项目期间锁定这些值。

为了可视化光照效果,请确保在视口选项中查看“详细光照”、“仅光照”和“反射”:

图片

然而,这里可能最引人注目的是体积光光量,可以通过视口的“显示 | 可视化 | 体积光照图”来预览,如下所示:

图片

这些方向性光照样本将用于运行时移动对象,也用于在重新构建静态光照之前移动的静态网格。对于经验丰富的 UE4 开发者来说,不要忘记,从 4.18 版本开始,这是默认设置,并且通常比旧的光照缓存模型更受欢迎。关于这些变化的详细描述可以在“进一步阅读”部分找到。

快速提及环境光遮蔽(实际上在 FrozenCove 中是关闭的),可以将其视为变暗区域。凹面表面在现实生活中会阻挡光线反射。虽然它增加了真实感,如果你已经至少进行了一次间接光照反射,它对光照的构建时间几乎不会增加,但请注意,它是以顶向下光照来简化计算的。所以在某种程度上,它与指数高度雾相似,如果你的游戏不是本质上平坦的游戏(例如,玩家在类似地球的表面上移动,就像我们大多数人一样),它可能会失去其真实感和价值。然而,在“使用环境光遮蔽”设置下关闭它却非常简单,在你的世界的 Lightmass 设置中。就像许多 Lightmass 选项一样,通常最好的做法是简单地尝试开启它来构建光照,在场景中四处走动,也许拍摄一些特定的截图,然后关闭它并重复这个过程,看看它是否有用。你可以在视口中的视图模式 | 缓冲区可视化 | 环境光遮蔽中预览这里完成的工作。

如果你发现自己遇到了光照构建时间问题,并且已经调整了一些可能影响这些构建时间的参数,那么值得注意的是,光照构建使用的是 Swarm Agent 系统,可以将计算任务分配到网络上的多台计算机上。更多关于这一点的内容也列在了进一步阅读部分。另外,对于许多游戏来说,纯动态光照已经足够好,对于需要适度动态光照细节但又不希望承受烘焙/烹饪光照的内存成本和构建时间的移动游戏等,大部分 Lightmass 可以关闭。就像往常一样,了解你的项目和它的需求,并准备好做一些早期实验,并与你的艺术家讨论最佳选项。如果消除烘焙光照能让你的艺术家为环境或角色添加更多细节纹理,这可能是一个他们愿意做出的权衡,这取决于你游戏的分辨率和其他设置,所以不要害怕实验和讨论!

最后,了解你的光照质量级别。它们可以从构建按钮的下拉菜单中选择,如下所示:

图片

这实际上只是从高到低构建时间与质量级别的一个非常高级的概述。如果你想快速在这之间切换,那么这应该是你质量与时间之间的首选。

光照贴图分析

正如所述,Lightmass 会自动在重要性体积内所有区域创建光照贴图。但这对你项目的内存意味着什么?你总是可以检查构建数据大小以快速估算(提示:这大部分将是你的光照贴图,可能还有一部分用于导航网格,以及一些内部的小需求)。例如,查看我们项目的Content/InfinithBladeIceLands/Maps文件夹,你会看到冻结湾的构建数据.ubulk文件,大约 100 MB。这是我们地图中的典型大小。然而,如果你想要更多细节,点击光照贴图世界设置中的向下箭头(更多选项),你将找到类似这样的光照贴图飞出菜单:

图片

双击其中任何一个都会提供更多细节:

图片

在这里,你可以看到压缩、大小(分辨率和内存),当然,你也可以更改这些设置中的许多。在静态网格编辑器中,单个网格可以在其网格设置下更改其光照贴图分辨率。此外,你可以在视口中通过视图模式 | 优化视图模式 | 光照密度查看这些光照贴图的 texel 密度,如下所示:

图片

摘要

这里有很多可以探索的内容,因此避免感到不知所措很重要。在这个阶段,团队或项目可能遇到的任何主要问题都应该可以得到一般性的解答。话虽如此,如果你发现自己与艺术家就视觉质量争论,或者与整个团队就构建时间争论,现在你应该手中有工具来探索选项、调整、微调,并找到最佳解决方案。UE4 中的光照既神奇又复杂,但幸运的是,理解整个系统的如何并不是关键,只需掌握什么为什么,就能做出正确的决策。在下一章中,我们将有更多轻松的时间展示 UE4 在场景视频中可以做到的一些更酷的视觉效果!

问题

  1. 体积光照贴图的功能是什么?

  2. 你会如何描述 Lightmass 默认包含的功能?

  3. 大气雾和体积雾有什么区别?

  4. 如何创建体积雾的本地实例?

  5. 如果你遇到缓慢的光照构建时间,最简单的方法是什么来调整质量与时间的关系?

  6. 指数高度贴图和环境遮蔽有什么限制?

  7. 在构建时,你可以在哪里找到光照贴图数据的一般大小?

  8. 传统光照贴图和体积光照贴图有什么区别?

进一步阅读

指数高度雾:

docs.unrealengine.com/en-us/Engine/Actors/FogEffects/HeightFog

体积雾(通过粒子的局部雾):

体积雾

间接光照贴图与光照缓存对比:

体积光照贴图

Unreal 中的群体代理:

Unreal Swarm 概述

第十二章:场景视频和视觉效果

简介

UE4 有一些令人惊叹的游戏内视觉效果。媒体框架是一个非常酷的工具,用于向游戏中添加场景视频。Unreal 还有一些显著发展的,但尚未完全准备就绪的工具用于捕获实时视频。在本章中,我们将从游戏玩法中捕获一段视频,然后将其投影到表面上作为场景视频播放。Unreal 还提供了大量的视觉效果,为了使我们的武器冲击更加生动,我们将添加一些带有物理的冲击粒子,以作为这些选项的基础。本章将重点关注:

  • 使用媒体框架创建游戏内视频播放器

    • 创建资产和材质,以便将其添加到地图中的任何演员

    • 触发并重复我们的视频

  • 向游戏中添加基于物理的粒子

    • 为我们的投射物创建发射器并在击中事件上生成它

    • 定位和修改粒子以获得酷炫的运动/感觉

技术要求

本章将在 GitHub 上我们项目的Chapter 12分支中实现其组件:github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-12

我们使用了 4.19.2 版本的引擎。

使用媒体框架播放场景中的视频

媒体框架为在引擎中播放视频添加了几个有用的关键系统。然而,我们将重点关注的是通过我们的播放器触发“屏幕”上的音频播放的场景视频。这在许多游戏中是一个非常常见且受欢迎的功能,使用 Unreal 的渲染目标材质,你可以将地图其他区域的实时渲染放置在任何地方。一个经典的例子是安全摄像头显示其他区域。但为了重申,我们只是关注如何将电影放入游戏以证明可以做什么,以及了解将其添加到游戏中的涉及内容。首先,我们还需要一些新项目。

创建我们的资产

制作一个优秀的场景视频播放器需要几个组件,让我们快速列举一下:

  • 当然是源视频!在 Epic 的文档中,他们列出了支持的文件格式,但一个简洁的版本是:如果有疑问,使用 MP4,因为它们在所有平台上都适用(具体内容稍后在此处介绍)。

  • 一个文件媒体源对象,它引用了,嗯,这个源媒体(在我们的案例中,是我们的视频)。

  • 一个在运行时执行工作的媒体播放器对象。

  • 一个相关的媒体纹理资产。

  • 使用此纹理的材料。

现在,关于 UE4 的巨大好消息是,很多操作都为你自动化了,所以不要被那个列表吓到,这个过程会出乎意料地快。

作为快速说明,我将简要描述这个特定视频的来源,因为实际的视频文件是这个概念的关键。当然,有几种方法可以捕获视频。虽然这本书并不是为了推荐任何特定的外部产品(除了承认所使用的开发工具和版本),但通过在互联网上搜索“Windows 屏幕捕获”是一个简单的解决方案。此外,使用移动设备上的某些游戏流媒体工具和应用程序也可以做到这一点:许多都有“游戏模式”,可以捕获视频并保存。

在编辑器中查看序列录制器(Windows | 序列录制器),因为这可能是你需要的。我过去在使用它时发现了一些问题;但是,像这样的系统一直在进行持续的工作,如果你能熟悉 UE4 内置的受支持系统,这将是最简单的方法。

你可以看到的已签入资产实际上只是我走进游戏中的 Countess 介绍场景,并将其捕获成 MP4 视频的过程,这个视频将与分支一起上传到 GitHub。所以,将这个视频文件添加到我们的内容文件夹后,我们可以回到编辑器,并在内容浏览器中制作项目:

如前所述,接下来我们需要的是一个文件媒体源:

图片

如你所见,这个特定对象实际上并没有什么复杂,只需将其指向源媒体,并注意有平台播放器覆盖选项:

图片

我们在这里要添加的最后一件事是我们的媒体播放器。请注意,在创建新的媒体播放器时,你会得到这个弹出窗口,并且绝对需要勾选这个框(因为它会自动为我们生成媒体纹理并将其连接起来):

图片

点击确定后,我们可以继续:

图片

在这里,只需将我们刚刚制作的 CountessVid 资产简单地拖放到左下角的列表中,播放器就准备好了,默认设置已经完成。我们的纹理资产现在已自动创建并关联(再次强调,这是现代 UE4 的一个非常好的简化功能!)。现在我们需要的只是一个材质,我们可以通过将纹理拖放到一个演员上来生成它,所以让我们继续进行。

在场景中构建和播放视频

理论上,你现在可以在任何静态网格演员或多个表面上播放视频。通常情况下,这只是一个平面,可以从模式窗口直接拖动到级别中。我们将通过将媒体纹理直接拖放到我们想要使用的演员(在这种情况下是我们的平面)上来生成一个材质,然后你可以看到添加了一个非常简单的材质,并设置在表面上。所以完成所有这些后,如这里所见,还剩下一个小问题:如果我们在场景中的屏幕(平面)上不右键单击+添加组件,添加一个媒体声音组件,并将其媒体连接到播放器,音频将无法播放:

图片

以这种方式手动连接音频是现在我对这个流程的唯一抱怨之一,所以现在我们几乎拥有了所有需要的东西,只需要触发视频并查看它:

图片

在这里,我们在屏幕前方添加了一个简单的触发体积,在关卡蓝图左侧添加了一个蓝图级别的变量(媒体播放器类型),选中体积后,在蓝图事件区域右键单击可以直接将它的 actor-overlap 事件拖入并触发一个开源节点,该节点也设置为指向我们新的资产。所以请注意,这个逻辑将触发任何使用我们媒体播放器的该材料实例,这可能很好,但也可能不是你想要的。这里有几个解决方案——最明显但不是最优雅的解决方案是,为每个需要单独播放的东西复制播放器。无论如何,现在我们可以走到屏幕前,像我们触发 MP4 视频那样多次观看和听到伯爵夫人的开场序列!

最后一点:当播放时,这个视频在场景中看起来相当暗:

图片

为了纠正这一点,我在材质中的颜色输出上进行了非常简单的乘法操作。再次强调,你可以用多种方法来处理这个问题;我总是从最简单的方法开始,如果它有效,就继续前进!你可以在这里检查这个变化,修改材质中的常量值,看看它对我们游戏中的输出有什么影响:

图片

现在我们来看看一些使用 UE4 制作的更多优秀的视觉效果,因为我们还没有充分利用它们的粒子系统。

添加物理粒子

我们的目标将有两个部分:首先,在弹射物击中时添加一些火花,但随后要让这些火花在物理宇宙中弹跳,而不会完全杀死我们的帧率。一个好消息是,UE4 支持所有平台上的 GPU 粒子,通常你希望在这里做这类工作。如果你发现自己在一个因为复杂视觉效果(或平台硬件规格低)而受 GPU 限制的游戏中,这类事情通常是首先被放弃的;但现在我们不要担心这个,让我们先做一些漂亮的火花。

在弹射物击中创建初始发射器

因此,有些人可能还记得在书的开始处我们添加了起始内容包。现在,我们终于可以更充分地利用它了。在文件夹底部滚动,可以看到 StarterContent/Particles(或只需在搜索框中执行 P_Sparks 的通配符搜索)。这并不完全是我们想要的,但已经很接近了,这总是能节省时间。注意,它已经使用了一些火花和烟雾 GPU Sprite 发射器(另一个是闪光/爆发)。看起来很接近,所以为什么不从这里开始呢?同样,对于 C++ 类和游戏开发的各个方面,如果你可以开始使用至少部分已经完成你想要的工作的内容,始终利用它并从那里开始修改以提高效率。现在,让我们来看看这些火花:

由于我们将对它们进行一些修改以使其行为略有不同,我只需将发射器(P_Sparks 资产)复制粘贴到 FirstPersonCPP/FX 中。我还将其重命名为 P_ImpactSparks 以避免在查找时产生混淆。现在我们有了自己的副本,可以开始修改它了。不过,首先,让我们退一步,让它们在投射物撞击时生成。我们需要在我们的 MasteringProjectile 类中添加一个新变量,并在生成撞击声音的附近生成它们:

首先,在我们的投射物头文件中添加:

UPROPERTY(EditAnywhere, BlueprintReadWrite)
class UParticleSystem* ImpactParticles;

然后在 .cpp 文件底部的 OnHit 中添加:

const float minVelocity = 1000.0f;
if (cueToPlay != nullptr && GetVelocity().Size() > minVelocity)
{
        UGameplayStatics::PlaySoundAtLocation(this, cueToPlay, Hit.Location);

        UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactParticles, Hit.Location, GetActorRotation(), true);
}

就这样,我们在撞击位置生成了粒子。只需转到 FirstPersonCPP/Blueprints/Weapons 文件夹中的每个投射物,并将它们的 ImpactParticle 变量设置为使用新复制的 P_ImpactSparks 资产。对于那些按步骤进行的人,你们在这个阶段可能会注意到一些问题:

首先,我们的粒子系统(发射器)永远存在,其次,它们总是垂直向上和向下生成,没有任何速度感,就像静止的喷泉一样简单地流向地面。第一个问题很容易解决:在 Cascade 中打开 P_ImpactSparks(双击资产),你会在主窗口中看到三个发射器。点击每个的 Required 栏,并在 Details 菜单中向下滚动,你会看到所有三个都有一个循环计数设置为 0(无限循环)——将其设置为 1 以修复我们这里的无限系统:

我并不是特别喜欢 Cascade 中的用户界面,但通过实践,我看到了一些熟练使用它的特效艺术家,他们可以非常快速地找到并修改系统。在这种情况下,我们可能希望增加火花的速度并减少发射器的寿命,但这些更多是外观上的事情。我们最后的任务是确保它们以正确的方式对准投射物撞击的表面,并确保我们可以在需要时调整火花的行为以优化性能。

那为什么不使用 Niagara,这个更新的粒子编辑器呢?说实话,在这个例子中,是我自己对 Cascade 的熟悉程度;但当然,任何人都可以使用 Niagara,我会在“进一步阅读”中添加一个链接(该链接从与 Cascade 的差异和相似之处开始)。类似于早期的 Matinee 与 Sequencer,两者都将继续存在并得到支持,但从长远来看,可能确实是一个好主意转向 Niagara。

对粒子物理进行定位和调整

仅让粒子系统降下看起来无聊的火花并不能满足这种效果,我们需要火花以符合弹道冲击的方式排列。这需要一点额外的代码工作和一点额外的 Cascade 工作量;但最终效果应该会很不错。

注意:如果你使用蓝图来生成各种效果,这将使你更容易将这些发射器和系统连接到外部因素。技术艺术家通常发现这一点非常有价值,可以快速迭代系统,直到它们看起来正确。只是要注意,一个有良好意图的内容创作者可能会意外地测试大量物理碰撞,所以这种实验可能会对性能造成危险!

首先,快速代码更改:

FRotator rot =  GetVelocity().ToOrientationRotator();
UGameplayStatics::SpawnEmitterAttached(ImpactParticles, GetRootComponent(), NAME_None, FVector::ZeroVector, rot, EAttachLocation::SnapToTargetIncludingScale, true);

这里有很多选项,你可以使用 Hit.Normal/Hit.ImpactNormal 来代替速度来构建你的旋转,例如,如果需要的话,不附加粒子。我发现在这里,附加系统使粒子感觉像是被“拉扯”的,这产生了一种混乱的外观,我喜欢这种效果,同时沿着速度设置旋转。在 Cascade 中,目前只有烟雾系统被设置为继承父级的速度;我将火花也设置为这样做(在发射器的列区域右键单击,添加该字段,并选择它)。注意左侧的减少发射器持续时间调整值:

图片

最终结果还不完全适合专业质量,了解自己的限制是随着团队成长的重要特质。不过,希望这段旅程有助于增强你对 Unreal 精彩视觉效果的信心。还有更多东西可以探索,但拥有推动项目前进的基础是这本书的主要内容。

摘要

我们已经触及了传统 UE4 提供的几乎所有主要系统。虽然还有一些更专业的系统,但在这个阶段,你应该能够舒适地在 UE4 中制作任何平台上的传统游戏。虚幻社区有大量的视觉特效可以免费下载,了解上述内容后,你应该有信心去探索其深层次选项!我们还没有真正探索的一个领域是;这是 UE4 中最新的之一,我们将在下一章中介绍:增强现实和虚拟现实,虚幻的增强现实和虚拟现实 API 和项目。这也是我们主要 GitHub 项目的更新结束:从它作为 FirstPersonCPP 模板的谦卑起源,到 FrozenCove 的雾和光照,到 Countess 和她的 AI,到加载和保存,以及效果和优化。我希望你发现这是一次启发性和有意义的旅程。现在,让我们进入增强现实和虚拟现实的新世界!

问题

  1. 为什么 MP4 通常是 UE4 中视频的最佳选择?(提示:查看进一步阅读部分以获取一些详细信息)

  2. 如果一个媒体播放器被多个演员引用,打开该媒体时会发生什么?

  3. 有什么快速简单的方法可以获取你的视频纹理和视频素材?

  4. 为什么在这里添加了颜色输出的常数乘数?

  5. 我们在粒子发射器上使用了什么好的策略来节省时间,无论是在 C++还是在资产创建中?

  6. 为什么我们应该将发射器附加到我们的弹道上,而不是让它像最初实现的那样在空间中保持静止?

  7. 我们是如何将发射器与弹道方向对齐的?

  8. 在什么情况下,让粒子在 GPU 上模拟(在 Cascade 中可以轻松更改)是不利的?

进一步阅读

媒体框架支持的视频类型:

docs.unrealengine.com/en-US/Engine/MediaFramework/TechReference

尼亚加拉粒子编辑器:

docs.unrealengine.com/en-us/Engine/Niagara

第十三章:UE4 中的虚拟现实和增强现实

简介

UE4 中最新且最激动人心的两个新增功能是其虚拟现实(VR)和增强现实AR)集成。虽然 VR 已经存在了一段时间,但 AR 在 2018 年夏季 4.20 版本中首次完全功能化,无需合并自定义分支或使用实验性组件。尽管两者都有巨大的世界可以探索,但我们将专注于本书的主题,通过构建项目并对其中的实际应用进行一些添加,来建立对 Unreal 系统意识和信心的构建。所以,准备好探索 Unreal 世界中最新且可能是最酷的新平台!我们这里的最后一章涵盖了:

  • 创建和修改 VR 项目

    • 准备部署到 Android GearVR 头戴式设备

    • 向项目中添加基于运动的新控制功能

  • 创建和修改 AR 项目

    • 准备部署到 Android 手机

    • 将我们的弹丸从主项目移植到我们的新 AR 游戏中!

技术要求

为了专注于这些新平台的技术,它们都将创建为新的独立项目,如上所述。虽然将现有的 Mastering 项目移植以支持 VR 甚至 AR 是完全可能的,正如本书多次提到的:像这样的重大决策最好在项目开始时做出,以便最初进行验证,并在整个开发过程中维护。话虽如此,在 AR 项目中,我们将从先前的项目中移植一些工作,以了解这个过程,同时也对创建的工作的可移植性进行了一次良好的测试。这两个相应的项目位于 GitHub 的以下分支中:

VR:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-13-VR

AR:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-13-AR

此外,对于 AR 组件,鉴于使用的 GearVR 平台,还在我的 Galaxy Note 8 上安装了 Google 的 ARCore 应用,以便在不安装自定义 Google API 的情况下进行 Unreal 集成,避免了之前安装时的巨大头痛。在这个领域(AR),4.20 与之前的集成相比是一个梦想,尤其是在 iOS 和 Android 上,因此强烈建议(尽管不是具体要求)至少更新到 4.20 以适应本章内容。

使用的引擎版本:4.20.2。

注意:对于使用 Mac 和 iOS 平台的人来说,虽然在这里我没有特别涵盖这些平台,但这些主要是蓝图项目模板,过程不会有太大的变化,所以请随意浏览本章中的蓝图代码、代码迁移以及其他要点,同时将“Finder”替换为“资源管理器”,“XCode”替换为“Visual Studio”,“iPad/iPhone”替换为“Android”。唯一可能最困难的部分已经在第十章中介绍过了,即打包游戏(PC、移动设备)。

制作 VR 项目并添加新控制

如我肯定读者们在这个阶段已经非常清楚的那样,VR 涉及用户佩戴头戴式设备,游戏或应用向他们展示一个他们可以完全沉浸其中的 3D 世界。

制作初始 VR 项目

对于那些一直陪伴我完成这本书全程的读者,我们将从第一章制作一个第一人称射击游戏的 C++项目进行简要回顾。不过,内容不会太多;但对于那些想直接跳到这里的读者,我们不会跳过任何主要步骤。所以首先启动引擎,不要指定项目,正如第一章中提到的,这可以从您的引擎安装目录本身完成,或者希望这里已经有了为它创建的快捷方式。当 Unreal 项目浏览器出现时,点击“新建项目”标签:

图片

然后选择蓝图标签页的“VR 项目”模板,我们将内容级别设置为“移动/平板”,并移除“入门内容”(尽管我通常建议在原型设计时保留它,因为它提供了一些有趣的物体来玩耍,但确实会占用相当多的额外空间)。当然,你可以随意命名它,但为了与 GitHub 的文件匹配,使用上面的名称,MasteringVR。点击创建按钮后,它应该会自动在编辑器中打开,并且对于多个平台(PS4-VR、Oculus Rift 等,“有线”头戴式设备)来说,它已经准备好部署和测试了。

对于那些已经通读全书的人来说,你们也会知道我通常更喜欢用 C++ 来工作,因为这样可以提高速度和便于调试,然而,这些模板目前仅作为蓝图模板存在。现在,话虽如此,添加一个 C++ 类并开始自己编译项目还是相当容易的,就像在 AR 项目中将要做的那样。但是,请注意,这会增加你的构建迭代时间。以我的情况为例,每次在编辑器中使用启动按钮,大约需要 5-10 分钟每次使用,因为每次它都会重新编译并重新签名你的 .apk。对于那些仔细查看 GitHub 的人来说,你们会注意到我一度打算在 .cpp 文件中的一个 pawn 类中添加代码,但注意到这个问题后,又回到了完整的蓝图。如果你像我们在这个 VR 部分一样只坚持使用蓝图,那么迭代时间可以少于一分钟(总共!)所以在你决定在像这样的项目中使用 C++ 之前,请三思而后行。

请记住:第一次为平台构建和部署时,需要编译所有引擎着色器和烹饪你的内容。第一次构建之后,迭代时间将如我上面所述;但第一次启动将会慢一些,也比较痛苦。

为 GearVR 构建和部署

即使有了方便的 VR 项目模板,对于那些跟随我的脚步并使用三星 Android 设备的人来说,要在 GearVR 平台上运行仍然需要许多步骤。

我有很多原因喜欢这个平台。首先:没有线缆!有线耳机更强大,并带来一些额外的功能,但同时也让人感觉总是被连接到 PC 或 PS4,这让我觉得分心。第二:可访问性。虽然商店和平台是三星的,而不是像其他 Android 应用那样是谷歌的,但很多人拥有 S7 及以上型号的手机,我认为它们在 GearVR 应用中的表现都非常好。希望它能够流行起来,也许三星和谷歌将来会在某个时刻整合他们的商店;但对于任何有选择在这样设备上尝试这些项目的人来说,我取得了非常好的效果。

因此,首先,对于任何基于 Android 的应用,你需要转到项目设置 -> 平台 -> Android,然后点击顶部的“立即配置”按钮:

图片

之后,在其设置中我们还需要设置一些其他内容:

图片

也就是说,我们需要将 SDK 版本设置为 19,Epic 建议将 KitKat+ 全屏沉浸式设置为 true(尽管我在测试中并没有发现这有必要)。

接下来,在同一部分向下滚动到高级 APK 设置,我们需要再勾选一个复选框:

图片

配置 AndroidManifest 以部署到 Oculus Mobile 是严格必要的,对于那些观察力敏锐的人,我提前打开了 OpenGL ES3.1,因为任何可以使用 GearVR 头显(Galaxy S6+)的设备都将支持它,而且没有理由限制你的着色器选项,也不必强迫自己在以后重新编译所有那些着色器。

注意,我也尝试启用 Vulkan,因为我喜欢它的硬件加速平台,但在这个基本项目中启动时出现了崩溃,所以,至少目前,为了避免麻烦,请将其保持未选中。

以下是两个最后的注意事项。首先,如果你打算像通常迭代时那样从编辑器启动,请将启动地图设置为 MotionControllerMap,就像这里所示,而不是默认的 StartupMap:

由于某种原因,GearVR 控制器在 StartupMap 中不会激活,但在 MotionControllerMap 中运行得很好(如果你制作一个独立的.apk 文件并安装运行,它本来就是默认的游戏地图)。注意,你可能需要点击摇杆几次,才能使事物正确显示,即使在这里也是如此。

最后,你需要按照这些说明从 Oculus 获取签名密钥,但这个过程比我们之前在构建和部署章节中提到的那些过程要简单得多:

dashboard.oculus.com/tools/osig-generator/

如果你没有在指定的文件夹中找到该密钥,你的应用在启动时将因为未签名而崩溃。

现在,我们可以在 GearVR 头显上快速进行更改,并在不到一分钟内查看这些更改!

对于想要使用 HMD(头戴式显示器)手柄本身的人来说,Epic 在“进一步阅读”部分提供了一个很好的指南。

添加 HMD 控制

现在我们可以在该级别中游玩,并可以使用摇杆进行传送,如果你能靠近蓝色盒子,你可以抓住它们并使用控制器上的扳机将它们扔掉,那么在没有必须专门使用传送的情况下在级别中移动可能是个不错的选择。我们甚至可以通过根据你的头部方向进行运动来释放整个摇杆,使用 HMD(头戴式显示器)的方向本身作为传达输入的手段。

然而,这意味着玩家现在不能自由地上下看而不移动;但我们会为这个问题也添加一个修复方案。让我们首先使用基于相机方向的简单逻辑通过蓝图让玩家移动起来。为此,我们将在 MotionControllerPawn(在内容浏览器中的 Content/VirtualRealityBP/Blueprints 中找到)中做这项工作。然而,为了真正实现这一点,我们需要将这个兵种转换为角色。所以就像我们过去做的那样,打开蓝图,转到文件 | 重新父类蓝图,并选择角色。现在,默认角色有一个相当高的胶囊,我们之前的兵种期望其原点在地面,所以让我们只将其胶囊做得小一些,不需要太小,34cm,它原来的半径,对于高度来说似乎也合适:

图片

接下来我们将添加一个新的蓝图功能,并在事件计时中从序列节点拖出一个引脚来调用它:

图片

在那个函数中,我们将添加一些简单的逻辑,根据相机方向移动玩家。

图片

正如您在开始时看到的,我之前提到的可以禁用移动的部分,是在任何控制器试图抓取或传送(以及如果倾斜角度小于 10 度,我们也会停止)时完成的。这样玩家在执行这些动作时可以完全环顾四周,但如果不这样做,前后倾斜将使玩家前后移动,而转向当然会改变方向。如果需要,您可以通过使用相机的翻滚逻辑和侧向移动向量,轻松地添加左右侧滑。

加上这个功能后,我发现抓取那些箱子并扔来扔去比仅仅通过传送要容易得多!

图片

如果您对上面的截图(来自运行 VR 的设备)感到好奇,我只是在蓝图里添加了一个执行控制台命令的节点,当我在控制器上拉动扳机时触发,并执行了"HighResShot 1920x1280"(但当然,您可以将分辨率设置为任何您想要的)然后从:\Phone\UE4Game\MasteringVR\Saved\Screenshots\Android中检索它们。

制作一个 AR 项目并将我们的投射物移植过来

AR 在过去一年中已经成为一个非常受欢迎的领域,因此看到 UE4 最终拥有一个非常稳固和简单的集成路径和项目模板真是太好了。对于初学者来说,增强现实与 VR 有很大不同,因为它不会将用户从现实世界带到另一个世界,而是通过一个设备与真实世界结合,这个设备可以是像微软的 HoloLens 这样的可穿戴设备,或者是新宣布的 Magic Leap 眼镜,或者是像许多当前智能手机和平板电脑这样的手持设备。AR 将真实世界的环境整合到应用或游戏中,使用户能够将现实世界物体与游戏中的交互结合起来。我们将从这个新项目中开始,然后从之前的工作中拉取一些代码和资源到这个分支中,以展示这个过程。

创建初始 AR 项目

就像我们在第一章和 VR 部分直接上面所做的那样,这将会是一部分复习;但是,由于这是一个从头开始的项目,最好还是走完每一个步骤(所以准备好再次使用虚幻项目浏览器!)所以再次在没有项目的情况下启动引擎,我们来到了浏览器,在“新建项目”和“蓝图”标签下,你现在会找到一个名为“手持 AR”的模板,选择它,我们将内容设置为移动/平板电脑,并且不包含起始内容(除非你想要的话)。

图片

如同往常,现在应该在你的编辑器中打开它,这样我们就可以立即开始工作了!

安卓部署的具体细节

在这里要做的事情比 VR 部分要少得多,但必须开始同样的关键步骤,为安卓进行配置:

图片

就像在 VR 中或者再次,任何安卓项目中,确保你前往“项目设置”|“平台”|“安卓”,并点击“立即配置”按钮。正如你下面可以看到的,在这个模板中,它已经为我们完成了之前为 VR 所做的其他工作,比如为你设置 APK 版本。

事实上,这就是在我设备上运行基本 AR 游戏所需的所有内容,从那里你可以点击添加一些形状到世界中,并使用调试菜单来感受 UE4 AR 正在做什么,以及可视化它做得有多好。

将我们的投射物移植到 AR 中并发射它们

现在我们将通过将我们的投射物从早期项目中拉入这个 AR 演示中,使事情变得更加有趣。首先,我们需要将源文件添加到正确的文件夹中,然后生成项目文件。为此,我在我的本地硬盘上创建了项目的两个副本(这使得 GitHub 处理起来有些棘手,因为它们都在同一个存储库中的分支);但为了简化事情,我将在这个分支的副本上本地工作(简单地剪切并粘贴到一个新文件夹中):

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-13-AR

并且将第十二章的分支放在我的 GitHub 本地驱动器常规位置:

github.com/PacktPublishing/Mastering-Game-Development-with-Unreal-Engine-4-Second-Edition/tree/Chapter-12

这样我就可以从第十二章中挑选项目到 AR 项目中。最后,这有点复杂,但完成时,我会简单地删除我的本地第十二章文件和文件夹,除了 git 信息用的.git隐藏文件夹,然后将本地 AR 移动(或重命名)到那个位置,并切换分支回到 AR 项目,并提交那些特定的更改。在这种情况下,Git 可能会很棘手,但我们现在只是想在 AR 环境中找点乐子,看看我们能做什么,所以让我们开始吧!

如注释所示,复制源文件是第一步,只需简单地将第十二章MasteringProjectile.h.cpp文件移动到MasteringAR/Source文件夹中。完成之后,我们可以右键点击MasteringAR.uproj文件,并使用我们的 UE4 版本生成项目文件,或者像过去一样使用批处理文件来确保我们有 VS 2017 文件。现在我们可以像平常一样构建和运行编辑器,并部署项目。但请注意,作为一个 C++项目,运行时的迭代时间将在设备上显著增加,因为每次部署都会编译代码;但这是我们在这里需要做的,以确保我们的项目能够发射出我们在前几章中构建的所有代码。

完全坦白说:我实际上不得不从这里的 UE4 AR 项目中,将其命名为 Mastering.uproject,添加一个新的 C++类 MasteringProjectile(基于 Actor 类),然后将主项目中的代码复制粘贴到这个项目中,以便迁移的资产能够正确找到其父类。过去,上面的复制粘贴文件版本一直工作得很好,但以防万一有人遇到麻烦,请尝试这个作为最后的手段,就像我一样。

有点不幸的是,我们实际上需要启动第十二章项目,以便将资产从那里迁移到我们的 AR 项目中。所以,在编辑器中打开它,然后浏览到我们的蓝图的项目部分,右键点击其中一个。你会看到一个弹出窗口,在资产操作中是迁移。

图片

下一个弹出的是列表,基本上是 Unreal 告诉你要复制的所有依赖项,只需点击确定:

图片

现在我们只需要将 AR 项目的 Content 文件夹中的内容文件夹指向我们的 Content 文件夹,那些弹射物以及它们所有的资产依赖(我们在第十二章,场景视频和视觉效果等所做的特殊 FX)都将随之而来。在共享这些资产的项目之间迁移资产可以节省大量时间,所以我很高兴终于有机会在这里分享它的使用。

完成这些后,我们就可以再次回到编辑器中的 AR 项目,创建我们的弹射物数组,并用它们代替项目开始时的测试几何形状:

就这样,现在我们的核心项目的弹射物已经在 AR 世界中生成,带有特殊的 FX 效果!

摘要

在这次旅程中,我们从 UE4 最基础的起点开始,到构建和修改其最新和最伟大的技术。没有什么比看到你在目标平台上的工作,并知道不久的将来全世界都能分享它,更令人满足的了。VR 和 AR 是令人兴奋的新领域,UE4 是它们发展的领导者,我们很幸运拥有它。拥有让这些技术运行起来的知识,以及它们带来的无数新项目和游戏,这使得这是一个非常激动人心的时刻,并使我们真正掌握 UE4 成为可能!

问题

  1. 在新项目中包含起始内容有哪些优缺点?

  2. 在制作 VR 或 AR 项目时,为什么尽可能长时间地保持仅蓝图是主要原因?

  3. 要使每个 Android 项目都能构建和部署,必须采取哪一步?

  4. 与当前的 PC/Console 版本相比,GearVR 平台有哪些优缺点?

  5. 当通过 HMD 添加移动时,可能会给用户带来什么问题?它是如何缓解的?

  6. 对于 AR 模板,哪些基本的 Android 设置已经设置好,我们不需要为 VR 模板设置?

  7. 当从蓝图仅转换为 C++项目时,采取了哪两个步骤?

  8. 如何将具有完整依赖关系的资产从一个项目迁移到另一个项目?

进一步阅读

UE4 中的 GearVR HMD 触摸板:

docs.unrealengine.com/en-us/Platforms/GearVR/HowTo/HMDTouchPad

Google 支持的 Android AR 设备:

developers.google.com/ar/discover/supported-devices

posted @ 2025-10-27 08:49  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报