Unreal-游戏开发项目(全)
Unreal 游戏开发项目(全)
原文:
annas-archive.org/md5/697adf25bb6fdefd7e5915903f33de14
译者:飞龙
前言
关于本书
游戏开发既可以是一种创造性的兴趣爱好,也可以是一种全职职业道路。这也是提高你的 C++技能并将其应用于引人入胜和具有挑战性的项目的一种激动人心的方式。
《使用虚幻引擎进行游戏开发项目》从你作为游戏开发者需要开始的基本技能开始。游戏设计的基础将被清晰地解释,并通过现实练习进行实际演示。然后,你将通过具有挑战性的活动应用所学到的知识。
这本书从虚幻编辑器和关键概念开始,如角色、蓝图、动画、继承和玩家输入。然后,你将开始三个项目中的第一个:构建一个躲避球游戏。在这个项目中,你将探索线追踪、碰撞、抛射物、用户界面和音效,结合这些概念来展示你的新技能。
然后,你将开始第二个项目;一个横向卷轴游戏,你将实现包括动画混合、敌人人工智能、生成对象和可收集物品在内的概念。最后一个项目是一款 FPS 游戏,你将涵盖创建多人环境的关键概念。
通过本书,你将拥有信心和知识,开始自己的创意 UE4 项目,并将你的想法变为现实。
关于作者
Hammad Fozi是 BIG IMMERSIVE 的首席游戏开发者(虚幻引擎)。
Gonçalo Marques从 6 岁开始就是一名活跃的玩家。他曾在葡萄牙初创公司 Sensei Tech 担任自由职业者,在那里他使用 UE4 开发了一个内部系统,用于生成辅助机器学习的数据集。
David Pereira在 1998 年开始了他的游戏开发生涯,他学会了使用 Clickteam 的 The Games Factory,并开始制作自己的小游戏。David 要感谢以下人员:我要感谢我的女朋友,我的家人和我的朋友在这个旅程中对我的支持。这本书献给我的祖母特蕾莎(“E vai daí ós'pois...!”)。
Devin Sherry是一名技术设计师,就职于名为 People Can Fly 的游戏工作室,并参与了他们使用虚幻引擎 4 构建的最新 IP。Devin 在 2012 年在前进科技大学学习游戏开发和游戏设计,获得了游戏设计学士学位。
受众
这本书适合任何想要开始使用 UE4 进行游戏开发的人。它也适用于以前使用过虚幻引擎并希望巩固、改进和应用他们的技能的人。为了更好地理解本书中解释的概念,你必须具备 C++基础知识,并了解变量、函数、类、多态和指针。为了与本书中使用的 IDE 完全兼容,建议使用 Windows 系统。
关于章节
《第一章》,《虚幻引擎介绍》,探讨了虚幻引擎编辑器。你将被介绍到编辑器的界面,看到如何在关卡中操作角色,了解蓝图视觉脚本语言的基础,并发现如何创建可以被网格使用的材质资产。
《第二章》,《使用虚幻引擎》,介绍了虚幻引擎游戏基础知识,以及如何创建一个 C++项目和设置项目的内容文件夹。你还将介绍动画的主题。
《第三章》,《角色类组件和蓝图设置》,向你介绍了虚幻角色类,以及对象继承的概念以及如何使用输入映射。
《第四章》,《玩家输入》,介绍了玩家输入的主题。你将学习如何将按键或触摸输入与游戏内动作(如跳跃或移动)相关联,通过使用动作映射和轴映射。
第五章,“线性跟踪”,开始了一个名为 Dodgeball 的新项目。在本章中,您将了解线性跟踪的概念以及它们在游戏中的各种用途。
第六章,“碰撞对象”,探讨了对象碰撞的主题。您将了解碰撞组件、碰撞事件和物理模拟。您还将学习定时器、投射物移动组件和物理材料的主题。
第七章,“UE4 实用工具”,教您如何在虚幻引擎中实现一些有用的实用工具,包括角色组件、接口和蓝图函数库,这将有助于使您的项目结构良好,并且易于其他加入您团队的人理解。
第八章,“用户界面”,探讨了游戏 UI 的主题。您将学习如何使用虚幻引擎的 UI 系统 UMG 制作菜单和 HUD,以及如何使用进度条显示玩家角色的生命值。
第九章,“音频-视觉元素”,介绍了虚幻引擎中声音和粒子效果的主题。您将学习如何将声音文件导入项目并将其用作 2D 和 3D 声音,以及如何将现有的粒子系统添加到游戏中。最后,将制作一个新的关卡,使用前几章构建的所有游戏机制来完成 Dodgeball 项目。
第十章,“创建一个 SuperSideScroller 游戏”,分解了 SuperSideScroller 游戏项目的游戏机制。您将通过 Epic Games Launcher 创建 C++ SideScroller 项目模板,并通过操纵默认人体模型骨架和导入自定义骨骼网格来学习动画的基本概念。
第十一章,“混合空间 1D、按键绑定和状态机”,向您介绍了用于开发平滑动画混合的工具,包括混合空间 1D 和动画状态机。您还将进入 C++代码,通过按键绑定和角色移动组件的帮助来开发玩家角色的奔跑机制。
第十二章,“动画混合和蒙太奇”,向您介绍了动画蒙太奇和动画蓝图中的动画混合功能,以开发玩家角色的投掷动画。您将了解动画插槽,并使用每个骨骼的分层混合来正确地在角色的移动动画和投掷动画之间进行混合。
第十三章,“敌人人工智能”,涵盖了人工智能以及如何使用行为树和黑板开发人工智能。您将实现一个沿着自定义路径巡逻的人工智能,使用您将开发的蓝图角色。
第十四章,“生成玩家投射物”,向您介绍了动画通知以及如何在游戏世界中生成对象。您将实现一个自定义的动画通知,在特定帧生成玩家投射物的投掷动画。您还将开发玩家投射物的功能,使其能够摧毁敌人人工智能。
第十五章,“收集品、能量增强和拾取物”,演示了如何创建一个可以操纵玩家移动的自定义药水增强,以及玩家角色的可收集硬币。您还将通过开发一个简单的 UI 来学习更多关于 UMG,以便统计玩家找到的收集品数量。
第十六章,“多人游戏基础”,向您介绍了重要的多人游戏概念,如服务器-客户端架构、连接、角色所有权、角色和变量复制。您还将学习如何制作 2D 混合空间以及如何使用变换修改骨骼节点。您将开始通过创建一个角色来工作在一个多人游戏 FPS 项目上,该角色可以行走、跳跃、上下查看,并具有两个复制的状态:生命值和护甲。
第十七章“远程过程调用”介绍了远程过程调用的使用方法,以及如何在虚幻引擎 4 中使用枚举和双向循环数组索引。您还将通过添加武器和弹药的概念来扩展多人游戏 FPS 项目。
第十八章“多人游戏中的游戏框架类”是本书的最后一章,解释了多人游戏中游戏框架类的存在位置,如何使用游戏状态和玩家状态类,以及如何实现一些有用的内置功能。您还将了解如何在游戏模式中使用匹配状态和其他概念。最后,您将通过添加死亡、重生、记分牌、击杀限制和拾取物品的概念来完成多人游戏 FPS 项目。
约定
文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入显示如下:
“打开Project Settings
并转到Engine
部分内的Collision
子部分。”
屏幕上显示的文字,例如菜单或对话框中的文字,也会在文本中出现,如下所示:
“点击New Object Channel
按钮,命名为Dodgeball
,并将其Default Response
设置为Block
。”
代码块设置如下:
if (bCanSeePlayer)
{
//Start throwing dodgeballs
GetWorldTimerManager().SetTimer(ThrowTimerHandle,this, &AEnemyCharacter::ThrowDodgeball,ThrowingInterval,true, ThrowingDelay);
}
新术语、缩写和重要单词显示如下:“在本章中,我们将介绍远程过程调用(RPC),这是另一个重要的多人游戏概念,允许服务器在客户端上执行函数,反之亦然。”
开始之前
本节将指导您完成安装和配置步骤,以便为您设置必要的工作环境。
安装 Visual Studio
因为我们将在虚幻引擎 4 中使用 C++,所以我们需要一个与引擎轻松配合的IDE(集成开发环境)。Visual Studio Community 是 Windows 上可用于此目的的最佳 IDE。如果您使用 macOS 或 Linux,您将需要使用另一个 IDE,例如 Visual Studio Code、QT Creator 或 Xcode(仅在 macOS 上可用)。
本书中给出的指导是针对 Windows 上的 Visual Studio Community 的,因此,如果您使用不同的操作系统和/或 IDE,则需要自行研究如何设置这些内容以在您的工作环境中使用。在本节中,您将通过安装 Visual Studio 来完成,以便您可以轻松编辑 UE4 的 C++文件。
-
转到 Visual Studio 下载网页
visualstudio.microsoft.com/downloads
。我们将在本书中使用的虚幻引擎 4 版本(4.24.3)推荐使用 Visual Studio Community 2019 版本。请务必下载该版本。 -
当您这样做时,打开您刚下载的可执行文件。它最终会带您到以下窗口,您将能够选择您的 Visual Studio 安装的模块。在那里,您将需要选中
Game Development with C++
模块,然后点击窗口右下角的Install
按钮。点击该按钮后,Visual Studio 将开始下载和安装。安装完成后,可能会要求您重新启动计算机。重新启动计算机后,Visual Studio 应该已安装并准备就绪。 -
第一次运行 Visual Studio 时,您可能会看到一些窗口,其中第一个是登录窗口。如果您有 Microsoft Outlook/Hotmail 帐户,您应该使用该帐户登录,否则,您可以点击
Not now, maybe later
跳过登录。
注意
如果您不输入电子邮件地址,您只能在 Visual Studio 锁定之前使用 30 天,之后您必须输入电子邮件地址才能继续使用它。
- 之后,您将被要求选择一个颜色方案。
Dark
主题是最受欢迎的主题,也是我们在本节中将使用的主题。
最后,您可以选择“启动 Visual Studio”选项。然而,一旦您这样做,您可以再次关闭它。我们将在本书的第一章中更深入地了解如何使用 Visual Studio。
Epic Games Launcher
要访问虚幻引擎 4,您需要下载 Epic Games Launcher,可在此链接下载:www.unrealengine.com/get-now
。这个链接将允许您下载 Windows 和 macOS 的 Epic Games Launcher。如果您使用 Linux,您将需要下载虚幻引擎源代码并从源代码编译 - docs.unrealengine.com/en-US/GettingStarted/DownloadingUnrealEngine
:
-
在那里,您需要选择“发布许可证”选项,并点击下面的“选择”按钮。这个许可证将允许您使用 UE4 创建项目,您可以直接发布给您的用户(例如在数字游戏商店)。然而,“创作者许可证”将不允许您直接将您的作品发布给最终用户。
-
之后,您将被要求接受条款和条件,一旦您接受了这些条款,一个.msi 文件将被下载到您的计算机上。下载完成后,打开这个.msi 文件,这将提示您安装 Epic Games Launcher。按照安装说明进行安装,然后启动 Epic Games Launcher。这样做后,您应该会看到一个登录界面。
-
如果您已经有一个帐户,您可以使用现有的凭据直接登录。如果没有,您将需要通过点击底部的“注册”文本来注册 Epic Games 帐户。
登录您的帐户后,您应该会看到“主页”选项卡。从那里,您可以点击“虚幻引擎”文本,转到“虚幻引擎”选项卡。
- 当您完成这些操作后,您将会看到“虚幻引擎”选项卡。虚幻引擎选项卡充当虚幻引擎资源的中心。从这个页面,您将能够访问以下内容:
-
“新闻”页面,您将能够查看所有最新的虚幻引擎新闻。
-
Youtube
频道,您将能够观看数十个关于不同虚幻引擎主题的教程和直播。 -
AnswerHub
页面,您将能够看到、提出和回答虚幻引擎社区提出和回答的问题。 -
“论坛”页面,您将能够访问虚幻引擎论坛。
-
“路线图”页面,您将能够访问虚幻引擎路线图,包括引擎过去版本中提供的功能,以及当前正在开发的未来版本的功能。
-
在 Epic Games Launcher 的顶部,在“虚幻引擎”选项卡中,您将能够看到其他几个选项卡,例如“虚幻引擎”选项卡(您当前正在查看的子选项卡)、“学习”选项卡和“市场”选项卡。让我们来看看这些虚幻引擎子选项卡。
-
“学习”选项卡将允许您访问与学习如何使用虚幻引擎 4 相关的几个资源。从这里,您可以访问“开始使用虚幻引擎 4”页面,该页面将带您进入一个页面,让您选择如何开始学习虚幻引擎 4。
-
您还可以访问“文档”页面,其中包含引擎源代码中使用的类的参考,以及“虚幻在线学习”页面,其中包含有关虚幻引擎 4 特定主题的多个课程。
-
在“学习”选项卡的右侧是“市场”选项卡。该选项卡显示了由虚幻引擎社区成员制作的几个资产和代码插件。在这里,您将能够找到 3D 资产、音乐、关卡和代码插件,这些将帮助您推进和加速游戏的开发。
-
最后,在
Marketplace
标签的右侧,我们有Library
标签。在这里,您将能够浏览和管理所有虚幻引擎版本的安装、您的虚幻引擎项目以及您的市场资产库。因为我们还没有这些东西,所以这些部分都是空的。让我们改变这一点。 -
点击
ENGINE VERSIONS
文本右侧的黄色加号。这将会显示一个新的图标,您将能够选择您想要的虚幻引擎版本。 -
在本书中,我们将使用虚幻引擎的版本
4.24.3
。选择该版本后,点击安装
按钮:
图 0.1:允许你安装虚幻引擎 4.24.3 的图标
- 完成后,您将能够选择此虚幻引擎版本的安装目录,这将是您选择的,然后您应该再次点击
安装
按钮。
注意
如果你在安装 4.24 版本时遇到问题,请确保将其安装在 D 驱动器上,路径尽可能短(也就是说,不要尝试安装太多文件夹深度,并确保这些文件夹名称较短)。
- 这将导致虚幻引擎 4.24.3 的安装开始。安装完成后,您可以通过点击版本图标的
启动
按钮来启动编辑器:
图 0.2:安装完成后的版本图标
代码包
你可以在 GitHub 上找到本书的代码文件,网址为packt.live/38urh8v
。在这里,你将找到练习代码、活动解决方案、图片以及完成本书实际部分所需的任何其他资产,如数据集。
第一章:1.虚幻引擎介绍
概述
本章将是对虚幻引擎编辑器的介绍。您将了解编辑器的界面;如何在关卡中添加、移除和操作对象;如何使用虚幻引擎的蓝图可视化脚本语言;以及如何将材质与网格结合使用。
在本章结束时,您将能够浏览虚幻引擎编辑器,创建自己的角色,操纵它们在关卡中,并创建材质。
介绍
欢迎来到使用虚幻引擎进行游戏开发项目。如果这是您第一次使用虚幻引擎 4(UE4),本书将帮助您开始使用市场上最受欢迎的游戏引擎之一。您将了解如何建立您的游戏开发技能,以及如何通过创建自己的视频游戏来表达自己。如果您已经尝试过使用 UE4,本书将帮助您进一步发展您的知识和技能,以便更轻松、更有效地制作游戏。
游戏引擎是一种软件应用程序,允许您从头开始制作视频游戏。它们的功能集有很大的差异,但通常允许您导入多媒体文件,如 3D 模型、图像、音频和视频,并通过编程来操作这些文件,您可以使用 C++、Python、Lua 等编程语言。
虚幻引擎 4 使用两种主要的编程语言,C++和蓝图,后者是一种可视化脚本语言,允许您做大部分 C++也可以做的事情。虽然本书将教授一些蓝图知识,但我们将主要关注 C++,因此希望您对这种语言有基本的了解,包括变量、函数、类、继承和多态等主题。我们会在适当的时候在本书中提醒您这些主题。
使用虚幻引擎 4 制作的热门视频游戏示例包括堡垒之夜、最终幻想 7 重制版、无主之地 3、星球大战:绝地陨落、战争机器 5和海盗之海,还有许多其他游戏。所有这些游戏都具有非常高的视觉保真度,广为人知,并且拥有或曾经拥有数百万玩家。
在以下链接中,您可以看到一些使用虚幻引擎 4 制作的优秀游戏:www.youtube.com/watch?v=lrPc2L0rfN4
。这个展示将向您展示虚幻引擎 4 允许您制作的游戏的多样性,无论是在视觉还是游戏风格上。
如果您希望有朝一日制作像视频中展示的游戏,或以任何方式为其做出贡献,您已迈出了那个方向的第一步。
我们现在将开始这第一步,我们将开始学习虚幻引擎编辑器。我们将了解其界面,如何在关卡中操纵对象,如何创建我们自己的对象,如何使用蓝图脚本语言,以及主要游戏事件的作用,以及如何为网格创建材质。
让我们从学习如何在这个第一个练习中创建一个新的虚幻引擎 4 项目开始这一章。
注意
在继续本章之前,请确保您已安装了前言中提到的所有必要软件。
练习 1.01:创建一个虚幻引擎 4 项目
在这个第一个练习中,我们将学习如何创建一个新的虚幻引擎 4 项目。UE4 有预定义的项目模板,允许您为项目实现基本设置。在这个练习中,我们将使用第三人称
模板项目。
以下步骤将帮助您完成这个练习:
-
安装虚幻引擎 4.24 版本后,通过点击版本图标的
启动
按钮来启动编辑器。 -
完成以上步骤后,您将会看到引擎的项目窗口,其中将显示您可以打开和处理的现有项目,并且还可以选择创建新项目。因为我们还没有项目,所以“最近的项目”部分将是空的。要创建新项目,您首先必须选择“项目类别”,在我们的情况下将是“游戏”。
-
选择了该选项后,点击“下一步”按钮。之后,您将看到项目模板窗口。该窗口将显示 Unreal Engine 中所有可用的项目模板。在创建新项目时,您可以选择添加一些资产和代码,而不是让项目从空白开始,然后您可以根据自己的喜好进行修改。有几种不同类型游戏的项目模板可供选择,但在这种情况下,我们将选择“第三人称”项目模板。
-
选择该模板并点击“下一步”按钮,这将带您到“项目设置”窗口。
在此窗口中,您可以选择与项目相关的一些选项:
-
“蓝图或 C++”:选择是否要能够添加 C++类。默认选项可能是“蓝图”,但在我们的情况下,我们将选择
C++
选项。 -
“质量”:选择您希望项目具有高质量图形还是高性能。您可以将此选项设置为“最高质量”。
-
“光线追踪”:选择是否启用光线追踪。光线追踪是一种新颖的图形渲染技术,它允许您通过模拟光线在数字环境中的路径(使用光线)来渲染物体。尽管这种技术在性能方面相当昂贵,但在照明方面尤其提供了更加逼真的图形。您可以将其设置为“禁用”。
-
“目标平台”:选择您希望该项目运行的主要平台。将此选项设置为“桌面/游戏机”。
-
“入门内容”:选择是否希望该项目附带一组基本资产。将此选项设置为“带入门内容”。
-
“位置和名称”:在窗口底部,您可以选择项目在计算机上存储的位置和名称。
- 确保所有选项都设置为预期值后,点击“创建项目”按钮。这将根据您设置的参数创建项目,并可能需要几分钟才能准备好。
现在让我们通过执行下一节中的步骤来开始学习 Unreal Engine 4 的基础知识,我们将学习如何使用编辑器的一些基础知识。
了解 Unreal
现在您将被介绍 Unreal Engine 编辑器,这是一个非常重要的主题,让您熟悉 Unreal Engine 4。
当您的项目生成完成后,您应该会看到 Unreal Engine 编辑器会自动打开。这个屏幕可能是您在使用 Unreal Engine 时最常见的屏幕,因此熟悉它非常重要。
让我们来分解一下编辑器窗口中所看到的内容:
图 1.1:Unreal Engine 编辑器分为六个主要窗口
内容浏览器
:占据屏幕底部大部分的窗口是内容浏览器
。此窗口将让您浏览和操作项目文件夹中的所有文件和资产。正如在本章开头提到的,虚幻引擎将允许您导入多种多媒体文件类型,而内容浏览器
是将允许您在其各自的子编辑器中浏览和编辑它们的窗口。每当您创建一个虚幻引擎项目时,它都会生成一个内容
文件夹。这个文件夹将是内容浏览器
,这意味着您只能浏览该文件夹中的文件。您可以通过查看其顶部来查看您当前在内容浏览器
中浏览的目录,而在我们的情况下,它是内容 -> ThirdPersonCPP
。
如果您单击内容浏览器
最左侧的过滤器
按钮左侧的图标,您将能够看到内容
文件夹的目录层次结构。此目录视图允许您选择、展开和折叠项目的内容
文件夹中的单个目录:
图 1.2:内容浏览器的目录视图
-
视口
:在屏幕的正中央,您将能够看到视口
窗口。这将显示当前级别的内容,并允许您浏览级别以及在其中添加、移动、删除和编辑对象。它还包含有关视觉过滤器、对象过滤器(您可以看到哪些对象)和级别中的照明的几个不同参数。 -
世界大纲
:在屏幕的右上角,您将看到世界大纲
。这将允许您快速列出和操作在您的级别中的对象。视口
和世界大纲
共同努力,让您能够管理您的级别,前者将向您展示它的外观,而后者将帮助您管理和组织它。与内容浏览器
类似,世界大纲
允许您在目录中组织级别中的对象,不同之处在于内容浏览器
显示项目中的资产,而世界大纲
显示级别中的对象。 -
Details
面板和世界设置
:在屏幕的最右侧,在世界大纲
下方,您将能够看到两个窗口 -Details
面板和世界设置
窗口。Details
窗口允许您编辑您在级别中选择的对象的属性。由于截图中没有选择任何对象,因此它是空的。但是,如果您通过左键单击选择级别中的任何对象,其属性应该会显示在此窗口中,如下面的截图所示:
图 1.3:详细信息选项卡
世界设置
窗口允许您设置级别的整体设置,而不是单个对象的设置。在这里,您可以更改诸如 Kill Z(您希望对象被销毁的高度)和所需的照明设置等内容:
图 1.4:世界设置窗口
工具栏
:在屏幕顶部,您将看到编辑器工具栏
,在那里您将能够保存当前级别、访问项目和编辑器设置,并播放您的级别,等等。
注意
我们将只使用这些工具栏中的一些按钮,即保存当前
、设置
、蓝图
、构建
和播放
按钮。
模式
:在屏幕的最左侧,您将看到模式
窗口。它将允许您将对象拖到您的级别中,例如立方体和球体、光源以及设计用于各种目的的其他类型的对象。
现在我们已经了解了虚幻引擎编辑器的主要窗口,让我们来看看如何管理这些窗口。
编辑器窗口
正如我们所见,虚幻引擎编辑器由许多窗口组成,所有这些窗口都是可调整大小、可移动的,并且都有相应的标签在顶部。您可以点击并按住窗口的标签并拖动它以将其移动到其他位置。您可以通过右键单击它们并选择隐藏
选项来隐藏标签标签:
图 1.5:如何隐藏标签
如果标签标签已被隐藏,您可以通过单击该窗口左上角的黄色三角形使其重新出现,如下图所示:
图 1.6:允许显示窗口标签的黄色三角形
请记住,您可以通过单击编辑器左上角的窗口
按钮来浏览和打开编辑器中的所有可用窗口,包括刚提到的窗口。
您还应该知道的另一件非常重要的事情是如何在编辑器内播放您的关卡(也称为工具栏
,您将看到播放
按钮。如果您点击它,您将开始在编辑器内播放当前打开的关卡。
一旦您点击播放
,您将能够通过使用W、A、S和D键来控制关卡中的玩家角色,使用空格键跳跃,并移动鼠标
来旋转相机:
图 1.7:在编辑器内播放的关卡
然后,您可以按Esc键(Escape)以停止播放关卡。
现在我们已经习惯了一些编辑器的窗口,让我们更深入地了解视口
窗口。
视口导航
我们在前一节中提到,视口
窗口将允许您可视化您的级别,并操纵其中的对象。因为这是您要使用的非常重要的窗口,并且具有许多功能,所以我们将在本节中更多地了解它。
在我们开始学习视口
窗口之前,让我们快速了解一下视口
窗口,它将始终显示当前选定级别的内容,本例中已经创建并与第三人称
模板项目一起生成。在此级别中,您将能够看到四个墙体对象,一个地面对象,一组楼梯和一些其他高架对象,以及由 UE4 模特代表的玩家角色。您可以创建多个级别并通过从内容浏览器
中打开它们来在它们之间切换。
为了操纵和浏览当前选定的级别,您必须使用视口
窗口。如果您在窗口内按住左鼠标按钮,您将能够通过将鼠标左和右移动来水平旋转相机,并通过将鼠标向前和向后移动来前后移动相机。您也可以通过按住右鼠标按钮来实现类似的结果,除了在将鼠标向前和向后移动时相机将垂直旋转,这样您就可以水平和垂直旋转相机。
此外,您还可以通过点击并按住视口
窗口的右鼠标按钮(左鼠标按钮也可以,但由于在旋转相机时没有太多自由度,因此使用它进行移动不太有用)并使用W和S键向前和向后移动,A和D键向侧面移动,E和Q键向上和向下移动。
如果您查看视口
窗口的右上角,您将看到一个带有数字的小摄像机图标,它将允许您更改相机在视口
窗口中移动的速度。
在Viewport
窗口中可以做的另一件事是更改其可视化设置。您可以通过单击当前显示为Lit
的按钮来更改Viewport
窗口中的可视化类型,这将显示所有可用于不同照明和其他类型可视化滤镜的选项。
如果单击Perspective
按钮,您将有选择在透视视图和正交视图之间切换,后者可能会帮助您更快地构建级别。
现在让我们转到操纵对象的主题,也称为 Actor,在您的级别中。
操纵 Actor
在虚幻引擎中,可以放置在级别中的所有对象都称为 Actor。在电影中,演员将是扮演角色的人,但在 UE4 中,您在级别中看到的每个对象,包括墙壁、地板、武器和角色,都是一个 Actor。
每个 Actor 必须有所谓的Transform
属性,这是三个东西的集合:
-
Vector
属性表示该 Actor 在X、Y和Z轴上的位置。矢量只是一个包含三个浮点数的元组,每个轴上的点的位置都有一个。 -
Rotator
属性表示该 Actor 沿X、Y和Z轴的旋转。旋转器也是一个包含三个浮点数的元组,每个轴上的旋转角度都有一个。 -
Vector
属性表示该 Actor 在级别中的比例(大小)在X、Y和Z轴上。这也是三个浮点数的集合,每个轴上都有一个比例值。
Actor 可以在级别中移动、旋转和缩放,这将相应地修改它们的Transform
属性。为了做到这一点,通过左键单击选择级别中的任何对象。您应该看到Move工具出现:
图 1.8:移动工具,允许您在级别中移动 Actor
移动工具是一个三轴标尺,允许您同时在任何轴上移动对象。移动工具的红色箭头(在前面的图像中指向左侧)表示X轴,绿色箭头(在前面的图像中指向右侧)表示Y轴,蓝色箭头(在前面的图像中向上指)表示Z轴。如果您单击并按住这些箭头中的任何一个,然后将它们拖动到级别中,您将在级别中沿该轴移动您的 Actor。如果单击连接两个箭头的手柄,您将同时沿着这两个轴移动 Actor,如果单击所有箭头交汇处的白色球体,您将自由移动 Actor 沿着所有三个轴:
图 1.9:使用移动工具在 Z 轴上移动的 Actor
移动工具将允许您在级别中移动 Actor,但如果您想旋转或缩放 Actor,您需要分别使用旋转和缩放工具。您可以通过按W、E和R键在移动、旋转和缩放工具之间切换。按E以切换到旋转工具:
图 1.10:旋转工具,允许您旋转 Actor
旋转工具将如预期般允许您在级别中旋转 Actor。您可以单击并按住任何弧线以围绕其关联轴旋转 Actor。红色弧线(前图中左上方)将围绕X轴旋转 Actor,绿色弧线(前图中右上方)将围绕Y轴旋转 Actor,蓝色弧线(前图中下方中心)将围绕Z轴旋转 Actor:
图 1.11:在 X 轴周围旋转 30 度之前和之后的立方体
请记住,物体围绕X轴的旋转通常被指定为横滚,它围绕Y轴的旋转通常被指定为俯仰,它围绕Z轴的旋转通常被指定为偏航。
最后,我们有比例工具。按R以切换到它:
图 1.12:比例工具
比例工具将允许您增加和减少角色在X、Y和Z轴上的比例(大小),红色手柄(上图左侧)将在X轴上缩放角色,绿色手柄(上图右侧)将在Y轴上缩放角色,蓝色手柄(上图上方)将在Z轴上缩放角色:
图 1.13:在所有三个轴上缩放前后的角色角色
您还可以通过单击“视口”窗口顶部的以下图标在移动、旋转和缩放工具之间切换:
图 1.14:移动、旋转和缩放工具图标
此外,您可以通过在移动、旋转和缩放工具图标右侧的网格捕捉选项更改移动、旋转和缩放对象的增量。通过按下当前为橙色的按钮,您将能够完全禁用捕捉,通过按下显示当前捕捉增量的按钮,您将能够更改这些增量:
图 1.15:用于移动、旋转和缩放的网格捕捉图标
现在您已经知道如何操作您级别中已经存在的角色,让我们在下一个练习中学习如何向我们的级别添加和删除角色。
练习 1.02:添加和删除角色
在这个练习中,我们将向我们的级别添加和删除角色。
在向您的级别添加角色时,有两种主要方法可以这样做:通过从内容浏览器
拖动资产,或者通过从模式
窗口的放置模式中拖动默认资产。
以下步骤将帮助您完成此练习:
- 如果您进入
ThirdPersonCPP -> Blueprints
目录,您将在内容浏览器
中看到ThirdPersonCharacter
角色。如果您使用左键将该资产拖到您的级别中,您将能够向其中添加该角色的一个实例,并且它将放置在您放开左键的地方:
图 1.16:将 ThirdPersonCharacter 角色的一个实例拖到我们的级别中
- 您也可以从
模式
窗口将一个角色拖到您的级别中:
图 1.17:将圆柱体角色拖到我们的级别中
- 要删除一个角色,您可以简单地选择该角色并按下删除键。您还可以右键单击一个角色,查看有关该角色的许多其他可用选项。
注意
尽管我们不会在本书中涵盖这个主题,但开发人员可以用简单的框和几何图形填充他们的级别,用于原型制作的一种方式是 BSP 刷。这些可以在构建级别时快速塑造成您想要的形状。要了解有关 BSP 刷的更多信息,请访问此页面:docs.unrealengine.com/en-US/Engine/Actors/Brushes
。
通过这样,我们结束了这个练习,并学会了如何向我们的级别添加和删除角色。
现在我们知道如何浏览视口
窗口,让我们了解蓝图角色。
蓝图角色
在 UE4 中,“蓝图”一词可用于指代两种不同的东西:UE4 的可视化脚本语言或特定类型的资产,也称为蓝图类或蓝图资产。
正如我们之前提到的,角色是可以放置在关卡中的对象。这个对象可以是 C++类的实例,也可以是蓝图类的实例,两者都必须从角色类(直接或间接地)继承。那么,C++类和蓝图类之间有什么区别呢?有一些:
-
如果您向 C++类添加编程逻辑,您将可以访问比创建蓝图类时更高级的引擎功能。
-
在蓝图类中,您可以轻松查看和编辑该类的可视组件,例如 3D 网格或触发框碰撞,以及修改在 C++类中定义的属性,这些属性暴露给编辑器,使得管理这些属性更加容易。
-
在蓝图类中,您可以轻松引用项目中的其他资产,而在 C++中,您也可以这样做,但不那么简单,也不那么灵活。
-
在蓝图可视化脚本上运行的编程逻辑在性能方面比 C++类慢。
-
在 C++类中,可以简单地让多个人同时工作而不会在源版本平台上发生冲突,而在蓝图类中,这将导致冲突,因为它被解释为二进制文件而不是文本文件,如果两个不同的人编辑相同的蓝图类,这将导致源版本平台上的冲突。
注意
如果您不知道什么是源版本平台,这是几个开发人员可以在同一项目上工作并且可以更新其他开发人员完成的工作的方式。在这些平台上,通常可以同时编辑同一文件的不同部分,只要它们编辑的是文件的不同部分,并且仍然可以接收其他程序员完成的更新,而不会影响您对同一文件的工作。最流行的源版本平台之一是 GitHub。
请记住,蓝图类可以继承自 C++类或另一个蓝图类。
最后,在我们继续创建我们的第一个蓝图类之前,您应该知道的另一件重要的事情是,您可以在 C++类中编写编程逻辑,然后创建一个从该类继承的蓝图类,但如果您在 C++类中指定了这一点,它也可以访问其属性和方法。您可以让蓝图类编辑在 C++类中定义的属性,以及调用和覆盖函数,使用蓝图脚本语言。我们将在本书中做一些这样的事情。
现在您对蓝图类有了一些了解,让我们在下一个练习中创建自己的蓝图类。
练习 1.03:创建蓝图角色
在这个简短的练习中,我们将学习如何创建一个新的蓝图角色。
以下步骤将帮助您完成此练习:
- 进入
ThirdPersonCPP -> Blueprints
目录,位于“内容浏览器”内,并在其中右键单击。 应该弹出以下窗口:
图 1.18:在内容浏览器内右键单击时出现的选项窗口
此选项菜单包含您可以在 UE4 中创建的资产类型(蓝图只是一种资产类型,以及其他类型的资产,如“关卡”、“材质”和“声音”)。
- 单击“蓝图类”图标以创建一个新的蓝图类。这样做时,您将有选择要继承的 C++或蓝图类的选项:
图 1.19:创建新蓝图类时弹出的选择父类窗口
- 从这个窗口中选择第一个类,
Actor
类。之后,你将自动选择新蓝图类的文本,以便轻松地为它命名。将这个蓝图类命名为TestActor
,然后按Enter
键接受这个名字。
按照这些步骤,你将创建你的蓝图类,完成这个练习。创建完这个资源后,用左键双击它以打开蓝图编辑器。
蓝图编辑器
蓝图编辑器是虚幻引擎编辑器中专门用于蓝图类的子编辑器。在这里,你可以编辑你的蓝图类的属性和逻辑,或者它们的父类,以及它们的视觉外观。
打开一个 Actor Blueprint 类时,你应该会看到蓝图编辑器。这是一个窗口,允许你在 UE4 中编辑蓝图类。让我们了解一下你当前看到的窗口:
![图 1.20:蓝图编辑器窗口分为五个部分(img/B16183_01_20.jpg)图 1.20:蓝图编辑器窗口分为五个部分 1. 视口
:在编辑器的正中央,你有视口
窗口。这个窗口,类似于我们已经了解的级别视口
窗口,将允许你可视化你的角色并编辑它的组件。每个角色可以有多个角色组件,其中一些有视觉表示,比如网格组件和碰撞组件。我们将在后面的章节更深入地讨论角色组件。从技术上讲,这个中心窗口包含三个选项卡,其中只有一个是视口
窗口,但我们将在讨论这个编辑器的界面后谈论另一个重要的选项卡,即事件图
。第三个选项卡是构造脚本
窗口,我们在本书中不会涉及。1. 组件
:在编辑器的左上方,你有组件
窗口。如前面所述,角色可以有多个角色组件,这个窗口将允许你在你的蓝图类中添加和移除这些角色组件,并访问它继承的 C++类中定义的角色组件。1. 我的蓝图
:在编辑器的左下方,你有我的蓝图
窗口。这将允许你浏览、添加和移除在这个蓝图类和它继承的 C++类中定义的变量和函数。请记住,蓝图有一种特殊类型的函数,称为BeginPlay
、ActorBeginOverlap
和Tick
。我们将在几段落后讨论这些。1. 详情
:在编辑器的右侧,你有详情
窗口。类似于编辑器的详情
窗口,这个窗口将显示当前选定的角色组件、函数、变量、事件或者这个蓝图类的任何其他单独元素的属性。如果你当前没有选定任何元素,这个窗口将是空的。1. 工具栏
:在编辑器的正上方,你有工具栏
窗口。这个窗口将允许你编译你在这个蓝图类中编写的代码,保存它,定位它在内容浏览器
中,并访问这个类的设置,以及其他事项。你可以通过查看蓝图编辑器右上角的父类来看到蓝图类的父类。如果你点击父类的名称,你将通过虚幻引擎编辑器或者 Visual Studio 被带到相应的蓝图类或 C++类。此外,你可以通过点击蓝图编辑器左上角的文件
选项卡,并选择重新指定蓝图
选项来更改蓝图类的父类,这将允许你指定这个蓝图类的新父类。现在我们已经了解了蓝图编辑器的基础知识,让我们来看看它的事件图。# 事件图事件图
窗口是你将编写所有蓝图可视化脚本代码、创建变量和函数以及访问在该类的父类中声明的其他变量和函数的地方。如果你选择事件图
选项卡,你应该能够在视口
选项卡的右侧看到,你将会看到事件图
窗口而不是视口
窗口。点击事件图
选项卡后,你将看到以下窗口:
图 1.21:事件图窗口,显示三个禁用的事件
你可以通过按住鼠标右键在事件图
中拖动来导航,通过滚动鼠标滚轮来放大和缩小,通过单击鼠标左键或按住并选择节点区域来选择图中的节点。
你也可以在事件图
窗口内右键单击来访问蓝图的操作菜单,该菜单允许你访问在事件图
中可以执行的操作,包括获取和设置变量,调用函数或事件,以及其他许多操作。
蓝图中脚本的工作方式是通过连接节点使用针。有几种类型的节点,如变量、函数和事件。你可以通过针连接这些节点,其中有两种类型的针:
- 执行针: 这些将决定节点执行的顺序。如果你想要执行节点 1 然后执行节点 2,你需要将节点 1 的输出执行针连接到节点 2 的输入执行针,如下面的截图所示:
图 1.22:连接事件 OnReset 节点的输出执行针到 MyVar 的 setter 节点的输入执行针
- 变量针:这些作为参数(也称为输入针),在节点的左侧,并返回值(也称为输出针),在节点的右侧,表示特定类型的值(整数、浮点数、布尔值等):
图 1.23:调用 Get Scalar Parameter Value 函数的节点,它有两个输入变量针和一个输出变量针
让我们通过下一个练习更好地理解这个。
练习 1.04:创建蓝图变量
在这个练习中,我们将看到如何通过创建一个Boolean
类型的新变量来创建蓝图变量。
在蓝图中,变量的工作方式类似于你在 C++中使用的变量。你可以创建它们,获取它们的值,并设置它们。
以下步骤将帮助你完成这个练习:
- 要创建一个新的蓝图变量,前往
我的蓝图
窗口并点击+ 变量
按钮:
图 1.24:在我的蓝图窗口中突出显示的+ 变量按钮,允许你创建一个新的蓝图变量
- 之后,你将自动被允许命名你的新变量。将这个新变量命名为
MyVar
:
图 1.25:命名新变量 MyVar
- 通过点击
工具栏
窗口左侧的编译
按钮来编译你的蓝图。如果你现在查看详细信息
窗口,你应该会看到以下内容:
图 1.26:详细信息窗口中的 MyVar 变量设置
- 在这里,您将能够编辑与此变量相关的所有设置,最重要的设置是
变量名称
,变量类型
和设置末尾的默认值
。布尔变量的值可以通过单击其右侧的灰色框来更改:
图 1.27:从变量类型下拉菜单中可用的变量类型
- 您还可以将变量的 getter 或 setter 拖到
My Blueprint
选项卡中,然后放入事件图
窗口中:
图 1.28:将 MyVar 拖入事件图窗口并选择是否添加 getter 或 setter
Getter 是包含变量当前值的节点,而 setter 是允许您更改变量值的节点。
- 要允许变量在此蓝图类的每个实例中可编辑,您可以单击
My Blueprint
窗口中该变量右侧的眼睛图标:
图 1.29:单击眼睛图标以显示变量并允许其可实例编辑
- 然后,您可以将此类的实例拖到您的级别中,选择该实例,并在编辑器的
详细
窗口中看到更改该变量值的选项:
图 1.30:可以通过该对象的详细面板编辑的 MyVar 变量
有了这个,我们完成了这个练习,现在知道如何创建我们自己的蓝图变量。现在让我们看看如何在下一个练习中创建蓝图函数。
练习 1.05:创建蓝图函数
在这个练习中,我们将创建我们的第一个蓝图函数。在蓝图中,函数和事件是相对类似的,唯一的区别是事件只会有一个输出引脚,通常是因为它是从蓝图类的外部调用的:
图 1.31:事件(左),不需要执行引脚的纯函数调用(中),和正常函数调用(右)
以下步骤将帮助您完成此练习:
- 单击
My Blueprint
窗口内的+函数
按钮:
图 1.32:悬停在+函数按钮上,这将创建一个新函数
-
将新函数命名为
MyFunc
。 -
通过单击
工具栏
窗口中的编译
按钮来编译您的蓝图:
图 1.33:编译按钮
- 如果您现在查看
详细
窗口,您应该会看到以下内容:
图 1.34:选择 MyFunc 函数并添加输入和输出引脚后的详细面板
在这里,您将能够编辑与此函数相关的所有设置,最重要的设置是输入
和输出
在设置末尾。这将允许您指定此函数必须接收和返回的变量。
最后,您可以通过从My Blueprint
窗口单击来编辑此函数的功能。这将在中心窗口中打开一个新选项卡,允许您指定此函数将执行的操作。在这种情况下,此函数每次被调用时将简单地返回false
:
图 1.35:MyFunc 函数的内容,接收一个布尔参数,并返回一个布尔类型
- 要保存对此蓝图类所做的修改,请单击工具栏上
Compile
按钮旁边的Save
按钮。或者,您可以选择使蓝图在成功编译时自动保存。
按照这些步骤,您现在知道如何创建自己的蓝图函数。现在让我们来看一下本章后面将要使用的蓝图节点。
浮点数乘法节点
蓝图包含许多与变量或函数无关的节点。其中一个例子是算术节点(即加法、减法、乘法等)。如果在蓝图操作菜单中搜索float * float
,您将找到浮点数乘法节点。
图 1.36:浮点数乘法节点
此节点允许您输入两个或多个浮点参数(您可以通过单击Add pin
文本右侧的+
图标添加更多参数),并输出所有参数的乘积结果。我们将在本章的后面使用此节点。
BeginPlay 和 Tick
现在让我们来看一下 UE4 中两个最重要的事件:BeginPlay
和Tick
。
如前所述,事件通常会从蓝图类外部调用。在BeginPlay
事件的情况下,当蓝图类的实例被放置在关卡中并且关卡开始播放时,或者在游戏进行中动态生成蓝图类的实例时,将调用此事件。您可以将BeginPlay
事件视为在蓝图类的实例上调用的第一个事件,您可以用它进行初始化。
在 UE4 中了解的另一个重要事件是Tick
事件。如您所知,游戏以一定的帧率运行,最常见的是 30 FPS(每秒帧数)或 60 FPS:这意味着游戏将每秒渲染 30 次或 60 次更新的游戏图像。Tick
事件将在游戏执行此操作时被调用,这意味着如果游戏以 30 FPS 运行,则Tick
事件将每秒被调用 30 次。
转到蓝图类的事件图
窗口,并通过选择它们所有并单击Delete
键来删除三个灰色事件,这将导致事件图
窗口变为空白。之后,在事件图
窗口内部右键单击,输入BeginPlay
,并选择Event BeginPlay
节点,然后单击Enter
键或在蓝图操作菜单中选择该选项。这将导致该事件被添加到事件图
窗口中:
图 1.37:通过蓝图操作菜单将 BeginPlay 事件添加到事件图窗口中
右键单击事件图窗口内部,输入Tick
,并选择Event Tick
节点。这将导致该事件被添加到事件图窗口中:
图 1.38:Tick 事件
与BeginPlay
事件不同,Tick
事件将带有一个参数DeltaTime
。此参数是一个浮点数,表示自上一帧渲染以来经过的时间。如果您的游戏以 30 FPS 运行,则每个帧之间的间隔(增量时间)平均为 1/30 秒,约为 0.033 秒(33.33 毫秒)。如果渲染帧 1,然后 0.2 秒后渲染帧 2,则帧 2 的增量时间将为 0.2 秒。如果帧 3 在帧 2 之后 0.1 秒渲染,则帧 3 的增量时间将为 0.1 秒,依此类推。
但是为什么DeltaTime
参数如此重要?让我们看看以下情景:您有一个蓝图类,它使用Tick
事件在每次渲染帧时在 Z 轴上增加 1 个单位的位置。然而,您面临一个问题:玩家可能以不同的帧率运行游戏,比如 30 FPS 和 60 FPS。以 60 FPS 运行游戏的玩家将导致Tick
事件被调用的次数是以 30 FPS 运行游戏的玩家的两倍,并且蓝图类将因此移动速度加快两倍。这就是增量时间的作用所在:因为以 60 FPS 运行游戏的Tick
事件被调用的增量时间值较低(渲染帧之间的间隔更小),您可以使用该值来改变 Z 轴上的位置。尽管以 60 FPS 运行游戏的Tick
事件被调用的次数是 30 FPS 运行游戏的两倍,但其增量时间是一半,因此一切都平衡了。这将导致以不同帧率玩游戏的两个玩家获得相同的结果。
注意
如果您希望一个使用增量时间移动的蓝图移动得更快或更慢,可以将增量时间乘以您希望它每秒移动的单位数(例如,如果您希望一个蓝图在 Z 轴上每秒移动 3 个单位,您可以告诉它每帧移动3 * DeltaTime
个单位)。
现在让我们尝试另一个练习,这将涉及使用蓝图节点和引脚。
练习 1.06:在 Z 轴上偏移 TestActor 类
在这个练习中,您将使用BeginPlay
事件在游戏开始播放时偏移(移动)TestActor
在 Z 轴上的位置。
以下步骤将帮助您完成此练习:
-
打开
TestActor
蓝图类。 -
使用“蓝图操作”菜单,将“事件 BeginPlay”节点添加到图表中,如果尚未添加。
-
添加
AddActorWorldOffset
函数,并将BeginPlay
事件的输出执行引脚连接到此函数的输入执行引脚。此函数负责在预期轴(X、Y和Z)上移动 Actor,并接收以下参数:
-
Target
:应调用此函数的 Actor,这将是调用此函数的 Actor。默认行为是在调用此函数的 Actor 上调用此函数,这正是我们想要的,并且使用self
属性显示。 -
DeltaLocation
:我们希望在三个轴上偏移此 Actor 的量:X、Y 和 Z。 -
我们不会涉及另外两个参数
Sweep
和Teleport
,所以您可以将它们保持不变。它们都是布尔类型,应该保持为false
:
图 1.39:BeginPlay 事件调用 AddActorWorldOffset 函数
- 拆分
Delta Location
输入引脚,这将导致将此Vector
属性拆分为三个浮点属性。您可以通过右键单击它们并选择“拆分结构引脚”来对由一个或多个子类型组成的任何变量类型执行此操作(您无法对浮点类型执行此操作,因为它不包含任何变量子类型):
图 1.40:将 Delta 位置参数从矢量拆分为三个浮点数
-
通过单击鼠标左键,输入该数字,然后按Enter键,将
Delta Location
的Z
属性设置为 100 个单位。这将导致我们的TestActor
在游戏开始时在 Z 轴上向上移动 100 个单位。 -
使用“组件”窗口向您的
TestActor
添加一个立方体形状,以便我们可以看到我们的角色。您可以通过单击“+添加组件”按钮,输入Cube
,然后选择“基本形状”部分下的第一个选项来执行此操作:
图 1.41:添加一个立方体形状
-
通过单击“编译”按钮来编译和保存您的蓝图类。
-
回到级别的“视口”窗口,并在级别中放置一个您的
TestActor
蓝图类的实例,如果您还没有这样做的话:
图 1.42:将 TestActor 的实例添加到级别
- 当您播放级别时,您应该注意到我们添加到级别中的
TestActor
处于更高的位置:
图 1.43:游戏开始时 TestActor 在 Z 轴上增加其位置
- 在进行这些修改后,通过按下Ctrl + S或单击编辑器“工具栏”上的“保存当前”按钮来保存我们对级别所做的更改。
在这个练习中,您已经学会了如何创建您自己的蓝图脚本逻辑的第一个角色蓝图类。
注意
TestActor
蓝图资产和此练习的最终结果的“地图”资产都可以在这里找到:packt.live/3lfYOa9
。
现在我们已经做到了这一点,让我们更多地了解ThirdPersonCharacter
蓝图类。
第三人称角色蓝图类
让我们来看看ThirdPersonCharacter
蓝图类,这是代表玩家控制的角色的蓝图,并看看它包含的角色组件。
转到“内容浏览器”中的“ThirdPersonCPP->蓝图”目录,并打开ThirdPersonCharacter
资产:
图 1.44:ThirdPersonCharacter 蓝图类
在之前的部分中,我们介绍了蓝图编辑器中的“组件”窗口,我们提到了角色组件。
角色组件是必须存在于角色内部的实体,并允许您将角色的逻辑分散到几个不同的角色组件中。在这个蓝图中,我们可以看到有四个可视表示的角色组件:
-
显示 UE4 模特的骨骼网格组件
-
一个摄像头组件,显示玩家将能够从哪里看到游戏
-
一个箭头组件,允许我们看到角色面对的方向(这主要用于开发目的,而不是在游戏进行时)
-
指定此角色的碰撞范围的胶囊组件
如果您查看“组件”窗口,您会看到一些比我们在“视口”窗口中看到的更多的角色组件。这是因为一些角色组件没有视觉表示,纯粹由 C++或蓝图代码组成。我们将在下一章和第九章“音频-视觉元素”中更深入地了解角色组件。
如果你看一下这个蓝图类的“事件图”窗口,你会发现它基本上是空的,就像我们在TestActor
蓝图类中看到的那样,尽管它有一些与之相关的逻辑。这是因为该逻辑是在 C++类中定义的,而不是在这个蓝图类中。我们将在下一章中看看如何做到这一点。
为了解释这个蓝图类的骨骼网格组件,我们应该先讨论网格和材料。
网格和材料
要使计算机可视化表示 3D 对象,需要两样东西:3D 网格和材料。
网格
3D 网格允许您指定对象的大小和形状,就像这个代表猴子头部的网格:
图 1.45:猴子头部的 3D 网格
网格由多个顶点、边和面组成。顶点只是具有X、Y和Z位置的 3D 坐标;边是两个顶点之间的连接(即一条线);面是三个或更多边的连接。您可以在前面的图中看到网格的各个顶点、边和面,其中每个面的颜色在白色和黑色之间变化,取决于面上反射的光线量。如今,视频游戏可以以这样的方式渲染网格,其中有成千上万的顶点,以至于您无法分辨出单个顶点,因为它们太靠在一起了。
材质
另一方面,材质允许您指定网格的表示方式。它们允许您指定网格的颜色,在其表面绘制纹理,甚至操纵其各个顶点。
创建网格是一件事,在撰写本书时,UE4 尚未得到适当支持,应在另一款软件(例如 Blender 或 Autodesk Maya)中完成,因此我们不会在这里详细介绍这一点。但是,我们将学习如何为现有网格创建材质。
在 UE4 中,您可以通过网格组件添加网格,这些网格继承自 Actor 组件类。有几种类型的网格组件,但最重要的两种是静态网格组件,用于没有动画的网格(例如,立方体,静态级别几何体),以及骨骼网格组件,用于具有动画的网格(例如,播放移动动画的角色网格)。正如我们之前所看到的,ThirdPersonCharacter
蓝图类包含骨骼网格组件,因为它用于表示播放移动动画的角色网格。在下一章中,我们将学习如何将资产(例如网格)导入到我们的 UE4 项目中。
现在让我们在下一个练习中看一下 UE4 中的材质。
在 UE4 中操作材质
在本节中,我们将看一看材质在 UE4 中的工作原理。
返回到您的“级别视口”窗口,并选择此“立方体”对象:
图 1.46:立方体对象,旁边的文字写着地板上的第三人称
查看“详细信息”窗口,您将能够看到与此对象的“静态网格”组件关联的网格和材质:
图 1.47:立方体对象的静态网格组件的材质(元素 0)属性
注意
请记住,网格可以有多个材质,但必须至少有一个。
单击“材质”属性旁边的放大镜图标,以转到“内容浏览器”中该材质的位置。该图标适用于编辑器中对任何资产的任何引用,因此您也可以对立方体对象的“静态网格”引用执行相同操作:
图 1.48:放大镜图标(左),可带您到该资产在内容浏览器中的位置(右)
双击使用鼠标左键打开“材质”编辑器中的资产。让我们来分解“材质编辑器”中的窗口:
图 1.49:将材质编辑器窗口分解为五个部分
-
图表
:在编辑器的正中央,您将看到图表
窗口。类似于蓝图编辑器的事件图表
窗口,材质编辑器的图表也是基于节点的,您将在此找到通过引脚连接的节点,尽管这里不会找到执行引脚,只有输入和输出引脚。 -
Palette
:在屏幕的右边缘,你会看到Palette
窗口,你可以在这里搜索所有可以添加到Graph
窗口的节点。你也可以像在蓝图编辑器的事件图
窗口中一样,通过在Graph
窗口内右键单击并输入你想要添加的节点来实现。 -
Viewport
:在屏幕的左上角,你会看到Viewport
窗口。在这里,你可以预览你的材质的结果,以及它在一些基本形状上的外观,比如球体、立方体和平面。 -
Details
:在屏幕的左下角,你会看到Details
窗口,类似于蓝图编辑器,你可以查看材质
资产的细节,或者查看Graph
窗口中当前选定节点的细节。 -
Toolbar
:在屏幕的顶部边缘,你会看到Toolbar
窗口,你可以在这里应用和保存对材质的更改,以及执行与Graph
窗口相关的几个操作。
在 UE4 的每个材质编辑器中,你都会找到一个名为Material
的节点,通过将该节点的引脚连接到其他节点,你可以指定与之相关的几个参数。
在这种情况下,你可以看到有一个名为0.7
的节点被插入到Roughness
引脚中。这个节点是一个Constant
节点,允许你指定与之关联的数字 - 在这种情况下是0.7
。你可以创建单个数字、2 个向量(例如,(1, 0.5)
)、3 个向量(例如,(1, 0.5, 4)
)和 4 个向量(例如,(1,0.5, 4, 0)
)的常数节点。要创建这些节点,你可以按住1
、2
、3
或4
数字键,同时在Graph
窗口上按下鼠标左键。
材质有几个输入参数,让我们来看一些最重要的参数:
-
BaseColor
:这个参数就是材质的颜色。通常,常数或纹理样本被用来连接到这个引脚,要么让一个物体成为特定颜色,要么映射到特定纹理。 -
Metallic
:这个参数将决定你的物体看起来有多像金属表面。你可以通过连接一个范围从 0(非金属)到 1(非常金属)的常数单个数字节点来实现这一点。 -
Specular
:这个参数将决定你的物体将反射多少光。你可以通过连接一个范围从 0(不反射任何光)到 1(反射所有光)的常数单个数字节点来实现这一点。如果你的物体已经非常金属,你将看不到任何或很少的差异。 -
Roughness
:这个参数将决定你的物体反射的光有多少会被散射(光散射得越多,这个物体反射周围的东西就越不清晰)。你可以通过连接一个范围从 0(物体基本上变成镜子)到 1(这个物体上的反射是模糊不清)的常数单个数字节点来实现这一点。
注意
要了解更多关于上述材质
输入的信息,请访问docs.unrealengine.com/en-US/Engine/Rendering/Materials/MaterialInputs
。
UE4 还允许你导入图像(.jpeg
、.png
)作为纹理
资产,然后可以在材质中使用纹理样本
节点引用:
图 1.50:纹理样本节点,允许你指定一个纹理并将其用作引脚的颜色通道
注意
我们将在下一章中看一下如何将文件导入到 UE4 中。
要创建一个新的材质
资产,你可以在Content Browser
内右键单击要创建新资产的目录,这将允许你选择要创建的资产,然后选择Material
。
现在您知道如何在 UE4 中创建和操作材质了。
现在让我们开始本章的活动,这将是本书的第一个活动。
活动 1.01:在 Z 轴上无限推进 TestActor
在这个活动中,您将使用TestActor
的Tick
事件来使其在Z轴上无限移动,而不仅仅在游戏开始时执行一次。
以下步骤将帮助您完成此活动:
-
打开
TestActor
蓝图类。 -
将
事件 Tick
节点添加到蓝图的事件图
窗口中。 -
添加
AddActorWorldOffset
函数,拆分其DeltaLocation
引脚,并将Tick
事件的输出执行引脚连接到此函数的输入执行引脚,类似于我们在练习 1.01,创建虚幻引擎 4 项目中所做的。 -
在
事件图
窗口中添加一个Float Multiplication节点。 -
将
Tick
事件的Delta Seconds
输出引脚连接到Float Multiplication节点的第一个输入引脚。 -
创建一个
float
类型的新变量,称为VerticalSpeed
,并将其默认值设置为25
。 -
在
事件图
窗口中为VerticalSpeed
变量添加一个 getter,并将其引脚连接到Float Multiplication节点的第二个输入引脚。之后,将Float Multiplication节点的输出引脚连接到AddActorWorldOffset
函数的Delta Location Z
引脚。 -
删除我们在练习 1.01,创建虚幻引擎 4 项目中创建的
BeginPlay
事件和连接到它的AddActorWorldOffset
函数。 -
播放关卡,注意我们的
TestActor
随着时间从地面上升到空中:
图 1.51:TestActor 在垂直方向上推进
完成这些步骤后,我们结束了这个活动——本书中的第一个活动。我们现在已经巩固了向蓝图编辑器的事件图
窗口添加和删除节点,以及使用Tick
事件及其DeltaSeconds
属性来创建跨不同帧率保持一致性的游戏逻辑。
注意
此活动的解决方案可以在此处找到:packt.live/338jEBx
。
TestActor
蓝图资产可以在这里找到:packt.live/2U8pAVZ
。
总结
通过完成本章,您已经迈出了游戏开发之旅的第一步,了解了虚幻引擎 4。您现在知道如何浏览虚幻引擎编辑器,操作关卡内的角色,创建自己的角色,使用蓝图脚本语言,以及在虚幻引擎 4 中如何表示 3D 对象。
希望您意识到在您面前有一个充满可能性的世界,并且在使用这个游戏开发工具创建各种东西方面,天空是极限。
在下一章中,您将从头开始重新创建本章自动生成的项目模板。您将学习如何创建自己的 C++类,然后创建可以操作其父类声明的属性的蓝图类,以及如何将角色网格和动画导入到虚幻引擎 4 中,以及熟悉其他与动画相关的资产,如动画蓝图。
第二章:使用虚幻引擎
概述
本章将重点介绍虚幻引擎中许多基本概念和特性。您将学习如何创建 C++项目,如何进行一些基本调试,以及如何处理特定角色的动画。
通过本章结束时,您将能够创建 C++模板项目,能够在 Visual Studio 中调试代码,了解文件夹结构和相关的最佳实践,并最终能够根据状态设置角色动画。
介绍
在上一章中,我们介绍了 Epic Games Launcher 的基础知识,以及虚幻编辑器的基本原理。我们了解了如何处理对象以及基本级别上的蓝图,还探索了第一人称模板。在本章中,我们将通过探索第三人称模板和处理输入和动画来进一步建立这些基础知识。
游戏开发可以使用多种语言,如 C、C++、Java、C#,甚至 Python。虽然每种语言都有优缺点,但在本书中我们将使用 C++,因为它是虚幻引擎中主要使用的编程语言。
在本章中,我们将带您快速了解如何在 UE4 中创建 C++项目和基本级别的调试。调试代码非常重要,因为它有助于开发人员处理错误。提供的工具非常方便,对于任何虚幻引擎开发人员都是必不可少的。
接下来,我们将深入了解在虚幻引擎中创建游戏和体验所涉及的核心类。您将探索游戏模式和相关的类概念,然后进行一项练习,以获得对此的实际理解。
本章的最后一部分是关于动画的。几乎每个游戏都包含动画,有些只是非常基本的,但有些则达到了非常高的水平,包括引人入胜的细节,这些细节对游戏体验至关重要。虚幻引擎提供了几种工具,您可以使用这些工具来创建和处理动画,包括具有复杂图表和状态机的动画蓝图。
创建和设置空白 C++项目
在每个项目开始时,您可能希望从 Epic 提供的模板中选择任何一个(其中包含准备执行的基本代码)并在此基础上进行开发。大部分/有时候,您可能需要设置一个空白项目,以便根据自己的需求进行开发。我们将在接下来的练习中学习如何做到这一点。
练习 2.01:创建一个空白的 C++项目
在这个练习中,您将学习如何从 Epic 提供的模板中创建一个空白的 C++项目。这将成为您未来许多 C++项目的基础。
以下步骤将帮助您完成这个练习:
-
从 Epic Games Launcher 启动虚幻引擎 4.24。
-
点击“游戏”部分,然后点击“下一步”。
-
确保选择“空白”项目模板,然后点击“下一步”。
-
点击“蓝图”部分下拉菜单,选择
C++
。
注意
确保项目文件夹和项目名称分别指定了适当的目录和名称。
设置好一切后,点击“创建项目”按钮。在本例中,我们的项目目录位于一个名为UnrealProjects
的文件夹中,该文件夹位于 E 驱动器内。项目名称设置为MyBlankProj
(建议您遵循这些名称和项目目录,但如果您愿意,也可以使用自己的名称)。
注意
项目名称不能包含任何空格。最好将虚幻目录放在驱动器的根目录附近(以避免在创建或导入资产到项目工作目录时遇到 256 字符路径限制等问题;对于小型项目,可能没问题,但对于更大规模的项目,文件夹层次可能会变得过于复杂,这一步很重要)。
您会注意到,在生成代码并创建项目文件后,项目将被打开,并附带其 Visual Studio 解决方案(.sln)文件。
注意
确保 Visual Studio 解决方案配置设置为 Development Editor,并且解决方案平台设置为 Win64 以进行桌面开发:
图 2.1:Visual Studio 部署设置
通过完成这个练习,我们现在知道如何在 UE4 上创建一个空的 C++项目,以及其中的注意事项。
在下一节中,我们将简要讨论文件夹结构,以及虚幻开发人员使用的最基本和最常用的文件夹结构格式。
虚幻引擎中的内容文件夹结构
在您的项目目录(E:/UnrealProjects/MyBlankProj
在我们的案例中)中,您会看到一个Content
文件夹。这是您的项目用于不同类型资产和项目相关数据(包括蓝图)的主要文件夹。C++代码放入项目的Source
文件夹中。请注意,最佳做法是通过虚幻编辑器直接创建新的 C++代码文件,因为这简化了流程并减少了错误。
您可以使用许多不同的策略来组织Content
文件夹中的数据。最基本和易于理解的是使用文件夹名称来表示其中的内容类型。因此,Content
文件夹目录结构可能类似于packt.live/3lCVFkR
中的示例。在这个示例中,您可以看到每个文件都被分类地放在表示其类型的文件夹名称下的第一级,随后的级别进一步将其分组到有意义的文件夹中。
注意
所有蓝图的名称应以BP
为前缀(以区分它们与虚幻引擎使用的默认蓝图)。其余前缀是可选的(但最好的做法是使用前面显示的前缀格式)。
在下一节中,我们将看一下 Visual Studio 解决方案。
使用 Visual Studio 解决方案
虚幻引擎中的每个 C++项目都有一个 Visual Studio 解决方案。这反过来驱动了所有的代码,并为开发人员提供了在运行状态下设置执行逻辑和调试代码的能力。
解决方案分析
项目目录中生成的 Visual Studio 解决方案(.sln)文件包含了整个项目和任何添加到其中的相关代码。
让我们来看看 Visual Studio 中存在的文件。双击 .sln 文件在 Visual Studio 中打开它。
在Solution Explorer
中,您将看到两个名为Engine
和Games
的项目。
引擎项目
在基本层面上,虚幻引擎本身就是一个 Visual Studio 项目,并有自己的解决方案文件。这包含了在虚幻引擎中共同工作的所有代码和第三方集成。该项目中的所有代码称为“源”代码。
引擎项目由当前用于该项目的虚幻引擎的外部依赖项、配置、插件、着色器和源代码组成。您可以随时浏览UE4 -> Source
文件夹,查看任何引擎代码。
注意
由于虚幻引擎是开源的,Epic 允许开发人员查看和编辑源代码以满足其需求和要求。但是,您不能编辑通过 Epic Games Launcher 安装的虚幻引擎版本的源代码。要能够对源代码进行更改和构建,您需要下载虚幻引擎的源代码版本,可以在 GitHub 上找到。您可以使用以下指南下载虚幻引擎的源代码版本:docs.unrealengine.com/en-US/GettingStarted/DownloadingUnrealEngine/index.html
下载后,您还可以参考以下指南来编译/构建新下载的引擎:docs.unrealengine.com/en-US/Programming/Development/BuildingUnrealEngine/index.html
游戏项目
在Games
目录下是解决方案文件夹,名称为您的项目。展开后,您会找到一组文件夹。您将关注以下内容:
-
配置文件夹:包含为项目和构建设置的所有配置(这些可以选择性地具有特定平台(如 Windows、Android、iOS、Xbox 或 PS)的设置)。
-
插件文件夹:这是一个可选文件夹,当您添加任何第三方插件(从 Epic Marketplace 下载或通过互联网获取)时会创建。该文件夹将包含与该项目相关的所有插件的源代码。
-
源文件夹:这是我们将要使用的主要文件夹。它将包含构建目标文件,以及项目的所有源代码。以下是源文件夹中默认文件的描述:
-
.Target.cs
扩展名,以及以Build.cs
结尾的一个构建文件。 -
ProjectName 代码文件(.cpp 和.h):默认情况下,为每个项目创建这些文件,并包含用于运行默认游戏模块代码的代码。
-
ProjectNameGameModeBase 代码文件(.cpp 和.h):默认情况下,会创建一个空的项目游戏模式基类。在大多数情况下通常不会使用。
-
ProjectName.uproject 文件:包含用于提供有关项目的基本信息以及与之关联的插件列表的描述符。
在 Visual Studio 中调试代码
Visual Studio 提供了强大的调试功能,通过在代码中设置断点。它使用户能够在特定代码行暂停游戏,以便开发人员可以查看变量的当前值,并以受控的方式逐步执行代码和游戏(可以逐行进行,逐个函数进行等)。
当您的游戏项目中有大量变量和代码文件,并且希望以逐步方式查看变量的值被更新和使用以调试代码、找出问题并解决问题时,这将非常有用。调试是任何开发人员工作的基本过程,只有经过许多连续的调试、分析和优化周期,项目才能足够完善以进行部署。
现在您已经对 Visual Studio 解决方案有了基本的了解,我们将继续并进行一个实际的练习。
练习 2.02:调试第三人称模板代码
在这个练习中,您将使用虚幻引擎的第三人称模板创建一个项目,并将在 Visual Studio 中调试代码。我们将调查模板项目的Character
类中名为BaseTurnRate
的变量的值。我们将看到随着我们逐行移动代码,该值如何更新。
以下步骤将帮助您完成此练习:
-
从 Epic Games Launcher 启动虚幻引擎。
-
点击
Games
部分,然后点击下一步
。 -
选择
Third Person
,然后点击下一步
。 -
选择 C++,将项目名称设置为
ThirdPersonDebug
,然后点击创建项目
按钮。 -
现在,关闭虚幻编辑器,转到 Visual Studio 解决方案,并打开
ThirdPersonDebugCharacter.cpp
文件:
图 2.2:ThirdPersonDebugCharacter.cpp 文件位置
- 左键单击在第
18
行左侧的栏上。应该会出现一个红色的圆点图标(您可以再次单击它将其关闭):
图 2.3:碰撞胶囊初始化代码
在这里,我们正在获取角色的capsule
组件(在第三章,角色类组件和蓝图设置中进一步解释),默认情况下是根组件。然后,我们调用它的InitCapsuleSize
方法,该方法接受两个参数:InRadius
浮点数和InHalfHeight
浮点数。
- 确保 VS 中的解决方案配置设置为
开发编辑器
,然后点击本地 Windows 调试器
按钮:
图 2.4:Visual Studio 构建设置
- 等到您能在左下角看到以下窗口为止:
注意
如果窗口没有弹出,您可以通过在调试
> 窗口
> 自动
下手动打开窗口。此外,您也可以使用本地
。
图 2.5:Visual Studio 变量监视窗口
this
显示了对象本身。对象包含它存储的变量和方法,通过展开它,我们能够看到整个对象及其变量在当前代码执行行的状态。
-
展开
this
,然后展开ACharacter
,然后展开CapsuleComponent
。在这里,您可以看到CapsuleHalfHeight = 88.0
和CapsuleRadius = 34.0
变量的值。在初始的红点所在的第18
行旁边,您会看到一个箭头。这意味着代码已经到达第17
行的末尾,尚未执行第18
行。 -
点击
步进
按钮进入下一行代码(快捷键:F11)。步进
将进入到该行内部的代码(如果存在)。另一方面,步过
将只执行当前代码并移动到下一行。由于当前行上没有函数,步进
将模仿步过
功能。
图 2.6:调试步进
- 请注意,箭头已移动到第
21
行,并且变量已经更新。CapsuleHalfHeight = 96.0
和CapsuleRadius = 42.0
以红色突出显示。还要注意,BaseTurnRate
变量初始化为0.0
:
图 2.7:BaseTurnRate 初始值
- 再次按下(F11)进入到第
22
行。现在,BaseTurnRate
变量的值为45.0
,BaseLookUpRate
初始化为0.0
,如下截图所示:
图 2.8:BaseTurnRate 更新的值
- 再次按下(F11)进入到第
27
行。现在,BaseLookUpRate
变量的值为45.0
。
同样,您被鼓励进入并调试代码的其他部分,不仅要熟悉调试器,还要了解代码在幕后是如何工作的。
通过完成这个练习,您已经学会了如何在 Visual Studio 中设置调试点,以及在某一点停止调试,然后逐行继续观察对象及其变量的值。这对于任何开发人员来说都是一个重要的方面,许多人经常使用这个工具来消除代码中的烦人错误,特别是当代码流量很大,变量的数量相当多时。
注意
在任何时候,您都可以通过顶部菜单栏上的以下按钮停止调试、重新开始调试或继续执行其余代码:
图 2.9:Visual Studio 中的调试工具
现在,我们将看一下如何将资产导入到虚幻项目中。
导入所需资产
虚幻引擎为用户提供了导入各种文件类型的能力,以便用户自定义其项目。开发人员可以调整和玩弄几种导入选项,以匹配其所需的设置。
游戏开发者经常导入的一些常见文件类型包括场景、网格、动画(从 Maya 和其他类似软件导出)、电影文件、图像(主要用于用户界面)、纹理、声音、CSV 文件中的数据和字体。这些文件可以从 Epic Marketplace 或其他途径(如互联网)获得,并在项目中使用。
资产可以通过将它们拖放到内容
文件夹中来导入,也可以通过在内容浏览器
中点击导入
按钮来导入。
现在让我们来进行一个练习,学习如何导入 FBX 文件以及如何完成这个操作。
练习 2.03:导入角色 FBX 文件
这个练习将专注于从 FBX 文件中导入 3D 模型。FBX 文件被广泛用于导出和导入 3D 模型,以及它们的材质、动画和纹理。
以下步骤将帮助您完成这个练习:
- 从 GitHub 的
Chapter02
->Exercise2.03
->ExerciseFiles
目录中下载SK_Mannequin.FBX
,ThirdPersonIdle.FBX
,ThirdPersonRun.FBX
和ThirdPersonWalk.FBX
文件。
注意
ExerciseFiles
目录可以在 GitHub 的以下链接找到:packt.live/2IiqTzq
。
-
打开我们在练习 2.01中创建的空白项目,创建一个空的 C++项目。
-
在项目的
内容浏览器
界面中,点击导入
:
图 2.10:内容浏览器导入按钮
-
浏览到我们在步骤 1中下载的文件目录,选择
SK_Mannequin.FBX
,然后点击打开
按钮。 -
确保
导入动画
按钮是全部导入
按钮。您可能会收到一个警告,指出没有平滑组
。您现在可以忽略这个警告。这样,您就成功地从 FBX 文件中导入了一个骨骼网格。现在,我们需要导入它的动画。 -
再次点击
导入
按钮,浏览到我们在步骤 1中创建的文件夹,并选择ThirdPersonIdle.fbx
,ThirdPersonRun.fbx
和ThirdPersonWalk.fbx
。然后点击打开
按钮。 -
确保骨架设置为您在步骤 5中导入的骨架,然后点击
全部导入
:
图 2.11:动画 FBX 导入选项
-
现在,您可以在
内容浏览器
中看到三个动画(ThirdPersonIdle
,ThirdPersonRun
和ThirdPersonWalk
)。 -
如果您双击
ThirdPersonIdle
,您会注意到左臂下垂。这意味着存在重定向问题。当动画与骨架分开导入时,虚幻引擎会将所有骨骼从动画映射到骨架,但有时会导致故障。我们现在要解决这个故障。
图 2.12:ThirdPersonIdle UE4 人体模型动画故障
- 打开
SK_Mannequin
骨骼网格,并打开骨架树
选项卡(如果之前没有打开)。
图 2.13:SK_Mannequin 骨架树选项卡选择
- 在
选项
下启用显示重定向选项
复选框。
图 2.14:启用重定向选项
-
现在在骨架树中,减少
spine_01
,thigh_l
和thigh_r
骨骼,以便更好地可见。 -
现在选择
spine_01
,thigh_l
和thigh_r
骨骼。在它们上面右键单击,然后在菜单中点击递归设置平移重定向骨架
按钮。这将修复我们之前遇到的骨骼平移问题。 -
重新打开
ThirdPersonIdle
动画
,以验证悬臂是否已经修复。
图 2.15:修复的 ThirdPersonIdle 动画
注意
您可以在 GitHub 的Chapter02
-> Exercise2.03
-> Ex2.03-Completed.rar
目录中找到完整的练习代码文件,链接如下:packt.live/2U8AScR
解压.rar
文件后,双击.uproject
文件。您会看到一个提示,询问是否要立即重建?
。点击该提示上的是
,这样它就可以构建必要的中间文件,然后应该自动在虚幻编辑器中打开项目。
通过完成这个练习,您已经了解了如何导入资产,更具体地说,导入了一个 FBX 骨骼网格和动画数据到您的项目中。对于许多游戏开发者的工作流程来说,这是至关重要的,因为资产是整个游戏的构建模块。
在下一节中,我们将看一下用于创建游戏的虚幻核心类,它们对于创建游戏或体验有多重要,以及如何在项目中使用它们。
虚幻游戏模式类
考虑这样一种情况,您希望能够暂停游戏。所有必要的逻辑和实现,以便能够暂停游戏的类将被放置在一个单独的类中。这个类将负责处理玩家进入游戏时的游戏流程。游戏流程可以是游戏中发生的任何动作或一系列动作。例如,游戏暂停、播放和重新开始被认为是简单的游戏流程动作。同样,在多人游戏的情况下,我们需要将所有与网络相关的游戏逻辑放在一起。这正是游戏模式类的作用。
游戏模式是一个驱动游戏逻辑并对玩家施加游戏相关规则的类。它基本上包含有关当前正在进行的游戏的信息,包括游戏变量和事件,这些将在本章后面提到。游戏模式可以容纳所有游戏对象的管理器,它是一个单例类,并且可以被游戏中的任何对象或抽象类直接访问。
与所有其他类一样,游戏模式类可以在蓝图或 C++中进行扩展。这可以用来包括可能需要的额外功能和逻辑,以便让玩家了解游戏内发生的情况。
让我们来看一些放在游戏模式类中的示例游戏逻辑:
-
限制允许进入游戏的玩家数量
-
控制新连接玩家的生成位置和玩家控制器逻辑
-
跟踪游戏得分
-
跟踪游戏胜利/失败条件。
-
实现游戏结束/重新开始游戏场景
在下一节中,我们将查看游戏模式提供的默认类。
游戏模式默认类
除了自身之外,游戏模式使用了几个类来实现游戏逻辑。它允许您为其以下默认值指定类:
-
游戏会话类:处理管理员级别的游戏流程,如登录批准。
-
游戏状态类:处理游戏状态,以便客户端可以看到游戏内发生的情况。
-
玩家控制器类:用于控制和操纵角色的主要类。可以被视为决定要做什么的大脑。
-
玩家状态类:保存玩家在游戏中的当前状态。
-
HUD 类:处理显示给玩家的用户界面。
-
默认 Pawn 类:玩家控制的主要角色。这本质上是玩家角色。
-
DefaultPawn
类,旁观者 Pawn 类指定了负责旁观游戏的 Pawn。 -
重播旁观玩家控制器:负责在游戏内回放期间操纵回放的玩家控制器。
-
服务器状态复制器类:负责复制服务器状态网络数据。
您可以使用默认类,也可以为自定义实现和行为指定自己的类。这些类将与游戏模式一起工作,并且将自动运行,而无需放置在世界中。
游戏事件
在多人游戏方面,当许多玩家进入游戏时,处理逻辑以允许他们进入游戏,维护其状态,并允许他们查看其他玩家的状态并处理其交互变得至关重要。
游戏模式为您提供了几个可以重写以处理多人游戏逻辑的事件。以下事件对于网络功能和能力(它们主要用于此目的)特别有用:
-
“在登录后”:此事件在玩家成功登录游戏后调用。从这一点开始,可以在玩家控制器类上调用复制逻辑(用于多人游戏中的网络)。
-
“处理新玩家的开始”:此事件在“在登录后”事件之后调用,可用于定义新进入玩家的情况。默认情况下,它为新连接的玩家创建一个角色。
-
“在指定位置生成默认角色”:此事件触发游戏中实际的角色生成。新连接的玩家可以在特定的变换位置或放置在关卡中的预设玩家起始位置生成(可以通过将玩家起始位置从模型窗口拖放到世界中来添加)。
-
“在注销时”:当玩家离开游戏或被销毁时调用此事件。
-
在重新开始玩家时:调用此事件以重新生成玩家。与“在指定位置生成默认角色”类似,玩家可以在特定的变换位置或预先指定的位置(使用玩家起始位置)重新生成。
网络
游戏模式类不会被复制到任何客户端或加入的玩家。它的范围仅限于生成它的服务器。本质上,客户端-服务器模型规定客户端只能作为服务器上进行游戏的输入。因此,游戏逻辑不应存在于客户端,而应仅存在于服务器。
GameModeBase 与 GameMode
从 4.14 版本开始,Epic 引入了AGameModeBase
类,它充当所有游戏模式类的父类。它本质上是AGameMode
类的简化版本。
然而,游戏模式类包含一些更适合多人射击类型游戏的附加功能,因为它实现了比赛状态的概念。默认情况下,“游戏模式基类”包含在基于模板的新项目中。
游戏模式还包含一个状态机,用于处理并跟踪玩家的状态。
关卡
在游戏中,关卡是游戏的一个部分或部分。由于许多游戏非常庞大,它们被分解为不同的关卡。加载感兴趣的关卡供玩家玩耍,然后当他们完成后,可能会加载另一个关卡(同时当前的关卡将被加载出)以便玩家可以继续。要完成游戏,玩家通常需要完成一组特定任务以进入下一关,最终完成游戏。
游戏模式可以直接应用于关卡。加载关卡时,将使用分配的游戏模式类来处理该特定关卡的所有逻辑和游戏玩法,并覆盖项目的游戏模式。可以在打开关卡后使用“世界设置”选项卡进行应用。
关卡蓝图是一个与关卡一起运行的蓝图,但不能在关卡范围之外访问。游戏模式可以在任何蓝图(包括关卡蓝图)中通过“获取游戏模式”节点访问。稍后可以将其转换为您的游戏模式类,以获取对其的引用。
注意
一个关卡只能分配一个游戏模式类。但是,可以将单个游戏模式类分配给多个关卡,以模仿类似的功能和逻辑。
虚幻角色类
Pawn
类,在虚幻引擎中,是可以被玩家或 AI 控制的最基本的角色类。它也在游戏中图形化地代表玩家/机器人。这个类中的代码应该涉及游戏实体的所有内容,包括交互、移动和能力逻辑。玩家在游戏中仍然只能控制一个角色。此外,玩家可以在游戏过程中取消控制一个角色并控制另一个角色。
默认角色
虚幻引擎提供了一个DefaultPawn
类(继承自基本的Pawn
类)。在Pawn
类的基础上,这个类包含了额外的代码,使其能够在世界中移动,就像在游戏的编辑版本中一样。
观战角色
一些游戏提供了观战游戏的功能。比如说,你正在等待朋友完成他们的游戏,然后加入你,所以你可以先观战他们的游戏。这使你能够观察玩家正在玩的游戏,通过一个可以移动的摄像头来观察玩家或游戏。一些游戏还提供了观战模式,可以回到过去,展示游戏中发生的特定动作或游戏中的任何时间点。
顾名思义,这是一种特殊类型的角色,提供了观战游戏的示例功能。它包含了所有基本工具(如观战角色移动组件)来实现这一点。
虚幻引擎玩家控制器类
玩家控制器类可以被视为玩家。它本质上是角色的灵魂。玩家控制器接收用户输入,并将其传递给角色和其他类,以便玩家与游戏进行交互。然而,在处理这个类时,您必须注意以下几点:
-
与角色不同,一个关卡中只能有一个玩家控制器代表玩家。(就像当你乘坐电梯时。在电梯内,你只能控制那部电梯,但你可以离开它并进入另一部电梯来控制它。)
-
玩家控制器在整个游戏中持续存在,但角色可能不会(例如,在战斗游戏中,玩家角色可能会死亡并重生,但玩家控制器仍然保持不变)。
-
由于角色的临时性和玩家控制器的永久性,开发人员需要考虑应该将哪些代码添加到哪个类中。
让我们通过下一个练习更好地理解这一点。
练习 2.04:设置游戏模式、玩家控制器和角色
这个练习将使用我们在练习 2.01中创建的空项目。我们将向游戏中添加我们的游戏模式、玩家控制器和Pawn
类,并测试我们的代码是否在蓝图中工作。
以下步骤将帮助您完成这个练习:
-
打开我们在练习 2.01中创建的项目,创建一个空的 C++项目。
-
在
内容浏览器
中右键单击,然后选择蓝图类
。 -
在
所有类
部分,找到并选择游戏模式
类:
图 2.16:选择游戏模式类
-
将其命名为
BP_MyGameMode
。 -
重复步骤 2-4,并在
常见类
部分选择Pawn
类,如前面的屏幕截图所示。将此类的名称设置为BP_MyPawn
。 -
重复步骤 2-4,并在
常见类
部分选择玩家控制器
类,如前面的屏幕截图所示。将此类的名称设置为BP_MyPC
:
图 2.17:游戏模式、角色和玩家控制器名称
- 打开
BP_MyGameMode
,并打开事件图
标签:
图 2.18:蓝图中的事件图标签
- 左键单击并从
Event BeginPlay
节点中的白色引脚拖动,然后释放左鼠标按钮以获得选项
菜单。键入print
并在列表中选择突出显示的print
节点:
图 2.19:打印字符串节点(蓝图)
-
在
In String
参数下放置的结果Print String
节点中,键入My Game Mode has started!
。 -
现在,按顶部菜单栏上的
编译
和保存
按钮。 -
重复步骤 7-10,分别为
BP_MyPawn
和BP_MyPC
类设置In String
参数为My Pawn has started!
和My PC has started!
。 -
最后,打开
World Settings
选项卡,在Game Mode
部分,使用下拉菜单将GameMode Override
,Default Pawn Class
和Player Controller Class
选项设置为我们各自的类:
图 2.20:世界设置和游戏模式设置
- 单击
播放
以播放游戏,并在顶部看到三个打印语句。这意味着当前的GameMode Override
,Default Pawn Class
和Player Controller Class
选项已设置为您指定的类,并正在运行它们的代码:
图 2.21:输出打印
注意
您可以在 GitHub 的Chapter02
-> Exercise2.04
-> Ex2.04-Completed.rar
目录中找到已完成的练习代码文件,链接如下:packt.live/3k7nS1K
提取.rar
文件后,双击.uproject
文件。您将看到一个提示,询问是否要立即重建?
。点击该提示上的是
,以便它可以构建必要的中间文件,之后应该会自动在虚幻编辑器中打开项目。
现在您已经了解了虚幻中的基本类以及它们的工作原理,在下一节中,我们将看一下动画,涉及到哪些过程,以及它们是如何完成的。接下来我们将进行一次练习。
动画
动画对于为游戏增添生动和丰富是至关重要的。出色的动画是区分普通游戏和优秀游戏的主要因素之一。视觉保真度是保持玩家对游戏兴奋和沉浸的关键,因此动画是虚幻引擎中创建的所有游戏和体验的核心部分。
注意
本章旨在介绍动画基础知识。对动画的更深入探讨将在第十三章,混合空间 1D,按键绑定和状态机中进行。
动画蓝图
动画蓝图是一种特定类型的蓝图,允许您控制骨骼网格的动画。它为用户提供了一个专门用于动画相关任务的图表。在这里,您可以定义计算骨架姿势的逻辑。
注意
骨骼网格是一种基于骨骼的网格,具有骨骼,所有这些骨骼汇集在一起形成网格,而静态网格(顾名思义)是一种不可动画的网格。骨骼网格通常用于角色和逼真的对象(例如玩家英雄),而静态网格用于基本或无生命的对象(例如墙壁)。
动画蓝图提供两种类型的图表:EventGraph
和AnimGraph
。
事件图
动画蓝图中的事件图提供了与动画相关的设置事件,正如我们在第一章,虚幻引擎介绍中学到的,可以用于变量操作和逻辑。事件图主要用于在动画蓝图中更新混合空间值,从而驱动AnimGraph
中的动画。这里主要使用的常见事件如下:
-
蓝图初始化动画:用于初始化动画。
-
蓝图更新动画:此事件在每一帧执行,使开发人员能够根据需要执行计算并更新其值:
图 2.22:动画事件图
在上述截图中,您可以看到默认的事件图。这里有事件蓝图更新动画
和尝试获取所有者
节点。您创建了新节点并将它们附加到图中,以完成练习 2.04中的一些有意义的任务,设置游戏模式、玩家控制器和模型。
动画图
动画图专门负责播放动画,并在每帧基础上输出骨架的最终姿势。它为开发人员提供了执行不同逻辑的特殊节点。例如,混合节点接受多个输入,并用于决定当前在执行中使用哪个输入。这个决定通常取决于一些外部输入(如 alpha 值)。
动画图通过评估节点,按照节点上的执行引脚之间的执行流程来工作。
在下面的截图中,您可以看到图上有一个单独的输出姿势
节点。这是动画的最终姿势输出,将在游戏中的相关骨骼网格上可见。我们将在练习 2.05中使用这个:
图 2.23:动画 AnimGraph
状态机
您已经学会了如何设置动画节点和逻辑,但缺少一个重要组件。谁决定何时播放或执行特定的动画或逻辑?这就是状态机的作用。例如,玩家可能需要从蹲姿转换到站立姿势,因此需要更新动画。代码将调用动画蓝图,访问状态机,并让它知道动画的状态需要改变,从而实现平滑的动画过渡。
状态机由状态和规则组成,可以被认为是描述动画状态的状态。状态机在特定时间总是处于一个状态。当满足某些条件(由规则定义)时,就会从一个状态转换到另一个状态。
过渡规则
每个过渡规则都包含一个名为Result
的布尔节点。如果布尔值为 true,则可以发生过渡,反之亦然:
图 2.24:过渡规则
混合空间
当您提供一堆动画时,您可以创建一个状态机并运行这些动画。然而,当您需要从一个动画过渡到另一个动画时,会出现问题。如果您简单地切换动画,它会出现故障,因为新动画的起始姿势可能与旧动画的结束姿势不同。
混合空间是用于根据它们的 alpha 值在不同动画之间进行插值的特殊资产。这反过来消除了故障问题,并在两个动画之间进行插值,导致动画的快速和平滑变化。
混合空间可以在一维中创建,称为混合空间 1D,或者在二维中创建,称为混合空间。这些根据一个或两个输入混合任意数量的动画。
练习 2.05:创建模特动画
现在您已经了解了大部分与动画相关的概念,我们将通过为默认模特添加一些动画逻辑来进行实际操作。我们将创建一个混合空间 1D、一个状态机和动画逻辑。
我们的目标是创建角色的奔跑动画,从而深入了解动画的工作原理,以及它们如何与 3D 世界中的实际角色绑定。
以下步骤将帮助您完成此练习:
- 下载并提取
Chapter02
->Exercise2.05
->ExerciseFiles
目录中的所有内容,这些内容可以在 GitHub 上找到。您可以将其提取到您在计算机上使用的任何目录中。
注意
ExerciseFiles
目录可以在 GitHub 上找到,链接如下:packt.live/32tIFGJ
。
-
双击
CharAnim.uproject
文件以启动项目。 -
按“播放”。使用键盘的W、A、S、D键进行移动,使用空格键进行跳跃。请注意,目前模特身上没有动画。
-
在“内容”文件夹中,浏览到“内容” -> “模特” -> “动画”。
-
右键单击“内容”文件夹,从“动画”部分选择“混合空间 1D”。
-
选择
UE4_Mannequin_Skeleton
。 -
将新创建的文件重命名为
BS_IdleRun
。 -
双击
BS_IdleRun
以打开它。 -
在“资产详细信息”选项卡中,在“轴设置”部分,展开“水平轴”部分,将“名称”设置为“速度”,将“最大轴值”设置为
375.0
:
图 2.25:混合空间 1D 轴设置
-
转到“样本插值”部分,并将“每秒目标权重插值速度”设置为
5.0
。 -
将
ThirdPersonIdle
、ThirdPersonWalk
和ThirdPersonRun
动画分别拖放到图表中:
图 2.26:混合空间预览器
- 在“资产详细信息”选项卡中,在“混合样本”中,设置以下变量值:
图 2.27:混合样本
-
单击“保存”并关闭此“资产”。
-
在“内容”文件夹内右键单击,从“动画”部分选择“动画蓝图”。
-
在“目标骨骼”部分,选择
UE4_Mannequin_Skeleton
,然后单击“确定”按钮:
图 2.28:创建动画蓝图资产
-
将文件命名为
Anim_Mannequin
,然后按Enter。 -
双击新创建的
Anim_Mannequin
文件。 -
接下来,转到“事件图”选项卡。
-
通过在左下角的变量部分单击
+
图标创建一个名为IsInAir?
的布尔变量。确保分配正确的类型:
图 2.29:添加变量
-
创建一个名为
Speed
的浮点变量。 -
拖动“尝试获取所有者”返回值节点,并输入“IsValid”。选择底部的一个:
图 2.30:事件图 IsValid 节点
- 将“事件蓝图更新动画”节点的
Exec
引脚连接到“IsValid”节点:
图 2.31:连接节点
-
从“尝试获取所有者”节点,使用“获取移动组件”节点。
-
从步骤 22中获得的节点中,获取
Is Falling
节点,并将布尔返回值连接到Is in Air?
布尔的“设置”节点。将SET
节点的执行引脚与“IsValid”执行引脚连接:
图 2.32:Is in Air 布尔设置
- 从“尝试获取所有者”节点,使用“获取速度”节点,获取其
VectorLength
,并将输出连接到Speed
的“变量设置”节点:
图 2.33:速度布尔设置
-
接下来,转到“动画图”选项卡。
-
在 AnimGraph内的任何位置右键单击,输入“状态机”,然后单击“添加新状态机”:
图 2.34:添加新状态机选项
-
确保选择节点,然后按F2进行重命名为
MannequinStateMachine
。 -
将
MannequinStateMachine
的输出引脚连接到“输出姿势”节点的输入引脚,并单击顶部栏上的编译按钮:
图 2.35:配置状态机结果输出姿势节点
-
双击
MannequinstateMachine
节点以进入状态机。您将看到一个Entry
节点。将连接到它的状态将成为模特的默认状态。在本练习中,这将是我们的“Idle 动画”。 -
在状态机内的空白区域上右键单击,然后从菜单中选择“添加状态”。按下F2将其重命名为
Idle/Run
。 -
从
Entry
文本旁边的图标拖动,将其指向Idle/Run
节点内部,然后释放以连接它:
图 2.36:将添加的状态连接到 Entry
-
双击
Idle/Run
状态以打开它。 -
从右下角的“资产浏览器”菜单中,选择并拖动
BS_IdleRun
动画到图表中。从左侧的“变量”部分获取Speed
变量并连接它,如图所示:
图 2.37:Idle/Run 状态设置
- 通过单击顶部横幅中的面包屑
MannequinStateMachine
返回到MannequinStateMachine
:
图 2.38:状态机导航面包屑
-
从“资产浏览器”菜单中,将
ThirdPersonJump_Start
动画拖放到图表中。将其重命名为Jump_Start
。 -
对
ThirdPersonJump_Loop
和ThirdPerson_Jump
重复步骤 35,并将它们分别重命名为Jump_Loop
和Jump_End
:
图 2.39:状态设置
-
打开
Jump_Start
状态。单击Play ThirdPersonJump_Start
节点。在“设置”部分取消选中“循环动画”。 -
打开
Jump_Loop
状态,单击Play ThirdPersonJump_Loop
节点。将Play Rate
设置为0.75
。 -
打开
Jump_End
状态,单击Play ThirdPerson_Jump
节点。取消选中“循环动画”布尔值。 -
由于我们可以从
Idle/Run
转换到Jump_Start
,因此从Idle/Run
状态拖动并将其放到Jump_Start
状态。同样,Jump_Start
导致Jump_Loop
,然后到Jump_End
,最后回到Idle/Run
。
拖放箭头以设置状态机,如下所示:
图 2.40:状态连接
- 双击
Idle/Run
到Jump_Start
转换规则图标,并将Is in Air?
变量的输出连接到结果:
图 2.41:Idle/Run 到 Jump_Start 转换规则设置
- 打开
Jump_Start
到Jump_Loop
转换规则。获取ThirdPersonJump_Start
的“剩余时间(比率)”节点,并检查其是否小于0.1
。将结果布尔值连接到结果:
图 2.42:Jump_Start 到 Jump_End 转换规则设置
- 打开
Jump_Loop
到Jump_End
转换规则。将Is in Air?
的反向输出连接到结果:
图 2.43:Jump_Loop 到 Jump_End 转换规则设置
- 打开
Jump_End
到Idle/Run
转换规则。获取ThirdPerson_Jump
的“剩余时间(比率)”节点,并检查其是否小于0.1
。将结果布尔值连接到结果:
图 2.44:Jump_End 到 Idle/Run 转换规则设置
-
关闭动画蓝图。
-
在“内容”文件夹中,浏览到“内容”->
ThirdPersonBP
->“蓝图文件夹”,并打开ThirdPersonCharacter
蓝图。 -
在“组件”选项卡中选择
Mesh
:
图 2.45:网格组件
- 在“详细信息”选项卡中,将
Anim Class
设置为您创建的Animation Blueprint
类:
图 2.46:在骨骼网格组件中指定动画蓝图
-
关闭蓝图。
-
再次玩游戏,注意动画。
以下应该是你实现的输出。正如你所看到的,我们的角色正在奔跑,奔跑动画正在显示:
图 2.47:角色奔跑动画
注意
你可以在 GitHub 上找到完整的练习代码文件,在Chapter02
->Exercise2.05
->Ex2.05-Completed.rar
目录下,链接如下:packt.live/3kdIlSL
解压缩.rar
文件后,双击.uproject
文件。你会看到一个提示,询问“是否要立即重建?”。点击该提示上的“是”,这样它就可以构建必要的中间文件,然后自动在虚幻编辑器中打开项目。
通过完成这个练习,你已经了解了如何创建状态机、Blend Space 1D、动画蓝图,以及如何将它们与角色的骨骼网格结合起来。你还处理了播放速率、过渡速度和过渡状态,帮助你理解动画世界是如何紧密联系在一起的。
我们通过理解状态机如何表示和过渡动画状态来开始这一部分。接下来,我们了解了 Blend Space 1D 如何在这些过渡中进行混合。所有这些都由动画蓝图使用,以决定角色当前的动画是什么。现在,让我们在一个活动中将所有这些概念结合起来。
活动 2.01:将动画链接到角色
假设作为虚幻游戏开发者,你已经获得了一个角色骨骼网格和它的动画,并且被要求将它们整合到一个项目中。为了做到这一点,在这个活动中,你将创建一个新角色的动画蓝图、状态机和 Blend Space 1D。通过完成这个活动,你应该能够在虚幻引擎中处理动画,并将它们链接到骨骼网格。
活动项目文件夹包含一个第三人称模板项目,以及一个新角色Ganfault
。
注意
这个角色及其动画是从mixamo.com下载的。这些已经放在我们的 GitHub 存储库的“内容”->Ganfault
文件夹中:packt.live/35eCGrk
Mixamo.com是一个销售带有动画的 3D 角色的网站,类似于一个专门用于 3D 模型的资产市场。它还包含一个免费模型库,以及付费模型。
以下步骤将帮助你完成这个活动:
-
创建一个用于行走/奔跑动画的 Blend Space 1D,并设置动画蓝图。
-
接下来,转到“内容”->
ThirdPersonBP
->“蓝图”,打开ThirdPersonCharacter
蓝图。 -
点击左侧的骨骼网格组件,在右侧的“详细信息”选项卡中,用
Ganfault
替换SkeletalMesh
引用。 -
同样地,更新骨骼网格组件的“动画蓝图”部分,使用你为
Ganfault
创建的动画蓝图。
注意
对于状态机,只实现空闲/奔跑和跳跃状态。
完成这个活动后,行走/奔跑和跳跃动画应该正常工作,如下所示:
图 2.48:活动 2.01 预期输出(左:奔跑;右:跳跃)
注意
此活动的解决方案可以在以下链接找到:packt.live/338jEBx
。
通过完成这个活动,你现在知道如何在虚幻引擎中导航项目、调试代码和处理动画。你还了解了状态机,它代表了动画状态和过渡之间的转换,以及在该过渡中使用的 Blend Spaces 1D。你现在能够根据游戏事件和输入为 3D 模型添加动画。
摘要
总结本章,我们首先学习了如何创建一个空项目。然后,我们了解了文件夹结构以及如何在项目目录中组织文件。之后,我们看了基于模板的项目。我们还学会了如何在代码中设置断点,以便在游戏运行时观察变量值并调试整个对象,这将帮助我们找到并消除代码中的错误。
此外,我们还了解了游戏模式、玩家角色和玩家控制器是虚幻引擎中用于设置游戏流程(代码执行顺序)的相关类,以及它们在项目中的设置方式。
最后,我们转向动画基础知识,并使用状态机、混合空间 1D 和动画蓝图,根据键盘输入使我们的角色在游戏中执行动画(行走/奔跑和跳跃)。
在整个本章中,我们更加熟悉了虚幻引擎中强大的工具,这些工具对游戏开发至关重要。虚幻的游戏模式及其默认类对于在虚幻引擎中制作任何类型的游戏或体验都是必需的。此外,动画为角色赋予生命,并帮助增加游戏内的沉浸感。所有游戏工作室都有动画、角色和游戏逻辑,因为这些是推动任何游戏的核心组件。这些技能将在你的游戏开发之旅中帮助你很多次。
在下一章中,我们将讨论虚幻引擎中的Character
类,它的组件以及如何扩展该类进行额外的设置。你将进行各种练习,然后进行一项活动。
第三章:角色类组件和蓝图设置
概述
本章将重点讨论 C++中的Character
类。您将学习如何在 C++中扩展Character
类,然后通过继承在蓝图中进一步扩展这个新创建的Character
类。您还将处理玩家输入和一些移动逻辑。
在本章结束时,您将能够理解 UE4 中的类继承是如何工作的,以及如何利用它来获得优势。您还将能够使用轴和动作输入映射,这在驱动与玩家相关的输入逻辑中非常关键。
介绍
在上一章中,我们学习了如何创建空项目和导入文件,使用哪种文件夹结构,以及如何处理动画。在本章中,我们将探索一些其他关键工具和功能,这些工具和功能在使用虚幻引擎时会用到。
游戏开发人员经常需要使用一些工具,这些工具可以节省他们构建游戏功能时的时间和精力。虚幻引擎强大的对象继承能力为开发人员提供了更高效的优势。开发人员还可以交替使用 C++和蓝图,并在开发游戏时充分利用它们。
开发人员获得的另一个增值好处是能够扩展代码以供以后在项目中使用。假设您的客户有新的要求,这些要求建立在旧的要求之上(这在大多数游戏工作室都是这样的情况)。现在,为了扩展功能,开发人员只需继承一个类并向其添加更多功能,以快速获得结果。这是非常强大的,在许多情况下都很方便。
在本章中,我们将讨论虚幻Character
类,创建 C++代码,然后在蓝图中扩展它,最后使用它来创建游戏角色。
虚幻角色类
在我们谈论虚幻Character
类之前,让我们简要地谈一下继承的概念。如果您习惯使用 C++或其他类似的语言,您应该已经熟悉这个概念。继承是一个类从另一个类中继承特性和行为的过程。一个 C++类可以被扩展以创建一个新的类 - 派生类 - 它保留了基类的属性,并允许修改这些属性,或者添加新的特性。Character
类就是一个例子。
Character
类是一种特殊类型的 pawn,是虚幻Pawn
类的后代。在扩展Pawn
类的基础上,Character
类默认具有一些移动能力,以及一些输入,可以为角色添加移动。作为标准,Character
类使用户能够让角色在创建的世界中行走、奔跑、跳跃、飞行和游泳。
由于Character
类是Pawn
类的扩展,它包含了所有的 pawn 的代码/逻辑,开发人员可以扩展这个类以添加更多功能。当扩展Character
类时,它的现有组件会作为继承组件传递到扩展类中。(在这种情况下,Capsule 组件、Arrow 组件和 Mesh)。
注意
继承组件无法被移除。它们的设置可以被改变,但添加到基类的组件将始终存在于扩展类中。在这种情况下,基类是Pawn
类,而扩展(或子)类是Character
类。
Character
类提供以下继承组件:
-
Capsule 组件:这是作为“原点”的根组件,其他组件在层次结构中附加到它上面。这个组件也可以用于碰撞,并且以胶囊的形式逻辑地勾勒出许多角色形式(特别是人形角色)。
-
隐藏
当游戏开始时,但可以调整为可见。如果需要,此组件可用于调试和调整游戏逻辑。 -
Character
类。在这里可以设置角色将采取的形式的骨骼网格,以及所有相关变量,包括动画、碰撞等。
大多数开发人员通常更喜欢在 C++中编写游戏和角色逻辑,并将该类扩展到蓝图,以便他们可以执行其他简单的任务,比如将资产连接到类。例如,开发人员可以创建一个从Character
类继承的 C++类,在该类中编写所有移动和跳跃逻辑,然后使用蓝图扩展此类,在其中开发人员可以使用所需的资产(如骨骼网格和动画蓝图)更新组件,并可选择在蓝图中编写其他功能。
扩展 Character 类
当 C++或蓝图继承时,Character
类会被扩展。这个扩展的Character
类将成为Character
类的子类(也称为其父类)。类扩展是面向对象编程的一个强大部分,类可以被扩展到很深的层次和层次结构。
练习 3.01:创建和设置第三人称角色 C++类
在此练习中,您将创建一个基于Character
类的 C++类。您还将初始化将在扩展此Character
类的类的默认值中设置的变量。
以下步骤将帮助您完成此练习:
-
启动 Unreal Engine,选择
Games
类别,然后单击Next
按钮。 -
选择
Blank
,然后单击Next
按钮。 -
选择
C++
作为项目类型,将项目名称设置为MyThirdPerson
,选择适当的项目目录,然后单击Create Project
按钮。 -
右键单击
Content Browser
界面,然后单击New C++ Class
按钮: -
在打开的对话框中,选择
Character
作为类类型,然后单击Next
按钮。 -
将其命名为
MyThirdPersonChar
,然后点击Create Class
按钮。 -
这样做后,Visual Studio 将打开
MyThirdPersonChar.cpp
和MyThirdPersonChar.h
选项卡。
注意
在某些系统上,可能需要以管理员权限运行 Unreal Engine 编辑器,以自动打开新创建的 C++文件的 Visual Studio 解决方案。
- 打开
MyThirdPersonChar.h
选项卡,在GENERATED_BODY()
文本下添加以下代码:
// Spring arm component which will act as a placeholder for the player camera
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = MyTPS_Cam, meta = (AllowPrivateAccess = "true"))
class USpringArmComponent* CameraBoom;
// Follow camera
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = MyTPS_Cam, meta = (AllowPrivateAccess = "true"))
class UCameraComponent* FollowCamera;
在上述代码中,我们声明了两个组件:Camera
本身和Camera boom
,它充当了玩家与摄像机之间的某个距离的占位符。这些组件将在步骤 11中在构造函数中初始化。
- 在
MyThirdPersonChar.h
文件的#include "CoreMinimal.h"
下的包含部分中添加以下内容:
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
- 现在,转到
MyThirdPersonChar.cpp
选项卡,在#include MyThirdPersonChar.h
代码后添加以下包含:
#include "Components/CapsuleComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
在上述代码片段中,代码将相关类添加到类中,这意味着我们现在可以访问其方法和定义。
- 在
AMyThirdPersonChar::AMyThirdPersonChar()
函数中,添加以下行:
// Set size for collision capsule
GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f);
// Don't rotate when the controller rotates. Let that just affect the camera.
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false;
// Configure character movement
GetCharacterMovement()->bOrientRotationToMovement = true;
// Create a camera boom (pulls in towards the player if there is a collision)
CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
CameraBoom->SetupAttachment(RootComponent);
CameraBoom->TargetArmLength = 300.0f;
CameraBoom->bUsePawnControlRotation = true;
// Create a camera that will follow the character
FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
FollowCamera->bUsePawnControlRotation = false;
上述代码片段的最后一行将设置摄像机与角色的旋转绑定。这意味着摄像机应该随着与该角色关联的玩家控制器的旋转而旋转。
- 返回 Unreal Engine 项目,点击顶部栏的
Compile
按钮:
图 3.1:Unreal Editor 顶部栏上的编译按钮
在右下角应该出现Compile Complete!
消息。
注意
您可以在 GitHub 上的Chapter03
-> Exercise3.01
目录中找到已完成的练习代码文件,链接如下:packt.live/3khFrMt
。
解压.rar
文件后,双击.uproject
文件。你会看到一个提示,询问是否要立即重建?
。点击该提示上的是
,这样它就可以构建必要的中间文件,然后应该会自动在 Unreal Editor 中打开项目。
通过完成这个练习,你学会了如何扩展Character
类。你还学会了如何初始化Character
类的默认组件,并且学会了如何在 Unreal Editor 中编译更新的代码。接下来,你将学会如何扩展你在蓝图中创建的 C++类,以及在许多情况下为什么这是可行的。
用蓝图扩展 C++类
如前所述,大多数开发人员将 C++代码逻辑扩展到蓝图中,以便将其与他们将使用的资产联系起来。这样做是为了实现比在代码中查找和设置资产更容易的资产分配。此外,它还使开发人员能够利用强大的蓝图功能,如时间轴、事件和即用型宏,与他们的 C++代码结合使用,以实现在 C++和蓝图一起开发的最大效益。
到目前为止,我们已经创建了一个 C++ Character
类。在其中,我们设置了组件和移动能力。现在,我们想要指定将在我们的类中使用的资产,并添加输入和移动能力。为此,最好是用蓝图进行扩展并在那里设置选项。这就是我们将在接下来的练习中要做的事情。
练习 3.02:用蓝图扩展 C++
在这个练习中,你将学会如何扩展你用蓝图创建的 C++类,以在现有的 C++代码之上添加蓝图代码。你还将添加输入按键绑定,这将负责移动角色。
以下步骤将帮助你完成这个练习:
- 下载并提取
Chapter03
|Exercise3.02
|ExerciseFiles
目录中的所有内容,这些内容可以在 GitHub 上找到。
注意
ExerciseFiles
目录可以在以下链接的 GitHub 上找到:packt.live/2GO0dG8
。
-
浏览到我们在练习 3.01中创建的
MyThirdPerson
项目内的Content
文件夹。 -
复制我们在步骤 1中创建的
MixamoAnimPack
文件夹,并将其粘贴到我们在步骤 2中打开的Content
文件夹目录中,如下图所示:
注意
MixamoAnimPack
资产是通过以下链接从 Epic 市场获得的:www.unrealengine.com/marketplace/en-US/product/mixamo-animation-pack
。
图 3.2:MixamoAnimPack 放置在项目目录中
-
打开项目。在
Content Browser
界面内右键单击,然后点击Blueprint Class
。 -
在
搜索
对话框中输入GameMode
,右键单击与名称匹配的类,然后点击选择
按钮。查看下面的截图:
图 3.3:创建 GameMode 类
-
在步骤 6中创建的蓝图命名为
BP_GameMode
。 -
现在,重复步骤 5。
-
在搜索框中,输入
MyThirdPersonChar
,选择该类,然后右键单击选择
按钮。 -
在步骤 9中创建的蓝图命名为
BP_MyTPC
。 -
在
World Settings
选项卡中,点击GameMode Override
旁边的None
选项,然后选择BP_GameMode
:
图 3.4:在世界设置中指定游戏模式
- 将
Default Pawn Class
设置为BP_MyTPC
:
图 3.5:在游戏模式中指定默认角色类
-
打开
BP_MyTPC
,在左侧的Components
选项卡的层次结构中点击Mesh (Inherited)
组件。 -
在“详细信息”选项卡中,找到“网格”部分,并将“骨骼网格”设置为
Maximo_Adam
。
注意
骨骼网格和动画将在第十三章中深入讨论,混合空间 1D,按键绑定和状态机。
- 在“详细信息”选项卡中,找到“动画”部分,并将“动画类”设置为
MixamoAnimBP_Adam_C
。您会注意到当选择时,此类名称会以_C
结尾。这基本上是 UE4 创建的蓝图的实例。在工作项目/构建中,蓝图通常以这种方式结尾,以区分蓝图类和该类的实例。
图 3.6:设置动画类和骨骼网格
-
从顶部菜单中,转到“编辑”下拉菜单,单击“项目设置”。
-
单击“输入”部分,该部分位于“引擎”部分中:
图 3.7:项目设置的输入部分
- 在“绑定”部分,单击“轴映射”旁边的
+
图标并展开该部分。
注意
动作映射是执行单个按键操作的动作,例如跳跃、冲刺或奔跑,而轴映射是分配的浮点值,将根据用户的按键返回浮点值。这在游戏手柄或 VR 手柄的情况下更相关,其中模拟拇指杆起作用。在这种情况下,它将返回拇指杆状态的浮点值,这对于管理玩家移动或相关功能非常重要。
-
将
NewAxisMapping_0
重命名为MoveForward
。 -
在
MoveForward
部分,单击下拉菜单并选择W
。 -
单击
MoveForward
图标旁边的+
图标以添加另一个字段。 -
将新字段设置为
S
。将其比例设置为-1.0
(因为我们希望使用S
键向后移动)。 -
通过重复步骤 18创建另一个轴映射,命名为
MoveRight
,并添加两个字段——A
为比例-1.0,D
为比例 1.0:
图 3.8:移动轴映射
- 打开
BP_MyTPC
并单击“事件图”选项卡:
图 3.9:事件图标签
- 右键单击图表内任意位置,键入
MoveForward
,并选择第一个节点选项:
图 3.10:MoveForward 轴事件
- 右键单击图表内,搜索“获取控制旋转”,并选择第一个节点选项。
注意
由于与玩家相关联的摄像机可以选择不显示角色的偏航、翻滚或俯仰,“获取控制旋转”给予角色完整的瞄准旋转。这在许多计算中很有用。
-
左键单击并从“获取控制旋转”节点的“返回值”处拖动,搜索“断裂旋转器”,并选择它。
-
右键单击图表内,搜索“创建旋转器”,并选择第一个节点选项。
-
将“断裂旋转器”中的
Z
(偏航)节点连接到“创建旋转器”节点的Z
(偏航)节点。
注意
使“旋转器”创建一个具有俯仰、翻滚和偏航值的旋转器,而断裂旋转器将一个旋转器分解为其组件(翻滚、俯仰和偏航)。
-
左键单击并从“创建旋转器”节点的“返回值”处拖动,搜索“获取前向矢量”,并选择它。
-
左键单击并从“获取前向矢量”节点的“返回值”处拖动,搜索“添加移动输入”,并选择它。
-
将
InputAxis MoveForward
节点中的“轴值”节点连接到“添加移动输入”节点中的“比例值”节点。 -
最后,将
InputAxis MoveForward
节点的白色“执行”引脚连接到“添加移动输入”节点。 -
右键单击图表内,搜索
InputAxis MoveRight
,并选择第一个节点选项。 -
左键单击并从“创建旋转器”节点的“返回值”处拖动,搜索“获取右矢量”,并选择它。
-
从
Get Right Vector
节点的Return Value
处进行左键单击并拖动,搜索Add Movement Input
,并选择它。 -
将
InputAxis MoveRight
节点中的Axis Value
引脚连接到我们在上一步中创建的Add Movement Input
节点中的Scale Value
引脚。 -
最后,将
InputAxis MoveRight
节点中的白色执行
引脚连接到我们在步骤 36中添加的Add Movement Input
节点:
图 3.11:移动逻辑
- 现在,转到
视口
选项卡。在这里,您会看到角色的正面没有指向箭头的方向,并且角色在胶囊组件上方。单击Mesh
组件,选择位于视口顶部的对象平移节点。然后,拖动网格上的箭头进行调整,使脚与胶囊组件底部对齐,并且网格旋转以指向箭头:
图 3.12:平移旋转和比例选择器部分
一旦角色在胶囊中对齐,它将显示如下截图:
图 3.13:在胶囊组件内调整网格
-
在
工具栏
菜单中,点击编译
按钮,然后点击保存
。 -
返回到地图选项卡,点击
播放
按钮以查看游戏中的角色。使用W、A、S和D键来移动。
注意
您可以在 GitHub 上的Chapter03
-> Exercise3.02
目录中找到已完成的练习代码文件,链接如下:packt.live/3keGxIU
。
解压.rar
文件后,双击.uproject
文件。您会看到一个提示询问“是否要立即重建?”。点击该提示上的“是”,这样它就可以构建必要的中间文件,之后应该会自动在虚幻编辑器中打开项目。
通过完成这个练习,您现在能够理解如何使用蓝图扩展 C++代码,以及为什么在许多情况下这对开发人员是有利的。您还学会了如何添加输入映射以及它们如何用于驱动与玩家相关的输入逻辑。
在本章的活动中,您将结合本章前面练习中所学到的技能,并扩展您在第 2.01 活动,将动画链接到角色活动中完成的项目。这将使您能够构建自己创建的蓝图,并了解它如何映射到现实世界的场景。
活动 3.01:在动画项目中使用蓝图扩展 C++角色类
现在,您已经创建了一个 C++类并将其与蓝图扩展,是时候将这两个概念结合到一个真实的场景中了。在这个活动中,您的目标是使我们在第 2.01 活动中的角色,Mixamo 角色动画,在键盘上使用空格键跳跃。但是,您需要从头开始在 C++中创建Character
类,然后稍后将其扩展为蓝图以达到最终目标。
以下步骤将帮助您完成此活动:
-
打开Activity 2.01,Mixamo 角色动画的项目。
-
在 C++中创建一个
Character
类,该类将初始化角色变量,包括与玩家相关联的摄像机。 -
将跳跃输入映射到项目设置中的空格键。
-
使用蓝图扩展创建的 C++类,以添加相关资产和跳跃功能。
预期输出:
当您按下空格键时,角色应该能够跳跃。关卡应该使用扩展了 C++ Character
类的蓝图:
图 3.14:Ganfault 跳跃活动预期输出
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
通过完成这项活动,您已经了解了在蓝图中扩展 C++代码以实现功能和逻辑的场景。这种 C++和蓝图的结合是游戏开发者在虚幻引擎中创作精湛和独特游戏的最强大工具。
总结
在本章中,您学习了如何创建一个 C++的Character
类,为其添加初始化代码,然后使用蓝图来扩展它以设置资产并添加额外的代码。
结果遵循了 C++代码以及蓝图代码,并可以在任何有意义的场景中使用。
您还学习了如何设置与W、A、S和D键映射的轴映射,以移动玩家(这是许多游戏中默认的移动映射)。您还学习了如何使角色在游戏中跳跃。
在下一章中,您将深入探讨输入映射以及如何在虚幻编辑器中使用移动预览器。这将帮助您创建具有稳定输入映射到游戏和玩家逻辑的游戏。它还将允许您快速测试您的游戏在移动设备上的外观和感觉,所有这些都在虚幻编辑器中完成。
第四章:玩家输入
概述
本章将解决玩家输入的主题。我们将学习如何将玩家的按键或触摸输入与游戏中的动作(如跳跃或移动)关联起来。
在本章结束时,您将了解“动作映射”和“轴映射”,如何创建和修改它们,如何监听每个映射,如何在按下和释放时执行游戏中的动作,以及如何预览您的游戏,就像您在移动设备上玩一样。
介绍
在上一章中,我们创建了一个从Character
类继承的 C++类,并添加了所有必要的Actor
组件,以便能够从该角色的视角看到游戏,并且能够看到角色本身。然后,我们创建了一个从该 C++类继承的“蓝图”类,以便可以直观地设置所有必要的组件。我们还简要了解了动作和轴映射。
在本章中,我们将更深入地讨论这些主题,并涵盖它们在 C++中的使用。我们将了解玩家输入在 UE4 中的工作原理,引擎如何处理输入事件(按键按下和释放),以及我们如何利用它们来控制游戏中的逻辑。
让我们从了解 UE4 如何将玩家按下的键抽象出来开始本章,以便更容易地通知您这些事件。
注意
在本章中,我们将使用我们在上一章中创建的Character
蓝图的另一个版本,称为BP_MyTPC
。本章的版本将具有默认的 UE4 Mannequin 网格,而不是来自 Mixamo 的网格。
输入动作和轴
玩家输入是区分视频游戏与其他娱乐媒体的因素:它们是互动的事实。要使视频游戏具有互动性,必须考虑玩家的输入。许多游戏通过允许玩家控制虚拟角色来实现这一点,该角色根据玩家按下的键和按钮在虚拟世界中行动,这正是我们将在本章中要做的事情。
如今,大多数游戏开发工具都允许您将按键抽象为动作和轴,这使您可以将名称(例如“跳跃”)与多种不同的玩家输入(按下按钮,轻扫摇杆等)关联起来。动作和轴的区别在于,动作用于二进制输入(可以按下或释放的输入,如键盘上的键),而轴用于标量或连续输入(即可以具有一系列值的输入,如摇杆,可以在“x”和“y”轴上从“-1”到1
)。
例如,如果您正在制作一款赛车游戏,在该游戏中,您越拉动游戏手柄的右扳机按钮,汽车就会加速得越快,那将是一个“轴”,因为它的值可以在 0 到 1 之间变化。但是,如果您希望玩家能够暂停游戏,那将是一个动作,因为它只需要知道玩家是否按下了某个特定的键。
通常,当玩家明确按下“空格键”时,使玩家角色跳跃并不是一个很好的主意,而是在按下“跳跃”动作时使玩家跳跃。然后,可以在其他地方编辑此“跳跃”动作的相关键,以便开发人员和玩家都可以轻松更改导致玩家角色跳跃的键。这就是 UE4 允许您指定玩家输入事件的方式(尽管您也可以监听明确的按键,但这通常不是最佳选择)。
打开您的 UE4 项目并转到“项目设置”窗口。您可以通过单击编辑器左上角的“编辑”,然后选择“项目设置…”,或者单击编辑器工具栏中的“设置”,然后选择“项目设置…”来执行此操作。
此窗口将允许您修改与项目相关的多个设置,涵盖各种类别。如果您在Project Settings
的左边缘向下滚动,您应该会在Engine
类别下找到Input
选项,它将带您到项目的输入设置。点击此选项。
这样做时,您应该会看到窗口右边的输入设置,您将能够访问项目的Action Mappings
和Axis Mappings
等内容:
图 4.1:输入设置窗口中可用的 Action 和 Axis Mappings
Action Mappings
属性允许您在项目中指定动作列表(例如跳跃动作)及其对应的键(例如空格键)。
Axis Mappings
允许您做同样的事情,但适用于没有二进制值(按下或释放)而是具有连续值的键,比如控制器上的摇杆,其值可以在x和y轴上从–1
到1
,或者控制器上的扳机按钮,其值可以在0
到1
之间。
例如,考虑 Xbox One 控制器,可以分解为以下部分:
-
左摇杆
,通常用于控制游戏中的移动 -
Dpad
,可用于控制移动,以及具有各种其他用途 -
右摇杆
,通常用于控制相机和视角 -
Face buttons (X, Y, A, and B)
,根据游戏可以有各种用途,但通常允许玩家在游戏世界中执行动作 -
Bumpers and Triggers (LB, RB, LT, and RT)
,可用于瞄准和射击或加速和刹车等动作
如果您愿意,还可以将二进制键设置为轴;例如,为玩家角色的移动设置游戏手柄摇杆(连续键,其值从–1
到1
)和键盘上的两个二进制键(W和S)。
我们将在本章中看看如何做到这一点。
当我们在第一章 虚幻引擎介绍中生成了Third Person
模板项目时,它已经配置了一些输入,包括W、A、S和D键,以及用于移动的左手柄摇杆
,以及用于跳跃的空格键
和游戏手柄底部面
按钮。
现在让我们在下一个练习中添加新的Action
和Axis Mappings
。
练习 4.01:创建跳跃动作和移动轴
在这个练习中,我们将为跳跃动作添加一个新的Action Mapping
,以及为移动动作添加一对新的Axis Mappings
。
要实现这一点,请按照以下步骤进行:
-
打开
Input Settings
菜单。 -
点击
Action Mappings
属性右侧的+
图标以创建一个新的Action Mapping
:
图 4.2:添加新的 Action Mapping
- 这样做时,您应该会看到一个名为
NewActionMapping_0
的新Action Mapping
,映射到None
键(表示它未映射到任何键):
图 4.3:新 Action Mapping 的默认设置
- 将此映射的名称更改为
Jump
,并将与之关联的键更改为空格键
。
要更改与此动作映射的键,您可以点击当前设置为None
键的下拉属性,输入空格键
,并选择第一个选项:
图 4.4:键下拉菜单(顶部),其中选择了空格键(底部)
- 您可以指定当玩家按住修饰键
Shift
、Ctrl
、Alt
或Cmd
时是否要执行此操作,通过勾选它们各自的适当复选框。您还可以通过单击X
图标将此键从Action Mapping
中移除:
图 4.5:键下拉菜单和指定修饰键的选项以及从这个 Action Mapping 中移除这个键
- 要向
Action Mapping
添加新的键,您可以简单地点击该Action Mapping
名称旁边的+
图标,要完全删除Action Mapping
,您可以点击其旁边的x
图标:
图 4.6:Action Mapping 的名称,旁边是+和 x 图标
现在让我们使用控制器按钮来映射到这个Action Mapping
。
因为大多数游戏手柄的键位非常相似,UE4 使用Gamepad
前缀将它们的大部分键抽象为通用术语。
- 向这个
Action Mapping
添加一个新的键,并将这个新的键设置为Gamepad Face Button Bottom
键。如果您使用的是 Xbox 控制器,这将是A
按钮,如果您使用的是 PlayStation 控制器,这将是X
按钮:
图 4.7:Gamepad Face Button Bottom 键添加到 Jump Action Mapping
现在我们已经设置好了我们的Jump
Action Mapping
,让我们设置我们的Movement Axis Mapping
。
- 点击
Axis Mappings
属性旁边的+
图标,添加一个新的Axis Mapping
。这个新的Axis Mapping
将用于使角色左右移动。将其命名为MoveRight
,并将其分配给Gamepad Left Thumbstick X-Axis
键,以便玩家可以使用左手柄的x轴来使角色左右移动:
图 4.8:MoveRight 轴映射与与之关联的 Gamepad Left Thumbstick X-Axis 键
如果您看到我们分配的键的右侧,您应该看到该键的Scale
属性,该属性将允许您反转轴,使玩家在将拇指杆向右倾斜时向左移动,反之亦然,并增加或减少轴的灵敏度。
为了允许玩家使用键盘上的左右移动键(这些键要么按下要么释放,并不像拇指杆那样具有连续值),我们将不得不添加两个具有反向值的键。
向这个Axis Mapping
添加两个新的键,第一个是D
键,Scale
为1
,第二个是A
键,Scale
为-1
。这将导致玩家按下D
键时角色向右移动,按下A
键时角色向左移动:
图 4.9:MoveRight 轴映射,同时具有 Gamepad 和键盘键
- 在这样做之后,添加另一个名为
MoveForward
的Axis Mapping
,使用Gamepad Left Thumbstick Y-Axis
,W
和S
键,后者的Scale
为-1
。这个轴将用于使角色前后移动:
图 4.10:MoveForward 轴映射
完成了这些步骤后,我们完成了本章的第一个练习,您已经学会了如何在 UE4 中指定Action
和Axis
Mappings
,从而使您可以抽象出哪些键负责哪些游戏内操作。
现在让我们来看看 UE4 如何处理玩家输入并在游戏中进行处理。
处理玩家输入
让我们想象一个情况,玩家按下与“空格键”相关联的Jump动作,使玩家角色跳跃。在玩家按下“空格键”和游戏使玩家角色跳跃之间,有很多事情要连接这两个事件。
让我们看看从一个事件到另一个事件所需的所有步骤:
-
硬件输入:玩家按下“空格键”。UE4 将监听此按键事件。
-
PlayerInput
类:在按键被按下或释放后,这个类将把该按键转换为一个动作或轴。如果有一个与该按键相关联的动作或轴,它将通知所有监听该动作的类,该按键刚刚被按下、释放或更新。在这种情况下,它将知道“空格键”与Jump动作相关联。 -
Player Controller
类:这是第一个接收这些事件的类,因为它用于代表游戏中的玩家。 -
Pawn
类:这个类(因此也是从它继承的Character
类)也可以监听这些事件,只要它们被玩家控制器所控制。如果是这样,它将在该类之后接收这些事件。在本章中,我们将使用我们的Character
C++类来监听动作和轴事件。
现在我们知道 UE4 如何处理玩家输入,让我们来看看DefaultInput.ini
文件以及它的工作原理。
DefaultInput.ini
如果您进入项目的目录,使用文件资源管理器,然后打开其Config
文件夹,您会在其中找到一些.ini
文件,其中之一应该是DefaultInput.ini
文件。顾名思义,这个文件保存了与输入相关的主要设置和配置。
在本章的第一个练习中,我们编辑了项目的“输入”设置,实际上是编辑器在写入和读取DefaultInput.ini
文件。
在您选择的文本编辑器中打开此文件。它包含许多属性,但我们现在要查看的是Action Mappings
和Axis Mappings
列表。在文件末尾附近,您应该看到,例如,Jump动作在此文件中被指定为:
+ActionMappings=(ActionName="Jump",bShift=False,bCtrl=False, bAlt=False,bCmd=False,Key=SpaceBar)
+ActionMappings=(ActionName="Jump",bShift=False,bCtrl=False, bAlt=False,bCmd=False,Key=Gamepad_FaceButton_Bottom)
您还可以看到一些轴被指定,比如MoveRight
轴:
+AxisMappings=(AxisName="MoveRight",Scale=1.000000, Key=Gamepad_LeftX)
+AxisMappings=(AxisName="MoveRight",Scale=1.000000,Key=D)
+AxisMappings=(AxisName="MoveRight",Scale=-1.000000,Key=A)
您可以直接编辑此文件以添加、修改和删除Action Mappings
和Axis Mappings
,而不是编辑项目的“输入设置”,尽管这不是一个非常用户友好的方式。请记住,当您将项目打包到可执行文件时,此文件也将可用,这意味着玩家可以根据自己的喜好编辑此文件。
让我们现在看看如何在 C++中监听Action Mappings
和Axis Mappings
。
练习 4.02:监听移动动作和轴
在这个练习中,我们将使用 C++将我们在上一节中创建的动作和轴注册到我们的角色类中,通过将这些动作和轴绑定到我们角色类中的特定函数。
对于Player Controller
或Character
来监听动作和轴,主要的方法是使用SetupPlayerInputComponent
函数注册Action
和Axis
委托。 MyThirdPersonChar
类应该已经有一个声明和实现这个函数。让我们的角色类通过以下步骤监听这些事件:
- 在 Visual Studio 中打开
MyThirdPersonChar
类头文件,并确保有一个名为SetupPlayerInputComponent
的protected
函数的声明,它返回空,并接收一个class UInputComponent* PlayerInputComponent
属性作为参数。这个函数应该被标记为virtual
和override
:
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
- 打开这个类的源文件,并确保这个函数有一个实现:
void AMyThirdPersonChar::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
}
- 在其实现中,首先调用
PlayerInputComponent
属性的BindAction
函数。这个函数允许这个类监听特定的动作,这种情况下是Jump
动作。它接收以下参数:
-
FName ActionName
- 我们想要监听的动作的名称;在我们的情况下是Jump
动作。 -
EInputEvent InputEvent
- 我们想要监听的特定按键事件,可以是按下、释放、双击等。在我们的情况下,我们想要监听按下事件,可以通过使用IE_Pressed
值来指定。 -
UserClass* Object
- 回调函数将在其上调用的对象;在我们的例子中是this
指针。 -
FInputActionHandlerSignature::TUObjectMethodDelegate< UserClass >::FMethodPtr Func
- 这个属性有点啰嗦,但本质上是一个指向当事件发生时将被调用的函数的指针,我们可以通过输入&
后跟类名,后跟::
,后跟函数名来指定。在我们的情况下,我们希望这是属于Character
类的现有Jump
函数,所以我们将用&ACharacter::Jump
来指定它。
PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump);
注意
所有用于监听动作的函数都必须不接收任何参数,除非您使用Delegates
,这超出了本书的范围。
- 为了让角色停止跳跃,您需要复制这一行,然后将新行的输入事件更改为
IE_Released
,被调用的函数更改为Character
类的StopJumping
函数。
PlayerInputComponent->BindAction("Jump", IE_Released, this, &ACharacter::StopJumping);
- 因为我们将使用
InputComponent
类,所以我们需要在源文件的顶部包含它:
#include "Components/InputComponent.h"
- 现在我们正在监听
Jump
动作,并且在执行该动作时使角色跳跃,让我们继续进行其移动。在类的头文件中,添加一个名为MoveRight
的protected
函数的声明,它不返回任何内容,并接收一个float Value
参数。这个函数将在MoveRight
轴的值更新时被调用。
void MoveRight(float Value);
- 在类的源文件中,添加这个函数的实现,我们将首先检查
Controller
属性是否有效(不是nullptr
),以及Value
属性是否不等于0
:
void AMyThirdPersonChar::MoveRight(float Value)
{
if (Controller != nullptr && Value != 0.0f)
{
}
}
- 如果这两个条件都为真,我们将使用
AddMovementInput
函数来移动我们的角色。这个函数的一个参数是角色移动的方向。为了计算这个方向,我们需要做两件事:
- 获取摄像机在z轴(偏航)上的旋转,以便我们根据摄像机的朝向移动角色。为了实现这一点,我们可以创建一个新的
FRotator
属性,俯仰(y轴上的旋转)和翻滚(x轴上的旋转)的值为0
,属性的偏航值为摄像机当前的偏航值。要获取摄像机的偏航值,我们可以调用玩家控制器的GetControlRotation
函数,然后访问它的Yaw
属性。
const FRotator YawRotation(0, Controller-> GetControlRotation().Yaw, 0);
const FVector Direction = UKismetMathLibrary::GetRightVector(YawRotation);
现在我们可以调用AddMovementInput
函数,传递Direction
和Value
属性作为参数。
AddMovementInput(Direction, Value);
- 因为我们将同时使用
KismetMathLibrary
和Controller
对象,所以我们需要在这个源文件的顶部包含它们:
#include "Kismet/KismetMathLibrary.h"
#include "GameFramework/Controller.h"
- 在这个类的
SetupPlayerInputComponent
函数中监听MoveRight
轴,通过调用PlayerInputComponent
属性的BindAxis
函数。这个函数用于监听轴而不是动作,其参数与BindAction
函数的参数之间唯一的区别是它不需要接收EInputState
参数。将"MoveRight"
、this
指针和这个类的MoveRight
函数作为参数传递给这个函数。
PlayerInputComponent->BindAxis("MoveRight", this, &AMyThirdPersonChar::MoveRight);
注意
所有用于监听轴的函数都必须接收一个float
属性作为参数,除非您使用Delegates
,这超出了本书的范围。
现在让我们在这个类中监听MoveForward
轴:
- 在类的头文件中,添加一个类似于
MoveRight
函数的声明,但将其命名为MoveForward
:
void MoveForward(float Value);
- 在类的源文件中,为这个新的
MoveForward
函数添加一个实现。将MoveRight
函数的实现复制到这个新的实现中,但用其GetForwardVector
函数的调用替换KismetMathLibrary
对象的GetRightVector
函数的调用。这将使用表示摄像头面向方向的向量,而不是其右向量,其面向右侧:
void AMyThirdPersonChar::MoveForward(float Value)
{
if (Controller != nullptr && Value != 0.0f)
{
const FRotator YawRotation(0, Controller-> GetControlRotation().Yaw, 0);
const FVector Direction = UKismetMathLibrary::GetForwardVector(YawRotation);
AddMovementInput(Direction, Value);
}
}
- 在
SetupPlayerInputComponent
函数的实现中,复制监听MoveRight
轴的代码行,并将第一个参数替换为"MoveForward"
,将最后一个参数替换为指向MoveForward
函数的指针:
PlayerInputComponent->BindAxis("MoveForward", this, &AMyThirdPersonChar::MoveForward);
-
现在编译您的代码,打开编辑器,并打开您的
BP_MyTPS
蓝图资产。删除InputAction Jump
事件,以及与之连接的节点。对于InputAxis MoveForward
和InputAxis MoveRight
事件也做同样的操作。我们将在 C++中复制这个逻辑,并需要删除其蓝图功能,以便在处理输入时不会发生冲突。 -
现在,播放关卡。您应该能够使用键盘的
W
、A
、S
和D
键或控制器的左摇杆来移动角色,以及使用Spacebar
键或游戏手柄底部按钮
来跳跃:
图 4.11:玩家角色移动
完成了所有这些步骤后,您已经完成了这个练习。现在您知道如何在 UE4 中使用 C++监听Action
和Axis
事件。
注意
您可以使用PlayerInputComponent
属性的BindKey
函数来监听特定的按键,而不是监听特定的Action
或Axis
。该函数接收与BindAction
函数相同的参数,除了第一个参数应该是一个键而不是FName
。您可以使用EKeys
枚举后跟::
来指定键。
现在,我们已经设置了所有必要的逻辑,使我们的角色移动和跳跃,让我们添加负责围绕角色旋转摄像头的逻辑。
围绕角色转动摄像头
摄像头是游戏中非常重要的一部分,因为它决定了玩家在整个游戏过程中看到的内容和方式。对于本项目来说,摄像头允许您不仅看到周围的世界,还可以看到您正在控制的角色。无论角色是否受到伤害、跌落或其他情况,玩家始终知道他们正在控制的角色的状态,并且能够使摄像头面向他们选择的方向是非常重要的。
与每个现代的第三人称游戏一样,我们将始终使摄像头围绕我们的玩家角色旋转。在第二章“使用虚幻引擎”中设置了Camera
和Spring Arm
组件之后,让我们继续添加两个新的“轴映射”,第一个称为Turn
,与Gamepad Right Thumbstick X-Axis
和MouseX
键相关联,第二个称为LookUp
,与Gamepad Right Thumbstick Y-Axis
和MouseY
键相关联,后者的比例为-1
。
这些“轴映射”将用于使玩家向右和向左以及向上和向下查看:
图 4.12:转动和 LookUp 轴映射
现在让我们添加负责根据玩家输入旋转摄像头的 C++逻辑。
转到MyThirdPersonChar
类的SetupPlayerInputComponent
函数实现,并将负责监听MoveRight
轴或MoveForward
轴的行重复两次。在第一行的副本中,将第一个参数更改为"Turn"
,最后一个参数更改为Pawn
类的AddControllerYawInput
函数,而第二行的副本应该将第一个参数设置为"LookUp"
,最后一个参数设置为Pawn
类的AddControllerPitchInput
函数。
这两个函数分别负责围绕z(左右转向)和y(上下查看)轴添加旋转输入:
PlayerInputComponent->BindAxis("Turn", this, &APawn::AddControllerYawInput);
PlayerInputComponent->BindAxis("LookUp", this, &APawn::AddControllerPitchInput);
如果您编译了本节中所做的更改,打开编辑器并播放级别,现在您应该能够通过旋转鼠标或倾斜控制器的右摇杆来移动摄像机:
图 4.13:摄像机围绕玩家旋转
这就结束了使用玩家输入围绕玩家角色旋转摄像机的逻辑。在下一个练习中,我们将广泛地了解移动平台,如 Android 和 iOS。
移动平台
由于技术的最新进展,现在大多数人口都可以使用价格实惠的移动设备,如智能手机和平板电脑。这些设备虽然小,但仍具有相当大的处理能力,现在可以做许多像笔记本电脑和台式电脑这样大的设备可以做的事情之一就是玩视频游戏。
因为移动设备比其他设备更实惠和多功能,您有很多人在上面玩游戏。因此,值得考虑为移动平台(如 Android 和 iOS,两个最大的移动应用商店)开发视频游戏。
让我们现在看一下如何在下一个练习中在虚拟移动设备上预览我们的游戏。
练习 4.03:在移动设备上预览
在这个练习中,我们将使用“移动预览”来玩我们的游戏,以了解在移动设备上玩我们的游戏是什么感觉。在这之前,我们必须进入“Android 平台”设置。
请查看以下步骤:
- 打开“项目设置”窗口,并在其左侧边滚动,直到在“平台”类别下找到
Android
选项。单击该选项。您应该会在类别右侧看到以下内容:
图 4.14:Android 平台窗口警告项目当前尚未配置为该平台
- 此警告是在告诉您项目尚未配置为 Android。要更改此设置,请点击红色警告内的“立即配置”按钮。当您这样做时,它应该会变成绿色警告,告诉您平台已配置:
图 4.15:Android 平台窗口通知您项目已为此平台配置
- 完成后,您可以关闭“项目设置”,单击编辑器工具栏中“播放”按钮旁边的箭头,并选择您看到的“移动预览”选项:
图 4.16:播放按钮下的移动预览选项
这将导致引擎开始加载此预览,并编译所有必要的着色器,这应该需要几分钟时间。
完成后,您应该会看到以下内容:
图 4.17:移动预览窗口播放游戏,就像在 Android 设备上一样
这个预览应该看起来与编辑器内的普通预览类似,但有一些显著的区别:
-
视觉保真度已经降低。因为移动平台没有与 PC 和游戏机相同类型的计算能力,所以视觉质量会降低以考虑到这一点。此外,一些高端平台上可用的渲染功能在移动平台上根本不受支持。
-
在屏幕的左下角和右下角添加了两个虚拟摇杆,它们的工作方式类似于控制器,左摇杆控制角色的移动,右摇杆控制摄像机的旋转。
这个窗口就像一个移动屏幕,你的鼠标就是你的手指,所以如果你按住左摇杆并拖动它,这将导致摇杆在屏幕上移动,从而使角色移动,就像下面的截图所示:
图 4.18:使用左虚拟摇杆移动角色
随着这一章的结束,我们学会了如何在 Android 移动平台上预览我们的游戏,并验证其输入是否正常工作。
现在让我们进入下一个练习,我们将添加触摸输入,使玩家角色跳跃。
练习 4.04:添加触摸屏输入
在这个练习中,我们将继续上一个练习,使玩家角色在玩家在触摸屏设备上点击屏幕时开始跳跃。
要向我们的游戏添加触摸屏输入,请按照以下步骤进行:
- 转到
MyThirdPersonChar
类的头文件,并添加两个声明受保护的函数,这两个函数返回空,并接收ETouchIndex::Type FingerIndex
和FVector Location
参数,第一个参数表示触摸屏幕的手指的索引(无论是第一个、第二个还是第三个手指),第二个参数表示触摸屏幕的位置。将其中一个函数命名为TouchBegin
,另一个命名为TouchEnd
:
void TouchBegin(ETouchIndex::Type FingerIndex, FVector Location);
void TouchEnd(ETouchIndex::Type FingerIndex, FVector Location);
- 在
MyThirdPersonChar
类的源文件中,添加这两个函数的实现,其中TouchBegin
函数将调用Jump
函数,而TouchEnd
函数将调用StopJumping
函数。这将导致我们的角色在玩家触摸屏幕时开始跳跃,并在他们停止触摸屏幕时停止跳跃:
void AMyThirdPersonChar::TouchBegin(ETouchIndex::Type FingerIndex, FVector Location)
{
Jump();
}
void AMyThirdPersonChar::TouchEnd(ETouchIndex::Type FingerIndex, FVector Location)
{
StopJumping();
}
- 转到
SetupPlayerInputComponent
函数的实现,并在PlayerInputComponent
的BindTouch
函数中添加两个调用,这将把屏幕被触摸的事件绑定到一个函数。这个函数接收与BindAction
函数相同的参数,除了第一个参数ActionName
。在第一个函数调用中,将输入事件IE_Pressed
、this
指针和这个类的TouchBegin
函数作为参数传递,而在第二个调用中,将输入事件IE_Released
、this
指针和这个类的TouchEnd
函数作为参数传递:
PlayerInputComponent->BindTouch(IE_Pressed, this, &AMyThirdPersonChar::TouchBegin);
PlayerInputComponent->BindTouch(IE_Released, this, &AMyThirdPersonChar::TouchEnd);
- 使用
Mobile Preview
预览游戏,就像我们在上一个练习中所做的那样。如果你用左鼠标按钮点击屏幕中间,玩家角色应该会跳跃:
图 4.19:点击屏幕中间后角色跳跃
随着这一章的结束,我们完成了使我们的角色在玩家触摸屏幕时跳跃的逻辑。现在我们已经学会了如何向我们的游戏添加输入,并将这些输入与游戏内的动作(如跳跃和移动玩家角色)关联起来,让我们通过在下一个活动中从头到尾地向我们的游戏添加一个新的Walk
动作来巩固我们在这一章中学到的知识。
活动 4.01:为我们的角色添加行走逻辑
在当前游戏中,我们的角色在使用移动键时默认奔跑,但我们需要减少角色的速度并使其行走。
因此,在这个活动中,我们将添加逻辑,使我们的角色在按住键盘上的Shift
键或“游戏手柄右侧按钮”键(Xbox 控制器的B
和 PlayStation 控制器的O
)移动时行走。此外,我们还将在移动平台上进行预览。
要做到这一点,请按照以下步骤:
-
通过“项目设置”窗口打开“输入设置”。
-
添加一个名为
Walk
的新Action Mapping
,并将其与“左 Shift”和“游戏手柄右侧按钮”键关联。 -
打开
MyThirdPersonChar
类的头文件,并添加两个返回空值并且不接收参数的protected
函数的声明,分别称为BeginWalking
和StopWalking
。 -
在类的源文件中为这两个函数添加实现。在
BeginWalking
函数的实现中,通过相应地修改CharacterMovementComponent
属性的MaxWalkSpeed
属性,将角色的速度改变为其值的 40%。要访问CharacterMovementComponent
属性,请使用GetCharacterMovement
函数。 -
StopWalking
函数的实现将是BeginWalking
函数的相反,它将使角色的行走速度增加 250%。 -
当按下该动作时,将“行走”动作绑定到
BeginWalking
函数,并在释放时绑定到StopWalking
函数。
按照这些步骤后,您应该能够让您的角色行走,通过按住键盘的左 Shift键或控制器的右侧按钮按钮来减慢速度并略微改变动画。
图 4.20:角色奔跑(左)和行走(右)
- 现在让我们在移动平台上预览我们的游戏,就像我们在练习 4.03中所做的那样,在移动预览中轻轻拖动左摇杆,使我们的角色慢慢行走。结果应该类似于以下屏幕截图:
图 4.21:移动预览中的角色行走
这就结束了我们的活动。 只要玩家按住“行走”动作,我们的角色现在应该能够慢慢地行走。
注意
此活动的解决方案可以在以下网址找到:packt.live/338jEBx
。
总结
在本章中,您已经学会了如何添加、删除和修改Action Mappings
和Axis Mappings
,这在确定哪些键触发特定动作或轴,如何监听它们以及在按下和释放时如何执行游戏逻辑时,给您一些灵活性。
现在您知道如何处理玩家的输入,您可以允许玩家与您的游戏进行交互,并提供视频游戏所广为人知的代理。
在下一章中,我们将从头开始制作我们自己的游戏。 它将被称为“躲避球”,玩家将控制一个角色试图逃离向它投掷躲避球的敌人。 在那一章中,我们将有机会开始学习许多重要的主题,重点是碰撞。
第五章:线性跟踪
概述
这一章将是一个名为躲避球的新游戏项目的开始,我们将从头开始创建一个基于碰撞概念的游戏。在本章中,您将修改第三人称模板项目,使其具有俯视视角。然后,您将介绍线性跟踪,这是游戏开发中的一个关键概念,并了解它们的潜力和用例。
在本章结束时,您将能够使用 UE4 内置的线性跟踪功能(在其他游戏开发工具中也称为射线投射或光线跟踪),通过执行不同类型的线性跟踪;创建自己的跟踪通道;并修改物体对每个跟踪通道的响应。
介绍
在之前的章节中,我们学习了如何重现虚幻引擎团队提供给我们的第三人称模板项目,以了解 UE4 工作流程和框架的一些基本概念。
在本章中,您将开始从头开始创建另一个游戏。在这个游戏中,玩家将以俯视的角度控制一个角色(类似于《合金装备》1、2 和 3 等游戏)。俯视视角意味着玩家控制一个角色,就好像从上方看下去一样,通常摄像机旋转是固定的(摄像机不会旋转)。在我们的游戏中,玩家角色必须从 A 点到 B 点,而不被敌人在整个关卡中投掷的躲避球击中。这个游戏的关卡将是迷宫般的,玩家将有多条路径可供选择,所有这些路径都将有敌人试图向玩家投掷躲避球。
本章我们将要涉及的具体主题包括线性跟踪(单一和多重)、扫描跟踪、跟踪通道和跟踪响应。在第一节中,我们将开始了解在视频游戏世界中碰撞是什么。
碰撞
碰撞基本上是两个物体相互接触的点(例如,两个物体碰撞,物体撞击角色,角色走进墙壁等)。大多数游戏开发工具都有自己的一套功能,允许碰撞和物理存在于游戏中。这一套功能被称为物理引擎,它负责与碰撞相关的一切。它负责执行线性跟踪,检查两个物体是否重叠,阻止彼此的移动,从墙壁上弹开等等。当我们要求游戏执行或通知我们这些碰撞事件时,游戏实际上是在要求物理引擎执行它,然后向我们展示这些碰撞事件的结果。
在您将要构建的躲避球
游戏中,需要考虑碰撞的例子包括检查敌人是否能看到玩家(这将通过线性跟踪来实现,在本章中介绍),模拟物理学上的一个对象,它将表现得就像一个躲避球一样,检查是否有任何东西阻挡玩家角色的移动,等等。
碰撞是大多数游戏中最重要的方面之一,因此了解它对于开始游戏开发至关重要。
在我们开始构建基于碰撞的功能之前,我们首先需要设置我们的新躲避球
项目,以支持我们将要实现的游戏机制。这个过程从下一节描述的步骤开始:项目设置。
项目设置
让我们通过创建我们的虚幻引擎项目开始这一章节:
-
启动
UE4。选择游戏
项目类别,然后按下一步
。 -
选择
第三人称模板
,然后按下一步
。 -
确保将第一个选项设置为
C++
而不是Blueprint
。 -
根据您的喜好选择项目的位置,并将项目命名为
躲避球
,然后按创建项目
。
项目生成完成后,您应该在屏幕上看到以下内容:
图 5.1:加载的躲避球项目
- 代码生成并打开项目后,关闭 UE4 编辑器,并在 Visual Studio 中打开生成的第三人角色类
DodgeballCharacter
的文件,如下图所示:
图 5.2:在 Visual Studio 中生成的文件
如前所述,您的项目将具有俯视角。鉴于我们从第三人模板开始此项目,我们需要在将其转换为俯视游戏之前进行一些更改。这将主要涉及更改现有角色类中的一些代码行。
练习 5.01:将躲避球角色转换为俯视角
在这个练习中,您将对生成的DodgeballCharacter
类进行必要的更改。请记住,它目前具有第三人称视角,其中角色的旋转由玩家的输入(即鼠标或右摇杆)决定。
在这个练习中,您将把它改为俯视角,不管玩家的输入如何,相机始终从上方跟随角色。
以下步骤将帮助您完成此练习:
-
前往
DodgeballCharacter
类的构造函数,并根据以下步骤更新CameraBoom
的属性。 -
将
CameraBoom
的属性TargetArmLength
更改为900.0f
,以在相机和玩家之间增加一些距离:
// The camera follows at this distance behind the character
CameraBoom->TargetArmLength = 900.0f;
- 接下来,添加一行代码,使用
SetRelativeRotation
函数将相对俯仰设置为-70
º,以便相机向下看玩家。FRotator
构造函数的参数分别是俯仰、偏航和翻滚:
//The camera looks down at the player
CameraBoom->SetRelativeRotation(FRotator(-70.f, 0.f, 0.f));
- 将
bUsePawnControlRotation
更改为false
,以便相机的旋转不受玩家的移动输入影响:
// Don't rotate the arm based on the controller
CameraBoom->bUsePawnControlRotation = false;
- 添加一行代码,将
bInheritPitch
、bInheritYaw
和bInheritRoll
设置为false
,以便相机的旋转不受角色方向的影响:
// Ignore pawn's pitch, yaw and roll
CameraBoom->bInheritPitch = false;
CameraBoom->bInheritYaw = false;
CameraBoom->bInheritRoll = false;
在我们进行了这些修改之后,我们将删除角色跳跃的能力(我们不希望玩家那么容易就躲开躲避球),以及根据玩家的旋转输入旋转相机的能力。
- 转到
DodgeballCharacter
源文件中的SetupPlayerInputComponent
函数,并删除以下代码行以删除跳跃的能力:
// REMOVE THESE LINES
PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump);
PlayerInputComponent->BindAction("Jump", IE_Released, this, Acharacter::StopJumping);
- 接下来,添加以下行以删除玩家的旋转输入:
// REMOVE THESE LINES
PlayerInputComponent->BindAxis("Turn", this, &APawn::AddControllerYawInput);
PlayerInputComponent->BindAxis("TurnRate", this, &ADodgeballCharacter::TurnAtRate);
PlayerInputComponent->BindAxis("LookUp", this, &APawn::AddControllerPitchInput);
PlayerInputComponent->BindAxis("LookUpRate", this, &ADodgeballCharacter::LookUpAtRate);
这一步是可选的,但为了保持代码整洁,您应该删除TurnAtRate
和LookUpAtRate
函数的声明和实现。
-
最后,在您进行了这些更改之后,从 Visual Studio 运行您的项目。
-
编辑器加载完成后,播放关卡。相机的视角应该是这样的,并且不应根据玩家的输入或角色的旋转而旋转:
图 5.3:将相机旋转锁定到俯视角
这就结束了本章的第一个练习,也是您新项目Dodgeball
的第一步。
接下来,您将创建EnemyCharacter
类。这个角色将是敌人,在玩家在视野中时向玩家投掷躲避球。但在这里出现的问题是:敌人如何知道它是否能看到玩家角色呢?
这将通过线追踪(也称为射线投射或光线追踪)的能力来实现,您将在下一节中了解到。
线追踪
任何游戏开发工具的最重要功能之一是执行线追踪的能力。这些功能是通过工具使用的物理引擎提供的。
线性跟踪是一种询问游戏是否有任何东西站在游戏世界中两点之间的方式。游戏将在你指定的两点之间发射一条射线,并返回被击中的对象(如果有的话),它们被击中的位置,以及角度等等。
在下图中,您可以看到线性跟踪的表示,我们假设对象1
被忽略,而对象2
被检测到,这是由于它们的跟踪通道属性(在接下来的段落中进一步解释):
图 5.4:从点 A 到点 B 执行的线性跟踪
在图 5.4中:
-
虚线代表线性跟踪撞击物体前的情况。
-
箭头代表线性跟踪的方向。
-
虚线代表线性跟踪撞击物体后的情况。
-
条纹圆圈代表线性跟踪的撞击点。
-
大方块代表线性跟踪路径上的两个对象(对象
1
和2
)。
我们注意到只有对象2
被线性跟踪击中,而对象1
没有,尽管它也在线性跟踪的路径上。这是由于对对象1
的跟踪通道属性所做的假设,这些将在本章后面讨论。
线性跟踪用于许多游戏功能,例如:
-
检查武器开火时是否击中物体
-
当角色看着可以与之交互的物品时进行突出显示
-
当相机自动围绕玩家角色旋转时
线性跟踪的一个常见且重要的特性是跟踪通道。当执行线性跟踪时,您可能只想检查特定类型的对象,这就是跟踪通道的作用。它们允许您指定在执行线性跟踪时使用的过滤器,以便它不会被不需要的对象阻挡。例如:
-
您可能只想执行线性跟踪以检查可见的对象。这些对象将阻挡
Visibility
跟踪通道。例如,不可见的墙壁,这些是游戏中用来阻挡玩家移动的不可见几何体,不可见,因此不会阻挡Visibility
跟踪通道。 -
您可能只想执行线性跟踪以检查可以与之交互的对象。这些对象将阻挡
Interaction
跟踪通道。 -
您可能只想执行线性跟踪以检查可以在游戏世界中移动的 pawn。这些对象将阻挡
Pawn
跟踪通道。
您可以指定不同对象如何对不同的跟踪通道做出反应,以便只有一些对象阻挡特定的跟踪通道,而其他对象忽略它们。在我们的情况下,我们想知道敌人和玩家角色之间是否有任何东西,这样我们就知道敌人是否能看到玩家。我们将使用线性跟踪来实现这一目的,通过检查任何阻挡敌人对玩家角色的视线的东西,使用Tick
事件。
在下一节中,我们将使用 C++创建EnemyCharacter
类。
创建 EnemyCharacter C++类
在我们的Dodgeball
游戏中,EnemyCharacter
类将不断地观察玩家角色,如果他们在视野内的话。这是同一个类,稍后将向玩家投掷躲避球;但是,这将留到下一章。在本章中,我们将专注于允许我们的敌人角色观察玩家的逻辑。
那么,让我们开始吧:
-
在编辑器内右键单击
Content Browser
,然后选择New C++ Class
。 -
选择
Character
类作为父类。 -
将新类命名为
EnemyCharacter
。
在你创建了类并在 Visual Studio 中打开了它的文件之后,让我们在其header
文件中添加LookAtActor
函数声明。这个函数应该是public
,不返回任何东西,只接收AActor* TargetActor
参数,这将是它应该面对的 Actor。看一下下面的代码片段,它展示了这个函数:
// Change the rotation of the character to face the given actor
void LookAtActor(AActor* TargetActor);
注意
尽管我们只希望敌人看着玩家的角色,但为了执行良好的软件开发实践,我们将稍微抽象化这个函数,并允许EnemyCharacter
看任何 Actor,因为允许一个 Actor 看另一个 Actor 或玩家角色的逻辑将是完全相同的。
记住,在编写代码时不应该创建不必要的限制。如果你可以编写类似的代码,同时又允许更多的可能性,那么你应该这样做,只要不过于复杂化程序的逻辑。
继续前进,如果EnemyCharacter
看不到Target
Actor
,它就不应该看着它。为了检查敌人是否能看到 Actor,它应该看着LookAtActor
函数,该函数将调用另一个函数,即CanSeeActor
函数。这就是你将在下一个练习中要做的事情。
练习 5.02:创建 CanSeeActor 函数,执行线性跟踪
在这个练习中,我们将创建CanSeeActor
函数,该函数将返回敌人角色是否能看到给定的 Actor。
以下步骤将帮助你完成这个练习:
- 在
EnemyCharacter
类的头文件中创建CanSeeActor
函数的声明,该函数将返回一个bool
,并接收一个const Actor* TargetActor
参数,这是我们想要看的 Actor。这个函数将是一个const
函数,因为它不会改变类的任何属性,参数也将是const
,因为我们不需要修改它的任何属性;我们只需要访问它们:
// Can we see the given actor
bool CanSeeActor(const AActor* TargetActor) const;
现在,让我们来到有趣的部分,即执行线性跟踪。
为了调用与线性跟踪相关的函数,我们将需要使用GetWorld
函数获取敌人当前的世界。然而,我们还没有在这个文件中包含World
类,所以让我们在接下来的步骤中这样做:
注意
GetWorld
函数对任何 Actor 都是可访问的,并将返回 Actor 所属的World
对象。请记住,为了执行线性跟踪,世界是必要的。
- 打开
EnemyCharacter
源文件,并找到以下代码行:
#include "EnemyCharacter.h"
在上一行代码的后面添加以下行:
#include "Engine/World.h"
- 接下来,在
EnemyCharacter
源文件中创建CanSeeActor
函数的实现,你将首先检查我们的TargetActor
是否为nullptr
。如果是,我们将返回false
,因为我们没有有效的 Actor 来检查我们的视线:
bool AEnemyCharacter::CanSeeActor(const AActor * TargetActor) const
{
if (TargetActor == nullptr)
{
return false;
}
}
接下来,在添加线性跟踪函数调用之前,我们需要设置一些必要的参数;我们将在接下来的步骤中实现这些参数。
- 在前面的
if
语句之后,创建一个变量来存储与线性跟踪结果相关的所有必要数据。Unreal 已经为此提供了一个内置类型,称为FHitResult
类型:
// Store the results of the Line Trace
FHitResult Hit;
这是我们将发送到线性跟踪函数的变量,该函数将用执行的线性跟踪的相关信息填充它。
- 创建两个
FVector
变量,用于我们线性跟踪的Start
和End
位置,并将它们分别设置为我们敌人当前的位置和我们目标当前的位置:
// Where the Line Trace starts and ends
FVector Start = GetActorLocation();
FVector End = TargetActor->GetActorLocation();
- 接下来,设置我们希望进行比较的跟踪通道。在我们的情况下,我们希望有一个
Visibility
跟踪通道,专门用于指示一个物体是否阻挡另一个物体的视图。幸运的是,对于我们来说,UE4 中已经存在这样一个跟踪通道,如下面的代码片段所示:
// The trace channel we want to compare against
ECollisionChannel Channel = ECollisionChannel::ECC_Visibility;
ECollisionChannel
枚举代表了所有可能的跟踪通道,我们将使用ECC_Visibility
值,该值代表Visibility
跟踪通道。
- 现在我们已经设置好所有必要的参数,我们最终可以调用
LineTrace
函数,LineTraceSingleByChannel
:
// Execute the Line Trace
GetWorld()->LineTraceSingleByChannel(Hit, Start, End, Channel);
此函数将考虑我们发送的参数,执行线性跟踪,并通过修改我们的Hit
变量返回其结果。
在我们继续之前,还有一些事情需要考虑。
如果线性跟踪从我们的敌人角色内部开始,这在我们的情况下会发生,这意味着线性跟踪很可能会立即击中我们的敌人角色并停在那里,因为我们的角色可能会阻塞Visibility
跟踪通道。为了解决这个问题,我们需要告诉线性跟踪忽略它。
- 使用内置的
FCollisionQueryParams
类型,可以为我们的线性跟踪提供更多选项:
FCollisionQueryParams QueryParams;
- 现在,更新线性跟踪以忽略我们的敌人,通过将自身添加到要忽略的 Actor 列表中:
// Ignore the actor that's executing this Line Trace
QueryParams.AddIgnoredActor(this);
我们还应将我们的目标添加到要忽略的 Actor 列表中,因为我们不想知道它是否阻塞了EnemySight
通道;我们只是想知道敌人和玩家角色之间是否有东西阻塞了该通道。
- 将目标 Actor 添加到要忽略的 Actor 列表中,如下面的代码片段所示:
// Ignore the target we're checking for
QueryParams.AddIgnoredActor(TargetActor);
- 接下来,通过将其作为
LineTraceSingleByChannel
函数的最后一个参数发送我们的FCollisionQueryParams
:
// Execute the Line Trace
GetWorld()->LineTraceSingleByChannel(Hit, Start, End, Channel, QueryParams);
- 通过返回线性跟踪是否击中任何东西来完成我们的
CanSeeActor
函数。我们可以通过访问我们的Hit
变量并检查是否有阻塞命中来实现这一点,使用bBlockingHit
属性。如果有,这意味着我们看不到我们的TargetActor
。可以通过以下代码片段实现:
return !Hit.bBlockingHit;
注意
虽然我们不需要从Hit
结果中获取更多信息,除了是否有阻塞命中,但Hit
变量可以为我们提供关于线性跟踪的更多信息,例如:
通过访问“Hit.GetActor()”函数,可以获取被线性跟踪击中的 Actor 的信息(如果没有击中 Actor,则为nullptr
)
通过访问“Hit.GetComponent()”函数,找到被线性跟踪击中的 Actor 组件的信息(如果没有击中 Actor 组件,则为nullptr
)
通过访问Hit.Location
变量获取击中位置的信息
通过访问Hit.Distance
变量找到击中的距离
通过访问Hit.ImpactNormal
变量找到线性跟踪击中对象的角度
最后,我们的CanSeeActor
函数已经完成。我们现在知道如何执行线性跟踪,并且可以将其用于我们敌人的逻辑。
通过完成这个练习,我们已经完成了CanSeeActor
函数;现在我们可以回到LookAtActor
函数。但是,首先有件事情我们应该看一下:可视化我们的线性跟踪。
可视化线性跟踪
在创建使用线性跟踪的新逻辑时,实际上在执行线性跟踪时可视化线性跟踪非常有用,而线性跟踪函数不允许您这样做。为了做到这一点,我们必须使用一组辅助调试函数,在运行时可以动态绘制对象,如线条、立方体、球体等。
然后让我们添加线性跟踪的可视化。为了使用调试函数,我们必须在最后一个include
行下添加以下include
:
#include "DrawDebugHelpers.h"
我们将调用DrawDebugLine
函数以可视化线性跟踪,该函数需要以下输入,与线性跟踪函数接收到的非常相似:
-
当前的
World
,我们将使用GetWorld
函数提供 -
线的
Start
和End
点,将与LineTraceSingleByChannel
函数相同 -
游戏中线的期望颜色,可以设置为“红色”
然后,我们可以在我们的线段跟踪函数调用下面添加DrawDebugLine
函数调用,如下面的代码片段所示:
// Execute the Line Trace
GetWorld()->LineTraceSingleByChannel(Hit, Start, End, Channel, QueryParams);
// Show the Line Trace inside the game
DrawDebugLine(GetWorld(), Start, End, FColor::Red);
这将允许您在执行时可视化线段跟踪,这非常有用。
注意
如果您需要,您还可以指定更多的可视化线段跟踪属性,比如它的生命周期和厚度。
有许多可用的DrawDebug
函数,可以绘制立方体、球体、圆锥体、甜甜圈,甚至自定义网格。
现在我们既可以执行又可以可视化我们的线段跟踪,让我们在LookAtActor
函数内使用我们在上一个练习中创建的CanSeeActor
函数。
练习 5.03:创建 LookAtActor 函数
在这个练习中,我们将创建LookAtActor
函数的定义,该函数将改变敌人的旋转,使其面向给定的角色。
以下步骤将帮助您完成这个练习:
-
在
EnemyCharacter
源文件中创建LookAtActor
函数定义。 -
首先检查我们的
TargetActor
是否为nullptr
,如果是,则立即返回空(因为它无效),如下面的代码片段所示:
void AEnemyCharacter::LookAtActor(AActor * TargetActor)
{
if (TargetActor == nullptr)
{
return;
}
}
- 接下来,我们要检查是否能看到我们的目标角色,使用我们的
CanSeeActor
函数:
if (CanSeeActor(TargetActor))
{
}
如果这个if
语句为真,那意味着我们能看到这个角色,并且我们将设置我们的旋转,以便面向该角色。幸运的是,UE4 中已经有一个允许我们这样做的函数:FindLookAtRotation
函数。这个函数将接收级别中的两个点作为输入,点 A(Start
点)和点 B(End
点),并返回起始点的对象必须具有的旋转,以便面向结束点的对象。
- 为了使用这个函数,包括
KismetMathLibrary
,如下面的代码片段所示:
#include "Kismet/KismetMathLibrary.h"
FindLookAtRotation
函数必须接收一个Start
和End
点,这将是我们的敌人位置和我们的目标角色位置,分别:
FVector Start = GetActorLocation();
FVector End = TargetActor->GetActorLocation();
// Calculate the necessary rotation for the Start point to face the End point
FRotator LookAtRotation = UKismetMathLibrary::FindLookAtRotation(Start, End);
- 最后,将敌人角色的旋转设置为与我们的
LookAtRotation
相同的值:
//Set the enemy's rotation to that rotation
SetActorRotation(LookAtRotation);
这就是LookAtActor
函数的全部内容。
现在最后一步是在 Tick 事件中调用LookAtActor
函数,并将玩家角色作为TargetActor
发送。
- 为了获取当前由玩家控制的角色,我们可以使用
GameplayStatics
对象。与其他 UE4 对象一样,我们必须首先包含它们:
#include "Kismet/GameplayStatics.h"
- 接下来,转到您的 Tick 函数的主体,并从
GameplayStatics
中调用GetPlayerCharacter
函数:
// Fetch the character currently being controlled by the player
ACharacter* PlayerCharacter = UGameplayStatics::GetPlayerCharacter(this, 0);
此函数接收以下输入:
-
一个世界上下文对象,本质上是属于我们当前世界的对象,用于让函数知道要访问哪个世界对象。这个世界上下文对象可以简单地是
this
指针。 -
玩家索引,鉴于我们的游戏应该是单人游戏,我们可以安全地假设为
0
(第一个玩家)。
- 接下来,调用
LookAtActor
函数,发送我们刚刚获取的玩家角色:
// Look at the player character every frame
LookAtActor(PlayerCharacter);
- 这个练习的最后一步是在 Visual Studio 中编译您的更改。
现在您已经完成了这个练习,您的EnemyCharacter
类已经具备了面向玩家角色的必要逻辑,如果它在视野内,我们可以开始创建EnemyCharacter
蓝图类。
创建 EnemyCharacter 蓝图类
现在我们已经完成了EnemyCharacter
C++类的逻辑,我们必须创建从中派生的蓝图类:
-
在编辑器中打开我们的项目。
-
转到
ThirdPersonCPP
文件夹中的Content Browser
中的Blueprints
文件夹。 -
右键单击并选择创建新的蓝图类。
-
在
Pick Parent Class
窗口底部附近展开All Classes
选项卡,搜索我们的EnemyCharacter
C++类,并将其选择为父类。 -
将蓝图类命名为
BP_EnemyCharacter
。 -
打开蓝图类,从“组件”选项卡中选择
SkeletalMeshComponent
(称为Mesh
),并将其“骨骼网格”属性设置为SK_Mannequin
,将其“动画类”属性设置为ThirdPerson_AnimBP
。 -
将
SkeletalMeshComponent
的Yaw更改为-90º
(在z 轴上),将其在z 轴上的位置更改为-83
个单位。 -
在设置好蓝图类之后,其网格设置应该与我们的
DodgeballCharacter
蓝图类非常相似。 -
将
BP_EnemyCharacter
类的一个实例拖到你的关卡中,放在一个可能阻挡其视线的物体附近,比如这个位置(所选角色是EnemyCharacter
):
图 5.5:将 BP_EnemyCharacter 类拖入关卡
- 现在我们终于可以玩游戏,验证我们的敌人在视线范围内时确实看向我们的玩家角色:
图 5.6:敌人角色使用线扫描清晰看到玩家
- 我们还可以看到,敌人在视线范围之外时停止看到玩家,如图 5.7所示:
图 5.7:敌人失去对玩家的视线
这就结束了我们的EnemyCharacter
的逻辑。在下一节中,我们将看一下扫描轨迹。
扫描轨迹
在继续我们的项目之前,了解一种线扫描的变体——扫描轨迹是很重要的。虽然我们不会在项目中使用它们,但了解它们以及如何使用它们是很重要的。
虽然线扫描基本上是在两点之间“发射一条射线”,但扫描轨迹将模拟在直线上两点之间“抛出一个物体”。被“抛出”的物体是模拟的(实际上并不存在于游戏中),可以有各种形状。在扫描轨迹中,“击中”位置将是虚拟物体(我们将其称为形状)从起点到终点抛出时首次击中另一个物体的位置。扫描轨迹的形状可以是盒形、球形或胶囊形。
这是从点A
到点B
的扫描轨迹的表示,我们假设由于其跟踪通道属性,物体1
被忽略,使用盒形:
图 5.8:扫描轨迹的表示
从图 5.8中,我们注意到以下内容:
-
使用盒形的扫描轨迹,从点 A 到点 B 执行。
-
虚线框表示扫描轨迹在击中物体之前。
-
虚线框表示扫描轨迹击中物体后的情况。
-
条纹圆圈表示扫描轨迹与物体
2
的碰撞点,即扫描轨迹盒形的表面与物体2
的表面相互碰撞的点。 -
大方块代表了两个物体在线扫描轨迹(物体
1
和2
)的路径上。 -
由于其跟踪通道属性的假设,物体
1
在扫描轨迹中被忽略。
在一些情况下,扫描跟踪比普通的线性跟踪更有用。让我们以我们的敌人角色为例,它可以投掷躲避球。如果我们想要为玩家添加一种方式,让玩家不断地可视化敌人投掷的下一个躲避球将会着陆的位置,那么最好的方法是使用扫描跟踪:我们可以用我们躲避球的形状(一个球体)对玩家进行扫描跟踪,检查碰撞点,并在碰撞点显示一个球体,这样玩家就可以看到。如果扫描跟踪击中了墙壁或某个角落,玩家就会知道,如果敌人此时投掷躲避球,它会首先击中那里。你可以使用简单的线性跟踪来达到同样的目的,但是为了达到相同的结果质量,设置会变得相当复杂,这就是为什么在这种情况下扫描跟踪是更好的解决方案。
现在,让我们快速看一下如何在代码中进行扫描跟踪。
练习 5.04:执行扫描跟踪
在这个练习中,我们将在代码中实现扫描跟踪。虽然我们不会在项目中使用它,但通过进行这个练习,你将熟悉这样的操作。
进入前几节创建的CanSeeActor
函数的末尾,然后按照以下步骤进行:
- 负责扫描跟踪的函数是
SweepSingleByChannel
,它在 UE4 中可用,并需要以下参数作为输入:
一个FHitResult
类型,用于存储扫描的结果(我们已经有了一个,所以不需要再创建另一个这种类型的变量):
// Store the results of the Line Trace
FHitResult Hit;
扫描的“起点”和“终点”(我们已经有了这两个,所以不需要再创建另一个这种类型的变量):
// Where the Sweep Trace starts and ends
FVector Start = GetActorLocation();
FVector End = TargetActor->GetActorLocation();
- 使用形状的预期旋转,它是一个
FQuat
类型(表示四元数)。在这种情况下,它被设置为在所有轴上的旋转为0
,通过访问FQuat
的Identity
属性来实现:
// Rotation of the shape used in the Sweep Trace
FQuat Rotation = FQuat::Identity;
- 现在,使用预期的跟踪通道进行比较(我们已经有了一个这样的变量,所以不需要再创建另一个这种类型的变量):
// The trace channel we want to compare against
ECollisionChannel Channel = ECollisionChannel::ECC_Visibility;
- 最后,通过调用
FcollisionShape
的MakeBox
函数并提供盒形形状在三个轴上的半径来使用盒形的形状进行扫描跟踪。这在下面的代码片段中显示:
// Shape of the object used in the Sweep Trace
FCollisionShape Shape = FCollisionShape::MakeBox(FVector(20.f, 20.f, 20.f));
- 接下来,调用
SweepSingleByChannel
函数:
GetWorld()->SweepSingleByChannel(Hit,
Start,
End,
Rotation,
Channel,
Shape);
完成了这些步骤后,我们完成了有关扫描跟踪的练习。鉴于我们不会在项目中使用扫描跟踪,你应该注释掉SweepSingleByChannel
函数,这样我们的Hit
变量就不会被修改,也不会丢失我们线性跟踪的结果。
现在我们已经完成了有关扫描跟踪的部分,让我们回到我们的“躲避球”项目,并学习如何更改对象对跟踪通道的响应。
更改可见性跟踪响应
在我们当前的设置中,每个可见的对象都会阻挡“可见性”跟踪通道;但是,如果我们想要改变一个对象是否完全阻挡该通道,该怎么办呢?为了做到这一点,我们必须改变一个组件对该通道的响应。看下面的例子:
- 我们选择我们在关卡中用来阻挡敌人视线的立方体,如图 5.9所示:
图 5.9:角色的默认生成点
- 然后,转到对象“详细面板”中的“碰撞”部分(它在“编辑器”界面中的默认位置):
图 5.10:虚幻引擎中详细面板中的碰撞选项卡
-
在这里,你会发现几个与碰撞相关的选项。我们现在要注意的是“碰撞预设”选项。它当前的值是“默认”,但是,我们想根据自己的喜好进行更改,所以我们将点击下拉框并将其值更改为“自定义”。
-
一旦这样做,您会注意到一整组新选项弹出:
图 5.11:碰撞预设设置为自定义
这组选项允许您指定此对象对线追踪和对象碰撞的响应方式,以及它是何种类型的碰撞对象。
您应该注意的选项是“可见性”。您会注意到它设置为“阻挡”,但您也可以将其设置为“重叠”和“忽略”。
现在,立方体正在阻挡“可见性”追踪通道,这就是为什么我们的敌人在立方体后面时看不到角色。但是,如果我们将对象对“可见性”追踪通道的响应更改为“重叠”或“忽略”,则该对象将不再阻止检查可见性的线追踪(这是您刚刚在 C++中编写的线追踪的情况)。
- 让我们将立方体对“可见性”通道的响应更改为“忽略”,然后玩游戏。您会注意到即使敌人在立方体后面时,它仍然朝向玩家角色:
图 5.12:敌人角色透过物体看玩家
这是因为立方体不再阻挡“可见性”追踪通道,因此敌人执行的线追踪在试图接触玩家角色时不再击中任何东西。
现在我们已经看到如何更改对象对特定追踪通道的响应方式,让我们将立方体对“可见性”通道的响应更改回“阻挡”。
但是,值得一提的是:如果我们将立方体对“可见性”通道的响应设置为“重叠”,而不是“忽略”,结果将是相同的。但是为什么呢,这两种响应的目的是什么?为了解释这一点,我们将看看多线追踪。
多线追踪
在练习 5.02中使用CanSeeActor
函数时,您可能会对我们使用的线追踪函数LineTraceSingleByChannel
的名称,特别是为什么使用了“单”这个词,感到困惑。原因是因为您也可以执行LineTraceMultiByChannel
。
但是这两种线追踪有何不同?
单线追踪在击中物体后将停止检查阻挡物体,并告诉我们击中的物体是什么,而多线追踪可以检查同一线追踪击中的任何物体。
单线追踪将:
-
忽略那些在线追踪中使用的追踪通道上设置为“忽略”或“重叠”的对象
-
找到其响应设置为“阻挡”的对象时停止
然而,多线追踪不会忽略那些在追踪通道上设置为“重叠”的对象,而是将它们添加为在线追踪期间找到的对象,并且只有在找到阻挡所需追踪通道的对象时(或者到达终点时)才会停止。在下一个图中,您将找到执行多线追踪的示例:
图 5.13:从点 A 到点 B 执行的多线追踪
在图 5.13中,我们注意到以下内容:
-
虚线代表线追踪在击中阻挡物体之前。
-
点线代表线追踪击中阻挡物体后。
-
条纹圆圈代表线追踪的影响点,其中只有最后一个在这种情况下是阻挡击中。
LineTraceSingleByChannel
和LineTraceMultiByChannel
函数之间唯一的区别在于它们的输入,后者必须接收TArray<FHitResult>
输入,而不是单个FHitResult
。所有其他输入都是相同的。
多线跟踪在模拟具有强穿透力的子弹行为时非常有用,可以穿过多个对象后完全停止。请记住,您还可以通过调用SweepMultiByChannel
函数进行多扫描跟踪。
注意
关于LineTraceSingleByChannel
函数的另一件事,你可能会想知道的是ByChannel
部分。这个区别与使用跟踪通道有关,而不是另一种选择,即对象类型。您可以通过调用LineTraceSingleByObjectType
函数来执行使用对象类型而不是跟踪通道的线跟踪,该函数也可以从 World 对象中获得。对象类型与我们将在下一章中涵盖的主题相关,因此我们暂时不会详细介绍这个函数。
摄像机跟踪通道
当将我们的立方体的响应更改为Visibility
跟踪通道时,您可能已经注意到了另一个内置的跟踪通道:Camera
。
该通道用于指定对象是否阻挡了摄像机弹簧臂和其关联的角色之间的视线。为了看到这一点,我们可以将一个对象拖到我们的级别中,并将其放置在这样一种方式,即它将保持在摄像机和我们的玩家角色之间。
看一下以下示例;我们首先复制floor
对象。
注意
您可以通过按住Alt键并沿任何方向拖动移动工具的箭头来轻松复制级别中的对象。
图 5.14:选择地板对象
- 接下来,我们更改其
Transform
值,如下图所示:
图 5.15:更新变换值
- 现在当您玩游戏时,您会注意到当角色走到我们复制的地板下方时,您不会失去对玩家角色的视线,而是弹簧臂会使摄像机向下移动,直到您能看到角色:
图 5.16:摄像机角度的变化
- 为了看到当对象不阻挡
Camera
跟踪通道时弹簧臂的行为如何不同,将我们复制的地板对Camera
通道的响应更改为Ignore
,然后再次播放级别。结果将是,当我们的角色走到复制的地板下方时,我们将失去对角色的视线。
完成这些步骤后,您会发现“摄像机”通道用于指定对象是否会导致弹簧臂将摄像机靠近玩家当它与该对象相交时。
现在我们知道如何使用现有的跟踪通道,那么如果我们想创建自己的跟踪通道呢?
练习 5.05:创建自定义 EnemySight 跟踪通道
正如我们之前讨论过的,UE4 自带两个跟踪通道:Visibility
和Camera
。第一个是一个通用通道,我们可以用它来指定哪些对象阻挡了对象的视线,而第二个允许我们指定对象是否阻挡了摄像机弹簧臂和其关联的角色之间的视线。
但是我们如何创建自己的跟踪通道呢?这就是我们将在本练习中探讨的内容。我们将创建一个新的EnemySight
跟踪通道,并使用它来检查敌人是否能看到玩家角色,而不是使用内置的Visibility
通道:
-
通过按编辑器左上角的“编辑”按钮打开“项目设置”,然后转到“碰撞”部分。在那里,您会找到“跟踪通道”部分。它目前为空,因为我们还没有创建自己的跟踪通道。
-
选择
New Trace Channel
选项。一个窗口应该弹出,让您可以命名您的新通道,并根据您项目中的对象设置其默认响应。将我们的新 Trace 通道命名为EnemySight
,并将其默认响应设置为Block
,因为我们希望大多数对象都这样做。 -
创建新的 Trace 通道后,我们必须回到我们的
EnemyCharacter
C++类中,并更改我们在 Line Trace 中比较的 Trace:
// The trace channel we want to compare against
ECollisionChannel Channel = ECollisionChannel::ECC_Visibility;
鉴于我们不再使用Visibility
通道,我们必须引用我们的新通道,但我们该如何做呢?
在项目目录中,您会找到Config
文件夹。该文件夹包含与您的项目相关的几个ini
文件,如DefaultGame.ini
,DefaultEditor.ini
,DefaultEngine.ini
等。每个文件都包含在加载项目时将被初始化的几个属性。这些属性以名称-值对(property=value
)的形式设置,您可以根据需要更改它们的值。
- 当我们创建了我们的
EnemySight
通道时,项目的DefaultEngine.ini
文件将被更新为我们的新 Trace 通道。在那个文件的某个地方,您会找到这一行:
+DefaultChannelResponses=(Channel=ECC_GameTraceChannel1, DefaultResponse=ECR_Block,bTraceType=True,bStaticObject=False, Name="EnemySight")
// The trace channel we want to compare against
ECollisionChannel Channel = ECollisionChannel::ECC_GameTraceChannel1;
- 验证我们的敌人在我们所做的所有更改之后行为是否保持不变。这意味着只要玩家角色在敌人的视野范围内,敌人就必须面对玩家角色。
通过完成这个练习,我们现在知道如何为任何所需的目的创建我们自己的 Trace 通道。
回到我们的敌人角色,还有一些方法可以改进它的逻辑。现在,当我们获取我们敌人的位置作为 Line Trace 的起点时,那个点大约在敌人的臀部附近,因为那是 Actor 的原点。然而,那通常不是人们的眼睛所在的地方,让一个类人角色从它的臀部而不是头部看会没有多大意义。
所以,让我们改变一下,让我们的敌人角色从它的眼睛开始检查是否看到玩家角色,而不是从它的臀部开始。
活动 5.01:创建 SightSource 属性
在这个活动中,我们将改进我们敌人的逻辑,以确定它是否应该看着玩家。目前,用于确定这一点的 Line Trace 是从我们角色的臀部附近(0,0,0
)在我们的BP_EnemyCharacter
蓝图中进行的,我们希望这更有意义一些,所以我们将使 Line Trace 的起点接近我们敌人的眼睛。那么,让我们开始吧。
以下步骤将帮助您完成这个活动:
-
在我们的
EnemyCharacter
C++类中声明一个名为SightSource
的新SceneComponent
。确保将其声明为UPROPERTY
,并使用VisibleAnywhere
,BlueprintReadOnly
,Category = LookAt
和meta = (AllowPrivateAccess = "true")
标签。 -
通过使用
CreateDefaultSubobject
函数在EnemyCharacter
构造函数中创建这个组件,并将其附加到RootComponent
。 -
将 Line Trace 的起始位置更改为
CanSeeActor
函数中的SightSource
组件的位置,而不是 Actor 的位置。 -
打开
BP_EnemyCharacter
蓝图类,并将SightSource
组件的位置更改为敌人头部的位置(10, 0, 80)
,就像在创建 EnemyCharacter 蓝图类部分中对BP_EnemyCharacter
的SkeletalMeshComponent
属性所做的那样。
Editor Panel
中的Transform
选项卡,如图 5.17所示。
图 5.17:更新 SightSource 组件的值
预期输出:
图 5.18:预期输出显示从臀部到眼睛的更新的 Line Trace
注意
这个活动的解决方案可以在这里找到:packt.live/338jEBx
。
通过完成这个活动,我们已经更新了我们的EnemyCharacter
的SightSource
属性。
总结
通过完成这一章,你已经为自己的工具箱增添了一个新工具:线性跟踪。你现在知道如何执行线性跟踪和扫描跟踪,包括单一和多重;如何改变对象对特定跟踪通道的响应;以及如何创建自己的跟踪通道。
在接下来的章节中,你将很快意识到这些是游戏开发中必不可少的技能,并且你将在未来的项目中充分利用它们。
现在我们知道如何使用线性跟踪,我们已经准备好迈出下一步,即对象碰撞。在下一章中,你将学习如何设置对象之间的碰撞,以及如何使用碰撞事件来创建自己的游戏逻辑。你将创建躲避球角色,它将受到实时物理模拟的影响;墙角色,它将阻挡角色的移动和躲避球的移动;以及负责在玩家接触到它时结束游戏的角色。
第六章:碰撞对象
概述
在本章中,我们将继续在上一章介绍的基于碰撞的游戏中添加更多的机制和对象。最初,我们将继续介绍对象碰撞。您将学习如何使用碰撞框、碰撞触发器、重叠事件、击中事件和物理模拟。您还将学习如何使用定时器、投射物移动组件和物理材料。
介绍
在上一章中,我们了解了一些碰撞的基本概念,即线追踪和扫描追踪。我们学习了如何执行不同类型的线追踪,如何创建自定义的追踪通道,以及如何更改对象对特定通道的响应方式。在本章中,我们将使用上一章学到的许多内容,学习关于对象碰撞。
在本章中,我们将继续通过添加围绕对象碰撞的游戏机制来构建我们的顶部“躲避球”游戏。我们将创建躲避球角色,它将作为一个从地板和墙壁上弹开的躲避球;一个墙壁角色,它将阻挡所有对象;一个幽灵墙角色,它只会阻挡玩家,而不会阻挡敌人的视线或躲避球;以及一个胜利箱角色,当玩家进入胜利箱时游戏结束,代表关卡的结束。
在我们开始创建我们的“躲避球”类之前,我们将在下一节中介绍对象碰撞的基本概念。
UE4 中的对象碰撞
每个游戏开发工具都必须有一个模拟多个对象之间碰撞的物理引擎,如前一章所述。碰撞是当今大多数游戏的基础,无论是 2D 还是 3D。在许多游戏中,这是玩家对环境进行操作的主要方式,无论是奔跑、跳跃还是射击,环境都会相应地使玩家着陆、受到打击等。毫不夸张地说,如果没有模拟碰撞,许多游戏根本无法制作。
因此,让我们了解 UE4 中对象碰撞的工作原理以及我们可以使用的方式,从碰撞组件开始。
碰撞组件
在 UE4 中,有两种类型的组件可以影响并受到碰撞的影响;它们如下:
-
网格
-
形状对象
网格可以简单到一个立方体,也可以复杂到有数万个顶点的高分辨率角色。网格的碰撞可以通过与网格一起导入 UE4 的自定义文件指定(这超出了本书的范围),也可以由 UE4 自动计算并由您自定义。
通常最好将碰撞网格保持尽可能简单(少三角形),以便物理引擎可以在运行时高效地计算碰撞。可以具有碰撞的网格类型如下:
-
静态网格
-
骨骼网格
-
程序化网格
-
以及其他
形状对象是简单的网格,在线框模式下表示,通过引起和接收碰撞事件来充当碰撞对象。
注意
线框模式是游戏开发中常用的可视化模式,通常用于调试目的,允许您看到没有任何面或纹理的网格 - 它们只能通过它们的边缘连接的顶点来看到。当我们向角色添加形状组件时,您将看到线框模式是什么。
请注意,形状对象本质上是不可见的网格,它们的三种类型如下:
-
盒形碰撞(C++中的盒形组件)
-
球形碰撞(C++中的球形组件)
-
胶囊碰撞器(C++中的胶囊组件)
注意
有一个类,所有提供几何和碰撞的组件都继承自它,那就是Primitive
组件。这个组件是包含任何类型几何的所有组件的基础,这适用于网格组件和形状组件。
那么,这些组件如何发生碰撞,以及它们碰撞时会发生什么?我们将在下一节中看看这个,即碰撞事件。
碰撞事件
假设有两个对象相互碰撞。可能发生两种情况:
-
它们会互相重叠,好像另一个对象不存在,这种情况下会调用
Overlap
事件。 -
它们会发生碰撞并阻止对方继续前进,这种情况下会调用
Block
事件。
在前一章中,我们学习了如何将对象对特定的Trace
通道的响应进行更改。在这个过程中,我们了解到对象的响应可以是Block
、Overlap
或Ignore
。
现在,让我们看看在碰撞中每种响应发生了什么。
Block
:
-
两个对象都会调用它们的
OnHit
事件。这个事件在两个对象在碰撞时阻止对方路径时被调用。如果其中一个对象正在模拟物理,那么该对象必须将其SimulationGeneratesHitEvents
属性设置为true
。 -
两个对象将互相阻止对方继续前进。
看一下下面的图,它展示了两个对象被扔出并互相弹开的例子:
图 6.1:对象 A 和对象 B 互相阻止对方
Overlap:如果两个对象不会互相阻止对方,并且它们中没有一个忽略另一个,那么它们会互相重叠:
-
如果两个对象的
GenerateOverlapEvents
属性都设置为true
,它们将调用它们的OnBeginOverlap
和OnEndOverlap
事件。这些重叠事件分别在一个对象开始和停止与另一个对象重叠时调用。如果它们中至少有一个没有将此属性设置为true
,则它们都不会调用这些事件。 -
对象会表现得好像另一个对象不存在,并且会互相重叠。
举个例子,假设玩家角色走进一个只对玩家角色做出反应的关卡结束触发器。
看一下下面的图,它展示了两个对象互相重叠的例子:
图 6.2:对象 A 和对象 B 互相重叠
Ignore:如果两个对象中至少有一个忽略另一个,它们会互相忽略:
-
任何一个对象都不会调用事件。
-
与
Overlap
响应类似,对象会表现得好像另一个对象不存在,并且会互相重叠。
两个对象互相忽略的一个例子是,当除了玩家角色以外的对象进入一个只对玩家角色做出反应的关卡结束触发器时。
注意
你可以看一下之前的图,两个对象互相重叠,以理解Ignore。
以下是一个表格,帮助你理解两个对象必须具有的必要响应,以触发先前描述的情况:
图 6.3:基于 Block、Overlap 和 Ignore 的对象的响应结果
根据这个表格,考虑你有两个对象 - 对象 A 和对象 B:
-
如果对象 A 将其响应设置为对象 B 的
Block
,而对象 B 将其响应设置为对象 A 的Block
,它们将会互相阻止对方。 -
如果对象 A 将其响应设置为对象 B 的
Block
,而对象 B 将其响应设置为对象 A 的Overlap
,它们将会互相重叠。 -
如果物体 A 将其对物体 B 的响应设置为“忽略”,而物体 B 将其对物体 A 的响应设置为“重叠”,它们将互相“忽略”。
注意
您可以在这里找到 UE4 碰撞交互的完整参考:docs.unrealengine.com/en-US/Engine/Physics/Collision/Overview
。
物体之间的碰撞有两个方面:
物理学:所有与物理模拟相关的碰撞,比如球受重力影响并从地板和墙壁上弹开。
游戏中的碰撞的物理模拟响应,可以是:
-
两个物体继续它们的轨迹,就好像另一个物体不存在一样(没有物理碰撞)。
-
两个物体相撞并改变它们的轨迹,通常至少有一个物体继续其运动,即阻挡彼此的路径。
查询:查询可以分为碰撞的两个方面,如下所示:
-
与游戏中的物体碰撞相关的事件,您可以使用这些事件创建额外的逻辑。这些事件与我们之前提到的是相同的:
-
“命中”事件
-
“开始重叠”事件
-
“结束重叠”事件
-
游戏中的碰撞的物理响应,可以是:
-
两个物体继续它们的运动,就好像另一个物体不存在一样(没有物理碰撞)。
-
两个物体相撞并阻挡彼此的路径
物理方面的物理响应可能听起来与查询方面的物理响应相似;然而,尽管它们都是物理响应,但它们会导致对象的行为不同。
物理方面的物理响应(物理模拟)仅适用于物体在模拟物理时(例如受重力影响、从墙壁和地面弹开等)。当这样的物体撞到墙壁时,会弹回并继续朝另一个方向移动。
另一方面,查询方面的物理响应适用于所有不模拟物理的物体。当一个物体不模拟物理时,可以通过代码控制移动(例如使用SetActorLocation
函数或使用角色移动组件)。在这种情况下,根据您用于移动物体的方法和其属性,当物体撞到墙壁时,它将简单地停止移动而不是弹回。这是因为您只是告诉物体朝某个方向移动,而有东西挡住了它的路径,所以物理引擎不允许该物体继续移动。
在下一节中,我们将看看碰撞通道。
碰撞通道
在上一章中,我们看了现有的跟踪通道(可见性和相机)并学习了如何创建自定义通道。现在您已经了解了跟踪通道,是时候谈谈对象通道,也称为对象类型了。
虽然跟踪通道仅用于线跟踪,但对象通道用于对象碰撞。您可以为每个“对象”通道指定一个“目的”,就像跟踪通道一样,比如角色、静态对象、物理对象、抛射物等等。然后,您可以指定您希望每种对象类型如何响应所有其他对象类型,即通过阻挡、重叠或忽略该类型的对象。
碰撞属性
现在我们已经了解了碰撞的工作原理,让我们回到上一章中选择的立方体的碰撞设置,我们在那里将其响应更改为可见性通道。
在下面的截图中可以看到立方体:
图 6.4:立方体阻挡敌人的视觉源
在编辑器中打开关卡,选择立方体并转到其详细面板的“碰撞”部分:
图 6.5:级别编辑器中的变化
在这里,我们可以看到一些对我们很重要的选项:
-
SimulationGeneratesHitEvents
,当物体模拟物理时允许调用OnHit
事件(我们将在本章后面讨论这个)。 -
GenerateOverlapEvents
,允许调用OnBeginOverlap
和OnEndOverlap
事件。 -
CanCharacterStepUpOn
,允许角色轻松站在这个物体上。 -
CollisionPresets
,允许我们指定此对象如何响应每个碰撞通道。
让我们将CollisionPresets
的值从默认
更改为自定义
,并查看出现的新选项:
图 6.6:碰撞预设的变化
这些选项中的第一个是CollisionEnabled
属性。它允许您指定要考虑此对象的碰撞的哪些方面:查询、物理、两者或无。再次,物理碰撞与物理模拟相关(此物体是否会被模拟物理的其他物体考虑),而查询碰撞与碰撞事件相关,以及物体是否会阻挡彼此的移动:
图 6.7:查询和物理的碰撞启用
第二个选项是ObjectType
属性。这与跟踪通道概念非常相似,但专门用于对象碰撞,并且最重要的是决定了这是什么类型的碰撞对象。UE4 提供的对象类型值如下:
-
WorldStatic
:不移动的物体(结构、建筑等) -
WorldDynamic
:可能移动的物体(由代码触发移动的物体,玩家可以拾取和移动的物体等) -
Pawn
:用于可以在级别中控制和移动的 Pawns -
PhysicsBody
:用于模拟物理的物体 -
Vehicle
:用于车辆物体 -
可破坏
:用于可破坏的网格
如前所述,您还可以创建自己的自定义对象类型(稍后在本章中提到),类似于您可以创建自己的跟踪通道(在上一章中介绍过)。
我们拥有的最后一个选项与碰撞响应
有关。鉴于这个Cube
对象具有默认的碰撞选项,所有响应都设置为阻挡
,这意味着该对象将阻挡所有线跟踪和所有阻挡WorldStatic
对象的对象,鉴于这是该对象的类型。
由于碰撞属性有很多不同的组合,UE4 允许您以碰撞预设的形式对碰撞属性值进行分组。
让我们回到CollisionPresets
属性,它当前设置为自定义
,并点击以查看所有可能的选项。一些现有的碰撞预设
如下:
无碰撞:用于根本不受碰撞影响的物体:
-
碰撞启用
:无碰撞
-
物体类型
:WorldStatic
-
响应:无关
-
示例:纯粹是视觉和遥远的物体,如玩家永远不会接触的物体
全部阻挡:用于静态物体并阻挡所有其他物体:
-
碰撞启用
:查询
和物理
-
物体类型
:WorldStatic
-
响应:
阻挡
所有通道 -
示例:靠近玩家角色并阻挡其移动的物体,如地板和墙壁,将始终保持静止
重叠所有:用于静态物体并与所有其他物体重叠:
-
碰撞启用
:仅查询
-
物体类型
:WorldStatic
-
响应:
重叠
所有通道 -
示例:放置在级别中的触发框,将始终保持静止
全部阻挡
预设,但用于可能在游戏过程中改变其变换的动态物体(物体类型
:WorldDynamic
)
Overlap All
预设,但对于可能在游戏过程中改变其变换的动态对象(对象类型
:WorldDynamic
)
Pawn:用于 pawns 和 characters:
-
碰撞使能
:Query
和Physics
-
对象类型
:Pawn
-
响应:
Block
所有通道,Ignore
可见性通道 -
示例:玩家角色和非玩家角色
物理演员:用于模拟物理的对象:
-
碰撞使能
:Query
和Physics
-
对象类型
:PhysicsBody
-
响应:
Block
所有通道 -
示例:受物理影响的对象,比如从地板和墙壁上弹开的球
就像其他碰撞属性一样,你也可以创建自己的碰撞预设。
注意
你可以在这里找到 UE4 碰撞响应的完整参考:docs.unrealengine.com/en-US/Engine/Physics/Collision/Reference
。
现在我们了解了碰撞的基本概念,让我们继续开始创建Dodgeball
类。下一个练习将指导你完成这个任务。
练习 6.01:创建 Dodgeball 类
在这个练习中,我们将创建我们的Dodgeball
类,这个类将被敌人投掷,并且会像真正的躲避球一样从地板和墙壁上弹开。
在我们真正开始创建Dodgeball
C++类和它的逻辑之前,我们应该为它设置所有必要的碰撞设置。
以下步骤将帮助你完成这个练习:
-
打开我们的
Project Settings
并转到Engine
部分中的Collision
子部分。当前没有对象通道,所以你需要创建一个新的。 -
点击
New Object Channel
按钮,命名为Dodgeball
,并将其默认响应
设置为Block
。 -
完成后,展开
Preset
部分。在这里,你会找到 UE4 中所有默认的预设。如果你选择其中一个并按下Edit
选项,你可以更改该Preset
碰撞的设置。 -
通过按下
New
选项创建自己的Preset
。我们希望我们的Dodgeball
Preset
设置如下:
-
名称
:Dodgeball
-
CollisionEnabled
:Collision Enabled (Query and Physics)
(我们希望这也被考虑为物理模拟以及碰撞事件) -
对象类型
:Dodgeball
-
碰撞响应
:对大多数选项选择Block,但对于相机和EnemySight
选择Ignore(我们不希望躲避球阻挡相机或敌人的视线)
- 一旦你选择了正确的选项,点击
Accept
。
现在Dodgeball
类的碰撞设置已经设置好了,让我们创建Dodgeball
C++类。
-
在
Content Browser
中,右键单击并选择New C++ Class
。 -
选择
Actor
作为父类。 -
选择
DodgeballProjectile
作为类的名称(我们的项目已经命名为Dodgeball
,所以我们不能再将这个新类命名为Dodgeball
)。 -
在 Visual Studio 中打开
DodgeballProjectile
类文件。我们首先要做的是添加躲避球的碰撞组件,所以我们将在我们的类头文件中添加一个SphereComponent
(actor 组件属性通常是私有的):
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Dodgeball, meta = (AllowPrivateAccess = "true"))
class USphereComponent* SphereComponent;
- 接下来,在我们的源文件顶部包含
SphereComponent
类:
#include "Components/SphereComponent.h"
注意
请记住,所有头文件包含都必须在.generated.h
之前。
现在,前往DodgeballProjectile
类的构造函数,在其源文件中执行以下步骤。
- 创建
SphereComponent
对象:
SphereComponent = CreateDefaultSubobject<USphereComponent>(TEXT("Sphere Collision"));
- 将其
半径
设置为35
个单位:
SphereComponent->SetSphereRadius(35.f);
- 将其
碰撞预设
设置为我们创建的Dodgeball
预设:
SphereComponent->SetCollisionProfileName(FName("Dodgeball"));
- 我们希望
Dodgeball
模拟物理,因此通知组件进行如下所示的设置:
SphereComponent->SetSimulatePhysics(true);
- 我们希望
Dodgeball
在模拟物理时调用OnHit
事件,因此调用SetNotifyRigidBodyCollision
函数以将其设置为true
(这与我们在对象属性的Collision
部分看到的SimulationGeneratesHitEvents
属性相同):
//Simulation generates Hit events
SphereComponent->SetNotifyRigidBodyCollision(true);
我们还希望监听SphereComponent
的OnHit
事件。
- 在
DodgeballProjectile
类的头文件中为将在OnHit
事件触发时调用的函数创建声明。此函数应该被命名为OnHit
。它应该是public
,不返回任何内容(void
),具有UFUNCTION
宏,并按照以下顺序接收一些参数:
-
UPrimitiveComponent* HitComp
:被击中并属于此演员的组件。原始组件是具有Transform
属性和某种几何形状(例如Mesh
或Shape
组件)的演员组件。 -
AActor* OtherActor
:碰撞中涉及的另一个演员。 -
UPrimitiveComponent* OtherComp
:被击中并属于其他演员的组件。 -
FVector NormalImpulse
:对象被击中后将移动的方向,以及以多大的力(通过检查向量的大小)。此参数仅对模拟物理的对象是非零的。 -
FHitResult& Hit
:碰撞结果的数据,包括此对象与其他对象之间的碰撞。正如我们在上一章中看到的,它包含诸如Hit
位置、法线、击中的组件和演员等属性。大部分相关信息已经通过其他参数可用,但如果需要更详细的信息,可以访问此参数:
UFUNCTION()
void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);
将OnHit
函数的实现添加到类的源文件中,并在该函数中,至少暂时,当它击中玩家时销毁躲避球。
- 将
OtherActor
参数转换为我们的DodgeballCharacter
类,并检查该值是否不是nullptr
。如果不是,则表示我们击中的其他演员是DodgeballCharacter
,我们将销毁此DodgeballProjectile
演员:
void ADodgeballProjectile::OnHit(UPrimitiveComponent * HitComp, AActor * OtherActor, UPrimitiveComponent * OtherComp, FVector NormalImpulse, const FHitResult & Hit)
{
if (Cast<ADodgeballCharacter>(OtherActor) != nullptr)
{
Destroy();
}
}
鉴于我们正在引用DodgebalCharacter
类,我们需要在此类的源文件顶部包含它:
#include "DodgeballCharacter.h"
注意
在下一章中,我们将更改此函数,使得躲避球在销毁自身之前对玩家造成伤害。我们将在讨论 Actor 组件时进行此操作。
- 返回
DodgeballProjectile
类的构造函数,并在末尾添加以下行,以便监听SphereComponent
的OnHit
事件:
// Listen to the OnComponentHit event by binding it to our function
SphereComponent->OnComponentHit.AddDynamic(this, &ADodgeballProjectile::OnHit);
这将绑定我们创建的OnHit
函数到这个SphereComponent
的OnHit
事件(因为这是一个演员组件,此事件称为OnComponentHit
),这意味着我们的函数将与该事件一起被调用。
- 最后,将
SphereComponent
设置为该演员的RootComponent
,如下面的代码片段所示:
// Set this Sphere Component as the root component,
// otherwise collision won't behave properly
RootComponent = SphereComponent;
注意
为了使移动的演员在碰撞时正确行为,无论是否模拟物理,通常需要将演员的主要碰撞组件设置为其RootComponent
。
例如,Character
类的RootComponent
是 Capsule Collider 组件,因为该演员将在周围移动,该组件是角色与环境碰撞的主要方式。
现在我们已经添加了DodgeballProjectile
C++类的逻辑,让我们继续创建我们的蓝图类。
-
编译更改并打开编辑器。
-
转到内容浏览器中的
Content
>ThirdPersonCPP
>Blueprints
目录,右键单击,创建一个新的蓝图类。 -
展开“所有类”部分,搜索
DodgeballProjectile
类,然后将其设置为父类。 -
将新的蓝图类命名为
BP_DodgeballProjectile
。 -
打开这个新的蓝图类。
-
注意演员视口窗口中
SphereCollision
组件的线框表示(默认情况下在游戏过程中隐藏,但可以通过更改此组件的Rendering
部分中的HiddenInGame
属性来更改该属性):
图 6.8:SphereCollision 组件的视觉线框表示
- 现在,添加一个新的
球体
网格作为现有的球体碰撞
组件的子级:
图 6.9:添加一个球体网格
- 将其比例更改为
0.65
,如下图所示:
图 6.10:更新比例
- 将其
碰撞预设
设置为无碰撞
:
图 6.11:更新碰撞预设为无碰撞
- 最后,打开我们的关卡,并在玩家附近放置一个
BP_DodgeballProjectile
类的实例(这个实例放置在 600 单位的高度):
图 6.12:躲避球在地面上弹跳
完成这些操作后,玩这个关卡。你会注意到躲避球会受到重力的影响,在触地几次后停止下来。
通过完成这个练习,你已经创建了一个行为像物理对象的对象。
现在你知道如何创建自己的碰撞对象类型,使用OnHit
事件,并更改对象的碰撞属性。
注意
在上一章中,我们简要提到了LineTraceSingleByObjectType
。现在我们知道对象碰撞是如何工作的,我们可以简要提到它的用法:当执行检查追踪通道的线追踪时,应该使用LineTraceSingleByChannel
函数;当执行检查对象
通道(对象类型)的线追踪时,应该使用LineTraceSingleByObjectType
函数。应该明确指出,与LineTraceSingleByChannel
函数不同,这个函数不会检查阻挡特定对象类型的对象,而是检查特定对象类型的对象。这两个函数具有完全相同的参数,追踪通道和对象通道都可以通过ECollisionChannel
枚举来使用。
但是,如果你想让球在地板上弹跳更多次呢?如果你想让它更有弹性呢?那么物理材料就派上用场了。
物理材料
在 UE4 中,你可以通过物理材料来自定义对象在模拟物理时的行为方式。为了进入这种新类型的资产,让我们创建我们自己的:
-
在
内容
文件夹内创建一个名为物理
的新文件夹。 -
在该文件夹内的
内容浏览器
上右键单击,并在创建高级资产
部分下,转到物理
子部分并选择物理材料
。 -
将这个新的物理材料命名为
PM_Dodgeball
。 -
打开资产并查看可用选项。
图 6.13:资产选项
我们应该注意的主要选项如下:
-
摩擦
:此属性从0
到1
,指定摩擦对这个对象的影响程度(0
表示此对象会像在冰上一样滑动,而1
表示此对象会像一块口香糖一样粘住)。 -
弹性
(也称为弹性):此属性从0
到1
,指定与另一个对象碰撞后保留多少速度(0
表示此对象永远不会从地面上弹跳,而1
表示此对象将长时间弹跳)。 -
密度
:此属性指定这个对象有多密集(即相对于其网格有多重)。两个对象可以是相同大小的,但如果一个比另一个密度高两倍,那就意味着它会重两倍。
为了让我们的DodgeballProjectile
对象更接近实际的躲避球,它将不得不承受相当大的摩擦(默认值为0.7
,足够高),并且非常有弹性。让我们将这个物理材料的弹性
属性增加到0.95
。
完成这些操作后,打开BP_DodgeballProjectile
蓝图类,并在其碰撞
部分内更改球体碰撞组件的物理材料为我们刚刚创建的PM_Dodgeball
:
图 6.14:更新 BP_DodgeballProjectile 蓝图类
注意
确保您在级别中添加的躲避球角色实例也具有这种物理材料。
如果您再次玩我们在练习 6.01中创建的级别,创建躲避球类,您会注意到我们的BP_DodgeballProjectile
现在会在停止之前在地面上反弹几次,行为更像一个真正的躲避球。
做完所有这些之后,我们只缺少一个东西,让我们的Dodgeball
角色行为像一个真正的躲避球。现在,我们没有办法投掷它。所以,让我们通过创建一个投射物移动组件来解决这个问题,这就是我们下一个练习要做的事情。
在之前的章节中,当我们复制第三人称模板项目时,我们了解到 UE4 自带的Character
类具有CharacterMovementComponent
。这个角色组件是允许角色以各种方式在级别中移动的,它有许多属性,允许您根据自己的喜好进行自定义。然而,还有另一个经常使用的移动组件:ProjectileMovementComponent
。
ProjectileMovementComponent
角色组件用于将投射物的行为赋予角色。它允许您设置初始速度、重力力量,甚至一些物理模拟参数,如“弹性”和“摩擦力”。然而,鉴于我们的Dodgeball Projectile
已经在模拟物理,我们将使用的唯一属性是InitialSpeed
。
练习 6.02:向 DodgeballProjectile 添加一个投射物移动组件
在这个练习中,我们将向我们的DodgeballProjectile
添加一个ProjectileMovementComponent
,以便它具有初始的水平速度。我们这样做是为了让我们的敌人可以投掷它,而不仅仅是垂直下落。
以下步骤将帮助您完成这个练习:
- 在
DodgeballProjectile
类的头文件中添加一个ProjectileMovementComponent
属性:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Dodgeball, meta = (AllowPrivateAccess = "true"))
class UProjectileMovementComponent* ProjectileMovement;
- 在类的源文件顶部包含
ProjectileMovementComponent
类:
#include "GameFramework/ProjectileMovementComponent.h"
- 在类的构造函数末尾,创建
ProjectileMovementComponent
对象:
ProjectileMovement = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("Pro jectile Movement"));
- 然后,将其
InitialSpeed
设置为1500
单位:
ProjectileMovement->InitialSpeed = 1500.f;
完成此操作后,编译您的项目并打开编辑器。为了演示躲避球的初始速度,将其在Z轴上降低,并将其放在玩家后面(这个放置在高度为 200 单位的位置):
图 6.15:躲避球沿 X 轴移动
当您玩这个级别时,您会注意到躲避球开始朝着它的X轴移动(红色箭头):
有了这个,我们可以结束我们的练习了。我们的DodgeballProjectile
现在的行为就像一个真正的躲避球。它下落,弹跳,被投掷。
我们项目的下一步是为我们的EnemyCharacter
添加逻辑,使其向玩家投掷这些躲避球,但在解决这个问题之前,我们必须先解决计时器的概念。
计时器
鉴于视频游戏的性质以及它们是强烈基于事件的,每个游戏开发工具都必须有一种方法让您在发生某事之前引起延迟或等待时间。例如,当您玩在线死亡竞赛游戏时,您的角色可以死亡然后重生,通常情况下,重生事件不会在您的角色死亡后立即发生,而是几秒钟后。有很多情况下,您希望某事发生,但只能在一定时间后发生。这将是我们的EnemyCharacter
的情况,它将每隔几秒钟投掷一次躲避球。这种延迟或等待时间可以通过计时器实现。
定时器允许您在一定时间后调用一个函数。您可以选择以一定的时间间隔循环调用该函数,并在循环开始之前设置延迟。如果要停止定时器,也可以这样做。
我们将使用定时器,这样我们的敌人就可以每隔X
时间投掷一个躲避球,只要它能看到玩家角色,并且当敌人不能再看到其目标时停止定时器。
在我们开始为EnemyCharacter
类添加逻辑,使其向玩家投掷躲避球之前,我们应该看一下另一个主题,即如何生成演员。
生成演员
在第一章,虚幻引擎介绍中,您学会了如何通过编辑器在级别中放置您创建的演员,但是如果您想在游戏进行时将该演员放置在级别中呢?这就是我们现在要看的。
UE4,就像大多数其他游戏开发工具一样,允许您在游戏运行时放置一个演员。这个过程称为SpawnActor
函数,可从World
对象(我们可以使用之前提到的GetWorld
函数访问)中获得。但是,SpawnActor
函数有一些需要传递的参数,如下所示:
-
一个
UClass*
属性,让函数知道将要生成的对象的类。这个属性可以是一个 C++类,通过NameOfC++Class::StaticClass()
函数可用,也可以是一个蓝图类,通过TSubclassOf
属性可用。通常最好不要直接从 C++类生成演员,而是创建一个蓝图类并生成该类的实例。 -
TSubclassOf
属性是您在 C++中引用蓝图类的一种方式。它用于在 C++代码中引用一个类,该类可能是蓝图类。您使用模板参数声明TSubclassOf
属性,该参数是该类必须继承的 C++类。我们将在下一个练习中看一下如何在实践中使用这个属性。 -
无论是
FTransform
属性还是FVector
和FRotator
属性,都将指示我们想要生成的对象的位置、旋转和比例。 -
一个可选的
FActorSpawnParameters
属性,允许您指定与生成过程相关的更多属性,例如谁导致演员生成(即Instigator
),如何处理对象生成,如果生成位置被其他对象占用,可能会导致重叠或阻塞事件等。
SpawnActor
函数将返回从此函数生成的演员的实例。鉴于它也是一个模板函数,您可以以这样的方式调用它,以便使用模板参数直接接收到您生成的演员类型的引用:
GetWorld()->SpawnActor<NameOfC++Class>(ClassReference, SpawnLocation, SpawnRotation);
在这种情况下,正在调用SpawnActor
函数,我们正在生成NameOfC++Class
类的一个实例。在这里,我们使用ClassReference
属性提供对类的引用,并使用SpawnLocation
和SpawnRotation
属性分别提供要生成的演员的位置和旋转。
您将在练习 6.03,向 EnemyCharacter 添加投掷项目逻辑中学习如何应用这些属性。
在继续练习之前,我想简要提一下SpawnActor
函数的一个变体,这也可能会派上用场:SpawnActorDeferred
函数。SpawnActor
函数将创建您指定的对象的实例,然后将其放置在世界中,而这个新的SpawnActorDeferred
函数将创建您想要的对象的实例,并且只有在调用演员的FinishSpawning
函数时才将其放置在世界中。
例如,假设我们想在生成 Dodgeball 时更改其InitialSpeed
。如果我们使用SpawnActor
函数,Dodgeball 有可能在我们设置其InitialSpeed
属性之前开始移动。然而,通过使用SpawnActorDeferred
函数,我们可以创建一个 dodge ball 的实例,然后将其InitialSpeed
设置为我们想要的任何值,然后通过调用新创建的 dodgeball 的FinishSpawning
函数将其放置在世界中,该函数的实例由SpawnActorDeferred
函数返回给我们。
现在我们知道如何在世界中生成一个 actor,也知道定时器的概念,我们可以在下一个练习中向我们的EnemyCharacter
类添加负责投掷 dodge 球的逻辑。
练习 6.03:向 EnemyCharacter 添加投掷投射物的逻辑
在这个练习中,我们将向我们刚刚创建的EnemyCharacter
类添加负责投掷 Dodgeball actor 的逻辑。
在 Visual Studio 中打开类的文件以开始。我们将首先修改我们的LookAtActor
函数,以便我们可以保存告诉我们是否能看到玩家的值,并用它来管理我们的定时器。
按照以下步骤完成这个练习:
- 在
EnemyCharacter
类的头文件中,将LookAtActor
函数的返回类型从void
更改为bool
:
// Change the rotation of the character to face the given actor
// Returns whether the given actor can be seen
bool LookAtActor(AActor* TargetActor);
- 在函数的实现中做同样的事情,在类的源文件中,同时在我们调用
CanSeeActor
函数的if
语句结束时返回true
。还在我们检查TargetActor
是否为nullptr
的第一个if
语句中返回false
,并在函数的结尾返回false
:
bool AEnemyCharacter::LookAtActor(AActor * TargetActor)
{
if (TargetActor == nullptr) return false;
if (CanSeeActor(TargetActor))
{
FVector Start = GetActorLocation();
FVector End = TargetActor->GetActorLocation();
// Calculate the necessary rotation for the Start point to face the End point
FRotator LookAtRotation = UKismetMathLibrary::FindLookAtRotation(Start, End);
//Set the enemy's rotation to that rotation
SetActorRotation(LookAtRotation);
return true;
}
return false;
}
- 接下来,在你的类头文件中添加两个
bool
属性,bCanSeePlayer
和bPreviousCanSeePlayer
,设置为protected
,它们将表示敌人角色在这一帧中是否能看到玩家,以及上一帧中玩家是否能被看到:
//Whether the enemy can see the player this frame
bool bCanSeePlayer = false;
//Whether the enemy could see the player last frame
bool bPreviousCanSeePlayer = false;
- 然后,转到你的类的
Tick
函数实现,并将bCanSeePlayer
的值设置为LookAtActor
函数的返回值。这将替换对LookAtActor
函数的先前调用:
// Look at the player character every frame
bCanSeePlayer = LookAtActor(PlayerCharacter);
- 然后,将
bPreviousCanSeePlayer
的值设置为bCanSeePlayer
的值:
bPreviousCanSeePlayer = bCanSeePlayer;
- 在前两行之间添加一个
if
语句,检查bCanSeePlayer
和bPreviousCanSeePlayer
的值是否不同。这意味着我们上一帧看不到玩家,现在可以看到,或者我们上一帧看到玩家,现在看不到:
bCanSeePlayer = LookAtActor(PlayerCharacter);
if (bCanSeePlayer != bPreviousCanSeePlayer)
{
}
bPreviousCanSeePlayer = bCanSeePlayer;
- 在这个
if
语句中,如果我们能看到玩家,我们希望启动一个定时器,如果我们不能再看到玩家,就停止定时器:
if (bCanSeePlayer != bPreviousCanSeePlayer)
{
if (bCanSeePlayer)
{
//Start throwing dodgeballs
}
else
{
//Stop throwing dodgeballs
}
}
- 为了启动一个定时器,我们需要在类的头文件中添加以下属性,它们都可以是
protected
:
- 一个
FTimerHandle
属性,负责标识我们要启动的定时器。它基本上作为特定定时器的标识符:
FTimerHandle ThrowTimerHandle;
- 一个
float
属性,表示投掷 dodgeball 之间等待的时间(间隔),以便我们可以循环定时器。我们给它一个默认值2
秒:
float ThrowingInterval = 2.f;
- 另一个
float
属性,表示定时器开始循环之前的初始延迟。让我们给它一个默认值0.5
秒:
float ThrowingDelay = 0.5f;
- 一个在定时器结束时调用的函数,我们将创建并命名为
ThrowDodgeball
。这个函数不返回任何值,也不接收任何参数:
void ThrowDodgeball();
在我们的源文件中,为了调用适当的函数启动定时器,我们需要添加一个#include
到负责这个的对象FTimerManager
。
每个World
都有一个定时器管理器,它可以启动和停止定时器,并访问与它们相关的相关函数,比如它们是否仍然活动,它们运行了多长时间等等:
#include "TimerManager.h"
- 现在,使用
GetWorldTimerManager
函数访问当前世界的定时器管理器:
GetWorldTimerManager()
- 接下来,如果我们能看到玩家角色,就调用定时器管理器的
SetTimer
函数,以启动负责投掷躲避球的计时器。SetTimer
函数接收以下参数:
-
代表所需计时器的
FTimerHandle
:ThrowTimerHandle
。 -
要调用的函数所属的对象:
this
。 -
要调用的函数,必须通过在其名称前加上
&ClassName::
来指定,得到&AEnemyCharacter::ThrowDodgeball
。 -
计时器的速率或间隔:
ThrowingInterval
。 -
这个计时器是否会循环:
true
。 -
这个计时器开始循环之前的延迟:
ThrowingDelay
。
以下代码片段包括这些参数:
if (bCanSeePlayer)
{
//Start throwing dodgeballs
GetWorldTimerManager().SetTimer(ThrowTimerHandle,this, &AEnemyCharacter::ThrowDodgeball,ThrowingInterval,true, ThrowingDelay);
}
- 如果我们看不到玩家并且想要停止计时器,可以使用
ClearTimer
函数来实现。这个函数只需要接收一个FTimerHandle
属性作为参数:
else
{
//Stop throwing dodgeballs
GetWorldTimerManager().ClearTimer(ThrowTimerHandle);
}
现在唯一剩下的就是实现ThrowDodgeball
函数。这个函数将负责生成一个新的DodgeballProjectile
角色。为了做到这一点,我们需要一个引用要生成的类,它必须继承自DodgeballProjectile
,所以下一步我们需要使用TSubclassOf
对象创建适当的属性。
- 在
EnemyCharacter
头文件中创建TSubclassOf
属性,可以是public
:
//The class used to spawn a dodgeball object
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Dodgeball)
TSubclassOf<class ADodgeballProjectile> DodgeballClass;
- 因为我们将使用
DodgeballProjectile
类,所以我们还需要在EnemyCharacter
源文件中包含它:
#include "DodgeballProjectile.h"
- 然后,在源文件中
ThrowDodgeball
函数的实现中,首先检查这个属性是否为nullptr
。如果是,我们立即return
:
void AEnemyCharacter::ThrowDodgeball()
{
if (DodgeballClass == nullptr)
{
return;
}
}
- 接下来,我们将从该类中生成一个新的角色。它的位置将在敌人前方
40
个单位,旋转角度与敌人相同。为了在敌人前方生成躲避球,我们需要访问敌人的ForwardVector
属性,这是一个单位FVector
(意味着它的长度为 1),表示角色面对的方向,并将其乘以我们想要生成躲避球的距离,即40
个单位:
FVector ForwardVector = GetActorForwardVector();
float SpawnDistance = 40.f;
FVector SpawnLocation = GetActorLocation() + (ForwardVector * SpawnDistance);
//Spawn new dodgeball
GetWorld()->SpawnActor<ADodgeballProjectile>(DodgeballClass, SpawnLocation, GetActorRotation());
这完成了我们需要对EnemyCharacter
类进行的修改。在完成设置此逻辑的蓝图之前,让我们快速修改一下我们的DodgeballProjectile
类。
-
在 Visual Studio 中打开
DodgeballProjectile
类的源文件。 -
在其
BeginPlay
事件中,将其LifeSpan
设置为5
秒。这个属性属于所有角色,规定了它们在游戏中还会存在多久才会被销毁。通过在BeginPlay
事件中将我们的躲避球的LifeSpan
设置为5
秒,我们告诉 UE4 在它生成后 5 秒后销毁该对象(或者,如果它已经放置在关卡中,在游戏开始后 5 秒)。我们这样做是为了避免在一定时间后地板上充满了躲避球,这会让游戏对玩家来说变得意外困难:
void ADodgeballProjectile::BeginPlay()
{
Super::BeginPlay();
SetLifeSpan(5.f);
}
现在我们已经完成了与EnemyCharacter
类的躲避球投掷逻辑相关的 C++逻辑,让我们编译我们的更改,打开编辑器,然后打开我们的BP_EnemyCharacter
蓝图。在那里,转到Class Defaults
面板,并将DodgeballClass
属性的值更改为BP_DodgeballProjectile
:
图 6.16:更新躲避球类
完成后,如果还在的话,可以移除我们在关卡中放置的BP_DodgeballProjectile
类的现有实例。
现在,我们可以玩我们的关卡。你会注意到敌人几乎立即开始向玩家投掷躲避球,并且只要玩家角色在视线中,它就会继续这样做:
图 6.17:敌人角色在玩家视线中投掷躲避球
有了这个,我们已经完成了EnemyCharacter
的躲避球投掷逻辑。您现在知道如何使用定时器,这是任何游戏程序员的必备工具。
墙
我们项目的下一步将是创建Wall
类。我们将有两种类型的墙:
-
一个普通的墙,它将阻挡敌人的视线,玩家角色和躲避球。
-
一个幽灵墙,它只会阻挡玩家角色,而不会阻挡敌人的视线和躲避球。您可能会在特定类型的益智游戏中找到这种类型的碰撞设置。
我们将在下一个练习中创建这两个 Wall 类。
练习 6.04:创建 Wall 类
在这个练习中,我们将创建代表普通Wall
和GhostWall
的Wall
类,后者只会阻挡玩家角色的移动,而不会阻挡敌人的视线或他们投掷的躲避球。
让我们从普通的Wall
类开始。这个 C++类基本上是空的,因为它唯一需要的是一个网格,以便反射抛射物并阻挡敌人的视线,这将通过其蓝图类添加。
以下步骤将帮助您完成此练习:
-
打开编辑器。
-
在内容浏览器的左上角,按绿色的
添加新
按钮。 -
在顶部选择第一个选项;
添加功能或内容包
。 -
将会出现一个新窗口。选择
内容包
选项卡,然后选择Starter Content
包,然后按添加到项目
按钮。这将向项目中添加一些基本资产,我们将在本章和一些后续章节中使用。 -
创建一个名为
Wall
的新的 C++类,其父类为Actor
类。 -
接下来,在 Visual Studio 中打开类的文件,并将
SceneComponent
添加为我们的 Wall 的RootComponent
:
Header
文件将如下所示:
private:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Wall, meta = (AllowPrivateAccess = "true"))
class USceneComponent* RootScene;
Source
文件将如下所示:
AWall::AWall()
{
// 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;
RootScene = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
RootComponent = RootScene;
}
-
编译您的代码并打开编辑器。
-
接下来,转到内容浏览器中的
Content
>ThirdPersonCPP
>:Blueprints
目录,创建一个新的蓝图类,该类继承自Wall
类,命名为BP_Wall
,然后打开该资产。 -
添加一个静态网格组件,并将其
StaticMesh
属性设置为Wall_400x300
。 -
将其
Material
属性设置为M_Metal_Steel
。 -
将静态网格组件的位置设置在X轴上为
-200
单位(以便网格相对于我们的角色原点居中):
图 6.18:更新静态网格组件的位置
这是您的蓝图类的视口应该看起来的样子:
图 6.19:蓝图类的视口墙
注意
通常最好将SceneComponent
添加为对象的RootComponent
,当不需要碰撞组件时,以便允许更多的灵活性与其子组件。
演员的RootComponent
不能修改其位置或旋转,这就是为什么在我们的情况下,如果我们在 Wall C++类中创建了一个静态网格组件,并将其设置为其 Root Component,而不是使用场景组件,我们将很难对其进行偏移。
现在我们已经设置了常规的Wall
类,让我们创建我们的GhostWall
类。因为这些类没有设置任何逻辑,我们只是将GhostWall
类创建为BP_Wall
蓝图类的子类,而不是我们的 C++类。
-
右键单击
BP_Wall
资产,然后选择创建子蓝图类
。 -
将新的蓝图命名为
BP_GhostWall
。 -
打开它。
-
更改静态网格组件的碰撞属性:
-
将其
CollisionPreset
设置为Custom
。 -
将其响应更改为
EnemySight
和Dodgeball
通道都为Overlap
。
- 将静态网格组件的
Material
属性更改为M_Metal_Copper
。
您的BP_GhostWall
的视口现在应该是这样的:
图 6.20:创建 Ghost Wall
现在你已经创建了这两个 Wall 角色,将它们放在关卡中进行测试。将它们的变换设置为以下变换值:
-
Wall:
位置
:(-710, 120, 130)
-
Ghost Wall:
位置
:(-910, -100, 130)
;旋转
:(0, 0, 90)
:
图 6.21:更新 Ghost Wall 的位置和旋转
最终结果应该是这样的:
图 6.22:带有 Ghost Wall 和 Wall 的最终结果
当你把你的角色藏在普通的Wall
(右边的那个)后面时,敌人不会向玩家扔躲避球;然而,当你试图把你的角色藏在GhostWall
(左边的那个)后面时,即使敌人无法穿过它,敌人也会向角色扔躲避球,它们会穿过墙壁,就好像它不存在一样!
这就结束了我们的练习。我们已经制作了我们的Wall
角色,它们将正常运作或者忽略敌人的视线和躲避球!
胜利宝盒
我们项目的下一步将是创建VictoryBox
角色。这个角色将负责在玩家角色进入时结束游戏,前提是玩家已经通过了关卡。为了做到这一点,我们将使用Overlap
事件。接下来的练习将帮助我们理解 Victory Box。
练习 6.05:创建 VictoryBox 类
在这个练习中,我们将创建VictoryBox
类,当玩家角色进入时,游戏将结束。
以下步骤将帮助你完成这个练习:
-
创建一个继承自角色的新的 C++类,并将其命名为
VictoryBox
。 -
在 Visual Studio 中打开该类的文件。
-
创建一个新的
SceneComponent
属性,它将被用作RootComponent
,就像我们的Wall
C++类一样:
Header
文件:
private:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = VictoryBox, meta = (AllowPrivateAccess = "true"))
class USceneComponent* RootScene;
源
文件:
AVictoryBox::AVictoryBox()
{
// 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;
RootScene = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
RootComponent = RootScene;
}
- 在头文件中声明一个
BoxComponent
,它将检查与玩家角色的重叠事件,也应该是private
:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = VictoryBox, meta = (AllowPrivateAccess = "true"))
class UBoxComponent* CollisionBox;
- 在类的源文件中包含
BoxComponent
文件:
#include "Components/BoxComponent.h"
- 创建
RootScene
组件后,创建BoxComponent
,它也应该是private
:
RootScene = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
RootComponent = RootScene;
CollisionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("Collision Box"));
- 使用
SetupAttachment
函数将其附加到RootComponent
:
CollisionBox->SetupAttachment(RootComponent);
- 将其
BoxExtent
属性设置为所有轴上的60
单位。这将使BoxComponent
的大小加倍为(120 x 120 x 120)
:
CollisionBox->SetBoxExtent(FVector(60.0f, 60.0f, 60.0f));
- 使用
SetRelativeLocation
函数将其相对位置在Z轴上偏移120
单位:
CollisionBox->SetRelativeLocation(FVector(0.0f, 0.0f, 120.0f));
- 现在,你需要一个函数来监听
BoxComponent
的OnBeginOverlap
事件。每当一个对象进入BoxComponent
时,这个事件将被调用。这个函数必须在UFUNCTION
宏之前,是public
的,不返回任何内容,并具有以下参数:
UFUNCTION()
void OnBeginOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
参数如下:
-
UPrimitiveComponent* OverlappedComp
:被重叠并属于该角色的组件。 -
AActor* OtherActor
:参与重叠的其他角色。 -
UPrimitiveComponent* OtherComp
:被重叠并属于其他角色的组件。 -
int32 OtherBodyIndex
:被击中的原始中的项目索引(通常对于实例化静态网格组件很有用)。 -
bool bFromSweep
:重叠是否起源于扫描跟踪。 -
FHitResult& SweepResult
:由该对象与其他对象之间的碰撞产生的扫描跟踪的数据。
注意
虽然我们在这个项目中不会使用OnEndOverlap
事件,但你很可能以后会需要使用它,所以这是该事件的必需函数签名,它看起来与我们刚刚学到的那个函数非常相似:
UFUNCTION()
void OnEndOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
- 接下来,我们需要将这个函数绑定到
BoxComponent
的OnComponentBeginOverlap
事件上:
CollisionBox->OnComponentBeginOverlap.AddDynamic(this, &AVictoryBox::OnBeginOverlap);
- 在我们的
OnBeginOverlap
函数实现中,我们将检查我们重叠的角色是否是DodgeballCharacter
。因为我们将引用这个类,所以我们也需要包括它:
#include "DodgeballCharacter.h"
void AVictoryBox::OnBeginOverlap(UPrimitiveComponent * OverlappedComp, AActor * OtherActor, UPrimitiveComponent * OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult & SweepResult)
{
if (Cast<ADodgeballCharacter>(OtherActor))
{
}
}
如果我们重叠的角色是DodgeballCharacter
,我们想要退出游戏。
- 我们将使用
KismetSystemLibrary
来实现这个目的。KismetSystemLibrary
类包含了在项目中通用使用的有用函数:
#include "Kismet/KismetSystemLibrary.h"
- 为了退出游戏,我们将调用
KismetSystemLibrary
的QuitGame
函数。这个函数接收以下内容:
UKismetSystemLibrary::QuitGame(GetWorld(),
nullptr,
EQuitPreference::Quit,
true);
前面代码片段中的重要参数解释如下:
-
一个
World
对象,我们可以用GetWorld
函数访问。 -
一个
PlayerController
对象,我们将设置为nullptr
。我们这样做是因为这个函数会自动这样找到一个。 -
一个
EQuitPreference
对象,表示我们想要结束游戏的方式,是退出还是只将其作为后台进程。我们希望实际退出游戏,而不只是将其作为后台进程。 -
一个
bool
,表示我们是否想要忽略平台的限制来退出游戏,我们将设置为true
。
接下来,我们将创建我们的蓝图类。
- 编译你的更改,打开编辑器,转到“内容”→
ThirdPersonCPP
→“蓝图”目录,在“内容浏览器”中创建一个继承自VictoryBox
的新蓝图类,并命名为BP_VictoryBox
。打开该资产并进行以下修改:
-
添加一个新的静态网格组件。
-
将其
StaticMesh
属性设置为Floor_400x400
。 -
将其“材质”属性设置为
M_Metal_Gold
。 -
将其比例设置为所有三个轴上的
0.75
单位。 -
将其位置设置为“(-150,-150,20)”,分别在X、Y和Z轴上。
在你做出这些改变之后,你的蓝图的视口选项卡应该看起来像这样:
图 6.23:胜利盒放置在蓝图的视口选项卡中
将蓝图放在你的关卡中以测试其功能:
图 6.24:用于测试的胜利盒蓝图在关卡中
如果你玩这个关卡并踏上金色的板子(并重叠碰撞箱),你会注意到游戏突然结束,这是预期的。
有了这个,我们结束了VictoryBox
类!你现在知道如何在你自己的项目中使用重叠事件。使用这些事件,你可以创建多种游戏机制,恭喜你完成了这个练习。
我们现在非常接近完成本章的结尾,我们将完成一个新的活动,但首先,我们需要对我们的DodgeballProjectile
类进行一些修改,即在下一个练习中添加一个 getter 函数到它的ProjectileMovementComponent
。
一个 getter 函数是一个只返回特定属性并且不做其他事情的函数。这些函数通常被标记为内联,这意味着当代码编译时,对该函数的调用将简单地被替换为它的内容。它们通常也被标记为const
,因为它们不修改类的任何属性。
练习 6.06:在 DodgeballProjectile 中添加 ProjectileMovementComponent Getter 函数
在这个练习中,我们将向DodgeballProjectile
类的ProjectileMovement
属性添加一个 getter 函数,以便其他类可以访问它并修改它的属性。我们将在本章的活动中做同样的事情。
为了做到这一点,你需要按照以下步骤进行:
-
在 Visual Studio 中打开
DodgeballProjectile
类的头文件。 -
添加一个名为
GetProjectileMovementComponent
的新public
函数。这个函数将是一个内联函数,在 UE4 的 C++版本中用FORCEINLINE
宏替换。该函数还应返回一个UProjectileMovementComponent*
并且是一个const
函数:
FORCEINLINE class UProjectileMovementComponent* GetProjectileMovementComponent() const
{
return ProjectileMovement;
}
注意
在特定函数使用FORCEINLINE
宏时,不能将该函数的声明添加到头文件中,然后将其实现添加到源文件中。两者必须同时在头文件中完成,如前所示。
通过这样做,我们完成了这个快速练习。在这里,我们为DodgeballProjectile
类添加了一个简单的getter
函数,我们将在本章的活动中使用它,在这里,我们将在EnemyCharacter
类中用SpawnActorDeferred
函数替换SpawnActor
函数。这将允许我们在生成实例之前安全地编辑DodgeballProjectile
类的属性。
活动 6.01:在 EnemyCharacter 中用 SpawnActorDeferred 替换 SpawnActor 函数
在这个活动中,您将更改 EnemyCharacter 的ThrowDodgeball
函数,以便使用SpawnActorDeferred
函数而不是SpawnActor
函数,以便在生成之前更改DodgeballProjectile
的InitialSpeed
。
以下步骤将帮助您完成此活动:
-
在 Visual Studio 中打开
EnemyCharacter
类的源文件。 -
转到
ThrowDodgeball
函数的实现。 -
因为
SpawnActorDeferred
函数不能只接收生成位置和旋转属性,而必须接收一个FTransform
属性,所以我们需要在调用该函数之前创建一个。让我们称之为SpawnTransform
,并按顺序发送生成旋转和位置作为其构造函数的输入,这将是这个敌人的旋转和SpawnLocation
属性,分别。 -
然后,将
SpawnActor
函数调用更新为SpawnActorDeferred
函数调用。将生成位置和生成旋转作为其第二个和第三个参数发送,将这些替换为我们刚刚创建的SpawnTransform
属性作为第二个参数。 -
确保将此函数调用的返回值保存在名为
Projectile
的ADodgeballProjectile*
属性中。
完成此操作后,您将成功创建一个新的DodgeballProjectile
对象。但是,我们仍然需要更改其InitialSpeed
属性并实际生成它。
-
调用
SpawnActorDeferred
函数后,调用Projectile
属性的GetProjectileMovementComponent
函数,该函数返回其 Projectile Movement Component,并将其InitialSpeed
属性更改为2200
单位。 -
因为我们将在
EnemyCharacter
类中访问属于 Projectile Movement Component 的属性,所以我们需要像在Exercise 6.02,Adding a Projectile Movement Component to DodgeballProjectile中那样包含该组件。 -
在更改
InitialSpeed
属性的值后,唯一剩下的事情就是调用Projectile
属性的FinishSpawning
函数,该函数将接收我们创建的SpawnTransform
属性作为参数。 -
完成此操作后,编译更改并打开编辑器。
预期输出:
图 6.25:向玩家投掷躲避球
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
通过完成此活动,您已巩固了SpawnActorDeferred
函数的使用,并知道如何在将来的项目中使用它。
总结
在本章中,您已经学会了如何使用物理模拟影响对象,创建自己的对象类型和碰撞预设,使用OnHit
,OnBeginOverlap
和OnEndOverlap
事件,更新对象的物理材料以及使用定时器。
现在你已经学会了碰撞主题的这些基本概念,你将能够想出新的创造性方式来运用它们,从而创建你自己的项目。
在下一章中,我们将看一下角色组件、接口和蓝图函数库,这些对于保持项目的复杂性可控和高度模块化非常有用,因此可以轻松地将一个项目的部分添加到另一个项目中。
第七章:用户界面
概述
在本章中,我们将继续我们在过去几章中一直在进行的基于躲避球的游戏的工作。我们将通过学习游戏 UI(用户界面)及其形式之一,即菜单和 HUD,来继续这个项目。到本章结束时,您将能够使用 UE4 的游戏 UI 系统 UMG 来制作一个带有可交互按钮的菜单,以及通过进度条显示玩家角色当前生命值的 HUD。
介绍
在上一章中,我们学习了通用工具,这些工具允许您通过使用蓝图函数库、角色组件和接口来正确结构化和组织项目中的代码和资产。
在本章中,我们将深入探讨游戏 UI 的主题,这是几乎每个视频游戏中都存在的东西。游戏 UI 是向玩家展示信息的主要方式之一,例如他们还剩下多少条命,他们的武器里有多少子弹,他们携带的武器是什么等等,并且允许玩家通过选择是否继续游戏、创建新游戏、选择要在哪个级别中玩等方式与游戏进行交互。这通常以图像和文本的形式展示给玩家。
用户界面或UI通常添加在游戏的渲染之上,这意味着它们位于游戏中您看到的所有其他内容的前面,并且行为类似于图层(您可以像在 Photoshop 中一样将它们叠加在彼此之上)。但是,也有一个例外:直接 UI。这种类型的 UI 不是分层到游戏的屏幕上,而是存在于游戏本身之内。一个很好的例子可以在游戏死亡空间中找到,在这个游戏中,您以第三人称视角控制一个角色,并且可以通过观察连接到他们背部的装置来看到他们的生命值,这是在游戏世界内部。
游戏 UI
通常有两种不同类型的游戏 UI:菜单和HUD。
菜单是允许玩家与之交互的 UI 面板,可以通过按下输入设备上的按钮或键来实现。
这可以通过许多不同的菜单形式来实现,包括以下内容:
-
主菜单,玩家可以选择是否继续游戏、创建新游戏、退出游戏等等
-
级别选择菜单,玩家可以选择要玩的级别
-
以及其他许多选项
HUD 是游戏过程中存在的 UI 面板,向玩家提供他们应该始终知道的信息,例如他们还剩下多少条命,他们可以使用哪些特殊能力等等。
在本章中,我们将涵盖游戏 UI,并为我们的游戏制作菜单和 HUD。
注意
我们不会在这里涵盖直接 UI,因为它超出了本书的范围。
那么我们如何在 UE4 中创建游戏 UI 呢?这样做的主要方式是使用虚幻运动图形(UMG),这是一种工具,允许您制作游戏 UI(在 UE4 术语中也称为小部件),包括菜单和 HUD,并将它们添加到屏幕上。
让我们在下一节中深入探讨这个主题。
UMG 基础知识
在 UE4 中,创建游戏 UI 的主要方式是使用 UMG 工具。这个工具将允许您以设计师
选项卡的形式制作游戏 UI,同时还可以通过 UMG 的图表
选项卡为您的游戏 UI 添加功能。
小部件是 UE4 允许您表示游戏 UI 的方式。小部件可以是基本的 UI 元素,如按钮
、文本
元素和图像
,但它们也可以组合在一起创建更复杂和完整的小部件,如菜单和 HUD,这正是我们将在本章中要做的。
让我们在下一个练习中使用 UMG 工具在 UE4 中创建我们的第一个小部件。
练习 8.01:创建小部件蓝图
在这个练习中,我们将创建我们的第一个小部件蓝图,并学习 UMG 的基本元素以及如何使用它们来创建游戏 UI。
以下步骤将帮助您完成这个练习:
-
为了创建我们的第一个小部件,打开编辑器,转到
Content Browser
中的ThirdPersonCPP -> Blueprints
文件夹,然后右键单击。 -
转到最后一节,
用户界面
,然后选择小部件蓝图
。
选择此选项将创建一个新的小部件蓝图
,这是 UE4 中小部件资产的名称。
- 将此小部件命名为
TestWidget
并打开它。您将看到用于编辑小部件蓝图的界面,在那里您将创建自己的小部件和 UI。以下是此窗口中所有选项卡的详细信息:
图 8.1:小部件蓝图编辑器分解为六个窗口
前面图中选项卡的详细信息如下:
-
调色板
- 此选项卡显示您可以添加到小部件的所有单独的 UI 元素。这包括按钮
,文本框
,图像
,滑块
,复选框
等等。 -
层次结构
- 此选项卡显示当前在您的小部件中存在的所有 UI 元素。正如您所看到的,目前我们的层次结构中只有一个画布面板
元素。 -
设计师
- 此选项卡显示您的小部件在视觉上的外观,根据层次结构中存在的元素以及它们的布局方式。因为我们当前小部件中唯一的元素没有视觉表示,所以此选项卡目前为空。 -
详细信息
- 此选项卡显示当前所选 UI 元素的属性。如果选择现有的画布面板
元素,则应出现前面截图中的所有选项。 -
因为此资产是
小部件蓝图
,这两个按钮允许您在设计师视图
和图形视图
之间切换,后者看起来与普通蓝图类的窗口完全相同。 -
动画
- 这两个选项卡都与小部件动画相关。小部件蓝图允许您随时间动画 UI 元素的属性,包括它们的位置
,比例
,颜色
等等。左侧选项卡允许您创建和选择要在右侧选项卡中编辑的动画,您将能够编辑它们随时间影响的属性。
- 现在让我们看一下我们的
小部件
中一些可用的 UI 元素,首先是现有的画布面板
。
画布面板
通常添加到小部件蓝图的根部,因为它们允许您将 UI 元素拖动到设计师
选项卡中的任何位置。这样,您可以按照自己的意愿布置这些元素:在屏幕中心,左上角,屏幕底部中心等等。现在让我们将另一个非常重要的 UI 元素拖到我们的小部件中:一个按钮
。
- 在
调色板
选项卡中,找到按钮
元素并将其拖到我们的设计师
选项卡中(按住鼠标左键拖动):
图 8.2:从调色板窗口将按钮元素拖到设计师窗口中
一旦您这样做,您就可以通过拖动周围的小白点调整按钮的大小(请记住,您只能对位于画布面板内的元素执行此操作):
图 8.3:使用周围的白点调整 UI 元素大小的结果
在小部件
中将元素拖入彼此的另一种方法是将它们拖入层次结构
选项卡,而不是设计师
选项卡。
- 现在将
文本
元素拖到我们的按钮
中,但这次使用层次结构
选项卡:
图 8.4:将文本元素从调色板窗口拖到层次结构窗口中
“文本”元素可以包含您指定的文本,具有您可以在“详细信息”面板中修改的特定大小和字体。在使用“层次结构”选项卡将“文本”元素拖动到“按钮”内之后,设计师选项卡应该如下所示:
图 8.5:在设计师选项卡中的按钮元素,在我们添加文本元素作为其子级后
让我们更改此“文本”块的一些属性。
- 在“层次结构”选项卡或“设计师”选项卡中选择它,并查看“详细信息”面板:
图 8.6:显示我们添加的文本元素的属性的详细信息面板
在这里,您会发现一些属性,您可以根据自己的喜好进行编辑。现在,我们只想专注于其中的两个:文本的“内容”和其“颜色和不透明度”。
- 将“文本”元素的“内容”从“文本块”更新为“按钮 1”:
图 8.7:将文本元素的文本属性更改为按钮 1
接下来,让我们将其“颜色和不透明度”从“白色”更改为“黑色”。
-
点击“颜色和不透明度”属性,看看弹出的窗口,“颜色选择器”。每当您在 UE4 中编辑“颜色”属性时,此窗口都会弹出。它允许您以许多不同的方式输入颜色,包括颜色轮、饱和度和值条、RGB 和 HSV 值滑块,以及其他几个选项。
-
现在,通过将“值”条(从上到下从白色到黑色的条)拖动到底部,然后按“确定”,将颜色从白色更改为黑色:
图 8.8:在颜色选择器窗口中选择黑色
- 在进行这些更改后,按钮应该看起来像这样:
图 8.9:更改文本元素的文本属性和颜色后的按钮元素
有了这个,我们结束了本章的第一个练习。您现在已经了解了 UMG 的一些基本知识,比如如何向您的小部件添加“按钮”和“文本”元素。
在我们进行下一个练习之前,让我们先了解一下锚点。
锚点
您可能已经意识到,视频游戏在许多不同的屏幕尺寸和许多不同的分辨率上进行播放。因此,确保您创建的菜单可以有效地适应所有这些不同的分辨率非常重要。这就是锚点的主要目的。
锚点允许您指定 UI 元素的大小在屏幕分辨率更改时如何适应,通过指定您希望其占据屏幕比例。使用锚点,您可以始终将 UI 元素放在屏幕的左上角,或始终占据屏幕的一半,无论屏幕的大小和分辨率如何。
当屏幕大小或分辨率发生变化时,您的小部件将相对于其锚点进行缩放和移动。只有直接作为“画布面板”的子级的元素才能有锚点,您可以通过“锚点奖章”来可视化它,当您选择所述元素时,在“设计师”选项卡中会显示一个白色的花瓣形状:
图 8.10:在设计师窗口中显示的轮廓的左上方的锚点奖章
默认情况下,锚点折叠到左上角,这意味着您无法控制按钮在分辨率更改时的缩放程度,因此让我们在下一个练习中更改它。
练习 8.02:编辑 UMG 锚点
在这个练习中,我们将改变小部件中的锚点,以便我们的按钮大小和形状能够适应各种屏幕分辨率和尺寸。
以下步骤将帮助您完成此练习:
- 选择我们在上一个练习中创建的按钮,然后转到
Details
面板,点击您看到的第一个属性,即Anchors
属性。在这里,您将能够看到Anchor
预设,这将根据所示的枢轴对齐 UI 元素。
我们希望将按钮居中显示在屏幕上。
- 点击屏幕中心的中心枢轴:
图 8.11:按钮的锚点属性,中心锚点用方框标出
您会看到我们的Anchor Medallion
现在已经改变了位置:
图 8.12:将按钮的锚点更改为中心后的锚点奖章
现在Anchor Medallion
位于屏幕中心,我们仍然无法控制按钮在不同分辨率下的缩放,但至少我们知道它会相对于屏幕中心进行缩放。
为了使我们的按钮居中显示在屏幕上,我们还需要将按钮的位置更改为屏幕中心。
- 重复选择中心锚点的上一步,但这次,在选择它之前,按住Ctrl键以将按钮的位置捕捉到此锚点。点击后释放Ctrl键。这应该是结果:
图 8.13:按钮元素被移动到其选定的中心锚点附近
从前面的截图中可以看到,我们的按钮位置已经改变,但它还没有正确居中在屏幕上。这是因为它的Alignment
。
Alignment
属性是Vector2D
类型(具有两个float
属性的元组:X
和Y
),它决定了 UI 元素相对于其总大小的中心。默认情况下设置为(0,0)
,意味着元素的中心是其左上角,这解释了前面截图中的结果。它可以一直到(1,1)
,即右下角。在这种情况下,考虑到我们希望对齐按钮,我们希望它是(0.5, 0.5)
。
- 在选择
Anchor
点时更新 UI 元素的对齐方式,您必须按住Shift键并重复上一步。或者,为了同时更新按钮的位置和对齐方式,选择中心Anchor
点时同时按住Ctrl和Shift键将完成任务。然后应该是这个结果:
图 8.14:按钮元素相对于其选定的锚点在中心位置
在这一点上,当改变屏幕的分辨率时,我们知道这个按钮将始终保持在屏幕中心。然而,为了保持按钮相对于分辨率的大小,我们需要进行一些修改。
- 将
Anchor Medallion
的右下角花瓣拖动到按钮的右下角:
图 8.15:拖动锚点奖章的右下角花瓣以更新按钮元素的锚点
- 将
Anchor Medallion
的左上角花瓣拖动到按钮的左上角:
图 8.16:拖动锚点奖章的左上角花瓣以更新按钮元素的锚点
注意
当更改“锚点”时,您在按钮周围看到的百分比是元素在屏幕上所占空间的百分比。例如,看最后一个截图,我们可以看到按钮在X坐标上占小部件空间的11.9%
,在Y坐标上占小部件空间的8.4%
。
通过按住Ctrl键移动“锚点勋章”的花瓣,可以将 UI 元素的大小设置为其锚点的大小。
现在,由于这些对锚点的更改,我们的按钮最终将适应不同的屏幕尺寸和分辨率。
您还可以使用“详细”面板手动编辑我们刚刚使用“锚点勋章”和移动按钮编辑的所有属性:
图 8.17:我们使用锚点勋章更改的属性,显示在详细窗口中
最后,我们需要知道如何在“设计师”选项卡中使用不同的分辨率来可视化我们的小部件。
- 拖动设计师选项卡内部轮廓框的右下方的双箭头:
图 8.18:在设计师选项卡内部轮廓框的右下方有双箭头
通过拖动双箭头,您可以将“画布”调整到任何屏幕分辨率。在下面的截图中,您将看到各种设备的最常用分辨率,并且您可以在每个分辨率下预览您的小部件:
图 8.19:我们可以选择在设计师窗口中预览的分辨率
注意
您可以在docs.unrealengine.com/en-US/Engine/UMG/UserGuide/Anchors
找到 UMG 锚点的完整参考。
这就结束了我们的练习。您已经了解了锚点和如何使您的小部件适应不同的屏幕尺寸和分辨率。
现在我们已经了解了一些 UMG 的基础知识,让我们看看如何为这个小部件蓝图创建一个小部件 C++类,这是我们将在下一个练习中要做的事情。
练习 8.03:创建 RestartWidget C++类
在这个练习中,我们将学习如何创建一个小部件 C++类,从中我们创建的小部件蓝图将继承。在我们的“躲避球”游戏中,当玩家死亡时,它将被添加到屏幕上,以便玩家可以选择重新开始级别。这个小部件将有一个按钮,当玩家点击它时,将重新开始级别。
这个练习的第一步将是向我们的项目添加与 UMG 相关的模块。虚幻引擎包括几个不同的模块,在每个项目中,您都必须指定您要使用哪些模块。当源代码文件生成时,我们的项目已经带有一些通用模块,但我们需要添加一些更多的模块。
以下步骤将帮助您完成这个练习:
-
打开位于项目
Source
文件夹内的 C#文件而不是 C++文件的Dodgeball.build.cs
文件。 -
打开文件,您会发现从
PublicDependencyModuleNames
属性调用的AddRange
函数。这个函数告诉引擎这个项目打算使用哪些模块。作为参数,发送了一个字符串数组,其中包含项目的所有预期模块的名称。鉴于我们打算使用 UMG,我们需要添加与 UMG 相关的模块:UMG
,Slate
和SlateCore
:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "UMG", "Slate", "SlateCore" });
现在我们已经通知引擎我们将使用 UMG 模块,让我们创建我们的小部件 C++类:
-
打开虚幻编辑器。
-
右键单击内容浏览器,然后选择“新的 C++类”。
-
将“显示所有类”复选框设置为
true
。 -
搜索
UserWidget
类,并将其选择为新类的父类。 -
将新的 C++类命名为
RestartWidget
。
在文件在 Visual Studio 中打开后,按照以下步骤对我们的 Widget C++类进行修改:
- 我们将要添加到这个类的第一件事是一个名为
RestartButton
的public
class UButton*
属性,它代表玩家将按下以重新启动级别的按钮。您将希望它通过使用UPROPERTY
宏和BindWidget
元标记绑定到从该类继承的蓝图类中的按钮。这将强制 Widget 蓝图具有一个名为RestartButton
的Button
,我们可以通过此属性在 C++中访问它,然后自由编辑其属性,例如在蓝图中的大小和位置:
UPROPERTY(meta = (BindWidget))
class UButton* RestartButton;
注意
使用BindWidget
元标记将导致编译错误,如果从该 C++类继承的 Widget 蓝图没有具有相同类型和名称的元素。如果您不希望发生这种情况,您将不得不将UPROPERTY
标记为可选的BindWidget
,如下所示:UPROPERTY(meta = (BindWidget, OptionalWidget = true))
这将使绑定此属性变为可选,并且在编译 Widget 蓝图时不会导致编译错误。
接下来,我们将添加一个函数,当玩家点击RestartButton
时将被调用,这将重新启动级别。我们将使用GameplayStatics
对象的OpenLevel
函数来实现这一点,然后发送当前级别的名称。
- 在 Widget 类的头文件中,添加一个名为
OnRestartClicked
的protected
函数的声明,它不返回任何内容并且不接收任何参数。此函数必须标记为UFUNCTION
:
protected:
UFUNCTION()
void OnRestartClicked();
- 在类的源文件中,添加一个
GameplayStatics
对象的include
:
#include "Kismet/GameplayStatics.h"
- 然后,为我们的
OnRestartClicked
函数添加一个实现:
void URestartWidget::OnRestartClicked()
{
}
- 在其实现中,调用
GameplayStatics
对象的OpenLevel
函数。此函数接收世界上下文对象作为参数,这将是this
指针,并且级别的名称,我们将不得不使用GameplayStatics
对象的GetCurrentLevelName
函数来获取。这个最后的函数也必须接收一个世界上下文对象,这也将是this
指针:
UGameplayStatics::OpenLevel(this, FName(*UGameplayStatics::GetCurrentLevelName(this)));
注意
对GameplayStatics
对象的GetCurrentLevelName
函数的调用必须在前面加上*
,因为它返回一个FString
,UE4 的字符串类型,并且必须被解引用才能传递给FName
构造函数。
下一步将是以一种方式绑定此函数,以便在玩家按下RestartButton
时调用它:
- 为了做到这一点,我们将不得不重写属于
UserWidget
类的一个函数,名为NativeOnInitialized
。这个函数只被调用一次,类似于 Actor 的BeginPlay
函数,这使得它适合进行我们的设置。在我们的 Widget 类的头文件中,使用virtual
和override
关键字添加一个public
NativeOnInitialized
函数的声明:
virtual void NativeOnInitialized() override;
- 接下来,在类的源文件中,添加此函数的实现。在其中,调用其
Super
函数并添加一个if
语句,检查我们的RestartButton
是否与nullptr
不同:
void URestartWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (RestartButton != nullptr)
{
}
}
- 如果
if
语句为真,我们将希望将我们的OnRestartClicked
函数绑定到按钮的OnClicked
事件。我们可以通过访问按钮的OnClicked
属性并调用其AddDynamic
函数来实现这一点,将我们想要调用该函数的对象(即this
指针)和要调用的函数的指针(即OnRestartClicked
函数)作为参数发送:
if (RestartButton != nullptr)
{
RestartButton->OnClicked.AddDynamic(this, &URestartWidget::OnRestartClicked);
}
- 因为我们正在访问与
Button
类相关的函数,所以我们还必须包含它:
#include "Components/Button.h"
注意
当玩家按下并释放按钮时,按钮的OnClicked
事件将被调用。还有其他与按钮相关的事件,包括OnPressed
事件(当玩家按下按钮时),OnReleased
事件(当玩家释放按钮时),以及OnHover
和OnUnhover
事件(当玩家分别开始和停止悬停在按钮上时)。
AddDynamic
函数必须接收一个标记有UFUNCTION
宏的函数的指针作为参数。如果没有,当调用该函数时会出现错误。这就是为什么我们用UFUNCTION
宏标记了OnRestartClicked
函数的原因。
完成这些步骤后,编译您的更改并打开编辑器。
-
打开您之前创建的
TestWidget
Widget Blueprint。我们希望将这个 Widget Blueprint 与我们刚刚创建的RestartWidget
类关联起来,所以我们需要重新设置其父类。 -
从 Widget Blueprint 的
File
选项卡中,选择Reparent Blueprint
选项,并选择RestartWidget
C++类作为其新的父类:
图 8.20:将 TestWidget 的类重新设置为 RestartWidget
您会注意到 Widget Blueprint 现在有一个与我们在 C++类中创建的BindWidget
元标记相关的编译错误:
图 8.21:设置父类为 RestartWidget 类后的编译错误
这是由于 C++类找不到名为RestartButton
的Button
属性造成的。
为了解决这个问题,我们需要将 Widget Blueprint 中的Button
元素重命名为RestartButton
:
图 8.22:将按钮元素重命名为 RestartButton
完成这些步骤后,关闭 Widget Blueprint,并将其名称从TestWidget
更改为BP_RestartWidget
,就像你在上一步中所做的那样。
这就完成了我们的 Widget 类的创建。您现在知道如何将 Widget C++类连接到 Widget Blueprint,这是处理 UE4 中游戏 UI 的一个非常重要的步骤。
接下来我们需要做的是创建我们的Player Controller
C++类,它将负责实例化我们的RestartWidget
并将其添加到屏幕上。我们将在接下来的练习中完成这个任务。
练习 8.04:创建将 RestartWidget 添加到屏幕的逻辑
在这个练习中,我们将创建负责将我们新创建的RestartWidget
添加到屏幕上的逻辑。当玩家死亡时,它将出现在屏幕上,以便他们有重新开始关卡的选项。
为了做到这一点,我们需要创建一个新的Player Controller
C++类,您可以按照以下步骤进行:
-
打开虚幻编辑器。
-
在
Content Browser
上右键单击,选择New C++ Class
。 -
搜索
Player Controller
类并选择它作为新类的父类。 -
将新的 C++类命名为
DodgeballPlayerController
。 -
在 Visual Studio 中打开类的文件。
当我们的玩家耗尽生命值时,DodgeballCharacter
类将访问这个Player Controller
类,并调用一个函数,该函数将在屏幕上添加RestartWidget
。请按照以下步骤继续进行。
为了知道要添加到屏幕上的 Widget 的类(它将是一个 Widget Blueprint 而不是 Widget C++类),我们需要使用TSubclassOf
类型。
- 在类的头文件中,添加一个名为
BP_RestartWidget
的public
TSubclassOf<class URestartWidget>
属性。确保将其设置为UPROPERTY
,并使用EditDefaultsOnly
标记,以便我们可以在蓝图类中编辑它:
public:
UPROPERTY(EditDefaultsOnly)
TSubclassOf<class URestartWidget> BP_RestartWidget;
为了实例化这个 Widget 并将其添加到屏幕上,我们需要保存一个对它的引用。
- 添加一个
private
类型为class URestartWidget*
的新变量,并将其命名为RestartWidget
。确保将其设置为没有标签的UPROPERTY
函数:
private:
UPROPERTY()
class URestartWidget* RestartWidget;
注意
尽管这个属性不应该在蓝图类中可编辑,但我们必须将这个引用设置为UPROPERTY
,否则垃圾收集器将销毁这个变量的内容。
我们需要的下一步是一个负责将我们的小部件添加到屏幕上的函数。
- 添加一个声明为返回无内容并且不接收参数的
public
函数,名为ShowRestartWidget
:
void ShowRestartWidget();
- 现在,转到我们类的源文件。首先,添加一个包含到
RestartWidget
类的包含:
#include "RestartWidget.h"
- 然后,添加我们的
ShowRestartWidget
函数的实现,我们将首先检查我们的BP_RestartWidget
变量是否不是nullptr
:
void ADodgeballPlayerController::ShowRestartWidget()
{
if (BP_RestartWidget != nullptr)
{
}
}
- 如果该变量有效(不同于
nullptr
),我们希望使用Player Controller
的SetPause
函数暂停游戏。这将确保游戏停止,直到玩家决定做些什么(在我们的情况下,将按下重新开始关卡的按钮):
SetPause(true);
接下来要做的是改变输入模式。在 UE4 中,有三种输入模式:仅游戏
,游戏和 UI
和仅 UI
。如果您的输入
模式包括游戏
,这意味着玩家角色和玩家控制器将通过输入操作
接收输入。如果您的输入
模式包括UI
,这意味着屏幕上的小部件将接收玩家的输入。当我们在屏幕上显示此小部件时,我们不希望玩家角色接收任何输入。
- 因此,更新为
仅 UI
输入
模式。您可以通过调用Player Controller
的SetInputMode
函数,并将FInputModeUIOnly
类型作为参数传递来实现这一点:
SetInputMode(FInputModeUIOnly());
之后,我们希望显示鼠标光标,以便玩家可以看到他们悬停在哪个按钮上。
- 我们将通过将
Player Controller
的bShowMouseCursor
属性设置为true
来实现这一点:
bShowMouseCursor = true;
- 现在,我们可以实例化我们的小部件,使用
Player Controller
的CreateWidget
函数,将 C++小部件类作为模板参数传递,这在我们的情况下是RestartWidget
,然后作为正常参数传递Owning Player
,这是拥有此小部件的Player Controller
,我们将使用this
指针发送,以及小部件类,这将是我们的BP_RestartWidget
属性:
RestartWidget = CreateWidget<URestartWidget>(this, BP_RestartWidget);
- 在我们实例化小部件之后,我们将使用小部件的
AddToViewport
函数将其添加到屏幕上:
RestartWidget->AddToViewport();
- 这就完成了我们的
ShowRestartWidget
函数。但是,我们还需要创建一个函数,用于从屏幕上移除RestartWidget
。在类的头文件中,添加一个声明为与ShowRestartWidget
函数类似的函数,但这次名为HideRestartWidget
:
void HideRestartWidget();
- 在类的源文件中,添加
HideRestartWidget
函数的实现:
void ADodgeballPlayerController::HideRestartWidget()
{
}
- 在这个函数中,我们应该首先通过调用其
RemoveFromParent
函数将小部件从屏幕上移除,并使用Destruct
函数将其销毁:
RestartWidget->RemoveFromParent();
RestartWidget->Destruct();
- 然后,我们希望使用前一个函数中使用的
SetPause
函数取消暂停游戏:
SetPause(false);
- 最后,将
输入
模式设置为仅游戏
,并以与前一个函数相同的方式隐藏鼠标光标(这次我们传递FInputModeGameOnly
类型):
SetInputMode(FInputModeGameOnly());
bShowMouseCursor = false;
这就完成了我们的Player Controller
C++类的逻辑。我们接下来应该调用一个函数,将我们的小部件添加到屏幕上。
- 转到
DodgeballCharacter
类的源文件,并向我们新创建的DodgeballPlayerController
添加include
关键字:
#include "DodgeballPlayerController.h"
- 在
DodgeballCharacter
类的OnDeath_Implementation
函数的实现中,用以下内容替换对QuitGame
函数的调用:
- 使用
GetController
函数获取角色的玩家控制器。您将希望将结果保存在名为PlayerController
的DodgeballPlayerController*
类型的变量中。因为该函数将返回一个Controller
类型的变量,您还需要将其转换为我们的PlayerController
类:
ADodgeballPlayerController* PlayerController = Cast<ADodgeballPlayerController>(GetController());
- 检查
PlayerController
变量是否有效。如果是,调用其ShowRestartWidget
函数:
if (PlayerController != nullptr)
{
PlayerController->ShowRestartWidget();
}
在进行了这些修改之后,我们唯一剩下的事情就是调用将我们的小部件从屏幕上隐藏的函数。打开RestartWidget
类的源文件并实现以下修改。
- 向
DodgeballPlayerController
添加一个include
,其中包含我们将要调用的函数:
#include "DodgeballPlayerController.h"
- 在
OnRestartClicked
函数实现中,在调用OpenLevel
函数之前,我们必须使用GetOwningPlayer
函数获取小部件的OwningPlayer
,它是PlayerController
类型的,并将其转换为DodgeballPlayerController
类:
ADodgeballPlayerController* PlayerController = Cast<ADodgeballPlayerController>(GetOwningPlayer());
- 然后,如果
PlayerController
变量有效,我们调用其HideRestartWidget
函数:
if (PlayerController != nullptr)
{
PlayerController->HideRestartWidget();
}
在您完成所有这些步骤之后,关闭编辑器,编译您的更改并打开编辑器。
您现在已经完成了这个练习。我们已经添加了所有必要的逻辑,将我们的RestartWidget
添加到屏幕上,我们唯一剩下的事情就是创建我们新创建的DodgeballPlayerController
的蓝图类,这将在下一个练习中完成。
练习 8.05:设置 DodgeballPlayerController 蓝图类
在这个练习中,我们将创建我们的DodgeballPlayerController
的蓝图类,以指定我们要添加到屏幕上的小部件,并告诉 UE4 在游戏开始时使用这个蓝图类。
为了做到这一点,请按照以下步骤进行:
-
转到
Content Browser
中的ThirdPersonCPP
->Blueprints
目录,在其中右键单击,并创建一个新的蓝图类。 -
搜索
DodgeballPlayerController
类并将其选择为父类。 -
将此蓝图类重命名为
BP_DodgeballPlayerController
。之后,打开此蓝图资源。 -
转到其
Class Defaults
选项卡,并将类的BP_RestartWidget
属性设置为我们创建的BP_RestartWidget
小部件蓝图。
现在,我们唯一剩下的事情就是确保这个Player Controller
蓝图类在游戏中被使用。
为了做到这一点,我们还需要遵循一些步骤。
- 转到
Content Browser
中的ThirdPersonCPP
->Blueprints
目录,在其中右键单击,创建一个新的蓝图类。搜索DodgeballGameMode
类并将其选择为父类,然后将此Blueprint
类重命名为BP_DodgeballGameMode
。
这个类负责告诉游戏使用哪些类来处理游戏的每个元素,比如使用哪个Player Controller
类等。
- 打开资源,转到其
Class Defaults
选项卡,并将类的PlayerControllerClass
属性设置为我们创建的BP_DodgeballPlayerController
类:
图 8.23:将 PlayerControllerClass 属性设置为 BP_DodgeballPlayerController
- 关闭资源并在位于
Level Viewport
窗口顶部的编辑器工具栏内选择Blueprints
下拉选项。从那里,选择Game Mode
(当前应设置为DodgeballGameMode
)-> 选择 GameModeBase Class -> BP_DodgeballGameMode
。这将告诉编辑器在所有关卡中使用这个新的Game Mode
。
现在,玩游戏,让您的角色被 Dodgeball 击中3
次。第三次之后,您应该看到游戏被暂停,并显示BP_RestartWidget
:
图 8.24:在玩家耗尽生命值后将我们的 BP_RestartWidget 添加到屏幕上
当您使用鼠标点击“按钮 1”时,您应该看到关卡重置为初始状态:
图 8.25:玩家按下前一个截图中显示的按钮后,关卡重新开始
这就结束了我们的练习。您现在知道如何创建小部件并在游戏中显示它们。这是成为一名熟练游戏开发者的旅程中的又一个关键步骤。
在我们继续下一个练习之前,让我们在下一节中看一下进度条。
进度条
视频游戏表示角色状态(如生命值、耐力等)的一种方式是通过进度条,这是我们将用来向玩家传达他们的角色有多少生命值的方式。进度条本质上是一个形状,通常是矩形,可以填充和清空,以显示玩家特定状态的进展。如果您想向玩家显示他们的角色生命值只有最大值的一半,您可以通过显示进度条为一半来实现。这正是我们将在本节中要做的。这个进度条将是我们躲避球游戏 HUD 中唯一的元素。
为了创建这个“生命值条”,我们首先需要创建我们的 HUD 小部件。打开编辑器,转到内容浏览器内的ThirdPersonCPP
-> “蓝图”目录,右键单击并创建一个新的“小部件蓝图”类别的“用户界面”类别。将这个新的小部件蓝图命名为BP_HUDWidget
。然后打开这个新的小部件蓝图。
UE4 中的进度条只是另一个 UI 元素,就像按钮
和文本
元素一样,这意味着我们可以将它从调色板
选项卡拖到我们的设计师
选项卡中。看下面的例子:
图 8.26:将进度条元素拖入设计师窗口
起初,这个进度条可能看起来类似于一个按钮;然而,它包含两个对于进度条很重要的特定属性:
-
百分比
- 允许您指定此进度条的进度,从0
到1
-
填充类型
- 允许您指定您希望此进度条如何填充(从左到右,从上到下等):
图 8.27:进度条的百分比和填充类型属性
如果将“百分比”属性设置为0.5
,则应该看到进度条相应地更新以填充其长度的一半:
图 8.28:进度条向右填充一半
在继续之前,将“百分比”属性设置为1
。
现在让我们将进度条的颜色从蓝色(默认颜色)改为红色。为了做到这一点,转到“详细信息”选项卡,在“外观”类别内,将“填充颜色和不透明度”属性设置为红色(RGB(1,0,0)
):
图 8.29:进度条的颜色被更改为红色
完成这些操作后,您的进度条现在应该使用红色作为填充颜色。
为了完成我们的进度条设置,让我们更新它的位置、大小和锚点。按照以下步骤来实现这一点:
- 在
槽(Canvas Panel Slot)
类别中,展开锚点
属性并将其属性设置为以下值:
-
最小值
:X
轴上的0.052
和Y
轴上的0.083
-
最大值
:X
轴上的0.208
和Y
轴上的0.116
- 将“左偏移”、“顶部偏移”、“右偏移”和“底部偏移”属性设置为
0
。
您的进度条现在应该是这样的:
图 8.30:在本节完成所有修改后的进度条
有了这个,我们就可以结束进度条的话题了。我们的下一步是添加所有必要的逻辑,以将这个进度条作为健康条使用,通过更新玩家角色的健康状况来更新其Percent
属性。我们将在下一个练习中做到这一点。
练习 8.06:创建健康条 C++逻辑
在这个练习中,我们将添加所有必要的 C++逻辑,以更新 HUD 中的进度条,因为玩家角色的健康状况会发生变化。
为了做到这一点,请按照以下步骤进行操作:
-
打开编辑器,并创建一个新的 C++类,该类继承自
UserWidget
,类似于我们在练习 8.03中所做的创建 RestartWidget C++类,但这次将其命名为HUDWidget
。这将是我们的 HUD Widget 所使用的 C++类。 -
在
HUDWidget
类的头文件中,添加一个新的public
属性,类型为class UProgressBar*
,名为HealthBar
。这种类型用于在 C++中表示进度条,就像我们在上一节中创建的那样。确保将此属性声明为带有BindWidget
标记的UPROPERTY
函数:
UPROPERTY(meta = (BindWidget))
class UProgressBar* HealthBar;
- 添加一个名为
UpdateHealthPercent
的public
函数声明,它不返回任何内容,并接收一个float HealthPercent
属性作为参数。这个函数将被调用以更新我们的进度条的Percent
属性:
void UpdateHealthPercent(float HealthPercent);
- 在
HUDWidget
类的源文件中,添加UpdateHealthPercent
函数的实现,该函数将调用HealthBar
属性的SetPercent
函数,并将HealthPercent
属性作为参数传递:
void UHUDWidget::UpdateHealthPercent(float HealthPercent)
{
HealthBar->SetPercent(HealthPercent);
}
- 因为我们将使用
ProgressBar
C++类,所以我们需要在类的源文件顶部添加一个include
:
#include "Components/ProgressBar.h"
下一步将是为我们的Player Controller
添加负责将HUDWidget
添加到屏幕的所有必要逻辑。按照以下步骤实现这一点:
- 在
DodgeballPlayerController
类的头文件中,添加一个public
属性,类型为TSubclassOf<class UHUDWidget>
,名为BP_HUDWidget
。确保将其标记为UPROPERTY
函数,并使用EditDefaultsOnly
标记。
这个属性将允许我们在DodgeballPlayerController
蓝图类中指定我们想要用作 HUD 的 Widget:
UPROPERTY(EditDefaultsOnly)
TSubclassOf<class UHUDWidget> BP_HUDWidget;
- 添加另一个属性,这次是
private
类型为class UHUDWidget*
,名为HUDWidget
。将其标记为UPROPERTY
,但不带任何标记:
UPROPERTY()
class UHUDWidget* HUDWidget;
- 添加一个
protected
声明,名为BeginPlay
函数,并将其标记为virtual
和override
:
virtual void BeginPlay() override;
- 添加一个新的
public
函数声明,名为UpdateHealthPercent
,它不返回任何内容,并接收一个float HealthPercent
作为参数。
这个函数将被我们的玩家角色类调用,以更新 HUD 中的健康条:
void UpdateHealthPercent(float HealthPercent);
- 现在转到
DodgeballPlayerController
类的源文件。首先添加一个include
到我们的HUDWidget
类:
#include "HUDWidget.h"
- 然后,添加
BeginPlay
函数的实现,我们将首先调用Super
对象的BeginPlay
函数:
void ADodgeballPlayerController::BeginPlay()
{
Super::BeginPlay();
}
- 在调用该函数后,检查
BP_HUDWidget
属性是否有效。如果有效,调用CreateWidget
函数,使用UHUDWidget
模板参数,并将Owning Player
、this
和 Widget 类BP_HUDWidget
作为参数传递。确保将HUDWidget
属性设置为此函数调用的返回值:
if (BP_HUDWidget != nullptr)
{
HUDWidget = CreateWidget<UHUDWidget>(this, BP_HUDWidget);
}
- 设置完
HUDWidget
属性后,调用其AddToViewport
函数:
HUDWidget->AddToViewport();
- 最后,添加
UpdateHealthPercent
函数的实现,在这里我们将检查HUDWidget
属性是否有效,如果有效,调用其UpdateHealthPercent
函数,并将HealthPercent
属性作为参数传递:
void ADodgeballPlayerController::UpdateHealthPercent(float HealthPercent)
{
if (HUDWidget != nullptr)
{
HUDWidget->UpdateHealthPercent(HealthPercent);
}
}
现在我们已经添加了负责将 HUD 添加到屏幕并允许其更新的逻辑,我们需要对其他类进行一些修改。按照以下步骤进行修改。
目前,我们在上一章创建的Health
接口只有OnDeath
事件,当一个对象耗尽生命值时会调用该事件。为了在玩家受到伤害时每次更新我们的生命条,我们需要允许我们的HealthInterface
类在发生这种情况时通知一个对象。
- 打开
HealthInterface
类的头文件,并添加一个类似于我们在练习 7.04中为OnDeath
事件所做的声明的声明,但这次是为OnTakeDamage
事件。每当一个对象受到伤害时,将调用此事件:
UFUNCTION(BlueprintNativeEvent, Category = Health)
void OnTakeDamage();
virtual void OnTakeDamage_Implementation() = 0;
- 现在我们已经在我们的
Interface
类中添加了这个事件,让我们添加调用该事件的逻辑:打开HealthComponent
类的源文件,在LoseHealth
函数的实现中,在从Health
属性中减去Amount
属性之后,检查Owner
是否实现了Health
接口,如果是,调用它的OnTakeDamage
事件。这与我们在同一函数中为我们的OnDeath
事件所做的方式相同,但这次只需将事件的名称更改为OnTakeDamage
:
if (GetOwner()->Implements<UHealthInterface>())
{
IHealthInterface::Execute_OnTakeDamage(GetOwner());
}
因为我们的生命条需要玩家角色的生命值作为百分比,我们需要做以下事情:
- 在我们的
HealthComponent
中添加一个public
函数,该函数返回HealthComponent
类的头文件中的声明,添加一个FORCEINLINE
函数的声明,该函数返回一个float
。这个函数应该被称为GetHealthPercent
,并且是一个const
函数。它的实现将简单地返回Health
属性除以100
,我们将假设这是游戏中一个对象可以拥有的最大生命值的百分比:
FORCEINLINE float GetHealthPercent() const { return Health / 100.f; }
- 现在转到
DodgeballCharacter
类的头文件,并添加一个名为OnTakeDamage_Implementation
的public
virtual
函数的声明,该函数不返回任何内容,也不接收任何参数。将其标记为virtual
和override
:
virtual void OnTakeDamage_Implementation() override;
- 在
DodgeballCharacter
类的源文件中,添加我们刚刚声明的OnTakeDamage_Implementation
函数的实现。将OnDeath_Implementation
函数的内容复制到这个新函数的实现中,但做出这个改变:不要调用PlayerController
的ShowRestartWidget
函数,而是调用它的UpdateHealthPercent
函数,并将HealthComponent
属性的GetHealthPercent
函数的返回值作为参数传递:
void ADodgeballCharacter::OnTakeDamage_Implementation()
{
ADodgeballPlayerController* PlayerController = Cast<ADodgeballPlayerController>(GetController());
if (PlayerController != nullptr)
{
PlayerController->UpdateHealthPercent(HealthComponent- >GetHealthPercent());
}
}
这结束了这个练习的代码设置。在你做完这些改变之后,编译你的代码,打开编辑器,然后做以下操作:
-
打开
BP_HUDWidget
小部件蓝图,并将其重新设置为HUDWidget
类,就像你在练习 8.03中所做的那样,创建RestartWidget C++ Class
。 -
这应该会导致编译错误,你可以通过将我们的进度条元素重命名为
HealthBar
来修复它。 -
关闭这个小部件蓝图,打开
BP_DodgeballPlayerController
蓝图类,并将其BP_HUDWidget
属性设置为BP_HUDWidget
小部件蓝图:
图 8.31:将 BP_HUDWidget 属性设置为 BP_HUDWidget
在你做完这些改变之后,播放关卡。你应该注意到屏幕左上角的生命条
:
图 8.32:在屏幕左上角显示的进度条
当玩家角色被躲避球击中时,你应该注意到生命条
被清空:
图 8.33:随着玩家角色失去生命值,进度条被清空
有了这些,我们结束了这个练习,你已经学会了在屏幕上添加 HUD 并在游戏过程中更新它的所有必要步骤。
活动 8.01:改进 RestartWidget
在本次活动中,我们将向我们的RestartWidget
添加一个Text
元素,显示Game Over
,以便玩家知道他们刚刚输掉了游戏;添加一个Exit
按钮,允许玩家退出游戏;还更新现有按钮的文本为Restart
,以便玩家知道点击该按钮时会发生什么。
以下步骤将帮助您完成此活动:
-
打开
BP_RestartWidget
Widget 蓝图。 -
将一个新的
Text
元素拖放到现有的Canvas Panel
元素中。 -
修改
Text
元素的属性:
-
展开
Anchors
属性,并在X
轴上将其Minimum
设置为0.291
,在Y
轴上设置为0.115
,将其Maximum
设置为0.708
,在X
轴上设置为0.255
,在Y
轴上设置为0.708
。 -
将
Offset Left
,Offset Top
,Offset Right
和Offset Bottom
属性设置为0
。 -
将
Text
属性设置为GAME OVER
。 -
将
Color and Opacity
属性设置为红色:RGBA(1.0, 0.082, 0.082, 1.0)
。 -
展开
Font
属性并将其Size
设置为100
。 -
将
Justification
属性设置为Align Text Center
。
-
选择
RestartButton
属性内的另一个Text
元素,并将其Text
属性更改为Restart
。 -
复制
RestartButton
属性并将副本的名称更改为ExitButton
。 -
将
ExitButton
属性中Text
元素的Text
属性更改为Exit
。 -
展开
ExitButton
属性的Anchor
属性,并将其Minimum
设置为X
轴上的0.44
,Y
轴上的0.615
,将其Maximum
设置为X
轴上的0.558
,Y
轴上的0.692
。 -
将
ExitButton
属性的Offset Left
,Offset Top
,Offset Right
和Offset Bottom
设置为0
。
完成这些更改后,我们需要添加处理ExitButton
属性点击的逻辑,这将退出游戏:
-
保存对
BP_RestartWidget
Widget 蓝图所做的更改,并在 Visual Studio 中打开RestartWidget
类的头文件。在该文件中,添加一个名为OnExitClicked
的protected
函数的声明,返回void
,不接收任何参数。确保将其标记为UFUNCTION
。 -
复制现有的
RestartButton
属性,但将其命名为ExitButton
。 -
在
RestartWidget
类的源文件中,为OnExitClicked
函数添加一个实现。将VictoryBox
类的源文件中OnBeginOverlap
函数的内容复制到OnExitClicked
函数中,但删除对DodgeballCharacter
类的转换。 -
在
NativeOnInitialized
函数的实现中,将我们创建的OnExitClicked
函数绑定到ExitButton
属性的OnClicked
事件,就像我们在Exercise 8.03,Creating the RestartWidget C++ Class中为RestartButton
属性所做的那样。
这就结束了本次活动的代码设置。编译您的更改,打开编辑器,然后打开BP_RestartWidget
并编译它,以确保由于BindWidget
标签而没有编译错误。
完成后,再次玩游戏,让玩家角色被三个 Dodgeball 击中,并注意Restart
Widget 出现了我们的新修改:
图 8.34:玩家耗尽生命值后显示的更新后的 BP_RestartWidget
如果按下Restart
按钮,您应该能够重新开始游戏,如果按下Exit
按钮,游戏应该结束。
这就结束了我们的活动。您已经巩固了使用Widget
蓝图和更改其元素属性的基础知识,现在可以开始制作自己的菜单了。
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
总结
通过本章的学习,您已经学会了如何在 UE4 中制作游戏 UI,了解了诸如菜单和 HUD 等内容。您已经了解了如何操作 Widget Blueprint 的 UI 元素,包括“按钮”、“文本”元素和“进度条”;有效地使用锚点,这对于使游戏 UI 优雅地适应多个屏幕至关重要;在 C++中监听鼠标事件,如OnClick
事件,并利用它来创建自己的游戏逻辑;以及如何将您创建的小部件添加到屏幕上,无论是在特定事件发生时还是始终存在。
在下一章中,我们将通过添加声音和粒子效果来完善我们的躲避球游戏,同时制作一个新的关卡。
第八章:9.音频-视觉元素
概述
在本章中,我们将完成我们在过去四章中一直在努力的基于躲避球的游戏。我们将通过添加音效、粒子效果,并创建另一个关卡来结束本章,这次关卡中玩家必须遵循实际路径才能完成。到本章结束时,您将能够向您的 UE4 项目添加 2D 和 3D 音效,以及粒子效果。
介绍
在上一章中,我们学习了游戏 UI 以及如何创建和添加用户界面(也称为小部件)到屏幕上。
在本章中,我们将学习如何向我们的游戏添加音频和粒子效果。这两个方面都将提高我们游戏的质量,并为玩家提供更加沉浸式的体验。
在视频游戏中,声音可以以声音效果(也称为 SFX)或音乐的形式出现。声音效果使您周围的世界更加真实和生动,而音乐则有助于为您的游戏设定基调。这两个方面对于您的游戏都非常重要。
在竞技游戏中,如《反恐精英:全球攻势》(CS:GO)中,声音也非常重要,因为玩家需要听到他们周围的声音,如枪声和脚步声,以及它们来自哪个方向,以尽可能多地了解他们周围的情况。
粒子效果和音效之所以重要,是因为它们使您的游戏世界更加真实和沉浸。
让我们通过学习 UE4 中的音频工作来开始本章。
UE4 中的音频
任何游戏的基本组成部分之一是声音。声音使您的游戏更加真实和沉浸,这将为您的玩家提供更好的体验。视频游戏通常有两种类型的声音:
-
2D 声音
-
3D 声音
2D 声音不考虑听者的距离和方向,而 3D 声音可以根据玩家的位置在音量上升或下降,并在右侧或左侧移动。2D 声音通常用于音乐,而 3D 声音通常用于音效。主要的声音文件类型是.wav 和.mp3。
以下是 UE4 中与音频相关的一些资产和类:
-
“声音基础”:代表包含音频的资产。这个类主要用于 C++和蓝图,用于引用可以播放的音频文件。
-
声波:代表已导入到 UE4 中的音频文件。继承自“声音基础”。
-
“声音提示”:一个音频资产,可以包含与衰减(随着听者距离变化而音量如何变化)、循环、声音混合和其他与音频相关的功能相关的逻辑。它继承自“声音基础”。
-
“声音类”:允许您将音频文件分组并管理其中一些设置,如音量和音调。一个例子是将所有与音效相关的声音分组到
SFX
声音类中,将所有角色对话分组到“对话”声音类中,等等。 -
“声音衰减”:允许您指定 3D 声音的行为的资产;例如,它将从哪个距离开始降低音量,它将在哪个距离变得听不见(无法听到),如果音量会随着距离的增加而线性或指数变化等等。
-
音频组件:允许您管理音频文件及其属性的演员组件。用于设置连续播放声音,如背景音乐。
在 UE4 中,我们可以像导入其他资产一样导入现有的声音:通过将文件从 Windows 文件资源管理器拖放到“内容浏览器”中,或者通过在“内容浏览器”中点击“导入”按钮。我们将在下一个练习中进行这个操作。
练习 9.01:导入音频文件
在这个练习中,您将从计算机中导入一个现有的声音文件到 UE4 中。当躲避球从表面弹起时,将播放此音频文件。
注意
如果您没有音频文件(.mp3 或.wav 文件)可用来完成此练习,您可以在此链接下载.mp3 或.wav 文件:www.freesoundeffects.com/free-track/bounce-1-468901/
。
将此文件保存为BOUNCE.wav
。
一旦您有音频文件,请按照以下步骤操作:
-
打开编辑器。
-
转到“内容浏览器”界面内的“内容”文件夹,并创建一个名为“音频”的新文件夹:
图 9.1:内容浏览器中的音频文件夹
-
转到您刚刚创建的“音频”文件夹。
-
将您的音频文件导入此文件夹。您可以通过将音频文件从“Windows 文件资源管理器”拖放到“内容浏览器”中来执行此操作。
-
完成此操作后,应该会出现一个名为您音频文件的新资产,您可以在单击它时听到它:
图 9.2:导入的音频文件
- 打开此资产。您应该看到许多可供编辑的属性。但是,我们将仅专注于“声音”类别内的一些属性:
图 9.3:声音资产的设置
以下属性可在“声音”类别中使用:
-
“循环”:此声音在播放时是否循环。
-
“音量”:此声音的音量。
-
“音调”:此声音的音调。音调越高,频率越高,音调越高。
-
“类”:此声音的“声音类”。
我们将更改的唯一属性是“类”属性。我们可以使用 UE4 提供的现有“声音”类之一,但让我们为躲避球创建自己的“声音类”,以便为我们的游戏创建一组新的声音。
-
转到“内容浏览器”界面内的“音频”文件夹。
-
右键单击,转到“声音”类别(倒数第二个类别),然后转到“类别”类别,选择“声音类”。这将创建一个新的“声音类”资产。将此资产重命名为“躲避球”。
-
打开您导入的声音资产,并将其“类”属性设置为“躲避球”:
图 9.4:将类属性更改为躲避球声音类
现在,这个导入的声音资产属于特定的类,您可以将与躲避球相关的其他声音效果分组到同一个“声音类”中,并通过该“声音类”编辑它们的属性,包括“音量”、“音调”和许多其他属性。
有了这个,我们就可以结束我们的练习了。您已经学会了如何将声音导入到您的项目中,以及如何更改它们的基本属性。现在,让我们继续进行下一个练习,在这个练习中,我们将在我们的游戏中每当躲避球从表面弹开时播放声音。
练习 9.02:当躲避球从表面弹开时播放声音
在这个练习中,我们将为我们的DodgeballProjectile
类添加必要的功能,以便当躲避球从表面弹开时播放声音。
要做到这一点,请按照以下步骤操作:
-
关闭编辑器并打开 Visual Studio。
-
在
DodgeballProjectile
类的头文件中,添加一个受保护的class USoundBase*
属性,名为BounceSound
。此属性应该是一个UPROPERTY
,并具有EditDefaultsOnly
标记,以便可以在蓝图中进行编辑:
// The sound the dodgeball will make when it bounces off of a surface
UPROPERTY(EditAnywhere, Category = Sound)
class USoundBase* BounceSound;
- 完成此操作后,转到
DodgeballProjectile
类的源文件,并添加一个包含GameplayStatics
对象的包含:
#include "Kismet/GameplayStatics.h"
- 然后,在类的
OnHit
函数的实现开始之前,在对DodgeballCharacter
类的转换之前,检查我们的BounceSound
是否是有效属性(与nullptr
不同),以及NormalImpulse
属性的大小是否大于600
单位(我们可以通过调用其Size
函数来访问大小)。
正如我们在第八章,用户界面中看到的,NormalImpulse
属性表示在被击中后改变躲避球轨迹的方向和大小的力量。我们要检查它的大小是否大于一定数量的原因是,当躲避球开始失去动量并且每秒在地板上反弹多次时,我们不希望每秒播放BounceSound
多次;否则,会产生很多噪音。因此,我们将检查躲避球所受的冲量是否大于该数量,以确保这种情况不会发生。如果这两个条件都成立,我们将调用GameplayStatics
对象的PlaySoundAtLocation
。这个函数负责播放 3D 声音。它接收五个参数:
-
一个世界上下文对象,我们将作为
this
指针传递。 -
一个
SoundBase
属性,将是我们的HitSound
属性。 -
声音的来源,我们将使用
GetActorLocation
函数传递。 -
VolumeMultiplier
,我们将传递一个值为1
。这个值表示播放此声音时音量会高低多少。例如,值为2
表示音量会是原来的两倍。 -
PitchMultiplier
,表示播放此声音时音调会高低多少。我们将使用FMath
对象的RandRange
函数传递这个值,该函数接收两个数字作为参数,并返回这两个数字之间的随机数。为了在0.7
和1.3
之间随机生成一个数字,我们将使用这些值作为参数调用这个函数。
看一下以下代码片段:
if (BounceSound != nullptr && NormalImpulse.Size() > 600.0f)
{
UGameplayStatics::PlaySoundAtLocation(this, BounceSound, GetActorLocation(), 1.0f, FMath::RandRange(0.7f, 1.3f));
}
注意
负责播放 2D 声音的函数也可以从GameplayStatics
对象中获得,它被称为PlaySound2D
。这个函数将接收与PlaySoundAtLocation
函数相同的参数,除了第三个参数,即声音的来源。
-
编译这些更改,然后打开虚幻编辑器。
-
打开
BP_DodgeballProjectile
蓝图,转到其Class Defaults
选项卡,并将BounceSound
属性设置为你导入的声音资产:
图 9.5:将 BounceSound 属性设置为我们导入的声音
- 再次玩这个关卡,进入敌人角色的视线。你应该注意到每当敌人角色投掷的躲避球击中墙壁或地板(而不是玩家角色)时,会播放不同音调的声音:
图 9.6:玩家角色导致敌人角色投掷躲避球
如果这样做成功了,恭喜你——你已经成功使用 UE4 播放了声音!如果你听不到声音,确保它是可听到的(它有一个你可以听到的音量级别)。
然而,你可能会注意到的另一件事是,无论角色与反弹的躲避球的距离如何,声音总是以相同的音量播放:声音不是以 3D 方式播放,而是以 2D 方式播放。要在 UE4 中以 3D 方式播放声音,我们必须学习关于声音衰减资产的知识。
声音衰减
要在 UE4 中以 3D 方式播放声音,你必须创建一个声音衰减资产,就像我们在本章的第一节中提到的那样。声音衰减资产将让你指定当声音与听者的距离增加时,你希望特定声音如何改变音量。看一下以下示例。
打开虚幻编辑器,转到内容浏览器
界面内的Audio
文件夹,右键单击,转到声音
类别,并选择声音衰减
。将这个新资产命名为BounceAttenuation
:
图 9.7:创建声音衰减资产
打开这个BounceAttenuation
资产。
声音衰减资产有许多设置;然而,我们主要关注衰减距离
部分的一些设置:
-
内半径
:这个float
属性允许我们指定声音开始降低音量的距离。如果声音在小于这个值的距离播放,音量不会受到影响。将此属性设置为200
单位。 -
衰减距离
:这个浮点属性允许我们指定声音变得听不见的距离。如果声音在大于这个值的距离播放,我们将听不到它。声音的音量将根据其与听者的距离以及它是更接近内半径
还是衰减距离
而变化。将此属性设置为1500
单位:
图 9.8:声音衰减资产设置
将其视为玩家周围的两个圆,较小的圆是内圆(半径值为内半径
),较大的圆是衰减圆(半径值为衰减距离
)。如果声音起源于内圆内部,则以全音量播放,而起源于衰减圆外部的声音则不会播放。
注意
您可以在这里找到有关声音衰减资产的更多信息:
docs.unrealengine.com/en-US/Engine/Audio/DistanceModelAttenuation
。
现在您已经了解了声音衰减资产,让我们继续下一个练习,我们将把躲避球弹起时播放的声音变成 3D 声音。
练习 9.03:将弹跳声音变成 3D 声音
在这个练习中,我们将把上一个练习中添加的躲避球弹起时播放的声音变成 3D 声音。这意味着当躲避球从地面弹起时播放的声音将根据其与玩家的距离而音量有所变化。我们这样做是为了当躲避球远离时,声音音量会很低,而当它靠近时,音量会很高。
要使用我们在上一节中创建的BounceAttenuation
资产,请按照以下步骤进行:
- 转到
DodgeballProjectile
的头文件,并添加一个名为BounceSoundAttenuation
的protected
class USoundAttenuation*
属性。这个属性应该是一个UPROPERTY
,并且有EditDefaultsOnly
标记,以便可以在蓝图中进行编辑:
// The sound attenuation of the previous sound
UPROPERTY(EditAnywhere, Category = Sound)
class USoundAttenuation* BounceSoundAttenuation;
- 转到
DodgeballProjectile
类的源文件中的OnHit
函数的实现,并向PlaySoundAtLocation
函数的调用添加以下参数:
-
StartTime
,我们将传递一个值为0
。这个值表示声音开始播放的时间。如果声音持续 2 秒,我们可以通过传递值1
使这个声音从其 1 秒标记开始。我们传递一个值0
,以便从头开始播放声音。 -
SoundAttenuation
,我们将传递我们的BounceSoundAttenuation
属性:
UGameplayStatics::PlaySoundAtLocation(this, BounceSound, GetActorLocation(), 1.0f, 1.0f, 0.0f, BounceSoundAttenuation);
注意
尽管我们只想传递额外的SoundAttenuation
参数,但我们也必须传递所有其他在它之前的参数。
-
编译这些更改,然后打开编辑器。
-
打开
BP_DodgeballProjectile
蓝图,转到其类默认
选项卡,并将BounceSoundAttenuation
属性设置为我们的BounceAttenuation
资产:
图 9.9:将 BoundSoundAttenuation 属性设置为 BounceAttenuation 资产
- 再次播放关卡并进入敌人角色的视线范围。您现在应该注意到,每当敌人角色投掷的躲避球击中墙壁或地板时播放的声音会根据距离的不同以不同的音量播放,并且如果躲避球远了,您将听不到它:
图 9.10:玩家角色使敌人角色投掷躲避球
有了这个,我们可以结束这个练习。您现在知道如何使用 UE4 播放 3D 声音。我们将在下一个练习中为我们的游戏添加背景音乐。
练习 9.04:为我们的游戏添加背景音乐
在这个练习中,我们将为我们的游戏添加背景音乐。我们将通过创建一个带有音频组件的新 Actor 来实现这一点,正如我们之前提到的,这是适合播放背景音乐的。要实现这一点,请按照以下步骤进行:
-
下载位于
packt.live/3pg21sQ
的音频文件,并将其导入到Content Browser
界面的“音频”文件夹中,就像我们在“练习 9.01”、“导入音频文件”中所做的那样。 -
右键单击
Content Browser
界面内部,并使用Actor
类作为其父类创建一个新的 C++类。将这个新类命名为MusicManager
。 -
当为这个类生成文件并且 Visual Studio 自动打开时,关闭编辑器。
-
在
MusicManager
类的头文件中,添加一个名为AudioComponent
的新的受保护属性,类型为class UAudioComponent*
。将其设置为UPROPERTY
,并添加VisibleAnywhere
和BlueprintReadOnly
标签:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
class UAudioComponent* AudioComponent;
- 在
MusicManager
类的源文件中,添加AudioComponent
类的包含:
#include "Components/AudioComponent.h"
- 在这个类的构造函数中,将
bCanEverTick
属性更改为false
:
PrimaryActorTick.bCanEverTick = false;
- 在这一行之后,添加一个新的行,通过调用
CreateDefaultSubobject
函数并将UAudioComponent
类作为模板参数和"Music Component"
作为普通参数传递来创建AudioComponent
类:
AudioComponent = CreateDefaultSubobject<UAudioComponent>(TEXT("Music Component"));
-
进行这些更改后,编译您的代码并打开编辑器。
-
转到
Content Browser
界面中的ThirdPersonCPP
->Blueprints
文件夹,并创建一个从MusicManager
类继承的新蓝图类。将其命名为BP_MusicManager
。 -
打开这个资产,选择它的“音频”组件,并将该组件的“声音”属性设置为您导入的声音:
图 9.11:更新声音属性
-
将
BP_MusicManager
类的实例拖入关卡中。 -
播放关卡。您应该注意到游戏开始时音乐开始播放,并且当它到达结尾时也应该自动循环播放(这是通过音频组件实现的)。
注意
音频组件将自动循环播放它们正在播放的任何声音,因此不需要更改该声音资产的“循环”属性。
完成所有这些步骤后,我们已经完成了这个练习。您现在知道如何为您的游戏添加简单的背景音乐了。
现在,让我们进入下一个话题,即粒子系统。
粒子系统
让我们谈谈许多视频游戏中非常重要的另一个元素:粒子系统。
在视频游戏术语中,粒子实质上是 3D 空间中可以用图像表示的位置。粒子系统是许多粒子的集合,可能具有不同的图像、形状、颜色和大小。在下图中,您将找到在 UE4 中制作的两个粒子系统的示例:
图 9.12:UE4 中的两个不同的粒子系统
左侧的粒子系统应该是电火花,可能来自被切割并且现在处于短路状态的电缆,而右侧的粒子系统应该是火。虽然左侧的粒子系统相对简单,但您可以看出右侧的粒子系统内有多种类型的粒子,这些粒子可以组合在同一个系统中。
注意
UE4 有两种不同的工具用于创建粒子系统:Cascade
和Niagara
。Cascade 是自 UE4 开始就存在的工具,而 Niagara 是一个更近期和复杂的系统,自 2020 年 5 月以来才成熟可用,截至虚幻引擎版本 4.25。
在 UE4 中创建粒子系统超出了本书的范围,但建议您使用 Niagara 而不是 Cascade,因为它是引擎的最新添加。
在本章中,我们将只使用已经包含在 UE4 中的粒子系统,但如果您想创建自己的粒子系统,这些链接将为您提供有关 Cascade 和 Niagara 的更多信息:
Cascade:docs.unrealengine.com/en-US/Engine/Rendering/ParticleSystems/Cascade
www.youtube.com/playlist?list=PLZlv_N0_O1gYDLyB3LVfjYIcbBe8NqR8t
Niagara:docs.unrealengine.com/en-US/Engine/Niagara/EmitterEditorReference/index.html
docs.unrealengine.com/en-US/Engine/Niagara/QuickStart
我们将在下一个练习中学习如何将粒子系统添加到我们的游戏中。在本章中,我们将简单地使用已经由 UE4 团队制作的现有粒子系统。
练习 9.05:当躲避球击中玩家时生成一个粒子系统
在这个练习中,我们将了解如何在 UE4 中生成一个粒子系统。在这种情况下,当敌人投掷的躲避球击中玩家时,我们将生成一个explosion
粒子系统。
为了实现这一点,请按照以下步骤:
-
关闭编辑器,打开 Visual Studio。
-
在
DodgeballProjectile
类的头文件中,添加一个受保护的class UParticleSystem*
属性,名为HitParticles
。
UParticleSystem
类型是 UE4 中的粒子系统的指定。确保将其设置为UPROPERTY
并给予EditDefaultsOnly
标签,以便可以在蓝图类中进行编辑:
// The particle system the dodgeball will spawn when it hits the player
UPROPERTY(EditAnywhere, Category = Particles)
class UParticleSystem* HitParticles;
- 在
DodgeballProjectile
类的源文件中,在其OnHit
函数的实现中。在调用Destroy
函数之前,检查我们的HitParticles
属性是否有效。如果有效,调用GameplayStatics
对象的SpawnEmitterAtLocation
函数。
此函数将生成一个将播放我们传递的粒子系统的角色。它接收以下参数:
-
一个
World
对象,我们将使用GetWorld
函数传递。 -
一个
UParticleSystem*
属性,它将是我们的HitParticles
属性。 -
将播放粒子系统的角色的
FTransform
,我们将使用GetActorTransform
函数传递:
if (HitParticles != nullptr)
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), HitParticles, GetActorTransform());
}
注意
虽然我们在这个项目中不会使用它,但与生成粒子系统相关的另一个函数来自GameplayStatics
对象,即SpawnEmitterAttached
函数。此函数将生成一个粒子系统并将其附加到一个角色,如果您想要,例如,使一个移动的物体着火,以便粒子系统始终保持附加到该物体,这可能会有用。
-
编译这些更改,然后打开编辑器。
-
打开
BP_DodgeballProjectile
蓝图,转到其Class Defaults
选项卡,并将HitParticles
属性设置为P_Explosion
粒子系统资产:
图 9.13:将 HitParticles 属性设置为 P_Explosion
- 现在,播放关卡,让您的玩家角色被躲避球击中。现在您应该看到爆炸粒子系统正在播放:
图 9.14:当躲避球击中玩家时播放的爆炸粒子系统
这就结束了这个练习。现在你知道如何在 UE4 中播放粒子系统。粒子系统将为您的游戏增添视觉效果,使其在视觉上更具吸引力。
在下一个活动中,我们将通过在躲避球击中玩家时播放声音来巩固我们在 UE4 中播放音频的知识。
活动 9.01:当躲避球击中玩家时播放声音
在这个活动中,我们将创建逻辑,负责在玩家角色被躲避球击中时每次播放声音。在视频游戏中,以多种方式向玩家传递关键信息非常重要,因此除了改变玩家角色的生命值条外,当玩家被击中时我们还将播放声音,以便玩家知道角色正在受到伤害。
要做到这一点,请按照以下步骤进行:
- 将一个声音文件导入到
内容浏览器
界面内的Audio
文件夹中,该声音文件将在玩家角色被击中时播放。
注意
如果您没有声音文件,您可以使用www.freesoundeffects.com/free-track/punch-426855/
上提供的声音文件。
-
打开
DodgeballProjectile
类的头文件。添加一个SoundBase*
属性,就像我们在练习 9.02中所做的那样,当躲避球从表面弹开时播放声音,但这次称其为DamageSound
。 -
打开
DodgeballProjectile
类的源文件。在OnHit
函数的实现中,在你伤害了玩家角色并在调用Destroy
函数之前,检查DamageSound
属性是否有效。如果有效,调用GameplayStatics
对象的PlaySound2D
函数(在练习 9.02中提到,当躲避球从表面弹开时播放声音),将this
和DamageSound
作为该函数调用的参数。 -
编译您的更改并打开编辑器。
-
打开
BP_DodgeballProjectile
蓝图,并将其DamageSound
属性设置为您在本活动开始时导入的声音文件。
当您播放关卡时,您应该注意到每当玩家被躲避球击中时,您将听到您导入的声音被播放:
图 9.15:当玩家角色被击中时应该播放声音
完成了这些步骤后,您已经完成了这个活动,并巩固了在 UE4 中播放 2D 和 3D 声音的使用。
注意
此活动的解决方案可以在以下网址找到:packt.live/338jEBx
。
现在,让我们通过学习一些关于关卡设计概念来结束本章。
关卡设计
自第五章,线性跟踪,与我们的躲避球游戏相关,我们已经添加了相当多的游戏机制和游戏机会,以及一些视听元素,所有这些都在本章中处理。现在我们有了所有这些游戏元素,我们必须将它们汇集到一个可以由玩家从头到尾玩的关卡中。为此,让我们学习一些关于关卡设计和关卡布局的知识。
关卡设计是一种特定的游戏设计学科,专注于在游戏中构建关卡。关卡设计师的目标是制作一个有趣的关卡,通过使用为该游戏构建的游戏机制向玩家介绍新的游戏玩法概念,包含良好的节奏(充满动作和轻松的游戏序列的良好平衡),以及更多内容。
为了测试关卡的结构,关卡设计师将首先构建所谓的关卡布局。这是关卡的一个非常简单和简化版本,使用了最终关卡将包含的大部分元素,但只使用简单的形状和几何图形制作。这样做的原因是为了在需要修改关卡的部分时更容易和节省时间:
图 9.16:使用 BSP Brushes 在 UE4 中制作的关卡布局示例
注意
应该注意的是,关卡设计是一种特定的游戏开发技能,值得有一本专门的书来介绍,而实际上也有很多这样的书,但是深入讨论这个话题超出了本书的范围。
在下一个练习中,我们将使用我们在最近几章中构建的机制来构建一个简单的关卡布局。
练习 9.06:构建关卡布局
在这个练习中,我们将创建一个新的关卡布局,其中包含一些结构,玩家将从关卡的某个地方开始,并通过一系列障碍物到达关卡的结尾。我们将使用我们在最近几章中构建的所有机制和对象来制作一个玩家能够完成的关卡。
尽管在这个练习中我们将为您提供一个解决方案,但鼓励您发挥创造力,提出自己的解决方案,因为在这种情况下没有对错之分。
要开始这个练习,请按照以下步骤操作:
-
打开编辑器。
-
转到
ThirdPersonCPP
->“内容浏览器”中的“地图”文件夹,复制ThirdPersonExampleMap
资产,并将其命名为Level1
。您可以通过选择资产并按下Ctrl + W或右键单击资产并选择“复制”(第三个选项)来执行此操作。 -
打开新创建的
Level1
地图。 -
删除地图中具有网格的所有对象,除了以下对象:
-
玩家角色
-
敌人角色(注意两个角色看起来是一样的)
-
地板对象
-
我们创建的墙对象
-
胜利箱对象
请记住,与照明和声音相关的资产应保持不变。
-
通过按下“构建”按钮为
Level1
建立照明。该按钮位于编辑器窗口顶部的“工具栏”中,“播放”按钮的左侧。 -
在您按照这些步骤操作后,您应该有一个空的地板,只有您在这个关卡中需要的对象(在步骤 4中提到的对象)。以下是在您分别按照步骤 4 和 5之后的
Level1
地图之前和之后的情况:
图 9.17:删除所需对象之前
一旦你删除了对象,你的地板应该如下所示:
图 9.18:删除所需对象后
因为建立一个关卡,即使是一个简单的关卡,也需要很多步骤和指示,所以我们将简单地展示一些可能的关卡截图,并鼓励您自己想出解决方案。
- 在这种情况下,我们只是简单地使用了现有的
EnemyCharacter
、Wall
和GhostWall
对象,并将它们多次复制,以创建玩家可以从头到尾穿越的简单布局。我们还移动了VictoryBox
对象,使其与新关卡的结束位置匹配:
图 9.19:创建的关卡-等距视图
关卡可以从俯视图中看到如下:
图 9.20:创建的关卡-俯视图,玩家角色用箭头标记
一旦你对结果满意,这意味着你已经完成了你的躲避球游戏,现在可以邀请你的朋友和家人来玩,并看看他们的想法。干得好 - 你离掌握游戏开发的艺术又近了一步!
额外功能
在我们结束本章之前,这里有一些建议,关于接下来在这个躲避球项目中你可以做些什么:
-
使之前创建的普通“墙”类不会阻挡敌人的视线。这样,敌人将始终向玩家投掷躲避球,但仍然会被这堵墙挡住。
-
添加一个新功能,允许玩家通过“扫描轨迹”概念来可视化敌人角色投掷的躲避球首先会影响到哪里。
-
添加一种新类型的墙,可以阻挡玩家角色、敌人角色和躲避球,但也会受到躲避球的伤害,并在耗尽生命值时被摧毁。
这个项目的扩展空间是无限的。我们鼓励你运用所学的技能,并进行进一步的研究,为你的游戏添加新功能并增加更多的复杂性。
总结
你现在已经完成了躲避球游戏项目。在本章中,你学会了如何通过播放音频和使用粒子系统来为你的游戏增添亮点。你现在知道如何为你的游戏添加 2D 和 3D 声音,以及一些相关的工具。现在,你可以尝试为你的游戏添加更多的声音效果,比如当敌人角色第一次看到你时的特殊声音效果(比如《合金装备》中的情况)、脚步声音效果或者胜利声音效果。
你还使用了在前几章中制作的所有工具来构建一个关卡,从而汇总了我们在这个项目中构建的所有逻辑。
在下一章中,我们将开始一个新项目:《超级横向卷轴》游戏。在那个项目中,你将接触到诸如增益、可收集物品、敌人人工智能(AI)、角色动画等主题。你将创建一个横向卷轴平台游戏,控制一个角色完成关卡,收集宝石,并使用增益来避开敌人。你将学习的两个最重要的主题是 UE4 的行为树和黑板,它们支持 AI 系统,以及动画蓝图,它允许你管理角色的动画。
第九章:创建一个 SuperSideScroller 游戏
概述
在本章中,我们将为新的SuperSideScroller
游戏设置项目。您将了解横向滚动游戏的不同方面,包括强化道具、可收集物品和敌人人工智能,我们将在项目中使用所有这些。您还将了解游戏开发中的角色动画流程,并了解如何操纵我们游戏角色的移动。
在本章结束时,您将能够创建一个横向滚动项目,操纵我们角色的默认模特骨骼,导入角色和动画,并创建角色和动画蓝图。
介绍
到目前为止,我们已经学到了很多关于虚幻引擎、C++编程和一般游戏开发技术和策略的知识。在之前的章节中,我们涵盖了诸如碰撞、追踪、如何在虚幻引擎 4 中使用 C++,甚至蓝图可视化脚本系统等主题。除此之外,我们还获得了关于骨骼、动画和动画蓝图的关键知识,我们将在即将到来的项目中利用这些知识。
对于我们的最新项目SuperSideScroller
,我们将使用许多在之前章节中使用过的概念和工具来开发我们的游戏特性和系统。碰撞、输入和 HUD 等概念将是我们项目的重点;然而,我们还将深入研究涉及动画的新概念,以重新创建流行横向滚动游戏的机制。最终项目将是我们迄今为止在本书中学到的一切的结晶。
有无数的横向滚动游戏示例可供参考。最近一些流行的横向滚动游戏包括Celeste、Hollow Knight和Shovel Knight,但是横向滚动/平台游戏背后也有悠久而丰富的历史,我们将在本章中讨论。
项目分解
让我们考虑一下著名的超级马里奥兄弟的例子,该游戏于 1985 年在任天堂娱乐系统(NES)主机上发布。这款游戏是由任天堂制作,由宫本茂设计。对于不熟悉这个系列的人来说,一般的想法是:玩家控制马里奥,他必须穿越蘑菇王国的许多危险障碍和生物,希望从邪恶的酷霸王鲍斯那里救出桃花公主。
注意
为了更好地理解游戏的运作方式,请随时在supermariobros.io/
免费在线玩游戏。整个超级马里奥兄弟系列的更深入的维基可以在这里找到:www.mariowiki.com/Super_Mario_Bros
。
以下是这种类型游戏的核心特点和机制:
SuperSideScroller
游戏将是 3D 而不是纯 2D,我们角色的移动将与马里奥的移动方式完全相同,只支持垂直和水平移动:
图 10.1:2D 和 3D 坐标向量的比较
-
SuperSideScroller
游戏也不例外。有许多不同的游戏,如Celeste、Hollow Knight和Super Meat Boy,如前所述,都使用了跳跃功能-所有这些都是 2D 的。 -
角色强化道具:没有角色强化道具,许多横向滚动游戏会失去混乱感和可重复性。例如,在游戏奥里和失落的森林中,开发者引入了不同的角色能力,改变了游戏的玩法。像三段跳或空中冲刺这样的能力打开了各种可能性,使玩家能够根据其移动能力创建有趣的布局。
-
敌方 AI:引入具有各种能力和行为的敌人,以增加玩家的挑战层次,除了通过可用的移动机制单独导航关卡的挑战之外。
注意
游戏中的 AI 可以以哪些方式与玩家互动?例如,在《上古卷轴 V:天际》中,各个城镇和村庄中的 AI 角色可以与玩家进行对话,以阐述世界构建元素,如历史,向玩家出售物品,甚至向玩家提供任务。
SuperSideScroller
游戏将允许玩家收集硬币。
现在我们已经评估了我们想要支持的游戏机制,我们可以分解每个机制的功能,以及它如何与我们的SuperSideScroller
相关,以及我们需要做些什么来实现这些功能。
玩家角色
当使用虚幻引擎 4 的侧向滚动
游戏项目模板时,几乎所有我们想要的角色功能都已经默认给我们了。
注意
在撰写本文时,我们使用的是虚幻引擎版本 4.24.2;使用引擎的其他版本可能会导致编辑器、工具以及后续逻辑的一些差异,因此请记住这一点。
现在,让我们在下一个练习中开始创建我们的项目。
练习 10.01:创建侧向滚动项目并使用角色移动组件
在本练习中,您将使用侧向滚动
模板设置虚幻引擎 4。这个练习将帮助您开始我们的游戏。
以下步骤将帮助您完成练习:
-
首先,打开 Epic Games Launcher,导航到左侧选项底部的
Unreal Engine
选项卡,并在顶部选择Library
选项。 -
接下来,您将收到一个窗口提示您要么打开现有项目,要么创建特定类别的新项目。其中包括
游戏
类别;选择此选项以进行我们的项目。选择了项目类别后,您现在需要选择项目的模板。 -
接下来,点击
侧向滚动
选项,因为我们希望我们的游戏使用 3D 骨骼网格和动画,而不仅仅是 2D 纹理、翻页书和 Paper2D 工具集的其他功能。
注意
请务必选择正确的侧向滚动
选项,因为虚幻引擎 4 有两种类型的侧向滚动项目:侧向滚动
和2D 侧向滚动
。
我们将在本练习之后讨论这两种项目模板之间的主要区别。
最后,我们需要设置我们的项目设置。
-
选择基于
C++
的项目,而不是蓝图
,以包括入门内容
,并将我们的平台选择为桌面/控制台
。其余的项目设置可以保留为默认设置。选择位置并命名项目为SuperSideScroller
,并将项目保存在您选择的适当目录中。 -
应用这些设置后,选择
创建项目
。当编译引擎完成后,虚幻编辑器和 Visual Studio 都将打开,我们就可以开始了。
图 10.2:虚幻引擎编辑器现在应该已经打开
接下来,我们继续操作默认的SideScroller
角色内存在的角色移动组件,并查看这如何影响角色。Character Movement
组件只能在Character
类中实现,并允许双足化身通过行走、跳跃、飞行和游泳移动。这个组件还具有内置的网络复制功能,这对于多人游戏是必要的。
- 在
Content Browser
中,导航到/SideScrollerCPP/Blueprints/
目录,并找到SideScrollerCharacter
蓝图:
图 10.3:在内容浏览器中选择默认的 SideScrollerCharacter 蓝图
- 双击蓝图资产以打开蓝图。有时,如果蓝图没有任何图形逻辑,您将看到图 10.4中显示的内容。如果您看到这个,请只需左键单击“打开完整蓝图编辑器”:
图 10.4:当蓝图没有图形逻辑时
-
打开角色“蓝图”,我们可以左键单击“组件”选项卡中的“CharacterMovement(继承)”组件,以查看此组件的参数。
-
现在,在“详细信息”面板下,我们可以访问数十个影响角色移动的参数。在
Character Movement: Walking
类别中,我们有Max Walk Speed
参数。将此值从600.0f
更改为2000.0f
。 -
最后,编译并保存我们的角色蓝图。现在,如果我们在编辑器中播放,我们可以观察到我们的玩家角色移动得有多快:
图 10.5:如果我们在编辑器中播放,我们可以看到我们的角色移动得更快
现在您已经完成了这项练习,亲身体验了对玩家角色移动方式的控制!尝试更改“最大行走速度”的值,并观察这些更改如何影响角色。
侧向滚动与 2D 侧向滚动
让我们在这里花点时间了解“2D 侧向滚动”项目模板和“侧向滚动”模板之间的主要区别。 “2D 侧向滚动”模板使用了基于纸张 2D 系统构建的虚幻引擎 4,利用了基于纹理的动画,通过纹理、精灵和纸张翻书。
注意
有关 Paper2D 的更多详细信息,请参阅以下文档:docs.unrealengine.com/en-US/Engine/Paper2D/index.html
。
有关 Paper2D 的材料足够多,值得有一本专门的教材,因此我们不会再涉及太多这个主题。然而,“侧向滚动”模板几乎与 2D 版本相同,只是我们使用 3D 动画骨骼而不是 2D 动画。
现在,让我们继续并看看执行我们的第一个活动来操纵玩家角色的跳跃动作。
活动 10.01:使我们的角色跳得更高
在这项活动中,我们将操纵默认的“侧向滚动”角色蓝图中CharacterMovement
组件中存在的一个新参数(跳跃),以观察这些属性如何影响我们的角色移动。
我们将实施从练习 10.01中学到的内容,创建侧向滚动项目并使用角色移动组件,并将其应用于如何创建我们的角色强化道具以及角色的一般移动感觉。
以下步骤将帮助您完成这项活动:
-
转到
SideScrollerCharacter
蓝图,并在CharacterMovement
组件中找到Jump Z Velocity
参数。 -
将此参数从默认的
1000.0
f 更改为2000.0
f。 -
编译并保存
SideScrollerCharacter
蓝图,并在编辑器中播放。观察我们的角色使用键盘上的空格键可以跳多高。 -
停止在编辑器中播放,返回到
SideScrollerCharacter
蓝图,并将Jump Z Velocity
从2000.0
f 的值更新为200.0
f。 -
再次编译并保存蓝图,然后在编辑器中播放,观察角色的跳跃。
预期输出:
图 10.6:跳跃角色的预期输出
注意
此活动的解决方案可在此处找到:packt.live/338jEBx
。
现在我们已经完成了这个活动,对于CharacterMovement
组件参数的一些更改如何影响我们的玩家角色有了更好的理解。当我们需要给我们的角色基本的移动行为,比如行走速度
和跳跃 Z 速度
时,我们可以在以后使用这些知识来实现我们想要的角色感觉。在继续之前,将跳跃 Z 速度参数恢复到默认值 1000.0f。
在我们项目的后期,当我们开发我们的玩家角色增强道具时,我们也会记住这些参数。
我们横向卷轴游戏的特点
现在让我们花点时间来详细说明我们将要设计的游戏。这些特性中的许多将在后面的章节中实现,但现在是一个好时机来规划项目的愿景。
敌人角色
在玩SuperSideScroller
项目时,你应该已经注意到默认情况下没有提供敌人 AI。因此,让我们讨论我们希望支持的敌人类型以及它们的工作方式。我们的SuperSideScroller
项目将支持一种敌人类型。
敌人将有一个基本的来回移动模式,并不支持任何攻击;只有与玩家角色碰撞,他们才能造成伤害。然而,我们需要设置敌人 AI 要移动的两个位置,接下来,我们需要决定 AI 是否应该改变位置。他们应该不断在位置之间移动,还是在选择新位置移动之前应该暂停一下?
最后,我们决定我们的 AI 是否应该始终知道玩家的位置。如果玩家进入敌人的一定范围,敌人是否应该知道这一点,并积极地朝着玩家最后所在的位置移动?
在第十三章 敌人人工智能中,我们将使用虚幻引擎 4 中可用的工具来开发这种 AI 逻辑。
增强道具
SuperSideScroller
游戏项目将支持一种类型的增强道具,即玩家可以从环境中拾取的药水。这种药水增强道具将增加玩家的移动速度和最大跳跃高度。这些效果只会持续很短的时间,然后就会消失。
记住你在练习 10.01 创建横向卷轴项目并使用角色移动组件和活动 10.01 使我们的角色跳得更高中实现的内容,关于CharacterMovement
组件,你可以开发一个改变角色重力影响的增强道具,这将允许以新的有趣方式穿越关卡和与敌人战斗。
可收集物品
视频游戏中的可收集物品有不同的用途。在某些情况下,可收集物品被用作一种货币,用于购买升级、物品和其他商品。在其他情况下,可收集物品用来提高你的得分或在收集足够的可收集物品时奖励你。对于SuperSideScroller
游戏项目,硬币将只有一个目的:给玩家一个目标,尽可能多地收集硬币,而不被敌人摧毁。
让我们分解一下我们可收集物品的主要方面:
-
可收集物品需要与我们的玩家进行交互;这意味着我们需要使用碰撞检测让玩家收集它,并且为我们的 UI 添加信息。
-
可收集物品需要一个视觉静态网格表示,以便玩家可以在关卡中识别它。
我们SuperSideScroller
项目的最后一个元素是砖块。砖块将为SuperSideScroller
游戏提供以下用途:
-
砖块被用作关卡设计的一个元素。砖块可以用来进入其他无法到达的区域;敌人可以放置在不同高度的砖块上,以提供游戏玩法的变化。
-
砖块中可以包含可收集的硬币。这给玩家一个动力去尝试并查看哪些方块包含可收集物品,哪些不包含。
HUD(头顶显示)
HUD UI 可以用于根据游戏类型和您支持的机制向玩家显示重要和相关的信息。对于SuperSideScroller
项目,将有一个 HUD 元素,它将向玩家显示他们收集了多少个硬币。每当玩家收集一个硬币时,此 UI 将更新,并且当玩家被销毁时将重置为0
。
现在我们已经列出了这个项目的一些具体内容,我们将继续进行动画流程。
动画步骤
需要明确的是,本书不会涵盖动画制作。我们不会讨论和学习如何使用 3D 软件工具(如 3D Studio Max、Maya 或 Blender)制作动画。然而,我们将学习如何将这些资产导入虚幻引擎,使用引擎内的动画资产,并使用可用的动画工具集来赋予角色生命。
角色动画流程
对于本书的目的,我们只关注 3D 动画以及动画在虚幻引擎 4 中的工作方式;然而,简要讨论许多行业中用于创建角色及其动画的流程是很重要的。
概念阶段
第一阶段是开发我们想要创建并稍后进行动画的角色的概念。这几乎总是以 2D 形式完成,可以手工完成,也可以通过使用诸如 Photoshop 之类的计算机程序完成。对于 3D 建模师来说,有几个关于角色外观和相对大小的参考图,可以使建模过程更加容易。下面,我们看到一个棍人角色在不同姿势下的基本示例。注意角色以不同的方式摆姿势:
图 10.7:一个 2D 角色概念的非常简单的例子
3D 建模阶段
一旦角色概念完成,流程就可以转移到下一个阶段:制作角色的 3D 模型。模型通常是在 3D Studio Max 或 Maya 等程序中制作的,但这些软件相对昂贵,除非您有学生许可证,并且更常用于专业环境中。
不需要详细讨论 3D 建模的复杂性,我们只需要知道 3D 艺术家使用计算机软件来操纵 3D 空间中的点(称为顶点)来创建物体。然后将这些物体雕刻成我们的角色或环境部件的形状。
绑定阶段
一旦最终的角色模型完成,就可以开始绑定过程。通常用于建模角色的软件通常也用于绑定角色。绑定意味着构建一系列形成角色骨架的骨骼。
在人形角色的情况下,我们通常会看到头部、脊柱、臀部、腿部等骨骼;但是骨架的形状可能会因您制作的角色类型而有所不同。大象的骨骼结构与人类完全不同。同一个骨骼结构也可以应用于不同的角色。
动画
一旦我们的角色绑定完成并且有了骨骼层次结构,就该是动画师拿起这个网格并用动画赋予它生命的时候了。
3D 动画,基本上是对骨骼在时间上的操纵。记录骨骼位置、旋转和缩放随时间的变化过程就是动画的结果。动画完成后,我们可以从 3D 软件中导出资产,并将其导入引擎。
资产导出和导入
当我们有了我们的 3D 角色网格,它的骨骼系统和动画,就是时候将这些资产从 3D 软件导出并导入到虚幻引擎 4 中了。重要的是要注意,负责角色、骨骼和动画的艺术家们将不断地将正在进行中的工作资产导入引擎,以更好地了解最终在游戏中的效果。我们将在本章的Activity 10.03、导入更多自定义动画以预览角色奔跑及其相关练习中实施这一点。
练习 10.02:探索 Persona 编辑器并操纵默认人体骨骼权重
现在我们对动画流程有了更好的理解,让我们深入了解一下在Side Scroller
模板项目中给我们的默认人体骨骼网格。
我们的目标是更多地了解默认骨骼网格和 Persona 编辑器中给我们的工具,以便更好地了解骨骼、骨骼权重和骨骼在虚幻引擎 4 中的工作方式。
以下步骤将帮助您完成练习:
-
打开虚幻引擎编辑器,导航到
内容浏览器
。 -
导航到
/Mannequin/Character/Mesh/
文件夹并打开UE4_Mannequin_Skeleton
资产:
图 10.8:UE4_Mannequin_Skeleton 资产在此处被突出显示并可见
打开骨骼资产后,我们看到了Persona 编辑器
:
图 10.9:Persona 编辑器
](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_10_09.jpg)
图 10.9:Persona 编辑器
让我们简要地解释一下 Persona 的骨骼编辑器:
-
在左侧(标有 1)我们看到了骨骼层次结构。这是在角色的绑定过程中制作的骨骼。
root
骨骼,顾名思义,是骨骼层次结构的根。这意味着对这个骨骼的变换将影响层次结构中的所有骨骼。从这里,我们可以选择一个骨骼或一组骨骼,并查看它们在角色网格上的位置。 -
接下来,我们看到了骨骼网格预览窗口(标有 2)。它显示了我们的角色网格,并且有一些额外的选项,我们可以切换这些选项,以便预览我们的骨骼和权重绘制。
-
在右侧(标有 3)我们有基本的变换选项,可以修改单个骨骼或骨骼组。还有其他可用的设置,我们将在下一个练习中加以利用。现在我们更了解它是什么以及我们在看什么,让我们看看我们的人体骨骼网格上的实际骨架是什么样子。
- 导航到
Character
,如图 10.10所示:
图 10.10:角色选项菜单让您能够在网格上显示人体骨骼的能力
- 从下拉菜单中选择
Bones
选项。然后确保选择了All Hierarchy
选项。选择此选项后,您将看到人体骨骼网格上方的轮廓骨架渲染在人体模型上方:
图 10.11:骨架叠加在人体骨骼网格上
- 现在,隐藏网格,只是预览骨骼层次结构,我们可以禁用
Mesh
属性:
-
导航到
Character
,从下拉菜单中选择Mesh
选项。 -
取消
Mesh
选项,结果应该如下所示:
图 10.12:默认角色的骨骼层次结构
为了本练习的目的,让我们切换Mesh
可见性,这样我们就可以看到网格和骨骼层次结构。
最后,一起看一下我们默认角色的权重缩放。
- 要预览此内容,请转到
Character
,然后从下拉菜单中选择Mesh
选项。然后,在标有Mesh Overlay Drawing
的部分底部选择Selected Bone Weight
选项:
图 10.13:下拉选项显示人体模型骨骼的选定骨骼权重
- 现在,如果我们从层次结构中选择一个骨骼或一组骨骼,我们可以看到每个骨骼如何影响网格的某个区域:
图 10.14:这是 spine_03 骨的权重缩放
您会注意到,当我们预览特定骨骼的权重缩放时,骨骼网格的不同部分会显示一系列颜色。这是权重缩放的视觉显示,而不是数值上的。诸如红色
、橙色
和黄色
的颜色表示骨骼的权重较大,这意味着这些颜色的高亮区域将受到更大影响。在蓝色
、绿色
和青色
的区域,它们仍会受到影响,但影响不那么显著。最后,没有高亮叠加的区域将不受选定骨骼的操作影响。请记住骨骼的层次结构,因为即使左臂没有叠加颜色,当您旋转、缩放和移动spine_03
骨时,它仍会受到影响,因为手臂是spine_03
骨的子级。请参考下面的图像,看看手臂是如何连接到脊柱的:
图 10.15:clavicle_l 和 clavicle_r 骨是 spine_03 骨的子级
让我们继续操作人体模型骨骼网格中的一个骨骼,并看看这些变化如何影响其动画。
- 在 Persona 编辑器中,左键单击骨骼层次结构中的
thigh_l
骨:
图 10.16:这里选择了 thigh_l 骨
选择thigh_l
骨后,我们清楚地知道权重缩放将如何影响网格的其他部分。此外,由于骨骼的结构,对该骨骼的任何修改都不会影响网格的上半身:
图 10.17:您可以看到,在骨骼层次结构中,大腿骨是骨盆骨的子级
- 使用前几章的知识,更改
thigh_l
骨的本地位置、本地旋转和比例值,以偏移骨骼的变换。下面的图像显示了要使用的值示例。
图 10.18:大腿 _l 值已更新
对骨骼变换进行更改后,您会看到人体模型的左腿完全改变,看起来很荒谬:
图 10.19:人体模型角色的左腿完全改变
-
接下来,在
Details
面板中,转到标有Preview Scene Settings
的选项卡。左键单击此选项卡,您将看到新选项,显示一些默认参数和一个Animation
部分。 -
使用
动画
部分预览动画以及它们如何受到对骨骼所做更改的影响。对于预览控制器
参数,将其更改为使用特定动画
选项。通过这样做,将出现一个名为动画
的新选项。动画
参数允许我们选择与角色骨骼关联的动画来预览。 -
接下来,左键单击下拉菜单,选择
ThirdPersonWalk
动画。 -
最后,现在你可以看到模特角色正在播放行走动画,但他们的左腿完全错位和错缩:
图 10.20:模特角色更新动画的预览
在继续之前,请确保将thigh_l
骨骼恢复到其原始本地位置、本地旋转和比例;否则,向前进行的动画将不会看起来正确。
现在,您已经完成了我们第二项练习的最后部分,亲身体验了骨骼对角色和动画的影响。
现在,让我们继续进行第二项活动,操作模特角色的不同骨骼并观察应用不同动画的结果。
活动 10.02:骨骼操作和动画
对于这项活动,我们将实践我们对默认模特角色上的骨骼操作如何影响骨骼上的动画的知识。
以下步骤将帮助您完成此活动:
-
选择将影响整个骨骼的骨骼。
-
更改此骨骼的比例,使角色的尺寸减半。使用这些值将
Scale
更改为(X=0.500000,Y=0.500000,Z=0.500000
)。 -
将奔跑动画应用于
预览场景设置
选项卡中的这个骨骼网格,并观察半尺寸角色的动画:
以下是预期输出:
图 10.21:尺寸减半的角色执行奔跑动画
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
完成此活动后,您现在已经实际了解了骨骼和骨骼网格的骨骼操作如何影响动画的应用。您还亲身见证了对骨骼的权重缩放对骨骼的影响。
虚幻引擎 4 中的动画
让我们分解动画在虚幻引擎内部的主要方面。关于本节中的主题的更深入信息可以在 Epic Games 的文档中直接找到:docs.unrealengine.com/en-US/Engine/Animation
。
骨骼
骨骼是虚幻引擎对外部 3D 软件中制作的角色骨骼的表示;我们在活动 10.02,骨骼操作和动画中看到了这一点。关于骨骼,我们已经讨论过的内容并不多,但主要的要点是一旦骨骼在引擎中,我们可以查看骨骼层次结构,操作每个单独的骨骼,并添加称为插座的对象。插座允许我们将对象附加到角色的骨骼上,并且我们可以使用这些插座来附加对象,如网格,并且在不破坏骨骼变换的情况下操纵插座的变换。在第一人称射击游戏中,通常会制作武器插座并将其附加到适当的手部。
骨骼网格
骨骼网格是一种特定类型的网格,它结合了 3D 角色模型和构成其骨架的骨骼层次结构。静态网格和骨骼网格的主要区别在于,骨骼网格用于使用动画的对象,而静态网格由于缺乏骨架而无法使用动画。我们将在下一章更深入地研究我们的主角骨骼网格,但我们将在本章后面的Activity 10.03中导入我们的主角骨骼网格,导入更多自定义动画以预览角色奔跑。
动画序列
最后,动画序列是一种可以在特定骨骼网格上播放的单独动画;它适用于的网格是在将动画导入到引擎时选择的骨架确定的。我们将在Activity 10.03中导入我们自己的角色骨骼网格和一个单独的动画资产,导入更多自定义动画以预览角色奔跑。
我们的动画序列中包含一个时间轴,允许我们逐帧预览动画,并附加了其他控件以暂停、循环、倒带等:
图 10.22:动画序列时间轴和预览窗口
在接下来的练习中,您将导入一个自定义角色和一个动画。自定义角色将包括一个骨骼网格和一个骨架,动画将被导入为动画序列。
练习 10.03:导入和设置角色和动画
对于我们的最后一个练习,我们将导入我们自定义的角色和一个我们将用于SuperSideScroller
游戏主角的动画,以及创建必要的角色蓝图和动画蓝图。
注意
本章附带了一个名为Assets
的文件夹中的一组文件,我们将导入这些文件到引擎中。这些资产来自 Mixamo:www.mixamo.com/
;请随意创建一个账户并查看那里提供的免费 3D 角色和动画内容。
Assets
内容可以在我们的 GitHub 上找到:packt.live/2IcXIOo
。
以下步骤将帮助您完成练习:
-
前往虚幻编辑器。
-
在“内容浏览器”中,创建一个名为
MainCharacter
的新文件夹。在这个文件夹中,创建两个名为Animation
和Mesh
的新文件夹。我们的“内容浏览器”选项卡现在应该看起来像下面的图片:
图 10.23:在内容浏览器中的 MainCharacter 目录中添加的文件夹
-
接下来,导入我们的角色模型。在我们创建的
Mesh
文件夹内,右键单击并选择“导入”选项,这将打开文件资源管理器菜单。导航到您保存了本章附带的Assets
文件夹的目录,并找到Character Mesh
文件夹内的MainCharacter.fbx
资产,例如\Assets\Character Mesh\MainCharacter.fbx
,然后打开该文件。 -
在选择此资产时,将出现 FBX 导入选项窗口。确保在各自的复选框中将“骨骼网格”和“导入网格”的选项设置为“检查”,并将其他选项保持为默认设置。
-
最后,我们可以选择“导入”选项,这样我们的 FBX 资产将被导入到引擎中。这将包括在 FBX 中创建的必要材质;一个物理资产,它将自动为我们创建并分配给“骨骼网格”;和“骨架资产”。
注意
忽略导入FBX
文件时可能出现的任何警告;它们不重要,不会影响我们未来的项目。
现在我们有了角色,让我们导入一个动画。
-
在
MainCharacter
文件夹目录中的Animation
文件夹内,再次右键单击并选择“导入”选项。 -
导航到保存了本章配套
Assets
文件夹的目录,并在Animations/Idle
文件夹中找到Idle.fbx
资产,例如\Assets\Animations\Idle\Idle.fbx
,然后打开该文件。
选择此资产时,将会出现一个几乎相同的窗口,就像我们导入角色骨骼网格时一样。由于这个资产只是一个动画,而不是骨骼网格/骨架,我们没有之前的选项,但有一个关键的参数需要正确设置:骨架
。
在我们的FBX
导入选项的网格
类别下的骨架
参数告诉动画应用于哪个骨架。如果不设置这个参数,我们无法导入我们的动画,将动画应用于错误的骨架可能会产生灾难性的结果,或者导致动画根本无法导入。幸运的是,我们的项目很简单,我们已经导入了角色骨骼网格和骨架。
- 选择
MainCharacter_Skeleton
并选择底部的导入
选项;将所有其他参数保持为默认设置。
图 10.24:导入 Idle.fbx 动画时的设置
现在我们知道要导入自定义角色网格和动画。了解这两种类型资产的导入过程至关重要,在下一个活动中,您将被挑战导入剩余的动画。让我们继续通过为SuperSideScroller
游戏的主角色创建角色蓝图和动画蓝图来进行这个练习。
现在,虽然侧向滚动模板项目确实包括了我们角色的蓝图和其他资产,比如动画蓝图,但为了组织和良好的开发实践,我们将要创建我们自己版本的这些资产。
- 在
内容浏览器
的MainCharacter
目录下创建一个名为蓝图
的新文件夹。在该目录中,基于所有类
下的SideScrollerCharacter
类创建一个新的蓝图。将这个新蓝图命名为BP_SuperSideScroller_MainCharacter
:
图 10.25:要用作角色蓝图父类的 SideScrollerCharacter 类
- 在我们的
蓝图
目录中,在内容浏览器
的空白区域右键单击,悬停在动画
选项上,然后选择动画蓝图
:
图 10.26:动画类别下的动画蓝图选项
- 选择此选项后,将会出现一个新窗口。这个新窗口要求我们为我们的动画蓝图应用一个父类和一个骨架。在我们的情况下,使用
MainCharacter_Skeleton
,选择确定,并将动画蓝图资产命名为AnimBP_SuperSideScroller_MainCharacter
:
图 10.27:创建动画蓝图时需要的设置
- 当我们打开我们的角色蓝图
BP_SuperSideScroller_MainCharacter
并选择网格
组件时,我们会发现一些可以更改的参数:
图 10.28:使用人体模型骨骼网格的 SuperSideScroller 角色蓝图
- 在
网格
类别下,我们有更新骨骼网格
的选项。找到我们的MainCharacter
骨骼网格并将其分配给这个参数:
图 10.29:我们的网格组件需要的设置,以正确使用我们的新骨骼网格和动画蓝图
在我们的角色蓝图中,选择Mesh
组件后,我们可以在Mesh
类别的正上方找到Animation
类别。幸运的是,默认情况下,Animation Mode
参数已经设置为Use Animation Blueprint
,这是我们需要的设置。
-
现在将
Anim
类参数分配给我们的新动画蓝图,AnimBP_SuperSideScroller_MainCharacter
。最后,返回到默认的SideScrollerExampleMap
关卡,并用我们的新角色蓝图替换默认角色。 -
接下来,请确保我们在
Content Browser
中选择了BP_SuperSideScroller_MainCharacter
,然后右键单击关卡中的默认角色,并选择用我们的新角色替换它:
图 10.30:在内容浏览器中选择角色蓝图后,我们可以简单地右键单击关卡中的默认角色,并用新角色替换它
- 在关卡中放置了我们的新角色后,我们现在可以在编辑器中进行游戏并在关卡中移动。结果应该看起来像下面的图片;我们的角色处于默认 T 形姿势并在关卡环境中移动:
图 10.31:您现在有自定义角色在关卡中奔跑
完成我们的最后一个练习后,您现在完全了解了如何导入自定义骨骼网格和动画。此外,您还学会了如何从头开始创建角色蓝图和动画蓝图,以及如何使用这些资产来创建SuperSideScroller
角色的基础。
让我们继续进行本章的最后一个活动,在这个活动中,您将被挑战导入角色的剩余动画,并在 Persona 编辑器中预览奔跑动画。
活动 10.03:导入更多自定义动画以预览角色奔跑
这个活动旨在导入剩余的动画,比如玩家角色的奔跑动画,并在角色骨架上预览奔跑动画,以确保它看起来正确。
在活动结束时,所有玩家角色动画将被导入项目中,您将准备好在下一章中使用这些动画来赋予玩家角色生命。
以下步骤将帮助您完成该活动:
-
作为提醒,我们需要导入的所有动画资产都存在于
\Assets\Animations
目录中,无论您将原始zip
文件夹保存在何处。导入MainCharacter/Animation
文件夹中的所有剩余动画。导入剩余的动画资产将与练习 10.03中的导入和设置角色和动画相同,当您导入Idle
动画时。 -
导航到
MainCharacter
骨架,并应用您在上一步中导入的Running
动画。 -
最后,应用
Running
动画后,在 Persona 编辑器中预览角色动画。
以下是预期输出:
图 10.32:带有额外自定义导入资产的角色的预期输出
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
完成这个最后的活动后,您现在已经第一手体验了将自定义骨骼和动画资产导入虚幻引擎 4 的过程。无论您导入的资产类型如何,导入过程在游戏行业中很常见,您对此应该感到舒适。
总结
有了玩家角色的骨骼、骨骼网格和动画导入到引擎中,我们可以继续进行下一章,在那里您将准备角色移动和 UpdateAnimation 蓝图,以便角色在关卡中移动时能够进行动画。
通过本章的练习和活动,您了解了骨骼和骨骼如何用于给角色添加动画和操纵。通过第一手经验将动画导入并应用到虚幻引擎 4 中,您现在对动画流程有了深刻的理解,从角色概念到最终导入项目的资产。
此外,您还学习了我们将在下一章中使用的主题,比如用于角色移动动画混合的混合空间。有了创建的SuperSideScroller
项目模板和玩家角色准备就绪,在下一章中,让我们开始使用动画蓝图为角色添加动画。
第十章:11.混合空间 1D、键绑定和状态机
概述
本章首先创建所需的混合空间资产,以允许从空闲到行走,最终到奔跑的移动动画混合,根据玩家角色的速度。然后,我们将实现新的键映射,并在 C++中使用这些映射来为玩家角色编写游戏功能,如冲刺。最后,我们将在角色动画蓝图中创建一个新的动画状态机,以便玩家动画可以在移动和跳跃之间平滑过渡。
到本章结束时,当SuperSideScroller
玩家角色在环境中移动时,将正确地进行动画处理,并以最适合游戏的方式移动。这意味着玩家将支持空闲、行走和冲刺动画,同时还支持跳跃所需的动画。
介绍
在上一章中,我们对动画和SuperSideScroller
项目的游戏设计开发进行了高层次的审视。您只是在项目开发的最初阶段。您还准备了玩家角色的动画蓝图、角色蓝图,并导入了所有必需的骨骼和动画资产。
此时,角色可以在关卡中移动,但却被困在 T 形姿势中,根本没有动画。通过为玩家角色创建一个新的混合空间,可以解决这个问题,这将在本章的第一个练习中完成。完成混合空间后,您将在角色动画蓝图中实现这一点,以便角色在移动时进行动画处理。
在本章中,您将使用许多新的函数、资产类型和变量,以实现玩家角色的期望移动。其中一些包括“动画蓝图”中的“尝试获取所有者”函数、“1D 混合空间资产”类型和项目配置文件中的“输入绑定”。
让我们首先通过学习混合空间,然后创建您需要的混合空间资产,以便在移动时使玩家角色进行动画处理。
混合空间
如其名称所示,混合空间允许您根据一个或多个条件在多个动画之间进行混合。混合空间在不同类型的视频游戏中使用,但通常在玩家可以看到整个角色的游戏中使用。在虚幻引擎 4 提供的第一人称模板项目中,通常不使用混合空间,因为玩家只能看到角色的手臂,如下所示:
图 11.1:虚幻引擎 4 中第一人称项目模板中默认角色的第一人称视角。
在需要平滑混合角色基于移动的动画的第三人称游戏中,混合空间更常见。一个很好的例子是虚幻引擎 4 提供的第三人称模板项目,如下所示:
图 11.2:虚幻引擎 4 中第一人称项目模板中默认角色的第三人称视角
混合空间允许玩家角色根据变量或一组变量在动画之间进行混合。例如,在《最后生还者》中的乔尔,他的移动动画是基于他的移动速度的,这个速度是由玩家通过控制器摇杆(或摇杆)提供的。随着速度的增加,他的动画从行走更新到奔跑,然后到冲刺。这就是我们在本章中要实现的目标。
让我们看看 Unreal Engine 提供的混合空间资产,在创建侧向滚动
项目模板时,通过打开/Mannequin/Animations/ThirdPerson_IdleRun_2D
。这是为侧向滚动
人体模型骨骼网格创建的 1D 混合空间资产,以便玩家角色可以根据角色的速度在空闲、行走和奔跑动画之间平滑过渡。
如果你在Persona
中检查,在左侧的资产详情
面板中,你会看到轴设置
类别,其中有水平轴
参数,我们可以在我们的动画蓝图中引用的变量。请参考下面的图像查看Persona
中的轴设置
。
图 11.3:这里显示了 1D 混合空间的轴设置
在预览窗口下方,我们还会看到一个小图表,沿着从左到右的线有点;其中一个点将被突出显示为绿色
,而其他点为白色
。我们可以左键单击并沿着水平轴拖动这个绿色
点,以预览基于其值的混合动画。在速度为0
时,我们的角色处于空闲
状态,当我们沿着轴移动我们的预览时,动画将开始混合行走,然后是奔跑
。请参考下面的图像查看单轴图表。
图 11.4:这里突出显示了 1D 混合空间的关键帧时间轴
在下一节中,我们将研究 1D 混合空间与普通混合空间的区别。
1D 混合空间与普通混合空间
在继续使用 Unreal Engine 4 中的 1D 混合空间之前,让我们花点时间区分混合空间和 1D 混合空间之间的主要区别。
-
Unreal 中的混合空间资产由两个变量控制,由混合空间图的X和Y轴表示。
-
另一方面,1D 混合空间只支持一个轴。
试着把这个想象成一个 2D 图表。你知道每个轴都有自己的方向,你可以更好地想象出为什么以及何时需要使用这个混合空间,而不是只支持单一轴的 1D 混合空间。
比如,假设你想让玩家角色在左右移动的同时也支持前后移动。如果你要在图表上映射这种移动,它会看起来像下面的图:
图 11.5:这是一个简单图表上混合空间运动的样子
现在,想象一下玩家角色的移动,记住游戏是一个侧向滚动
。角色不会支持左右平移或前后移动。玩家角色只需要在一个方向上进行动画,因为侧向滚动
角色默认会朝着移动方向旋转。只需要支持一个方向是你使用 1D 混合空间而不是普通混合空间的原因。
我们需要为我们的主角设置这种类型的混合空间资产,并将其用于相同的目的,即基于移动的动画混合。在下一个练习中,让我们一起使用我们的自定义动画资产创建混合空间资产。
练习 11.01:创建角色移动 1D 混合空间
为了让玩家角色在移动时进行动画,你需要首先创建一个如前所述的混合空间。
在这个练习中,你将创建混合空间资产,添加空闲动画,并更新CharacterMovement
组件,以便分配与混合空间相对应的适当行走速度值。
以下步骤将帮助你完成练习:
-
在
Content Browser
中导航到/MainCharacter/Animation
文件夹,其中包含您在上一章中导入的所有新动画。 -
现在,在
Content Browser
的主区域中右键单击,从下拉菜单中悬停在Animation
选项上,然后从其附加的下拉菜单中左键单击选择Blend Space 1D
。 -
确保选择
MainCharacter_Skeleton
,而不是UE4_Mannequin_Skeleton
,作为混合空间的骨骼。
注意
如果应用了不正确的骨骼,那么在为需要骨骼的资产(如混合空间或动画蓝图)选择骨骼时,混合空间对于玩家角色和其自定义骨骼网格将无法正常工作。在这里,您正在告诉这个资产它与哪个骨骼兼容。通过这样做,在混合空间的情况下,您可以使用为该骨骼制作的动画,并确保一切与其他一切兼容。
-
将此混合空间资产命名为
SideScroller_IdleRun_1D
。 -
接下来,打开
SideScroller_IdleRun_1D
混合空间资产。您可以在预览窗口下方看到单轴图表:
图 11.6:Unreal Engine 4 中用于创建混合空间的编辑工具
在编辑器的左侧,您有包含Axis Settings
类别的Asset Details
面板。在这里,您将标记轴并提供最小和最大浮点值,这些值稍后将在玩家角色的Animation Blueprint
中对您有用。请参考下面的图表,查看为Horizontal Axis
设置的默认值。
图 11.7:影响混合空间轴的轴设置
- 现在,将
Horizontal Axis
重命名为Speed
:
图 11.8:水平轴现在命名为 Speed
- 下一步是建立
Minimum Axis Value
和Maximum Axis Value
。您希望最小值默认为0.0f
,因为玩家角色在完全不移动时将处于Idle
状态。
但Maximum Axis Value
呢?这个有点棘手,因为您需要记住以下几点:
-
您将支持角色的冲刺行为,允许玩家在按住左 Shift键盘按钮时移动得更快。释放时,玩家将返回默认行走速度。
-
行走速度要匹配
CharacterMovementComponent
的角色Max Walk Speed
参数。
在设置Maximum Axis Value
之前,您需要将角色的Max Walk Speed
设置为适合SuperSideScroller
游戏的值。
- 为此,导航到
/Game/MainCharacter/Blueprints/
并打开BP_SuperSideScroller_MainCharacter
蓝图:
图 11.9:SuperSideScroller 主角蓝图的目录
- 选择
Character Movement
组件,在Details
面板中,在Character Movement: Walking
类别下,找到Max Walk Speed
参数,并将该值设置为300.0f
。
将Max Walk Speed
参数设置后,返回到SideScroller_IdleRun_1D
混合空间,并设置Maximum Axis Value
参数。如果行走速度为300.0f
,最大值应该是多少?请记住,您将支持玩家角色的冲刺,因此这个最大值需要大于行走速度。
-
将
Maximum Axis Value
参数更新为500.0f
。 -
最后,将
Number of Grid Divisions
参数设置为5
。这样做的原因是,在处理分区时,每个网格点之间的100
单位间距使得更容易处理,因为Maximum Axis Value
是500.0f
。在应用动画沿网格时,这对于网格点捕捉非常有用。 -
将剩余的属性设置为默认值:
图 11.10:混合空间的最终轴设置
通过这些设置,您告诉混合空间使用0.0f
到500.0f
之间的传入浮点值来在下一步和活动中混合动画。通过将网格分成5
个部分,您可以轻松地在轴图表上的正确浮点值处添加所需的动画。
让我们继续创建混合空间,通过将第一个动画添加到轴图表中,即Idle
动画。
-
在网格的右侧,有
Asset Browser
选项卡。请注意,资产列表包括您在第十二章 动画混合和蒙太奇中导入的玩家角色的所有动画。这是因为您在创建混合空间时选择了MainCharacter_Skeleton
资产。 -
接下来,左键单击并将
Idle
动画拖动到我们的网格位置0.0
:
图 11.11:将 Idle 动画拖动到网格位置 0.0
注意,将此动画拖动到网格时,它将捕捉到网格点。一旦动画添加到混合空间中,玩家角色就会从其默认 T 形状改变,并开始播放Idle
动画:
图 11.12:将 Idle 动画添加到 1D 混合空间,玩家角色开始动画
完成这个练习后,您现在了解了如何创建 1D 混合空间,更重要的是,您知道了 1D 混合空间和普通混合空间之间的区别。此外,您知道了在玩家角色移动组件和混合空间之间对齐值的重要性,以及为什么需要确保行走速度与混合空间中的值适当地相关。
现在让我们继续进行本章的第一个活动,在这个活动中,您将像添加Idle
动画一样,将剩余的Walking
和Running
动画应用到混合空间中。
活动 11.01:将 Walking 和 Running 动画添加到混合空间
到目前为止,1D 运动混合空间进展顺利,但您缺少行走和奔跑动画。在本活动中,您将通过将这些动画添加到适合主角的水平轴值的混合空间来完成混合空间。
使用从练习 11.01 创建 CharacterMovement 1D 混合空间中获得的知识,执行以下步骤来完成角色移动混合空间:
-
继续进行练习 11.01 创建 CharacterMovement 1D 混合空间,返回
Asset Browser
。 -
现在,将
Walking
动画添加到水平网格位置300.0f
。 -
最后,将
Running
动画添加到水平网格位置500.0f
。
注意
请记住,您可以左键单击并沿着网格轴拖动绿色预览网格点,以查看动画根据轴值如何混合在一起,因此请注意角色动画预览窗口,以确保它看起来正确。
预期输出如下:
图 11.13:混合空间中的 Running 动画
当这个活动完成时,你将拥有一个功能性的混合空间,根据代表玩家角色速度的水平轴的值,将角色的移动动画从Idle
混合到Walking
再到Running
。
注意
这个活动的解决方案可以在以下网址找到:packt.live/338jEBx
。
主角动画蓝图
将动画添加到混合空间后,你应该能够四处走动并看到这些动画在起作用,对吗?嗯,不是的。如果选择在编辑器中播放,你会注意到主角仍然以 T 形姿势移动。原因是因为你还没有告诉动画蓝图使用我们的混合空间资产,这将在本章后面进行。
动画蓝图
在跳入上一章创建的动画蓝图之前,让我们简要讨论一下这种类型的蓝图是什么,以及它的主要功能是什么。动画蓝图是一种蓝图,允许你控制骨骼和骨骼网格的动画,此处指的是上一章导入的玩家角色骨骼和网格。
动画蓝图分为两个主要图表:
-
事件图
-
动画图
事件图的工作方式与普通蓝图相同,你可以使用事件、函数和变量来编写游戏逻辑。另一方面,动画图是动画蓝图独有的,这是你在其中使用逻辑来确定骨骼和骨骼网格在任何给定帧的最终姿势。在这里,你可以使用状态机、动画插槽、混合空间和其他与动画相关的节点,然后输出给角色的最终动画。
看一下以下示例(你可以跟着做)。
在MainCharacter/Blueprints
目录中打开AnimBP_SuperSideScroller_MainCharacter
动画蓝图。
默认情况下,AnimGraph
应该打开,你可以在其中看到角色预览、我们的Asset Browser
选项卡和主图表。就是在这个AnimGraph
中,你将实现刚刚创建的混合空间,以便在关卡中移动时玩家角色能够正确地进行动画。
让我们开始下一个练习,我们将在这个练习中做这个,并学习更多关于动画蓝图的知识。
练习 11.02:将混合空间添加到角色动画蓝图
在这个练习中,你将把混合空间添加到动画蓝图,并准备必要的变量来控制这个混合空间,根据玩家角色的移动速度。让我们从将混合空间添加到AnimGraph
开始。
以下步骤将帮助你完成这个练习:
- 通过在右侧找到
Asset Browser
,左键单击并将SideScroller_IdleRun_1D
混合空间资产拖入AnimGraph
中,将混合空间添加到AnimGraph
。
请注意,这个混合空间节点的变量输入标签为Speed
,就像混合空间内部的水平轴一样。请参考图 11.14,看看Asset Browser
中的混合空间。
注意
如果你给Horizontal Axis
取了不同的名字,新名字会显示为混合空间的输入参数。
图 11.14:Asset Browser 让你访问与 MainCharacter_Skeleton 相关的所有动画资产
- 接下来,将混合空间节点的
Output Pose
资产连接到Output Pose
节点的Result
引脚。现在,在预览中的动画姿势显示为角色的Idle
动画姿势:
图 11.15:你现在对混合空间有了有限的控制,并可以手动输入值到 Speed 参数中来更新角色的移动动画
- 如果你使用
PIE
,(Idle
动画而不是保持 T-Pose:
图 11.16:玩家角色现在在游戏中播放 Idle 动画
现在,你可以使用Speed
输入变量来控制我们的混合空间。有了使用混合空间的能力,你需要一种方法来存储角色的移动速度,并将该值传递给混合空间的Speed
输入参数。这就是你需要做的:
- 导航到我们的动画蓝图的
事件图
。默认情况下,会有事件蓝图更新动画
事件和一个纯Try Get Pawn Owner
函数。请参考图 11.17,查看事件图
的默认设置。该事件在每帧动画更新时更新,并在尝试获取更多信息之前返回SuperSideScroller
玩家角色蓝图类。
图 11.17:动画蓝图包括此事件和函数对,默认情况下在你的事件图中使用
注意
在虚幻引擎 4 中,Pure
函数和Impure
函数的主要区别在于,Pure
函数意味着它包含的逻辑不会修改它所使用的类的变量或成员。在Try Get Pawn Owner
的情况下,它只是返回动画蓝图的Pawn
所有者的引用。Impure
函数没有这个含义,并且可以自由修改任何它想要修改的变量或成员。
- 从
Try Get Pawn Owner
函数获取Return Value
,然后从出现的上下文敏感
菜单中搜索转换为SuperSideScrollerCharacter
:
图 11.18:上下文敏感菜单可以找到相关的函数或变量,基于这些可以对所检查的对象采取行动
- 将
事件蓝图更新动画
的执行输出引脚连接到转换的执行输入引脚:
图 11.19:在事件图中,使用 Try Get Pawn Owner 函数将返回的 Pawn 对象转换为 SuperSideScrollerCharacter 类
你创建的角色蓝图继承自SuperSideScrollerCharacter
类。由于这个动画蓝图的拥有者是你的BP_SuperSideScroller_MainCharacter
角色蓝图,并且这个蓝图继承自SuperSideScrollerCharacter
类,所以转换函数将成功执行。
- 接下来,将转换后的返回值存储到自己的变量中;这样,我们在动画蓝图中需要再次使用它时就有一个引用。参考图 11.20,确保将这个新变量命名为
MainCharacter
。
注意
在上下文敏感的下拉菜单中有提升为变量
的选项,它允许你将任何有效值类型存储到自己的变量中。
图 11.20:只要转换成功,你就会想要跟踪所拥有的角色
- 现在,要跟踪角色的速度,使用
MainCharacter
变量中的Get Velocity
函数。Actor
类的每个对象都可以访问这个函数,它返回对象移动的大小和方向向量:
图 11.21:GetVelocity 函数可以在 Utilities/Transformation 下找到
- 从“获取速度”中,您可以使用
VectorLength
函数来获取实际速度:
图 11.22:VectorLength 函数返回矢量的大小,但不返回方向
- 从
VectorLength
函数的Return Value
然后可以提升为自己的变量命名为Speed
:
图 11.23:每个角色都有 Get Velocity 函数,返回角色移动的大小和方向
在这个练习中,您可以使用GetVelocity
函数获得玩家角色的速度。从GetVelocity
函数返回的矢量给出了矢量的长度以确定实际速度。通过将这个值存储在Speed
变量中,您现在可以在动画蓝图的AnimGraph
中引用这个值来更新您的混合空间,在下一个练习中将会这样做。
速度矢量
在进行下一步之前,让我们解释一下当您获取角色的速度并将该矢量的矢量长度提升为Speed
变量时,您正在做什么。
什么是速度?速度是一个具有给定GetVelocity
函数和返回速度矢量上的VectorLength
函数的矢量;您正在获取我们角色的Speed
变量的值。这就是为什么您将该值存储在变量中并将其用于控制混合空间的原因,如下图所示,这是矢量的一个示例。其中一个具有正(右)方向,大小为100
,另一个具有负(左)方向,大小为35
。
图 11.24:显示两个不同的矢量的图
练习 11.03:将混合空间添加到角色动画蓝图
现在您对“矢量”以及如何存储玩家角色的Speed
变量有了更好的理解,您可以按照以下步骤将速度应用于本章前面创建的 1D 混合空间。
以下步骤将帮助您完成练习:
-
导航到您的
AnimBP_SuperSideScroller_MainCharacter
动画蓝图中的AnimGraph
。 -
使用
Speed
变量通过左键单击并将其拖动到AnimGraph
中实时更新混合空间,并将变量连接到Blendspace Player
函数的输入:
图 11.25:现在您可以在每帧更新动画时使用 Speed 变量来更新混合空间
- 接下来,编译动画蓝图。
现在您可以根据玩家角色的速度更新混合空间。当您使用PIE
时,您可以看到角色在移动时处于Idle
和Walking
动画中:
图 11.26:玩家角色最终能够在关卡中四处走动
最后,主角正在使用基于移动速度的移动动画。在下一个活动中,您将更新角色移动组件,以便可以从混合空间预览角色奔跑动画。
活动 11.02:在游戏中预览奔跑动画
通过更新动画蓝图并获取玩家角色的速度,您现在可以在游戏中预览Idle
和Walking
动画。
在这个活动中,您将更新玩家角色蓝图的CharacterMovement
组件,以便您还可以在游戏中预览Running
动画。
执行以下步骤来实现这一点:
-
导航到并打开
BP_SuperSideScroller_MainCharacter
玩家角色蓝图。 -
访问
CharacterMovement
组件。 -
将“最大行走速度”参数修改为
500.0
,以便您的角色可以快速移动,从“空闲”到“行走”,最终到“奔跑”时混合其动画。
在本活动结束时,您将允许玩家角色达到一定速度,以便在游戏中预览“奔跑”动画。
预期输出如下:
图 11.27:玩家角色奔跑
注意
可以在以下网址找到此活动的解决方案:packt.live/338jEBx
。
现在您已经处理了玩家角色从“空闲”到“行走”,最终到“奔跑”的混合移动,让我们继续下一步,添加功能以允许玩家角色通过奔跑移动得更快。
输入绑定
每个游戏都需要玩家的输入,无论是键盘上的按键,如W、A、S和D,用于移动玩家角色,还是控制器上的摇杆;这就是使视频游戏成为互动体验的原因。虚幻引擎 4 允许我们将键盘、鼠标、游戏手柄和其他类型的控件映射到标记的动作或轴上,然后您可以在蓝图或 C++中引用这些动作或轴,以允许角色或游戏功能发生。重要的是要指出,每个独特的动作或轴映射可以有一个或多个按键绑定,并且同一个按键绑定可以用于多个映射。输入绑定保存在名为DefaultInput.ini
的初始化文件中,并且可以在项目目录的Config
文件夹中找到。
注意
输入绑定可以直接从DefaultInput.ini
文件或通过编辑器中的“项目设置”进行编辑;后者在编辑时更容易访问,且更少出错。
让我们为玩家角色的“奔跑”功能添加一个新的输入绑定。
练习 11.04:添加奔跑和投掷输入
随着玩家角色在关卡中移动,您现在将为玩家角色实现一个独特的角色类,该类源自基本的SuperSideScrollerCharacter
C++类。这样做的原因是,您可以轻松区分玩家角色和敌人的类,而不仅仅依赖于独特的蓝图类。
在创建独特的 C++角色类时,您将实现“奔跑”行为,以允许玩家角色根据需要“行走”和“奔跑”。
让我们首先通过添加“奔跑”的输入绑定来实现“奔跑”机制:
-
在编辑器顶部的工具栏上导航到“编辑”选项,然后从下拉列表中选择“项目设置”。
-
在“项目设置”中,导航到左侧“引擎”类别下的“输入”选项。默认情况下,虚幻引擎提供的
Side Scroller
模板项目为“跳跃”提供了动作映射,键为W、上箭头键、空格键和游戏手柄底部按钮。 -
通过左键单击“动作映射”旁边的
+
按钮添加新的“动作映射”。将此映射标记为“奔跑”,并为其控件添加两个键;“左 Shift”和“游戏手柄右肩”。请参考下面的图示以获取更新后的绑定。
图 11.28:应用于按键绑定的跳跃和奔跑动作映射
有了“奔跑”输入绑定后,您需要为基于SuperSideScroller
角色类的玩家角色创建一个新的 C++类。
-
返回编辑器,导航到“文件”,然后从下拉列表中选择“新建 C++类”选项。
-
新的玩家角色类将继承自 SuperSideScrollerCharacter 父类,因为这个基类具有玩家角色所需的大部分功能。选择父类后,左键单击“下一步”。请参考以下图片,看看如何找到 SuperSideScrollerCharacter 类。
图 11.29:选择 SuperSideScrollerCharacter 父类
- 将这个新类命名为
SuperSideScroller_Player
。除非您有必要调整这个新类的文件目录,否则将路径保留为 Unreal Engine 为您提供的默认路径。在命名新类并选择要保存类的目录之后,左键单击“创建类”。
选择“创建类”后,Unreal Engine 将为您生成源文件和头文件,并且 Visual Studio 将自动打开这些文件。您会注意到头文件和源文件几乎是空的。这没关系,因为您是从 SuperSideScrollerCharacter 类继承的,您想要的大部分逻辑都在那个类中完成了。
- 在 SuperSideScroller_Player 中,您只会添加您需要的功能。您可以在 SuperSideScroller_Player.h 文件中查看继承正在发生的地方:
class SUPERSIDESCROLLER_API ASuperSideScroller_Player : public ASuperSideScrollerCharacter
这个类声明表示新的 ASuperSideScroller_Player 类继承自 ASuperSideScrollerCharacter 类。
通过完成这个练习,您可以为“冲刺”机制添加必要的“输入绑定”,然后可以在 C++中引用并用于允许玩家进行冲刺。现在您还创建了玩家角色的 C++类,您可以更新代码以添加“冲刺”功能,但首先您需要更新“蓝图”角色和动画蓝图以引用这个新类。让我们在下一个练习中完成这个任务。
当您将蓝图重新设置为新类时会发生什么?每个蓝图都继承自一个父类。在大多数情况下,这是Actor
,但在您的角色蓝图的情况下,它的父类是SuperSideScrollerCharacter
。从父类继承允许蓝图继承该类的功能和变量,以便逻辑可以在蓝图级别上重用。
例如,当从 SuperSideScrollerCharacter 类继承时,蓝图会继承诸如 CharacterMovement 组件和 Mesh 骨骼网格组件之类的组件,然后可以在蓝图中进行修改。
练习 11.05:重新设置角色蓝图的父类
现在您已经为玩家角色创建了一个新的角色类,您需要更新BP_SuperSideScroller_MainCharacter
蓝图,以使用SuperSideScroller_Player
类作为其父类。如果不这样做,那么您添加到新类的任何逻辑都不会影响蓝图中创建的角色。
按照以下步骤将蓝图重新设置为新的角色类:
-
导航到
/Game/MainCharacter/Blueprints/
,并打开BP_SuperSideScroller_MainCharacter
蓝图。 -
在工具栏上选择“文件”选项,然后从下拉菜单中选择“重新设置父蓝图”选项。
-
选择“重新设置父蓝图”选项时,Unreal 会要求您为蓝图重新设置父类。搜索
SuperSideScroller_Player
,然后通过左键单击从下拉菜单中选择该选项。
一旦您为蓝图选择了新的父类,Unreal 将重新加载蓝图并重新编译它,这两个过程都将自动进行。
注意
在将蓝图重新父类化为新的父类时要小心,因为这可能导致编译错误或设置被擦除或恢复为类默认值。虚幻引擎将在将蓝图重新父类化为新类后显示任何可能发生的警告或错误。这些警告和错误通常发生在蓝图逻辑引用不再存在于新父类中的变量或其他类成员的情况下。即使没有编译错误,最好确认在重新父类化之后您对蓝图所做的任何逻辑或设置仍然存在,然后再继续工作。
现在您的角色蓝图已正确重新父类化为新的SuperSideScroller_Player
类,您还需要更新AnimBP_SuperSideScroller_MainCharacter
动画蓝图,以确保在使用尝试获取所有者
函数时转换为正确的类。
-
接下来,导航到
/MainCharacter/Blueprints/
目录,并打开AnimBP_SuperSideScroller_MainCharacter
动画蓝图。 -
打开
事件图
。从尝试获取所有者
函数的返回值
中,搜索转换
为SuperSideScroller_Player
:
图 11.30:与转换为基本 SuperSideScrollerCharacter 类不同,您可以转换为新的 SuperSideScroller_Player 类
- 然后,将输出连接为
SuperSideScroller_Player
转换为MainCharacter
变量。这是因为MainCharacter
变量是SuperSideScrollerCharacter
类型,而新的SuperSideScroller_Player
类继承自该类:
图 11.31:您仍然可以使用 MainCharacter 变量,因为 SuperSideScroller_Player 基于 SuperSideScrollerCharacter 进行继承
现在,BP_SuperSideScroller_MainCharacter
角色蓝图和AnimBP_SuperSideScroller_MainCharacter
动画蓝图都引用了您的新SuperSideScroller_Player
类,现在可以安全地进入 C++并编写角色冲刺功能。
练习 11.06:编写角色冲刺功能
在上一次练习中正确实现了新的SuperSideScroller_Player
类引用后,现在是时候开始编写功能,允许玩家角色进行冲刺了。
执行以下步骤将冲刺
机制添加到角色中:
-
首先要处理的是
SuperSideScroller_Player
类的构造函数。返回 Visual Studio 并打开SuperSideScroller_Player.h
头文件。 -
您将在本练习的后面使用
构造函数
来为变量设置初始化值。现在,它将是一个空的构造函数。确保声明是在public
访问修饰符标题下进行的,就像下面的代码中所示:
//Constructor
ASuperSideScroller_Player();
- 构造函数声明后,在
SuperSideScroller_Player.cpp
源文件中创建构造函数定义:
ASuperSideScroller_Player::ASuperSideScroller_Player()
{
}
构造函数就位后,现在可以创建SetupPlayerInputComponent
函数,以便您可以使用之前创建的按键绑定来调用SuperSideScroller_Player
类中的函数。
SetupPlayerInputComponent
函数是角色类默认内置的函数,因此您需要将其声明为带有override
修饰符的虚拟
函数。这告诉虚幻引擎您正在使用此函数,并打算在这个新类中重新定义其功能。确保声明是在Protected
访问修饰符标题下进行的。
SetupPlayerInputComponent
函数需要将UInputComponent
类的对象传递到函数中,如下所示:
protected:
//Override base character class function to setup our player input component
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
UInputComponent* PlayerInputComponent
变量是从我们的ASuperSideScroller_Player()
类继承的UCharacter
基类中继承的,因此必须用作SetupPlayerInputComponent()
函数的输入参数。使用其他任何名称都将导致编译错误。
- 现在,在源文件中创建
SetupPlayerInputComponent
函数的定义。在函数的主体中,我们将使用Super
关键字来调用它:
//Not always necessary, but good practice to call the function in the base class with Super.
Super::SetupPlayerInputComponent(PlayerInputComponent);
Super
关键字使我们能够调用SetupPlayerInputComponent
父方法。有了SetupPlayerInputComponent
函数准备好了,您需要包含以下头文件,以便在继续进行此练习时不会出现任何编译错误:
-
#include "Components/InputComponent.h"
-
#include "GameFramework/CharacterMovementComponent.h"
您需要包含输入组件的头文件,以便将键映射绑定到接下来将创建的冲刺功能上。Character Movement
组件的头文件将对冲刺功能是必需的,因为您将根据玩家是否正在冲刺来更新Max Walk Speed
参数。以下是所有需要包含的玩家角色的头文件:
#include "SuperSideScroller_Player.h"
#include "Components/InputComponent"
#include "GameFramework/CharacterMovementComponent.h"
在SuperSideScroller_Player
类的源文件中包含了必要的头文件后,您现在可以创建用于使玩家角色移动更快的冲刺功能。让我们首先声明所需的变量和函数。
- 在
SuperSideScroller_Player
类的头文件中的Private
访问修饰符下,声明一个名为bIsSprinting
的新布尔变量。这个变量将被用作一个保险措施,以确切地知道玩家角色在进行任何移动速度更改之前是否正在冲刺:
private:
//Bool to control if we are sprinting. Failsafe.
bool bIsSprinting;
- 接下来,声明两个新函数,
Sprint();
和StopSprinting();
。这两个函数不需要任何参数,也不返回任何内容。在Protected
访问修饰符下声明这些函数:
//Sprinting
void Sprint();
//StopSprinting
void StopSprinting();
当玩家按住/释放与绑定的Sprint
键映射相对应的键时,将调用Sprint();
函数,并且当玩家释放与绑定的键相对应的键时,将调用StopSprinting()
函数。
- 从
Sprint();
函数的定义开始。在SuperSideScroller_Player
类的源文件中,创建此函数的定义,如下所示:
void ASuperSideScroller_Player::Sprint()
{
}
-
在函数内部,您首先要检查
bIsSprinting
变量的值。如果玩家bIsSprinting
为False
,则继续执行函数的其余部分。 -
在
If
语句内,将bIsSprinting
变量设置为True
。然后,您可以访问GetCharacterMovement()
函数并修改MaxWalkSpeed
参数。将MaxWalkSpeed
设置为500.0f
。请记住,移动混合空间的Maximum Axis Value
参数为500.0f
。这意味着玩家角色将达到使用Running
动画所需的速度:
void ASuperSideScroller_Player::Sprint()
{
if (!bIsSprinting)
{
bIsSprinting = true;
GetCharacterMovement()->MaxWalkSpeed = 500.0f;
}
}
StopSprinting()
函数几乎与您刚刚编写的Sprint()
函数相同,但它的工作方式相反。您首先要检查玩家是否正在冲刺,也就是bIsSprinting
为True
。如果是,就继续执行函数的其余部分。
- 在
If
语句内,将bIsSprinting
设置为False
。然后,访问GetCharacterMovement()
函数来修改MaxWalkSpeed
。将MaxWalkSpeed
设置回300.0f
,这是玩家角色行走的默认速度。这意味着玩家角色只会达到Walking
动画所需的速度:
void ASuperSideScroller_Player::StopSprinting()
{
if (bIsSprinting)
{
bIsSprinting = false;
GetCharacterMovement()->MaxWalkSpeed = 300.0f;
}
}
现在您已经拥有了需要进行冲刺的功能,是时候将这些功能绑定到您之前创建的动作映射上了。为了做到这一点,在SetupPlayerInputComponent
函数中执行以下步骤。
- 让我们开始绑定
Sprint()
函数。在SetupPlayerInputComponent
函数内部,使用传递给函数的PlayerInputComponent
变量来调用BindAction
函数。
我们需要BindAction
的参数如下:
-
在
Project Settings
中写入的动作映射的名称,这是您在此练习中之前设置的,这种情况下是Sprint
。 -
EInputEvent
类型的枚举值,您想要用于此绑定;在这种情况下,您将使用IE_Pressed
,因为这个绑定将是当按下Sprint
键时。
//Bind pressed action Sprint to your Sprint function
PlayerInputComponent->BindAction"Sprint", IE_Pressed, this, &ASuperSideScroller_Player::Sprint);
- 您将对
StopSprinting()
函数做同样的事情,但这次您需要使用IE_Released
枚举值,并引用StopSprinting
函数:
//Bind released action Sprint to your StopSprinting function
PlayerInputComponent->BindAction("Sprint", IE_Released, this, &ASuperSideScroller_Player::StopSprinting);
通过将Action Mappings
绑定到奔跑功能,您需要做的最后一件事是设置bIsSprinting
变量和Character Movement
组件的MaxWalkSpeed
参数的默认初始化值。
-
在您的
SuperSideScroller_Player
类的源文件中的constructor
函数中,添加bIsSprinting = false
行。这个变量被构造为 false,因为玩家角色默认情况下不应该在奔跑。 -
最后,通过添加一行
GetCharacterMovement()->MaxWalkSpeed = 300.0f
,将角色移动组件的MaxWalkSpeed
参数设置为300.0f
。请查看以下代码:
ASuperSideScroller_Player::ASuperSideScroller_Player()
{
//Set sprinting to false by default.
bIsSprinting = false;
//Set our max Walk Speed to 300.0f
GetCharacterMovement()->MaxWalkSpeed = 300.0f;
}
通过在构造函数中初始化变量,SuperSideScroller_Player
类现在已经完成。返回到虚幻引擎,左键单击工具栏上的Compile
按钮。这将重新编译代码并执行编辑器的热重载。
重新编译和热重载编辑器后,您可以在编辑器中进行播放,并看到您努力的成果。基本移动行为与以前相同,但现在如果您按住左 Shift或游戏手柄右肩,玩家角色将奔跑并开始播放Running
动画。
图 11.32:玩家角色现在可以奔跑
玩家角色能够奔跑后,让我们继续下一个活动,在这个活动中,您将以非常相似的方式实现基本的Throw
功能。
活动 11.03:实现投掷输入
这个游戏包含的一个功能是玩家能够向敌人投掷抛射物。在本章中,您不会创建抛射物或实现动画,但您将设置按键绑定和 C++实现,以便在下一章中使用。
在这个活动中,您需要为Throw
投射功能设置按键绑定,并在 C++中实现调试日志,当玩家按下与Throw
映射的按键时,执行以下操作。
-
在输入绑定中的
Project Settings
中添加一个新的Throw
输入。将此绑定命名为ThrowProjectile
,并将其绑定到左鼠标按钮和游戏手柄右扳机。 -
在 Visual Studio 中,向
SuperSideScroller_Player
的头文件中添加一个新的函数。将这个函数命名为ThrowProjectile()
。这将是一个没有参数的 void 函数。 -
在
SuperSideScroller_Player
类的源文件中创建定义。在这个函数的定义中,使用UE_LOG
打印一条消息,让您知道函数被成功调用。
注意
您可以在这里了解更多关于UE_LOG
的信息:www.ue4community.wiki/Legacy/Logs,_Printing_Messages_To_Yourself_During_Runtime
。
这个活动结束时的预期结果是,当您使用左鼠标按钮或游戏手柄右扳机时,输出日志
中将出现一条日志,让您知道ThrowProjectile
函数被成功调用。稍后您将使用这个函数来生成您的抛射物。
预期的输出如下:
图 11.33:预期的输出日志
注意
这个活动的解决方案可以在这里找到:packt.live/338jEBx
。
完成这个活动后,您现在已经在第十三章 敌人人工智能中创建了玩家投射物的功能。您现在也已经掌握了向游戏添加新的键映射的知识和经验,并且实现了利用这些映射来启用游戏功能的 C++功能。现在,您将继续更新玩家角色移动,以允许玩家跳跃时正确播放跳跃动画。
动画状态机
现在,让我们了解一下在虚幻引擎 4 和动画中状态机的概念。状态机是将动画或一组动画分类到它们自己的状态中的一种方式。状态可以被认为是玩家角色在特定时间内的条件。玩家当前是在走路吗?玩家在跳跃吗?在许多第三人称游戏中,比如最后的生还者,这是将移动、跳跃、蹲下和攀爬动画分离到它们自己的状态中。每个状态在游戏进行时都是可访问的。条件可以包括玩家是否在跳跃、玩家角色的速度以及玩家是否处于蹲下状态。状态机的工作是使用称为转换规则的逻辑决策在各个状态之间进行转换。当您创建多个状态和相互交织的多个转换规则时,状态机开始看起来像一个网络。
请参考以下图片,查看ThirdPerson_AnimBP
动画蓝图的状态机外观。
注意
可以在这里找到状态机的一般概述:docs.unrealengine.com/en-US/Engine/Animation/StateMachines/Overview/index.html
图 11.34:ThirdPerson_AnimBP 的状态机,包含在 SideScroller 项目模板中
对于玩家角色的状态机,这个状态机将处理默认玩家移动和跳跃的状态。目前,玩家角色通过使用由角色速度控制的混合空间来简单地进行动画。在下一个练习中,您将创建一个新的状态机,并将移动混合空间逻辑移动到该状态机内的自己状态。让我们开始创建新的状态机。
练习 11.07:玩家角色移动和跳跃状态机
在这个练习中,您将实现一个新的动画状态机,并将现有的移动混合空间集成到状态机中。此外,您将设置玩家跳跃开始时的状态,以及玩家在跳跃期间的状态。
让我们从添加这个新状态机开始:
-
导航到
/MainCharacter/Blueprints/
目录,并打开AnimBP_SuperSideScroller_MainCharacter
动画蓝图。 -
在
AnimGraph
中,在图表的空白处右键单击,并在上下文敏感搜索中搜索state machine
,以找到Add New State Machine
选项。将这个新状态机命名为Movement
。 -
现在,我们可以连接新状态机
Movement
的输出姿势到动画的输出姿势,而不是连接SideScroller_IdleRun
混合空间的输出姿势:
图 11.35:新的 Movement 状态机替换了旧的混合空间
将空状态机连接到动画蓝图的Output Pose
将导致显示下面的警告。这意味着在该状态机中没有任何操作,结果将无效到Output Pose
。不要担心;您将在下一步中解决这个问题。
图 11.36:空状态机导致编译警告
- 双击
Movement
状态机以打开状态机本身。下面的图像显示了这是什么样子。
图 11.37:这是创建的空状态机
您将首先添加一个新状态,该状态将处理角色之前的操作;空闲
,行走
和奔跑
。
- 从
Entry
点,左键单击并拖动以打开上下文敏感搜索。您会注意到只有两个选项-添加导管
和添加状态
。现在,您将添加一个新状态并将此状态命名为Movement
。请参考以下图像,查看已创建的Movement
状态。
图 11.38:在状态机内部,您需要添加一个新状态,该状态将处理您之前创建的移动混合空间
图 11.39:新的移动状态
- 复制并粘贴您在上一步中连接
Speed
变量到SideScroller_IdleRun
混合空间的逻辑到新创建的Movement
状态。将其连接到此状态的Output Animation Pose
节点的Result
引脚:
图 11.40:将混合空间的输出姿势连接到此状态的输出姿势
现在,如果重新编译动画蓝图,您会注意到首先看到的警告现在已经消失。这是因为您添加了一个新状态,该状态将动画输出到Output Animation Pose
,而不是拥有一个空状态机。
通过完成这个练习,您已经构建了您的第一个状态机。虽然这是一个非常简单的状态机,但现在您正在告诉角色默认进入并使用Movement
状态。如果您现在 PIE,您会看到玩家角色现在像之前制作状态机之前一样移动。这意味着您的状态机正在运行,并且您可以继续下一步,即添加跳跃所需的初始状态。让我们从创建JumpStart
状态开始。
转换规则
导管是告诉每个状态可以在何种条件下从一个状态转换到另一个状态的一种方式。在这种情况下,转换规则被创建为Movement
和JumpStart
状态之间的连接。这由连接状态之间的方向箭头指示。工具提示提到术语转换规则,这意味着您需要定义这些状态之间的转换如何发生,使用布尔值来实现。
图 11.41:需要有一个转换规则从移动到角色跳跃的开始
练习 11.08:向状态机添加状态和转换规则
在从玩家角色的默认移动混合空间过渡到跳跃动画的情况下,您需要知道玩家何时决定跳跃。这可以使用玩家角色的Character Movement
组件中的一个有用函数IsFalling
来完成。您将希望跟踪玩家当前是否正在下落,以便在跳跃中进行过渡。这样做的最佳方式是将IsFalling
函数的结果存储在自己的变量中,就像您在跟踪玩家速度时所做的那样。
以下步骤将帮助您完成此练习:
-
回到状态机的概述,左键单击并从
Movement
状态的边缘拖动以再次打开上下文敏感菜单。 -
选择
Add State
选项并将此状态命名为JumpStart
。当您这样做时,虚幻将自动连接这些状态并为您实现一个空的Transition Rule
:
图 11.42:虚幻自动为您连接两个状态时创建的 Transition Rule
- 返回到动画蓝图中的
Event Graph
,在那里您使用了事件蓝图更新动画事件来存储玩家角色的Speed
。
图 11.43:SuperSideScroller 玩家动画蓝图的 EventGraph
- 为
MainCharacter
创建一个 getter 变量并访问Character Movement
组件。从Character Movement
组件,左键单击并拖动以访问上下文敏感菜单。搜索IsFalling
:
图 11.44:如何找到 IsFalling 函数
- 角色移动组件可以通过
IsFalling
函数告诉您玩家角色当前是否在空中:
图 11.45:角色移动组件显示玩家角色的状态
- 从
IsFalling
函数的Return Value
布尔值,左键单击并拖动以搜索上下文敏感菜单中的Promote to Variable
选项。将此变量命名为bIsInAir
。在提升为变量时,返回值输出针应自动连接到新提升的变量的输入针。如果没有,请记得连接它们。
图 11.46:包含 IsFalling 函数值的新变量 bIsInAir
现在你正在存储玩家的状态以及他们是否正在下落,这是Movement
和JumpStart
状态之间的过渡规则的完美候选者。
- 在
Movement State
机器中,双击Transition Rule
进入其图表。您将只找到一个输出节点Result
,带有参数Can Enter Transition
。在这里,您只需要使用bIsInAir
变量并将其连接到该输出。现在,Transition Rule
表示如果玩家在空中,则可以发生Movement
状态和JumpStart
状态之间的过渡。
图 11.47:当在空中时,玩家将过渡到跳跃动画的开始
在Movement
和JumpStart
状态之间放置了Transition Rule
后,剩下的就是告诉JumpStart
状态使用哪个动画。
- 从状态机图中,双击
JumpStart
状态以进入其图表。从“资产浏览器”中,左键单击并将JumpingStart
动画拖到图表中:
图 11.48:在左键单击并将其拖入状态之前,确保在资产浏览器中选择了 JumpingStart 动画
- 将
Play JumpingStart
节点的输出连接到Output Animation Pose
节点的Result
引脚:
图 11.49:将 JumpingStart 动画连接到 JumpStart 状态的输出动画姿势
在进行下一个状态之前,需要更改JumpingStart
动画节点上的设置。
- 左键单击
Play JumpingStart
动画节点,并更新“详细信息”面板以具有以下设置:
-
“循环动画=假”
-
“播放速率=2.0”
请参考以下图表,查看Play JumpingStart
动画节点的最终设置。
图 11.50:由于 JumpStart 动画的缓慢,增加播放速率将导致整体跳跃动画更加流畅
您将将“循环动画”参数设置为False
,因为没有理由让这个动画循环;无论如何它都应该只播放一次。这个动画循环的唯一方式是玩家角色在这个状态下被卡住,但由于您将创建的下一个状态,这永远不会发生。将“播放速率”设置为3.0
的原因是因为动画本身,JumpingStart
,对于您正在制作的游戏来说太长了。动画让角色急剧弯曲膝盖,并在一秒多的时间内向上跳跃。对于JumpStart
状态,您希望角色更快地播放这个动画,以使其更流畅,并提供更平滑的过渡到下一个状态;JumpLoop
。
一旦玩家角色开始JumpStart
动画,动画中会有一个时间点,此时玩家在空中,并应该过渡到一个新状态。这个新状态将循环,直到玩家不再在空中,并可以过渡到结束跳跃的最终状态。接下来,让我们创建这个新状态,它将从JumpStart
状态过渡。
- 从状态机图中,左键单击并从
JumpStart
状态拖动并选择“添加状态”选项。将此新状态命名为JumpLoop
。与以前一样,虚幻将自动为您提供这些状态之间的“转换规则”,您将在下一个练习中添加。最后,重新编译动画蓝图,并忽略编译器结果下可能出现的任何警告。
图 11.51:创建另一个状态,将处理角色在初始跳跃后空中的动画
通过完成这个练习,您已经为JumpStart
和JumpLoop
添加并连接了自己的状态。这些状态通过“转换规则”连接,现在您对状态机中的状态如何通过每个转换规则中建立的规则从一个状态过渡到另一个状态有了更好的理解。
在下一个练习中,您将更深入地了解如何通过函数“剩余时间比例”从JumpStart
状态过渡到JumpLoop
状态。
练习 11.09:剩余时间比例函数
为了使“跳跃开始”状态顺利过渡到“跳跃循环”状态,您需要花一点时间思考确切地想要这个过渡如何工作。基于“跳跃开始”和“跳跃循环”动画的工作方式,最好在“跳跃开始”动画播放一定时间后过渡到“跳跃循环”动画。这样,“跳跃循环”状态就会在“跳跃开始”动画播放X
秒后平稳播放。
执行以下步骤来实现这一点:
-
双击“跳跃开始”和“跳跃循环”之间的“过渡规则”以打开其图表。您将应用的“过渡规则”是检查“跳跃开始”动画剩余多少时间。这是因为“跳跃开始”动画还剩下一定比例的时间,您可以安全地假设玩家在空中并准备过渡到“跳跃循环”动画状态。
-
要做到这一点,首先确保在“资源浏览器”中选择了“跳跃开始”动画,然后在“过渡规则”的“事件图”中右键单击并找到“时间剩余比率”函数。
让我们花点时间来谈谈“时间剩余比率”函数及其作用。该函数返回一个在0.0f
和1.0f
之间的浮点数,告诉您指定动画剩余多少时间。值0.0f
和1.0f
可以直接转换为百分比值,以便更容易考虑。在“跳跃开始”动画的情况下,您希望知道动画剩余的百分比是否小于 60%,以成功过渡到“跳跃循环”状态。这就是您现在要做的。
- 从“时间剩余比率”函数的“返回值”浮点输出参数中,从上下文敏感搜索菜单中搜索“小于比较操作”节点。由于您正在处理一个在
0.0f
和1.0f
之间的返回值,为了知道动画剩余的百分比是否小于 60%,您需要将这个返回值与0.6f
进行比较。最终结果如下:
图 11.52:在过渡到跳跃循环动画之前,您需要知道跳跃开始动画剩余多少时间
有了这个“过渡规则”,剩下的就是将“跳跃循环”动画添加到“跳跃循环”状态中。
- 在“移动”状态机中,双击“跳跃循环”状态以进入其图表。在“资源浏览器”中选择“跳跃循环”动画资产,单击并将其拖放到图表中。将其输出连接到“输出动画姿势”的“结果”输入,如下所示。默认设置的“播放跳跃循环”节点将保持不变。
图 11.53:跳跃循环动画连接到新状态的输出动画姿势
将“跳跃循环”动画放置在“跳跃循环”状态中后,您现在可以编译动画蓝图并进行 PIE。您会注意到移动和奔跑动画仍然存在,但当您尝试跳跃时会发生什么?玩家角色开始“跳跃开始”状态,并在空中播放“跳跃循环”动画。这很棒,状态机正在工作,但当玩家角色到达地面并不再在空中时会发生什么?玩家角色不会过渡回“移动”状态,这是有道理的,因为您还没有添加“跳跃结束”状态或“跳跃循环”和“跳跃结束”之间的过渡,以及从“跳跃结束”回到“移动”状态。您将在下一个活动中完成这些。请参见下面的示例,其中玩家角色被困在“跳跃循环”状态中:
图 11.54:玩家角色现在可以播放跳跃开始动画和跳跃循环动画,但无法过渡回默认移动状态
通过完成这个练习,您已成功使用Time Remaining Ratio
函数从JumpStart
状态过渡到JumpLoop
状态。这个函数允许您知道动画播放到哪个阶段,有了这个信息,状态机就可以过渡到JumpLoop
状态。玩家现在可以成功地从默认的Movement
状态过渡到JumpStart
状态,然后到JumpLoop
状态,导致一个有趣的问题。玩家现在被困在JumpLoop
状态,因为状态机没有包含回到Movement
状态的过渡。让我们在下一个活动中解决这个问题。
活动 11.04:完成移动和跳跃状态机
完成了一半的状态机,现在是时候添加跳跃结束的状态,以及允许您从JumpLoop
状态过渡到这个新状态的过渡规则,以及从这个新状态过渡回Movement
状态的过渡规则。
完成Movement
状态机的以下操作:
-
添加一个新的
Jump End
状态,从JumpLoop
过渡。将此状态命名为JumpEnd
。 -
将
JumpEnd
动画添加到新的JumpEnd
状态。 -
根据
JumpEnd
动画以及我们希望在JumpLoop
、JumpEnd
和Movement
状态之间快速过渡的方式,考虑修改动画的参数,就像你为JumpStart
动画所做的那样。循环动画
参数需要设置为False
,播放速率
参数需要设置为3.0
。 -
在
JumpLoop
状态到JumpEnd
状态添加一个过渡规则
,基于bIsInAir
变量。 -
根据
JumpEnd
动画的Time Remaining Ratio
函数,从JumpEnd
状态到Movement
状态添加一个过渡规则
。(查看JumpStart
到JumpLoop
的过渡规则)。
通过本次活动,您将拥有一个完全运作的移动状态机,允许玩家角色空闲、行走、冲刺,以及能够跳跃并在跳跃开始时正确地进行动画,以及在空中和着陆时进行动画。
预期输出如下:
图 11.55:玩家角色的空闲、行走、冲刺和跳跃动画
注意
可以在以下链接找到此活动的解决方案:packt.live/338jEBx
。
通过完成这个活动,您现在已经完成了玩家角色的移动状态机。通过添加剩余的JumpEnd
状态和从JumpLoop
状态过渡到该状态的过渡规则
,以及从JumpEnd
状态回到Movement
状态的过渡规则
,您成功地创建了您的第一个动画状态机。现在,您可以在地图上奔跑并跳上高处的平台,同时正确地进行动画并在移动和跳跃状态之间过渡。
总结
玩家移动混合空间已创建,玩家角色动画蓝图使用状态机从移动到跳跃的过渡,您已准备好进入下一章,在那里您将准备所需的动画插槽、动画剪辑,并更新动画蓝图,以使用角色的上半身进行投掷动画。
通过本章的练习和活动,您学会了如何创建一个 1D 混合空间,允许平滑地混合基于移动的动画,如空闲、行走和奔跑,使用玩家角色的速度来控制动画的混合。
另外,您还学会了如何将新的按键绑定集成到项目设置中,并在 C++中绑定这些按键,以启用角色的游戏机制,如冲刺和投掷。
最后,您学会了如何在角色动画蓝图中实现自己的动画状态机,以便玩家能够在移动动画之间进行过渡,跳跃的各种状态,然后再回到移动状态。有了所有这些逻辑,让我们在下一章继续创建资产和逻辑,允许玩家角色播放投掷动画,并设置敌人的基础类。
第十一章:动画混合和蒙太奇
概述
通过本章结束时,你将能够使用动画蒙太奇
工具来创建一个独特的投掷动画,使用你在第十章中导入的投掷
动画序列。通过这个蒙太奇,你将创建并使用动画插槽,允许你在玩家角色的动画蓝图中混合动画。你还将了解如何使用混合节点有效地混合角色的移动和投掷动画。
在完成玩家角色动画后,你将为敌人 AI 创建所需的类和资产,并学习更多关于材质和材质实例
,这将使这个敌人在游戏中具有独特的视觉颜色,以便可以进行区分。最后,敌人将准备好进入第十三章,敌人人工智能,在那里你将开始创建 AI 行为逻辑。
介绍
在上一章中,你通过在混合空间
中实现移动动画,并在动画蓝图中使用该混合空间
来根据玩家速度驱动动画,使玩家角色栩栩如生。然后,你能够基于玩家输入在 C++中实现功能,允许角色奔跑。最后,你利用动画蓝图内置的动画状态机来驱动角色的移动状态和跳跃状态,以实现在行走和跳跃之间流畅过渡。
随着玩家角色动画蓝图和状态机的工作,现在是时候通过实现角色的“投掷”动画来介绍动画蒙太奇和动画插槽了。在本章中,你将学习更多关于动画混合的知识,看看虚幻引擎如何通过创建动画蒙太奇
来处理多个动画的混合,并使用新的保存缓存姿势
和骨骼层叠混合
,以便玩家可以正确地将你在上一章中处理的移动动画与你将在本章实现的新投掷动画进行混合。
让我们首先学习一下什么是动画蒙太奇和动画插槽,以及它们如何用于角色动画。
动画混合、动画插槽和动画蒙太奇
动画混合是在骨骼网格上尽可能无缝地过渡多个动画之间的过程。你已经熟悉了动画混合的技术,因为你在第十一章中为玩家角色创建了一个混合空间
资产,其中角色在“空闲”、“行走”和“奔跑”动画之间平滑过渡。现在,你将通过探索和实现新的叠加技术来扩展这些知识,以将角色的移动动画与投掷动画结合起来。通过使用动画插槽
,你将把投掷动画发送到一组上半身骨骼和其子骨骼,以便允许移动和投掷动画同时应用而不会对其他动画产生负面影响。但首先,让我们更多地谈谈动画蒙太奇。
动画蒙太奇是一个非常强大的资产,它允许你将多个动画组合在一起,并将这些组合动画分割成所谓的部分。部分可以单独播放,按特定顺序播放,甚至循环播放。
动画蒙太奇也很有用,因为你可以通过蓝图或 C++来控制动画蒙太奇中的动画;这意味着你可以根据正在播放的动画部分或蒙太奇中调用的任何“通知”来调用逻辑、更新变量、复制数据等。在 C++中,有一个UAnimInstance
对象,你可以使用它来调用诸如UAnimInstance::Montage_Play
之类的函数,这允许你从 C++中访问和播放蒙太奇。
注意
这种方法将在第十四章“生成玩家投射物”中使用,当您开始为游戏添加细节时。关于动画和Notifies
在 Unreal Engine 4 中如何通过 C++处理的更多信息可以在docs.unrealengine.com/en-US/API/Runtime/Engine/Animation/AnimNotifies/UAnimNotifyState/index.html
找到。
您将在本章的第一个练习中了解更多关于Notifies
的内容,并且您将在第十四章“生成玩家投射物”中编写自己的通知状态。
下面的图片显示了动画蒙太奇的Persona
编辑器。然而,这将在练习 12.01“设置动画蒙太奇”中进一步拆分:
图 12.1:Persona 编辑器,在编辑动画蒙太奇时打开
就像在动画序列中一样,动画蒙太奇允许在动画的时间轴上触发Notifies
,这样可以触发声音、粒子效果和事件。Event
Notifies
将允许我们从蓝图或 C++中调用逻辑。Epic Games 在他们的文档中提供了一个武器重新加载Animation Montage
的示例,该示例分为reload start
、reload loop
和reload complete
的动画。通过拆分这些动画并应用Notifies
来触发sounds
和events
,开发人员可以完全控制reload loop
根据内部变量播放多长时间,以及在动画过程中播放任何额外的声音或效果。
最后,动画蒙太奇支持所谓的Anim Slots。Anim Slots 允许您对动画或一组动画进行分类,稍后可以在 Animation Blueprint 中引用,以允许基于插槽的独特混合行为。这意味着您可以定义一个 Anim Slot,稍后可以在 Animation Blueprint 中使用,以允许使用此插槽的动画以任何您想要的方式在基本移动动画的基础上混合;在我们的情况下,只影响玩家角色的上半身而不影响下半身。
让我们开始为玩家角色的Throw
动画创建Animation Montage
,这是第一个练习。
练习 12.01:设置动画蒙太奇
玩家角色的最后一件事是设置 Anim Slot,这将单独将此动画分类为上半身动画。您将在 Animation Blueprint 中使用此 Anim Slot,结合混合函数,允许玩家角色投掷投射物,同时在移动和跳跃时正确地对下半身进行动画处理。
通过这个练习结束时,玩家角色将能够仅使用上半身播放Throw
动画,而下半身仍将使用您在上一章中定义的movement animation
。
让我们开始为角色创建Animation Montage
,投掷并设置那里的 Anim Slot:
-
首先,导航到
/MainCharacter/Animation
目录,这是所有动画资产的位置。 -
现在,在内容浏览器中右键单击,悬停在可用下拉菜单中的
Animation
选项上。 -
然后,左键单击以选择从出现的附加下拉菜单中的
Animation Montage
选项。 -
就像创建其他基于动画的资产一样,比如
Blend Spaces
或Animation Blueprints
,Unreal Engine 会要求您为这个Animation Montage
分配一个Skeleton
对象。在这种情况下,选择MainCharacter_Skeleton
。 -
将新的
Animation Montage
命名为AM_Throw
。现在,双击打开蒙太奇:
图 12.2:您已成功创建了一个动画蒙太奇资产
打开“动画剪辑”资产时,您会看到类似的编辑器布局,就像打开“动画序列”时一样。有一个“预览”窗口,显示默认的 T 形主角骨架,但一旦您向这个剪辑添加动画,骨架将更新以反映这些变化。
通过完成这个练习,您已成功为“超级横向卷轴”项目创建了一个“动画剪辑”资产。现在是时候了解更多关于动画剪辑以及如何添加您需要的“投掷”动画和动画插槽,以便将“投掷”动画与现有角色移动动画混合。
动画剪辑
看一下下面的图:
图 12.3:动画剪辑 Persona 编辑器中的动画预览窗口
在“预览”窗口下方,您有主要剪辑时间轴,以及其他部分;让我们从上到下评估这些部分:
-
“剪辑”部分是可以添加一个或多个动画的动画集合。您还可以右键单击时间轴上的任何点以创建“部分”区域。
-
部分:如前所述,部分允许您设置单个动画序列的播放顺序以及部分是否应该循环。
为了投掷剪辑的目的,您不需要使用此功能,因为您只会在此剪辑中使用一个动画:
图 12.4:预览窗口和剪辑和部分区域
-
“元素定时”部分为您提供了剪辑的预览以及剪辑各个方面的顺序。通知的播放顺序,“剪辑”部分和其他元素将在这里进行可视化显示,以便您快速预览剪辑的工作方式。
-
“通知”使您能够在动画时间轴上添加点,然后通知其他系统执行操作或从蓝图和 C++中调用逻辑。通知选项,如“播放声音”或“播放粒子效果”,允许您在动画的特定时间播放声音或粒子。一个例子是在武器重新装填的动画中;您可以在动画的时间轴上添加通知,以在重新装填的精确时刻播放重新装填声音。在实现投掷投射物时,您将在项目的后续阶段使用这些“通知”:
图 12.5:元素定时和通知区域
现在您已经熟悉了动画剪辑的界面,您可以按照下一个练习将“投掷”动画添加到剪辑中。
练习 12.02:将投掷动画添加到剪辑中
现在您对动画剪辑是什么以及这些资产如何工作有了更好的理解,是时候将“投掷”动画添加到您在练习 12.01中创建的剪辑中了,设置动画剪辑。尽管您只会向此剪辑添加一个动画,但重要的是要强调您可以向剪辑添加多个独特的动画,然后播放。现在,让我们开始通过添加您在第十章中导入项目的“投掷”动画来开始:
在“资产浏览器”中找到“投掷”动画资产。然后,左键单击并将其拖放到“剪辑”部分下的时间轴上:
图 12.6:带有基于动画的资产的资产浏览器窗口
一旦将动画添加到动画剪辑中,预览窗口中的角色骨架将更新以反映此更改并开始播放动画:
图 12.7:玩家角色开始动画
现在投掷
动画已经添加到动画蒙太奇中,您可以继续创建动画槽
。
动画槽管理器
选项卡应该停靠在右侧的资产浏览器
选项卡旁边。如果您看不到动画槽管理器
选项卡,可以通过导航到顶部动画蒙太奇
编辑器窗口的工具栏中的窗口
选项卡来访问它。在那里,左键单击选择动画槽管理器
选项,窗口将出现。
完成此练习后,您已经将投掷
动画添加到了新的动画蒙太奇中,并且可以回放动画,以预览它在Persona
编辑器中的外观。
现在,您可以继续学习有关动画槽和动画槽管理器
的知识,然后在本章后面的部分中添加自己独特的动画槽,以便用于动画混合。
动画槽管理器
动画槽管理器
是您管理动画槽
的地方,正如其名称所示。从该选项卡中,您可以通过左键单击添加组
选项创建新的组
,并将其标记为Face
,以向其他人说明该组中的槽影响角色的面部。默认情况下,虚幻引擎为您提供了一个名为DefaultGroup
的组
和一个名为DefaultSlot
的动画槽
,该槽位于该组中。
让我们创建一个新的动画槽。
练习 12.03:添加新的动画槽
现在您对动画槽和动画槽管理器
有了更好的理解,您可以按照以下步骤创建一个名为上半身
的新动画槽。创建了这个新槽后,它可以在动画蓝图中使用和引用,以处理动画混合,这将在以后的练习中进行。
让我们通过以下步骤创建动画槽:
-
在
动画槽管理器
中,左键单击添加槽
选项。 -
在添加新槽时,虚幻将要求您给这个
动画槽
命名。将此槽命名为上半身
。动画槽的命名很重要,就像命名其他任何资产和参数一样,因为您稍后将在动画蓝图中引用此槽。
创建了动画槽后,现在可以更新用于投掷
蒙太奇的槽。
- 在
Montage
部分,有一个下拉菜单显示应用的动画槽
;默认情况下,它设置为DefaultGroup.DefaultSlot
。左键单击,然后从下拉菜单中选择DefaultGroup.Upper Body
:
图 12.8:新的动画槽将出现在下拉列表中
注意
更改动画槽
后,您可能会注意到玩家角色停止动画并返回到 T 形状。不用担心-如果发生这种情况,只需关闭动画蒙太奇
,然后重新打开它。重新打开后,角色将再次播放投掷
动画。
创建了您的动画槽
并放置在投掷
蒙太奇中后,现在是时候更新动画蓝图,以便玩家角色意识到这个槽,并根据它正确地进行动画。
-
导航到
/MainCharacter/Blueprints/
目录中的AnimBP_SuperSideScroller_MainCharacter
资产。 -
通过双击打开此资产并打开
动画图
。
完成此练习后,您已经使用动画槽管理器
在动画蒙太奇中创建了您的第一个动画槽。有了这个槽,现在可以在玩家角色动画蓝图中使用和引用它,以处理在上一章中实现的投掷
动画和移动动画之间所需的动画混合。在执行此操作之前,您需要了解有关动画蓝图中保存缓存姿势
节点的更多信息。
保存缓存姿势
在处理复杂动画和角色时,有时需要在多个地方引用状态机输出的姿势。如果你还没有注意到,你的Movement
状态机的输出姿势不能连接到多个其他节点。这就是Save Cached Pose
节点派上用场的地方;它允许你缓存或存储一个姿势,然后可以在多个地方引用。你需要使用它来设置上半身动画的新 Anim Slot。
让我们开始吧。
练习 12.04:保存 Movement 状态机的缓存姿势
为了有效地混合使用上一练习中创建的Upper Body Anim Slot
和已经存在的玩家角色的移动动画的Throw
动画,你需要能够在动画蓝图中引用Movement
状态机。为了实现这一点,按照以下步骤在动画蓝图中实现Save Cached Pose
节点:
- 在
Anim Graph
中,右键单击并搜索New Save Cached Pose
。将其命名为Movement Cache
:
图 12.9:姿势将在每帧评估一次,然后被缓存
- 现在,不要直接将你的
Movement
状态机连接到输出姿势,而是连接到缓存节点:
图 12.10:Movement 状态机正在被缓存
- 使用缓存的
Movement
状态机姿势,现在你只需要引用它。这可以通过搜索“使用缓存的姿势”节点来实现。
注意
所有缓存的姿势都会显示在上下文敏感菜单中。只需确保选择你在步骤 1中给它的名字的缓存姿势。
- 有了缓存姿势节点后,将其连接到
AnimGraph
的Output Pose
:
图 12.11:这与将 Movement 状态机直接连接到输出姿势相同
在步骤 4之后,你会注意到主角在最后一章之后会正确地进行动画并移动。这证明了Movement
状态机的缓存正在工作。下面的图片显示了玩家角色在动画蓝图的预览窗口中回到了Idle
动画。
现在,Movement
状态机的缓存工作正常,你将使用这个缓存来通过骨骼上的Anim Slot
混合动画:
图 12.12:主角正在按预期进行动画
完成这个练习后,你现在可以在动画蓝图中任何你想要的地方引用缓存的Movement
状态机姿势。有了这个便利,你现在可以使用缓存的姿势开始在缓存的移动姿势和Upper Body
Anim Slot 之间进行混合,使用一个叫做Layered blend per bone
的函数。
Layered blend per bone
你将在这里使用的节点是Layered blend per bone
。这个节点可以屏蔽角色骨骼上的一组骨骼,使动画忽略这些骨骼。
对于我们的玩家角色和Throw
动画,你将屏蔽下半身,以便只有上半身进行动画。目标是能够同时执行投掷和移动动画,并使这些动画混合在一起;否则,当你执行投掷时,移动动画会完全中断。
练习 12.05:使用上半身 Anim Slot 混合动画
“每个骨骼的分层混合”功能允许我们将“投掷”动画与您在上一章中实现的移动动画混合,并控制“投掷”动画对玩家角色骨骼的影响程度。
在这个练习中,您将使用“每个骨骼的分层混合”功能完全屏蔽角色的下半身,当播放“投掷”动画时,以便它不会影响角色下半身的移动动画。
让我们从添加“每个骨骼的分层混合”节点开始,并讨论其输入参数和设置:
- 在动画蓝图中,右键单击并在“上下文敏感”搜索中搜索“每个骨骼的分层混合”。
图 12.13显示了“每个骨骼的分层混合”节点及其参数。
-
第一个参数“基础姿势”是角色的基础姿势;在这种情况下,“移动”状态机的缓存姿势将是基础姿势。
-
第二个参数是您想要在“基础姿势”上叠加的“混合姿势 0”节点;请记住,选择“添加引脚”将创建额外的“混合姿势”和“混合权重”参数。现在,您只会使用一个“混合姿势”节点。
-
最后一个参数是“混合权重”,它是“混合姿势”对“基础姿势”的影响程度,范围从
0.0
到1.0
作为 alpha 值:
图 12.13:每个骨骼的分层混合节点
在连接任何内容到此节点之前,您需要向其属性中添加一个层。
- 左键单击选择节点并导航到“详细信息”。您需要左键单击“层设置”旁边的箭头,以找到此设置的第一个索引
0
。左键单击“分支过滤器”旁边的+
以创建新的过滤器。
这里再次有两个参数,即以下参数:
- “骨骼名称”:指定混合将发生的骨骼,并确定被屏蔽的骨骼的子层次结构。在本项目的主角骨架中,将“骨骼名称”设置为“脊柱”。图 12.14显示了“脊柱”骨及其子骨与主角的下半身不相关联。这可以在“骨架”资产
MainCharacter_Skeleton
中看到:
图 12.14:脊柱骨及其子骨与主角的上半身相关联
-
“混合深度”:骨骼及其子骨受动画影响的深度。值为
0
将不会影响所选骨骼的根子骨。 -
“网格空间旋转混合”:确定是否在“网格空间”或“本地空间”中混合骨骼旋转。“网格空间”旋转是指骨架网格的边界框作为其基本旋转,而“本地空间”旋转是指所讨论的骨骼名称的局部旋转。在这种情况下,我们希望旋转混合发生在网格空间中,因此我们将将此参数设置为 true。
混合传播到骨骼的所有子骨,以停止在特定骨骼上的混合,将它们添加到数组中,并将它们的混合深度值设为0
。最终结果如下:
图 12.15:您可以使用一个混合节点设置多个层
- 在“每个骨骼的分层混合”节点上设置好参数后,您可以将“移动缓存”缓存姿势连接到分层混合的“基础姿势”节点。确保将“每个骨骼的分层混合”节点的输出连接到动画蓝图的“输出姿势”:
图 12.16:将移动状态机的缓存姿势添加到每个骨骼的分层混合节点
现在是时候使用您之前创建的 Anim Slot,通过Layered blend per bone
节点仅过滤使用此插槽的动画了。
- 在
AnimGraph
中右键单击,搜索DefaultSlot
。左键单击选择Slot
节点并导航到Details
。在那里,您会找到Slot Name
属性。左键单击此下拉菜单,找到并选择DefaultGroup.Upper Body
插槽。
在更改Slot Name
属性时,Slot
节点将更新以表示这个新名称。Slot
节点需要一个源姿势,这将再次是对Movement
状态机的引用。这意味着您需要为Movement Cache
姿势创建另一个Use Cached Pose
节点。
- 将缓存的姿势连接到
Slot
节点的源:
图 12.17:通过 Anim Slot 过滤缓存的 Movement 姿势
- 现在剩下的就是将
Upper Body
插槽节点连接到Blend Pose 0
输入。然后,将Layered blend per bone
的最终姿势连接到Output Pose
动画蓝图的结果:
图 12.18:主角动画蓝图的最终设置
在主角动画蓝图中放置了 Anim Slot 和Layered blend per bone
节点后,您终于完成了主角的动画部分。
接下来,让我们简要讨论一下“投掷”动画的动画混合的重要性以及“投掷”动画将用于什么,然后再继续练习 12.06,在那里您将在游戏中预览“投掷”动画。
投掷动画
到目前为止,您已经投入了大量的工作,以确保“投掷”动画与之前章节中在动画蓝图中设置的“移动”动画正确融合。这一努力的主要原因是确保角色在执行多个动画时的视觉保真度。在接下来的练习和活动中,您将亲身体会到错误设置动画混合的视觉后果。
回到“投掷”动画,每款现代视频游戏都以某种形式实现动画混合,只要美术指导和游戏机制需要这样的功能。一个极好地运用动画的现代游戏系列的例子是由Naughty Dog开发的Uncharted系列。
如果您对这个系列不熟悉,您可以在这里观看最新版本的完整游戏玩法:www.youtube.com/watch?v=5evF_funE8A
。
Uncharted系列非常擅长使用成千上万的动画和混合技术,为玩家角色带来令人难以置信的真实感、重量感和动感,让您在玩游戏时感觉非常良好。虽然Super SideScroller项目不会像这样精致,但您正在学习制作视频游戏中不可思议动画所需的基础知识:
练习 12.06:预览投掷动画
在上一个练习中,您做了很多工作,通过使用Save Cached Pose
和Layered blend per bone
节点,允许玩家角色的Movement
动画和Throw
动画之间进行动画混合。执行以下步骤,在游戏中预览Throw
动画,并看到您劳动的成果:
-
导航到
/MainCharacter/Blueprints/
目录,并打开角色的BP_SuperSideScroller_MainCharacter
蓝图。 -
如果您还记得,在上一章中,您为投掷创建了名为
ThrowProjectile
的Input Action
。 -
在角色蓝图的“事件图”中,右键单击并在“上下文敏感”下拉搜索中搜索
ThrowProjectile
。用左键单击选择它,在图表中创建事件节点。
有了这个事件,您需要一个函数,允许您在玩家使用左鼠标按钮投掷时播放“动画蒙太奇”。
- 右键单击在“事件图”中搜索
Play Montage
。确保不要将其与类似的函数Play Anim Montage
混淆。
Play Montage
函数需要两个重要的输入:
-
Montage to Play
-
“在骨骼网格组件”
让我们首先处理“骨骼网格组件”。
- 玩家角色有一个“骨骼网格组件”,可以在标记为“网格”的组件选项卡中找到。左键单击并拖动一个
Get
引用到这个变量,并将其连接到此函数的In Skeletal Mesh Component
输入:
图 12.19:玩家角色的网格连接到 In Skeletal Mesh Component 输入
现在要做的最后一件事是告诉这个函数播放哪个蒙太奇。幸运的是,这个项目中只有一个蒙太奇存在:AM_Throw
。
-
在
Montage to Play
输入下的下拉菜单上左键单击,然后左键单击选择AM_Throw
。 -
最后,将
ThrowProjectile
事件的Pressed
执行输出连接到Play Montage
函数的执行输入引脚:
图 12.20:当玩家按下 ThrowProjectile 输入动作时,将播放 AM_Throw 蒙太奇
- 现在,当您点击左鼠标按钮时,玩家角色将播放投掷“动画蒙太奇”。
现在注意一下,您可以同时行走和奔跑,同时投掷,每个动画都会混合在一起,不会相互干扰:
图 12.21:玩家角色现在可以移动和投掷
不要担心重复使用左鼠标按钮动作播放Throw
蒙太奇时可能出现的任何错误;在后面的章节中,当您实现将要投掷的抛射物时,这些问题将得到解决。现在,您只想知道在Anim Slot
和Animation Blueprint
上所做的工作是否能够实现所需的动画混合结果。
让我们继续通过现在创建 C++类、蓝图和材质来设置敌人,以便在下一章中使用。
超级横向卷轴游戏敌人
当玩家角色在移动和执行Throw
动画时正确播放动画时,现在是时候谈论SuperSideScroller
游戏将呈现的敌人类型了。我们将有一个简单类型的敌人。
这个敌人将有一个基本的来回移动模式,不支持任何攻击;只有与玩家角色碰撞时才能造成伤害。
在下一个练习中,您将为第一种敌人类型设置基础敌人类的 C++,并配置敌人的蓝图和动画蓝图,为第十三章“敌人人工智能”做准备,在那里您将实现这个敌人的人工智能。为了效率和时间考虑,您将使用 Unreal Engine 4 在SideScroller
模板中已经提供的资产用于敌人。这意味着您将使用默认人偶资产的骨架、骨骼网格、动画和动画蓝图。让我们开始创建第一个敌人类。
练习 12.07:创建敌人基础 C++类
本练习的目标是从头开始创建一个新的敌人类,并在 第十三章 敌人人工智能 中准备好使用敌人。首先,按照以下步骤在 C++ 中创建一个新的敌人类:
-
在编辑器中,导航到
文件
并选择新建 C++ 类
来开始创建新的敌人类。 -
接下来,请确保在尝试搜索类之前,检查
选择父类
窗口提示的顶部的显示所有类
复选框。然后,搜索SuperSideScrollerCharacter
并 左键单击 选择它作为父类。 -
最后,您需要给这个类起一个名字并选择一个目录。将这个类命名为
EnemyBase
,并不要更改目录路径。准备好后,左键单击Create Class
按钮,让虚幻引擎为您创建新的类。
当您创建一个新类时,虚幻引擎会自动为您打开 Visual Studio,并准备好 .cpp
和 .h
文件。目前,您不会对代码进行任何更改,因此关闭 Visual Studio。
让我们为敌人资产在内容浏览器中创建文件夹结构。接下来。
- 回到虚幻引擎 4 编辑器,导航到内容浏览器,并创建一个名为
Enemy
的新文件夹:
图 12.22:通过右键单击现有文件夹并选择新文件夹来创建新文件夹
-
在
Enemy
文件夹中,创建另一个名为Blueprints
的文件夹,您将在其中创建并保存敌人的蓝图资产。 -
在
/Enemy/Blueprints
目录中,右键单击 并选择蓝图类
。从选择父类
中搜索您刚刚创建的新 C++ 类EnemyBase
,如图所示:
图 12.23:现在,新的 EnemyBase 类可供您创建蓝图
- 将其命名为
BP_Enemy
。
现在,您已经使用 EnemyBase
类作为父类为第一个敌人创建了 蓝图
,是时候处理 动画蓝图
了。您将使用虚幻引擎在 SideScroller
模板项目中提供给您的默认 动画蓝图
。按照下一个练习中的步骤创建现有 动画蓝图
的副本并将其移动到 /Enemy/Blueprints
目录。
练习 12.08:创建和应用敌人动画蓝图
在上一个练习中,您创建了使用 EnemyBase
类作为父类的第一个敌人的 蓝图
。在这个练习中,您将处理动画蓝图。
以下步骤将帮助您完成此练习:
-
导航到
/Mannequin/Animations
目录,并找到ThirdPerson_AnimBP
资产。 -
现在,复制
ThirdPerson_AnimBP
资产。有两种方法可以复制资产:
-
在内容浏览器中选择所需的资产,然后按 CTRL + W。
-
在内容浏览器中 右键单击 所需的资产,然后从下拉菜单中选择
复制
。
-
现在,左键单击 并拖动此重复的资产到
/Enemy/Blueprints
目录,并在释放 左键单击 鼠标按钮时选择移动选项。 -
将此重复的资产命名为
AnimBP_Enemy
。最好创建一个资产的副本,以便稍后根据需要进行修改,而不会影响原始资产的功能:
敌人 蓝图
和 动画蓝图
创建完成后,现在是时候更新敌人蓝图,以使用默认的 骨骼网格
人体模型和新的 动画蓝图
副本了。
-
导航到
/Enemy/Blueprints
并打开BP_Enemy
。 -
接下来,导航到
Mesh
组件并选择它以访问其详细信息
面板。首先,将SK_Mannequin
分配给骨骼网格
参数,如图所示:
图 12.24:您将使用默认的 SK_Mannequin 骨骼网格作为新敌人的角色
- 现在,您需要将“AnimBP_Enemy 动画蓝图”应用到
Mesh
组件上。导航到Mesh
组件的“详细信息”面板的“动画”类别,在“动画类”下,分配AnimBP_Enemy
:
图 12.25:将新的 AnimBP_Enemy 动画蓝图分配为敌人角色的动画类
- 最后,当在“预览”窗口中预览角色时,您会注意到角色网格的位置和旋转不正确。通过将
Mesh
组件的“变换”属性设置为以下内容来修复这个问题:
-
“位置”:(
X
=0.000000
,Y
=0.000000
,Z
=-90.000000
) -
“旋转”:(Roll=
0.000000
,Pitch=0
,Yaw=-90.000000
) -
“缩放”:(
X
=1.000000
,one
=1.000000
,Z
=1.000000
)
“变换”设置将如下所示:
图 12.26:这些是变换设置,以便您的角色被正确定位和旋转
以下图显示了迄今为止Mesh
组件的设置。请确保您的设置与此处显示的相匹配:
图 12.27:敌人角色的 Mesh 组件设置
这里要做的最后一件事是创建人体模型主要材质的“材质实例”,以便这个敌人可以拥有一个独特的颜色,有助于将其与其他敌人类型区分开来。
让我们首先了解更多关于材质和“材质实例”的知识。
材质和材质实例
在继续下一个练习之前,我们需要先简要讨论一下材质和“材质实例”是什么,然后您才能使用这些资产并将它们应用到新的敌人角色上。尽管本书更加关注使用虚幻引擎 4 进行游戏开发的技术方面,但您仍然需要知道材质和“材质实例”是什么以及它们在视频游戏中的使用方式。
注意
有关材质的更多信息,请参考以下 Epic Games 文档:docs.unrealengine.com/en-US/Engine/Rendering/Materials/index.html
。
材质是一种可以应用于网格的资产类型,然后控制网格在游戏中的外观的资产。 “材质”编辑器让您控制最终视觉结果的许多部分,包括对“纹理”、“自发光”和“高光”等参数的控制。以下图显示了应用了M_UE4Man_Body
材质的默认人体模型骨骼网格:
图 12.28:默认的人体模型骨骼网格应用了基本材质
“材质实例”是“材质”的扩展,您无法访问或控制“材质实例”派生的基本“材质”,但您可以控制“材质”创建者向您公开的参数。许多参数可以从“材质实例”内部向您公开以供使用。
Unreal Engine 在“侧向滚动”模板项目中为我们提供了一个Material Instance
的示例,名为M_UE4Man_ChestLogo
,位于/Mannequin/Character/Materials/
目录中。以下图片显示了基于父材质M_UE4Man_Body
给Material Instance
提供的一组暴露参数。要重点关注的最重要的参数是名为BodyColor
的Vector
参数。您将在下一个练习中使用这个参数来为敌人角色提供独特的颜色:
图 12.29:M_UE4Man_ChestLogo Material Instance 资产的参数列表
练习 12.09:创建并应用敌人材质实例
现在您已经基本了解了材质和材质实例是什么,是时候从M_UE4ManBody
资产创建您自己的Material Instance
了。通过这个Material Instance
,您将调整BodyColor
参数,为敌人角色提供独特的视觉表现。让我们从创建新的Material Instance
开始。
以下步骤将帮助您完成此练习:
-
导航到
/Mannequin/Character/Materials
目录,找到默认人体模型角色M_UE4ManBody
使用的“材质”。 -
可以通过右键单击“材质”资产
M_UE4Man_Body
,然后左键单击“创建材质实例”选项来创建Material Instance
。将此资产命名为MI_Enemy01
。
图 12.30:任何材质都可以用来创建材质实例
在Enemy
文件夹中创建一个名为Materials
的新文件夹。左键单击并将Material Instance
拖放到/Enemy/Materials
目录中,将资产移动到这个新文件夹中:
图 12.31:重命名 Material Instance MI_Enemy
- 双击
Material Instance
,在左侧找到Details
面板。在那里,您会找到一个名为BodyColor
的Vector Parameter
属性。确保复选框被选中以启用此参数,然后将其值更改为红色。现在,Material Instance
应该呈现红色,如图所示:
图 12.32:现在,敌人的材质是红色
- 保存
Material Instance
资产,并导航回BP_Enemy01
蓝图。选择Mesh
组件,并更新“元素 0”材料参数为MI_Enemy
:
图 12.33:将新的 Material Instance 资产 MI_Enemy 分配给 Mesh 组件的材料的元素 0
- 现在,第一种敌人类型在视觉上已经准备就绪,并且已经准备好适用于下一章的 AI 的适当的
Blueprint
和动画蓝图资产:
图 12.34:最终敌人角色设置
完成此练习后,您现在已经创建了一个Material Instance
并将其应用于敌人角色,使其具有独特的视觉表现。
让我们通过进行一个简短的活动来结束本章,这将帮助您更好地理解使用Layered blend per bone
节点来混合动画,这是在之前的练习中使用的。
活动 12.01:更新混合权重
在“练习 12.06”结束时,“预览投掷动画”,您能够混合移动动画和“投掷”动画,以便它们可以同时播放而不会对彼此产生负面影响。结果是玩家角色在行走或奔跑时能够正确执行动画,同时在上半身执行“投掷”动画。
在这个活动中,您将尝试使用Layered blend per bone
节点的混合偏差值和参数,以更好地理解动画混合的工作原理。
以下步骤将帮助您完成此活动:
- 更新
Layered blend per bone
节点的Blend Weights
输入参数,以确保Throw
动画的附加姿势与基础移动姿势完全不混合。尝试在这里使用值,如0.0f
和0.5f
,以比较动画中的差异。
注意
确保在完成后将此值返回为1.0f
,以免影响您在上一个练习中设置的混合。
-
更新
Layered blend per bone
节点的设置,以更改受混合影响的骨骼,以便整个角色的身体都受到混合的影响。从MainCharacter_Skeleton
资产的骨骼层次结构中的根骨骼开始是个好主意。 -
保持上一步的设置不变,向分支过滤器添加一个新的数组元素,并在这个新的数组元素中,添加骨骼名称和混合深度值“-1.0f”,这样只有角色的左腿在混合 Throw 动画时才能继续正确地播放移动动画。
注意
完成此活动后,请确保将Layered blend per bone
节点的设置返回到第一个练习结束时设置的值,以确保角色动画不会丢失任何进展。
预期输出如下:
图 12.35:显示整个角色身体受影响的输出
图 12.36:当混合 Throw 动画时,左腿继续正确地播放移动动画
图 12.37:角色的右腿在移动时播放 Throw 动画的结尾
注意
此活动的解决方案可在以下链接找到:packt.live/338jEBx
。
在结束本活动之前,请将Layered blend per bone
设置返回到练习 12.05“使用上半身动画插槽混合动画”的最后设置的值。如果您不将这些值恢复到原始设置,那么下一章节中即将进行的练习和活动中的动画结果将不同。您可以手动设置回原始值,或者参考以下链接中具有这些设置的文件:packt.live/2GKGMxM
。
完成此活动后,您现在对动画混合的工作原理和混合权重如何影响Layered blend per bone
节点上的附加姿势对基础姿势的影响有了更深入的理解。
注意
在这个项目中,您还没有使用许多动画混合技术,强烈建议您研究这些技术,首先查看docs.unrealengine.com/en-US/Engine/Animation/AnimationBlending/index.html
上的文档。
总结
通过使用 C++类、蓝图和材质设置敌人,您已经准备好进入下一章节,在那里您将利用虚幻引擎 4 中的行为树等系统为这个敌人创建 AI。
从本章的练习和活动中,您学会了如何创建一个“动画蒙太奇”,允许播放动画。您还学会了如何在这个蒙太奇中设置一个动画插槽,以便为玩家角色的上半身进行分类。
接下来,您将学习如何使用“使用缓存姿势”节点来缓存状态机的输出姿势,以便在更复杂的动画蓝图中可以引用这个姿势的多个实例。然后,通过学习“每骨层混合”功能,您可以使用动画插槽将基本移动姿势与“投掷”动画的附加层进行混合。
最后,您将通过创建 C++类、蓝图和其他资产来组建敌人的基础,以便为下一章做好准备。敌人准备就绪后,让我们继续创建敌人的人工智能,以便它可以与玩家进行互动。
第十二章:敌人人工智能
概述
本章以简要回顾《超级横向卷轴》游戏中敌人人工智能的行为方式开始。然后,你将学习虚幻引擎 4 中的控制器,并学习如何创建一个 AI 控制器。接着,你将学习如何通过在游戏的主要关卡中添加导航网格来更多地了解虚幻引擎 4 中的 AI 导航。
通过本章的学习,你将能够创建一个敌人可以移动的可导航空间。你还将能够创建一个敌人 AI 角色,并使用黑板和行为树在不同位置之间导航。最后,你将学会如何创建和实现一个玩家投射物类,并为其添加视觉元素。
介绍
在上一章中,你使用了动画混合、动画插槽、动画蓝图和混合函数(如每骨层混合)为玩家角色添加了分层动画。
在本章中,你将学习如何使用导航网格在游戏世界内创建一个可导航的空间,使敌人可以在其中移动。定义关卡的可导航空间对于允许人工智能访问和移动到关卡的特定区域至关重要。
接下来,你将创建一个敌人 AI 角色,使用虚幻引擎 4 中的黑板和行为树等 AI 工具在游戏世界内的巡逻点位置之间导航。
你还将学习如何使用导航网格在游戏世界内创建一个可导航的空间,使敌人可以在其中移动。定义关卡的可导航空间对于允许 AI 访问和移动到关卡的特定区域至关重要。
最后,你将学习如何在 C++中创建一个玩家投射物类,以及如何实现OnHit()
碰撞事件函数来识别并记录投射物击中游戏世界中的物体。除了创建类之外,你还将创建这个玩家投射物类的蓝图,并为玩家投射物添加视觉元素,如静态网格。
《超级横向卷轴》游戏终于要完成了,通过本章的学习,你将在很好的位置上,可以继续学习第十四章《生成玩家投射物》,在那里你将处理游戏的一些细节,如音效和视觉效果。
本章的主要重点是使用人工智能使你在第十二章《动画混合和蒙太奇》中创建的 C++敌人类活灵活现。虚幻引擎 4 使用许多不同的工具来实现人工智能,如 AI 控制器、黑板和行为树,你将在本章中学习并使用这些工具。在你深入了解这些系统之前,让我们花一点时间了解近年来游戏中人工智能的使用方式。自从《超级马里奥兄弟》以来,人工智能显然已经发展了许多。
敌人人工智能
什么是人工智能?这个术语可以有很多不同的含义,取决于它所用于的领域和背景,因此让我们以一种对视频游戏主题有意义的方式来定义它。
AI是一个意识到自己环境并做出选择以最优化地实现其预期目的的实体。AI 使用所谓的有限状态机根据其从用户或环境接收到的输入切换多个状态之间。例如,视频游戏中的 AI 可以根据其当前的健康状态在攻击状态和防御状态之间切换。
在《你好邻居》和《异形:孤立》等游戏中,AI 的目标是尽可能高效地找到玩家,同时也遵循开发者定义的一些预定模式,以确保玩家可以智胜。《你好邻居》通过让 AI 从玩家过去的行为中学习并试图根据所学知识智胜玩家,为其 AI 添加了一个非常有创意的元素。
您可以在游戏发布商TinyBuild Games的视频中找到有关 AI 如何工作的信息:www.youtube.com/watch?v=Hu7Z52RaBGk
。
有趣和有趣的 AI 对于任何游戏都至关重要,取决于您正在制作的游戏,这可能意味着非常复杂或非常简单的 AI。您将为SuperSideScroller
游戏创建的 AI 不会像之前提到的那些那样复杂,但它将满足我们希望创建的游戏的需求。
让我们来分析一下敌人的行为方式:
-
敌人将是一个非常简单的敌人,具有基本的来回移动模式,不会支持任何攻击;只有与玩家角色碰撞,它们才能造成伤害。
-
然而,我们需要设置敌人 AI 要移动的位置。
-
接下来,我们决定 AI 是否应该改变位置,是否应该在不同位置之间不断移动,或者在选择新位置移动之间是否应该有暂停?
幸运的是,对于我们来说,虚幻引擎 4 为我们提供了一系列工具,我们可以使用这些工具来开发复杂的 AI。然而,在我们的项目中,我们将使用这些工具来创建一个简单的敌人类型。让我们首先讨论一下虚幻引擎 4 中的 AI 控制器是什么。
AI 控制器
让我们讨论玩家控制器和AI 控制器之间的主要区别是什么。这两个角色都是从基本的Controller 类派生出来的,控制器用于控制一个Pawn或Character的行动。
玩家控制器依赖于实际玩家的输入,而 AI 控制器则将 AI 应用于他们所拥有的角色,并根据 AI 设置的规则对环境做出响应。通过这样做,AI 可以根据玩家和其他外部因素做出智能决策,而无需实际玩家明确告诉它这样做。多个相同的 AI pawn 实例可以共享相同的 AI 控制器,并且相同的 AI 控制器可以用于不同的 AI pawn 类。像虚幻引擎 4 中的所有角色一样,AI 是通过UWorld
类生成的。
注意
您将在第十四章“生成玩家投射物”中了解更多关于UWorld
类的信息,但作为参考,请在这里阅读更多:docs.unrealengine.com/en-US/API/Runtime/Engine/Engine/UWorld/index.html
。
玩家控制器和 AI 控制器的最重要的方面是它们将控制的 pawns。让我们更多地了解 AI 控制器如何处理这一点。
自动拥有 AI
像所有控制器一样,AI 控制器必须拥有一个pawn。在 C++中,您可以使用以下函数来拥有一个 pawn:
void AController::Possess(APawn* InPawn)
您还可以使用以下功能取消拥有一个 pawn:
void AController::UnPossess()
还有void AController::OnPossess(APawn* InPawn)
和void AController::OnUnPossess()
函数,分别在调用Possess()
和UnPossess()
函数时调用。
在 AI 方面,特别是在虚幻引擎 4 的背景下,AI Pawns 或 Characters 可以被 AI Controller 占有的方法有两种。让我们看看这些选项:
-
“放置在世界中”:这是您将在此项目中处理 AI 的第一种方法;一旦游戏开始,您将手动将这些敌人角色放置到游戏世界中,AI 将在游戏开始后处理其余部分。
-
“生成”:这是第二种方法,稍微复杂一些,因为它需要一个显式的函数调用,无论是在 C++还是 Blueprint 中,都需要“生成”指定类的实例。
Spawn Actor
方法需要一些参数,包括World
对象和Transform
参数,如Location
和Rotation
,以确保正确生成实例。 -
放置在世界中或生成
:如果您不确定要使用哪种方法,一个安全的选项是放置在世界中或生成
;这样两种方法都受支持。
为了SuperSideScroller
游戏,您将使用Placed In World
选项,因为您将手动放置游戏级别中的 AI。
练习 13.01:实现 AI 控制器
在敌人 pawn 可以执行任何操作之前,它需要被 AI 控制器占有。这也需要在 AI 执行任何逻辑之前发生。这个练习将在虚幻引擎 4 编辑器中进行。完成这个练习后,您将创建一个 AI 控制器并将其应用于您在上一章中创建的敌人。让我们开始创建 AI 控制器角色。
以下步骤将帮助您完成这个练习:
-
转到
内容浏览器
界面,导航到内容/Enemy
目录。 -
右键单击
Enemy
文件夹,选择新建文件夹
选项。将这个新文件夹命名为AI
。在新的AI
文件夹目录中,右键单击并选择蓝图类
选项。 -
从
选择父类
对话框中,展开所有类
并手动搜索AIController
类。 -
左键单击此类选项,然后左键单击底部的绿色
选择
选项以从此类创建一个新的蓝图
。请参考以下截图以了解在哪里找到AIController
类。还要注意悬停在类选项上时出现的工具提示;它包含有关开发人员的有用信息:
图 13.1:在选择父类对话框中找到的 AIController 资产类
- 创建了这个新的
AIController 蓝图
后,将此资产命名为BP_AIControllerEnemy
。
AI 控制器已创建并命名,现在是将此资产分配给您在上一章中创建的第一个敌人蓝图的时候了。
-
直接导航到
/Enemy/Blueprints
,找到BP_Enemy
。双击打开此蓝图。 -
在第一个敌人
蓝图
的详细信息
面板中,有一个标有Pawn
的部分。这是您可以设置关于Pawn
或Character
的 AI 功能的不同参数的地方。 -
AI 控制器类
参数确定了要为此敌人使用哪个 AI 控制器,左键单击下拉菜单以查找并选择您之前创建的 AI 控制器;即BP_AIController_Enemy
。
完成这个练习后,敌人 AI 现在知道要使用哪个 AI 控制器。这是至关重要的,因为在 AI 控制器中,AI 将使用并执行您将在本章后面创建的行为树。
AI 控制器现在已分配给敌人,这意味着您几乎可以开始为这个 AI 开发实际的智能了。在这样做之前,还有一个重要的话题需要讨论,那就是导航网格。
导航网格
任何 AI 的最关键方面之一,尤其是在视频游戏中,就是以复杂的方式导航环境。在虚幻引擎 4 中,引擎有一种方法告诉 AI 哪些环境部分是可导航的,哪些部分不是。这是通过导航网格或Nav Mesh来实现的。
这里的 Mesh 一词有误导性,因为它是通过编辑器中的一个体积来实现的。我们需要在我们的级别中有一个导航网格,这样我们的 AI 才能有效地导航游戏世界的可玩范围。我们将在下面的练习中一起添加一个。
虚幻引擎 4 还支持动态导航网格
,允许导航网格在动态对象在环境中移动时实时更新。这导致 AI 能够识别环境中的这些变化,并相应地更新它们的路径/导航。本书不会涵盖这一点,但您可以通过项目设置 -> 导航网格 -> 运行时生成
访问配置选项。
练习 13.02:为 AI 敌人实现导航网格体积
在这个练习中,您将向SideScrollerExampleMap
添加一个导航网格,并探索在虚幻引擎 4 中导航网格的工作原理。您还将学习如何为游戏的需求参数化这个体积。这个练习将在虚幻引擎 4 编辑器中进行。
通过本练习,您将更加了解导航网格。您还将能够在接下来的活动中在自己的关卡中实现这个体积。让我们开始向关卡添加导航网格体积。
以下步骤将帮助您完成这个练习:
-
如果您尚未打开地图,请通过导航到
文件
并左键单击打开级别
选项来打开SideScrollerExampleMap
。从打开级别
对话框,导航到/SideScrollerCPP/Maps
找到SideScrollerExampleMap
。用左键单击选择此地图,然后在底部左键单击打开
以打开地图。 -
打开地图后,导航到右侧找到
模式
面板。模式
面板是一组易于访问的角色类型,如体积
、灯光
、几何
等。在体积
类别下,您会找到Nav Mesh Bounds Volume
选项。 -
左键单击并将此体积拖入地图/场景中。默认情况下,您将在编辑器中看到体积的轮廓。按
P
键可可视化体积所包含的导航
区域,但请确保体积与地面几何相交,以便看到绿色可视化,如下面的屏幕截图所示:
图 13.2:引擎和 AI 感知为可导航的区域轮廓
有了Nav Mesh
体积后,让我们调整它的形状,使体积延伸到整个关卡区域。之后,您将学习如何调整Nav Mesh
体积的参数以适应游戏的目的。
- 左键单击选择
NavMeshBoundsVolume
并导航到其详细信息
面板。有一个标有刷设置
的部分,允许您调整体积的形状和大小。找到最适合您的值。一些建议的设置是刷类型:添加
,刷形状:盒子
,X:3000.0
,Y:3000.0
和Z:3000.0
。
注意,当NavMeshBoundsVolume
的形状和尺寸发生变化时,Nav Mesh
将调整并重新计算可导航区域。这可以在下面的屏幕截图中看到。您还会注意到上层平台是不可导航的;您稍后会修复这个问题。
图 13.3:现在,NavMeshBoundsVolume 延伸到整个可播放区域的示例地图
通过完成这个练习,您已经将第一个NavMeshBoundsVolume
角色放入了游戏世界,并使用调试键'P'
可视化了默认地图中的可导航区域。接下来,您将学习更多关于RecastNavMesh
角色的知识,当将NavMeshBoundsVolume
放入关卡时,也会创建这个角色。
重塑导航网格
当您添加NavMeshBoundsVolume
时,您可能已经注意到另一个角色被自动创建:一个名为RecastNavMesh-Default
的RecastNavMesh
角色。这个RecastNavMesh
充当了导航网格的“大脑”,因为它包含了调整导航网格所需的参数,直接影响 AI 在给定区域的导航。
以下截图显示了此资产,从 World Outliner
选项卡中看到:
图 13.4:从世界大纲器选项卡中看到的 RecastNavMesh actor
注意
RecastNavMesh
中存在许多参数,我们只会在本书中涵盖重要的参数。有关更多信息,请查看 docs.unrealengine.com/en-US/API/Runtime/NavigationSystem/NavMesh/ARecastNavMesh/index.html
。
现在只有两个对您重要的主要部分:
-
Display
:Display
部分,顾名思义,只包含影响NavMeshBoundsVolume
生成的可导航区域的可视化调试显示的参数。建议您尝试切换此类别下的每个参数,以查看它如何影响生成的 Nav Mesh 的显示。 -
Generation
:Generation
类别包含一组值,作为 Nav Mesh 生成和确定哪些几何区域是可导航的,哪些不可导航的规则集。这里有很多选项,这可能使概念非常令人生畏,但让我们只讨论这个类别下的一些参数:
-
Cell Size
指的是 Nav Mesh 在区域内生成可导航空间的精度。您将在本练习的下一步中更新此值,因此您将看到这如何实时影响可导航区域。 -
Agent Radius
指的是将要在该区域导航的角色的半径。在您的游戏中,这里设置的半径是具有最大半径的角色的碰撞组件的半径。 -
Agent Height
指的是将要在该区域导航的角色的高度。在您的游戏中,这里设置的高度是具有最大 Half Height 的角色的碰撞组件的一半高度。您可以将其乘以2.0f
来获得完整的高度。 -
Agent Max Slope
指的是游戏世界中可以存在的斜坡的坡度角度。默认情况下,该值为44
度,这是一个参数,除非您的游戏需要更改,否则您将不会更改。 -
Agent Max Step Height
指的是 AI 可以导航的台阶的高度,关于楼梯台阶。与Agent Max Slope
类似,这是一个参数,除非您的游戏明确需要更改此值,否则您很可能不会更改。
现在您已经了解了 Recast Nav Mesh 参数,让我们将这些知识付诸实践,进行下一个练习,其中将指导您更改其中一些参数。
练习 13.03:重新设置 Nav Mesh 体积参数
现在您在关卡中有了 Nav Mesh
体积,是时候改变 Recast Nav Mesh
actor 的参数,以便 Nav Mesh 允许敌人 AI 在比其他平台更薄的平台上导航。这个练习将在虚幻引擎 4 编辑器中进行。
以下步骤将帮助您完成这个练习:
- 您将更新
Cell Size
和Agent Height
,使其适应您的角色的需求和 Nav Mesh 所需的精度:
Cell Size: 5.0f
Agent Height: 192.0f
以下截图显示了由于我们对 Cell Size
进行的更改,上层平台现在是可导航的:
图 13.5:将 Cell Size 从 19.0f 更改为 5.0f,使狭窄的上层平台可导航
通过为 SuperSideScrollerExampleMap
设置自己的 Nav Mesh
,您现在可以继续并为敌人创建 AI 逻辑。在这样做之前,完成以下活动,创建您自己的关卡,具有独特的布局和 NavMeshBoundsVolume
actor,您可以在本项目的其余部分中使用。
活动 13.01:创建新级别
现在你已经在示例地图中添加了NavMeshBoundsVolume
,是时候为Super SideScroller
游戏的其余部分创建你自己的地图了。通过创建自己的地图,你将更好地理解NavMeshBoundsVolume
和RecastNavMesh
的属性如何影响它们所放置的环境。
注意
在继续解决这个活动之前,如果你需要一个可以用于SuperSideScroller
游戏剩余章节的示例级别,那就不用担心了——本章附带了SuperSideScroller.umap
资源,以及一个名为SuperSideScroller_NoNavMesh
的地图,不包含NavMeshBoundsVolume
。你可以使用SuperSideScroller.umap
作为创建自己级别的参考,或者获取如何改进自己级别的想法。你可以在这里下载地图:packt.live/3lo7v2f
。
执行以下步骤创建一个简单的地图:
-
创建一个
新级别
。 -
将这个级别命名为
SuperSideScroller
。 -
使用该项目的
内容浏览器
界面中默认提供的静态网格资源,创建一个有不同高度的有趣空间以导航。将你的玩家角色Blueprint
添加到级别中,并确保它由Player Controller 0
控制。 -
将
NavMeshBoundsVolume
actor 添加到你的级别中,并调整其尺寸,使其适应你创建的空间。在为这个活动提供的示例地图中,设置的尺寸应分别为1000.0
、5000.0
和2000.0
,分别对应X、Y和Z轴。 -
确保通过按下
P
键启用NavMeshBoundsVolume
的调试可视化。 -
调整
RecastNavMesh
actor 的参数,使NavMeshBoundsVolume
在你的级别中运行良好。在提供的示例地图中,Cell Size
参数设置为5.0f
,Agent Radius
设置为42.0f
,Agent Height
设置为192.0f
。使用这些值作为参考。
预期输出:
图 13.6:SuperSideScroller 地图
通过这个活动的结束,你将拥有一个包含所需的NavMeshBoundsVolume
和RecastNavMesh
actor 设置的级别。这将允许我们在接下来的练习中开发的 AI 能够正确运行。再次强调,如果你不确定级别应该是什么样子,请参考提供的示例地图SuperSideScroller.umap
。现在,是时候开始开发SuperSideScroller
游戏的 AI 了。
注意
这个活动的解决方案可以在以下网址找到:packt.live/338jEBx
。
行为树和黑板
行为树和黑板共同工作,允许我们的 AI 遵循不同的逻辑路径,并根据各种条件和变量做出决策。
行为树(BT)是一种可视化脚本工具,允许你根据特定因素和参数告诉一个角色该做什么。例如,一个行为树可以告诉一个 AI 根据 AI 是否能看到玩家而移动到某个位置。
为了举例说明行为树和黑板在游戏中的使用,让我们看看使用虚幻引擎 4 开发的游戏战争机器 5。战争机器 5 中的 AI,以及整个战争机器系列,总是试图包抄玩家,或者迫使玩家离开掩体。为了做到这一点,AI 逻辑的一个关键组成部分是知道玩家是谁,以及玩家在哪里。在黑板中存在一个对玩家的引用变量,以及一个用于存储玩家位置的位置向量。确定这些变量如何使用以及 AI 将如何使用这些信息的逻辑是在行为树中执行的。
黑板是你定义的一组变量,这些变量是行为树执行动作和使用这些值进行决策所需的。
行为树是您创建希望 AI 执行的任务的地方,例如移动到某个位置,或执行您创建的自定义任务。与 Unreal Engine 4 中的许多编辑工具一样,行为树在很大程度上是一种非常视觉化的脚本体验。
黑板是您定义变量的地方,也称为键,然后行为树将引用这些变量。您在这里创建的键可以在任务、服务和装饰器中使用,以根据您希望 AI 如何运行来实现不同的目的。以下截图显示了一个示例变量键集,可以被其关联的行为树引用。
没有黑板,行为树将无法在不同的任务、服务或装饰器之间传递和存储信息,因此变得无用。
图 13.7:黑板中的一组变量示例,可以在行为树中访问
行为树由一组对象组成 - 即复合体、任务、装饰器和服务 - 它们共同定义了 AI 根据您设置的条件和逻辑流动来行为和响应的方式。所有行为树都始于所谓的根,逻辑流从这里开始;这不能被修改,只有一个执行分支。让我们更详细地看看这些对象:
复合体
复合节点的功能是告诉行为树如何执行任务和其他操作。以下截图显示了 Unreal Engine 默认提供的所有复合节点的完整列表:选择器、序列和简单并行。
复合节点也可以附加装饰器和服务,以便在执行行为树分支之前应用可选条件:
图 13.8:复合节点的完整列表 - 选择器、序列和简单并行
选择器
:选择器复合节点从左到右执行其子节点,并且当其中一个子任务成功时将停止执行。使用以下截图中显示的示例,如果FinishWithResult
任务成功,父选择器成功,这将导致根再次执行,并且FinishWithResult
再次执行。这种模式将持续到FinishWithResult
失败。然后选择器将执行MakeNoise
。如果MakeNoise
失败,选择器
失败,根将再次执行。如果MakeNoise
任务成功,那么选择器将成功,根将再次执行。根据行为树的流程,如果选择器失败或成功,下一个复合分支将开始执行。在以下截图中,没有其他复合节点,因此如果选择器失败或成功,根节点将再次执行。但是,如果有一个序列复合节点,并且其下有多个选择器节点,每个选择器将尝试按顺序执行其子节点。无论成功与否,每个选择器都将依次执行:
图 13.9:选择器复合节点在行为树中的使用示例
请注意,当添加任务和复合
节点时,您会注意到每个节点的右上角有数字值。这些数字表示这些节点将被执行的顺序。模式遵循从上到下,从左到右的范式,这些值可以帮助您跟踪顺序。任何未连接的任务或复合
节点将被赋予值-1
,以表示未使用。
序列
:序列
组合节点从左到右执行其子节点,并且当其中一个子任务失败时将停止执行。使用下面截图中显示的示例,如果移动到
任务成功,那么父序列
节点将执行等待
任务。如果等待
任务成功,那么序列成功,根
将再次执行。然而,如果移动到
任务失败,序列将失败,根
将再次执行,导致等待
任务永远不会执行:
图 13.10:序列组合节点在行为树中的使用示例
简单并行
:简单并行
组合节点允许您同时执行任务
和一个新的独立逻辑分支。下面的截图显示了这将是什么样子的一个非常基本的示例。在这个示例中,用于等待5
秒的任务与执行一系列新任务的序列
同时执行:
图 13.11:选择器组合节点在行为树中的使用示例
简单并行
组合节点也是唯一在其详细信息
面板中具有参数的组合
节点,即完成模式
。有两个选项:
-
立即
:当设置为立即
时,简单并行将在主任务完成后立即成功完成。在这种情况下,等待
任务完成后,后台树序列将中止,整个简单并行
将再次执行。 -
延迟
:当设置为延迟
时,简单并行将在后台树完成执行并且任务完成后立即成功完成。在这种情况下,等待
任务将在5
秒后完成,但整个简单并行
将等待移动到
和播放声音
任务执行后再重新开始。
任务
这些是我们的 AI 可以执行的任务。虚幻引擎默认提供了内置任务供我们使用,但我们也可以在蓝图和 C++中创建自己的任务。这包括任务,如告诉我们的 AI移动到
特定位置,旋转到一个方向
,甚至告诉 AI 开火。还要知道,您可以使用蓝图创建自定义任务。让我们简要讨论一下您将用来开发敌人角色 AI 的两个任务:`
-
移动到任务
:这是行为树中常用的任务之一,在本章的后续练习中将使用此任务。移动到任务
使用导航系统告诉 AI 如何移动以及移动的位置。您将使用此任务告诉 AI 敌人要去哪里。 -
等待任务
:这是行为树中另一个常用的任务,因为它允许在任务执行之间延迟。这可以用于允许 AI 在移动到新位置之前等待几秒钟。
装饰器
装饰器
是可以添加到任务或组合
节点(如序列
或选择器
)的条件,允许分支逻辑发生。例如,我们可以有一个装饰器
来检查敌人是否知道玩家的位置。如果是,我们可以告诉敌人朝着上次已知的位置移动。如果不是,我们可以告诉我们的 AI 生成一个新位置并移动到那里。还要知道,您可以使用蓝图创建自定义装饰器。
让我们简要讨论一下您将用来开发敌人角色 AI 的装饰器——在位置
装饰器。这确定了受控棋子是否在装饰器本身指定的位置。这对您很有用,可以确保行为树在您知道 AI 已到达给定位置之前不执行。
服务
Services
与Decorators
非常相似,因为它们可以与Tasks
和Composite
节点链接。主要区别在于Service
允许我们根据服务中定义的间隔执行一系列节点。还要知道,您可以使用蓝图创建自定义服务。
练习 13.04:创建 AI 行为树和黑板
现在您已经对行为树和黑板有了概述,这个练习将指导您创建这些资产,告诉 AI 控制器使用您创建的行为树,并将黑板分配给行为树。您在这里创建的黑板和行为树资产将用于SuperSideScroller
游戏。此练习将在虚幻引擎 4 编辑器中执行。
以下步骤将帮助您完成此练习:
-
在
Content Browser
界面中,导航到/Enemy/AI
目录。这是您创建 AI 控制器的相同目录。 -
在此目录中,在
Content Browser
界面的空白区域右键单击,导航到Artificial Intelligence
选项,并选择Behavior Tree
以创建Behavior Tree
资产。将此资产命名为BT_EnemyAI
。 -
在上一步的相同目录中,在
Content Browser
界面的空白区域再次右键单击,导航到Artificial Intelligence
选项,并选择Blackboard
以创建Blackboard
资产。将此资产命名为BB_EnemyAI
。
在继续告诉 AI 控制器运行这个新行为树之前,让我们首先将黑板分配给这个行为树,以便它们正确连接。
-
通过双击
Content Browser
界面中的资产打开BT_EnemyAI
。一旦打开,导航到右侧的Details
面板,并找到Blackboard Asset
参数。 -
单击此参数上的下拉菜单,并找到您之前创建的
BB_EnemyAI
Blackboard
资产。在关闭之前编译和保存行为树。 -
接下来,通过双击
Content Browser
界面内的 AI 控制器BP_AIController_Enemy
资产来打开它。在控制器内,右键单击并搜索Run Behavior Tree
函数。
Run Behavior Tree
函数非常简单:您将行为树分配给控制器,函数返回行为树是否成功开始执行。
- 最后,将
Event BeginPlay
事件节点连接到Run Behavior Tree
函数的执行引脚,并分配Behavior Tree
资产BT_EnemyAI
,这是您在此练习中创建的:
。
图 13.12:分配 BT_EnemyAI 行为树
完成此练习后,敌人 AI 控制器现在知道运行BT_EnemyAI
行为树,并且此行为树知道使用名为BB_EnemyAI
的黑板资产。有了这一点,您可以开始使用行为树逻辑来开发 AI,以便敌人角色可以在级别中移动。
练习 13.05:创建新的行为树任务
此练习的目标是为敌人 AI 开发一个 AI 任务,使角色能够在您级别的Nav Mesh
体积范围内找到一个随机点进行移动。
尽管SuperSideScroller
游戏只允许二维移动,让我们让 AI 在您在Activity 13.01中创建的级别的三维空间中移动,然后努力将敌人限制在二维空间内。
按照以下步骤为敌人创建新的任务:
-
首先,打开您在上一个练习中创建的黑板资产
BB_EnemyAI
。 -
在
Blackboard
的左上方左键单击New Key
选项,并选择Vector
选项。将此向量命名为MoveToLocation
。您将使用此vector
变量来跟踪 AI 的下一个移动位置。
为了这个敌方 AI 的目的,你需要创建一个新的“任务”,因为目前在虚幻中可用的任务不符合敌方行为的需求。
-
导航到并打开你在上一个练习中创建的“行为树”资产,
BT_EnemyAI
。随机点选择的 -
在顶部工具栏上左键单击“新建任务”选项。创建新的“任务”时,它会自动为你打开任务资产。但是,如果你已经创建了一个任务,在选择“新建任务”选项时会出现一个下拉选项列表。在处理这个“任务”的逻辑之前,你需要重命名资产。
-
关闭“任务”资产窗口,导航到
/Enemy/AI/
,这是“任务”保存的位置。默认情况下,提供的名称是BTTask_BlueprintBase_New
。将此资产重命名为BTTask_FindLocation
。 -
重命名新的“任务”资产后,双击打开“任务编辑器”。新的任务将使它们的蓝图图完全为空,并且不会为你提供任何默认事件来在图中使用。
-
右键单击图中,在上下文敏感搜索中找到“事件接收执行 AI”选项。
-
左键单击“事件接收执行 AI”选项,在“任务”图中创建事件节点,如下截图所示:
图 13.13:事件接收执行 AI 返回所有者控制器和受控角色
注意
“事件接收执行 AI”事件将让你可以访问所有者控制器和受控角色。在接下来的步骤中,你将使用受控角色来完成这个任务。
-
每个“任务”都需要调用“完成执行”函数,以便“行为树”资产知道何时可以继续下一个“任务”或从树上分支出去。在图中右键单击,通过上下文敏感搜索搜索“完成执行”。
-
左键单击上下文敏感搜索中的“完成执行”选项,在你的“任务”蓝图图中创建节点,如下截图所示:
图 13.14:完成执行函数,其中包含一个布尔参数,用于确定任务是否成功
你需要的下一个函数叫做“在可导航半径内获取随机位置”。这个函数,顾名思义,返回可导航区域内定义半径内的随机向量位置。这将允许敌方角色找到随机位置并移动到这些位置。
- 右键单击图中,在上下文敏感搜索中搜索“在可导航半径内获取随机位置”。左键单击“在可导航半径内获取随机位置”选项,将此函数放置在图中。
有了这两个函数,并且准备好了“事件接收执行 AI”,现在是时候为敌方 AI 获取随机位置了。
- 从“事件接收执行 AI”的“受控角色”输出中,通过上下文敏感搜索找到“获取角色位置”函数:
图 13.15:敌方角色的位置将作为随机点选择的原点
- 将“获取角色位置”的向量返回值连接到“获取可导航半径内随机位置”的“原点”向量输入参数,如下截图所示。现在,这个函数将使用敌方 AI 角色的位置作为确定下一个随机点的原点:
图 13.16:现在,敌方角色的位置将被用作随机点向量搜索的原点
- 接下来,您需要告诉
GetRandomLocationInNavigableRadius
函数要检查级别可导航区域中的随机点的“半径”。将此值设置为1000.0f
。
剩下的参数,Nav Data
和Filter Class
,可以保持不变。现在,您正在从GetRandomLocationInNavigableRadius
获取随机位置,您需要能够将此值存储在您在本练习中创建的Blackboard
向量中。
-
要获得对
Blackboard
向量变量的引用,您需要在此Task
内创建一个Blackboard Key Selector
类型的新变量。创建此新变量并命名为NewLocation
。 -
现在,您需要将此变量设置为
Public
变量,以便在行为树中公开。左键单击 “眼睛”图标,使眼睛可见。 -
有了
Blackboard Key Selector
变量准备好后,左键单击 并拖动此变量的Getter
。然后,从此变量中拉出并搜索Set Blackboard Value as Vector
,如下屏幕截图所示:
图 13.17:Set Blackboard Value 有各种不同类型,支持 Blackboard 中可能存在的不同变量
- 将
GetRandomLocationInNavigableRadius
的RandomLocation
输出向量连接到Set Blackboard Value as Vector
的Value
向量输入参数。然后,连接这两个函数节点的执行引脚。结果将如下所示:
图 13.18:现在,Blackboard 向量值被分配了这个新的随机位置
最后,您将使用GetRandomLocationInNavigableRadius
函数的Return Value
布尔输出参数来确定Task
是否成功执行。
- 将布尔输出参数连接到
Finish Execute
函数的Success
输入参数,并连接Set Blackboard Value as Vector
和Finish Execute
函数节点的执行引脚。以下屏幕截图显示了Task
逻辑的最终结果:
图 13.19:任务的最终设置
注
您可以在以下链接找到前面的屏幕截图的完整分辨率,以便更好地查看:packt.live/3lmLyk5
。
通过完成此练习,您已经使用虚幻引擎 4 中的蓝图创建了您的第一个自定义Task
。现在,您有一个任务,可以在级别的Nav Mesh Volume
的可导航边界内找到一个随机位置,使用敌人的 pawn 作为此搜索的起点。在下一个练习中,您将在行为树中实现这个新的Task
,并看到敌人 AI 在您的级别周围移动。
练习 13.06:创建行为树逻辑
本练习的目标是在行为树中实现您在上一个练习中创建的新Task
,以便使敌人 AI 在级别的可导航空间内找到一个随机位置,然后移动到该位置。您将使用Composite
、Task
和Services
节点的组合来实现此行为。本练习将在虚幻引擎 4 编辑器中进行。
以下步骤将帮助您完成此练习:
-
首先,打开您在“Exercise 13.04”中创建的行为树,“Creating the AI Behavior Tree and Blackboard”,即
BT_EnemyAI
。 -
在此“行为树”中,左键单击 并从
Root
节点底部拖动,并从上下文敏感搜索中选择Sequence
节点。结果将是将Root
连接到Sequence
复合节点。 -
接下来,从
Sequence
节点左键单击并拖动以打开上下文敏感菜单。在此菜单中,搜索您在上一个任务中创建的“任务”,即BTTask_FindLocation
。 -
默认情况下,
BTTask_FindLocation
任务应自动将New Location
键选择器变量分配给Blackboard
的MovetoLocation
向量变量。如果没有发生这种情况,您可以在任务的“详细信息”面板中手动分配此选择器。
现在,BTTask_FindLocation
将把NewLocation
选择器分配给Blackboard
的MovetoLocation
向量变量。这意味着从任务返回的随机位置将被分配给Blackboard
变量,并且您可以在其他任务中引用此变量。
现在,您正在查找有效的随机位置并将此位置分配给Blackboard
变量,即MovetoLocation
,您可以使用Move To
任务告诉 AI 移动到此位置。
- 左键单击并从
Sequence
复合节点中拖动。然后,在上下文敏感搜索中找到Move To
任务。您的“行为树”现在将如下所示:
图 13.20:选择随机位置后,移动任务将让 AI 移动到这个新位置
- 默认情况下,
Move To
任务应将MoveToLocation
分配为其Blackboard Key
值。如果没有,请选择任务。在其“详细信息”面板中,您将找到Blackboard Key
参数,您可以在其中分配变量。在“详细信息”面板中,还将“可接受半径”设置为50.0f
。
现在,行为树使用BTTask_FindLocation
自定义任务找到随机位置,并使用MoveTo
任务告诉 AI 移动到该位置。这两个任务通过引用名为MovetoLocation
的Blackboard
向量变量相互通信位置。
这里要做的最后一件事是向Sequence
复合节点添加一个Decorator
,以确保敌人角色在再次执行树以查找并移动到新位置之前不处于随机位置。
-
右键单击
Sequence
的顶部区域,然后选择“添加装饰者”。从下拉菜单中左键单击并选择“在位置”。 -
由于您已经在
Blackboard
中有一个向量参数,Decorator
应自动将MovetoLocation
分配为Blackboard Key
。通过选择Decorator
并确保Blackboard Key
分配给MovetoLocation
来验证这一点。 -
有了装饰者,您已经完成了行为树。最终结果将如下所示:
图 13.21:AI 敌人行为树的最终设置
这个行为树告诉 AI 使用BTTask_FindLocation
找到一个随机位置,并将此位置分配给名为MovetoLocation
的 Blackboard 值。当此任务成功时,行为树将执行MoveTo
任务,该任务将告诉 AI 移动到这个新的随机位置。序列包含一个Decorator
,它确保敌方 AI 在再次执行之前处于MovetoLocation
,就像 AI 的安全网一样。
-
在测试新的 AI 行为之前,确保将
BP_Enemy AI
放入您的级别中,如果之前的练习和活动中没有的话。 -
现在,如果您使用
PIE
或“模拟”,您将看到敌方 AI 在Nav Mesh Volume
内围绕地图奔跑并移动到随机位置:
图 13.22:敌方 AI 现在将从一个位置移动到另一个位置
注意
有些情况下,敌人 AI 不会移动。这可能是由于“在可导航半径内获取随机位置”函数未返回True
引起的。这是一个已知问题,如果发生,请重新启动编辑器并重试。
通过完成这个练习,您已经创建了一个完全功能的行为树,允许敌人 AI 在您的级别的可导航范围内找到并移动到一个随机位置。您在上一个练习中创建的任务允许您找到这个随机点,而“移动到”任务允许 AI 角色朝着这个新位置移动。
由于“序列”组合节点的工作方式,每个任务必须在继续下一个任务之前成功完成,所以首先,敌人成功找到一个随机位置,然后朝着这个位置移动。只有当“移动到”任务完成时,整个行为树才会重新开始并选择一个新的随机位置。
现在,您可以继续进行下一个活动,在这个活动中,您将添加到这个行为树,以便让 AI 在选择新的随机点之间等待,这样敌人就不会不断移动。
活动 13.02:AI 移动到玩家位置
在上一个练习中,您能够让 AI 敌人角色通过使用自定义“任务”和“移动到”任务一起移动到“导航网格体”范围内的随机位置。
在这个活动中,您将继续上一个练习并更新行为树。您将利用“等待”任务使用一个“装饰器”,并创建自己的新自定义任务,让 AI 跟随玩家角色并每隔几秒更新其位置。
以下步骤将帮助您完成这个活动:
-
在您之前创建的
BT_EnemyAI
行为树中,您将继续从上次离开的地方创建一个新任务。通过从工具栏中选择“新任务”并选择BTTask_BlueprintBase
来完成这个任务。将这个新任务命名为BTTask_FindPlayer
。 -
在
BTTask_FindPlayer
任务中,创建一个名为Event Receive Execute AI
的新事件。 -
找到“获取玩家角色”函数,以获取对玩家的引用;确保使用
Player Index 0
。 -
从玩家角色中调用“获取角色位置”函数,以找到玩家当前的位置。
-
在这个任务中创建一个新的黑板键“选择器”变量。将此变量命名为
NewLocation
。 -
左键单击并将
NewLocation
变量拖入图表中。从该变量中,搜索“设置黑板数值”函数为“向量”。 -
将“设置黑板数值”作为“向量”函数连接到事件“接收执行 AI”节点的执行引脚。
-
添加“完成执行”函数,确保布尔值“成功”参数为
True
。 -
最后,将“设置黑板数值”作为“向量”函数连接到“完成执行”函数。
-
保存并编译任务“蓝图”,返回到
BT_EnemyAI
行为树。 -
用新的
BTTask_FindPlayer
任务替换BTTask_FindLocation
任务,使得这个新任务现在是“序列”组合节点下的第一个任务。 -
通过以下自定义
BTTask_FindLocation
和Move To
任务,在“序列”组合节点下方添加一个新的“播放声音”任务作为第三个任务。 -
在“播放声音”参数中,添加
Explosion_Cue SoundCue
资产。 -
在“播放声音”任务中添加一个“是否在位置”装饰器,并确保将“移动到位置”键分配给该装饰器。
-
在“序列”组合节点下方添加一个新的“等待”任务作为第四个任务,跟随“播放声音”任务。
-
将“等待”任务设置为等待
2.0f
秒后成功完成。
预期输出如下:
图 13.23:敌人 AI 跟随玩家并每 2 秒更新一次玩家位置
敌方 AI 角色将移动到关卡中可导航空间内玩家的最后已知位置,并在每个玩家位置之间暂停2.0f
秒。
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
完成此活动后,您已经学会了创建一个新的任务,使 AI 能够找到玩家位置并移动到玩家的最后已知位置。在进行下一组练习之前,删除PlaySound
任务,并用您在Exercise 13.05中创建的BTTask_FindLocation
任务替换BTTask_FindPlayer
任务。请参考Exercise 13.05,Creating a New Behavior Tree Task和Exercise 13.06,Creating the Behavior Tree Logic,以确保行为树正确返回。您将在即将进行的练习中使用BTTask_FindLocation
任务。
在下一个练习中,您将通过开发一个新的Blueprint
角色来解决这个问题,这将允许您设置 AI 可以朝向的特定位置。
练习 13.07:创建敌方巡逻位置
目前 AI 敌人角色的问题在于它们可以在 3D 可导航空间中自由移动,因为行为树允许它们在该空间内找到一个随机位置。相反,AI 需要被给予您可以在编辑器中指定和更改的巡逻点。然后它将随机选择其中一个巡逻点进行移动。这就是您将为SuperSideScroller
游戏做的事情:创建敌方 AI 可以移动到的巡逻点。本练习将向您展示如何使用简单的Blueprint角色创建这些巡逻点。本练习将在 Unreal Engine 4 编辑器中执行。
以下步骤将帮助您完成此练习:
-
首先,导航到
/Enemy/Blueprints/
目录。这是您将创建用于 AI 巡逻点的新Blueprint
角色的位置。 -
在此目录中,右键单击并选择
Blueprint Class
选项,然后从菜单中左键单击此选项。 -
从
Pick Parent Class
菜单提示中,左键单击Actor
选项,创建一个基于Actor
类的新Blueprint
:
图 13.24:Actor 类是可以放置或生成在游戏世界中的所有对象的基类
- 将此新资产命名为
BP_AIPoints
,并通过在Content Browser
界面中双击资产来打开此Blueprint
。
注意
Blueprints
的界面与其他系统(如Animation Blueprints
和Tasks
)共享许多相同的功能和布局,因此这些都应该对您来说很熟悉。
-
在蓝图 UI 左侧的
Variables
选项卡中导航,左键单击+Variable
按钮。将此变量命名为Points
。 -
从
Variable Type
下拉菜单中,左键单击并选择Vector
选项。 -
接下来,您需要将这个向量变量设置为
Array
,以便可以存储多个巡逻位置。左键单击Vector
旁边的黄色图标,然后左键单击选择Array
选项。 -
设置
Points
向量变量的最后一步是启用Instance Editable
和Show 3D Widget
:
-
Instance Editable
参数允许此向量变量在放置在级别中的角色上公开可见,使得每个此角色的实例都可以编辑此变量。 -
Show 3D Widget
允许您使用编辑器视口中可见的 3D 变换小部件来定位向量值。您将在本练习的后续步骤中看到这意味着什么。还需要注意的是,Show 3D Widget
选项仅适用于涉及演员变换的变量,例如Vectors
和Transforms
。
简单的角色设置完成后,现在是将角色放置到关卡中并开始设置巡逻点位置的时候了。
- 将
BP_AIPoints
actor 蓝图添加到您的级别中,如下所示:
图 13.25:BP_AIPoints actor 现在在级别中
-
选择
BP_AIPoints
actor,导航到其Details
面板,并找到Points
变量。 -
接下来,您可以通过左键单击
+
符号向向量数组添加新元素,如下所示:
图 13.26:数组中可以有许多元素,但数组越大,分配的内存就越多
- 当您向向量数组添加新元素时,将会出现一个 3D 小部件,您可以左键单击以选择并在级别中移动,如下所示:
图 13.27:第一个巡逻点向量位置
注意
当您更新代表向量数组元素的 3D 小部件的位置时,Details
面板中的 3D 坐标将更新为Points
变量。
- 最后,将尽可能多的元素添加到向量数组中,以适应您级别的上下文。请记住,这些巡逻点的位置应该对齐,使它们沿水平轴成一条直线,与角色移动的方向平行。以下屏幕截图显示了本练习中包含的示例
SideScroller.umap
级别中的设置:
图 13.28:示例巡逻点路径,如在 SideScroller.umap 示例级别中所见
- 继续重复最后一步,创建多个巡逻点并根据需要放置 3D 小部件。您可以使用提供的
SideScroller.umap
示例级别作为设置这些巡逻点
的参考。
通过完成这个练习,您已经创建了一个包含Vector
位置数组的新Actor
蓝图,现在可以使用编辑器中的 3D 小部件手动设置这些位置。通过手动设置巡逻点位置的能力,您可以完全控制 AI 可以移动到的位置,但是有一个问题。目前还没有功能来从这个数组中选择一个点并将其传递给行为树,以便 AI 可以在这些巡逻点之间移动。在设置这个功能之前,让我们先了解更多关于向量和向量变换的知识,因为这些知识将在下一个练习中证明有用。
向量变换
在进行下一个练习之前,重要的是您了解一下向量变换,更重要的是了解Transform Location
函数的作用。当涉及到角色的位置时,有两种思考其位置的方式:世界空间和本地空间。角色在世界空间中的位置是相对于世界本身的位置;更简单地说,这是您将实际角色放置到级别中的位置。角色的本地位置是相对于自身或父级角色的位置。
让我们以BP_AIPoints
actor 作为世界空间和本地空间的示例。Points
数组的每个位置都是本地空间向量,因为它们是相对于BP_AIPoints
actor 本身的世界空间位置的位置。以下屏幕截图显示了Points
数组中的向量列表,如前面的练习所示。这些值是相对于您级别中BP_AIPoints
actor 的位置的位置:
图 13.29:相对于 BP_AIPoints actor 的世界空间位置,Points 数组的本地空间位置向量
为了使敌人 AI 移动到这些Points
的正确世界空间位置,您需要使用一个名为Transform Location
的函数。这个函数接受两个参数:
-
T
:这是您用来将向量位置参数从局部空间转换为世界空间值的提供的Transform
。 -
位置
:这是要从局部空间转换为世界空间的位置
。
然后将向量转换的结果作为函数的返回值。您将在下一个练习中使用此函数,从Points
数组中返回一个随机选择的向量点,并将该值从局部空间向量转换为世界空间向量。然后,将使用这个新的世界空间向量来告诉敌人 AI 在世界中如何移动。让我们现在实现这个。
练习 13.08:在数组中选择一个随机点
现在您对向量和向量转换有了更多的了解,您可以继续进行这个练习,在这个练习中,您将创建一个简单的蓝图
函数,选择一个巡逻点向量位置中的一个,并使用名为Transform Location
的内置函数将其向量从局部空间值转换为世界空间值。通过返回向量位置的世界空间值,然后将这个值传递给行为树,使得 AI 将移动到正确的位置。这个练习将在虚幻引擎 4 编辑器中进行。
以下步骤将帮助您完成这个练习。让我们从创建新函数开始:
-
导航回
BP_AIPoints
蓝图,并通过左键单击蓝图编辑器左侧的函数
类别旁边的+
按钮来创建一个新函数。将此函数命名为GetNextPoint
。 -
在为这个函数添加逻辑之前,通过左键单击
函数
类别下的函数来选择此函数,以访问其详细信息
面板。 -
在“详细信息”面板中,启用
Pure
参数,以便将此函数标记为“纯函数”。在第十一章中,混合空间 1D,键绑定和状态机中,当在玩家角色的动画蓝图中工作时,您了解了“纯函数”;在这里也是一样的。 -
接下来,
GetNextPoint
函数需要返回一个向量,行为树可以用来告诉敌人 AI 要移动到哪里。通过左键单击详细信息
函数类别下的+
符号来添加这个新的输出。将变量类型设置为Vector
,并将其命名为NextPoint
,如下面的屏幕截图所示:
图 13.30:函数可以返回不同类型的多个变量,根据您的逻辑需求
- 在添加
输出
变量时,函数将自动生成一个Return
节点并将其放入函数图中,如下面的屏幕截图所示。您将使用这个输出来返回敌人 AI 移动到的新向量巡逻点:
图 13.31:函数的自动生成返回节点,包括 NewPoint 向量输出变量
现在函数的基础工作已经完成,让我们开始添加逻辑。
- 为了选择一个随机位置,首先需要找到
Points
数组的长度。创建Points
向量的Getter
,从这个向量变量中左键单击并拖动以搜索Length
函数,如下面的屏幕截图所示:
图 13.32:Length 函数是一个纯函数,返回数组的长度
- 使用
Length
函数的整数输出,左键单击并拖动以使用上下文敏感搜索找到Random Integer
函数,如下截图所示。Random Integer
函数返回一个在0
和最大值
之间的随机整数;在这种情况下,这是Points
向量数组的Length
:
图 13.33:使用随机整数将允许函数从Points
向量数组中返回一个随机向量
到目前为止,你正在生成一个在Points
向量数组的长度之间的随机整数。接下来,你需要找到返回的Random Integer
的索引位置处Points
向量数组的元素。
-
通过创建一个新的
Points
向量数组的Getter
。然后,左键单击并拖动以搜索Get (a copy)
函数。 -
接下来,将
Random Integer
函数的返回值连接到Get (a copy)
函数的输入。这将告诉函数选择一个随机整数,并使用该整数作为要从Points
向量数组返回的索引。
现在你从Points
向量数组中获取了一个随机向量,你需要使用Transform Location
函数将位置从局部空间转换为世界空间向量。
正如你已经学到的那样,Points
数组中的向量是相对于关卡中BP_AIPoints
角色位置的局部空间位置。因此,你需要使用Transform Location
函数将随机选择的局部空间向量转换为世界空间向量,以便 AI 敌人移动到正确的位置。
-
左键单击并从
Get (a copy)
函数的向量输出处拖动,并通过上下文敏感搜索,找到Transform Location
函数。 -
将
Get (a copy)
函数的向量输出连接到Transform Location
函数的Location
输入。 -
最后一步是使用蓝图角色本身的变换作为
Transform Location
函数的T
参数。通过右键单击图表并通过上下文敏感搜索,找到GetActorTransform
函数并将其连接到Transform Location
参数T
。 -
最后,将
Transform Location
函数的Return Value
向量连接到函数的NewPoint
向量输出:
图 13.34:GetNextPoint
函数的最终逻辑设置
注意
你可以在以下链接找到前面的截图的全分辨率以便更好地查看:packt.live/35jlilb
。
通过完成这个练习,你在BP_AIPoints
角色内创建了一个新的蓝图函数,该函数从Points
数组变量中获取一个随机索引,使用Transform Location
函数将其转换为世界空间向量值,并返回这个新的向量值。你将在 AI 行为树中的BTTask_FindLocation
任务中使用这个函数,以便敌人移动到你设置的其中一个点。在你这样做之前,敌人 AI 需要一个对BP_AIPoints
角色的引用,以便它知道可以从哪些点中选择并移动。我们将在下一个练习中完成这个任务。
练习 13.09:引用巡逻点角色
现在BP_AIPoints
角色有一个从其向量巡逻点数组中返回随机转换位置的函数,你需要让敌人 AI 在关卡中引用这个角色,以便它知道要引用哪些巡逻点。为此,你将在敌人角色蓝图中添加一个新的Object Reference
变量,并分配之前放置在关卡中的BP_AIPoints
角色。这个练习将在虚幻引擎 4 编辑器中进行。让我们开始添加Object Reference。
注意
对象引用变量
存储对特定类对象或演员的引用。有了这个引用变量,您可以访问此类可用的公开变量、事件和函数。
以下步骤将帮助您完成此练习:
-
导航到
/Enemy/Blueprints/
目录,并通过双击内容浏览器
界面中的资产打开敌人角色蓝图BP_Enemy
。 -
创建一个
BP_AIPoints
类型的新变量,并确保变量类型为对象引用
。 -
为了引用级别中现有的
BP_AIPoints
演员,您需要通过启用实例可编辑
参数使上一步的变量成为公共变量
。将此变量命名为巡逻点
。 -
现在您已经设置了对象引用,导航到您的级别并选择您的敌人 AI。下面的截图显示了放置在提供的示例级别中的敌人 AI;即
SuperSideScroller.umap
。如果您的级别中没有放置敌人,请立即这样做:
注意
将敌人放置到级别中与 Unreal Engine 4 中的任何其他演员一样。左键单击并从内容浏览器界面将敌人 AI 蓝图拖放到级别中。
图 13.35:敌人 AI 放置在示例级别 SuperSideScroller.umap 中
](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_13_35.jpg)
图 13.35:敌人 AI 放置在示例级别 SuperSideScroller.umap 中
- 从其
详细信息
面板中,在默认
类别下找到巡逻点
变量。这里要做的最后一件事是通过左键单击巡逻点
变量的下拉菜单,并从列表中找到在练习 13.07中已经放置在级别中的BP_AIPoints
演员。
完成此练习后,您的级别中的敌人 AI 现在引用了级别中的BP_AIPoints
演员。有了有效的引用,敌人 AI 可以使用这个演员来确定在BTTask_FindLocation
任务中移动的点集。现在要做的就是更新BTTask_FindLocation
任务,使其使用这些点而不是找到一个随机位置。
练习 13.10:更新 BTTask_FindLocation
完成敌人 AI 巡逻行为的最后一步是替换BTTask_FindLocation
中的逻辑,使其使用BP_AIPoints
演员的GetNextPoint
函数,而不是在级别的可导航空间内查找随机位置。这个练习将在 Unreal Engine 4 编辑器中执行。
作为提醒,在开始之前,回顾一下练习 13.05结束时BTTask_FindLocation
任务的外观。
以下步骤将帮助您完成此练习:
- 首先要做的是从
Event Receive Execute AI
中获取返回的Controlled Pawn
引用,并将其转换为BP_Enemy
,如下截图所示。这样,您就可以访问上一个练习中的巡逻点
对象引用变量:
图 13.36:转换还确保返回的 Controlled Pawn 是 BP_Enemy 类类型
-
接下来,您可以通过左键单击并从
转换为 BP_Enemy
下的As BP Enemy
引脚中拖动,并通过上下文敏感搜索找到巡逻点
对象引用变量。 -
从
巡逻点
引用中,您可以左键单击并拖动以搜索您在练习 13.08中创建的GetNextPoint
函数,选择数组中的随机点。 -
现在,您可以将
GetNextPoint
函数的NextPoint
向量输出参数连接到Set Blackboard Value as Vector
函数,并将执行引脚从转换连接到Set Blackboard Value as Vector
函数。现在,每次执行BTTask_FindLocation
任务时,都会设置一个新的随机巡逻点。 -
最后,将
Set Blackboard Value as Vector
函数连接到Finish Execute
函数,并手动将Success
参数设置为True
,以便如果转换成功,此任务将始终成功。 -
作为备用方案,创建
Finish Execute
的副本并连接到Cast
函数的Cast Failed
执行引脚。然后,将Success
参数设置为False
。这将作为备用方案,以便如果由于任何原因Controlled Pawn
不是BP_Enemy
类,任务将失败。这是一个很好的调试实践,以确保任务对其预期的 AI 类的功能性:
图 13.37:在逻辑中考虑任何转换失败总是一个很好的实践
注意
您可以在以下链接找到前面的截图的全分辨率版本以便更好地查看:packt.live/3n58THA
。
随着BTTask_FindLocation
任务更新为使用敌人中BP_AIPoints
角色引用的随机巡逻点,敌人 AI 现在将在巡逻点之间随机移动。
图 13.38:敌人 AI 现在在关卡中的巡逻点位置之间移动
完成这个练习后,敌人 AI 现在使用对关卡中BP_AIPoints
角色的引用,以找到并移动到关卡中的巡逻点。关卡中的每个敌人角色实例都可以引用另一个唯一实例的BP_AIPoints
角色,也可以共享相同的实例引用。由您决定每个敌人 AI 如何在关卡中移动。
玩家抛射物
在本章的最后一部分,您将专注于创建玩家抛射物的基础,该基础可用于摧毁敌人。目标是创建适当的角色类,引入所需的碰撞和抛射物移动组件到类中,并设置抛射物运动行为的必要参数。
为了简单起见,玩家的抛射物将不使用重力,将在一次命中时摧毁敌人,并且抛射物本身将在撞击任何表面时被摧毁;例如,它不会从墙上弹开。玩家抛射物的主要目标是让玩家可以生成并用来摧毁整个关卡中的敌人的抛射物。在本章中,您将设置基本的框架功能,而在第十四章中,生成玩家抛射物,您将添加声音和视觉效果。让我们开始创建玩家抛射物类。
练习 13.11:创建玩家抛射物
到目前为止,我们一直在虚幻引擎 4 编辑器中工作,创建我们的敌人 AI。对于玩家抛射物,我们将使用 C++和 Visual Studio 来创建这个新类。玩家抛射物将允许玩家摧毁放置在关卡中的敌人。这个抛射物将有一个短暂的寿命,以高速行进,并且将与敌人和环境发生碰撞。
这个练习的目标是为玩家的抛射物设置基础角色类,并开始在抛射物的头文件中概述所需的函数和组件。
以下步骤将帮助您完成这个练习:
- 首先,您需要使用
Actor
类作为玩家抛射物的父类来创建一个新的 C++类。接下来,将这个新的 actor 类命名为PlayerProjectile
,并左键单击菜单提示的底部右侧的Create Class
选项。
创建新类后,Visual Studio 将为该类生成所需的源文件和头文件,并为您打开这些文件。actor 基类包含了一些默认函数,对于玩家抛射物来说是不需要的。
- 在
PlayerProjectile.h
文件中找到以下代码行并删除它们:
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
```
这些代码行代表了默认情况下包含在每个基于 Actor 的类中的`Tick()`和`BeginPlay()`函数的声明。`Tick()`函数在每一帧都会被调用,允许您在每一帧上执行逻辑,这可能会变得昂贵,取决于您要做什么。`BeginPlay()`函数在此 actor 被初始化并开始播放时被调用。这可以用来在 actor 进入世界时立即执行逻辑。这些函数被删除是因为它们对于`Player Projectile`不是必需的,只会使代码混乱。
1. 在`PlayerProjectile.h`头文件中删除这些行后,您还可以从`PlayerProjectile.cpp`源文件中删除以下行:
```cpp
// Called when the game starts or when spawned
void APlayerProjectile::BeginPlay()
{
Super::BeginPlay();
}
// Called every frame
void APlayerProjectile::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
```
这些代码行代表了您在上一步中删除的两个函数的函数实现;也就是说,`Tick()`和`BeginPlay()`。同样,这些被删除是因为它们对于`Player Projectile`没有任何作用,只会给代码增加混乱。此外,如果没有在`PlayerProjectile.h`头文件中声明,您将无法编译这些代码。唯一剩下的函数将是抛射物类的构造函数,您将在下一个练习中用它来初始化抛射物的组件。现在您已经从`PlayerProjectile`类中删除了不必要的代码,让我们添加抛射物所需的函数和组件。
1. 在`PlayerProjectile.h`头文件中,添加以下组件。让我们详细讨论这些组件:
```cpp
public:
//Sphere collision component
UPROPERTY(VisibleDefaultsOnly, Category = Projectile)
class USphereComponent* CollisionComp;
private:
//Projectile movement component
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Movement, meta = (AllowPrivateAccess = "true"))
class UProjectileMovementComponent* ProjectileMovement;
//Static mesh component
UPROPERTY(VisibleDefaultsOnly, Category = Projectile)
class UStaticMeshComponent* MeshComp;
```
在这里,您正在添加三个不同的组件。首先是碰撞组件,您将用它来使抛射物识别与敌人和环境资产的碰撞。接下来的组件是抛射物移动组件,您应该从上一个项目中熟悉它。这将允许抛射物表现得像一个抛射物。最后一个组件是静态网格组件。您将使用它来为这个抛射物提供一个视觉表示,以便在游戏中看到它。
1. 接下来,将以下函数签名代码添加到`PlayerProjectile.h`头文件中,在`public`访问修饰符下:
```cpp
UFUNCTION()
void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);
```
这个最终的事件声明将允许玩家抛射物响应您在上一步中创建的`CollisionComp`组件的`OnHit`事件。
1. 现在,为了使这段代码编译,您需要在`PlayerProjectile.cpp`源文件中实现上一步的函数。添加以下代码:
```cpp
void APlayerProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
}
```
`OnHit`事件为您提供了关于发生的碰撞的大量信息。您将在下一个练习中使用的最重要的参数是`OtherActor`参数。`OtherActor`参数将告诉您此`OnHit`事件响应的 actor。这将允许您知道这个其他 actor 是否是敌人。当抛射物击中它们时,您将使用这些信息来摧毁敌人。
1. 最后,返回虚幻引擎编辑器,*左键单击*`Compile`选项来编译新代码。
完成此练习后,您现在已经为`Player Projectile`类准备好了框架。该类具有`Projectile Movement`、`Collision`和`Static Mesh`所需的组件,以及为`OnHit`碰撞准备的事件签名,以便弹丸可以识别与其他角色的碰撞。
在下一个练习中,您将继续自定义并启用`Player Projectile`的参数,以使其在`SuperSideScroller`项目中按您的需求运行。
## 练习 13.12:初始化玩家投射物设置
现在`PlayerProjectile`类的框架已经就位,是时候更新该类的构造函数,以便为弹丸设置所需的默认设置,使其移动和行为符合您的要求。为此,您需要初始化`Projectile Movement`、`Collision`和`Static Mesh`组件。
以下步骤将帮助您完成此练习:
1. 打开 Visual Studio 并导航到`PlayerProjectile.cpp`源文件。
1. 在构造函数中添加任何代码之前,在`PlayerProjectile.cpp`源文件中包括以下文件:
```cpp
#include "GameFramework/ProjectileMovementComponent.h"
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"
```
这些头文件将允许您初始化和更新弹丸移动组件、球体碰撞组件和静态网格组件的参数。如果不包括这些文件,`PlayerProjectile`类将不知道如何处理这些组件以及如何访问它们的函数和参数。
1. 默认情况下,`APlayerProjectile::APlayerProjectile()`构造函数包括以下行:
```cpp
PrimaryActorTick.bCanEverTick = true;
```
这行代码可以完全删除,因为在玩家投射物中不需要。
1. 在`PlayerProjectile.cpp`源文件中,将以下行添加到`APlayerProjectile::APlayerProjectile()`构造函数中:
```cpp
CollisionComp = CreateDefaultSubobject <USphereComponent>(TEXT("SphereComp"));
CollisionComp->InitSphereRadius(15.0f);
CollisionComp->BodyInstance.SetCollisionProfileName("BlockAll");
CollisionComp->OnComponentHit.AddDynamic(this, &APlayerProjectile::OnHit);
```
第一行初始化了球体碰撞组件,并将其分配给您在上一个练习中创建的`CollisionComp`变量。`Sphere Collision Component`有一个名为`InitSphereRadius`的参数。这将确定碰撞角色的大小或半径,默认情况下,值为`15.0f`效果很好。接下来,将碰撞组件的`Collision Profile Name`设置为`BlockAll`,以便将碰撞配置文件设置为`BlockAll`,这意味着当它与其他对象发生碰撞时,此碰撞组件将响应`OnHit`。最后,您添加的最后一行允许`OnComponentHit`事件使用您在上一个练习中创建的函数进行响应:
```cpp
void APlayerProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
}
```
这意味着当碰撞组件接收到来自碰撞事件的`OnComponentHit`事件时,它将使用该函数进行响应;但是,此函数目前为空。您将在本章后面的部分向此函数添加代码。
1. `Collision Component`的最后一件事是将该组件设置为玩家投射物角色的`root`组件。在构造函数中,在*Step 4*的行之后添加以下代码行:
```cpp
// Set as root component
RootComponent = CollisionComp;
```
1. 碰撞组件设置好并准备好后,让我们继续进行`Projectile Movement`组件。将以下行添加到构造函数中:
```cpp
// Use a ProjectileMovementComponent to govern this projectile's movement
ProjectileMovement = CreateDefaultSubobject<UProjectileMovementComponent>
(TEXT("ProjectileComp")) ;
ProjectileMovement->UpdatedComponent = CollisionComp;
ProjectileMovement->ProjectileGravityScale = 0.0f;
ProjectileMovement->InitialSpeed = 800.0f;
ProjectileMovement->MaxSpeed = 800.0f;
```
第一行初始化了`Projectile Movement Component`并将其分配给你在上一个练习中创建的`ProjectileMovement`变量。接下来,我们将`CollisionComp`设置为投射物移动组件的更新组件。我们这样做的原因是因为`Projectile Movement`组件将使用角色的`root`组件作为移动的组件。然后,你将投射物的重力比例设置为`0.0f`,因为玩家投射物不应受重力影响;其行为应该允许投射物以相同的速度、相同的高度移动,并且不受重力影响。最后,你将`InitialSpeed`和`MaxSpeed`参数都设置为`500.0f`。这将使投射物立即以这个速度开始移动,并在其寿命期间保持这个速度。玩家投射物不支持任何形式的加速运动。
1. 初始化并设置了投射物移动组件后,现在是为`Static Mesh Component`做同样的操作的时候了。在上一步的代码行之后添加以下代码:
```cpp
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComp"));
MeshComp->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
```
第一行初始化了`Static Mesh Component`并将其分配给你在上一个练习中创建的`MeshComp`变量。然后,使用名为`FAttachmentTransformRules`的结构将这个静态网格组件附加到`RootComponent`,以确保`Static Mesh Component`在附加时保持其世界变换,这是这个练习的*步骤 5*中的`CollisionComp`。
注意
你可以在这里找到有关`FAttachmentTransformRules`结构的更多信息:[`docs.unrealengine.com/en-US/API/Runtime/Engine/Engine/FAttachmentTransformRules/index.html`](https://docs.unrealengine.com/en-US/API/Runtime/Engine/Engine/FAttachmentTransformRules/index.html)。
1. 最后,让我们给`Player Projectile`一个初始寿命为`3`秒,这样如果投射物在这段时间内没有与任何物体碰撞,它将自动销毁。在构造函数的末尾添加以下代码:
```cpp
InitialLifeSpan = 3.0f;
```
1. 最后,返回虚幻引擎编辑器,*左键单击*`Compile`选项来编译新代码。
通过完成这个练习,你已经为`Player Projectile`设置了基础工作,以便它可以在编辑器中作为*Blueprint* actor 创建。所有三个必需的组件都已初始化,并包含了你想要的这个投射物的默认参数。现在我们只需要从这个类创建*Blueprint*来在关卡中看到它。
## 活动 13.03:创建玩家投射物蓝图
为了完成本章,你将从新的`PlayerProjectile`类创建`Blueprint` actor,并自定义这个 actor,使其使用一个用于调试目的的`Static Mesh Component`的占位形状。这样可以在游戏世界中查看投射物。然后,你将在`PlayerProjectile.cpp`源文件中的`APlayerProjectile::OnHit`函数中添加一个`UE_LOG()`函数,以确保当投射物与关卡中的物体接触时调用这个函数。你需要执行以下步骤:
1. 在`Content Browser`界面中,在`/MainCharacter`目录中创建一个名为`Projectile`的新文件夹。
1. 在这个目录中,从你在*练习 13.11*中创建的`PlayerProjectile`类创建一个新的蓝图,命名为`BP_PlayerProjectile`。
1. 打开`BP_PlayerProjectile`并导航到它的组件。选择`MeshComp`组件以访问其设置。
1. 将`Shape_Sphere`网格添加到`MeshComp`组件的静态网格参数中。
1. 更新`MeshComp`的变换,使其适应`CollisionComp`组件的比例和位置。使用以下值:
```cpp
Location:(X=0.000000,Y=0.000000,Z=-10.000000)
Scale: (X=0.200000,Y=0.200000,Z=0.200000)
```
1. 编译并保存`BP_PlayerProjectile`蓝图。
1. 在 Visual Studio 中导航到`PlayerProjectile.cpp`源文件,并找到`APlayerProjectile::OnHit`函数。
1. 在函数内部,实现`UE_LOG`调用,以便记录的行是`LogTemp`,`Warning log level`,并显示文本`HIT`。`UE_LOG`在*第十一章*,*Blend Spaces 1D,Key Bindings 和 State Machines*中有所涉及。
1. 编译您的代码更改并导航到您在上一个练习中放置`BP_PlayerProjectile`角色的级别。如果您还没有将此角色添加到级别中,请立即添加。
1. 在测试之前,请确保在`Window`选项中打开`Output Log`。从`Window`下拉菜单中,悬停在`Developers Tools`选项上,*左键单击*以选择`Output Log`。
1. 使用`PIE`并在抛射物与某物发生碰撞时注意`Output Log`中的日志警告。
预期输出如下:
![图 13.39:MeshComp 的比例更适合 Collision Comp 的大小](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_13_39.jpg)
图 13.39:MeshComp 的比例更适合 Collision Comp 的大小
日志警告应如下所示:
![图 13.40:当抛射物击中物体时,在输出日志中显示文本 HIT](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_13_40.jpg)
图 13.40:当抛射物击中物体时,在输出日志中显示文本 HIT
完成这最后一个活动后,`Player Projectile`已准备好进入下一章,在这一章中,当玩家使用`Throw`动作时,您将生成此抛射物。您将更新`APlayerProjectile::OnHit`函数,以便它销毁与之发生碰撞的敌人,并成为玩家用来对抗敌人的有效进攻工具。
注意
此活动的解决方案可在以下网址找到:[`packt.live/338jEBx`](https://packt.live/338jEBx)。
# 总结
在本章中,您学习了如何使用 Unreal Engine 4 提供的 AI 工具的不同方面,包括黑板、行为树和 AI 控制器。通过自定义创建的任务和 Unreal Engine 4 提供的默认任务的组合,并使用装饰器,您能够使敌人 AI 在您自己级别中添加的 Nav Mesh 的范围内导航。
除此之外,您还创建了一个新的蓝图角色,允许您使用`Vector`数组变量添加巡逻点。然后,您为此角色添加了一个新函数,该函数随机选择其中一个点,将其位置从局部空间转换为世界空间,然后返回此新值供敌人角色使用。
通过能够随机选择巡逻点,您更新了自定义的`BTTask_FindLocation`任务,以查找并移动到所选的巡逻点,使敌人能够从每个巡逻点随机移动。这将使敌人 AI 角色与玩家和环境的互动达到一个全新的水平。
最后,您创建了玩家抛射物,玩家将能够使用它来摧毁环境中的敌人。您利用了`Projectile Movement Component`和`Sphere Component`,以允许抛射物移动并识别和响应环境中的碰撞。
随着玩家抛射物处于功能状态,现在是时候进入下一章了,在这一章中,您将使用`Anim Notifies`在玩家使用`Throw`动作时生成抛射物。
# 第十三章:生成玩家投射物
概述
在本章中,你将学习`Anim Notifies`和`Anim States`,这些可以在动画蒙太奇中找到。你将使用 C++编写自己的`Anim Notify`,并在`Throw`动画蒙太奇中实现此通知。最后,你将学习视觉和音频效果,以及这些效果在游戏中的使用。
在本章结束时,你将能够在蓝图和 C++中播放动画蒙太奇,并知道如何使用 C++和`UWorld`类将对象生成到游戏世界中。这些游戏元素将被赋予音频和视觉组件作为额外的精细层,并且你的`SuperSideScroller`玩家角色将能够投掷摧毁敌人的投射物。
# 介绍
在上一章中,通过创建一个行为树,使敌人可以从你创建的`BP_AIPoints`角色中随机选择点,你在敌人角色的 AI 方面取得了很大的进展。这使得`SuperSideScroller`游戏更加生动,因为现在你可以在游戏世界中有多个敌人移动。此外,你还学会了虚幻引擎 4 中一些可用于制作各种复杂程度的人工智能的不同工具。这些工具包括`导航网格`、行为树和黑板。
现在你的游戏中有敌人在四处奔跑,你需要允许玩家用上一章末开始创建的玩家投射物来击败这些敌人。
在本章中,你将学习如何使用`UAnimNotify`类在`Throw`动画蒙太奇的特定帧生成玩家投射物。你还将学习如何将这个新的通知添加到蒙太奇本身,以及如何向主角骨骼添加一个新的`Socket`,从中投射物将生成。最后,你将学习如何使用`粒子系统`和`声音提示`为游戏添加视觉和音频层。
让我们通过学习`Anim Notifies`和`Anim Notify States`开始本章。之后,你将通过创建自己的`UAnimNotify`类来实践,以便在`Throw`动画蒙太奇期间生成玩家投射物。
# Anim Notifies 和 Anim Notify States
在创建精致和复杂的动画时,需要一种方式让动画师和程序员在动画中添加自定义事件,以允许发生额外的效果、层和功能。虚幻引擎 4 中的解决方案是使用`Anim Notifies`和`Anim Notify States`。
`Anim Notify`和`Anim Notify State`之间的主要区别在于`Anim Notify State`具有三个`Anim Notify`没有的独特事件。这些事件分别是`Notify Begin`,`Notify End`和`Notify Tick`,所有这些事件都可以在蓝图或 C++中使用。当涉及到这些事件时,虚幻引擎 4 确保以下行为:
+ `Notify State`始终以`Notify Begin Event`开始。
+ `Notify State`将始终以`Notify End Event`结束。
+ `Notify Tick Event`将始终发生在`Notify Begin`和`Notify End`事件之间。
然而,`Anim Notify`是一个更简化的版本,它只使用一个函数`Notify()`,允许程序员为通知本身添加功能。它的工作方式是“发射并忘记”,这意味着你不需要担心`Notify()`事件的开始、结束或中间发生了什么。正是由于`Anim Notify`的简单性,以及我们不需要`Anim Notify State`中包含的事件,我们将使用`Anim Notify`来为 Super Side-Scroller 游戏生成玩家投射物。
在进行下一个练习之前,你将在 C++中创建自己的自定义`Anim Notify`,让我们简要讨论一些虚幻引擎 4 默认提供的`Anim Notifies`的示例。默认`Anim Notifies`状态的完整列表可以在以下截图中看到:
![图 14.1:Unreal Engine 4 中提供的默认 Anim 通知的完整列表](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_14_01.jpg)
图 14.1:Unreal Engine 4 中提供的默认 Anim 通知的完整列表
在本章后面,您将使用两个`Anim 通知`:`播放粒子效果`和`播放声音`。让我们更详细地讨论这两个,以便在使用它们时您对它们更加熟悉:
+ `播放粒子效果`:`播放粒子效果`通知允许您在动画的某一帧生成和播放粒子系统,正如其名称所示。如下面的屏幕截图所示,您可以更改正在使用的 VFX,例如更新粒子的`位置`、`旋转`和`缩放`设置。您甚至可以将粒子附加到指定的`Socket 名称`,如果您愿意的话:![图 14.2:播放粒子效果通知的详细面板,其中允许您自定义粒子](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_14_02.jpg)
图 14.2:播放粒子效果通知的详细面板,允许您自定义粒子
注意
视觉效果,简称 VFX,对于任何游戏来说都是至关重要的元素。在 Unreal Engine 4 中,使用一个名为*Cascade*的工具在编辑器内创建视觉效果。自 Unreal Engine 版本 4.20 以来,引入了一个名为*Niagara*的新工具作为免费插件,以改进 VFX 的质量和流程。您可以在这里了解更多关于*Niagara*的信息:[`docs.unrealengine.com/en-US/Engine/Niagara/Overview/index.html`](https://docs.unrealengine.com/en-US/Engine/Niagara/Overview/index.html)。
游戏中常见的一个例子是使用这种类型的通知在玩家行走或奔跑时在玩家脚下生成泥土或其他效果。能够指定在动画的哪一帧生成这些效果非常强大,可以让您为角色创建令人信服的效果。
+ `播放声音`:`播放声音`通知允许您在动画的某一帧播放`Soundcue`或`Soundwave`。如下面的屏幕截图所示,您可以更改正在使用的声音,更新其`音量`和`音调`值,甚至通过将其附加到指定的`Socket 名称`使声音跟随声音的所有者:![图 14.3:播放声音通知的详细面板,其中](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_14_03.jpg)
图 14.3:播放声音通知的详细面板,允许您自定义声音
与`播放粒子效果`通知所示的例子类似,`播放声音`通知也可以常用于在角色移动时播放脚步声。通过精确控制在动画时间轴的哪个位置播放声音,可以创建逼真的声音效果。
虽然您将不会使用`Anim 通知状态`,但至少了解默认情况下可用的选项仍然很重要,如下面的屏幕截图所示:
![图 14.4:Unreal Engine 4 中提供给您的默认 Anim 通知状态的完整列表](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_14_04.jpg)
图 14.4:Unreal Engine 4 中提供给您的默认 Anim 通知状态的完整列表
注意
在动画序列中不可用的两个“通知”状态是*Montage 通知窗口*和*禁用根动作*状态,如前面的屏幕截图所示。有关通知的更多信息,请参阅以下文档:[docs.unrealengine.com/en-US/Engine/Animation/Sequences/Notifies/index.html](http://docs.unrealengine.com/en-US/Engine/Animation/Sequences/Notifies/index.html)。
现在您对`Anim 通知`和`Anim 通知状态`更加熟悉,让我们继续进行下一个练习,您将在 C++中创建自定义的`Anim 通知`,用于生成玩家的投射物。
## 练习 14.01:创建一个 UAnim 通知类
玩家角色在`SuperSideScroller`游戏中的主要进攻能力是玩家可以向敌人投掷的投射物。在上一章中,您设置了投射物的框架和基本功能,但现在,玩家无法使用它。为了使生成或投掷投射物对眼睛有说服力,您需要创建一个自定义的`Anim Notify`,然后将其添加到`Throw`动画蒙太奇中。这个`Anim Notify`将让玩家知道是时候生成投射物了。
执行以下操作创建新的`UAnimNotify`类:
1. 在虚幻引擎 4 中,导航到`文件`选项,*左键单击*选择`新的 C++类`选项。
1. 从“选择父类”对话框窗口中,搜索`AnimNotify`并*左键单击*`AnimNotify`选项。然后,*左键单击*“下一步”选项来命名新类。
1. 将此新类命名为`Anim_ProjectileNotify`。命名后,*左键单击*选择`创建类`选项,以便虚幻引擎 4 重新编译并在 Visual Studio 中热重载新类。一旦 Visual Studio 打开,您将可以使用头文件`Anim_ProjectileNotify.h`和源文件`Anim_ProjectileNotify.cpp`。
1. `UAnimNotify`基类有一个函数需要在您的类中实现:
```cpp
virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation);
当时间轴上的通知被击中时,此函数将自动调用。通过覆盖此函数,您将能够向通知添加自己的逻辑。此函数还使您能够访问拥有通知的骨骼网格
组件以及当前正在播放的动画序列。
- 接下来,让我们在头文件中添加此函数的覆盖声明。在头文件
Anim_ProjectileNotify.h
中,在GENERATED_BODY()
下面添加以下代码:
public: virtual void Notify(USkeletalMeshComponent* MeshComp,UAnimSequenceBase* Animation) override;
现在您已经将函数添加到头文件中,是时候在Anim_ProjectileNotify
源文件中定义该函数了。
- 在
Anim_ProjectileNotify.cpp
源文件中,定义该函数并添加一个UE_LOG()
调用,打印文本"Throw Notify"
,如下所示:
void UAnim_ProjectileNotify::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
{
UE_LOG(LogTemp, Warning, TEXT("Throw Notify"));
}
目前,您将仅使用此UE_LOG()
调试工具,以便知道在下一个练习中将此通知添加到Throw
动画蒙太奇时,该函数是否被正确调用。
在本练习中,您通过添加以下函数创建了实现自己的AnimNotify
类所需的基础工作:
Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
在此函数中,您使用UE_LOG()
在输出日志中打印自定义文本"Throw Notify"
,以便您知道此通知是否正常工作。
在本章后面,您将更新此函数,以便调用将生成玩家投射物的逻辑,但首先,让我们将新通知添加到Throw
动画蒙太奇中。
练习 14.02:将通知添加到投掷蒙太奇
现在您有了Anim_ProjectileNotify
通知,是时候将此通知添加到Throw
动画蒙太奇中,以便实际为您所用。
在本练习中,您将在Throw
蒙太奇的时间轴上的确切帧上添加Anim_ProjectileNotify
,以便您期望投射物生成。
完成以下步骤以实现此目标:
- 回到虚幻引擎,在
内容浏览器
界面中,转到/MainCharacter/Animation/
目录。在此目录中,双击AM_Throw
资产以打开动画蒙太奇
编辑器。
在动画蒙太奇
编辑器的底部,您将找到动画的时间轴。默认情况下,您会观察到红色的条会随着动画的播放而沿着时间轴移动。
- 左键单击这个
红色
条,并手动将其移动到第 22 个帧
,尽可能靠近,如下面的截图所示:
图 14.5:红色条允许您在时间轴上手动定位通知
Throw
动画的第 22 帧是您期望玩家生成并投掷抛射物的确切时刻。以下截图显示了抛掷动画的帧,如在Persona
编辑器中所见:
图 14.6:玩家抛射物应该生成的确切时刻
- 现在您已经知道通知应该播放的时间轴位置,您现在可以在
Notifies
时间轴上右键单击细长的红色
线。
这将显示一个弹出窗口,您可以在其中添加Notify
或Notify State
。在某些情况下,Notifies
时间轴可能会被折叠并且难以找到;只需左键单击Notifies
一词,即可在折叠和展开之间切换。
-
选择
Add Notify
,然后从提供的选项中找到并选择Anim Projectile Notify
。 -
在将
Anim Projectile Notify
添加到通知时间轴后,您将看到以下内容:
图 14.7:Anim_ProjectileNotify 成功添加到 Throw 动画蒙太奇
-
在
Throw
动画蒙太奇时间轴上放置Anim_ProjectileNotify
通知后,保存蒙太奇。 -
如果
Output Log
窗口不可见,请通过导航到Window
选项并悬停在Developer Tools
上来重新启用窗口。找到Output Log
选项,左键单击以启用它。 -
现在,使用
PIE
,一旦进入游戏,使用左鼠标按钮开始播放Throw
蒙太奇。
在您添加通知的动画位置,您现在将在输出日志中看到调试日志文本Throw Notify
出现。
正如您可能还记得的第十二章,动画混合和蒙太奇中,您已将Play Montage
函数添加到了玩家角色蓝图BP_SuperSideScroller_MainCharacter
。为了在 Unreal Engine 4 的上下文中学习 C++,您将在即将进行的练习中将此逻辑从蓝图移至 C++。这样我们就不会过分依赖蓝图脚本来实现玩家角色的基本行为。
完成此练习后,您已成功将自定义的Anim Notify
类Anim_ProjectileNotify
添加到Throw
动画蒙太奇中。此通知已添加到您期望从玩家手中投掷抛射物的确切帧。由于您在第十二章,动画混合和蒙太奇中为玩家角色添加了蓝图逻辑,因此当使用左鼠标按钮调用InputAction
事件ThrowProjectile
时,您可以播放此Throw
动画蒙太奇。在从蓝图中播放 Throw 动画蒙太奇转换为从 C++中播放蒙太奇之前,让我们再讨论一下播放动画蒙太奇。
播放动画蒙太奇
正如您在第十二章,动画混合和蒙太奇中所学到的,这些项目对于允许动画师将单独的动画序列组合成一个完整的蒙太奇非常有用。通过将蒙太奇分割为自己独特的部分并为粒子和声音添加通知,动画师和动画程序员可以制作处理动画的所有不同方面的复杂蒙太奇集。
但是一旦动画蒙太奇准备就绪,我们如何在角色上播放这个蒙太奇?您已经熟悉第一种方法,即通过蓝图。
在蓝图中播放动画蒙太奇
在蓝图中,Play Montage
函数可供您使用,如下截图所示:
图 14.8:蓝图中的播放蒙太奇功能
您已经使用了播放AM_Throw
动画 Montage 的函数。此函数需要 Montage 必须在其上播放的“骨骼网格”组件,并且需要播放的动画 Montage。
其余的参数是可选的,具体取决于 Montage 的工作方式。让我们快速看看这些参数:
-
“播放速率”: “播放速率”参数允许您增加或减少动画 Montage 的播放速度。要加快播放速度,您将增加此值;否则,您将减少值以减慢播放速度。
-
“起始位置”: “起始位置”参数允许您设置 Montage 时间轴上的起始位置(以秒为单位),从该位置开始播放 Montage。例如,在一个持续 3 秒的动画 Montage 中,您可以选择让 Montage 从
1.0f
位置开始,而不是从0.0f
开始。 -
“起始部分”: “起始部分”参数允许您告诉动画 Montage 从特定部分开始。根据 Montage 的设置方式,您可以为 Montage 的不同部分创建多个部分。例如,霰丨弹丨枪武器重新装填动画 Montage 将包括一个用于重新装填的初始移动部分,一个用于实际子弹重新装填的循环部分,以及一个用于重新装备武器的最终部分,以便它准备好再次开火。
当涉及到Play Montage
函数的输出时,您有几种不同的选择:
-
“完成时”: “完成时”输出在动画 Montage 完成播放并完全混合结束时调用。
-
“混合结束时”: “混合结束时”输出在动画 Montage 开始混合结束时调用。这可能发生在“混合触发时间”期间,或者如果 Montage 过早结束。
-
“中断时”: “中断时”输出在由于另一个试图在相同骨架上播放的 Montage 中断此 Montage 而开始混合结束时调用。
-
“通知开始”和“通知结束”:如果您正在使用动画 Montage 中“通知”类别下的“Montage 通知”选项,则“通知开始”和“通知结束”输出都会被调用。通过“通知名称”参数返回给 Montage 通知的名称。
在 C++中播放动画 Montage
在 C++方面,您只需要了解一个事情,那就是UAnimInstance::Montage_Play()
函数。此函数需要要播放的动画 Montage,以及播放 Montage 的播放速率,EMontagePlayReturnType 类型的值,用于确定播放 Montage 的起始位置的 float 值,以及用于确定是否停止或中断所有 Montage 的布尔值。
尽管您不会更改EMontagePlayReturnType
的默认参数,即EMontagePlayReturnType::MontageLength
,但仍然重要知道此枚举器存在的两个值:
-
“Montage 长度”: “Montage 长度”值返回 Montage 本身的长度,以秒为单位。
-
“持续时间”: “持续时间”值返回 Montage 的播放持续时间,等于 Montage 的长度除以播放速率。
注意
有关UAnimMontage
类的更多详细信息,请参阅以下文档:https://docs.unrealengine.com/en-US/API/Runtime/Engine/Animation/UAnimMontage/index.html。
您将在下一个练习中了解有关播放动画 Montage 的 C++实现的更多信息。
练习 14.03:在 C++中播放投掷动画
现在你对在虚幻引擎 4 中通过蓝图和 C++播放动画蒙太奇有了更好的理解,是时候将播放“投掷”动画蒙太奇的逻辑从蓝图迁移到 C++了。这个改变的原因是因为蓝图逻辑是作为一个占位方法放置的,这样你就可以预览“投掷”蒙太奇。这本书更加专注于 C++游戏开发,因此,学习如何在代码中实现这个逻辑是很重要的。
让我们首先从蓝图中移除逻辑,然后继续在玩家角色类中用 C++重新创建这个逻辑。
以下步骤将帮助你完成这个练习:
-
导航到玩家角色蓝图,
BP_SuperSideScroller_MainCharacter
,可以在以下目录中找到:/MainCharacter/Blueprints/
。双击这个资源来打开它。 -
在这个蓝图中,你会找到
InputAction ThrowProjectile
事件和你创建的Play Montage
函数,用于预览Throw
动画蒙太奇,如下截图所示。删除这个逻辑,然后重新编译并保存玩家角色蓝图:
图 14.9:你不再需要在玩家角色蓝图中使用这个占位逻辑
-
现在,使用
PIE
并尝试用左鼠标按钮让玩家角色投掷。你会发现玩家角色不再播放Throw
动画蒙太奇。让我们通过在 C++中添加所需的逻辑来修复这个问题。 -
在 Visual Studio 中打开玩家角色的头文件,
SuperSideScroller_Player.h
。 -
你需要做的第一件事是创建一个新的变量,用于玩家角色的
Throw
动画。在Private
访问修饰符下添加以下代码:
UPROPERTY(EditAnywhere)
class UAnimMontage* ThrowMontage;
现在你有一个变量,它将代表“投掷”动画蒙太奇,是时候在SuperSideScroller_Player.cpp
文件中添加播放蒙太奇的逻辑了。
- 在你调用
UAnimInstance::Montage_Play()
之前,你需要在源文件顶部的现有列表中添加以下include
目录,以便访问这个函数:
#include "Animation/AnimInstance.h"
正如我们从第九章,音频-视觉元素中知道的,玩家角色已经有一个名为ThrowProjectile
的函数,每当按下左鼠标按钮时就会调用。作为提醒,在 C++中绑定发生在这里:
//Bind pressed action ThrowProjectile to your ThrowProjectile function
PlayerInputComponent->BindAction("ThrowProjectile", IE_Pressed, this, &ASuperSideScroller_Player::ThrowProjectile);
- 更新
ThrowProjectile
,使其播放你在这个练习中设置的ThrowMontage
。将以下代码添加到ThrowProjectile()
函数中。然后,我们可以讨论这里发生了什么:
void ASuperSideScroller_Player::ThrowProjectile()
{
if (ThrowMontage)
{
bool bIsMontagePlaying = GetMesh()->GetAnimInstance()-> Montage_IsPlaying(ThrowMontage);
if (!bIsMontagePlaying)
{
GetMesh()->GetAnimInstance()->Montage_Play(ThrowMontage, 2.0f);
}
} }
第一行是检查ThrowMontage
是否有效;如果我们没有分配有效的动画蒙太奇,继续逻辑就没有意义,而且在后续函数调用中使用 NULL 对象可能会导致崩溃,这也是很危险的。接下来,我们声明一个新的布尔变量,称为bIsMontagePlaying
,用于确定ThrowMontage
是否已经在玩家角色的骨骼网格上播放。这个检查是因为Throw
动画蒙太奇在已经播放时不应该再次播放;如果玩家反复按下左鼠标按钮,这将导致动画中断。
接下来,有一个If
语句,检查ThrowMontage
是否有效,以及蒙太奇是否正在播放。只要满足这些条件,就可以安全地继续播放动画蒙太奇。
-
在
If
语句内部,您正在告诉玩家的骨骼网格以1.0f
的播放速率播放ThrowMontage
动画蒙太奇。使用1.0f
值是为了使动画蒙太奇以预期速度播放。大于1.0f
的值将使蒙太奇以更快的速度播放,而小于1.0f
的值将使蒙太奇以更慢的速度播放。您学到的其他参数,如起始位置或EMontagePlayReturnType
参数,可以保持其默认值。回到虚幻引擎 4 编辑器内,进行代码重新编译,就像您以前做过的那样。 -
代码成功重新编译后,导航回玩家角色蓝图
BP_SuperSideScroller_MainCharacter
,该蓝图可以在以下目录中找到:/MainCharacter/Blueprints/
。双击此资源以打开它。 -
在玩家角色的“详细信息”面板中,您现在将看到您添加的“投掷动画”参数。
-
左键单击“投掷动画”参数的下拉菜单,找到
AM_Throw
动画。再次左键单击AM_Throw
选项以选择它作为此参数。请参考以下截图,查看变量应如何设置:
图 14.10:现在,投掷动画被分配为 AM_Throw 动画
- 重新编译并保存玩家角色蓝图。然后,使用
PIE
生成玩家角色,并使用鼠标左键播放“投掷动画”。以下截图显示了这一过程:
图 14.11:玩家角色现在能够再次执行投掷动画
通过完成这个练习,您已经学会了如何向玩家角色添加“动画蒙太奇”参数,以及如何在 C++中播放蒙太奇。除了在 C++中播放“投掷”动画蒙太奇之外,您还通过添加检查蒙太奇是否已经在播放来控制“投掷”动画可以播放的频率。通过这样做,您可以防止玩家不断按下“投掷”输入,导致动画中断或完全不播放。
注意
尝试将“动画蒙太奇”的播放速率从1.0f
设置为2.0f
,然后重新编译代码。观察增加动画播放速率如何影响玩家对动画的外观和感觉。
游戏世界和生成对象
当涉及将对象生成到游戏世界中时,实际上是代表您的关卡的World
对象处理了这些对象的创建。您可以将UWorld
类对象视为代表您的关卡的单个顶层对象。
UWorld
类可以做很多事情,比如从世界中生成和移除对象,检测何时正在更改或流入/流出级别,甚至执行线性跟踪以帮助进行对象检测。在本章中,我们将专注于生成对象。
UWorld
类有多种SpawnActor()
函数的变体,取决于您希望如何生成对象,或者您在生成此对象的上下文中可以访问哪些参数。要考虑的三个一致参数是:
-
UClass
:UClass
参数只是您想要生成的对象的类。 -
FActorSpawnParameters
:这是一个包含变量的结构,为生成的对象提供更多上下文和引用。有关此结构中包含的所有变量的列表,请参考虚幻引擎 4 社区维基上的这篇文章:https://www.ue4community.wiki/Actor#Spawn
让我们简要讨论FActorSpawnParameters
中包含的一个更关键的变量:Owner
actor。Owner
是生成此对象的 actor,在玩家角色和投射物的情况下,您需要明确引用玩家作为投射物的所有者。尤其是在这个游戏的背景下,这是很重要的,因为您不希望投射物与其Owner
发生碰撞;您希望这个投射物完全忽略所有者,只与敌人或关卡环境发生碰撞。
Transform
:当将对象生成到世界中时,世界需要知道此 actor 的位置
、旋转
和缩放
属性,然后才能生成它。在SpawnActor()
函数的某些模板中,需要传递完整的Transform
,而在其他模板中,需要单独传递Location
和Rotation
。
在继续生成玩家投射物之前,让我们设置玩家角色“骨架”中的Socket
位置,以便在“投掷”动画期间可以从玩家手生成投射物。
练习 14.04:创建投射物生成 Socket
为了生成玩家投射物,您需要确定投射物将生成的Transform
,主要关注位置
和旋转
,而不是缩放
。
在这个练习中,您将在玩家角色的“骨架”上创建一个新的Socket
,然后可以在代码中引用它,以便获取生成投射物的位置。
让我们开始吧:
-
在虚幻引擎 4 中,导航到“内容浏览器”界面,找到
/MainCharacter/Mesh/
目录。 -
在此目录中,找到“骨架”资产;即
MainCharacter_Skeleton.uasset
。双击打开此“骨架”。
为了确定投射物应该生成的最佳位置,我们需要将“投掷”动画剪辑添加为骨架的预览动画。
-
在
Details
面板中,在Animation
类别下,找到Preview Controller
参数,并选择Use Specific Animation
选项。 -
接下来,左键单击下拉菜单,找到并选择可用动画列表中的
AM_Throw
动画剪辑。
现在,玩家角色的“骨架”将开始预览“投掷”动画剪辑,如下面的屏幕截图所示:
图 14.12:玩家角色预览投掷动画剪辑
如果您还记得练习 14.02,添加到投掷剪辑的通知,您在“投掷”动画的第 22 帧添加了Anim_ProjectileNotify
。
- 使用“骨架”编辑器底部的时间轴,将“红色”条移动到尽可能接近第 22 帧。请参考以下屏幕截图:
图 14.13:在之前的练习中添加了 Anim_ProjectileNotify 的第 22 帧相同的帧
在“投掷”动画的第 22 帧,玩家角色应该如下所示:
图 14.14:在投掷动画剪辑的第 22 帧,角色的手位于释放投射物的位置
正如您所看到的,玩家角色将从他们的右手投掷投射物,因此新的Socket
应该连接到右手。让我们看一下玩家角色的骨骼层次结构,如下面的屏幕截图所示:
图 14.15:在玩家角色骨架的层次结构中找到的 RightHand 骨骼
-
从骨骼层次结构中找到
RightHand
骨骼。这可以在RightShoulder
骨骼层次结构下找到。 -
右键单击
RightHand
骨骼,然后左键单击出现的选项列表中的Add Socket
选项。将此插座命名为ProjectileSocket
。
此外,当添加一个新的Socket
时,整个RightHand
的层次结构将扩展,新的插座将出现在底部。
- 选择
ProjectileSocket
,使用Transform
小部件小部件将此Socket
定位到以下位置:
Location = (X=12.961717,Y=25.448450,Z=-7.120584)
最终结果应该如下所示:
图 14.16:抛射物插座在世界空间中抛出动画的第 22 帧的最终位置。
如果你的小部件看起来有点不同,那是因为上面的图像显示了世界空间中的插座位置,而不是本地空间。
- 现在
ProjectileSocket
的位置已经就位,保存MainCharacter_Skeleton
资产。
通过完成这个练习,你现在知道玩家抛射物将从哪个位置生成。由于你在预览中使用了Throw
动画蒙太奇,并使用了相同的动画的第 22 帧,所以你知道这个位置将根据Anim_ProjectileNotify
的触发时间是正确的。
现在,让我们继续在 C++中生成玩家抛射物。
练习 14.05:准备SpawnProjectile()
函数
现在ProjectileSocket
已经就位,并且现在有一个位置可以生成玩家抛射物了,让我们添加生成玩家抛射物所需的代码。
通过这个练习结束时,你将有一个准备好生成抛射物的函数,并且它将准备好从Anim_ProjectileNotify
类中调用。
执行以下步骤:
-
从 Visual Studio 中,导航到
SuperSideScroller_Player.h
头文件。 -
你需要一个指向
PlayerProjectile
类的类引用变量。你可以使用名为TSubclassOf
的变量模板类类型来实现这一点。在Private
访问修饰符下,将以下代码添加到头文件中:
UPROPERTY(EditAnywhere)
TSubclassOf<class APlayerProjectile> PlayerProjectile;
现在你已经准备好变量,是时候声明你将用来生成抛射物的函数了。
- 在
ThrowProjectile()
函数的声明和Public
访问修饰符下添加以下函数声明:
void SpawnProjectile();
- 在准备
SpawnProjectile()
函数的定义之前,将以下include
目录添加到SuperSideScroller_Player.cpp
源文件的包含列表中:
#include "PlayerProjectile.h"
#include "Engine/World.h"
#include "Components/SphereComponent.h"
你需要包含PlayerProjectile.h
,因为这是为了引用抛射物类的碰撞组件而必需的。接下来,使用Engine/World.h
的包含是为了使用SpawnActor()
函数和访问FActorSpawnParameters
结构。最后,你需要使用Components/SphereComponent.h
的包含,以便更新玩家抛射物的碰撞组件,使其忽略玩家。
- 接下来,在
SuperSideScroller_Player.cpp
源文件的底部创建SpawnProjectile()
函数的定义,如下所示:
void ASuperSideScroller_Player::SpawnProjectile()
{
}
这个函数需要做的第一件事是检查PlayerProjectile
类变量是否有效。如果这个对象无效,继续尝试生成它就没有意义了。
- 更新
SpawnProjectile()
函数如下:
void ASuperSideScroller_Player::SpawnProjectile()
{
if(PlayerProjectile)
{
}
}
现在,如果PlayerProjectile
对象有效,你将想要获取玩家当前存在的UWorld
对象,并确保这个世界在继续之前是有效的。
- 更新
SpawnProjectile()
函数如下:
void ASuperSideScroller_Player::SpawnProjectile()
{
if(PlayerProjectile)
{
UWorld* World = GetWorld();
if (World)
{
}
}
}
此时,你已经进行了安全检查,以确保PlayerProjectile
和UWorld
都是有效的,所以现在可以安全地尝试生成抛射物了。首先要做的是声明一个新的FactorSpawnParameters
类型的变量,并将玩家指定为所有者。
- 在最近的
if
语句中添加以下代码,使SpawnProjectile()
函数看起来像这样:
void ASuperSideScroller_Player::SpawnProjectile()
{
if(PlayerProjectile)
{
UWorld* World = GetWorld();
if (World)
{
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = this;
}
}
}
正如你之前学到的,UWorld
对象的SpawnActor()
函数调用将需要FActorSpawnParameters
结构作为生成对象的初始化的一部分。对于玩家投射物,你可以使用this
关键字作为玩家角色类的引用,作为投射物的所有者。这在以后在这个函数中更新投射物的碰撞时会派上用场。
- 接下来,你需要处理
SpawnActor()
函数的Location
和Rotation
参数。在最新的一行下面添加以下行:
FVector SpawnLocation = this->GetMesh()- >GetSocketLocation(FName("ProjectileSocket"));
FRotator Rotation = GetActorForwardVector().Rotation();
在第一行中,声明一个名为SpawnLocation
的新FVector
变量。这个向量使用你在上一个练习中创建的ProjectileSocket
插座的Socket
位置。从GetMesh()
函数返回的Skeletal Mesh
组件包含一个名为GetSocketLocation()
的函数,它将返回传入的FName
的插座位置;在这种情况下,是名为ProjectileSocket
。
在第二行,声明一个名为Rotation
的新FRotator
变量。这个值设置为玩家的前向向量,转换为Rotator
容器。这将确保玩家投射物生成的旋转,或者换句话说,方向,将在玩家的前方,并且它将远离玩家。
现在,生成项目所需的所有参数都已准备好。
- 在上一步的代码下面添加以下行:
APlayerProjectile* Projectile = World- >SpawnActor<APlayerProjectile>(PlayerProjectile, SpawnLocation, Rotation, SpawnParams);
World->SpawnActor()
函数将返回你尝试生成的类的对象;在这种情况下是APlayerProjectile
。这就是为什么在实际生成之前要添加APlayerProjectile* Projectile
。然后,你要传入SpawnLocation
、Rotation
和SpawnParams
参数,以确保项目生成在你想要的位置和方式。
- 最后,你可以通过添加以下代码行将玩家角色添加到要忽略的演员数组中:
if (Projectile)
{
Projectile->CollisionComp-> MoveIgnoreActors.Add(SpawnParams.Owner);
}
现在你有了投射物的引用,这一行正在更新CollisionComp
组件,以便将玩家或SpawnParams.Owner
添加到MoveIgnoreActors
数组中。这个演员数组将被投射物的碰撞忽略,因为这个投射物不应该与投掷它的玩家发生碰撞。
- 返回编辑器重新编译新添加的代码。代码成功编译后,这个练习就完成了。
完成这个练习后,你现在有一个函数,可以生成分配给玩家角色内的玩家投射物类。通过为投射物和世界的有效性添加安全检查,你确保如果生成了一个对象,它是一个有效的对象在一个有效的世界内。
接下来,为UWorld SpawnActor()
函数设置适当的location
、rotation
和FActorSpawnParameters
参数,以确保玩家投射物在正确的位置生成,基于上一个练习中的插座位置,以适当的方向远离玩家,并以玩家角色作为其Owner
。
现在是时候更新Anim_ProjectileNotify
源文件,以便生成投射物。
练习 14.06:更新 Anim_ProjectileNotify 类
你已经准备好允许玩家投射物生成的函数,但是你还没有在任何地方调用这个函数。回到练习 14.01,创建 UAnim Notify 类,你创建了Anim_ProjectileNotify
类,而在练习 14.02,将通知添加到投掷动画,你将这个通知添加到Throw
动画蒙太奇中。
现在是时候更新Uanim
Notify
类,以便调用SpawnProjectile()
函数。
要实现这一点,请执行以下操作:
- 在 Visual Studio 中,打开
Anim_ProjectileNotify.cpp
源文件。
在源文件中,您有以下代码:
#include "Anim_ProjectileNotify.h"
void UAnim_ProjectileNotify::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
{
UE_LOG(LogTemp, Warning, TEXT("Throw Notify"));
}
-
从
Notify()
函数中删除UE_LOG()
行。 -
接下来,在
Anim_ProjectileNotify.h
下面添加以下include
行:
#include "Components/SkeletalMeshComponent.h"
#include "SuperSideScroller/SuperSideScroller_Player.h"
您需要包含SuperSideScroller_Player.h
头文件,因为这是在调用您在上一个练习中创建的SpawnProjectile()
函数时所需的。我们还包括了SkeletalMeshComponent.h
,因为我们将在Notify()
函数中引用此组件,所以最好也在这里包含它。
Notify()
函数传入拥有的Skeletal Mesh
的引用,标记为MeshComp
。您可以使用骨骼网格来通过使用GetOwner()
函数并将返回的角色转换为您的SuperSideScroller_Player
类来获取对玩家角色的引用。我们将在下一步中执行此操作。
- 在
Notify()
函数中,添加以下代码行:
ASuperSideScroller_Player* Player = Cast<ASuperSideScroller_Player>(MeshComp->GetOwner());
- 现在您已经有了对玩家的引用,您需要在调用
SpawnProjectile()
函数之前对Player
变量进行有效性检查。在上一步的行之后添加以下代码行:
if (Player)
{
Player->SpawnProjectile();
}
- 现在
SpawnProjectile()
函数从Notify()
函数中被调用,返回编辑器重新编译和热重载您所做的代码更改。
在您能够使用PIE
四处奔跑并投掷玩家投射物之前,您需要从上一个练习中分配Player Projectile
变量。
-
在
Content Browser
界面中,导航到/MainCharacter/Blueprints
目录,找到BP_SuperSideScroller_MainCharacter
蓝图。 双击打开蓝图。 -
在
Details
面板中,在Throw Montage
参数下,您将找到Player Projectile
参数。 左键单击此参数的下拉选项,并找到BP_PlayerProjectile
。 左键单击此选项以将其分配给Player Projectile
变量。 -
重新编译并保存
BP_SuperSideScroller_MainCharacter
蓝图。 -
现在,使用
PIE
并使用鼠标左键。玩家角色将播放Throw
动画,玩家投射物将生成。
注意,投射物是从您创建的ProjectileSocket
函数中生成的,并且它远离玩家。以下截图显示了这一点:
图 14.17:玩家现在可以投掷玩家投射物
完成此练习后,玩家现在可以投掷玩家投射物。当前状态下的玩家投射物对敌人无效,只是在空中飞行。在Throw
动画 Montage、Anim_ProjectileNotify
类和玩家角色之间需要很多移动部件才能让玩家投掷投射物。
在即将进行的练习中,您将更新玩家投射物,以便销毁敌人并播放额外的效果,如粒子和声音。
销毁角色
到目前为止,在本章中,我们已经非常关注在游戏世界中生成或创建角色;玩家角色使用UWorld
类来生成投射物。Unreal Engine 4 及其基本的Actor
类带有一个默认函数,您可以使用它来销毁或移除游戏世界中的角色:
bool AActor::Destroy( bool bNetForce, bool bShouldModifyLevel )
您可以在 Visual Studio 中找到此函数的完整实现,方法是在/Source/Runtime/Engine/Actor.cpp
目录中找到Actor.cpp
源文件。此函数存在于所有扩展自Actor
类的类中,在 Unreal Engine 4 的情况下,它存在于所有可以在游戏世界中生成或放置的类中。更明确地说,EnemyBase
和PlayerProjectile
类都是Actor
类的子类,因此可以被销毁。
进一步查看AActor::Destroy()
函数,您将找到以下行:
World->DestroyActor( this, bNetForce, bShouldModifyLevel );
我们不会详细讨论UWorld
类到底如何销毁角色,但重要的是要强调UWorld
类负责在世界中创建和销毁角色。随时深入挖掘源引擎代码,找到更多关于UWorld
类如何处理角色的销毁和生成的信息。
现在你对 Unreal Engine 4 如何处理游戏世界中的角色的销毁和移除有了更多的上下文,我们将为敌人角色实现这一功能。
练习 14.07:创建 DestroyEnemy()函数
Super SideScroller
游戏的主要玩法是玩家在关卡中移动并使用投射物来摧毁敌人。在项目的这一阶段,你已经处理了玩家移动和生成玩家投射物。然而,投射物还不能摧毁敌人。
为了实现这个功能,我们将首先向EnemyBase
类添加一些逻辑,以便它知道如何处理自己的销毁,并在与玩家投射物碰撞时从游戏中移除它。
完成以下步骤来实现这一点:
-
首先,转到 Visual Studio 并打开
EnemyBase.h
头文件。 -
在头文件中,在
Public
访问修饰符下创建一个名为DestroyEnemy()
的新函数声明,如下所示:
public:
void DestroyEnemy();
确保这个函数定义写在GENERATED_BODY()
下面,在类定义内部。
-
保存这些更改到头文件,并打开
EnemyBase.cpp
源文件,以添加这个函数的实现。 -
在
#include
行下面,添加以下函数定义:
void AEnemyBase::DestroyEnemy()
{
}
目前,这个函数将非常简单。你只需要调用基类Actor
的继承Destroy()
函数。
- 更新
DestroyEnemy()
函数,使其看起来像这样:
void AEnemyBase::DestroyEnemy()
{
Destroy();
}
- 完成这个函数后,保存源文件并返回编辑器,这样你就可以重新编译和热重载代码了。
完成这个练习后,敌人角色现在有一个函数,可以轻松处理角色的销毁。DestroyEnemy()
函数是公开可访问的,因此其他类可以调用它,在处理玩家投射物的销毁时会很方便。
你创建自己独特的销毁敌人角色的函数的原因是因为你将在本章后面使用这个函数来为敌人被玩家投射物销毁时添加 VFX 和 SFX。
在进行敌人销毁的润色元素之前,让我们在玩家投射物类中实现一个类似的函数,以便它也可以被销毁。
练习 14.08:销毁投射物
现在敌人角色可以通过你在上一个练习中实现的新的DestroyEnemy()
函数处理被销毁了,现在是时候为玩家投射物做同样的事情了。
通过这个练习结束时,玩家投射物将有自己独特的函数来处理自己的销毁和从游戏世界中移除。
让我们开始吧:
-
在 Visual Studio 中,打开玩家投射物的头文件;也就是
PlayerProjectile.h
。 -
在
Public
访问修饰符下,添加以下函数声明:
void ExplodeProjectile();
-
接下来,打开玩家投射物的源文件;也就是
PlayerProjectile.cpp
。 -
在
APlayerProjectile::OnHit
函数下面,添加ExplodeProjectile()
函数的定义:
void APlayerProjectile::ExplodeProjectile()
{
}
目前,这个函数将与上一个练习中的DestroyEnemy()
函数完全相同。
- 将继承的
Destroy()
函数添加到新的ExplodeProjectile()
函数中,如下所示:
void APlayerProjectile::ExplodeProjectile()
{
Destroy();
}
- 完成这个函数后,保存源文件并返回编辑器,这样你就可以重新编译和热重载代码了。
完成此练习后,玩家抛射物现在具有一个可以轻松处理角色摧毁的功能。您需要创建自己独特的函数来处理摧毁玩家抛射物角色的原因与创建DestroyEnemy()
函数的原因相同-您将在本章后面使用此函数为玩家抛射物与其他角色碰撞时添加 VFX 和 SFX。
现在您已经有了在玩家抛射物和敌人角色内部实现Destroy()
函数的经验,是时候将这两个元素结合起来了。
在下一个活动中,您将使玩家抛射物能够在碰撞时摧毁敌人角色。
活动 14.01:抛射物摧毁敌人
现在玩家抛射物和敌人角色都可以处理被摧毁的情况,是时候迈出额外的一步,允许玩家抛射物在碰撞时摧毁敌人角色了。
执行以下步骤来实现这一点:
-
在
PlayerProjectile.cpp
源文件的顶部添加#include
语句,引用EnemyBase.h
头文件。 -
在
APlayerProjectile::OnHit()
函数中,创建一个AEnemyBase*
类型的新变量,并将此变量命名为Enemy
。 -
将
APlayerProjectile::OnHit()
函数的OtherActor
参数转换为AEnemyBase*
类,并将Enemy
变量设置为此转换的结果。 -
使用
if()
语句检查Enemy
变量的有效性。 -
如果
Enemy
有效,则从此Enemy
调用DestroyEnemy()
函数。 -
在
if()
块之后,调用ExplodeProjectile()
函数。 -
保存源文件的更改并返回到虚幻引擎 4 编辑器。
-
使用
PIE
,然后使用玩家抛射物对抗敌人以观察结果。
预期输出如下:
图 14.18:玩家投掷抛射物
当抛射物击中敌人时,敌人角色被摧毁,如下所示:
图 14.19:抛射物和敌人被摧毁
完成此活动后,玩家抛射物和敌人角色在碰撞时可以被摧毁。此外,每当另一个角色触发其APlayerProjectile::OnHit()
函数时,玩家抛射物也将被摧毁。
通过这样,Super SideScroller
游戏的一个重要元素已经完成:玩家抛射物的生成以及敌人与抛射物碰撞时的摧毁。您可以观察到摧毁这些角色非常简单,对玩家来说并不是很有趣。
因此,在本章的即将进行的练习中,您将更多地了解有关视觉和音频效果,即 VFX 和 SFX。您还将针对敌人角色和玩家抛射物实现这些元素。
现在敌人角色和玩家抛射物都可以被摧毁,让我们简要讨论一下 VFX 和 SFX 是什么,以及它们将如何影响项目。
注意
此活动的解决方案可在以下链接找到:packt.live/338jEBx
。
视觉和音频效果
视觉效果,如粒子系统和声音效果,如声音提示,在视频游戏中扮演着重要角色。它们在系统、游戏机制甚至基本操作之上增添了一层光泽,使这些元素更有趣或更令人愉悦。
让我们先了解视觉效果,然后是音频效果。
视觉效果(VFX)
在虚幻引擎 4 的上下文中,视觉效果由所谓的粒子系统组成。粒子系统由发射器组成,发射器由模块组成。在这些模块中,您可以使用材料、网格和数学模块来控制发射器的外观和行为。最终结果可以是从火炬、雪花、雨、灰尘等各种效果。
注意
您可以在这里了解更多信息:docs.unrealengine.com/en-US/Resources/Showcases/Effects/index.html
。
音频效果(SFX)
在虚幻引擎 4 的上下文中,音频效果由声波和声音提示的组合组成:
-
声波是可以导入到虚幻引擎 4 中的
.wav
音频格式文件。 -
声音提示将声波音频文件与其他节点(如振荡器、调制器和连接器)组合在一起,为您的游戏创建独特和复杂的声音。
注意
您可以在这里了解更多信息:docs.unrealengine.com/en-US/Engine/Audio/SoundCues/NodeReference/index.html
。
让我们以 Valve 开发的游戏Portal 2为例。
在Portal 2中,玩家使用传送枪发射两个传送门:一个橙色和一个蓝色。这些传送门允许玩家穿越间隙,将物体从一个位置移动到另一个位置,并利用其他简单的机制,这些机制叠加在一起,形成复杂的谜题。使用这些传送门,传送门发射的声音效果以及这些传送门的视觉 VFX 使游戏更加有趣。如果您对这款游戏不熟悉,请观看完整的攻略视频:www.youtube.com/watch?v=ZFqk8aj4-PA
。
注意
有关声音和声音设计重要性的进一步阅读,请参阅以下 Gamasutra 文章:www.gamasutra.com/view/news/318157/7_games_worth_studying_for_their_excellent_sound_design.php
。
在虚幻引擎 4 的上下文中,VFX 最初是使用称为材质
、静态网格
和数学
的工具创建的,以为游戏世界创建有趣和令人信服的效果。本书不会深入介绍这个工具的工作原理,但您可以在这里找到有关 Cascade 的信息:www.ue4community.wiki/Legacy/Introduction_to_Particles_in_UE4_-_2_-_Cascade_at_a_Glance
。
在引擎的更新版本中,从 4.20 版本开始,有一个名为Niagara
的插件,与 Cascade 不同,它使用类似蓝图的系统,您可以在其中直观地编写效果的行为,而不是使用预定义行为的模块。您可以在这里找到有关 Niagara 的更多信息:docs.unrealengine.com/en-US/Engine/Niagara/Overview/index.html
。
在第九章,音频-视觉元素中,您了解了更多关于音频以及音频在虚幻引擎 4 中的处理。现在需要知道的是,虚幻引擎 4 使用.wav
文件格式将音频导入到引擎中。从那里,您可以直接使用.wav
文件,在编辑器中称为声波,或者您可以将这些资产转换为声音提示,这样可以在声波上添加音频效果。
最后,有一个重要的类需要了解,您将在即将进行的练习中引用这个类,这个类叫做UGameplayStatics
。这是虚幻引擎中的一个静态类,可以从 C++和蓝图中使用,它提供了各种有用的与游戏相关的功能。您将在即将进行的练习中使用的两个函数如下:
UGameplayStatics::SpawnEmitterAtLocation
UGameplayStatics:SpawnSoundAtLocation
这两个函数的工作方式非常相似;它们都需要一个World
上下文对象来生成效果,要生成的粒子系统或音频,以及要生成效果的位置。您将使用这些函数来生成敌人的销毁效果。
练习 14.09:在敌人被摧毁时添加效果
在本练习中,您将向项目中添加本章和练习包含的新内容。这包括粒子 VFX 和声音 SFX,以及它们所需的所有资产。然后,您将更新EnemyBase
类,以便它可以使用音频和粒子系统参数,在玩家投射物销毁敌人时添加所需的光泽层。
通过本练习结束时,您将拥有一个敌人,当它与玩家投射物碰撞时,会在视觉和听觉上被摧毁。
让我们开始:
-
首先,我们需要从
Action RPG
项目中迁移特定资产,这些资产可以在“虚幻引擎启动器”的“学习”选项卡中找到。 -
从
Epic Games Launcher
导航到“学习”选项卡,在“游戏”类别下,您将找到Action RPG
:
注意
在本章后续练习中,您将从动作 RPG 项目中获取其他资产,因此应保持此项目打开,以避免重复打开项目。
-
左键单击
Action RPG
游戏项目,然后左键单击“创建项目”选项。 -
从这里,选择引擎版本 4.24,并选择要下载项目的目录。然后,左键单击“创建”按钮开始安装项目。
-
Action RPG
项目下载完成后,导航到Epic Games Launcher
的“库”选项卡,找到My Projects
部分下的ActionRPG
。 -
双击
ActionRPG
项目,以在 Unreal Engine 编辑器中打开它。 -
在编辑器中,在“内容浏览器”界面中找到
A_Guardian_Death_Cue
音频资产。右键单击此资产,然后选择“资产操作”,然后选择“迁移”。 -
选择“迁移”后,您将看到所有在
A_Guardian_Death_Cue
中引用的资产。这包括所有音频类和声波文件。从“资产报告”对话框中选择“确定”。 -
接下来,您需要导航到
Super SideScroller
项目的“内容”文件夹,左键单击“选择文件夹”。 -
迁移过程完成后,您将在编辑器中收到通知,通知您迁移已成功完成。
-
对
P_Goblin_Death
VFX 资产执行相同的迁移步骤。您要添加到项目中的两个主要资产如下:
A_Guardian_Death_Cue
P_Goblin_Death
P_Goblin_Death
粒子系统资产引用了Effects
目录中包含的材质和纹理等其他资产,而A_Guardian_Death_Cue
引用了Assets
目录中包含的其他声音波资产。
- 将这些文件夹迁移到
SuperSideScroller
项目的“内容”目录后,打开 Unreal Engine 4 编辑器,以在项目的“内容浏览器”中找到包含在项目中的新文件夹。
您将用于敌人角色销毁的粒子称为P_Goblin_Death
,可以在/Effects/FX_Particle/
目录中找到。您将用于敌人角色销毁的声音称为A_Guardian_Death_Cue
,可以在/Assets/Sounds/Creatures/Guardian/
目录中找到。现在您需要的资产已导入到编辑器中,让我们继续进行编码。
-
打开 Visual Studio 并导航到敌人基类的头文件;也就是
EnemyBase.h
。 -
添加以下
UPROPERTY()
变量。这将代表敌人被销毁时的粒子系统。确保这是在Public
访问修饰符下声明的:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
class UParticleSystem* DeathEffect;
- 添加以下
UPROPERTY()
变量。这将代表敌人被销毁时的声音。确保这是在Public
访问修饰符下声明的:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
class USoundBase* DeathSound;
有了这两个属性的定义,让我们继续添加所需的逻辑,以便在敌人被摧毁时生成和使用这些效果。
- 在敌人基类的源文件
EnemyBase.cpp
中,添加以下包含UGameplayStatics
和UWorld
类:
#include "Kismet/GameplayStatics.h"
#include "Engine/World.h"
当敌人被摧毁时,您将使用UGameplayStatics
和UWorld
类将声音和粒子系统生成到世界中。
- 在
AEnemyBase::DestroyEnemy()
函数中,您有一行代码:
Destroy();
- 在
Destroy()
函数调用之前添加以下代码行:
UWorld* World = GetWorld();
在尝试生成粒子系统或声音之前,有必要定义UWorld
对象,因为需要一个World
上下文对象。
- 接下来,使用
if()
语句检查您刚刚定义的World
对象的有效性:
if(World)
{
}
- 在
if()
块内,添加以下代码来检查DeathEffect
属性的有效性,然后使用UGameplayStatics
的SpawnEmitterAtLocation
函数生成这个效果:
if(DeathEffect)
{
UGameplayStatics::SpawnEmitterAtLocation(World, DeathEffect, GetActorTransform());
}
无法再次强调,在尝试生成或操作对象之前,您应该确保对象是有效的。这样做可以避免引擎崩溃。
- 在
if(DeathEffect)
块之后,执行DeathSound
属性的相同有效性检查,然后使用UGameplayStatics::SpawnSoundAtLocation
函数生成声音:
if(DeathSound)
{
UGameplayStatics::SpawnSoundAtLocation(World, DeathSound, GetActorLocation());
}
在调用Destroy()
函数之前,您需要检查DeathEffect
和DeathSound
属性是否都有效,如果是,则使用适当的UGameplayStatics
函数生成这些效果。这样无论这两个属性是否有效,敌人角色都将被摧毁。
-
现在
AEnemyBase::DestroyEnemy()
函数已经更新以生成这些效果,返回到虚幻引擎 4 编辑器中编译和热重载这些代码更改。 -
在
Content Browser
界面中,导航到/Enemy/Blueprints/
目录。双击BP_Enemy
资源以打开它。 -
在敌人蓝图的
Details
面板中,您将找到Death Effect
和Death Sound
属性。左键单击Death Effect
属性的下拉列表,并找到P_Goblin_Death
粒子系统。 -
接下来,在
Death Effect
参数下方,左键单击Death Sound
属性的下拉列表,并找到A_Guardian_Death_Cue
声音提示。 -
现在这些参数已经更新并分配了正确的效果,编译并保存敌人蓝图。
-
使用
PIE
,生成玩家角色并向敌人投掷玩家投射物。如果你的关卡中没有敌人,请添加一个。当玩家投射物与敌人碰撞时,你添加的 VFX 和 SFX 将播放,如下截图所示:
图 14.20:现在,敌人爆炸并在火光中被摧毁
完成此练习后,敌人角色现在在被玩家投射物摧毁时播放粒子系统和声音提示。这为游戏增添了一层精致,使得摧毁敌人更加令人满意。
在下一个练习中,您将为玩家投射物添加新的粒子系统和音频组件,使其在飞行时看起来更有趣并且听起来更有趣。
练习 14.10:向玩家投射物添加效果
在当前状态下,玩家投射物的功能是按预期的方式运行的;它在空中飞行,与游戏世界中的物体碰撞,并被摧毁。然而,从视觉上看,玩家投射物只是一个带有纯白色纹理的球。
在这个练习中,您将通过添加粒子系统和音频组件为玩家投射物增添一层精致,使得投射物更加愉快使用。
完成以下步骤以实现这一点:
- 与之前的练习一样,我们需要从“动作 RPG”项目迁移资产到我们的
Super SideScroller
项目。请参考练习 14.09,“在敌人被销毁时添加效果”,了解如何安装和迁移来自“动作 RPG”项目的资产。
您要添加到项目中的两个主要资产如下:
P_Env_Fire_Grate_01
A_Ambient_Fire01_Cue
P_Env_Fire_Grate_01
粒子系统资产引用了其他资产,例如包含在Effects
目录中的材质和纹理,而A_Ambient_Fire01_Cue
引用了包含在Assets
目录中的其他声音波和声音衰减资产。
您将用于玩家投射物的粒子是名为P_Env_Fire_Grate_01
,可以在/Effects/FX_Particle/
目录中找到。这是与之前练习中使用的P_Goblin_Death
VFX 相同的目录。您将用于玩家投射物的声音是名为A_Ambient_Fire01_Cue
,可以在/Assets/Sounds/Ambient/
目录中找到。
-
右键单击“动作 RPG”项目的“内容浏览器”界面中的每个资产,然后选择“资产操作”,然后选择“迁移”。
-
在确认迁移之前,请确保选择
Super SideScroller
项目的“内容”文件夹目录。
现在,必需的资产已迁移到我们的项目中,让我们继续创建玩家投射物类。
-
打开 Visual Studio,并导航到玩家投射物类的头文件;即
PlayerProjectile.h
。 -
在
Private
访问修饰符下,在UStaticMeshComponent* MeshComp
类组件声明下面,添加以下代码以声明玩家投射物的新音频组件:
UPROPERTY(VisibleDefaultsOnly, Category = Sound)
class UAudioComponent* ProjectileMovementSound;
- 接下来,在音频组件声明下面添加以下代码,以声明一个新的粒子系统组件:
UPROPERTY(VisibleDefaultsOnly, Category = Projectile)
class UParticleSystemComponent* ProjectileEffect;
与在蓝图中可以定义的属性不同,例如在敌人角色类中,这些效果将成为玩家投射物的组件。这是因为这些效果应该附加到投射物的碰撞组件上,以便它们随着投射物在关卡中移动时移动。
- 在头文件中声明这两个组件后,打开玩家投射物的源文件,并将以下包含添加到文件顶部的
include
行列表中:
#include "Components/AudioComponent.h"
#include "Engine/Classes/Particles/ParticleSystemComponent.h"
您需要引用音频组件和粒子系统类,以便使用CreateDefaultSubobject
函数创建这些子对象,并将这些组件附加到RootComponent
。
- 添加以下行以创建
ProjectileMovementSound
组件的默认子对象,并将此组件附加到RootComponent
:
ProjectileMovementSound = CreateDefaultSubobject<UAudioComponent> (TEXT("ProjectileMovementSound"));
ProjectileMovementSound->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
- 接下来,添加以下行以创建
ProjectileEffect
组件的默认子对象,并将此组件附加到RootComponent
:
ProjectileEffect = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("Projectile Effect"));
ProjectileEffect->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
-
现在,您已经创建、初始化并将这两个组件附加到
RootComponent
,返回到 Unreal Engine 4 编辑器中重新编译并热重载这些代码更改。 -
从“内容浏览器”界面,导航到
/MainCharacter/Projectile/
目录。找到BP_PlayerProjectile
资产,双击打开蓝图。
在“组件”选项卡中,您将找到使用前面的代码添加的两个新组件。请注意,这些组件附加到CollisionComp
组件,也称为RootComponent
。
- 左键单击选择
ProjectileEffect
组件,并在“详细信息”面板中将P_Env_Fire_Grate_01
VFX 资产分配给此参数,如下截图所示:
图 14.21:现在,您可以将 P_Env_fire_Grate_01 VFX 资产应用到您之前添加的粒子系统组件
- 在分配音频组件之前,让我们调整
ProjectileEffect
VFX 资产的Transform
。更新 VFX 的Transform
的Rotation
和Scale
参数,使其与以下截图中显示的内容匹配:
图 14.22:粒子系统组件的更新变换,以便更好地适应抛射物
- 导航到蓝图中的
Viewport
选项卡,查看Transform
的这些更改。ProjectileEffect
应该如下所示:
图 14.23:现在,火焰 VFX 已经被适当地缩放和旋转
-
现在 VFX 已经设置好了,左键单击
ProjectileMovementSound
组件,并将A_Ambient_Fire01_Cue
分配给该组件。 -
保存并重新编译
BP_PlayerProjectile
蓝图。使用PIE
并观察当你投掷抛射物时,它现在显示了 VFX 资产并播放了分配的声音:
图 14.24:玩家抛射物现在在飞行时有了 VFX 和 SFX
完成这个练习后,玩家的抛射物现在有了一个 VFX 和一个 SFX,它们在飞行时一起播放。这些元素使抛射物栩栩如生,并使其更有趣。
由于 VFX 和 SFX 是作为抛射物的组件创建的,它们在抛射物被销毁时也会被销毁。
在下一个练习中,你将向Throw
动画蒙太奇添加一个粒子通知和一个声音通知,以便在玩家投掷抛射物时提供更多的影响。
练习 14.11:添加 VFX 和 SFX 通知
到目前为止,你一直在通过 C++实现游戏的抛光元素,这是一种有效的实现手段。为了增加变化,并扩展你对虚幻引擎 4 工具集的了解,这个练习将教你如何在动画蒙太奇中使用通知来添加粒子系统和音频。让我们开始吧!
和之前的练习一样,我们需要从Action RPG
项目迁移资产到我们的Super SideScroller
项目。请参考练习 14.09,当敌人被销毁时添加特效,学习如何从Action RPG
项目安装和迁移资产。执行以下步骤:
- 打开
ActionRPG
项目,并导航到Content Browser
界面。
你添加到项目中的两个主要资产如下:
P_Skill_001
A_Ability_FireballCast_Cue
P_Skill_001
粒子系统资产引用了Effects
目录中包含的材质和纹理等其他资产,而A_Ability_FireballCast_Cue
引用了Assets
目录中包含的其他声音波资产。
当抛射物被投掷时,玩家将使用的粒子是P_Skill_001
,可以在/Effects/FX_Particle/
目录中找到。这是之前练习中使用的P_Goblin_Death
和P_Env_Fire_Grate_01
VFX 资产所使用的相同目录。你将用于敌人角色销毁的声音称为A_Ambient_Fire01_Cue
,可以在/Assets/Sounds/Ambient/
目录中找到。
-
右键单击
Action RPG
项目的Content Browser
界面中的每个资产,然后选择Asset Actions
,然后选择Migrate
。 -
在确认迁移之前,请确保选择
Super SideScroller
项目的Content
文件夹的目录。
现在你需要的资产已经迁移到你的项目中,让我们继续添加所需的通知到AM_Throw
资产。在继续进行这个练习之前,请确保返回到你的Super SideScroller
项目。
-
从
内容浏览器
界面,导航到/MainCharacter/Animation/
目录。找到AM_Throw
资产并双击打开它。 -
在
动画蒙太奇
编辑器中央的预览窗口下方,找到通知
部分。这是您在本章早些时候添加Anim_ProjectileNotify
的相同部分。 -
在
通知
轨道的右侧,您会找到一个+
号,允许您使用额外的通知轨道。左键单击添加一个新轨道,如下图所示:
图 14.25:在时间轴上添加多个轨道以在添加多个通知时保持组织
-
在与
Anim_ProjectileNotify
相同的帧中,在上一步创建的新轨道内右键单击。从添加通知
列表中左键单击选择播放粒子效果
。 -
创建后,左键单击选择新通知并访问其
详细信息
面板。在详细信息
中,将P_Skill_001
VFX 资产添加到粒子系统
参数中。
添加了这个新的 VFX 之后,您会注意到 VFX 几乎放在了玩家角色的脚下,但不完全是您想要的位置。这个 VFX 应该直接放在地板上,或者放在角色的底部。以下屏幕截图展示了这个位置:
图 14.26:粒子通知的位置不在地面上
为了解决这个问题,您需要向玩家角色骨架添加一个新的插座
。
-
导航到
/MainCharacter/Mesh/
目录。双击MainCharacter_Skeleton
资产以打开它。 -
在左侧的
骨骼
骨骼层次结构上,右键单击Hips
骨骼,左键单击选择添加插座
选项。将此新插座命名为EffectSocket
。 -
左键单击从骨骼层次结构中选择此插座,以查看其当前位置。默认情况下,其位置设置为与
Hips
骨骼相同的位置。以下屏幕截图显示了此位置:
(X=0.000000,Y=100.000000,Z=0.000000)
这个位置将更靠近地面和玩家角色的脚。最终位置如下图所示:
图 14.28:将插座位置移动到玩家骨架的底部
](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-dev-pj-ue/img/B16183_14_28.jpg)
图 14.28:将插座位置移动到玩家骨架的底部
-
现在您已经有了粒子通知的位置,请返回到
AM_Throw
动画蒙太奇。 -
在
播放粒子效果
通知的详细信息
面板中,有插座名称
参数。使用EffectSocket
作为名称。
注意
如果EffectSocket
没有出现在自动完成中,请关闭并重新打开动画蒙太奇。重新打开后,EffectSocket
选项应该会出现。
- 最后,粒子效果的比例有点太大,因此调整投影物的比例,使其值如下:
(X=0.500000,Y=0.500000,Z=0.500000)
现在,当通过此通知播放粒子效果时,其位置和比例将是正确的,如下所示:
图 14.29:粒子现在在玩家角色骨架的底部播放
-
要添加
播放声音
通知,请在通知
时间轴部分添加一个新轨道;现在总共应该有三个。 -
在这个新轨道上,并且与
播放粒子效果
和Anim_ProjectileNotify
通知的帧位置相同,右键单击并从添加通知
选择中选择播放声音
通知。以下屏幕截图显示了如何找到此通知:
图 14.30:您在本章早些时候了解到的 Play Sound 通知
-
接下来,左键单击选择
Play Sound
通知并访问其Details
面板。 -
从
Details
面板中找到Sound
参数,并分配A_Ability_FireballCast_Cue
。
分配了声音后,当播放Throw
动画时,您将看到 VFX 播放并听到声音。Notifies
轨道应如下所示:
图 14.31:投掷动画蒙太奇时间轴上的最终通知设置
-
保存
AM_Throw
资产并使用PIE
来投掷玩家投射物。 -
现在,当您投掷投射物时,您将看到粒子通知播放
P_Skill_001
VFX,并听到A_Ability_FireballCast_Cue
SFX。结果将如下所示:
图 14.32:现在,当玩家投掷投射物时,会播放强大的 VFX 和 SFX
完成这个最后的练习后,玩家现在在投掷玩家投射物时会播放强大的 VFX 和 SFX。这使得投掷动画更有力量,感觉就像玩家角色在用很多能量来投掷投射物。
在接下来的最后一个活动中,您将利用您从最近几个练习中获得的知识,为玩家投射物在被销毁时添加 VFX 和 SFX。
活动 14.02:为投射物销毁时添加效果
在这个最后的活动中,您将利用您从为玩家投射物和敌人角色添加 VFX 和 SFX 元素中获得的知识,为投射物与物体碰撞时创建爆炸效果。我们添加这个额外的爆炸效果的原因是为了在销毁投射物与环境物体碰撞时增加一定的光泽度。如果玩家投射物撞击物体并在没有任何音频或视觉反馈的情况下消失,那将显得尴尬和不合时宜。
您将为玩家投射物添加粒子系统和声音提示参数,并在投射物与物体碰撞时生成这些元素。
执行以下步骤以实现预期输出:
-
在
PlayerProjectile.h
头文件中,添加一个新的粒子系统变量和一个新的声音基础变量。 -
将粒子系统变量命名为
DestroyEffect
,将声音基础变量命名为DestroySound
。 -
在
PlayerProjectile.cpp
源文件中,将UGameplayStatics
的包含添加到包含列表中。 -
更新
APlayerProjectile::ExplodeProjectile()
函数,使其现在生成DestroyEffect
和DestroySound
对象。返回虚幻引擎 4 编辑器并重新编译新的 C++代码。在BP_PlayerProjectile
蓝图中,将默认包含在您的项目中的P_Explosion
VFX 分配给投射物的Destroy Effect
参数。 -
将
Explosion_Cue
SFX 分配给投射物的Destroy Sound
参数,该 SFX 已默认包含在您的项目中。 -
保存并编译玩家投射蓝图。
-
使用
PIE
观察新的玩家投射物销毁 VFX 和 SFX。
预期输出如下:
图 14.33:投射物 VFX 和 SFX
完成这个活动后,您现在已经有了为游戏添加光泽元素的经验。您不仅通过 C++代码添加了这些元素,还通过虚幻引擎 4 的其他工具添加了这些元素。在这一点上,您已经有足够的经验来为您的游戏添加粒子系统和音频,而不必担心如何实现这些功能。
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
总结
在本章中,您学到了在游戏开发世界中视觉和音频效果的重要性。通过使用 C++代码和通知的组合,您能够为玩家的抛射物和敌人角色的碰撞带来游戏功能,以及通过添加 VFX 和 SFX 来提升这些功能。除此之外,您还了解了在虚幻引擎 4 中如何生成和销毁对象。
此外,您还了解了动画蒙太奇如何在蓝图和 C++中播放。通过将从蓝图播放“投掷”动画蒙太奇的逻辑迁移到 C++,您学会了两种方法的工作原理,以及如何为游戏使用这两种实现。
通过使用 C++添加新的动画通知,您能够将此通知添加到“投掷”动画蒙太奇中,从而允许玩家生成上一章中创建的玩家抛射物。通过使用UWorld->SpawnActor()
函数,并向玩家骨骼添加新的插座,您能够在“投掷”动画的确切帧和您想要的确切位置生成玩家抛射物。
最后,您学会了如何在“投掷”动画蒙太奇中使用“播放粒子效果”和“播放声音”通知,为玩家抛射物的投掷添加 VFX 和 SFX。本章让您有机会了解虚幻引擎 4 中在游戏中使用 VFX 和 SFX 时存在的不同方法。
现在,玩家的抛射物可以被投掷并摧毁敌人角色,是时候实现游戏的最后一组机制了。在下一章中,您将创建玩家可以收集的可收集物品,并为玩家创建一个可以在短时间内改善玩家移动机制的增益道具。
第十四章:收藏品、强化道具和拾取物品
概述
在本章中,我们将为玩家创建可收藏的硬币和药水强化道具。此外,我们将使用虚幻运动图形 UI 设计师(UMG)为可收藏的硬币设计 UI。最后,我们将创建砖块,这些砖块将隐藏着这些收藏品。通过本章的学习,你将能够在关卡环境中为玩家角色实现收藏品和强化道具。
介绍
在上一章中,你创建了玩家投射物,并使用Anim Notifies
在“投掷”动画期间生成玩家投射物。玩家投射物将作为玩家对抗整个关卡中的敌人的主要进攻游戏机制。由于虚幻引擎 4 提供的默认Anim Notifies
和你自己的自定义Anim_ProjectileNotify
类的组合,玩家投射物机制看起来感觉很棒。
我们需要开发的最后一组机制是硬币收藏品和药水强化道具。让我们简要地分析一下收藏品和强化道具是如何影响其他游戏的,以及它们将为我们的“超级横向卷轴”游戏带来什么成就。
硬币收藏品
收藏品给玩家一个动力去彻底探索关卡。在许多游戏中,比如《虚空骑士》,收藏品也可以作为一种货币,用来购买角色升级和物品。在其他更经典的平台游戏中,比如超级马里奥或索尼克,收藏品可以提高玩家在关卡中的得分。
在当今的游戏环境中,游戏包含成就是一种预期。收藏品是将成就融入游戏的好方法;例如,在某个关卡或整个游戏中收集所有的硬币的成就。对于“超级横向卷轴”游戏来说,硬币收藏品将成为玩家探索游戏关卡的满意手段,尽可能多地找到硬币。
药水强化道具
强化道具给玩家永久或临时的优势,可以对抗敌人或者玩家必须穿越的环境。有许多游戏示例使用了强化道具,其中最著名的之一就是《银河战士》系列。《银河战士》使用强化道具让玩家探索新区域并对抗更强大的敌人。
强化道具也是将成就融入游戏的另一种方式。例如,你可以设定一个成就,使用特定的强化道具摧毁一定数量的敌人。对于“超级横向卷轴”游戏来说,药水强化道具将提高玩家在关卡环境中的能力,增加他们的移动速度和跳跃高度。
在本章中,你将学习如何使用 C++创建硬币收藏品和药水强化道具,为“超级横向卷轴”游戏增加更多的游戏层次。这些游戏元素将源自你将创建的相同基础actor
类。你还将为收藏品和强化道具添加视觉和音频元素,使它们更加精致。
为了使硬币收藏品和药水强化道具对玩家更具视觉吸引力,我们将为这些角色添加一个旋转组件,以吸引玩家的注意。这就是URotatingMovementComponent
非常有用的地方;它允许我们以一种非常优化和直接的方式为角色添加旋转,而不是编写自己的逻辑来处理角色的不断旋转。让我们开始学习更多关于这个组件的知识。
URotatingMovementComponent
URotatingMovementComponent
是 Unreal Engine 4 中存在的几个移动组件之一。在SuperSideScroller
游戏项目中,您已经熟悉了CharacterMovementComponent
和ProjectileMovementComponent
,而RotatingMovementComponent
只是另一个移动组件。作为一个复习,移动组件允许不同类型的移动发生在它们所属的 actor 或角色上。
注意
CharacterMovementComponent
允许您控制角色的移动参数,如其移动速度和跳跃高度,在第十章“创建 SuperSideScroller 游戏”中,当您创建SuperSideScroller
玩家角色时进行了介绍。ProjectileMovementComponent
允许您向 actor 添加基于抛射物的移动功能,如速度和重力,在第十四章“生成玩家抛射物”中,当您开发玩家抛射物时进行了介绍。
与CharacterMovementComponent
相比,RotatingMovementComponent
是一个非常简单的移动组件,因为它只涉及旋转RotatingMovementComponent
所属的 actor;没有其他操作。RotatingMovementComponent
根据定义的Rotation Rate
、枢轴平移以及使用本地空间或世界空间中的旋转选项执行组件的连续旋转。
此外,RotatingMovementComponent
与通过蓝图中的Event Tick
或Timelines
等其他旋转 actor 的方法相比要高效得多。
注意
关于移动组件的更多信息可以在这里找到:docs.unrealengine.com/en-US/Engine/Components/Movement/index.html#rotatingmovementcomponent
。
我们将使用RotatingMovementComponent
来允许硬币可收集和药水增强沿 Yaw 轴在原地旋转。这种旋转将吸引玩家的注意力,并给他们一个视觉提示,表明这个可收集物品是重要的。
现在您对RotatingMovementComponent
有了更好的理解,让我们继续创建PickableActor_Base
类,这是硬币可收集和药水增强将从中派生的类。
练习 15.01:创建 PickableActor_Base 类并添加 URotatingMovementComponent
在这个练习中,您将创建PickableActor_Base
actor 类,这将作为可收集的硬币和药水增强的基类。您还将从这个 C++基类创建一个蓝图类,以预览URotatingMovementComponent
的工作原理。按照以下步骤完成这个练习:
注意
在SuperSideScroller
游戏项目中,您已经多次执行了以下许多步骤,因此将有限的图像来帮助您进行指导。只有在引入新概念时才会有相应的图像。
-
在 Unreal Engine 4 编辑器中,左键单击编辑器左上角的“文件”选项,然后左键单击“新建 C++类”选项。
-
从“选择父类”窗口中,选择
Actor
选项,然后左键单击此窗口底部的“下一步”按钮。 -
将此类命名为
PickableActor_Base
,并将默认的“路径”目录保持不变。然后,选择此窗口底部的“创建类”按钮。 -
选择“创建类”按钮后,Unreal Engine 4 将重新编译项目代码,并自动打开 Visual Studio,其中包含
PickableActor_Base
类的头文件和源文件。 -
默认情况下,
Actor
类在头文件中提供了virtual void Tick(float DeltaTime) override;
函数声明。对于PickableActor_Base
类,我们不需要Tick
函数,因此从PickableActor_Base.h
头文件中删除此函数声明。 -
接下来,您还需要从
PickableActor_Base.cpp
文件中删除该函数;否则,您将收到编译错误。在此源文件中,查找并删除以下代码:
void PickableActor_Base::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
注意
在许多情况下,使用Tick()
函数进行移动更新可能会导致性能问题,因为Tick()
函数在每一帧都会被调用。相反,尝试使用Gameplay Timer
函数在指定的时间间隔执行某些更新,而不是在每一帧上执行。您可以在这里了解更多关于Gameplay Timers
的信息:docs.unrealengine.com/en-US/Programming/UnrealArchitecture/Timers/index.html
。
- 现在,是时候添加
PickableActor_Base
类所需的组件了。让我们从USphereComponent
开始,您将使用它来检测与玩家的重叠碰撞。在PickableActor_Base.h
头文件中的Protected
访问修饰符内添加以下代码:
UPROPERTY(VisibleDefaultsOnly, Category = PickableItem)
class USphereComponent* CollisionComp;
USphereComponent
的声明现在应该对您非常熟悉;我们在以前的章节中已经做过这个,比如第十六章,多人游戏基础,当我们创建PlayerProjectile
类时。
- 接下来,在声明
USphereComponent
下面添加以下代码来创建一个新的UStaticMeshComponent
。这将用于视觉上代表硬币可收集或药水提升:
UPROPERTY(VisibleDefaultsOnly, Category = PickableItem)
class UStaticMeshComponent* MeshComp;
- 最后,在声明
UStaticMeshComponent
下面添加以下代码来创建一个新的URotatingMovementComponent
。这将用于给可收集的硬币和药水提供简单的旋转运动:
UPROPERTY(VisibleDefaultsOnly, Category = PickableItem)
class URotatingMovementComponent* RotationComp;
- 现在,您已经在
PickableActor_Base.h
头文件中声明了组件,转到PickableActor_Base.cpp
源文件,以便为这些添加的组件添加所需的#includes
。在源文件的顶部,在第一个#include "PickableActor_Base.h"
之后添加以下行:
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"
#include "GameFramework/RotatingMovementComponent.h"
- 现在,您已经为组件准备好了必要的
include
文件,可以在APickableActor_Base::APickableActor_Base()
构造函数中添加必要的代码来初始化这些组件:
APickableActor_Base::APickableActor_Base()
{
}
- 首先,通过在
APickableActor_Base::APickableActor_Base()
中添加以下代码来初始化USphereComponent
组件变量CollisionComp
:
CollisionComp = CreateDefaultSubobject <USphereComponent>(TEXT("SphereComp"));
- 接下来,通过在上一步提供的代码下面添加以下代码,使用默认的球体半径
30.0f
来初始化USphereComponent
:
CollisionComp->InitSphereRadius(30.0f);
- 由于玩家角色需要与此组件重叠,因此您需要添加以下代码,以便默认情况下,
USphereComponent
具有Overlap All Dynamic
的碰撞设置:
CollisionComp->BodyInstance.SetCollisionProfileName("OverlapAllDynamic");
- 最后,
CollisionComp USphereComponent
应该是这个角色的根组件。添加以下代码来分配这个:
RootComponent = CollisionComp;
- 现在,
CollisionComp USphereComponent
已经初始化,让我们为MeshComp UStaticMeshComponent
做同样的事情。添加以下代码。之后,我们将讨论代码为我们做了什么:
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComp"));
MeshComp->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
MeshComp->SetCollisionEnabled(ECollisionEnabled::NoCollision);
第一行使用CreateDefaultSubobject()
模板函数初始化了MeshComp UStaticMeshComponent
。接下来,您使用AttachTo()
函数将MeshComp
附加到您为CollisionComp
创建的根组件。最后,MeshComp UStaticMeshComponent
默认不应具有任何碰撞,因此您使用SetCollisionEnabled()
函数并传入ECollisionEnable::NoCollision
枚举值。
- 最后,我们可以通过添加以下代码来初始化
URotatingMovementComponent RotationComp
:
RotationComp = CreateDefaultSubobject<URotatingMovementComponent>(TEXT("RotationComp"));
-
所有组件初始化后,编译 C++代码并返回到 Unreal Engine 4 编辑器。编译成功后,您将继续为
PickableActor_Base
创建蓝图类。 -
在
Content Browser
窗口中,通过右键单击Content
文件夹并选择New Folder
选项来创建一个名为PickableItems
的新文件夹。 -
在
PickableItems
文件夹中,右键单击并选择“蓝图类”。从“选择父类”窗口中,搜索PickableActor_Base
类并左键单击“选择”以创建新的蓝图。 -
将此蓝图命名为
BP_PickableActor_Base
并双击打开蓝图。 -
在“组件”选项卡中,选择
MeshComp Static Mesh Component
并将Shape_Cone
静态网格分配给“详细”面板中的“静态网格”参数。请参考以下截图:
图 15.1:分配给 BP_Pickable_Base actor 类的 MeshComp UStaticMeshComponent 的 Shape_Cone 网格
-
接下来,选择
RotationComp
URotatingMovementComponent
并在详细
面板的旋转组件
类别下找到旋转速率
参数。 -
将“旋转速率”设置为以下值:
(X=100.000000,Y=100.000000,Z=100.000000)
这些值确定了 actor 每秒沿每个轴旋转的速度。这意味着锥形 actor 将沿每个轴以每秒 100 度的速度旋转。
-
编译
PickableActor_Base
蓝图并将此 actor 添加到您的级别中。 -
现在,如果您使用 PIE 并查看级别中的
PickableActor_Base
actor,您将看到它正在旋转。请参考以下截图:
图 15.2:现在,锥形网格沿所有轴旋转,根据我们添加到 URotatingMovementComponent 的旋转速率窗口的值
注意
您可以在此处找到此练习的资产和代码:packt.live/3njhwyt
。
通过完成此练习,您已经创建了PickableActor_Base
类所需的基本组件,并学会了如何实现和使用URotatingMovementComponent
。有了准备好的PickableActor_Base
类,并且在蓝图 actor 上实现了URotatingMovementComponent
,我们可以通过添加重叠检测功能,销毁可收集的 actor,并在玩家拾取 actor 时产生音频效果来完成该类。在接下来的活动中,您将添加PickableActor_Base
类所需的其余功能。
活动 15.01:在 PickableActor_Base 中进行玩家重叠检测和产生效果
现在PickableActor_Base
类具有所有必需的组件,并且其构造函数初始化了这些组件,是时候添加其功能的其余部分了。这些功能将在本章后面的硬币可收集物和药水增益中继承。这些额外的功能包括玩家重叠检测,销毁可收集的 actor,并产生音频效果以向玩家提供反馈,表明它已被成功拾取。执行以下步骤以添加功能,允许USoundBase
类对象在可收集物与玩家重叠时播放:
-
在
PickableActor_Base
类中创建一个接受玩家引用作为输入参数的新函数。将此函数命名为PlayerPickedUp
。 -
创建一个名为
BeginOverlap()
的新UFUNCTION
。在继续之前,请确保包括此函数的所有必需输入参数。请参考第六章,碰撞对象,在那里您在VictoryBox
类内使用了此函数。 -
为
USoundBase
类添加一个新的UPROPERTY()
,并将其命名为PickupSound
。 -
在
PickableActor_Base.cpp
源文件中,为BeginOverlap()
和PlayerPickedUp()
函数创建定义。 -
现在,在源文件的顶部为
SuperSideScroller_Player
类和GameplayStatics
类添加所需的#include
文件。 -
在
BeginOverlap()
函数中,使用函数的OtherActor
输入参数创建对玩家的引用。 -
在
PlayerPickedUp()
函数中,为GetWorld()
函数返回的UWorld*
对象创建一个变量。 -
使用
UGameplayStatics
库在PickableActor_Base
actor 的位置生成PickUpSound
。 -
然后,调用
Destroy()
函数,以便角色被销毁并从世界中移除。 -
最后,在
APickableActor_Base::APickableActor_Base()
构造函数中,将CollisionComp
的OnComponentBeginOverlap
事件绑定到BeginOverlap()
函数。 -
从
Epic Games Launcher
的Learn
选项卡中下载并安装Unreal Match 3
项目。使用您在第十四章中获得的知识,将Match_Combo
声波资产从该项目迁移到您的SuperSideScroller
项目中。 -
将此声音应用到
BP_PickableActor_Base
蓝图的PickupSound
参数上。 -
编译蓝图,如果您的关卡中不存在蓝图,则现在将
BP_PickableActor_Base
actor 添加到您的关卡中。 -
在
PIE
中,使您的角色与BP_PickableActor_Base
actor 重叠。
预期输出:
图 15.3:BP_PickableActor_Base 对象可以被玩家重叠和拾取
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
完成这个活动后,您已经证明了您如何向您的角色类添加OnBeginOverlap()
功能,并且如何使用这个功能来执行您的角色的逻辑的知识。在PickableActor_Base
的情况下,我们添加了一个逻辑,将生成一个自定义声音并销毁该角色。
现在PickableActor_Base
类已经设置好了,是时候开发从中派生的可收集硬币和增益药水类了。硬币可收集类将继承您刚刚创建的PickableActor_Base
类。它将覆盖关键功能,如PlayerPickedUp()
函数,以便我们可以在玩家拾取时实现独特的逻辑。除了从继承的父PickableActor_Base
类中覆盖功能之外,硬币可收集类还将具有其自己独特的属性集,如当前硬币价值和独特的拾取声音。我们将在下一个练习中一起创建硬币可收集类。
练习 15.02:创建 PickableActor_Collectable 类
在这个练习中,您将创建PickableActor_Collectable
类,该类将从您在练习 15.01中创建的PickableActor_Base
类派生,并在活动 15.01中完成,创建 PickableActor_Base 类并添加 URotatingMovement 组件。这个类将被用作玩家可以在关卡中收集的主要可收集硬币。按照以下步骤完成这个练习:
-
在虚幻引擎 4 编辑器中,左键单击编辑器左上角的
文件
选项,然后左键单击新建 C++类
选项。 -
从
Choose Parent Class
窗口中选择PickableActor_Base
选项,然后在此窗口底部左键单击Next
按钮。 -
将此类命名为
PickableActor_Collectable
,并将默认的Path
目录保持不变。然后,在此窗口底部选择Create Class
按钮。 -
选择
Create Class
按钮后,虚幻引擎 4 将重新编译项目代码,并将自动打开 Visual Studio,显示PickableActor_Collectable
类的头文件和源文件。 -
默认情况下,
PickableActor_Collectable.h
头文件在其类声明中没有声明的函数或变量。您需要在新的Protected Access Modifier
下添加BeginPlay()
函数的覆盖。添加以下代码:
protected:
virtual void BeginPlay() override;
我们覆盖“BeginPlay()函数的原因是,
URotatingMovementComponent需要角色初始化并使用“BeginPlay()
来正确旋转角色。因此,我们需要创建这个函数的覆盖声明,并在源文件中创建一个基本的定义。然而,首先,我们需要覆盖另一个重要的函数,来自PickableActor_Base
父类。
- 通过在“Protected Access Modifier”下添加以下代码,覆盖
PickableActor_Base
父类中的PlayerPickedUp()
函数:
virtual void PlayerPickedUp(class ASuperSideScroller_Player* Player) override;
通过这样做,我们表明我们将使用并覆盖“PlayerPickedUp()`函数的功能。
- 最后,创建一个名为
UPROPERTY()
的新整数,它将保存硬币可收集的价值;在这种情况下,它的价值将是1
。添加以下代码来实现这一点:
public:
UPROPERTY(EditAnywhere, Category = Collectable)
int32 CollectableValue = 1;
在这里,我们正在创建一个整数变量,该变量将在蓝图中可访问,并具有默认值为1
。如果您愿意,可以使用“EditAnywhere UPROPERTY()`关键字来更改硬币可收集物品的价值。
- 现在,我们可以继续在
PickableActor_Collectable.cpp
源文件中创建覆盖的“PlayerPickedUp()`函数的定义。在源文件中添加以下代码:
void APickableActor_Collectable::PlayerPickedUp(class ASuperSideScroller_Player* Player)
{
}
- 现在,我们需要使用
Super
关键字调用“PlayerPickedUp()父函数。将以下代码添加到“PlayerPicked()
函数中:
Super::PlayerPickedUp(Player);
使用Super::PlayerPickedUp(Player)
调用父函数,将确保您在PickableActor_Base
类中创建的功能被调用。您可能还记得,父类中的“PlayerPickedUp()函数调用生成
PickupSound`声音对象并销毁角色。
- 接下来,在源文件中创建
BeginPlay()
函数的定义,添加以下代码:
void APickableActor_Collectable::BeginPlay()
{
}
- 在 C++中,最后要做的一件事是再次使用
Super
关键字调用“BeginPlay()父函数。将以下代码添加到
PickableActor_Collectable类中的“BeginPlay()
函数中:
Super::BeginPlay();
- 编译 C++代码并返回编辑器。
注意
您可以在以下链接找到此练习的资产和代码:packt.live/35fRN3E
。
现在您已成功编译了PickableActor_Collectable
类,已经为硬币可收集物品创建了所需的框架。在接下来的活动中,您将从这个类创建一个蓝图,并完成硬币可收集物品角色。
活动 15.02:完成 PickableActor_Collectable 角色
现在,PickableActor_Collectable
类已经具有了所有必要的继承功能和独特属性,是时候从这个类创建蓝图,并添加一个Static Mesh
,更新其URotatingMovementComponent
,并将声音应用到PickUpSound
属性。执行以下步骤来完成PickableActor_Collectable
角色:
-
从
Epic Games Launcher
中,在Learn
选项卡下的Engine Feature Samples
类别下找到Content Examples
项目。 -
从
Content Examples
项目中创建并安装一个新项目。 -
将
SM_Pickup_Coin
资产及其所有引用的资产从Content Examples
项目迁移到您的SuperSideScroller
项目。 -
在
Content Browser
窗口中的Content/PickableItems
目录中创建一个名为Collectable
的新文件夹。 -
在这个新的
Collectable
文件夹中,从您在练习 15.02中创建的PickableActor_Collectable
类创建一个新的蓝图。将这个新的蓝图命名为BP_Collectable
。 -
在这个蓝图中,将
MeshComp
组件的Static Mesh
参数设置为您在本次活动中导入的SM_Pickup_Coin
网格。 -
接下来,将
Match_Combo
声音资源添加到可收集物品的PickupSound
参数中。 -
最后,更新
RotationComp
组件,使演员沿 Z 轴以每秒 90 度旋转。 -
编译蓝图,在您的级别中放置
BP_Collectable
,并使用 PIE。 -
将玩家角色与
BP_Collectable
演员重叠,并观察结果。
预期输出:
图 15.4:可旋转的硬币可被玩家重叠
注意
此活动的解决方案可在以下位置找到:packt.live/338jEBx
。
完成此活动后,您已经证明了您知道如何将资产迁移到您的虚幻项目中,以及如何使用和更新URotatingMovementComponent
以满足硬币收藏的需求。现在硬币收藏演员已经完成,是时候为玩家添加功能,以便玩家可以跟踪他们收集了多少硬币。
首先,我们将创建使用UE_LOG
计算硬币数量的逻辑,然后在游戏的 UI 上使用 UMG 实现硬币计数器。
使用 UE_LOG 记录变量
在第十一章,混合空间 1D,键绑定和状态机中,我们使用并了解了UE_LOG
函数,以便在玩家应该投掷抛射物时记录。然后我们在第十三章,敌人人工智能中使用UE_LOG
函数,记录玩家抛射物击中物体的情况。UE_LOG
是一个强大的日志记录工具,我们可以使用它将重要信息从我们的 C++函数输出到编辑器中的输出日志
窗口中。到目前为止,我们只记录了FStrings
,以在输出日志
窗口中显示一般文本,以了解我们的函数是否被调用。现在是时候学习如何记录变量以调试玩家收集了多少硬币。
注意
在 Unreal Engine 4 中还有另一个有用的调试函数,称为AddOnScreenDebugMessage
。您可以在这里了解更多关于此功能的信息:docs.unrealengine.com/en-US/API/Runtime/Engine/Engine/UEngine/AddOnScreenDebugMessage/1/index.html
。
在创建TEXT()
宏使用的FString
语法时,我们可以添加格式说明符以记录不同类型的变量。我们只讨论如何为整数变量添加格式说明符。
注意
您可以通过阅读以下文档找到有关如何指定其他变量类型的更多信息:www.ue4community.wiki/Logging#Logging_an_FString
。
这是在传递FString "Example Text"
时UE_LOG()
的样子:
UE_LOG(LogTemp, Warning, TEXT("Example Text"));
在这里,您有Log Category
,Log Verbose Level
和实际的FString
,"Example Text"
,显示在日志中。要记录整数变量,您需要在TEXT()
宏中添加%d
,然后在TEXT()
宏之外添加整数变量名称,用逗号分隔。这是一个例子:
UE_LOG(LogTemp, Warning, TEXT("My integer variable %d), MyInteger);
格式说明符由%
符号标识,每种变量类型都有一个对应的字母。在整数的情况下,使用字母d
。您将使用此方法记录整数变量,以记录玩家在下一个练习中拥有的硬币收藏数量。
练习 15.03:跟踪玩家的硬币数量
在这个练习中,您将创建必要的属性和函数,以便跟踪玩家在整个级别中收集的硬币数量。您将在本章后面使用此跟踪来向玩家展示。按照以下步骤完成此练习:
-
在 Visual Studio 中,找到并打开
SuperSideScroller_Player.h
头文件。 -
在
Private Access Modifier
下,创建一个名为NumberofCollectables
的新int
变量,如下所示:
int32 NumberofCollectables;
这将是一个私有属性,用于跟踪玩家已收集的硬币的当前数量。您将创建一个公共函数,用于返回这个整数值。出于安全原因,我们这样做是为了确保没有其他类可以修改这个值。
- 在现有的
public
访问修饰符下,使用BlueprintPure
关键字创建一个新的UFUNCTION()
,名为GetCurrentNumberOfCollectables()
。这个函数将返回一个int
。以下代码将其添加为内联函数:
UFUNCTION(BlueprintPure)
int32 GetCurrentNumberofCollectables() { return NumberofCollectables; };
我们使用UFUNCTION()
和BlueprintPure
关键字将这个函数暴露给蓝图,以便我们以后在 UMG 中使用它。
- 声明一个新的
void
函数,在public
访问修饰符下,名为IncrementNumberofCollectables()
,接受一个名为Value
的整数参数:
void IncrementNumberofCollectables(int32 Value);
这是您将用来跟踪玩家收集了多少硬币的主要函数。我们还将添加一些安全措施,以确保这个值永远不会是负数。
-
声明了
IncrementNumberofCollectables()
函数,让我们在SuperSideScroller_Player.cpp
源文件中创建这个函数的定义。 -
编写以下代码来创建
IncrementNumberofCollectables
函数的定义:
void ASuperSideScroller_Player::IncrementNumberofCollectables(int32 Value)
{
}
- 这里需要处理的主要情况是,传递给这个函数的整数值是否小于或等于
0
。在这种情况下,我们不希望麻烦增加NumberofCollectables
变量。在IncrementNumberofCollectables()
函数中添加以下代码:
if(Value== 0)
{
return;
}
这个if()
语句表示如果value
输入参数小于或等于0
,函数将结束。由于IncrementNumberofCollectables()
函数返回void
,在这种情况下使用return
关键字是完全可以的。
我们添加了这个检查,确保传递给IncrementNumberofCollectables()
函数的value
参数既不是 0 也不是负数,因为建立良好的编码习惯非常重要;这保证了处理了所有可能的结果。在实际的开发环境中,可能会有设计师或其他程序员尝试使用IncrementNumberofCollectables()
函数并尝试传递一个负值或等于 0 的值。如果函数没有考虑到这些可能性,后续开发中可能会出现 bug。
- 现在我们已经处理了
value
小于或等于0
的边缘情况,让我们继续使用else()
语句来增加NumberofCollectables
。在上一步的if()
语句下面添加以下代码:
else
{
NumberofCollectables += Value;
}
- 接下来,让我们使用
UE_LOG
和我们学到的关于记录变量的知识来记录NumberofCollectables
。在else()
语句之后添加以下代码来正确记录NumberofCollectables
:
UE_LOG(LogTemp, Warning, TEXT("Number of Coins: %d"), NumberofCollectables);
通过UE_LOG()
,我们正在创建一个更健壮的日志来跟踪硬币的数量。这为 UI 的工作奠定了基础。这是因为我们实质上是通过 UMG 在本章后期向玩家记录相同的信息。
添加了UE_LOG()
后,我们只需要在PickableActor_Collectable
类中调用IncrementNumberofCollectables()
函数。
- 在
PickableActor_Collectable.cpp
源文件中,添加以下头文件:
#include "SuperSideScroller_Player.h"
- 接下来,在
PlayerPickedUp()
函数内,在Super::PlayerPickedUp(Player)
行之前添加以下函数调用:
Player->IncrementNumberofCollectables(CollectableValue);
-
现在,我们的
PickableActor_Collectable
类正在调用我们玩家的IncrementNumberofCollectables
函数,重新编译 C++代码并返回到 Unreal Engine 4 编辑器。 -
在 UE4 编辑器中,通过左键单击
Window
,然后悬停在Developer Tools
选项上,打开Output Log
窗口。从这个额外的下拉菜单中选择Output Log
。 -
现在,在你的关卡中添加多个
BP_Collectable
角色,然后使用 PIE。 -
当您重叠每个可收集的硬币时,请观察“输出日志”窗口,以找出每次收集硬币时,“输出日志”窗口将向您显示您已收集了多少枚硬币。
注意
您可以在此处找到此练习的资产和代码:packt.live/36t6xM5
。
通过完成此练习,您现在已经完成了开发跟踪玩家收集的硬币数量的 UI 元素所需工作的一半。下半部分将使用在此活动中开发的功能在 UMG 内向玩家在屏幕上显示这些信息。为此,我们需要在虚幻引擎 4 内学习更多关于 UMG 的知识。
UMG
UMG,或虚幻动态图形用户界面设计师,是虚幻引擎 4 用于创建菜单、游戏内 HUD 元素(如生命条)和其他用户界面的主要工具。
在SuperSideScroller
游戏中,我们将仅使用“文本”小部件来构建我们的练习 15.04中的“硬币收集 UI”,创建硬币计数器 UI HUD 元素。我们将在下一节中更多地了解“文本”小部件。
文本小部件
“文本”小部件是存在的较简单的小部件之一。这是因为它只允许您向用户显示文本信息并自定义这些文本的视觉效果。几乎每个游戏都以某种方式使用文本向玩家显示信息。例如,《守望先锋》使用基于文本的用户界面向玩家显示关键的比赛数据。如果不使用文本,向玩家传达关键的统计数据,如总伤害、游戏时间总计等,可能会非常困难,甚至不可能。
“文本”小部件出现在 UMG 的“调色板”选项卡中。当您将“文本”小部件添加到“画布”面板时,它将默认显示“文本块”。您可以通过将文本添加到小部件的“文本”参数中来自定义此文本。或者,您可以使用“功能绑定”来显示更强大的文本,可以引用内部或外部变量。“功能绑定”应该在需要显示可能会改变的信息时使用;这可能是代表玩家得分、玩家拥有的金钱数量,或者在我们的情况下,玩家收集的硬币数量。
您将使用“文本”小部件的“功能绑定”功能来显示玩家使用您在练习 15.03中创建的“GetCurrentNumberofCollectables()”函数收集的硬币数量,跟踪玩家的硬币数量。
现在我们在“画布”面板中有了“文本”小部件,是时候将这个小部件定位到我们需要的位置了。为此,我们将利用锚点。
锚点
锚点用于定义小部件在“画布”面板上的期望位置。一旦定义,锚点将确保小部件在不同平台设备(如手机、平板电脑和计算机)的不同屏幕尺寸上保持这个位置。没有锚点,小部件的位置可能会在不同的屏幕分辨率之间变化,这是不希望发生的。
注意
有关锚点的更多信息,请参阅以下文档:docs.unrealengine.com/en-US/Engine/UMG/UserGuide/Anchors/index.html
。
为了我们的“硬币收集 UI”和您将使用的“文本”小部件,锚点将位于屏幕的左上角。您还将从此“锚点”位置添加位置偏移,以便文本对玩家更加可见和可读。在继续创建我们的“硬币收集 UI”之前,让我们了解一下“文本格式”,您将使用它来向玩家显示当前收集的硬币数量。
文本格式
与 C++中可用的UE_LOG()
宏类似,蓝图提供了类似的解决方案,用于显示文本并格式化文本以允许添加自定义变量。格式文本
函数接受一个标记为Format
的单个文本输入,并返回Result
文本。然后可以用于显示信息:
图 15.5:格式文本函数允许我们使用格式化参数自定义文本
格式文本
函数不像UE_LOG()
那样使用%
符号,而是使用{}
符号来表示可以传递到字符串中的参数。在{}
符号之间,您需要添加一个参数名称;这可以是任何你想要的东西,但它应该代表参数是什么。请参考以下截图中显示的示例:
图 15.6:在这里,我们将一个示例整数传递到格式化文本中
格式文本
函数仅支持Byte
、Integer
、Float
、Text
或EText Gender
变量类型,因此,如果您尝试将任何其他类型的变量作为参数传递到函数中,您必须将其转换为受支持的类型之一。
注意
格式文本
功能还用于文本本地化
,您可以为游戏支持多种语言。有关如何在 C++和蓝图中执行此操作的更多信息,请访问:docs.unrealengine.com/en-US/Gameplay/Localization/Formatting/index.html
。
在下一个练习中,您将在 UMG 中的Text
小部件中与格式文本
函数一起使用,我们将创建Coin Counter UI
小部件,以显示玩家收集的硬币数量。您还将使用Anchors
将Text
小部件定位在屏幕的左上角。
练习 15.04:创建硬币计数器 UI HUD 元素
在这个练习中,您将创建 UMG UI 资产,用于显示和更新玩家收集的硬币数量。您将使用在练习 15.02中创建的GetCurrentNumberofCollectables()
内联函数,在屏幕上使用简单的Text
小部件显示此值。按照以下步骤完成此操作:
-
让我们首先在
Content Browser
窗口内创建一个名为UI
的新文件夹。在编辑器中的浏览器目录顶部的Content
文件夹上右键单击,然后选择New Folder
。 -
在新的
/Content/UI
目录内,右键单击,而不是选择Blueprint Class
,悬停在列表底部的User Interface
选项上,然后左键单击Widget Blueprint
选项。 -
将这个新的
Widget Blueprint
命名为BP_UI_CoinCollection
,然后双击该资产以打开 UMG 编辑器。 -
默认情况下,
Widget
面板是空的,您会在左侧找到一个空的层次结构,如下截图所示:
图 15.7:Widget 面板层次结构概述了 UI 的不同元素如何相互叠加
- 在
Hierarchy
选项卡上方是Palette
选项卡,列出了您可以在 UI 内使用的所有可用小部件。我们只关注Text
小部件,它列在Common
类别下。不要将此选项与 Rich Text Block 小部件混淆。
注意
有关 UMG 中所有可用Widgets
的更详细参考,请阅读 Epic Games 的以下文档:docs.unrealengine.com/en-US/Engine/UMG/UserGuide/WidgetTypeReference/index.html
。
- 通过左键单击并将
Text
小部件从Palette
选项卡拖动到Canvas
面板根下的Hierarchy
选项卡,或者通过左键单击并将Text
小部件直接拖放到 UMG 编辑器中间的Canvas
面板本身中,将Text
小部件添加到UI
面板。
在更改此小部件的文本之前,我们需要更新其锚点、位置和字体大小,以满足我们在向玩家显示信息方面的需求。
- 选择
Text
小部件后,您会在其Details
面板下看到许多选项来自定义此文本。这里要做的第一件事是将Text
小部件锚定到Canvas
面板的左上角。左键单击Anchors
下拉菜单,并选择左上角的锚定选项,如下截图所示:
图 15.8:默认情况下,有选项可以锚定小部件在屏幕的不同位置
锚定允许小部件在Canvas
面板内保持所需的位置,而不受不同屏幕尺寸的影响。
现在Text
小部件已经锚定在左上角,我们需要设置它相对于此锚点的位置,以便为文本提供更好的定位和可读性的偏移量。
-
在
Anchors
选项下的Details
面板中,有Position X
和Position Y
的参数。将这两个参数都设置为100.0f
。 -
接下来,启用
Size To Content
参数,以便Text
小部件的大小将根据其显示的文本大小自动调整大小,如下截图所示:
图 15.9:Size To Content
参数将确保Text
小部件将显示其完整内容,不会被切断
-
这里需要做的最后一件事是更新
Text
小部件使用的字体大小。在Text
小部件的Details
面板的Appearance
选项卡下,您会找到Size
参数。将此值设置为48
。 -
最终的
Text
小部件将如下所示:
图 15.10:现在Text
小部件已经锚定在画布面板的左上角,具有较小的相对偏移和更大的字体,以便玩家更容易阅读
现在Text
小部件已经定位和调整大小,让我们为文本添加一个新的绑定,以便它将自动更新并匹配玩家拥有的可收集物品的数量的值。
-
选择
Text
小部件后,在其Details
面板的Content
类别下找到Text
参数。在那里,您会找到Bind
选项。 -
左键单击
Bind
选项,然后选择Create Binding
。这样做时,新的Function Binding
将自动创建,并被命名为GetText_0
。请参考以下截图:
图 15.11:重命名绑定函数非常重要,因为它们的默认名称太通用了
-
将此函数重命名为
获取可收集物品的数量
。 -
在继续使用此函数之前,创建一个名为
Player
的新对象引用变量,其类型为SuperSideScroller_Player
。通过启用变量的Instance Editable
和Expose on Spawn
参数,使此变量成为Public
并在生成时可公开,如下面的截图所示:
图 15.12:现在,Player 变量应该具有 Instance Editable 和 Expose on Spawn 参数
通过将Player
变量设置为Public
并在生成时公开,您将能够在创建小部件并将其添加到屏幕时分配此变量。我们将在练习 15.05中执行此操作,将硬币计数器 UI 添加到玩家屏幕。
现在我们有一个对SuperSideScroller_Player
的引用变量,让我们继续使用Get Number of Collectables
绑定函数。
-
将
Player
变量的Getter
添加到Get Number of Collectables
函数中。 -
从此变量中,左键单击 并从上下文敏感的下拉菜单中拖动,并找到并选择
Get Current Number of Collectables
函数。请参阅下面的截图:
图 15.13:您在练习 15.03 中创建的 Get Current Numberof Collectables C++函数
- 接下来,左键单击 并拖动
Get Number of Collectables
的Return Value
文本参数到Return Node
。从上下文敏感的下拉菜单中,搜索并选择Format Text
选项,如下面的截图所示:
图 15.14:现在,我们可以创建自定义和格式化的文本以满足文本的需求
- 在
Format Text
函数中添加以下文本:
Coins: {coins}
请参阅下面的截图:
图 15.15:现在,格式化的文本有一个新的输入参数,我们可以使用它来显示自定义信息
请记住,使用{}
符号表示允许您将变量传递到文本中的文本参数。
- 最后,将
GetCurrentNumberofCollectables()
函数的整数Return Value
连接到Format Text
函数的通配符coins
输入引脚,如下所示:
图 15.16:现在,文本小部件将根据从 Get Current Numberof Collectables 函数返回的更新值自动更新
- 编译并保存
BP_UI_CoinCollection
小部件蓝图。
注意
您可以在此处找到此练习的资产和代码:packt.live/3eQJjTU
。
完成此练习后,您已经创建了显示玩家收集的硬币当前数量所需的UI UMG
小部件。通过使用GetCurrentNumberofCollectables()
C++函数和Text
小部件的绑定功能,UI 将根据收集的硬币数量始终更新其值。在下一个练习中,我们将将此 UI 添加到玩家的屏幕上,但首先,我们将简要了解如何向玩家屏幕添加和删除 UMG。
添加和创建 UMG 用户小部件
现在我们已经在 UMG 中创建了 Coin Collection UI,是时候学习如何将 UI 添加到玩家屏幕上并从中移除了。通过将 Coin Collection UI 添加到玩家屏幕上,UI 将对玩家可见,并且可以在玩家收集硬币时进行更新。
在蓝图中,有一个名为Create Widget
的函数,如下面的屏幕截图所示。如果没有分配类,它将被标记为Construct None
,但不要让这使你困惑:
图 15.17:默认情况下的 Create 小部件,没有应用类
此函数要求创建User
小部件的类,并需要一个Player Controller
作为此 UI 的拥有玩家的引用。然后,此函数将生成的用户小部件作为其Return Value
返回,然后您可以使用Add to Viewport
函数将其添加到玩家的视口。 Create Widget
函数只实例化小部件对象;它不会将此小部件添加到玩家的屏幕上。正是Add to Viewport
函数使此小部件在玩家的屏幕上可见。
图 15.18:带有 ZOrder 的 Add to Viewport 函数
视口是游戏屏幕,覆盖了你对游戏世界的视图,并且它使用所谓的ZOrder
来确定覆盖深度,在多个 UI 元素需要在彼此上方或下方重叠的情况下。默认情况下,Add to Viewport
函数将把User
小部件添加到屏幕上,并使其填满整个屏幕;也就是说,除非调用Set Desired Size In Viewport
函数来手动设置它应该填充的大小:
图 15.19:Size 参数确定传入的 User 小部件的期望大小
在 C++中,您还有一个名为“CreateWidget()”的函数:
template<typename WidgetT, typename OwnerT>
WidgetT * CreateWidget
(
OwnerT * OwningObject,
TSubclassOf < UUserWidget > UserWidgetClass,
FName WidgetName
)
“CreateWidget()”函数可通过UserWidget
类使用,在/Engine/Source/Runtime/UMG/Public/Blueprint/UserWidget.h
中可以找到。
可以在第八章“用户界面”中找到一个例子,您可以使用“CreateWidget()”函数创建BP_HUDWidget
:
HUDWidget = CreateWidget<UHUDWidget>(this, BP_HUDWidget);
有关 C++中“CreateWidget()”函数的更多信息,请参阅第八章“用户界面”和Exercise 8.06“创建健康条 C++逻辑”。
这个函数几乎与其蓝图对应函数的工作方式相同,因为它接受Owning Object
参数,就像蓝图函数的Owning Player
参数一样,并且需要创建User Widget
类。C++的“CreateWidget()”函数还接受一个FName
参数来表示小部件的名称。
现在我们已经了解了用于向玩家屏幕添加 UI 的方法,让我们将这些知识付诸实践。在以下练习中,您将实现Create Widget
和Add to Viewport
蓝图函数,以便我们可以将我们在Exercise 15.04中创建的硬币收集 UI 添加到玩家屏幕上。
练习 15.05:将硬币计数器 UI 添加到玩家屏幕
在这个练习中,您将创建一个新的Player Controller
类,以便您可以使用玩家控制器将BP_UI_CoinCollection
小部件蓝图添加到玩家的屏幕上。然后,您还将创建一个新的Game Mode
类,并将此游戏模式应用于SuperSideScroller
项目。执行以下步骤完成此练习:
-
在虚幻引擎 4 编辑器中,导航到“文件”,然后选择“新建 C++类”。
-
从“选择父类”对话框中,找到并选择
Player Controller
选项。 -
将新的
Player Controller
类命名为SuperSideScroller_Controller
,然后左键单击“创建类”按钮。Visual Studio 将自动生成并打开SuperSideScroller_Controller
类的源文件和头文件,但现在我们将留在虚幻引擎 4 编辑器内。 -
在“内容浏览器”窗口中,在
MainCharacter
文件夹目录下,创建一个名为PlayerController
的新文件夹。 -
在
PlayerController
文件夹中,右键并使用新的SuperSideScroller_Controller
类创建一个新的Blueprint Class
。请参考以下截图:
图 15.20:找到新的 SuperSideScroller_Controller 类以创建一个新的蓝图
- 将这个新的蓝图命名为
BP_SuperSideScroller_PC
,然后双击该资产以打开它。
要将BP_UI_CoinCollection
widget 添加到屏幕上,我们需要使用Add to Viewport
函数和Create Widget
函数。我们希望在玩家角色被玩家控制器Possess
之后,将 UI 添加到玩家的屏幕上。
- 右键在蓝图图表中,并从上下文敏感菜单中找到
Event On Possess
选项,左键将其添加到图表中。请参考以下截图:
图 15.21:每次这个控制器类拥有一个新的 pawn 时,将调用 Event On Possess 选项
Event On Possess
事件节点返回Possessed Pawn
。我们将使用这个 pawn 传递给我们的BP_UI_CoinCollection UI Widget
,但首先,我们需要Cast To
SuperSideScroller_Player
类。
- 左键并从
Event On Possess
节点的Possessed Pawn
参数输出中拖动。然后,搜索并找到Cast to SuperSideScroller_Player
节点。请参考以下截图:
图 15.22:我们需要转换为 SuperSideScroller_Player 以确保我们转换到正确的玩家角色类
-
现在,右键并搜索
Create Widget
函数将其添加到蓝图图表中。 -
从下拉类参数中,找到并分配在Exercise 15.04中创建的
BP_UI_CoinCollection
资产,Creating the Coin Counter UI HUD Element。请参考以下截图:
图 15.23:Create Widget 函数将使用传递给它的 UMG 类创建一个新的 UI 对象
将Class
参数更新为BP_UI_CoinCollection
类后,您会注意到Create Widget
函数将更新以显示您创建的Player
变量,设置为Exposed on Spawn
。
- 右键在蓝图图表中搜索并找到
Self
引用变量。将Self
对象变量连接到Create Widget
函数的Owning Player
参数,如下图所示:
图 15.24:Owning Player 输入参数是 Player Controller 类型
拥有玩家
参数是指将显示和拥有此 UI 对象的Player Controller
类型。由于我们将此 UI 添加到SuperSideScroller_Controller
蓝图中,我们可以直接使用Self
引用变量传递给函数。
- 接下来,将返回的
SuperSideScroller_Player
变量从Cast
节点传递到Create Widget
函数的Player
输入节点。然后,连接Cast
节点和Create Widget
函数的执行引脚,如下图所示:
图 15.25:如果 Cast To SuperSideScroller_Player 有效,我们可以创建 BP_UI_CoinCollection widget 并传递被占有的玩家
注意
您可以在以下链接找到前面截图的全分辨率以获得更好的查看体验:packt.live/3f89m99
。
-
右键单击蓝图图表内部再次搜索并找到
Add to Viewport
函数,以便将其放置在图表中。 -
将
Create Widget
函数的输出Return Value
参数连接到Add to Viewport
函数的Target
输入参数;不要更改ZOrder
参数。 -
最后,连接
Create Widget
和Add to Viewport
函数的执行引脚,如下所示:
图 15.26:创建完 BP_UI_CoinCollection 小部件后,我们可以将其添加到玩家视口
注意
您可以在以下链接找到前面截图的全分辨率以获得更好的查看体验:packt.live/2UwufBd
。
现在,玩家控制器将BP_UI_CoinCollection
小部件添加到玩家视口,我们需要创建一个GameMode
蓝图,并将BP_SuperSideScroller_MainCharacter
和BP_SuperSideScroller_PC
类应用到这个游戏模式中。
-
在
Content Browser
窗口中,通过右键单击Content
文件夹并选择New Folder
来创建一个新文件夹。将此文件夹命名为GameMode
。 -
接下来,右键单击并选择
Blueprint Class
开始创建游戏模式蓝图。从Pick Parent Class
对话框中搜索并找到SuperSideScrollerGameMode
,位于All Classes
下。 -
将这个新的
GameMode
蓝图命名为BP_SuperSideScroller_GameMode
。双击此资产以打开它。
GameMode
蓝图包含一个类列表,您可以使用自己的类进行自定义。目前,我们只需要担心Player Controller Class
和Default Pawn Class
。
-
左键单击
Player Controller Class
下拉菜单,找到并选择之前在此练习中创建的BP_SuperSideScroller_PC
蓝图。 -
然后,左键单击
Default Pawn Class
下拉菜单,找到并选择BP_SuperSideScroller_MainCharacter
蓝图。
现在我们有了一个自定义的GameMode
,它利用我们自定义的Player Controller
和Player Character
类,让我们将这个游戏模式添加到Project Settings
窗口,这样在使用 PIE 和构建项目时,默认情况下会使用游戏模式。
-
从 Unreal Engine 4 编辑器中,导航到屏幕顶部的
Edit
选项。左键单击此选项,并从下拉菜单中找到并选择Project Settings
选项。 -
在
Project Settings
窗口的左侧,您将看到一个分成几个部分的类别列表。在Project
部分下,左键单击Maps & Modes
类别。 -
在
Maps & Modes
部分,您有一些与项目默认地图和游戏模式相关的参数。在此部分的顶部,您有Default GameMode
选项。左键单击此下拉菜单,找到并选择之前在此练习中创建的SuperSideScroller_GameMode
蓝图。
注意
对Maps & Modes
部分的更改会自动保存并写入DefaultEngine.ini
文件,该文件位于项目的Config
文件夹中。可以通过更新GameMode Override
参数来覆盖每个级别的Default GameMode
,该参数位于级别的World Settings
窗口中。
- 关闭
Project Settings
窗口并返回到您的级别。使用 PIE 并开始收集硬币。观察到每次收集硬币时,BP_UI_CoinCollection
小部件都会显示并更新,如下图所示:
图 15.27:现在,您收集的每个硬币都将显示在玩家 UI 上
注意
您可以在此处找到此练习的资产和代码:packt.live/2JRfSFz
。
完成此练习后,您已经创建了UI UMG
小部件,该小部件用于显示玩家收集的当前硬币数量。通过使用GetCurrentNumberofCollectables()
C++函数和Text
小部件的绑定功能,UI 将根据收集的硬币数量始终更新其值。
到目前为止,我们已经专注于可收集的硬币,并允许玩家收集这些硬币并将总硬币数添加到玩家的 UI 中。现在,我们将专注于药水增益,并为玩家在短时间内提供移动速度和跳跃高度增加。为了实现这个功能,我们首先需要研究计时器。
计时器
虚幻引擎 4 中的计时器允许您在延迟后执行操作,或者每隔 X 秒执行一次。在SuperSideScroller
药水增益的情况下,将使用计时器在 8 秒后将玩家的移动和跳跃恢复到默认状态。
注意
在蓝图中,您可以使用延迟节点以及计时器句柄来实现相同的结果。但是,在 C++中,计时器是实现延迟和重复逻辑的最佳手段。
计时器由“计时器管理器”或FTimerManager
管理,它存在于UWorld
对象中。您将从FTimerManager
类中使用的两个主要函数称为SetTimer()
和ClearTimer()
:
void SetTimer
(
FTimerHandle & InOutHandle,
TFunction < void )> && Callback,
float InRate,
bool InbLoop,
float InFirstDelay
)
void ClearTimer(FTimerHandle& InHandle)
您可能已经注意到,在两个函数中都需要FTimerHandle
。此句柄用于控制您设置的计时器。使用此句柄,您可以暂停、恢复、清除甚至延长计时器。
SetTimer()
函数还有其他参数可帮助您在最初设置计时器时自定义此“计时器”。回调函数将在“计时器”完成后调用,如果InbLoop
参数为True
,则它将继续无限期调用回调函数,直到计时器停止。 InRate
参数是计时器本身的持续时间,而InFirstDelay
是在计时器开始计时之前应用于计时器的初始延迟。
FTimerManager
类的头文件可以在此处找到:/Engine/Source/Runtime/Engine/Public/TimerManager.h。
注意
您可以通过阅读此处的文档了解有关计时器和FTimerHandle
的更多信息:docs.unrealengine.com/en-US/Programming/UnrealArchitecture/Timers/index.html
。
在接下来的练习中,您将在SuperSideScroller_Player
类中创建自己的FTimerHandle
,并使用它来控制药水增益对玩家的持续时间。
练习 15.06:将药水增益行为添加到玩家
在此练习中,您将创建药水增益背后的逻辑,以及它将如何影响玩家角色。您将利用计时器和计时器句柄,以确保增益效果只持续很短的时间。按照以下步骤完成此操作:
-
在 Visual Studio 中,导航到并打开
SuperSideScroller_Player.h
头文件。 -
在“我们的私有访问修饰符”下,添加一个名为
PowerupHandle
的FTimerHandle
类型的新变量:
FTimerHandle PowerupHandle;
此计时器句柄将负责跟踪自启动以来经过的时间。这将允许我们控制药水增益效果持续多长时间。
- 接下来,在我们的“私有访问修饰符”下添加一个名为
bHasPowerupActive
的布尔变量:
bool bHasPowerupActive;
在更新Sprint()
和StopSprinting()
函数时,我们将使用此布尔变量来确保根据增益是否激活来适当更新玩家的冲刺移动速度。
- 接下来,在我们的“公共访问修饰符”下声明一个名为
IncreaseMovementPowerup()
的新 void 函数:
void IncreaseMovementPowerup();
这是将从药水增益类调用的函数,以启用玩家的增益效果。
- 最后,您需要创建一个处理电源增强效果结束时的函数。在
Protected Access Modifier
下创建一个名为EndPowerup()
的函数:
void EndPowerup();
有了所有必要的变量和声明的函数,现在是时候开始定义这些新函数并处理玩家的电源增强效果了。
-
导航到
SuperSideScroller_Player.cpp
源文件。 -
首先,在源文件的顶部添加头文件
#include "TimerManager.h"
;我们将需要这个类来使用Timers
。 -
通过在源文件中添加以下代码来定义
IncreaseMovementPowerup()
函数:
void ASuperSideScroller_Player::IncreaseMovementPowerup()
{
}
- 当调用此函数时,我们需要做的第一件事是将
bHasPowerupActive
变量设置为true
。将以下代码添加到IncreaseMovementPowerup()
函数中:
bHasPowerupActive = true;
- 接下来,添加以下代码来增加玩家角色移动组件的
MaxWalkSpeed
和JumpZVelocity
组件:
GetCharacterMovement()->MaxWalkSpeed = 500.0f;
GetCharacterMovement()->JumpZVelocity = 1500.0f;
在这里,我们将MaxWalkSpeed
从默认值300.0f
更改为500.0f
。您可能还记得,默认的冲刺速度也是500.0f
。我们将在本活动的后续部分中解决这个问题,以在电源增强状态下增加冲刺速度。
- 利用计时器,我们需要获得对
UWorld
对象的引用。添加以下代码:
UWorld* World = GetWorld();
if (World)
{
}
与项目中以前做过的许多次一样,我们使用GetWorld()
函数来获取对UWorld
对象的引用,并将此引用保存在其变量中。
- 现在我们已经有了对
World
对象的引用,并且已经执行了有效性检查,可以安全地使用TimerManager
来设置电源增强计时器。在上一步中显示的if()
语句中添加以下代码:
World->GetTimerManager().SetTimer(PowerupHandle, this, &ASuperSideScroller_Player::EndPowerup, 8.0f, false);
在这里,您正在使用TimerManager
类来设置计时器。SetTimer()
函数接受要使用的FTimerHandle
组件;在这种情况下,是您创建的PowerupHandle
变量。接下来,我们需要通过使用this
关键字传递对玩家类的引用。然后,我们需要提供在计时器结束后调用的回调函数,这种情况下是&ASuperSideScroller_Player::EndPowerup
函数。8.0f
表示计时器的持续时间;随时根据需要进行调整,但目前 8 秒是可以的。最后,还有一个参数,用于确定此计时器是否应该循环;在这种情况下,不应该循环。
- 创建
EndPowerup()
函数的函数定义:
void ASuperSideScroller_Player::EndPowerup()
{
}
- 当调用
EndPowerup()
函数时,首先要做的是将bHasPowerupActive
变量设置为false
。在EndPowerup()
函数中添加以下代码:
bHasPowerupActive = false;
- 接下来,将角色移动组件的
MaxWalkSpeed
和JumpZVelocity
参数更改回它们的默认值。添加以下代码:
GetCharacterMovement()->MaxWalkSpeed = 300.0f;
GetCharacterMovement()->JumpZVelocity = 1000.0f;
在这里,我们正在将角色移动组件的MaxWalkSpeed
和JumpZVelocity
参数都更改为它们的默认值。
- 再次利用计时器并清除
PowerupHandle
的计时器处理,我们需要获得对UWorld
对象的引用。添加以下代码:
UWorld* World = GetWorld();
if (World)
{
}
- 最后,我们可以添加代码来清除计时器句柄的
PowerupHandle
:
World->GetTimerManager().ClearTimer(PowerupHandle);
通过使用ClearTimer()
函数并传入PowerupHandle
,我们确保此计时器不再有效,并且不再影响玩家。
现在我们已经创建了处理电源增强效果和与效果相关的计时器的函数,我们需要更新Sprint()
和StopSprinting()
函数,以便它们在玩家处于电源增强状态时也考虑到速度。
- 将
Sprint()
函数更新为以下内容:
void ASuperSideScroller_Player::Sprint()
{
if (!bIsSprinting)
{
bIsSprinting = true;
if (bHasPowerupActive)
{
GetCharacterMovement()->MaxWalkSpeed = 900.0f;
}
else
{
GetCharacterMovement()->MaxWalkSpeed = 500.0f;
}
}
}
在这里,我们正在更新Sprint()
函数以考虑bHasPowerupActive
是否为 true。如果此变量为 true,则我们在冲刺时将MaxWalkSpeed
从500.0f
增加到900.0f
,如下所示:
if (bHasPowerupActive)
{
GetCharacterMovement()->MaxWalkSpeed = 900.0f;
}
如果bHasPowerupActive
为 false,则我们将MaxWalkSpeed
增加到500.0f
,就像默认情况下一样。
- 将
StopSprinting()
函数更新为以下内容:
void ASuperSideScroller_Player::StopSprinting()
{
if (bIsSprinting)
{
bIsSprinting = false;
if (bHasPowerupActive)
{
GetCharacterMovement()->MaxWalkSpeed = 500.0f;
}
else
{
GetCharacterMovement()->MaxWalkSpeed = 300.0f;
}
}
}
在这里,我们更新StopSprinting()
函数,以考虑bHasPowerupActive
是否为真。如果这个变量为真,则将MaxWalkSpeed
值设置为500.0f
,而不是300.0f
,如下所示:
if (bHasPowerupActive)
{
GetCharacterMovement()->MaxWalkSpeed = 500.0f;
}
如果bHasPowerupActive
为假,则将MaxWalkSpeed
设置为300.0f
,就像默认情况下一样。
- 最后,我们只需要重新编译 C++代码。
注意
您可以在这里找到这个练习的资产和代码:packt.live/3eP39yL
。
完成这个练习后,您已经在玩家角色中创建了药水增益效果。增益效果增加了玩家的默认移动速度,并增加了他们的跳跃高度。此外,增益效果还增加了奔跑速度。通过使用计时器句柄,您能够控制增益效果持续的时间。
现在,是时候创建药水增益角色了,这样我们就可以在游戏中有一个这个增益的表示了。
活动 15.03:创建药水增益角色
现在SuperSideScroller_Player
类处理了药水增益的效果,是时候创建药水增益类和蓝图了。这个活动的目的是创建药水增益类,继承自PickableActor_Base
类,实现重叠功能以授予您在练习 15.06中实现的移动效果,将药水增益行为添加到玩家,并创建药水蓝图角色。执行这些步骤来创建药水增益类和创建药水蓝图角色:
-
创建一个新的 C++类,继承自
PickableActor_Base
类,并将这个新类命名为PickableActor_Powerup
。 -
添加
BeginPlay()
和PlayerPickedUp()
函数的重写函数声明。 -
为
BeginPlay()
函数创建函数定义。在BeginPlay()
函数中,添加对父类函数的调用。 -
为
PlayerPickedUp()
函数创建函数定义。在PlayerPickedUp()
函数中,添加对PlayerPickedUp()
父类函数的调用。 -
接下来,添加
#include
文件,引用SuperSideScroller_Player
类,以便我们可以引用玩家类及其函数。 -
在
PlayerPickedUp()
函数中,使用函数本身的Player
输入参数来调用IncreaseMovementPowerup()
函数。 -
从
Epic Games Launcher
中,在Learn
选项卡的Games
类别下找到Action RPG
项目。使用这个来创建并安装一个新项目。 -
将
A_Character_Heal_Mana_Cue
和SM_PotionBottle
资产以及它们所有引用的资产从Action RPG
项目迁移到您的SuperSideScroller
项目。 -
在
PickableItems
目录中的Content Browser
窗口中创建一个名为Powerup
的新文件夹。在该目录中基于PickableActor_Powerup
类创建一个新的蓝图,并将此资产命名为BP_Powerup
。 -
在
BP_Powerup
中,更新MeshComp
组件以使用SM_PotionBottle
静态网格。 -
接下来,添加
A_Character_Heal_Mana_Cue
,将其导入为Pickup Sound
参数。 -
最后,更新
RotationComp
组件,使得角色每秒绕Pitch
轴旋转 60 度,绕Yaw
轴旋转 180 度。 -
将
BP_Powerup
添加到您的级别中,并使用 PIE 观察与增益重叠时的结果。
预期输出:
图 15.28:药水增益现在有了一个很好的视觉表示,玩家可以重叠以启用其增益效果
注意
这个活动的解决方案可以在这里找到:packt.live/338jEBx
。
通过完成这个活动,您能够在创建一个新的 C++类方面进行知识测试,该类继承自PickableActor_Base
类,并覆盖PlayerPickedUp()
函数以添加自定义逻辑。通过从玩家类中添加对IncreaseMovementPowerup()
函数的调用,您能够在与该角色重叠时为玩家添加移动增益效果。然后,通过使用自定义网格、材质和音频资产,您能够从PickableActor_Powerup
类中为蓝图角色赋予生命。
现在我们已经创建了硬币可收集物品和药水增益,我们需要将一个新的游戏功能实现到项目中:Brick
类。在超级马里奥等游戏中,砖块中包含玩家可以找到的隐藏硬币和增益物品。这些砖块还可以用作到达高架平台和关卡内区域的手段。在我们的SuperSideScroller
项目中,Brick
类将用于包含玩家的隐藏硬币可收集物品,并作为允许玩家通过使用砖块作为路径来访问难以到达位置的手段。因此,在下一节中,我们将创建需要被打破以找到隐藏硬币的Brick
类。
练习 15.07:创建 Brick 类
现在我们已经创建了硬币可收集物品和药水增益,是时候创建Brick
类了,这将为玩家包含隐藏的硬币。砖块是SuperSideScroller
项目的最终游戏元素。在这个练习中,您将创建Brick
类,这将作为SuperSideScroller
游戏项目的平台机制的一部分使用,同时也作为一个容纳玩家可收集物品的手段。按照以下步骤创建Brick
类及其蓝图:
-
在虚幻引擎 4 编辑器中,导航到
文件
,然后选择新建 C++类
。 -
从
选择父类
对话框中,找到并选择Actor
类。 -
将此类命名为
SuperSideScroller_Brick
并左键单击Create Class
。Visual Studio 和虚幻引擎将重新编译代码并为您打开此类。
默认情况下,SuperSideScroller_Brick
类带有Tick()
函数,但我们不需要这个函数用于Brick
类。在继续之前,从SuperSideScroller_Brick.h
头文件中删除Tick()
函数声明,并从SuperSideScroller_Brick.cpp
源文件中删除函数定义。
- 在
SuperSideScroller_Brick.h
文件的Private Access Modifier
下,添加以下代码来声明一个新的UStaticMeshComponent* UPROPERTY()
函数,以表示游戏世界中的砖块:
UPROPERTY(VisibleDefaultsOnly, Category = Brick)
class UStaticMeshComponent* BrickMesh;
- 接下来,我们需要创建一个
UBoxComponent UPROPERTY()
,用于处理与玩家角色的碰撞。在我们的Private Access Modifier
下添加以下代码来添加这个组件:
UPROPERTY(VisibleDefaultsOnly, Category = Brick)
class UBoxComponent* BrickCollision;
- 在我们的
Private Access Modifier
下创建UFUNCTION()
声明OnHit()
函数。这将用于确定UBoxComponent
何时被玩家击中:
UFUNCTION()
void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UprimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);
注意
在本项目中开发PlayerProjectile
类时,您在第十三章 敌人人工智能中使用了OnHit()
函数。请查看该章节以获取有关OnHit()
函数的更多信息。
- 接下来,在我们的
Private Access Modifier
下创建一个新的布尔UPROPERTY()
,使用EditAnywhere
关键字,命名为bHasCollectable
:
UPROPERTY(EditAnywhere)
bool bHasCollectable;
这个布尔值将确定砖块是否包含玩家的硬币可收集物品。
- 现在,我们需要一个变量来保存此砖块中有多少硬币可收集物品供玩家使用。我们将通过创建一个名为
Collectable Value
的整数变量来实现这一点。将其放在private access modifier
下,使用EditAnywhere
关键字,并将其默认值设置为1
,如下所示:
UPROPERTY(EditAnywhere)
int32 CollectableValue = 1;
砖块将需要包含一个独特的声音和粒子系统,以便在玩家摧毁砖块时具有良好的光泽层。我们将在下面添加这些属性。
-
在
SuperSideScroller_Brick.h
头文件中创建一个新的Public Access Modifier
。 -
接下来,使用
EditAnywhere
和BlueprintReadOnly
关键字为USoundBase
类的变量创建一个新的UPROPERTY()
。将此变量命名为HitSound
,如下所示:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
class USoundBase* HitSound;
- 然后,使用
EditAnywhere
和BlueprintReadOnly
关键字为UParticleSystem
类的变量创建一个新的UPROPERTY()
。确保将其放在public access modifier
下,并将此变量命名为Explosion
,如下所示:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Brick)
class UParticleSystem* Explosion;
现在,我们已经为Brick
类准备好了所有必要的属性,让我们继续进行SuperSideScroller_Brick.cpp
源文件,在那里我们将初始化组件。
- 让我们首先添加以下用于
StaticMeshComponent
和BoxComponent
的#include
目录。将以下代码添加到源文件的#include
列表中:
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
- 首先,通过将以下代码添加到
ASuperSideScroller_Brick::ASuperSideScroller_Brick()
构造函数来初始化BrickMesh
组件:
BrickMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BrickMesh"));
- 接下来,
BrickMesh
组件应该具有碰撞,以便玩家可以在其上行走,用于平台游戏目的。为了确保这种情况默认发生,添加以下代码将碰撞设置为"BlockAll"
:
BrickMesh->SetCollisionProfileName("BlockAll");
- 最后,
BrickMesh
组件将作为Brick
角色的根组件。添加以下代码来实现这一点:
RootComponent = BrickMesh;
- 现在,通过将以下代码添加到构造函数中来初始化我们的
BrickCollision UBoxComponent
:
BrickCollision = CreateDefaultSubobject<UBoxComponent> (TEXT("BrickCollision"));
- 就像
BrickMesh
组件一样,BrickCollision
组件也需要将其碰撞设置为"BlockAll"
,以便在本练习的后续步骤中添加OnHit()
回调事件。添加以下代码:
BrickCollision->SetCollisionProfileName("BlockAll");
- 接下来,需要将
BrickCollision
组件附加到BrickMesh
组件上。我们可以通过添加以下代码来实现这一点:
BrickCollision->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepWorldTransform);
- 在完成
BrickCollision
组件的初始化之前,我们需要为OnHit()
函数添加函数定义。将以下定义添加到源文件中:
void ASuperSideScroller_Brick::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
}
- 现在我们已经定义了
OnHit()
函数,我们可以将OnComponentHit
回调分配给BrickCollision
组件。将以下代码添加到构造函数中:
BrickCollision->OnComponentHit.AddDynamic(this, &ASuperSideScroller_Brick::OnHit);
-
编译
SuperSideScroller_Brick
类的 C++代码,并返回到 Unreal Engine 4 编辑器。 -
在“内容浏览器”窗口中,右键单击“内容”文件夹,然后选择“新建文件夹”选项。将此文件夹命名为“砖块”。
-
在
Brick
文件夹内右键单击,然后选择“蓝图类”。在“选择父类”对话框窗口的“所有类”搜索栏中,搜索并选择SuperSideScroller_Brick
类。 -
将这个新的蓝图命名为
BP_Brick
,然后双击该资源以打开它。 -
从“组件”选项卡中选择
BrickMesh
组件,并将其Static Mesh
参数设置为Shape_Cube
网格。 -
仍然选择
BrickMesh
组件,将Element 0
材质参数设置为M_Brick_Clay_Beveled
。在创建新项目时,Epic Games 默认提供了M_Brick_Clay_Beveled
材质。它可以在“内容浏览器”窗口的StarterContent
目录中找到。
与玩家角色的需求以及SuperSideScroller
游戏项目的平台机制相适应,我们需要调整BrickMesh
组件的比例。
- 选择
BrickMesh
组件后,对其Scale
参数进行以下更改:
(X=0.750000,Y=0.750000,Z=0.750000)
现在,BrickMesh
组件的大小为其正常大小的75%
,当我们将角色放入游戏世界时,以及在我们在关卡中开发有趣的平台部分时,Brick
角色将变得更易于我们作为设计者管理。
最后一步是更新BrickCollision
组件的位置,使其只有一部分碰撞从BrickMesh
组件的底部伸出。
- 从
Components
选项卡中选择BrickCollision
组件,并将其Location
参数更新为以下值:
(X=0.000000,Y=0.000000,Z=30.000000)
BrickCollision
组件现在应该定位如下:
图 15.29:现在,BrickCollision 组件刚好在 BrickMesh 组件之外
我们调整BrickCollision
组件的位置,以便玩家只能在砖块下跳时击中UBoxComponent
。通过使其略微超出BrickMesh
组件,我们可以更好地控制这一点,并确保玩家无法以其他方式击中该组件。
注意
您可以在此处找到此练习的资产和代码:packt.live/3kr7rh6
。
完成这个练习后,您已经能够为SuperSideScroller_Brick
类创建基本框架,并组合蓝图角色以在游戏世界中表示砖块。通过添加立方体网格和砖块材质,您为砖块添加了良好的视觉效果。在接下来的练习中,您将为砖块添加剩余的 C++逻辑。这将允许玩家摧毁砖块并获得可收集物品。
练习 15.08:添加 Brick 类 C++逻辑
在上一个练习中,通过添加必要的组件并创建BP_Brick
蓝图角色,您为SuperSideScroller_Brick
类创建了基本框架。在这个练习中,您将在练习 15.07,创建 Brick 类的 C++代码的基础上添加逻辑,以赋予Brick
类逻辑。这将允许砖块给玩家金币收藏品。执行以下步骤来完成这个过程:
- 首先,我们需要创建一个函数,将可收集物品添加到玩家。在我们的
Private Access Modifier
下,在SuperSideScroller_Brick.h
头文件中添加以下函数声明:
void AddCollectable(class ASuperSideScroller_Player* Player);
我们希望传递对SuperSideScroller_Player
类的引用,以便我们可以从该类调用IncrementNumberofCollectables()
函数。
- 接下来,在我们的
Private Access Modifier
下创建一个名为PlayHitSound()
的 void 函数声明:
void PlayHitSound();
PlayHitSound()
函数将负责生成您在练习 15.07,创建 Brick 类中创建的HitSound
属性。
- 最后,在我们的
Private Access Modifier
下创建另一个名为PlayHitExplosion()
的 void 函数声明:
void PlayHitExplosion();
PlayHitExplosion()
函数将负责生成您在练习 15.07中创建的Explosion
属性。
在头文件中声明了SuperSideScroller_Brick
类所需的其余函数后,让我们继续在源文件中定义这些函数。
- 在
SuperSideScroller_Brick.cpp
源文件的顶部,将以下#includes
添加到已存在的include
目录列表中:
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "SuperSideScroller_Player.h"
World
和GameplayStatics
类的包含是必要的,以生成砖块的HitSound
和Explosion
效果。包括SuperSideScroller_Player
类是为了调用IncrementNumberofCollectables()
类函数。
- 让我们从
AddCollectable()
函数的函数定义开始。添加以下代码:
void ASuperSideScroller_Brick::AddCollectable(class ASuperSideScroller_Player* Player)
{
}
- 现在,通过使用
Player
函数输入参数调用IncrementNumberofCollectables()
函数:
Player->IncrementNumberofCollectables(CollectableValue);
- 对于
PlayHitSound()
函数,您需要获取对UWorld*
对象的引用,并在从UGameplayStatics
类调用SpawnSoundAtLocation
函数之前验证HitSound
属性是否有效。这是您已经做过许多次的过程,所以这是整个函数代码:
void ASuperSideScroller_Brick::PlayHitSound()
{
UWorld* World = GetWorld();
if (World)
{
if (HitSound)
{
UGameplayStatics::SpawnSoundAtLocation(World, HitSound, GetActorLocation());
}
}
}
- 就像
PlayHitSound()
函数一样,PlayHitExplosion()
函数将以几乎相似的方式工作,这是您在此项目中已经做过许多次的过程。添加以下代码以创建函数定义:
void ASuperSideScroller_Brick::PlayHitExplosion()
{
UWorld* World = GetWorld();
if (World)
{
if (Explosion)
{
UGameplayStatics::SpawnEmitterAtLocation(World, Explosion, GetActorTransform());
}
}
}
有了这些函数的定义,让我们更新OnHit()
函数,以便如果玩家确实击中了BrickCollision
组件,我们可以生成HitSound
和Explosion
,并将一个硬币可收集物品添加到玩家的收集物品中。
- 首先,在
OnHit()
函数中,创建一个名为Player
的新变量,类型为ASuperSideScroller_Player
,其值等于函数的OtherActor
输入参数的Cast
,如下所示:
ASuperSideScroller_Player* Player = Cast<ASuperSideScroller_Player>(OtherActor);
- 接下来,我们只想在
Player
有效且bHasCollectable
为True
时继续执行此函数。添加以下if()
语句:
if (Player && bHasCollectable)
{
}
- 如果
if()
语句中的条件满足,那么我们需要调用AddCollectable()
、PlayHitSound()
和PlayHitExplosion()
函数。确保在AddCollectable()
函数中也传入Player
变量:
AddCollectable(Player);
PlayHitSound();
PlayHitExplosion();
- 最后,在
if()
语句内添加销毁砖块的函数调用:
Destroy();
-
当我们需要的
OnHit()
函数定义好了,重新编译 C++代码,但暂时不要返回到虚幻引擎 4 编辑器。 -
对于砖块爆炸的 VFX 和 SFX,我们需要从
Epic Games Launcher
提供给我们的两个不同项目中迁移资源:Blueprints
项目和Content Examples
项目。 -
利用您之前练习中的知识,使用引擎版本 4.24 下载并安装这些项目。这两个项目都可以在
Learn
选项卡的Engine Feature Samples
类别下找到。 -
安装完成后,打开
Content Examples
项目,并在Content Browser
窗口中找到P_Pixel_Explosion
资源。 -
右键单击此资源,然后选择
资源操作
,然后选择迁移
。将此资源及其所有引用的资源迁移到您的SuperSideScroller
项目中。 -
一旦成功迁移了此资源,关闭
Content Examples
项目,然后打开Blueprints
项目。 -
从
Blueprints
项目的Content Browser
窗口中找到Blueprints_TextPop01
资源。 -
右键单击此资源,然后选择
资源操作
,然后选择迁移
。将此资源及其所有引用的资源迁移到您的SuperSideScroller
项目中。
将这些资源迁移到您的项目后,返回到您的SuperSideScroller
项目的虚幻引擎 4 编辑器中。
-
在
Content Browser
窗口中导航到Brick
文件夹,双击BP_Brick
资源以打开它。 -
在角色的
Details
面板中,找到Super Side Scroller Brick
部分,并将HitSound
参数设置为您导入的Blueprints_TextPop01
声波。 -
接下来,将您导入的
P_Pixel_Explosion
粒子添加到Explosion
参数中。 -
重新编译
BP_Brick
蓝图并将两个这样的角色添加到您的关卡中。 -
将其中一个砖块的
bHasCollectable
参数设置为True
;将另一个设置为False
。请参考以下截图:
图 15.30:此砖块角色设置为生成可收集物品
- 使用 PIE,观察当您尝试用角色的头部跳跃击中砖块底部时,两个砖块角色之间行为的差异,如下截图所示:
图 15.31:现在,玩家可以击中砖块并将其摧毁
当bHasCollectable
为True
时,SuperSideScroller_Brick
将播放我们的HitSound
,生成Explosion
粒子系统,向玩家添加一个硬币可收集物品,并被摧毁。
注意
您可以在此处找到此练习的资产和代码:packt.live/3pjhoAv
。
通过完成这个练习,你现在已经完成了SuperSideScroller
游戏项目的游戏机制开发。现在,SuperSideScroller_Brick
类可以用于平台游戏玩法和我们想要的游戏中的金币收集机制。
现在砖块可以被摧毁,隐藏的金币可以被收集,我们为SuperSideScroller
游戏项目设定的所有游戏元素都已经完成。
总结
在这一章中,你将你的知识付诸实践,为SuperSideScroller
游戏项目创建了剩余的游戏机制。通过结合 C++和蓝图,你开发了玩家可以在关卡中收集的药水能力提升和金币。此外,通过使用你在第十四章“生成玩家投射物”中学到的知识,你为这些可收集物品添加了独特的音频和视觉资产,为游戏增添了一层精美的润色。
你学习并利用了虚幻引擎 4 中的UMG UI
系统,创建了一个简单而有效的 UI 反馈系统,用于显示玩家已经收集的金币数量。通过使用Text
小部件的绑定功能,你能够保持 UI 与玩家当前已经收集的金币数量保持更新。最后,你使用了从SuperSideScroller
项目中学到的知识创建了一个Brick
类,用于为玩家隐藏金币,让他们可以收集和找到它们。
SuperSideScroller
项目是一个庞大的项目,涵盖了虚幻引擎 4 中许多可用的工具和实践。在第十章“创建 SuperSideScroller 游戏”中,我们导入了自定义的骨骼和动画资产,用于开发玩家角色的动画蓝图。在第十一章“Blend Spaces 1D, Key Bindings, and State Machines”中,我们使用了Blend spaces
允许玩家角色在空闲、行走和奔跑动画之间进行混合,同时使用Animation State Machine
来处理玩家角色的跳跃和移动状态。然后,我们学习了如何使用角色移动组件来控制玩家的移动和跳跃高度。
在第十二章“Animation Blending and Montages”中,我们通过使用Layered Blend per Bone
功能和Saved Cached Poses
更多地了解了动画蓝图中的动画混合。通过为玩家角色的投掷动画添加一个新的AnimSlot
,我们能够使玩家的移动动画和投掷动画平滑地混合在一起。在第十三章“Enemy Artificial Intelligence”中,我们使用了行为树和黑板的强大系统来为敌人开发 AI 行为。我们创建了自己的Task
,使敌人 AI 能够在我们还开发的自定义蓝图中的巡逻点之间移动。
在第十四章“生成玩家投射物”中,我们学习了如何创建Anim Notify
,以及如何在玩家角色的投掷动画中实现这个通知来生成玩家投射物。然后,我们学习了如何创建投射物,以及如何使用Projectile Movement Component
让玩家投射物在游戏世界中移动。
最后,在这一章中,我们学习了如何使用UMG
工具集为可收集的金币创建 UI,以及如何操纵我们的Character Movement Component
为玩家创建药水能力提升。最后,你创建了一个Brick
类,可以用来为玩家隐藏金币,让他们找到并收集。
这个总结只是对我们在SuperSideScroller
项目中学到和完成的内容进行了初步的介绍。在你继续之前,这里有一些挑战供你测试知识并扩展项目:
-
添加一个新的能力提升,降低应用于玩家角色的重力。导入自定义网格和音频资产,使这个能力提升与你制作的药水能力提升有独特的外观。
-
当玩家角色收集到 10 个硬币时,给予玩家一个力量增强道具。
-
实现当玩家与 AI 重叠时允许玩家被摧毁的功能。包括当发生这种情况时,能够让玩家重新生成。
-
添加另一个能让玩家免疫的力量增强道具,这样当他们与敌人重叠时就不会被摧毁。(事实上,拥有这个力量增强道具时,与敌人重叠时可能会摧毁敌人。)
-
利用您为
SuperSideScroller
项目开发的所有游戏元素,创建一个新的关卡,利用这些元素打造一个有趣的平台竞技场。 -
添加多个具有有趣巡逻点的敌人,挑战玩家在导航区域时。
-
将力量增强道具放置在难以到达的地方,以便玩家需要提高他们的平台技能来获取它们。
-
为玩家创建危险的陷阱,使他们需要跨越,并添加功能,当玩家从地图上掉下去时会摧毁玩家。
在下一章中,您将学习关于多人游戏的基础知识,服务器-客户端架构,以及在虚幻引擎 4 中用于多人游戏的游戏框架类。您将利用这些知识来扩展虚幻引擎 4 中的多人射击游戏项目。
第十五章:多人游戏基础知识
概述
在本章中,您将了解一些重要的多人游戏概念,以便使用虚幻引擎 4 的网络框架为您的游戏添加多人游戏支持。
在本章结束时,您将了解基本的多人游戏概念,如服务器-客户端架构、连接和角色所有权,以及角色和变量复制。您将能够实现这些概念,创建自己的多人游戏。您还将能够制作 2D 混合空间,这允许您在 2D 网格中的动画之间进行混合。最后,您将学习如何使用Transform (Modify) Bone
节点在运行时控制骨骼网格骨骼。
介绍
在上一章中,我们完成了SuperSideScroller
游戏,并使用了 1D 混合空间、动画蓝图和动画蒙太奇。在本章中,我们将在此基础上构建,并学习如何使用虚幻引擎为游戏添加多人游戏功能。
多人游戏在过去十年里发展迅速。像 Fortnite、PUBG、英雄联盟、火箭联盟、守望先锋和 CS:GO 等游戏在游戏社区中获得了很大的流行,并取得了巨大的成功。如今,几乎所有的游戏都需要具有某种多人游戏体验,以使其更具相关性和成功。
这样做的原因是它在现有的游戏玩法之上增加了新的可能性,比如能够在合作模式(也称为合作模式)中与朋友一起玩,或者与来自世界各地的人对战,这大大增加了游戏的长期性和价值。
在下一个主题中,我们将讨论多人游戏的基础知识。
多人游戏基础知识
在游戏中,你可能经常听到多人游戏这个术语,但对于游戏开发者来说,它意味着什么呢?实际上,多人游戏只是通过网络(互联网或局域网)在服务器和其连接的客户端之间发送的一组指令,以给玩家产生共享世界的错觉。
为了使其工作,服务器需要能够与客户端进行通信,但客户端也需要与服务器进行通信(客户端到服务器)。这是因为客户端通常是影响游戏世界的一方,因此他们需要一种方式来告知服务器他们在玩游戏时的意图。
这种服务器和客户端之间的来回通信的一个例子是当玩家在游戏中尝试开火时。看一下下面的图,它展示了客户端和服务器的交互:
图 16.1:多人游戏中玩家想要开火时的客户端-服务器交互
让我们来看看图 16.1中显示的内容:
-
玩家按住鼠标左键,并且该玩家的客户端告诉服务器它想要开火。
-
服务器通过检查以下内容来验证玩家是否可以开火:
-
如果玩家还活着
-
如果玩家装备了武器
-
如果玩家有足够的弹药
- 如果所有验证都有效,则服务器将执行以下操作:
-
运行逻辑以扣除弹药
-
在服务器上生成抛射物角色,自动发送到所有客户端
-
在所有客户端的角色实例上播放开火动画,以确保它们之间的某种同步性,这有助于传达它们是同一个世界的想法,尽管实际上并非如此
- 如果任何验证失败,服务器会告诉特定的客户端该做什么:
-
玩家已经死亡-不做任何事情
-
玩家没有装备武器-不做任何事情
-
玩家没有足够的弹药-播放空击声音
请记住,如果您希望游戏支持多人游戏,则强烈建议您在开发周期的尽早阶段就这样做。如果您尝试运行启用了多人游戏的单人项目,您会注意到一些功能可能正常工作,但可能大多数功能都无法正常工作或达到预期效果。
这是因为当您在单人游戏中执行游戏时,代码在本地立即运行,但是当您将多人游戏加入到方程式中时,您正在添加外部因素,例如与具有延迟的网络上的客户端进行通信的权威服务器,就像您在图 16.1中看到的那样。
为了使一切正常运行,您需要将现有代码分解为以下部分:
-
仅在服务器上运行的代码
-
仅在客户端上运行的代码
-
在两者上运行的代码,这可能需要很长时间,具体取决于您的单人游戏的复杂性
为了为游戏添加多人游戏支持,虚幻引擎 4 已经内置了一个非常强大和带宽高效的网络框架,使用权威服务器-客户端架构。
以下是其工作原理的图表:
图 16.2:虚幻引擎 4 中的服务器-客户端架构
在图 16.2中,您可以看到服务器-客户端架构在虚幻引擎 4 中是如何工作的。每个玩家控制一个客户端,使用双向连接与服务器通信。服务器在特定级别上运行游戏模式(仅存在于服务器中)并控制信息流,以便客户端可以在游戏世界中看到并相互交互。
注意
多人游戏可能是一个非常复杂的话题,因此接下来的几章将作为介绍,帮助您了解基本知识,但不会深入研究。因此,出于简单起见,一些概念可能被省略。
在下一节中,我们将看看服务器。
服务器
服务器是架构中最关键的部分,因为它负责处理大部分工作并做出重要决策。
以下是服务器的主要责任概述:
-
创建和管理共享世界实例:服务器在特定级别和游戏模式中运行其自己的游戏实例(这将在接下来的章节中介绍),这将成为所有连接的客户端之间的共享世界。使用的级别可以随时更改,并且如果适用,服务器可以自动带上所有连接的客户端。
-
游戏模式中的
PostLogin
函数被调用。从那时起,客户端将进入游戏,并成为共享世界的一部分,玩家将能够看到并与其他客户端进行交互。如果客户端在任何时候断开连接,那么所有其他客户端都将收到通知,并且游戏模式中的Logout
函数将被调用。 -
生成所有客户端需要了解的角色:如果要生成所有客户端中存在的角色,则需要在服务器上执行此操作。原因是服务器具有权限,并且是唯一可以告诉每个客户端创建其自己的该角色实例的人。
这是在多人游戏中生成角色的最常见方式,因为大多数角色需要存在于所有客户端中。一个例子是能量增强,所有客户端都可以看到并与之交互。
-
运行关键的游戏逻辑:为了确保游戏对所有客户端都是公平的,关键的游戏逻辑需要仅在服务器上执行。如果客户端负责处理健康扣除,那将是非常容易被利用的,因为玩家可以使用工具在内存中更改健康当前值为 100%,所以玩家在游戏中永远不会死亡。
-
处理变量复制:如果您有一个复制的变量(在本章中介绍),那么它的值应该只在服务器上更改。这将确保所有客户端的值会自动更新。您仍然可以在客户端上更改值,但它将始终被服务器的最新值替换,以防止作弊并确保所有客户端同步。
-
处理来自客户端的 RPC:服务器需要处理来自客户端发送的远程过程调用(第十七章,远程过程调用)。
现在您知道服务器的功能,我们可以讨论在虚幻引擎 4 中创建服务器的两种不同方式。
专用服务器
专用服务器仅运行服务器逻辑,因此您不会看到典型的游戏运行窗口,您可以在其中控制本地玩家角色。此外,如果使用-log
命令提示符运行专用服务器,您将看到一个控制台窗口,记录有关服务器上发生的事件的相关信息,例如客户端是否已连接或断开连接等。作为开发人员,您还可以使用UE_LOG
宏记录自己的信息。
使用专用服务器是创建多人游戏服务器的一种非常常见的方式,因为它比监听服务器更轻量级,您可以将其托管在服务器堆栈上并让其保持运行。
要在虚幻引擎 4 中启动专用服务器,可以使用以下命令参数:
- 通过快捷方式或命令提示符在编辑器中启动专用服务器,请运行以下命令:
<UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe <UProject Location> <Map Name> -server -game -log
以下是一个示例:
C:\Program Files\Epic Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe D:\TestProject\TestProject.uproject TestMap -server -game -log
- 打包项目需要专门构建的项目的特殊构建,用作专用服务器。
注意
您可以通过访问allarsblog.com/2015/11/06/support-dedicated-servers/
和www.ue4community.wiki/Dedicated_Server_Guide_(Windows)
了解有关设置打包专用服务器的更多信息。
监听服务器
监听服务器同时充当服务器和客户端,因此您还将拥有一个窗口,可以以此服务器类型的客户端玩游戏。它还具有是最快启动服务器的优势,但它不像专用服务器那样轻量级,因此可以连接的客户端数量将受到限制。
要启动监听服务器,可以使用以下命令参数:
- 通过快捷方式或命令提示符在编辑器中启动专用服务器,请运行以下命令:
<UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe <UProject Location> <Map Name>?Listen -game
以下是一个示例:
C:\Program Files\Epic Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe D:\TestProject\TestProject.uproject TestMap?Listen -game
- 打包项目(仅限开发构建)需要专门构建的项目的特殊构建,用作专用服务器:
<Project Name>.exe <Map Name>?Listen -game
以下是一个示例:
D:\Packaged\TestProject\TestProject.exe TestMap?Listen –game
在下一节中,我们将讨论客户端。
客户端
客户端是架构中最简单的部分,因为大多数参与者将在服务器上拥有权限,所以在这些情况下,工作将在服务器上完成,客户端只需服从其命令。
以下是客户端的主要职责概述:
-
从服务器强制执行变量复制:服务器通常对客户端知道的所有参与者具有权限,因此当复制变量的值在服务器上更改时,客户端需要强制执行该值。
-
处理来自服务器的 RPC:客户端需要处理来自服务器发送的远程过程调用(在第十七章,远程过程调用中介绍)。
-
模拟时预测移动:当客户端模拟参与者(本章后面介绍)时,它需要根据参与者的速度本地预测其位置。
-
生成只有客户端需要知道的参与者:如果要生成只存在于客户端的参与者,则需要在特定客户端上执行该操作。
这是生成角色的最不常见的方法,因为很少有情况下您希望一个角色只存在于一个客户端。一个例子是多人生存游戏中的放置预览角色,玩家控制一个半透明版本的墙,其他玩家直到实际放置之前都看不到。
客户端可以以不同的方式加入服务器。以下是最常见的方法列表:
- 使用虚幻引擎 4 控制台(默认为`键)打开它并输入:
Open <Server IP Address>
例如:
Open 194.56.23.4
- 使用
Execute Console Command
蓝图节点。一个例子如下:
图 16.3:使用 Execute Console Command 节点加入具有示例 IP 的服务器
- 使用
APlayerController
中的ConsoleCommand
函数如下:
PlayerController->ConsoleCommand("Open <Server IP Address>");
这是一个例子:
PlayerController->ConsoleCommand("Open 194.56.23.4");
- 通过快捷方式或命令提示符使用编辑器可执行文件:
<UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe <UProject Location> <Server IP Address> -game
这是一个例子:
C:\Program Files\Epic Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe D:\TestProject\TestProject.uproject 194.56.23.4 -game
- 通过快捷方式或命令提示符使用打包的开发版本:
<Project Name>.exe <Server IP Address>
这是一个例子:
D:\Packaged\TestProject\TestProject.exe 194.56.23.4
在下一个练习中,我们将在多人游戏中测试虚幻引擎 4 附带的第三人称模板。
练习 16.01:在多人游戏中测试第三人称模板
在这个练习中,我们将创建一个第三人称模板项目,并在多人游戏中进行游玩。
以下步骤将帮助您完成练习。
- 使用蓝图创建一个名为
TestMultiplayer
的新Third Person
模板项目,并将其保存到您选择的位置。
项目创建后,应该打开编辑器。现在我们将在多人游戏中测试项目的行为:
-
在编辑器中,
播放
按钮右侧有一个带有向下箭头的选项。单击它,您应该看到一个选项列表。在多人游戏选项
部分下,您可以配置要使用多少个客户端以及是否需要专用服务器。 -
取消
运行专用服务器
的选中,将玩家数量
更改为3
,然后单击新编辑器窗口(PIE)
。 -
您应该看到三个窗口相互堆叠,代表三个客户端:
图 16.4:启动三个带有监听服务器的客户端窗口
如您所见,这有点凌乱,所以让我们改变窗口的大小。在键盘上按Esc停止播放。
-
再次单击
播放
按钮旁边的向下箭头,并选择最后一个选项高级设置
。 -
搜索
游戏视口设置
部分。将新视口分辨率
更改为640x480
,然后关闭编辑器首选项
选项卡。 -
再次播放游戏,您应该看到以下内容:
图 16.5:使用 640x480 分辨率启动三个客户端窗口与监听服务器
一旦开始游戏,您会注意到窗口的标题栏显示服务器
,客户端 1
和客户端 2
。由于您可以在服务器
窗口中控制一个角色,这意味着我们正在运行服务器+客户端 0
而不仅仅是服务器
,以避免混淆。
通过完成这个练习,您现在有了一个设置,其中您将有一个服务器和三个客户端运行(客户端 0
,客户端 1
和客户端 2
)。
注意
当您同时运行多个窗口时,您会注意到一次只能在一个窗口上进行输入焦点。要将焦点转移到另一个窗口,只需按下Shift + F1以失去当前的输入焦点,然后单击要关注的新窗口。
如果您在其中一个窗口中玩游戏,您会注意到您可以四处移动和跳跃,其他客户端也能看到。
一切正常运行的原因是角色移动组件自动复制位置、旋转和下落状态(用于显示您是否在跳跃)给您。如果要添加自定义行为,如攻击动画,您不能只是告诉客户端在按键时本地播放动画,因为这在其他客户端上不起作用。这就是为什么需要服务器,作为中介,告诉所有客户端在一个客户端按下按键时播放动画。
打包版本
项目完成后,最好将其打包(如前几章所述),这样我们就会得到一个纯粹的独立版本,不需要使用虚幻引擎编辑器,运行速度更快,更轻量。
以下步骤将帮助您创建Exercise 16.01,在多人游戏文件中测试第三人称模板的打包版本:
-
转到
文件
->打包项目
->Windows
->Windows(64 位)
。 -
选择一个文件夹放置打包版本,并等待完成。
-
转到所选文件夹,并打开其中的
WindowsNoEditor
文件夹。 -
右键单击
TestMultiplayer.exe
,选择“创建快捷方式”。 -
将新的快捷方式重命名为
运行服务器
。 -
右键单击它,选择“属性”。
-
在目标上附加
ThirdPersonExampleMap?Listen -server
,这将使用ThirdPersonExampleMap
创建一个监听服务器。您应该得到这个:
"<Path>\WindowsNoEditor\TestMultiplayer.exe" ThirdPersonExampleMap?Listen -server
-
点击“确定”并运行快捷方式。
-
您应该会收到 Windows 防火墙提示,所以允许它。
-
保持服务器运行,并返回文件夹,从
TestMultiplayer.exe
创建另一个快捷方式。 -
将其重命名为
运行客户端
。 -
右键单击它,选择“属性”。
-
在目标上附加
127.0.0.1
,这是您本地服务器的 IP。您应该得到"<Path>\WindowsNoEditor\TestMultiplayer.exe" 127.0.0.1
。 -
点击“确定”并运行快捷方式。
-
现在你已经连接到监听服务器,所以你可以看到彼此的角色。
-
每次单击“运行客户端”快捷方式,您都会向服务器添加一个新的客户端,因此您可以在同一台机器上运行几个客户端。
在接下来的部分,我们将看看连接和所有权。
连接和所有权
在使用虚幻引擎进行多人游戏时,一个重要的概念是连接。当客户端加入服务器时,它将获得一个新的玩家控制器,并与之关联一个连接。
如果一个角色与服务器没有有效的连接,那么该角色将无法进行复制操作,如变量复制(本章后面介绍)或调用 RPC(在第十七章,远程过程调用中介绍)。
如果玩家控制器是唯一持有连接的角色,那么这是否意味着它是唯一可以进行复制操作的地方?不是,这就是GetNetConnection
函数发挥作用的地方,该函数在AActor
中定义。
在对角色进行复制操作(如变量复制或调用 RPC)时,虚幻框架将通过调用GetNetConnection()
函数来获取角色的连接。如果连接有效,则复制操作将被处理,如果无效,则不会发生任何事情。GetNetConnection()
的最常见实现来自APawn
和AActor
。
让我们看看APawn
类如何实现GetNetConnection()
函数,这通常用于角色:
class UNetConnection* APawn::GetNetConnection() const
{
// if have a controller, it has the net connection
if ( Controller )
{
return Controller->GetNetConnection();
}
return Super::GetNetConnection();
}
前面的实现是虚幻引擎 4 源代码的一部分,它首先检查 pawn 是否有有效的控制器。如果控制器有效,则使用其连接。如果控制器无效,则使用GetNetConnection()
函数的父实现,即AActor
上的实现:
UNetConnection* AActor::GetNetConnection() const
{
return Owner ? Owner->GetNetConnection() : nullptr;
}
前面的实现也是虚幻引擎 4 源代码的一部分,它将检查角色是否有有效的所有者。如果有,它将使用所有者的连接;如果没有,它将返回一个无效的连接。那么这个Owner
变量是什么?每个角色都有一个名为Owner
的变量(可以通过调用SetOwner
函数来设置其值),显示哪个角色拥有它,因此你可以将其视为父角色。
在这个GetNetConnection()
的实现中使用所有者的连接将像一个层次结构一样工作。如果在所有者的层次结构中找到一个是玩家控制器或者被玩家控制器控制的所有者,那么它将有一个有效的连接,并且能够处理复制操作。看下面的例子。
注意
在监听服务器中,由其客户端控制的角色的连接将始终无效,因为该客户端已经是服务器的一部分,因此不需要连接。
想象一个武器角色被放置在世界中,它就在那里。在这种情况下,武器将没有所有者,因此如果武器尝试执行任何复制操作,如变量复制或调用 RPC,将不会发生任何事情。
然而,如果客户端拾取武器并在服务器上调用SetOwner
并将值设置为角色,那么武器现在将有一个有效的连接。原因是武器是一个角色,因此为了获取其连接,它将使用AActor
的GetNetConnection()
实现,该实现返回其所有者的连接。由于所有者是客户端的角色,它将使用APawn
的GetNetConnection()
的实现。角色有一个有效的玩家控制器,因此这是函数返回的连接。
这里有一个图表来帮助你理解这个逻辑:
图 16.6:武器角色的连接和所有权示例
让我们了解无效所有者的元素:
-
AWeapon
没有覆盖GetNetConnection
函数,因此要获取武器的连接,它将调用找到的第一个实现,即AActor::GetNetConnection
。 -
AActor::GetNetConnection
的实现调用其所有者的GetNetConnection
。由于没有所有者,连接是无效的。
有效的所有者将包括以下元素:
-
AWeapon
没有覆盖GetNetConnection
函数,因此要获取其连接,它将调用找到的第一个实现,即AActor::GetNetConnection
。 -
AActor::GetNetConnection
的实现调用其所有者的GetNetConnection
。由于所有者是拾取武器的角色,它将在其上调用GetNetConnection
。 -
ACharacter
没有覆盖GetNetConnection
函数,因此要获取其连接,它将调用找到的第一个实现,即APawn::GetNetConnection
。 -
APawn::GetNetConnection
的实现使用拥有的玩家控制器的连接。由于拥有的玩家控制器是有效的,因此它将使用该连接来处理武器。
注意
为了使SetOwner
按预期工作,它需要在大多数情况下在服务器上执行,这意味着需要在权限上执行。如果你只在客户端执行SetOwner
,它仍然无法执行复制操作。
角色
当你在服务器上生成一个角色时,将在服务器上创建一个角色的版本,并在每个客户端上创建一个版本。由于在游戏的不同实例(服务器
,客户端 1
,客户端 2
等)上有相同角色的不同版本,因此知道哪个版本的角色是哪个是很重要的。这将使我们知道可以在这些实例中执行什么逻辑。
为了帮助解决这种情况,每个角色都有以下两个变量:
-
GetLocalRole()
函数。 -
GetRemoteRole()
函数。
GetLocalRole()
和GetRemoteRole()
函数的返回类型是ENetRole
,它是一个枚举,可以具有以下可能的值:
-
ROLE_None
:该角色没有角色,因为它没有被复制。 -
ROLE_SimulatedProxy
:当前游戏实例对该角色没有权限,并且也没有通过玩家控制器来控制它。这意味着它的移动将通过使用角色速度的最后一个值来进行模拟/预测。 -
ROLE_AutonomousProxy
:当前游戏实例对该角色没有权限,但它由玩家控制。这意味着我们可以根据玩家的输入向服务器发送更准确的移动信息,而不仅仅使用角色速度的最后一个值。 -
ROLE_Authority
:当前游戏实例对该角色具有完全权限。这意味着如果该角色在服务器上,对该角色的复制变量所做的更改将被视为每个客户端需要通过变量复制强制执行的值。
让我们看一下以下示例代码片段:
ENetRole MyLocalRole = GetLocalRole();
ENetRole MyRemoteRole = GetRemoteRole();
FString String;
if(MyLocalRole == ROLE_Authority)
{
if(MyRemoteRole == ROLE_AutonomousProxy)
{
String = «This version of the actor is the authority and
it›s being controlled by a player on its client»;
}
else if(MyRemoteRole == ROLE_SimulatedProxy)
{
String = «This version of the actor is the authority but
it›s not being controlled by a player on its client»;
}
}
else String = "This version of the actor isn't the authority";
GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Red, String);
上述代码片段将将本地角色和远程角色的值分别存储到MyLocalRole
和MyRemoteRole
中。之后,它将根据该角色的版本是权限还是在其客户端上由玩家控制而在屏幕上打印不同的消息。
注意
重要的是要理解,如果一个角色具有ROLE_Authority
的本地角色,这并不意味着它在服务器上;这意味着它在最初生成角色的游戏实例上,并因此对其具有权限。
如果客户端生成一个角色,即使服务器和其他客户端不知道它,它的本地角色仍将是ROLE_Authority
。大多数多人游戏中的角色都将由服务器生成;这就是为什么很容易误解权限总是指服务器。
以下是一个表格,帮助您理解角色在不同情况下将具有的角色:
图 16.7:角色在不同场景中可以拥有的角色
在上表中,您可以看到角色在不同情况下将具有的角色。
让我们分析每种情况,并解释为什么角色具有该角色:
在服务器上生成的角色
该角色在服务器上生成,因此服务器版本的该角色将具有ROLE_Authority
的本地角色和ROLE_SimulatedProxy
的远程角色,这是客户端版本的该角色的本地角色。对于该角色的客户端版本,其本地角色将是ROLE_SimulatedProxy
,远程角色将是ROLE_Authority
,这是服务器角色版本的本地角色。
在客户端上生成的角色
角色在客户端上生成,因此该角色的客户端版本将具有ROLE_Authority
的本地角色和ROLE_SimulatedProxy
的远程角色。由于该角色未在服务器上生成,因此它只会存在于生成它的客户端上,因此在服务器和其他客户端上不会有该角色的版本。
在服务器上生成的玩家拥有的角色
该角色在服务器上生成,因此服务器版本的该角色将具有ROLE_Authority
的本地角色和ROLE_AutonomousProxy
的远程角色,这是客户端版本的该角色的本地角色。对于该角色的客户端版本,其本地角色将是ROLE_AutonomousProxy
,因为它由PlayerController
控制,并且远程角色将是ROLE_Authority
,这是服务器角色版本的本地角色。
在客户端上生成的玩家拥有的角色
该 pawn 在客户端上生成,因此该 pawn 的客户端版本将具有ROLE_Authority
的本地角色和ROLE_SimulatedProxy
的远程角色。由于 pawn 没有在服务器上生成,因此它只会存在于生成它的客户端上,因此在服务器和其他客户端上不会有这个 pawn 的版本。
练习 16.02:实现所有权和角色
在这个练习中,我们将创建一个使用 Third Person 模板作为基础的 C++项目。
创建一个名为OwnershipTestActor
的新 actor,它具有静态网格组件作为根组件,并且在每次 tick 时,它将执行以下操作:
-
在权限方面,它将检查在一定半径内(由名为
OwnershipRadius
的EditAnywhere
变量配置)哪个角色离它最近,并将该角色设置为其所有者。当半径内没有角色时,所有者将为nullptr
。 -
显示其本地角色、远程角色、所有者和连接。
-
编辑
OwnershipRolesCharacter
并覆盖Tick
函数,以便显示其本地角色、远程角色、所有者和连接。 -
创建一个名为
OwnershipRoles.h
的新头文件,其中包含ROLE_TO_String
宏,将ENetRole
转换为Fstring
变量。
以下步骤将帮助您完成练习:
-
使用
C++
创建一个名为OwnershipRoles
的新Third Person
模板项目,并将其保存到您选择的位置。 -
项目创建完成后,应该打开编辑器以及 Visual Studio 解决方案。
-
使用编辑器,创建一个名为
OwnershipTestActor
的新 C++类,该类派生自Actor
。 -
编译完成后,Visual Studio 应该弹出新创建的
.h
和.cpp
文件。 -
关闭编辑器,返回 Visual Studio。
-
在 Visual Studio 中,打开
OwnershipRoles.h
文件并添加以下宏:
#define ROLE_TO_STRING(Value) FindObject<UEnum>(ANY_PACKAGE, TEXT("ENetRole"), true)->GetNameStringByIndex((int32)Value)
这个宏将把我们从GetLocalRole()
函数和GetRemoteRole()
获得的ENetRole
枚举转换为FString
。它的工作方式是通过在虚幻引擎的反射系统中找到ENetRole
枚举类型,并从中将Value
参数转换为FString
变量,以便在屏幕上打印出来。
-
现在,打开
OwnershipTestActor.h
文件。 -
根据以下代码片段中所示,声明静态网格组件和所有权半径的受保护变量:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Ownership Test Actor")
UStaticMeshComponent* Mesh;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Ownership Test Actor")
float OwnershipRadius = 400.0f;
在上面的代码片段中,我们声明了静态网格组件和OwnershipRadius
变量,它允许您配置所有权的半径。
-
接下来,删除
BeginPlay
的声明,并将构造函数和Tick
函数的声明移到受保护的区域。 -
现在,打开
OwnershipTestActor.cpp
文件,并根据以下代码片段中提到的添加所需的头文件:
#include "DrawDebugHelpers.h"
#include "OwnershipRoles.h"
#include "OwnershipRolesCharacter.h"
#include "Components/StaticMeshComponent.h"
#include "Kismet/GameplayStatics.h"
在上面的代码片段中,我们包括了DrawDebugHelpers.h
,因为我们将调用DrawDebugSphere
和DrawDebugString
函数。我们包括OwnershipRoles.h
,OwnershipRolesCharacter.h
和StaticMeshComponent.h
,以便.cpp
文件知道这些类。最后,我们包括GameplayStatics.h
,因为我们将调用GetAllActorsOfClass
函数。
- 在构造函数定义中,创建静态网格组件并将其设置为根组件:
Mesh = CreateDefaultSubobject<UStaticMeshComponent>("Mesh");
RootComponent = Mesh;
- 在构造函数中,将
bReplicates
设置为true
,告诉虚幻引擎该 actor 会复制,并且也应该存在于所有客户端中:
bReplicates = true;
-
删除
BeginPlay
函数定义。 -
在
Tick
函数中,绘制一个调试球来帮助可视化所有权半径,如下面的代码片段所示:
DrawDebugSphere(GetWorld(), GetActorLocation(), OwnershipRadius, 32, FColor::Yellow);
- 仍然在
Tick
函数中,创建特定于权限的逻辑,该逻辑将获取所有权半径内最接近的AOwnershipRolesCharacter
,如果与当前角色不同,则将其设置为所有者:
if (HasAuthority())
{
AActor* NextOwner = nullptr;
float MinDistance = OwnershipRadius;
TArray<AActor*> Actors;
UGameplayStatics::GetAllActorsOfClass(this, AOwnershipRolesCharacter::StaticClass(), Actors);
for (AActor* Actor : Actors)
{
const float Distance = GetDistanceTo(Actor);
if (Distance <= MinDistance)
{
MinDistance = Distance;
NextOwner = Actor;
}
}
if (GetOwner() != NextOwner)
{
SetOwner(NextOwner);
}
}
- 仍然在
Tick
函数中,将本地/远程角色的值(使用我们之前创建的ROLE_TO_STRING
宏),当前所有者和连接转换为字符串:
const FString LocalRoleString = ROLE_TO_STRING(GetLocalRole());
const FString RemoteRoleString = ROLE_TO_STRING(GetRemoteRole());
const FString OwnerString = GetOwner() != nullptr ? GetOwner()- >GetName() : TEXT("No Owner");
const FString ConnectionString = GetNetConnection() != nullptr ? TEXT("Valid Connection") : TEXT("Invalid Connection");
- 最后,使用
DrawDebugString
在屏幕上显示我们在上一步中转换的字符串:
const FString Values = FString::Printf(TEXT("LocalRole = %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"), *LocalRoleString, *RemoteRoleString, *OwnerString, *ConnectionString);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr, FColor::White, 0.0f, true);
注意
不要不断使用GetLocalRole() == ROLE_Authority
来检查角色是否具有权限,可以使用AActor
中定义的HasAuthority()
辅助函数。
- 接下来,打开
OwnershipRolesCharacter.h
并将Tick
函数声明为受保护的:
virtual void Tick(float DeltaTime) override;
- 现在,打开
OwnershipRolesCharacter.cpp
并按照以下代码片段中所示包含头文件:
#include "DrawDebugHelpers.h"
#include "OwnershipRoles.h"
- 实现
Tick
函数:
void AOwnershipRolesCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
- 将本地/远程角色的值(使用我们之前创建的
ROLE_TO_STRING
宏),当前所有者和连接转换为字符串:
const FString LocalRoleString = ROLE_TO_STRING(GetLocalRole());
const FString RemoteRoleString = ROLE_TO_STRING(GetRemoteRole());
const FString OwnerString = GetOwner() != nullptr ? GetOwner()- >GetName() : TEXT("No Owner");
const FString ConnectionString = GetNetConnection() != nullptr ? TEXT("Valid Connection") : TEXT("Invalid Connection");
- 使用
DrawDebugString
在屏幕上显示我们在上一步中转换的字符串:
const FString Values = FString::Printf(TEXT("LocalRole = %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"), *LocalRoleString, *RemoteRoleString, *OwnerString, *ConnectionString);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr, FColor::White, 0.0f, true);
最后,我们可以测试项目。
-
运行代码并等待编辑器完全加载。
-
在
Content
文件夹中创建一个名为OwnershipTestActor_BP
的新蓝图,它派生自OwnershipTestActor
。将Mesh
设置为使用立方体网格,并在世界中放置一个实例。 -
转到
多人游戏选项
并将客户端数量设置为2
。 -
将窗口大小设置为
800x600
。 -
使用
New Editor Window (PIE)
进行游戏。
你应该得到以下输出:
图 16.8:服务器和 Client 1 窗口上的预期结果
通过完成这个练习,你将更好地理解连接和所有权是如何工作的。这些是重要的概念,因为与复制相关的一切都依赖于它们。
下次当你看到一个角色没有进行复制操作时,你会知道需要首先检查它是否有有效的连接和所有者。
现在,让我们分析服务器和客户端窗口中显示的值。
服务器窗口
看一下上一个练习中Server
窗口的以下输出截图:
图 16.9:服务器窗口
注意
显示Server Character
,Client 1 Character
和Ownership Test Actor
的文本不是原始截图的一部分,是为了帮助你理解哪个角色和角色是哪个而添加的。
在上面的截图中,你可以看到Server Character
,Client 1 Character
和Ownership Test
立方体角色。
首先分析Server Character
的值。
服务器角色
这是监听服务器正在控制的角色。与这个角色相关的值如下:
-
LocalRole = ROLE_Authority
:因为这个角色是在服务器上生成的,这是当前的游戏实例。 -
RemoteRole = ROLE_SimulatedProxy
:因为这个角色是在服务器上生成的,所以其他客户端只能模拟它。 -
Owner = PlayerController_0
:因为这个角色由监听服务器的客户端控制,使用了名为PlayerController_0
的第一个PlayerController
实例。 -
Connection = Invalid Connection
:因为我们是监听服务器的客户端,所以不需要连接。
接下来,我们将在同一个窗口中查看Client 1 Character
。
Client 1 Character
这是Client 1
正在控制的角色。与这个角色相关的值如下:
-
LocalRole = ROLE_Authority
:因为这个角色是在服务器上生成的,这是当前的游戏实例。 -
RemoteRole = ROLE_AutonomousProxy
:因为这个角色是在服务器上生成的,但是由另一个客户端控制。 -
Owner = PlayerController_1
:因为这个角色是由另一个客户端控制的,使用了名为PlayerController_1
的第二个PlayerController
实例。 -
Connection = Valid Connection
:因为这个角色由另一个客户端控制,所以需要与服务器建立连接。
接下来,我们将在同一个窗口中查看OwnershipTest
角色。
OwnershipTest Actor
这是将其所有者设置为一定所有权半径内最近的角色的立方体演员。与该演员相关的值如下:
-
LocalRole = ROLE_Authority
:因为这个演员被放置在级别中,并在服务器上生成,这是当前游戏实例。 -
RemoteRole = ROLE_SimulatedProxy
:因为这个演员是在服务器中生成的,但没有被任何客户端控制。 -
Owner
和Connection
的值将基于最近的角色。如果在所有权半径内没有角色,则它们将具有无所有者
和无效连接
的值。
现在,让我们看一下Client 1
窗口:
图 16.10:客户端 1 窗口
客户端 1 窗口
Client 1
窗口的值将与Server
窗口的值完全相同,只是LocalRole
和RemoteRole
的值将被颠倒,因为它们始终相对于您所在的游戏实例。
另一个例外是服务器角色没有所有者,其他连接的客户端将没有有效连接。原因是客户端不存储其他客户端的玩家控制器和连接,只有服务器才会存储,但这将在第十八章中更深入地介绍多人游戏中的游戏框架类。
在下一节中,我们将看一下变量复制。
变量复制
服务器可以使客户端保持同步的一种方式是使用变量复制。其工作方式是,每秒特定次数(在AActor::NetUpdateFrequency
变量中为每个演员定义,也暴露给蓝图)服务器中的变量复制系统将检查是否有任何需要使用最新值更新的客户端中的复制变量(在下一节中解释)。
如果变量满足所有复制条件,那么服务器将向客户端发送更新并强制执行新值。
例如,如果您有一个复制的Health
变量,并且客户端使用黑客工具将变量的值从10
设置为100
,那么复制系统将强制从服务器获取真实值并将其更改回10
,从而使黑客无效。
只有在以下情况下才会将变量发送到客户端进行更新:
-
变量被设置为复制。
-
值已在服务器上更改。
-
客户端上的值与服务器上的值不同。
-
演员已启用复制。
-
演员是相关的,并满足所有复制条件。
需要考虑的一个重要事项是,确定变量是否应该复制的逻辑仅在每秒执行AActor::NetUpdateFrequency
次。换句话说,服务器在更改服务器上的变量值后不会立即向客户端发送更新请求。只有在变量复制系统执行时(每秒AActor::NetUpdateFrequency
次),并且确定客户端的值与服务器的值不同时,才会发送该请求。
例如,如果您有一个整数复制一个名为Test
的变量,其默认值为5
。如果您在服务器上调用一个将Test
设置为3
的函数,并在下一行将其更改为8
,那么只有后者的更改会发送更新请求到客户端。原因是这两个更改是在NetUpdateFrequency
间隔之间进行的,因此当变量复制系统执行时,当前值为8
,因为它与客户端的值不同(仍为5
),它将更新它们。如果您将其设置回5
,则不会向客户端发送任何更改。
复制变量
在虚幻引擎中,任何可以使用UPROPERTY
宏的变量都可以设置为复制,并且可以使用两个限定词来执行此操作。
复制
如果你只想说一个变量被复制,那么你使用Replicated
修饰符。
看下面的例子:
UPROPERTY(Replicated)
float Health = 100.0f;
在上述代码片段中,我们声明了一个名为Health
的浮点变量,就像我们通常做的那样。不同之处在于,我们添加了UPROPERTY(Replicated)
,告诉虚幻引擎Health
变量将被复制。
RepNotify
如果你想说一个变量被复制并且每次更新时都调用一个函数,那么你使用ReplicatedUsing=<Function Name>
修饰符。看下面的例子:
UPROPERTY(ReplicatedUsing=OnRep_Health)
float Health = 100.0f;
UFUNCTION()
void OnRep_Health()
{
UpdateHUD();
}
在上述代码片段中,我们声明了一个名为Health
的浮点变量。不同之处在于,我们添加了UPROPERTY(ReplicatedUsing=OnRep_Health)
,告诉虚幻引擎这个变量将被复制,并且每次更新时都会调用OnRep_Health
函数,在这种特定情况下,它将调用一个函数来更新HUD
。
通常,回调函数的命名方案是OnRepNotify_<Variable Name>
或OnRep_<Variable Name>
。
注意
在ReplicatingUsing
修饰符中使用的函数需要标记为UFUNCTION()
。
GetLifetimeReplicatedProps
除了将变量标记为复制外,您还需要在角色的cpp
文件中实现GetLifetimeReplicatedProps
函数。需要考虑的一件事是,一旦您至少有一个复制的变量,此函数将在内部声明,因此您不应该在角色的头文件中声明它。这个函数的目的是告诉您每个复制的变量应该如何复制。您可以通过在您想要复制的每个变量上使用DOREPLIFETIME
宏及其变体来实现这一点。
DOREPLIFETIME
这个宏告诉复制系统,复制的变量(作为参数输入)将在没有复制条件的情况下复制到所有客户端。
以下是它的语法:
DOREPLIFETIME(<Class Name>, <Replicated Variable Name>);
看下面的例子:
void AVariableReplicationActor::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AVariableReplicationActor, Health);
}
在上述代码片段中,我们使用DOREPLIFETIME
宏告诉复制系统,AVariableReplicationActor
类中的Health
变量将在没有额外条件的情况下复制。
DOREPLIFETIME_CONDITION
这个宏告诉复制系统,复制的变量(作为参数输入)只会根据满足的条件(作为参数输入)复制给客户端。
以下是语法:
DOREPLIFETIME_CONDITION(<Class Name>, <Replicated Variable Name>, <Condition>);
条件参数可以是以下值之一:
-
COND_InitialOnly
:变量只会复制一次,进行初始复制。 -
COND_OwnerOnly
:变量只会复制给角色的所有者。 -
COND_SkipOwner
:变量不会复制给角色的所有者。 -
COND_SimulatedOnly
:变量只会复制到正在模拟的角色。 -
COND_AutonomousOnly
:变量只会复制给自主角色。 -
COND_SimulatedOrPhysics
:变量只会复制到正在模拟的角色或bRepPhysics
设置为 true 的角色。 -
COND_InitialOrOwner
:变量只会进行初始复制,或者只会复制给角色的所有者。 -
COND_Custom
:变量只有在其SetCustomIsActiveOverride
布尔条件(在AActor::PreReplication
函数中使用)为 true 时才会复制。
看下面的例子:
void AVariableReplicationActor::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION(AVariableReplicationActor, Health, COND_OwnerOnly);
}
在上述代码片段中,我们使用DOREPLIFETIME_CONDITION
宏告诉复制系统,AVariableReplicationActor
类中的Health
变量只会为该角色的所有者复制。
注意
还有更多的DOREPLIFETIME
宏可用,但本书不会涵盖它们。要查看所有变体,请检查虚幻引擎 4 源代码中的UnrealNetwork.h
文件。请参阅以下说明:docs.unrealengine.com/en-US/GettingStarted/DownloadingUnrealEngine/index.html
。
练习 16.03:使用 Replicated、RepNotify、DOREPLIFETIME 和 DOREPLIFETIME_CONDITION 复制变量
在这个练习中,我们将创建一个 C++项目,该项目以第三人称模板为基础,并向角色添加两个以以下方式复制的变量:
-
变量
A
是一个浮点数,将使用Replicated UPROPERTY
说明符和DOREPLIFETIME
宏。 -
变量
B
是一个整数,将使用ReplicatedUsing UPROPERTY
说明符和DOREPLIFETIME_CONDITION
宏。
以下步骤将帮助您完成练习:
-
使用 C++创建一个名为
VariableReplication
的Third Person
模板项目,并将其保存到您选择的位置。 -
项目创建后,应打开编辑器以及 Visual Studio 解决方案。
-
关闭编辑器,返回 Visual Studio。
-
打开
VariableReplicationCharacter.h
文件。 -
然后,在
VariableReplicationCharacter.generated.h
之前包含UnrealNetwork.h
头文件,其中包含我们将使用的DOREPLIFETIME
宏的定义:
#include "Net/UnrealNetwork.h"
- 使用各自的复制说明符将受保护的变量
A
和B
声明为UPROPERTY
:
UPROPERTY(Replicated)
float A = 100.0f;
UPROPERTY(ReplicatedUsing = OnRepNotify_B)
int32 B;
- 将
Tick
函数声明为受保护:
virtual void Tick(float DeltaTime) override;
- 由于我们将变量
B
声明为ReplicatedUsing = OnRepNotify_B
,因此我们还需要将受保护的OnRepNotify_B
回调函数声明为UFUNCTION
:
UFUNCTION()
void OnRepNotify_B();
- 现在,打开
VariableReplicationCharacter.cpp
文件,并包括Engine.h
头文件,这样我们就可以使用AddOnScreenDebugMessage
函数,以及DrawDebugHelpers.h
头文件,这样我们就可以使用DrawDebugString
函数:
#include "Engine/Engine.h"
#include "DrawDebugHelpers.h"
- 实现
GetLifetimeReplicatedProps
函数:
void AVariableReplicationCharacter::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
}
- 将其设置为
A
变量,它将在没有任何额外条件的情况下复制:
DOREPLIFETIME(AVariableReplicationCharacter, A);
- 将其设置为
B
变量,这将仅复制到此角色的所有者:
DOREPLIFETIME_CONDITION(AVariableReplicationCharacter, B, COND_OwnerOnly);
- 实现
Tick
函数:
void AVariableReplicationCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
- 接下来,运行特定权限的逻辑,将
1
添加到A
和B
:
if (HasAuthority())
{
A++;
B++;
}
由于此角色将在服务器上生成,因此只有服务器将执行此逻辑。
- 在角色的位置上显示
A
和B
的值:
const FString Values = FString::Printf(TEXT("A = %.2f B = %d"), A, B);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr, FColor::White, 0.0f, true);
- 实现变量
B
的RepNotify
函数,该函数在屏幕上显示一条消息,说明B
变量已更改为新值:
void AVariableReplicationCharacter::OnRepNotify_B()
{
const FString String = FString::Printf(TEXT("B was changed by the server and is now %d!"), B);
GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Red,String);
}
最后,您可以测试项目:
-
运行代码,等待编辑器完全加载。
-
转到“多人游戏选项”,并将客户端数量设置为
2
。 -
将窗口大小设置为
800x600
。 -
使用“新编辑器窗口(PIE)”进行游戏。
完成此练习后,您将能够在每个客户端上进行游戏,并且您会注意到角色显示其各自的A
和B
的值。
现在,让我们分析“服务器”和“客户端 1”窗口中显示的值。
服务器窗口
在“服务器”窗口中,您可以看到“服务器角色”的值,这是由服务器控制的角色,在后台,您可以看到“客户端 1 角色”的值:
图 16.11:服务器窗口
可以观察到的输出如下:
-
“服务器”“角色” -
A = 674.00 B = 574
-
“客户端 1”“角色” -
A = 670.00 B = 570
在特定时间点,“服务器”“角色”的A
值为674
,B
值为574
。之所以A
和B
有不同的值,是因为A
从100
开始,B
从0
开始,这是574
次A++
和B++
后的正确值。
至于为什么“客户端 1”“角色”的值与服务器角色不同,那是因为“客户端 1”稍后创建,所以在这种情况下,A++
和B++
的计数将偏移 4 个滴答声。
接下来,我们将查看“客户端 1”窗口。
客户端 1 窗口
在“客户端 1”窗口中,您可以看到“客户端 1 角色”的值,这是由“客户端 1”控制的角色,在后台,您可以看到“服务器角色”的值:
图 16.12:客户端 1 窗口
可以观察到的输出如下:
-
Server
Character
–A = 674.00 B = 0
-
Client 1
Character
–A = 670.00 B = 570
Client 1 Character
从服务器那里得到了正确的值,因此变量复制正常工作。如果您查看Server Character
,A
是674
,这是正确的,但B
是0
。原因是A
使用了DOREPLIFETIME
,它不会添加任何额外的复制条件,因此它将复制变量并在服务器上的变量更改时每次使客户端保持最新状态。
另一方面,变量B
使用DOREPLIFETIME_CONDITION
和COND_OwnerOnly
,由于Client 1
不是拥有Server Character
的客户端(监听服务器的客户端是),因此该值不会被复制,并且保持不变为0
的默认值。
如果您返回代码并将B
的复制条件更改为使用COND_SimulatedOnly
而不是COND_OwnerOnly
,您会注意到结果将在Client 1
窗口中被颠倒。B
的值将被复制到Server Character
,但不会复制到自己的角色。
注意
RepNotify
消息显示在Server
窗口而不是客户端窗口的原因是,当在编辑器中播放时,两个窗口共享同一个进程,因此在屏幕上打印文本不准确。要获得正确的行为,您需要运行游戏的打包版本。
2D 混合空间
在第二章,使用虚幻引擎中,我们创建了一个 1D 混合空间,根据Speed
轴的值来混合角色的移动状态(空闲、行走和奔跑)。对于这个特定的示例,它工作得相当好,因为您只需要一个轴,但是如果我们希望角色也能够斜行,那么我们实际上无法做到。
为了探索这种情况,虚幻引擎允许您创建 2D 混合空间。概念几乎完全相同;唯一的区别是您有一个额外的轴用于动画,因此您不仅可以在水平方向上混合它们,还可以在垂直方向上混合它们。
练习 16.04:创建移动 2D 混合空间
在这个练习中,我们将创建一个使用两个轴而不是一个轴的混合空间。垂直轴将是Speed
,取值范围为0
到800
。水平轴将是Direction
,表示角色速度和旋转/前向矢量之间的相对角度(-180 到 180
)。
以下图将帮助您计算本练习中的方向:
图 16.13:基于前向矢量和速度之间角度的方向值
在前面的图中,您可以看到方向是如何计算的。前向矢量表示角色当前面对的方向,数字表示如果前向矢量指向该方向,则前向矢量将与速度矢量形成的角度。如果角色朝向某个方向,然后按键移动角色向右,那么速度矢量将与前向矢量垂直。这意味着角度将是 90º,这将是我们的方向。
如果我们根据这个逻辑设置我们的 2D 混合空间,我们可以根据角色的移动角度使用正确的动画。
以下步骤将帮助您完成练习:
-
使用
Blueprints
创建一个名为Blendspace2D
的新Third Person
模板项目,并将其保存到您选择的位置。 -
项目创建后,应该打开编辑器。
-
接下来,您将导入移动动画。在编辑器中,转到
Content\Mannequin\Animations
文件夹。 -
点击
导入
按钮。 -
进入
Chapter16\Exercise16.04\Assets
文件夹,选择所有fbx
文件,然后点击打开
按钮。 -
在导入对话框中,确保选择角色的骨架并点击
Import All
按钮。 -
保存所有新文件到
Assets
文件夹中。 -
点击
Add New
按钮并选择Animation -> Blend Space
。 -
接下来,选择角色的骨架。
-
重命名混合空间为
BS_Movement
并打开它。 -
创建水平
Direction
轴(-180 至 180)和垂直Speed
轴(0 至 800),如下图所示:
图 16.14:2D 混合空间轴设置
-
将
Idle_Rifle_Ironsights
动画拖到Speed
为0
的 5 个网格条目上。 -
将
Walk_Fwd_Rifle_Ironsights
动画拖到Speed
为800
,Direction
为0
的位置。 -
将
Walk_Lt_Rifle_Ironsights
动画拖到Speed
为800
,Direction
为-90
的位置。 -
将
Walk_Rt_Rifle_Ironsights
动画拖到Speed
为800
,Direction
为90
的位置。
您应该最终得到一个可以通过按住Shift并移动鼠标来预览的混合空间。
-
现在,在
Asset Details
面板上,将Target Weight Interpolation Speed Per Sec
变量设置为5
,以使插值更加平滑。 -
保存并关闭混合空间。
-
现在,更新动画蓝图以使用新的混合空间。
-
转到
Content\Mannequin\Animations
并打开随 Third Person 模板一起提供的文件–ThirdPerson_AnimBP
。 -
接下来,转到事件图并创建一个名为
Direction
的新浮点变量。 -
使用
Calculate Direction
函数的结果设置Direction
的值,该函数计算角度(-180º至 180º)在角色的速度
和旋转
之间:
图 16.15:计算用于 2D 混合空间的速度和方向
注意
您可以在以下链接找到前面的截图的全分辨率版本以便更好地查看:packt.live/3pAbbAl
。
- 在
AnimGraph
中,转到正在使用旧的 1D 混合空间的Idle/Run
状态,如下截图所示:
图 16.16:AnimGraph 中的空闲/奔跑状态
- 用
BS_Movement
替换该混合空间,并像这样使用Direction
变量:
图 16.17:1D 混合空间已被新的 2D 混合空间替换
-
保存并关闭动画蓝图。现在您需要更新角色。
-
转到
Content\ThirdPersonBP\Blueprints
文件夹并打开ThirdPersonCharacter
。 -
在角色的
Details
面板上,将Use Controller Rotation Yaw
设置为true
,这将使角色的Yaw
旋转始终面向控制旋转的 Yaw。 -
转到角色移动组件并将
Max Walk Speed
设置为800
。 -
将
Orient Rotation to Movement
设置为false
,这将防止角色朝向移动方向旋转。 -
保存并关闭角色蓝图。
如果现在使用两个客户端玩游戏并移动角色,它将向前和向后走,但也会侧移,如下面的截图所示:
图 16.18:服务器和客户端 1 窗口上的预期输出
通过完成这个练习,您将提高对如何创建 2D 混合空间、它们的工作原理以及它们相对于仅使用常规 1D 混合空间的优势的理解。
在下一节中,我们将看一下如何转换角色的骨骼,以便根据摄像机的俯仰旋转玩家的躯干上下。
转换(修改)骨骼
在我们继续之前,有一个非常有用的节点,您可以在 AnimGraph 中使用,称为Transform (Modify) Bone
节点,它允许您在运行时转换骨骼的平移、旋转和缩放。
您可以通过右键单击空白处,在AnimGraph
中添加它,输入transform modify
,然后从列表中选择节点。如果单击Transform (Modify) Bone
节点,您将在Details
面板上有相当多的选项。
以下是每个选项的解释。
Bone to Modify
选项将告诉节点将要变换的骨骼是哪个。
在该选项之后,您有三个部分,分别代表每个变换操作(Translation
,Rotation
和Scale
)。在每个部分中,您可以执行以下操作:
Translation,Rotation,Scale
:此选项将告诉节点您要应用多少特定变换操作。最终结果将取决于您选择的模式(在下一节中介绍)。
有两种方法可以设置此值:
-
设置一个常量值,比如(
X=0.0,Y=0.0,Z=0.0
) -
使用一个变量,这样它可以在运行时更改。为了实现这一点,您需要采取以下步骤(此示例是为了
Rotation
,但相同的概念也适用于Translation
和Scale
):
- 单击常量值旁边的复选框,并确保它被选中。一旦您这样做了,常量值的文本框将消失。
图 16.19:勾选复选框
Transform (Modify) Bone
将添加一个输入,这样您就可以插入您的变量:
图 16.20:变量用作变换(修改)骨骼节点的输入
设置模式
这将告诉节点如何处理该值。您可以从以下三个选项中选择一个:
-
Ignore
:不对提供的值进行任何操作。 -
Add to Existing
:获取骨骼的当前值,并将提供的值添加到其中。 -
Replace Existing
:用提供的值替换骨骼的当前值。
设置空间
这将定义节点应该应用变换的空间。您可以从以下四个选项中选择一个:
-
World Space
:变换将发生在世界空间中。 -
Component Space
:变换将发生在骨骼网格组件空间中。 -
Parent Bone Space
:变换将发生在所选骨骼的父骨骼空间中。 -
Bone Space
:变换将发生在所选骨骼的空间中。
最后但同样重要的是Alpha
,它是一个值,允许您控制要应用的变换量。例如,如果Alpha
值为浮点数,则不同值将产生以下行为:
-
如果
Alpha
为 0.0,则不会应用任何变换。 -
如果
Alpha
为 0.5,则只会应用一半的变换。 -
如果
Alpha
为 1.0,则会应用整个变换。
在下一个练习中,我们将使用Transform (Modify) Bone
节点来使角色能够根据摄像机的旋转从练习 16.04,创建一个 2D 混合运动空间中上下观察。
练习 16.05:创建一个能够上下观察的角色
在这个练习中,我们将复制练习 16.04中的项目,创建一个 2D 混合运动空间,并使角色能够根据摄像机的旋转上下观察。为了实现这一点,我们将使用Transform (Modify) Bone
节点来根据摄像机的俯仰在组件空间中旋转spine_03
骨骼。
以下步骤将帮助您完成练习:
-
首先,您需要复制并重命名练习 16.04中的项目,创建一个 2D 混合运动空间。
-
从练习 16.04中复制
Blendspace2D
项目文件夹,创建一个 2D 混合运动空间,粘贴到一个新文件夹中,并将其重命名为TransformModifyBone
。 -
打开新的项目文件夹,将
Blendspace2D.uproject
文件重命名为TransformModifyBone.uproject
,然后打开它。
接下来,您将更新动画蓝图。
-
转到
Content\Mannequin\Animations
,并打开ThirdPerson_AnimBP
。 -
转到“事件图”,创建一个名为“俯仰”的浮点变量,并将其设置为 pawn 旋转和基本瞄准旋转之间的减法(或 delta)的俯仰,如下图所示:
图 16.21:计算俯仰
作为使用“分解旋转器”节点的替代方法,您可以右键单击“返回值”,然后选择“拆分结构引脚”。
注意
“分解旋转器”节点允许您将“旋转器”变量分解为代表“俯仰”、“偏航”和“翻滚”的三个浮点变量。当您想要访问每个单独组件的值或者只想使用一个或两个组件而不是整个旋转时,这将非常有用。
请注意,“拆分结构引脚”选项只会在“返回值”未连接到任何东西时出现。一旦您进行拆分,它将创建三根分开的电线,分别代表“翻滚”、“俯仰”和“偏航”,就像一个分解但没有额外的节点。
你应该得到以下结果:
图 16.22:使用拆分结构引脚选项计算俯仰
这个逻辑使用了 pawn 的旋转并将其减去摄像机的旋转,以获得“俯仰”的差异,如下图所示:
图 16.23:如何计算 Delta Pitch
- 接下来,转到
AnimGraph
并添加一个带有以下设置的“变换(修改)骨骼”节点:
图 16.24:变换(修改)骨骼节点的设置
在前面的截图中,我们将“要修改的骨骼”设置为spine_03
,因为这是我们想要旋转的骨骼。我们还将“旋转模式”设置为“添加到现有”,因为我们希望保留动画中的原始旋转并添加偏移量。其余选项需要保持默认值。
- 将“变换(修改)骨骼”节点连接到“状态机”和“输出姿势”,如下截图所示:
图 16.25:变换(修改)骨骼连接到输出姿势
在前面的图中,您可以看到完整的AnimGraph
,它将允许角色通过旋转spine_03
骨骼来上下查看,基于摄像机的俯仰。 “状态机”将是起点,从那里,它将需要转换为组件空间,以便能够使用“变换(修改)骨骼”节点,然后连接到“输出姿势”节点,再转换回本地空间。
注意
我们将“俯仰”变量连接到“翻滚”的原因是骨骼在骨架内部是以这种方式旋转的。您也可以在输入参数上使用“拆分结构引脚”,这样您就不必添加“制作旋转器”节点。
如果您使用两个客户端测试项目,并在其中一个角色上向上和向下移动鼠标,您会注意到它会上下俯仰,如下截图所示:
图 16.26:根据摄像机旋转使角色网格上下俯仰
通过完成这个最终练习,您将了解如何在动画蓝图中使用“变换(修改)骨骼”节点在运行时修改骨骼。这个节点可以在各种场景中使用,所以对您可能非常有用。
在下一个活动中,您将通过创建我们将在多人 FPS 项目中使用的角色来将您学到的一切付诸实践。
活动 16.01:为多人 FPS 项目创建角色
在此活动中,您将为我们在接下来的几章中构建的多人 FPS 项目创建角色。 角色将具有一些不同的机制,但是对于此活动,您只需要创建一个可以行走,跳跃,上下查看并具有两个复制的统计数据:生命值和护甲的角色。
以下步骤将帮助您完成此活动:
-
创建一个名为
MultiplayerFPS
的Blank C++
项目,不包含起始内容。 -
从
Activity16.01\Assets
文件夹导入骨骼网格和动画,并将它们分别放置在Content\Player\Mesh
和Content\Player\Animations
文件夹中。 -
从
Activity16.01\Assets
文件夹导入以下声音到Content\Player\Sounds
:
-
Jump.wav
:在Jump_From_Stand_Ironsights
动画上使用Play Sound
动画通知播放此声音。 -
Footstep.wav
:通过使用Play Sound
动画通知,在每次行走动画中脚踩在地板上时播放此声音。 -
Spawn.wav
:在角色的SpawnSound
变量上使用此音频。
-
通过重新定位其骨骼并创建一个名为
Camera
的插座来设置骨骼网格,该插座是头骨的子级,并具有相对位置(X=7.88, Y=4.73, Z=-10.00)。 -
在
Content\Player\Animations
中创建一个名为BS_Movement
的 2D 混合空间,该空间使用导入的移动动画和Target Weight Interpolation Speed Per Sec
为5
。 -
在
Project Settings
中创建输入映射,使用第四章中获得的知识,Player Input:
-
跳跃(动作映射)- 空格键
-
向前移动(轴映射)- W(比例
1.0
)和S(比例-1.0
) -
向右移动(轴映射)- A(比例
-1.0
)和D(比例1.0
) -
转向(轴映射)- 鼠标X(比例
1.0
) -
向上查看(轴映射)- 鼠标Y(比例
-1.0
)
- 创建一个名为
FPSCharacter
的 C++类,执行以下操作:
-
派生自
Character
类。 -
在
Camera
插座上附加到骨骼网格上的摄像头组件,并将pawn control rotation
设置为true
。 -
具有仅复制到所有者的
health
和armor
变量。 -
具有最大
health
和armor
的变量,以及护甲吸收多少伤害的百分比。 -
具有初始化摄像头,禁用打勾,并将
Max Walk Speed
设置为800
和Jump Z Velocity
设置为600
的构造函数。 -
在
BeginPlay
中,播放生成声音并在具有权限时初始化health
为max health
。 -
创建并绑定处理输入动作和轴的功能。
-
具有添加/删除/设置生命值的功能。 还确保角色死亡的情况。
-
具有添加/设置/吸收护甲的功能。护甲吸收根据
ArmorAbsorption
变量减少护甲,并根据以下公式更改伤害值:
Damage = (Damage * (1 - ArmorAbsorption)) - FMath::Min(RemainingArmor, 0);
- 在
Content\Player\Animations
中创建名为ABP_Player
的动画蓝图,其中包含以下状态的State Machine
:
-
Idle/Run
:使用具有Speed
和Direction
变量的BS_Movement
-
Jump
:当Is Jumping
变量为true
时,播放跳跃动画并从Idle/Run
状态转换
它还使用Transform (Modify) Bone
根据相机的 Pitch 使角色上下俯仰。
-
在
Content\UI
中创建一个名为UI_HUD
的UMG
小部件,以Health: 100
和Armor: 100
的格式显示角色的Health
和Armor
,使用第十五章中获得的知识,Collectibles, Power-ups, and Pickups。 -
在
Content\Player
中创建一个名为BP_Player
的蓝图,该蓝图派生自FPSCharacter
,并设置网格组件具有以下值:
-
使用
SK_Mannequin
骨骼网格 -
使用
ABP_Player
动画蓝图 -
将
Location
设置为(X=0.0, Y=0.0, Z=-88.0) -
将
Rotation
设置为(X=0.0, Y=0.0, Z=-90.0)
此外,在Begin Play
事件中,需要创建UI_HUD
的小部件实例并将其添加到视口中。
-
在
Content\Blueprints
中创建一个名为BP_GameMode
的蓝图,它派生自MultiplayerFPSGameModeBase
,并将BP_Player
作为DefaultPawn
类使用。 -
在
Content\Maps
中创建一个名为DM-Test
的测试地图,并将其设置为Project Settings
中的默认地图。
预期输出:
结果应该是一个项目,每个客户端都有一个第一人称角色,可以移动、跳跃和四处张望。这些动作也将被复制,因此每个客户端都能看到其他客户端角色正在做什么。
每个客户端还将拥有一个显示健康和护甲值的 HUD。
图 16.27:预期输出
注意
此活动的解决方案可在以下链接找到:packt.live/338jEBx
。
最终结果应该是两个角色可以看到彼此移动、跳跃和四处张望。每个客户端还会显示其角色的健康和护甲值。
通过完成此活动,您应该对服务器-客户端架构、变量复制、角色、2D 混合空间和“变换(修改)骨骼”节点的工作原理有一个很好的了解。
总结
在本章中,我们学习了一些关键的多人游戏概念,比如服务器-客户端架构的工作原理,服务器和客户端的责任,监听服务器设置比专用服务器快但不够轻量级,所有权和连接,角色和变量复制。
我们还学习了一些有用的动画技巧,比如如何使用 2D 混合空间,这允许您在两轴网格之间混合动画,以及变换(修改)骨骼节点,它具有在运行时修改骨骼的能力。最后,我们创建了一个第一人称多人游戏项目,其中您可以让角色行走、观看和跳跃,这将是我们在接下来的几章中将要开发的多人第一人称射击项目的基础。
在下一章中,我们将学习如何使用 RPCs,这允许客户端和服务器在彼此上执行函数。我们还将介绍如何在编辑器中使用枚举以及如何使用双向循环数组索引,这允许您在数组中向前和向后循环,并在超出限制时循环回来。
第十六章:17.远程过程调用
概述
在本章中,您将了解远程过程调用,这是虚幻引擎 4 网络框架的另一个重要多人游戏概念。您还将学习如何在虚幻引擎 4 中使用枚举,以及如何使用双向循环数组索引,这是一种帮助您在两个方向上迭代数组并在超出其索引限制时循环的方法。
在本章结束时,您将了解远程过程调用是如何使服务器和客户端在彼此上执行逻辑的。您还将能够在虚幻引擎 4 编辑器中公开枚举,并使用双向循环数组索引来循环遍历数组。
介绍
在上一章中,我们涵盖了一些关键的多人游戏概念,包括服务器-客户端架构,连接和所有权,角色和变量复制。我们还看到监听服务器比专用服务器更快设置,但不够轻量级。我们利用这些知识创建了一个基本的第一人称射击角色,可以行走,跳跃和四处张望。
在本章中,我们将介绍远程过程调用(RPC),这是另一个重要的多人游戏概念,允许服务器在客户端上执行函数,反之亦然。到目前为止,我们已经学习了变量复制作为服务器和客户端之间通信的一种形式,但这还不够,因为服务器可能需要在客户端上执行特定的逻辑,而不涉及更新变量的值。客户端还需要一种方式告诉服务器其意图,以便服务器可以验证操作并让其他客户端知道。这将确保多人游戏世界同步,我们将在本章中更详细地探讨这一点。我们还将介绍如何在虚幻引擎 4 中使用枚举,以及双向循环数组索引的使用,这有助于您在两个方向上迭代数组,并在超出其索引限制时循环。
在第一个主题中,我们将研究 RPC。
远程过程调用
我们在第十六章,多人游戏基础中涵盖了变量复制,虽然这是一个非常有用的功能,但在允许在远程机器上执行自定义代码(客户端到服务器或服务器到客户端)方面有一些限制,主要有两个原因:
-
第一个原因是变量复制严格来说是一种服务器到客户端的通信形式,因此客户端无法使用变量复制来告诉服务器通过改变变量的值来执行一些自定义逻辑。
-
第二个原因是变量复制,顾名思义,是由变量的值驱动的,因此即使变量复制允许客户端到服务器的通信,也需要您在客户端更改变量的值来触发服务器上的
RepNotify
功能来运行自定义逻辑,这并不是很实际。
为了解决这个问题,虚幻引擎 4 支持 RPC。RPC 的工作原理就像一个普通的函数,可以定义和调用,但是它不会在本地执行,而是在远程机器上执行。使用 RPC 的主要目标是有可能在远程机器上执行特定的逻辑,这与变量没有直接关联。要能够使用 RPC,请确保在打开复制的角色中定义它们。
有三种类型的 RPC,每种都有不同的目的:
-
服务器 RPC
-
多播 RPC
-
客户端 RPC
让我们详细了解这三种类型,并解释何时应该使用它们:
服务器 RPC
每当您希望服务器在定义了 RPC 的角色上运行函数时,您就会使用服务器 RPC。您希望这样做的两个主要原因是:
-
第一个原因是出于安全考虑,因为在制作多人游戏时,特别是竞争性游戏,您总是要假设客户端会尝试作弊。确保没有作弊的方法是强制客户端在服务器上执行对游戏玩法至关重要的函数。
-
第二个原因是为了同步性,因为关键的游戏逻辑只在服务器上执行,这意味着重要的变量只会在那里被改变,这将触发变量复制逻辑,在变量被改变时更新客户端。
一个例子是当客户端的角色尝试开火时。由于客户端可能会尝试作弊,您不能只在本地执行开火逻辑。正确的做法是让客户端调用一个告诉服务器验证Fire
动作的服务器 RPC,确保角色有足够的弹药并且装备了武器等等。如果一切都符合要求,那么它将扣除弹药变量,并最终执行一个多播 RPC(在下一个 RPC 类型中介绍),告诉所有客户端在该角色上播放开火动画。
声明
要声明服务器 RPC,您需要在UFUNCTION
宏上使用Server
修饰符。请看以下例子:
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter);
在上述代码中,Server
修饰符在UFUNCTION
宏上被用来声明该函数是一个服务器 RPC。您可以像普通函数一样在服务器 RPC 上有参数,但是有一些注意事项将在本主题后面解释,以及Reliable
和WithValidation
修饰符的目的。
执行
要执行服务器 RPC,您需要在定义它的角色实例上从客户端调用它。请看以下例子:
void ARPCTest::CallMyOwnServerRPC(int32 IntegerParameter)
{
ServerMyOwnRPC(IntegerParameter);
}
void ARPCTest::CallServerRPCOfAnotherActor(AAnotherActor* OtherActor)
{
if(OtherActor != nullptr)
{
OtherActor->ServerAnotherActorRPC();
}
}
第一个代码片段实现了CallMyOwnServerRPC
函数,该函数调用了其自己ARPCTest
类中定义的ServerMyOwnRPC
RPC 函数,带有一个整数参数。这将在该角色实例的服务器版本上执行ServerMyOwnRPC
函数的实现。
第二个代码片段实现了CallServerRPCOfAnotherActor
函数,该函数调用了ServerAnotherActorRPC
RPC 函数,在AAnotherActor
中定义,在OtherActor
实例上只要它是有效的。这将在OtherActor
实例的服务器版本上执行ServerAnotherActorRPC
函数的实现。
有效连接
从客户端调用服务器 RPC 时需要考虑的一个重要事项是定义它的角色需要有一个有效的连接。如果尝试在没有有效连接的角色上调用服务器 RPC,则什么也不会发生。您必须确保该角色要么是玩家控制器,要么被一个(如果适用)控制,或者其拥有的角色有一个有效的连接。
多播 RPC
当您希望服务器告诉所有客户端在定义了 RPC 的角色上运行函数时,您可以使用多播 RPC。
一个例子是当客户端的角色尝试开火时。在客户端调用服务器 RPC 请求允许开火,并且服务器处理了请求(所有验证都通过,弹药已经扣除,线性跟踪/抛射物已经处理),然后我们需要进行多播 RPC,以便该特定角色的所有实例都播放开火动画。这将确保无论哪个客户端正在观察角色,角色都会一直播放开火动画。
声明
要声明多播 RPC,您需要在UFUNCTION
宏上使用NetMulticast
修饰符。请看以下例子:
UFUNCTION(NetMulticast)
void MulticastRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter);
在前面的代码中,NetMulticast
修饰符用于UFUNCTION
宏,表示接下来的函数是一个多播 RPC。你可以像普通函数一样在多播 RPC 中使用参数,但与服务器 RPC 一样有相同的注意事项。
执行
要执行多播 RPC,你需要在定义它的角色实例上从服务器调用它。看一下下面的例子:
void ARPCTest::CallMyOwnMulticastRPC(int32 IntegerParameter)
{
MulticastMyOwnRPC(IntegerParameter);
}
void ARPCTest::CallMulticastRPCOfAnotherActor(AAnotherActor* OtherActor)
{
if(OtherActor != nullptr)
{
OtherActor->MulticastAnotherActorRPC();
}
}
第一个代码片段实现了CallMyOwnMulticastRPC
函数,它调用了其自己ARPCTest
类中定义的MulticastMyOwnRPC
RPC 函数,带有一个整数参数。这将在该角色实例的所有客户端版本上执行MulticastMyOwnRPC
函数的实现。
第二个代码片段实现了CallMulticastRPCOfAnotherActor
函数,它调用了AAnotherActor
中定义的MulticastAnotherActorRPC
RPC 函数,只要OtherActor
实例有效。这将在OtherActor
实例的所有客户端版本上执行MulticastAnotherActorRPC
函数的实现。
客户端 RPC
当你想要在定义 RPC 的角色的拥有客户端上运行函数时,你可以使用客户端 RPC。要设置拥有客户端,你需要在服务器上调用 SetOwner,并使用客户端的玩家控制器进行设置。
例如,当角色被抛射物击中并播放只有该客户端会听到的疼痛声音时。通过从服务器调用客户端 RPC,声音只会在拥有客户端上播放,因此其他客户端不会听到。
声明
要声明客户端 RPC,你需要在UFUNCTION
宏上使用Client
修饰符。看一下下面的例子:
UFUNCTION(Client)
void ClientRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter);
在前面的代码中,Client
修饰符用于UFUNCTION
宏,表示接下来的函数是一个客户端 RPC。你可以像普通函数一样在客户端 RPC 中使用参数,但与服务器 RPC 和多播 RPC 一样有相同的注意事项。
执行
要执行客户端 RPC,你需要在定义它的角色实例上从服务器调用它。看一下下面的例子:
void ARPCTest::CallMyOwnClientRPC(int32 IntegerParameter)
{
ClientMyOwnRPC(IntegerParameter);
}
void ARPCTest::CallClientRPCOfAnotherActor(AAnotherActor* OtherActor)
{
if(OtherActor != nullptr)
{
OtherActor->ClientAnotherActorRPC();
}
}
第一个代码片段实现了CallMyOwnClientRPC
函数,它调用了其自己ARPCTest
类中定义的ClientMyOwnRPC
RPC 函数,带有一个整数参数。这将在该角色实例的拥有客户端版本上执行ClientMyOwnRPC
函数的实现。
第二个代码片段实现了CallClientRPCOfAnotherActor
函数,它调用了AAnotherActor
中定义的ClientAnotherActorRPC
RPC 函数,只要OtherActor
实例有效。这将在OtherActor
实例的拥有客户端版本上执行ClientAnotherActorRPC
函数的实现。
在使用 RPC 时需要考虑的重要事项
RPC 非常有用,但在使用它们时有一些需要考虑的事项,比如:
实现
RPC 的实现与典型函数略有不同。你不应该像通常那样实现函数,而是只实现它的_Implementation
版本,即使你没有在头文件中声明它。看一下下面的例子:
服务器 RPC:
void ARPCTest::ServerRPCTest_Implementation(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在前面的代码片段中,我们实现了ServerRPCTest
函数的_Implementation
版本,它使用了三个参数。
多播 RPC:
void ARPCTest::MulticastRPCTest_Implementation(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在前面的代码片段中,我们实现了MulticastRPCTest
函数的_Implementation
版本,它使用了三个参数。
客户端 RPC:
void ARPCTest::ClientRPCTest_Implementation(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在前面的代码片段中,我们实现了ClientRPCTest
函数的_Implementation
版本,它使用了三个参数。
从前面的例子中可以看出,无论你正在实现的 RPC 的类型是什么,你都应该只实现函数的_Implementation
版本,而不是普通版本,就像下面的代码片段中所演示的那样:
void ARPCTest::ServerRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在上面的代码中,我们正在定义ServerRPCFunction
的正常实现。如果您像这样实现 RPC,将会收到一个错误,说它已经被实现了。原因是当您在头文件中声明 RPC 函数时,虚幻引擎 4 将自动在内部创建正常的实现,然后调用_Implementation
版本。如果您创建自己版本的正常实现,构建将失败,因为它会找到相同函数的两个实现。要解决此问题,只需确保只实现 RPC 的_Implementation
版本。
接下来,我们转到名称前缀。
名称前缀
在虚幻引擎 4 中,最好使用相应类型的前缀来命名 RPC。看看以下示例:
-
一个
ServerRPCFunction
。 -
一个
MulticastRPCFunction
。 -
一个
ClientRPCFunction
。
返回值
由于 RPC 的调用和执行通常在不同的机器上进行,因此您不能有返回值,因此它总是需要是 void。
重写
您可以重写 RPC 的实现,通过在子类中声明和实现_Implementation
函数而不使用UFUNCTION
宏来扩展或绕过父功能。以下是一个示例:
父类上的声明:
UFUNCTION(Server)
void ServerRPCTest(int32 IntegerParameter);
在前面的代码片段中,我们有ServerRPCTest
函数的父类声明,它使用一个整数参数。
子类上的重写声明:
virtual void ServerRPCTest_Implementation(int32 IntegerParameter) override;
在前面的代码片段中,我们在子类头文件中重写了ServerRPCTest_Implementation
函数的声明。函数的实现就像任何其他重写一样,如果您仍然想执行父功能,可以调用Super::ServerRPCTest_Implementation
。
支持的参数类型
在使用 RPC 时,您可以像任何其他函数一样添加参数。目前,支持最常见的类型,包括bool
、int32
、float
、FString
、FName
、TArray
、TSet
和TMap
。您需要更加注意的类型是指向任何UObject
类或子类的指针,特别是 actors。
如果您使用 actor 参数创建 RPC,则该 actor 也需要存在于远程机器上,否则它将为nullptr
。另一件重要的事情是要注意每个版本的 actor 的实例名称可能是不同的。这意味着如果您使用 actor 参数调用 RPC,那么在调用 RPC 时 actor 的实例名称可能与在远程机器上执行 RPC 时的实例名称不同。以下是一个示例,以帮助您理解这一点:
图 17.1:监听服务器和两个运行的客户端
在上面的示例中,您可以看到三个运行的客户端(其中一个是监听服务器),每个窗口都显示所有角色实例的名称。如果您查看客户端 1 窗口,其控制的角色实例称为ThirdPersonCharacter_C_0
,但在服务器窗口上,相应的角色称为ThirdPersonCharacter_C_1
。这意味着如果客户端 1 调用服务器 RPC 并将其ThirdPersonCharacter_C_0
作为参数传递,那么在服务器上执行 RPC 时,参数将是ThirdPersonCharacter_C_1
,这是该机器上等效角色的实例名称。
在目标机器上执行 RPC
您可以直接在目标机器上调用 RPC,并且它仍然会执行。换句话说,您可以在服务器上调用服务器 RPC 并执行,以及在客户端上调用 Multicast/Client RPC,但在这种情况下,它只会在调用 RPC 的客户端上执行逻辑。无论如何,在这些情况下,您应该始终直接调用_Implementation
版本,以便更快地执行逻辑。
这是因为_Implementation
版本只包含执行逻辑,没有创建和通过网络发送 RPC 请求的开销,而常规调用有。
看一下具有服务器权限的演员的以下示例:
void ARPCTest::CallServerRPC(int32 IntegerParameter)
{
if(HasAuthority())
{
ServerRPCFunction_Implementation(IntegerParameter);
}
else ServerRPCFunction(IntegerParameter);
}
在上面的示例中,您有CallServerRPC
函数以两种不同的方式调用ServerRPCFunction
。如果演员已经在服务器上,则调用ServerRPCFunction_Implementation
,这将跳过前面提到的开销。
如果演员不在服务器上,则通过使用ServerRPCFunction
执行常规调用,这将增加创建和通过网络发送 RPC 请求所需的开销。
验证
当您定义 RPC 时,您可以选择使用附加函数来检查 RPC 调用之前是否存在任何无效输入。这用于避免处理 RPC,如果输入无效,由于作弊或其他原因。
要使用验证,您需要在UFUNCTION
宏中添加WithValidation
修饰符。当您使用该修饰符时,您将被强制实现函数的_Validate
版本,该版本将返回一个布尔值,指示 RPC 是否可以执行。
看一下以下示例:
UFUNCTION(Server, WithValidation)
void ServerSetHealth(float NewHealth);
在上述代码中,我们声明了一个经过验证的服务器 RPC 称为ServerSetHealth
,它接受一个浮点参数作为Health
的新值。至于实现,如下所示:
bool ARPCTest::ServerSetHealth_Validate(float NewHealth)
{
return NewHealth <= MaxHealth;
}
void ARPCTest::ServerSetHealth_Implementation(float NewHealth)
{
Health = NewHealth;
}
在上述代码中,我们实现了_Validate
函数,该函数将检查新的健康值是否小于或等于健康的最大值。如果客户端尝试通过200
和MaxHealth
为100
调用ServerSetHealth
,则不会调用 RPC,这可以防止客户端使用超出一定范围的值更改健康。如果_Validate
函数返回true
,则会像往常一样调用_Implementation
函数,该函数将使用NewHealth
的值设置Health
。
可靠性
当您声明 RPC 时,您需要在UFUNCTION
宏中使用Reliable
或Unreliable
修饰符之一。以下是它们的快速概述:
可靠
:用于确保执行 RPC,通过重复请求直到远程机器确认其接收。这应仅用于非常重要的 RPC,例如执行关键的游戏逻辑。以下是如何使用它的示例:
UFUNCTION(Server, Reliable)
void ServerReliableRPCFunction(int32 IntegerParameter);
不可靠
:用于不关心 RPC 是否由于糟糕的网络条件而执行,例如播放声音或生成粒子效果。这应仅用于不太重要或非常频繁调用以更新值的 RPC,因为如果一个调用错过了,因为它经常更新,所以不重要。以下是如何使用它的示例:
UFUNCTION(Server, Unreliable)
void ServerUnreliableRPCFunction(int32 IntegerParameter);
注意
有关 RPC 的更多信息,请访问docs.unrealengine.com/en-US/Gameplay/Networking/Actors/RPCs/index.html
。
在下一个练习中,您将看到如何实现不同类型的 RPC。
练习 17.01:使用远程过程调用
在这个练习中,我们将创建一个使用Third Person
模板的 C++项目,并按以下方式扩展它:
-
添加一个火灾计时器变量,它将防止客户端在火灾动画期间不断按下火灾按钮。
-
添加一个新的弹药整数变量,默认值为
5
,并复制给所有客户端。 -
添加一个“火灾动画剪辑”,当服务器告诉客户端射击有效时播放。
-
添加一个“无弹药声音”,当服务器告诉客户端他们没有足够的弹药时会播放。
-
每当玩家按下左鼠标按钮时,客户端将执行可靠且经过验证的服务器 RPC,检查角色是否有足够的弹药。如果有,它将从 Ammo 变量中减去 1,并调用一个不可靠的多播 RPC,在每个客户端播放开火动画。如果没有弹药,那么它将执行一个不可靠的客户端 RPC,只有拥有客户端才能听到
No Ammo Sound
。
以下步骤将帮助您完成练习:
-
使用
C++
创建一个名为RPC
的新Third Person
模板项目,并将其保存到您选择的位置。 -
项目创建后,应该打开编辑器以及 Visual Studio 解决方案。
-
关闭编辑器,返回 Visual Studio。
-
打开
RPCCharacter.h
文件,并包括UnrealNetwork.h
头文件,其中包含我们将要使用的DOREPLIFETIME_CONDITION
宏的定义:
#include "Net/UnrealNetwork.h"
- 声明受保护的计时器变量,以防止客户端滥用
Fire
动作:
FTimerHandle FireTimer;
- 声明受保护的可复制的弹药变量,初始为
5
发子弹:
UPROPERTY(Replicated)
int32 Ammo = 5;
- 接下来,声明一个受保护的动画蒙太奇变量,当角色开火时将会播放:
UPROPERTY(EditDefaultsOnly, Category = "RPC Character")
UAnimMontage* FireAnimMontage;
- 声明一个受保护的声音变量,当角色没有弹药时将会播放:
UPROPERTY(EditDefaultsOnly, Category = "RPC Character")
USoundBase* NoAmmoSound;
- 重写
Tick
函数:
virtual void Tick(float DeltaSeconds) override;
- 声明一个输入函数,用于处理左鼠标按钮的按压:
void OnPressedFire();
- 声明可靠且经过验证的服务器 RPC 以进行射击:
UFUNCTION(Server, Reliable, WithValidation, Category = "RPC Character")
void ServerFire();
- 声明一个不可靠的多播 RPC,将在所有客户端上播放开火动画:
UFUNCTION(NetMulticast, Unreliable, Category = "RPC Character")
void MulticastFire();
- 声明一个不可靠的客户端 RPC,仅在拥有客户端中播放声音:
UFUNCTION(Client, Unreliable, Category = "RPC Character")
void ClientPlaySound2D(USoundBase* Sound);
- 现在,打开
RPCCharacter.cpp
文件,并包括DrawDebugHelpers.h
,GameplayStatics.h
,TimerManager.h
和World.h
:
#include "DrawDebugHelpers.h"
#include "Kismet/GameplayStatics.h"
#include "TimerManager.h"
#include "Engine/World.h"
- 在构造函数的末尾,启用
Tick
函数:
PrimaryActorTick.bCanEverTick = true;
- 实现
GetLifetimeReplicatedProps
函数,以便Ammo
变量能够复制到所有客户端:
void ARPCCharacter::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ARPCCharacter, Ammo);
}
- 接下来,实现
Tick
函数,显示Ammo
变量的值:
void ARPCCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
const FString AmmoString = FString::Printf(TEXT("Ammo = %d"), Ammo);
DrawDebugString(GetWorld(), GetActorLocation(), AmmoString, nullptr, FColor::White, 0.0f, true);
}
- 在
SetupPlayerInputController
函数的末尾,将Fire
动作绑定到OnPressedFire
函数:
PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &ARPCCharacter::OnPressedFire);
- 实现处理左鼠标按钮按压的函数,该函数将调用 fire Server RPC:
void ARPCCharacter::OnPressedFire()
{
ServerFire();
}
- 实现 fire 服务器 RPC 验证函数:
bool ARPCCharacter::ServerFire_Validate()
{
return true;
}
- 实现 fire 服务器 RPC 实现函数:
void ARPCCharacter::ServerFire_Implementation()
{
}
- 现在,添加逻辑以在上一次射击后仍处于活动状态时中止函数:
if (GetWorldTimerManager().IsTimerActive(FireTimer))
{
return;
}
- 检查角色是否有弹药。如果没有,那么只在控制角色的客户端中播放
NoAmmoSound
并中止函数:
if (Ammo == 0)
{
ClientPlaySound2D(NoAmmoSound);
return;
}
- 扣除弹药并安排
FireTimer
变量,以防止在播放开火动画时过度使用此函数:
Ammo--;
GetWorldTimerManager().SetTimer(FireTimer, 1.5f, false);
- 调用 fire 多播 RPC,使所有客户端播放开火动画:
MulticastFire();
- 实现 fire 多播 RPC,将播放开火动画蒙太奇:
void ARPCCharacter::MulticastFire_Implementation()
{
if (FireAnimMontage != nullptr)
{
PlayAnimMontage(FireAnimMontage);
}
}
- 实现在客户端播放 2D 声音的客户端 RPC:
void ARPCCharacter::ClientPlaySound2D_Implementation(USoundBase* Sound)
{
UGameplayStatics::PlaySound2D(GetWorld(), Sound);
}
最后,您可以在编辑器中启动项目。
-
编译代码并等待编辑器完全加载。
-
转到
Project Settings
,转到Engine
,然后Input
,并添加Fire
动作绑定:
图 17.2:添加新的 Fire 动作绑定
-
关闭
Project Settings
。 -
在
Content Browser
中,转到Content\Mannequin\Animations
文件夹。 -
点击
导入
按钮,转到Exercise17.01\Assets
文件夹,并导入ThirdPersonFire.fbx
文件,然后确保它使用UE4_Mannequin_Skeleton
骨架。
注意
前面提到的Assets
文件夹可以在我们的 GitHub 存储库中找到packt.live/36pEvAT
。
-
打开新的动画,在详细信息面板中找到
启用根动作
选项,并将其设置为 true。这将在播放动画时防止角色移动。 -
保存并关闭
ThirdPersonFire
。 -
右键单击“内容浏览器”上的
ThirdPersonFire
,然后选择“创建->AnimMontage”。 -
将
AnimMontage
重命名为ThirdPersonFire_Montage
。 -
Animations
文件夹应该是这样的:
图 17.3:模特的动画文件夹
-
打开
ThirdPerson_AnimBP
,然后打开AnimGraph
。 -
右键单击图表的空白部分,添加一个
DefaultSlot
节点(以便播放动画镜头),并将其连接在“状态机”和“输出姿势”之间。您应该会得到以下输出:
图 17.4:角色的 AnimGraph
-
保存并关闭
ThirdPerson_AnimBP
。 -
在“内容浏览器”中,转到“内容”文件夹,创建一个名为“音频”的新文件夹,并打开它。
-
单击“导入”按钮,转到
Exercise17.01\Assets
文件夹,导入noammo.wav
,然后保存。 -
转到
Content\ThirdPersonCPP\Blueprints
,并打开ThirdPersonCharacter
蓝图。 -
在类默认值中,将“无弹药声音”设置为使用
noammo
,并将Fire Anim Montage
设置为使用ThirdPersonFire_Montage
。 -
保存并关闭
ThirdPersonCharacter
。 -
转到多人游戏选项,并将客户端数量设置为
2
。 -
将窗口大小设置为 800x600,并使用 PIE 播放。
您应该会得到以下输出:
图 17.5:练习的最终结果
通过完成这个练习,您将能够在每个客户端上进行游戏,每次按下左鼠标按钮时,客户端的角色将播放Fire Anim
镜头,所有客户端都将能够看到,并且其弹药将减少 1。如果您在弹药为0
时尝试开火,该客户端将听到“无弹药声音”,并且不会执行开火动画,因为服务器没有调用多播 RPC。如果您尝试连续按下开火按钮,您会注意到只有在动画完成后才会触发新的开火。
在下一节中,我们将讨论枚举,它在游戏开发中用于许多不同的事情,比如管理角色的状态(是否空闲、行走、攻击或死亡等),或者为装备槽数组中的每个索引分配一个人类友好的名称(头部、主要武器、次要武器、躯干、手部、腰带、裤子等)。
枚举
枚举是一种用户定义的数据类型,它包含一系列整数常量,其中每个项目都由您分配了一个人类友好的名称,这使得代码更容易阅读。例如,我们可以使用整数变量来表示角色可能处于的不同状态 - 0
表示空闲,1
表示行走,依此类推。这种方法的问题在于,当您开始编写诸如if(State == 0)
之类的代码时,很难记住0
的含义,特别是如果您有很多状态,没有使用一些文档或注释来帮助您记住。为了解决这个问题,您应该使用枚举,其中您可以编写诸如if(State == EState::Idle)
之类的代码,这样更加明确和易于理解。
在 C++中,您有两种类型的枚举,旧的原始枚举和引入于 C++11 的新枚举类。如果您想在编辑器中使用 C++枚举,您的第一反应可能是以典型的方式来声明一个使用枚举作为参数的变量或函数,分别使用UPROPERTY
或UFUNCTION
。
问题是,如果您尝试这样做,将会出现编译错误。看一下以下示例:
enum class ETestEnum : uint8
{
EnumValue1,
EnumValue2,
EnumValue3
};
在上面的代码片段中,我们声明了一个名为ETestEnum
的枚举类,它有三个可能的值 - EnumValue1
,EnumValue2
和EnumValue3
。
之后,尝试以下示例之一:
UPROPERTY()
ETestEnum TestEnum;
UFUNCTION()
void SetTestEnum(ETestEnum NewTestEnum) { TestEnum = NewTestEnum; }
在前面的代码片段中,我们声明了一个在类中使用ETestEnum
枚举的UPROPERTY
变量和UFUNCTION
函数。如果您尝试编译,您将收到以下编译错误:
error : Unrecognized type 'ETestEnum' - type must be a UCLASS, USTRUCT or UENUM
注意
在虚幻引擎 4 中,最好的做法是使用字母E
作为枚举名称的前缀。例如EWeaponType
和EAmmoType
。
这个错误发生的原因是,当您尝试使用UPROPERTY
或UFUNCTION
宏将类、结构或枚举暴露给编辑器时,您需要分别使用UCLASS
、USTRUCT
和UENUM
宏将其添加到虚幻引擎 4 反射系统中。
注意
您可以通过访问以下链接了解更多关于虚幻引擎 4 反射系统的信息:www.unrealengine.com/en-US/blog/unreal-property-system-reflection
。
有了这些知识,修复先前的错误就很简单了,只需执行以下操作:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1,
EnumValue2,
EnumValue3
};
在下一节中,我们将看看TEnumAsByte
类型。
TEnumAsByte
如果您想向使用原始枚举的引擎公开变量,那么您需要使用TEnumAsByte
类型。如果您使用原始枚举(而不是枚举类)声明UPROPERTY
变量,您将收到编译错误。
看下面的例子:
UENUM()
enum ETestRawEnum
{
EnumValue1,
EnumValue2,
EnumValue3
};
如果您使用ETestRawEnum
声明UPROPERTY
变量,如下所示:
UPROPERTY()
ETestRawEnum TestRawEnum;
您将收到以下编译错误:
error : You cannot use the raw enum name as a type for member variables, instead use TEnumAsByte or a C++11 enum class with an explicit underlying type.
要解决此错误,您需要将变量的枚举类型(在本例中为ETestRawEnum
)用TEnumAsByte<>
括起来,如下所示:
UPROPERTY()
TEnumAsByte<ETestRawEnum> TestRawEnum;
UMETA
当您使用UENUM
宏将枚举添加到虚幻引擎反射系统时,这将允许您在枚举的每个值上使用UMETA
宏。UMETA
宏,就像其他宏(如UPROPERTY
或UFUNCTION
)一样,可以使用说明符,这些说明符将告知虚幻引擎 4 如何处理该值。以下是最常用的UMETA
说明符列表:
DisplayName
此说明符允许您在编辑器中显示枚举值时定义一个更易读的新名称。
看下面的例子:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1 UMETA(DisplayName = "My First Option",
EnumValue2 UMETA(DisplayName = "My Second Option",
EnumValue3 UMETA(DisplayName = "My Third Option"
};
让我们声明以下变量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Test")
ETestEnum TestEnum;
然后,当您打开编辑器并查看TestEnum
变量时,您将看到一个下拉菜单,其中EnumValue1
,EnumValue2
和EnumValue3
已分别替换为My First Option
,My Second Option
和My Third Option
。
Hidden
此说明符允许您从下拉菜单中隐藏特定的枚举值。当只想在 C++中使用枚举值而不想在编辑器中使用时,通常会使用此功能。
看下面的例子:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1 UMETA(DisplayName = "My First Option"),
EnumValue2 UMETA(Hidden),
EnumValue3 UMETA(DisplayName = "My Third Option")
};
让我们声明以下变量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Test")
ETestEnum TestEnum;
然后,当您打开编辑器并查看TestEnum
变量时,您将看到一个下拉菜单。您应该注意到My Second Option
不会出现在下拉菜单中,因此无法选择。
注意
有关所有 UMETA 说明符的更多信息,请访问docs.unrealengine.com/en-US/Programming/UnrealArchitecture/Reference/Metadata/#enummetadataspecifiers
。
在下一节中,我们将看看UENUM
宏的BlueprintType
说明符。
BlueprintType
此UENUM
说明符将枚举暴露给蓝图。这意味着在创建新变量或函数的输入/输出时,下拉菜单中将有该枚举的条目,就像以下示例中一样:
图 17.6:设置变量以使用 ETestEnum 变量类型。
它还将显示您可以在编辑器中对枚举调用的其他函数,就像这个例子一样:
图 17.7:使用 BlueprintType 时可用的其他函数列表
MAX
在使用枚举时,通常希望知道它有多少个值。在虚幻引擎 4 中,标准做法是将MAX
添加为最后一个值,这将自动隐藏在编辑器中。
看一下以下示例:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1,
EnumValue2,
EnumValue3,
MAX
};
如果要知道 C++中ETestEnum
有多少个值,只需执行以下操作:
const int32 MaxCount = (int32)ETestEnum::MAX;
这是因为 C++中的枚举内部存储为数字,第一个值为0
,第二个值为1
,依此类推。这意味着只要MAX
是最后一个值,它将始终具有枚举中的总值。需要考虑的一个重要事项是,为了使MAX
给出正确的值,不能更改枚举的内部编号顺序,如下所示:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1 = 4,
EnumValue2 = 78,
EnumValue3 = 100,
MAX
};
在这种情况下,MAX
将是101
,因为它将使用紧接前一个值的数字,即EnumValue3 = 100
。
使用MAX
只能在 C++中使用,而不能在编辑器中使用,因为MAX
值在蓝图中是隐藏的,如前所述。要在蓝图中获取枚举的条目数,应在UENUM
宏中使用BlueprintType
修饰符,以便在上下文菜单中公开一些有用的函数。之后,您只需要在上下文菜单中输入枚举的名称。如果选择Get number of entries in ETestEnum
选项,将获得一个返回枚举条目数的函数。
在下一个练习中,您将在虚幻引擎 4 编辑器中使用 C++枚举。
练习 17.02:在虚幻引擎 4 编辑器中使用 C++枚举
在这个练习中,我们将创建一个使用Third Person
模板的新 C++项目,并添加以下内容:
-
一个名为
EWeaponType
的枚举,包含3
种武器-手枪、霰丨弹丨枪和火箭发射器。 -
一个名为
EAmmoType
的枚举,包含3
种弹药类型-子弹、弹壳和火箭。 -
一个名为
Weapon
的变量,使用EWeaponType
来告诉当前武器的类型。 -
一个名为
Ammo
的整数数组变量,保存每种类型的弹药数量,初始化值为10
。 -
当玩家按下1、2和3键时,它将分别将
Weapon
设置为Pistol
、Shotgun
和Rocket Launcher
。 -
当玩家按下左鼠标按钮时,这将消耗当前武器的弹药。
-
每次
Tick
函数调用时,角色将显示当前武器类型和相应的弹药类型和数量。
以下步骤将帮助您完成练习:
- 使用 C++创建一个名为
Enumerations
的新Third Person
模板项目,并将其保存到您选择的位置。
项目创建后,它应该同时打开编辑器和 Visual Studio 解决方案。
-
关闭编辑器,返回 Visual Studio。
-
打开
Enumerations.h
文件。 -
创建一个名为
ENUM_TO_INT32
的宏,它将枚举转换为int32
数据类型:
#define ENUM_TO_INT32(Value) (int32)Value
- 创建一个名为
ENUM_TO_FSTRING
的宏,它将获取enum
数据类型的值的显示名称,并将其转换为FString
数据类型:
#define ENUM_TO_FSTRING(Enum, Value) FindObject<UEnum>(ANY_PACKAGE, TEXT(Enum), true)- >GetDisplayNameTextByIndex((int32)Value).ToString()
- 声明枚举
EWeaponType
和EAmmoType
:
UENUM(BlueprintType)
enum class EWeaponType : uint8
{
Pistol UMETA(Display Name = «Glock 19»),
Shotgun UMETA(Display Name = «Winchester M1897»),
RocketLauncher UMETA(Display Name = «RPG»),
MAX
};
UENUM(BlueprintType)
enum class EAmmoType : uint8
{
Bullets UMETA(DisplayName = «9mm Bullets»),
Shells UMETA(Display Name = «12 Gauge Shotgun Shells»),
Rockets UMETA(Display Name = «RPG Rockets»),
MAX
};
- 打开
EnumerationsCharacter.h
文件,包括Enumerations.h
头文件:
#include "Enumerations.h"
- 声明受保护的
Weapon
变量,保存所选武器的武器类型:
UPROPERTY(BlueprintReadOnly, Category = "Enumerations Character")
EWeaponType Weapon;
- 声明受保护的
Ammo
数组,保存每种类型的弹药数量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Enumerations Character")
TArray<int32> Ammo;
- 声明
Begin Play
和Tick
函数的受保护覆盖:
virtual void BeginPlay() override;
virtual void Tick(float DeltaSeconds) override;
- 声明受保护的输入函数:
void OnPressedPistol();
void OnPressedShotgun();
void OnPressedRocketLauncher();
void OnPressedFire();
- 打开
EnumerationsCharacter.cpp
文件,包括DrawDebugHelpers.h
头文件:
#include "DrawDebugHelpers.h"
- 在
SetupPlayerInputController
函数的末尾绑定新的动作绑定,如下面的代码片段所示:
PlayerInputComponent->BindAction("Pistol", IE_Pressed, this, &AEnumerationsCharacter::OnPressedPistol);
PlayerInputComponent->BindAction("Shotgun", IE_Pressed, this, &AEnumerationsCharacter::OnPressedShotgun);
PlayerInputComponent->BindAction("Rocket Launcher", IE_Pressed, this, &AEnumerationsCharacter::OnPressedRocketLauncher);
PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &AEnumerationsCharacter::OnPressedFire);
- 接下来,实现
BeginPlay
的重写,执行父逻辑,但也初始化Ammo
数组的大小,大小为EAmmoType
枚举中的条目数。数组中的每个位置也将初始化为值10
:
void AEnumerationsCharacter::BeginPlay()
{
Super::BeginPlay();
const int32 AmmoCount = ENUM_TO_INT32(EAmmoType::MAX);
Ammo.Init(10, AmmoCount);
}
- 实现
Tick
的重写:
void AEnumerationsCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
}
- 将
Weapon
变量转换为int32
,将Weapon
变量转换为FString
:
const int32 WeaponIndex = ENUM_TO_INT32(Weapon);
const FString WeaponString = ENUM_TO_FSTRING("EWeaponType", Weapon);
- 将弹药类型转换为
FString
并获取当前武器的弹药数量:
const FString AmmoTypeString = ENUM_TO_FSTRING("EAmmoType", Weapon);
const int32 AmmoCount = Ammo[WeaponIndex];
我们使用Weapon
来获取弹药类型字符串,因为EAmmoType
中的条目与等效的EWeaponType
的弹药类型匹配。换句话说,Pistol = 0
使用Bullets = 0
,Shotgun = 1
使用Shells = 1
,RocketLauncher = 2
使用Rockets = 2
,因此这是我们可以利用的一对一映射。
- 在角色位置显示当前武器的名称及其对应的弹药类型和弹药数量,如下面的代码片段所示:
const FString String = FString::Printf(TEXT("Weapon = %s\nAmmo Type = %s\nAmmo Count = %d"), *WeaponString, *AmmoTypeString, AmmoCount);
DrawDebugString(GetWorld(), GetActorLocation(), String, nullptr, FColor::White, 0.0f, true);
- 实现装备输入函数,将
Weapon
变量设置为相应的值:
void AEnumerationsCharacter::OnPressedPistol()
{
Weapon = EWeaponType::Pistol;
}
void AEnumerationsCharacter::OnPressedShotgun()
{
Weapon = EWeaponType::Shotgun;
}
void AEnumerationsCharacter::OnPressedRocketLauncher()
{
Weapon = EWeaponType::RocketLauncher;
}
- 实现火力输入函数,该函数将使用武器索引获取相应的弹药类型计数,并减去
1
,只要结果值大于或等于 0:
void AEnumerationsCharacter::OnPressedFire()
{
const int32 WeaponIndex = ENUM_TO_INT32(Weapon);
const int32 NewRawAmmoCount = Ammo[WeaponIndex] - 1;
const int32 NewAmmoCount = FMath::Max(NewRawAmmoCount, 0);
Ammo[WeaponIndex] = NewAmmoCount;
}
-
编译代码并运行编辑器。
-
转到
项目设置
,然后转到引擎
,然后转到输入
,并添加新的动作绑定
:
图 17.8:添加手枪、霰丨弹丨枪、火箭发射器和火焰绑定
-
关闭
项目设置
。 -
在单人模式下(一个客户端和禁用的专用服务器)中进行
New Editor Window (PIE)
游戏:
图 17.9:练习的最终结果
通过完成此练习,您将能够使用1、2和3键选择当前武器。您会注意到每次都会显示当前武器的类型及其对应的弹药类型和弹药数量。如果按下火键,这将减少当前武器的弹药数量,但不会低于0
。
在下一节中,您将学习双向循环数组索引。
双向循环数组索引
有时,当您使用数组存储信息时,您可能希望以双向循环的方式迭代它。一个例子是射击游戏中的上一个/下一个武器逻辑,您在其中有一个包含武器的数组,并且希望能够以特定方向循环遍历它们,当您到达第一个或最后一个索引时,您希望循环回到最后一个和第一个索引。执行此示例的典型方法将是以下内容:
AWeapon * APlayer::GetPreviousWeapon()
{
if(WeaponIndex - 1 < 0)
{
WeaponIndex = Weapons.Num() - 1;
}
else WeaponIndex--;
return Weapons[WeaponIndex];
}
AWeapon * APlayer::GetNextWeapon()
{
if(WeaponIndex + 1 > Weapons.Num() - 1)
{
WeaponIndex = 0;
}
else WeaponIndex++;
return Weapons[WeaponIndex];
}
在上述代码中,我们调整武器索引以在新武器索引超出武器数组限制时循环回来,这可能发生在两种情况下。第一种情况是玩家装备了库存中的最后一把武器并要求下一把武器。在这种情况下,它应该返回到第一把武器。
第二种情况是玩家装备了库存中的第一把武器并要求上一把武器。在这种情况下,它应该转到最后一把武器。
虽然示例代码有效,但仍然需要大量代码来解决这样一个微不足道的问题。为了改进这段代码,有一个数学公式将帮助您在一个函数中自动考虑这两种情况。它被称为模数(在 C++中表示为%
运算符),它给出两个数字之间的余数。
那么我们如何使用模数来进行双向循环数组索引?让我们使用模数重写上一个例子:
AWeapon * APlayer::GetNewWeapon(int32 Direction)
{
const int32 WeaponCount = Weapons.Num();
const int32 NewIndex = WeaponIndex + Direction;
const in32 ClampedNewIndex = NewIndex % WeaponCount;
WeaponIndex = (ClampedNewIndex + WeaponCount) % WeaponCount;
return Weapons[WeaponIndex];
}
这是新版本,您可以立即看出它有点难以理解,但它更加功能齐全和紧凑。如果您不使用变量来存储每个操作的中间值,您可能可以将整个函数编写为一两行代码。
让我们分解前面的代码片段:
const int WeaponCount = Weapons.Num()
:我们需要知道数组的大小,以确定它应该循环回0
的索引。换句话说,如果WeaponCount = 4
,那么数组具有索引0
、1
、2
和3
,这告诉我们索引 4 是应该回到0
的截止索引。
const int32 NewIndex = WeaponIndex + Direction
:这是未经夹紧到数组限制的新原始索引。Direction
变量用于指示我们要导航数组的偏移量,如果我们想要上一个索引,则为-1
,如果我们想要下一个索引,则为1
。
const int32 ClampedNewIndex = NewIndex % WeaponCount
:这将确保由于模数属性,NewIndex
在0
到WeaponCount - 1
的区间内。
如果Direction
总是1
,那么ClampedNewIndex
就足够了。问题是,当WeaponIndex
为0
且Direction
为-1
时,模运算不太适用于负值,这会导致NewIndex
为-1
。为了解决这个限制,我们需要进行一些额外的计算。
WeaponIndex = (ClampedNewIndex + WeaponCount) % WeaponCount
:这将向ClampedNewIndex
添加WeaponCount
以使其为正,并再次应用模数以获得正确的夹紧索引,从而解决了问题。
return Weapons[WeaponIndex]
:这将返回计算出的WeaponIndex
索引位置的武器。
让我们看一个实际的例子来帮助你理解所有这些是如何工作的:
武器 =
-
[0] 刀
-
[1] 手枪
-
[2] 霰丨弹丨枪
-
[3] 火箭发射器
Weapons.Num() = 4
。
假设WeaponIndex = 3
,Direction = 1
。
然后:
NewIndex
= WeaponIndex + Direction = 3 + 1 = 4
ClampedIndex
= NewIndex % WeaponCount = 4 % 4 = 0
WeaponIndex
= (ClampedIndex + WeaponCount) % WeaponCount = (0 + 4) % 4 = 0
在这个例子中,武器索引的起始值是3
(火箭发射器),我们想要下一个武器(因为Direction
是1
)。进行计算,WeaponIndex
现在将是0
(刀)。这是期望的行为,因为我们有 4 个武器,所以我们回到了这种情况下,由于Direction
是1
,我们可以只使用ClampedIndex
而不进行额外的计算。
让我们再次使用不同的值进行调试。
假设WeaponIndex = 0
,Direction = -1
:
NewIndex
= WeaponIndex + Direction = 0 + -1 = -1
ClampedIndex
= *NewIndex % WeaponCount *= -1 % 4 = -1
WeaponIndex
= (ClampedIndex + WeaponCount) % WeaponCount = (-1 + 4) % 4 = 3
在这个例子中,武器索引的起始值为 0(刀),我们想要上一个武器(因为Direction
是-1
)。进行计算,WeaponIndex
现在将是 3(火箭发射器)。这是期望的行为,因为我们有 4 个武器,所以我们回到了 3。在这种特定情况下,NewIndex
是负数,所以我们不能只使用ClampedIndex
;我们需要进行额外的计算来获得正确的值。
Exercise 17.03: 使用双向循环数组索引在枚举之间循环
在这个练习中,我们将使用Exercise17.02,在虚幻引擎 4 编辑器中使用 C++枚举中的项目,并为武器循环添加两个新的动作映射。鼠标滚轮向上将转到上一个武器类型,鼠标滚轮向下将转到下一个武器类型。
以下步骤将帮助你完成练习:
- 首先,从Exercise 17.02,在虚幻引擎 4 编辑器中使用 C++枚举中打开 Visual Studio 项目。
接下来,你将更新Enumerations.h
并添加一个宏,该宏将以非常方便的方式处理双向数组循环,如下所示。
- 打开
Enumerations.h
并添加GET_CIRCULAR_ARRAY_INDEX
宏,该宏将应用我们已经讨论过的模数公式:
#define GET_CIRCULAR_ARRAY_INDEX(Index, Count) (Index % Count + Count) % Count
- 打开
EnumerationsCharacter.h
并声明武器循环的新输入函数:
void OnPressedPreviousWeapon();
void OnPressedNextWeapon();
- 声明
CycleWeapons
函数,如下面的代码片段所示:
void CycleWeapons(int32 Direction);
- 打开
EnumerationsCharacter.cpp
并在SetupPlayerInputController
函数中绑定新的动作绑定:
PlayerInputComponent->BindAction("Previous Weapon", IE_Pressed, this, &AEnumerationsCharacter::OnPressedPreviousWeapon);
PlayerInputComponent->BindAction("Next Weapon", IE_Pressed, this, &AEnumerationsCharacter::OnPressedNextWeapon);
- 现在,实现新的输入函数,如下面的代码片段所示:
void AEnumerationsCharacter::OnPressedPreviousWeapon()
{
CycleWeapons(-1);
}
void AEnumerationsCharacter::OnPressedNextWeapon()
{
CycleWeapons(1);
}
在上面的代码片段中,我们定义了处理“上一个武器”和“下一个武器”动作映射的函数。每个函数都使用CycleWeapons
函数,对于上一个武器使用方向为-1
,对于下一个武器使用方向为1
。
- 实现
CycleWeapons
函数,根据当前武器索引和Direction
参数进行双向循环。
void AEnumerationsCharacter::CycleWeapons(int32 Direction)
{
const int32 WeaponIndex = ENUM_TO_INT32(Weapon);
const int32 AmmoCount = Ammo.Num();
const int32 NextRawWeaponIndex = WeaponIndex + Direction;
const int32 NextWeaponIndex = GET_CIRCULAR_ARRAY_INDEX(NextRawWeaponIndex , AmmoCount);
Weapon = (EWeaponType)NextWeaponIndex;
}
在上面的代码片段中,我们实现了CycleWeapons
函数,该函数使用模运算符根据提供的方向计算下一个有效武器索引。
-
编译代码并运行编辑器。
-
转到
Project Settings
,然后转到Engine
,然后转到Input
,并添加新的动作绑定
:
图 17.10:添加上一个武器和下一个武器绑定
-
关闭
Project Settings
。 -
现在,在单人模式下(一个客户端和禁用专用服务器)中播放
New Editor Window (PIE)
:
图 17.11:练习的最终结果
通过完成这个练习,您将能够使用鼠标滚轮在武器之间进行循环。如果选择火箭发射器并使用鼠标滚轮向下滚动到下一个武器,它将返回到手枪。如果使用鼠标滚轮向下滚动到上一个武器并选择手枪,它将返回到火箭发射器。
在下一个活动中,您将向我们在第十六章“多人游戏基础”中开始的多人 FPS 项目中添加武器和弹药的概念。
活动 17.01:向多人 FPS 游戏添加武器和弹药
在这个活动中,您将向我们在上一章活动中开始的多人 FPS 项目中添加武器和弹药的概念。您需要使用本章中介绍的不同类型的 RPC 来完成这个活动。
以下步骤将帮助您完成这个活动:
-
从Activity 16.01“为多人 FPS 项目创建角色”中打开
MultiplayerFPS
项目。 -
创建一个名为
Upper Body
的新AnimMontage
插槽。 -
从
Activity17.01\Assets
文件夹导入动画(Pistol_Fire.fbx
,MachineGun_Fire.fbx
和Railgun_Fire.fbx
)到Content\Player\Animations
。
注意
Assets 文件夹Activity17.01\Assets
可以在我们的 GitHub 存储库中找到,网址为packt.live/2It4Plb
。
- 为
Pistol_Fire
,MachineGun_Fire
和Railgun_Fire
创建一个动画蒙太奇,并确保它们具有以下配置:
Blend In
时间为0.01
,Blend Out
时间为0.1
,并确保它使用Upper Body
插槽。
Blend In
时间为0.01
,Blend Out
时间为0.1
,并确保它使用Upper Body
插槽。
Upper Body
插槽。
-
从
Activity17.01\Assets
文件夹导入SK_Weapon.fbx
,NoAmmo.wav
,WeaponChange.wav
和Hit.wav
到Content\Weapons
。 -
从
Activity17.01\Assets
导入Pistol_Fire_Sound.wav
到Content\Weapons\Pistol
,并在Pistol_Fire
动画的AnimNotify
Play Sound 中使用它。 -
创建一个名为
M_Pistol
的简单绿色材质,并将其放置在Content\Weapons\Pistol
上。 -
从
Activity17.01\Assets
导入MachineGun_Fire_Sound.wav
到Content\Weapons\MachineGun
,并在MachineGun_Fire
动画中的AnimNotify
Play Sound 中使用它。 -
创建一个名为
M_MachineGun
的简单红色材质,并将其放置在Content\Weapons\MachineGun
上。 -
从
Activity17.01\Assets
导入Railgun_Fire_Sound.wav
到Content\Weapons\Railgun
,并在Railgun_Fire
动画中使用AnimNotify
播放声音。 -
创建一个名为
M_Railgun
的简单白色材质,并将其放置在Content\Weapons\Railgun
上。 -
编辑
SK_Mannequin
骨骼网格,并从hand_r
创建一个名为GripPoint
的插槽,相对位置为(X=-10.403845,Y=6.0,Z=-3.124871),相对旋转为(X=0.0,Y=0.0,Z=90.0)。 -
在
Project Settings
中添加以下输入映射,使用第四章,玩家输入中获得的知识:
-
射击(动作映射):鼠标左键
-
上一个武器(动作映射):鼠标滚轮向上
-
下一个武器(动作映射):鼠标滚轮向下
-
手枪(动作映射):1
-
机枪(动作映射):2
-
Railgun(动作映射):3
-
在
MultiplayerFPS.h
中,创建ENUM_TO_INT32(Enum)
宏,将枚举转换为int32
,并创建GET_CIRCULAR_ARRAY_INDEX(Index, Count)
,该宏使用双向循环数组索引将索引转换为在0
和-1
计数之间的索引。 -
创建一个名为
EnumTypes.h
的头文件,其中包含以下枚举:
EWeaponType:手枪,机枪,电磁炮,最大
EWeaponFireMode:单发,自动
EAmmoType:子弹,弹丸,最大
-
创建一个 C++类
Weapon
,它继承自Actor
类,具有名为Mesh
的骨骼网格组件作为根组件。在变量方面,它存储名称、武器类型、弹药类型、射击模式、命中扫描范围、命中扫描伤害、射速、开火时使用的动画蒙太奇以及无弹药时播放的声音。在功能方面,它需要能够开始射击(也需要停止射击,因为是自动射击模式),检查玩家是否能够开火。如果可以,它会在所有客户端上播放射击动画,并使用提供的长度在摄像机位置和方向进行一条线的射线检测,以对其命中的角色造成伤害。如果没有弹药,它将仅在拥有客户端上播放声音。 -
编辑
FPSCharacter
以支持Fire
、Previous/Next Weapon
、Pistol
、Machine Gun
和Railgun
的新映射。在变量方面,它需要存储每种类型的弹药数量,当前装备的武器,所有武器类和生成的实例,命中其他玩家时播放的声音,以及更换武器时的声音。在功能方面,它需要能够装备/循环/添加武器,管理弹药(添加、移除和获取),处理角色受伤时,在所有客户端上播放动画蒙太奇,并在拥有客户端上播放声音。 -
从
AWeapon
创建BP_Pistol
,将其放置在Content\Weapons\Pistol
上,并使用以下值进行配置:
-
骨骼网格:
Content\Weapons\SK_Weapon
-
材质:
Content\Weapons\Pistol\M_Pistol
-
名称:
手枪 Mk I
-
武器类型:
手枪
,弹药类型:子弹
,射击模式:自动
-
命中扫描范围:
9999.9
,命中扫描伤害:5.0
,射速:0.5
-
射击动画蒙太奇:
Content\Player\Animations\Pistol_Fire_Montage
-
无弹药声音:
Content\Weapons\NoAmmo
- 从
AWeapon
创建BP_MachineGun
,并将其放置在Content\Weapons\MachineGun
上,并使用以下值进行配置:
-
骨骼网格:
Content\Weapons\SK_Weapon
-
材质:
Content\Weapons\MachineGun\M_MachineGun
-
名称:
机枪 Mk I
-
武器类型:
机枪
,弹药类型:子弹
,射击模式:自动
-
命中扫描范围:
9999.9
,命中扫描伤害:5.0
,射速:0.1
-
射击动画蒙太奇:
Content\Player\Animations\MachineGun_Fire_Montage
-
无弹药声音:
Content\Weapons\NoAmmo
- 从
AWeapon
创建BP_Railgun
,并将其放置在Content\Weapons\Railgun
上,并使用以下值进行配置:
-
骨骼网格:
Content\Weapons\SK_Weapon
-
材质:
Content\Weapons\Railgun\M_Railgun
-
名称:电磁炮
Mk I
,武器类型:电磁炮
,弹药类型:弹丸
,射击模式:单发
-
命中扫描范围:
9999.9
,命中扫描伤害:100.0
,射速:1.5
-
开火动画蒙太奇:
Content\Player\Animations\Railgun_Fire_Montage
-
无弹药声音:
Content\Weapons\NoAmmo
- 使用以下数值配置
BP_Player
:
-
武器类(索引 0:
BP_Pistol
,索引 1:BP_MachineGun
,索引 2:BP_Railgun
)。 -
命中声音:
Content\Weapons\Hit
。 -
武器切换声音:
Content\Weapons\WeaponChange
。 -
使网格组件阻止可见性通道,以便可以被武器的 hitscan 命中。
-
编辑
ABP_Player
,使用Layered Blend Per Bone
节点,在spine_01
骨骼上启用Mesh Space Rotation Blend
,以便上半身动画使用上半身插槽。 -
编辑
UI_HUD
,使其在屏幕中央显示一个白色点十字准星,以及当前武器和弹药数量在健康和护甲指示器下方:
图 17.12:活动的预期结果
结果应该是一个项目,其中每个客户端都将拥有带弹药的武器,并且能够使用它们来射击和伤害其他玩家。您还可以使用1、2和3键以及鼠标滚轮向上和向下来选择武器。
注意:
此活动的解决方案可在以下网址找到:https://packt.live/338jEBx。
总结
在本章中,您学会了如何使用 RPC 允许服务器和客户端在彼此上执行逻辑。我们还学习了在虚幻引擎 4 中如何使用UENUM
宏以及如何使用双向循环数组索引,这有助于您在两个方向上迭代数组,并在超出其索引限制时循环。
完成本章的活动后,您将拥有一个基本的可玩游戏,玩家可以互相射击和切换武器,但我们仍然可以添加更多内容,使游戏变得更加有趣。
在下一章中,我们将了解最常见的游戏框架类的实例在多人游戏中存在的位置,以及了解我们尚未涵盖的 Player State 和 Game State 类。我们还将介绍一些在多人游戏中使用的游戏模式中的新概念,以及一些有用的通用内置功能。
第十七章:17.远程过程调用
概述
在本章中,您将了解远程过程调用,这是虚幻引擎 4 网络框架的另一个重要多人游戏概念。您还将学习如何在虚幻引擎 4 中使用枚举,以及如何使用双向循环数组索引,这是一种帮助您在两个方向上迭代数组并能够在超出索引限制时循环的方法。
通过本章的学习,您将了解远程过程调用是如何使服务器和客户端在彼此之间执行逻辑的。您还将能够在虚幻引擎 4 编辑器中公开枚举,并使用双向循环数组索引来循环遍历数组。
介绍
在上一章中,我们涵盖了一些关键的多人游戏概念,包括服务器-客户端架构、连接和所有权、角色和变量复制。我们还看到了监听服务器设置比专用服务器更快,但不够轻量级。我们利用这些知识创建了一个基本的第一人称射击角色,可以行走、跳跃和四处张望。
在本章中,我们将介绍远程过程调用(RPC),这是另一个重要的多人游戏概念,允许服务器在客户端上执行函数,反之亦然。到目前为止,我们已经学习了变量复制作为服务器和客户端之间的通信形式,但这还不够,因为服务器可能需要在客户端上执行特定的逻辑,而不涉及更新变量的值。客户端还需要一种方式来告诉服务器它的意图,以便服务器可以验证动作并让其他客户端知道。这将确保多人游戏世界同步,我们将在本章中更详细地探讨这个问题。我们还将介绍如何在虚幻引擎 4 中使用枚举,以及双向循环数组索引,这有助于在两个方向上迭代数组,并在超出索引限制时循环。
在第一个主题中,我们将研究 RPC。
远程过程调用
我们在第十六章 多人游戏基础中涵盖了变量复制,虽然这是一个非常有用的功能,但在允许在远程机器上执行自定义代码(客户端到服务器或服务器到客户端)方面有一些限制,主要有两个原因。
-
第一个原因是变量复制严格来说是一种服务器到客户端的通信形式,因此客户端没有办法使用变量复制来告诉服务器通过改变变量的值来执行一些自定义逻辑。
-
第二个原因是,变量复制,顾名思义,是由变量的值驱动的,因此,即使变量复制允许客户端到服务器的通信,也需要您在客户端上更改变量的值来触发服务器上的
RepNotify
功能来运行自定义逻辑,这并不是非常实际的。
为了解决这个问题,虚幻引擎 4 支持 RPC。RPC 的工作原理就像一个普通的函数,可以定义和调用,但是它不会在本地执行,而是在远程机器上执行。使用 RPC 的主要目标是有可能在远程机器上执行特定逻辑,这与变量没有直接关联。要使用 RPC,确保在打开了复制的 actor 中定义它们。
有三种类型的 RPC,每种都有不同的目的:
-
服务器 RPC
-
多播 RPC
-
客户端 RPC
让我们详细讨论这三种类型,并解释何时应该使用它们:
服务器 RPC
每当您希望服务器在定义了 RPC 的 actor 上运行函数时,都可以使用服务器 RPC。有两个主要原因您会这样做:
-
第一个原因是出于安全考虑,因为在制作多人游戏时,特别是竞争性游戏,你总是要假设客户端会试图作弊。确保没有作弊的方法是强制客户端在服务器上执行对游戏玩法至关重要的功能。
-
第二个原因是为了同步性,因为关键的游戏逻辑只在服务器上执行,这意味着重要的变量只会在那里被改变,这将触发变量复制逻辑,以在变量改变时更新客户端。
一个例子是当客户端的角色尝试开火时。由于客户端可能会试图作弊,你不能只在本地执行开火逻辑。正确的做法是让客户端调用一个服务器 RPC,告诉服务器通过验证Fire
动作来验证角色是否有足够的弹药和装备了武器等。如果一切正常,它将扣除弹药变量,最后执行一个多播 RPC(在下一个 RPC 类型中介绍),告诉所有客户端在该角色上播放开火动画。
声明
要声明一个服务器 RPC,你需要在UFUNCTION
宏上使用Server
修饰符。看下面的例子:
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter);
在上述代码中,Server
修饰符用于UFUNCTION
宏,表示该函数是一个服务器 RPC。你可以像普通函数一样在服务器 RPC 上使用参数,但有一些后面会在本主题中解释的注意事项,以及Reliable
和WithValidation
修饰符的用途。
执行
要执行服务器 RPC,你需要从客户端在定义了它的角色实例上调用它。看下面的例子:
void ARPCTest::CallMyOwnServerRPC(int32 IntegerParameter)
{
ServerMyOwnRPC(IntegerParameter);
}
void ARPCTest::CallServerRPCOfAnotherActor(AAnotherActor* OtherActor)
{
if(OtherActor != nullptr)
{
OtherActor->ServerAnotherActorRPC();
}
}
第一个代码片段实现了CallMyOwnServerRPC
函数,该函数调用其自己ARPCTest
类中定义的ServerMyOwnRPC
RPC 函数,带有一个整数参数。这将在该角色实例的服务器版本上执行ServerMyOwnRPC
函数的实现。
第二个代码片段实现了CallServerRPCOfAnotherActor
函数,该函数调用AAnotherActor
中定义的ServerAnotherActorRPC
RPC 函数,只要OtherActor
实例有效。这将在OtherActor
实例的服务器版本上执行ServerAnotherActorRPC
函数的实现。
有效连接
在从客户端调用服务器 RPC 时需要考虑的一件重要事情是,定义它的角色需要有一个有效的连接。如果你尝试在没有有效连接的角色上调用服务器 RPC,那么什么也不会发生。你必须确保该角色要么是玩家控制器,要么被一个(如果适用)控制,或者其拥有的角色有一个有效的连接。
多播 RPC
当你希望服务器告诉所有客户端在定义了 RPC 的角色上运行一个函数时,你使用多播 RPC。
一个例子是当客户端的角色尝试开火时。在客户端调用服务器 RPC 请求开火许可并且服务器已处理了请求(所有验证都通过,弹药已扣除,线性跟踪/抛射物已处理),然后我们需要进行多播 RPC,以便该特定角色的所有实例都播放开火动画。这将确保无论哪个客户端正在观察角色,角色都会一直播放开火动画。
声明
要声明一个多播 RPC,你需要在UFUNCTION
宏上使用NetMulticast
修饰符。看下面的例子:
UFUNCTION(NetMulticast)
void MulticastRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter);
在上述代码中,NetMulticast
修饰符用于UFUNCTION
宏,表示以下函数是一个多播 RPC。您可以像普通函数一样在多播 RPC 上使用参数,但具有与服务器 RPC 相同的注意事项。
执行
执行多播 RPC 时,您需要从定义它的 actor 实例上的服务器调用它。看一下以下示例:
void ARPCTest::CallMyOwnMulticastRPC(int32 IntegerParameter)
{
MulticastMyOwnRPC(IntegerParameter);
}
void ARPCTest::CallMulticastRPCOfAnotherActor(AAnotherActor* OtherActor)
{
if(OtherActor != nullptr)
{
OtherActor->MulticastAnotherActorRPC();
}
}
第一个代码片段实现了CallMyOwnMulticastRPC
函数,该函数调用了ARPCTest
类中定义的MulticastMyOwnRPC
RPC 函数,带有一个整数参数。这将在该 actor 实例的所有客户端版本上执行MulticastMyOwnRPC
函数的实现。
第二个代码片段实现了CallMulticastRPCOfAnotherActor
函数,该函数调用了AAnotherActor
中定义的MulticastAnotherActorRPC
RPC 函数,只要OtherActor
实例有效。这将在所有客户端版本的OtherActor
实例上执行MulticastAnotherActorRPC
函数的实现。
客户端 RPC
当您希望仅在定义 RPC 的 actor 的拥有客户端上运行函数时,您可以使用客户端 RPC。要设置拥有客户端,您需要在服务器上调用 SetOwner,并使用客户端的玩家控制器进行设置。
例如,当角色被抛射物击中并播放只有该客户端会听到的疼痛声音时。通过从服务器调用客户端 RPC,声音将仅在拥有客户端上播放,因此其他客户端不会听到。
声明
要声明客户端 RPC,您需要在UFUNCTION
宏上使用Client
修饰符。看一下以下示例:
UFUNCTION(Client)
void ClientRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter);
在前面的代码中,Client
修饰符用于UFUNCTION
宏,表示以下函数是一个客户端 RPC。您可以像普通函数一样在客户端 RPC 上使用参数,但具有与服务器 RPC 和多播 RPC 相同的注意事项。
执行
执行客户端 RPC 时,您需要从定义它的 actor 实例上的服务器调用它。看一下以下示例:
void ARPCTest::CallMyOwnClientRPC(int32 IntegerParameter)
{
ClientMyOwnRPC(IntegerParameter);
}
void ARPCTest::CallClientRPCOfAnotherActor(AAnotherActor* OtherActor)
{
if(OtherActor != nullptr)
{
OtherActor->ClientAnotherActorRPC();
}
}
第一个代码片段实现了CallMyOwnClientRPC
函数,该函数调用了ARPCTest
类中定义的ClientMyOwnRPC
RPC 函数,带有一个整数参数。这将在该 actor 实例的拥有客户端版本上执行ClientMyOwnRPC
函数的实现。
第二个代码片段实现了CallClientRPCOfAnotherActor
函数,该函数调用了AAnotherActor
中定义的ClientAnotherActorRPC
RPC 函数,只要OtherActor
实例有效。这将在拥有客户端版本的OtherActor
实例上执行ClientAnotherActorRPC
函数的实现。
使用 RPC 时的重要注意事项
RPC 非常有用,但在使用它们时有一些需要考虑的事项,例如:
实现
RPC 的实现与典型函数略有不同。您应该只实现它的_Implementation
版本,即使您没有在头文件中声明它。看一下以下示例:
服务器 RPC:
void ARPCTest::ServerRPCTest_Implementation(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在前面的代码片段中,我们实现了ServerRPCTest
函数的_Implementation
版本,该函数使用了三个参数。
多播 RPC:
void ARPCTest::MulticastRPCTest_Implementation(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在前面的代码片段中,我们实现了MulticastRPCTest
函数的_Implementation
版本,该函数使用了三个参数。
客户端 RPC:
void ARPCTest::ClientRPCTest_Implementation(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在前面的代码片段中,我们实现了ClientRPCTest
函数的_Implementation
版本,该函数使用了三个参数。
如前面的示例所示,无论您实现的 RPC 类型如何,您都应该只实现函数的_Implementation
版本,而不是正常版本,就像以下代码片段中所示:
void ARPCTest::ServerRPCFunction(int32 IntegerParameter, float FloatParameter, AActor* ActorParameter)
{
}
在上述代码中,我们正在定义ServerRPCFunction
的正常实现。如果您像这样实现 RPC,您将收到一个错误,指出它已经被实现。原因是当您在头文件中声明 RPC 函数时,Unreal Engine 4 将自动在内部创建正常的实现,然后稍后调用_Implementation
版本。如果您创建自己版本的正常实现,构建将失败,因为它会找到相同函数的两个实现。要解决此问题,只需确保只实现 RPC 的_Implementation
版本。
接下来,我们转到名称前缀。
名称前缀
在 Unreal Engine 4 中,最好的做法是使用相应类型的前缀来命名 RPC。看看以下例子:
-
一个
ServerRPCFunction
。 -
一个
MulticastRPCFunction
。 -
一个
ClientRPCFunction
。
返回值
由于 RPC 的调用和执行通常在不同的机器上进行,因此您不能有返回值,因此它总是需要是 void。
覆盖
您可以通过在子类中声明和实现_Implementation
函数来覆盖 RPC 的实现,以扩展或绕过父类的功能,而无需使用UFUNCTION
宏。以下是一个例子:
父类上的声明:
UFUNCTION(Server)
void ServerRPCTest(int32 IntegerParameter);
在上述代码片段中,我们有ServerRPCTest
函数的父类声明,它使用一个整数参数。
子类上的覆盖声明:
virtual void ServerRPCTest_Implementation(int32 IntegerParameter) override;
在上述代码片段中,我们在子类头文件中覆盖了ServerRPCTest_Implementation
函数的声明。函数的实现就像任何其他覆盖一样,还可以调用Super::ServerRPCTest_Implementation
,如果您仍然希望执行父功能。
支持的参数类型
在使用 RPC 时,您可以像任何其他函数一样添加参数。目前,支持大多数常见类型,包括bool
、int32
、float
、FString
、FName
、TArray
、TSet
和TMap
。您需要更注意的类型是指向任何UObject
类或子类的指针,特别是 actors。
如果您创建一个带有 actor 参数的 RPC,则该 actor 也需要存在于远程机器上,否则它将为nullptr
。另一件重要的事情要考虑的是每个版本的 actor 的实例名称可能不同。这意味着如果您调用带有 actor 参数的 RPC,那么在调用 RPC 时 actor 的实例名称可能与在远程机器上执行 RPC 时的实例名称不同。以下是一个例子,以帮助您理解这一点:
图 17.1:监听服务器和两个客户端运行
在上面的例子中,您可以看到三个客户端正在运行(其中一个是监听服务器),每个窗口都显示所有角色实例的名称。如果您查看客户端 1 窗口,其控制的角色实例称为ThirdPersonCharacter_C_0
,但在服务器窗口上,相应的角色称为ThirdPersonCharacter_C_1
。这意味着如果客户端 1 调用服务器 RPC 并将其ThirdPersonCharacter_C_0
作为参数传递,那么在服务器上执行 RPC 时,参数将是ThirdPersonCharacter_C_1
,这是该机器上等效角色的实例名称。
在目标机器上执行 RPC
您可以直接在目标机器上调用 RPC,并且它仍然会执行。换句话说,您可以在服务器上调用服务器 RPC 并执行,以及在客户端上调用 Multicast/Client RPC,但在这种情况下,它只会在调用 RPC 的客户端上执行逻辑。无论如何,在这些情况下,您应该始终直接调用_Implementation
版本,以便更快地执行逻辑。
这是因为_Implementation
版本只包含执行逻辑,没有创建和通过网络发送 RPC 请求的开销,而常规调用有。
看一下以下的一个在服务器上具有权限的演员的例子:
void ARPCTest::CallServerRPC(int32 IntegerParameter)
{
if(HasAuthority())
{
ServerRPCFunction_Implementation(IntegerParameter);
}
else ServerRPCFunction(IntegerParameter);
}
在上面的例子中,您有一个CallServerRPC
函数,它以两种不同的方式调用ServerRPCFunction
。如果演员已经在服务器上,则调用ServerRPCFunction_Implementation
,这将跳过前面提到的开销。
如果演员不在服务器上,则通过使用ServerRPCFunction
执行常规调用,这将增加创建和通过网络发送 RPC 请求所需的开销。
验证
当您定义 RPC 时,您可以选择使用附加函数来检查在调用 RPC 之前是否存在任何无效输入。这用于避免处理 RPC,如果输入无效,由于作弊或其他原因。
要使用验证,您需要在UFUNCTION
宏中添加WithValidation
说明符。当您使用该说明符时,您将被迫实现函数的_Validate
版本,该版本将返回一个布尔值,指示 RPC 是否可以执行。
看一下以下的例子:
UFUNCTION(Server, WithValidation)
void ServerSetHealth(float NewHealth);
在上面的代码中,我们声明了一个名为ServerSetHealth
的验证服务器 RPC,它接受一个浮点参数作为Health
的新值。至于实现,如下所示:
bool ARPCTest::ServerSetHealth_Validate(float NewHealth)
{
return NewHealth <= MaxHealth;
}
void ARPCTest::ServerSetHealth_Implementation(float NewHealth)
{
Health = NewHealth;
}
在上面的代码中,我们实现了_Validate
函数,它将检查新的健康是否小于或等于健康的最大值。如果客户端尝试黑客并使用200
和MaxHealth
为100
调用ServerSetHealth
,则不会调用 RPC,这将防止客户端使用超出一定范围的值更改健康。如果_Validate
函数返回true
,则将像往常一样调用_Implementation
函数,该函数将Health
设置为NewHealth
的值。
可靠性
当您声明 RPC 时,您必须在UFUNCTION
宏中使用Reliable
或Unreliable
说明符。以下是它们的快速概述:
Reliable
:当您希望确保 RPC 被执行时使用,通过重复请求直到远程机器确认其接收。这应仅用于非常重要的 RPC,例如执行关键的游戏逻辑。以下是如何使用它的示例:
UFUNCTION(Server, Reliable)
void ServerReliableRPCFunction(int32 IntegerParameter);
Unreliable
:当您不关心 RPC 是否由于糟糕的网络条件而执行时使用,例如播放声音或生成粒子效果。这应仅用于不太重要或非常频繁调用以更新值的 RPC,因为如果一个调用丢失了,因为它经常更新,所以不重要。以下是如何使用它的示例:
UFUNCTION(Server, Unreliable)
void ServerUnreliableRPCFunction(int32 IntegerParameter);
注意
有关 RPC 的更多信息,请访问docs.unrealengine.com/en-US/Gameplay/Networking/Actors/RPCs/index.html
。
在下一个练习中,您将看到如何实现不同类型的 RPC。
练习 17.01:使用远程过程调用
在这个练习中,我们将创建一个使用Third Person
模板的 C++项目,并且我们将以以下方式扩展它:
-
添加一个火灾定时器变量,以防止客户端在开火动画期间滥用开火按钮。
-
添加一个新的 Ammo 整数变量,默认为
5
并复制到所有客户端。 -
添加一个
Fire Anim montage
,当服务器告诉客户端射击有效时播放。 -
添加一个
No Ammo Sound
,当服务器告诉客户端他们没有足够的弹药时播放。 -
每当玩家按下“左鼠标按钮”时,客户端将执行可靠和验证的服务器 RPC,检查角色是否有足够的弹药。如果有,它将从弹药变量中减去 1,并调用一个不可靠的多播 RPC,在每个客户端播放火力动画。如果没有弹药,那么它将执行一个不可靠的客户端 RPC,只有拥有客户端才能听到“无弹药声音”。
以下步骤将帮助您完成练习:
-
使用
C++
创建一个名为RPC
的新Third Person
模板项目,并将其保存到您选择的位置。 -
项目创建后,应该打开编辑器以及 Visual Studio 解决方案。
-
关闭编辑器,返回 Visual Studio。
-
打开
RPCCharacter.h
文件并包含UnrealNetwork.h
头文件,其中包含我们将要使用的DOREPLIFETIME_CONDITION
宏的定义:
#include "Net/UnrealNetwork.h"
- 声明受保护的计时器变量,以防止客户端滥用
Fire
动作:
FTimerHandle FireTimer;
- 声明受保护的复制弹药变量,起始为
5
发射:
UPROPERTY(Replicated)
int32 Ammo = 5;
- 接下来,声明受保护的动画蒙太奇变量,将在角色开火时播放:
UPROPERTY(EditDefaultsOnly, Category = "RPC Character")
UAnimMontage* FireAnimMontage;
- 声明受保护的声音变量,当角色没有弹药时将播放:
UPROPERTY(EditDefaultsOnly, Category = "RPC Character")
USoundBase* NoAmmoSound;
- 重写
Tick
函数:
virtual void Tick(float DeltaSeconds) override;
- 声明处理“左鼠标按钮”按下的输入函数:
void OnPressedFire();
- 声明可靠和验证的用于开火的服务器 RPC:
UFUNCTION(Server, Reliable, WithValidation, Category = "RPC Character")
void ServerFire();
- 声明不可靠的多播 RPC,将在所有客户端上播放火力动画:
UFUNCTION(NetMulticast, Unreliable, Category = "RPC Character")
void MulticastFire();
- 声明不可靠的客户端 RPC,仅在拥有客户端播放声音:
UFUNCTION(Client, Unreliable, Category = "RPC Character")
void ClientPlaySound2D(USoundBase* Sound);
- 现在,打开
RPCCharacter.cpp
文件并包含DrawDebugHelpers.h
,GameplayStatics.h
,TimerManager.h
和World.h
:
#include "DrawDebugHelpers.h"
#include "Kismet/GameplayStatics.h"
#include "TimerManager.h"
#include "Engine/World.h"
- 在构造函数的末尾,启用
Tick
函数:
PrimaryActorTick.bCanEverTick = true;
- 实现
GetLifetimeReplicatedProps
函数,以便Ammo
变量将复制到所有客户端:
void ARPCCharacter::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ARPCCharacter, Ammo);
}
- 接下来,实现
Tick
函数,显示Ammo
变量的值:
void ARPCCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
const FString AmmoString = FString::Printf(TEXT("Ammo = %d"), Ammo);
DrawDebugString(GetWorld(), GetActorLocation(), AmmoString, nullptr, FColor::White, 0.0f, true);
}
- 在
SetupPlayerInputController
函数的末尾,将Fire
动作绑定到OnPressedFire
函数:
PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &ARPCCharacter::OnPressedFire);
- 实现处理“左鼠标按钮”按下的函数,该函数将调用火力服务器 RPC:
void ARPCCharacter::OnPressedFire()
{
ServerFire();
}
- 实现火力服务器 RPC 验证函数:
bool ARPCCharacter::ServerFire_Validate()
{
return true;
}
- 实现火力服务器 RPC 实现函数:
void ARPCCharacter::ServerFire_Implementation()
{
}
- 现在,如果上一次射击后火力计时器仍处于活动状态,则添加中止函数的逻辑:
if (GetWorldTimerManager().IsTimerActive(FireTimer))
{
return;
}
- 检查角色是否有弹药。如果没有,那么只在控制角色的客户端播放“无弹药声音”,并中止函数:
if (Ammo == 0)
{
ClientPlaySound2D(NoAmmoSound);
return;
}
- 扣除弹药并安排
FireTimer
变量,以防止在播放火力动画时滥用此函数:
Ammo--;
GetWorldTimerManager().SetTimer(FireTimer, 1.5f, false);
- 调用火力多播 RPC,使所有客户端播放火力动画:
MulticastFire();
- 实现火力多播 RPC,将播放火力动画蒙太奇:
void ARPCCharacter::MulticastFire_Implementation()
{
if (FireAnimMontage != nullptr)
{
PlayAnimMontage(FireAnimMontage);
}
}
- 实现播放 2D 声音的客户端 RPC:
void ARPCCharacter::ClientPlaySound2D_Implementation(USoundBase* Sound)
{
UGameplayStatics::PlaySound2D(GetWorld(), Sound);
}
最后,您可以在编辑器中启动项目。
-
编译代码并等待编辑器完全加载。
-
转到“项目设置”,转到“引擎”,然后“输入”,添加
Fire
动作绑定:
图 17.2:添加新的 Fire 动作绑定
-
关闭“项目设置”。
-
在“内容浏览器”中,转到
Content\Mannequin\Animations
文件夹。 -
单击“导入”按钮,转到
Exercise17.01\Assets
文件夹,导入ThirdPersonFire.fbx
文件,然后确保它使用UE4_Mannequin_Skeleton
骨架。
注意
前面提到的Assets
文件夹可在我们的 GitHub 存储库packt.live/36pEvAT
上找到。
-
打开新的动画,在详细信息面板上找到“启用根动作”选项,并将其设置为 true。这将在播放动画时防止角色移动。
-
保存并关闭
ThirdPersonFire
。 -
右键单击
Content Browser
上的ThirdPersonFire
,选择Create -> AnimMontage
。 -
将
AnimMontage
重命名为ThirdPersonFire_Montage
。 -
Animations
文件夹应该是这样的:
图 17.3:Mannequin 的动画文件夹
-
打开
ThirdPerson_AnimBP
,然后打开AnimGraph
。 -
右键单击图表的空白部分,添加一个
DefaultSlot
节点(以便播放动画剪辑),并将其连接在State Machine
和Output Pose
之间。您应该获得以下输出:
图 17.4:角色的 AnimGraph
-
保存并关闭
ThirdPerson_AnimBP
。 -
在
Content Browser
中,转到Content
文件夹,创建一个名为Audio
的新文件夹,并打开它。 -
单击
导入
按钮,转到Exercise17.01\Assets
文件夹,导入noammo.wav
并保存。 -
转到
Content\ThirdPersonCPP\Blueprints
并打开ThirdPersonCharacter
蓝图。 -
在类默认值中,将
No Ammo Sound
设置为使用noammo
,并将Fire Anim Montage
设置为使用ThirdPersonFire_Montage
。 -
保存并关闭
ThirdPersonCharacter
。 -
转到多人游戏选项,并将客户端数量设置为
2
。 -
将窗口大小设置为 800x600 并使用 PIE 进行游戏。
您应该获得以下输出:
图 17.5:练习的最终结果
通过完成这个练习,您将能够在每个客户端上进行游戏,并且每次按下左鼠标按钮时,客户端的角色将播放Fire Anim
剪辑,所有客户端都将能够看到,并且其弹药将减少1
。如果在弹药为0
时尝试开火,该客户端将听到No Ammo Sound
并且不会执行开火动画,因为服务器没有调用 Multicast RPC。如果尝试连续按下开火按钮,您会注意到只有在动画完成后才会触发新的开火。
在下一节中,我们将看一下枚举,在游戏开发中,它们用于许多不同的事情,例如管理角色的状态(空闲、行走、攻击、死亡等)或为装备槽数组中的每个索引分配一个易于理解的名称(头部、主武器、副武器、躯干、手部、腰带、裤子等)。
枚举
枚举是一种用户定义的数据类型,它保存一系列整数常量,其中每个项目都由您分配了一个易于理解的名称,这使得代码更容易阅读。例如,我们可以使用整数变量来表示角色可能处于的不同状态-0
表示它处于空闲状态,1
表示它正在行走,依此类推。这种方法的问题在于,当您开始编写诸如if(State == 0)
之类的代码时,很难记住0
的含义,特别是如果您有很多状态,没有使用一些文档或注释来帮助您记住。为了解决这个问题,您应该使用枚举,其中您可以编写诸如if(State == EState::Idle)
之类的代码,这样更加明确和易于理解。
在 C++中,您有两种枚举类型,旧的原始枚举和引入于 C++11 的新枚举类。如果您想在编辑器中使用 C++枚举,您的第一反应可能是以典型方式声明变量或函数,即使用枚举作为参数,分别使用UPROPERTY
或UFUNCTION
。
问题是,如果您尝试这样做,您将收到编译错误。看一下以下示例:
enum class ETestEnum : uint8
{
EnumValue1,
EnumValue2,
EnumValue3
};
在上面的代码片段中,我们声明了一个名为ETestEnum
的枚举类,它有三个可能的值-EnumValue1
、EnumValue2
和EnumValue3
。
之后,尝试以下示例之一:
UPROPERTY()
ETestEnum TestEnum;
UFUNCTION()
void SetTestEnum(ETestEnum NewTestEnum) { TestEnum = NewTestEnum; }
在前面的代码片段中,我们在类中声明了一个使用ETestEnum
枚举的UPROPERTY
变量和UFUNCTION
函数。如果尝试编译,将收到以下编译错误:
error : Unrecognized type 'ETestEnum' - type must be a UCLASS, USTRUCT or UENUM
注意
在虚幻引擎 4 中,最好的做法是使用字母E
作为枚举名称的前缀。例如EWeaponType
和EAmmoType
。
这个错误发生的原因是,当您尝试使用UPROPERTY
或UFUNCTION
宏将类、结构或枚举暴露给编辑器时,您需要分别使用UCLASS
、USTRUCT
和UENUM
宏将其添加到虚幻引擎 4 反射系统中。
注意
您可以通过访问以下链接了解更多关于虚幻引擎 4 反射系统的信息:www.unrealengine.com/en-US/blog/unreal-property-system-reflection
。
有了这些知识,修复先前的错误就很简单了,只需执行以下操作:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1,
EnumValue2,
EnumValue3
};
在下一节中,我们将看一下TEnumAsByte
类型。
TEnumAsByte
如果要将使用原始枚举的变量暴露给引擎,则需要使用TEnumAsByte
类型。如果使用原始枚举(而不是枚举类)声明UPROPERTY
变量,将会收到编译错误。
看下面的例子:
UENUM()
enum ETestRawEnum
{
EnumValue1,
EnumValue2,
EnumValue3
};
如果使用ETestRawEnum
声明UPROPERTY
变量,如下所示:
UPROPERTY()
ETestRawEnum TestRawEnum;
您将收到这个编译错误:
error : You cannot use the raw enum name as a type for member variables, instead use TEnumAsByte or a C++11 enum class with an explicit underlying type.
要修复此错误,您需要用TEnumAsByte<>
将变量的枚举类型(在本例中为ETestRawEnum
)括起来,如下所示:
UPROPERTY()
TEnumAsByte<ETestRawEnum> TestRawEnum;
UMETA
当您使用UENUM
宏将枚举添加到虚幻引擎反射系统时,这将允许您在枚举的每个值上使用UMETA
宏。UMETA
宏,就像其他宏(如UPROPERTY
或UFUNCTION
)一样,可以使用说明符,这些说明符将告诉虚幻引擎 4 如何处理该值。以下是最常用的UMETA
说明符列表:
DisplayName
这个说明符允许您在编辑器中显示枚举值时定义一个更容易阅读的新名称。
看下面的例子:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1 UMETA(DisplayName = "My First Option",
EnumValue2 UMETA(DisplayName = "My Second Option",
EnumValue3 UMETA(DisplayName = "My Third Option"
};
让我们声明以下变量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Test")
ETestEnum TestEnum;
然后,当您打开编辑器并查看TestEnum
变量时,您将看到一个下拉菜单,其中EnumValue1
,EnumValue2
和EnumValue3
已分别替换为My First Option
,My Second Option
和My Third Option
。
隐藏
这个说明符允许您隐藏下拉菜单中的特定枚举值。当只想在 C++中使用枚举值时,通常会使用这个说明符。
看下面的例子:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1 UMETA(DisplayName = "My First Option"),
EnumValue2 UMETA(Hidden),
EnumValue3 UMETA(DisplayName = "My Third Option")
};
让我们声明以下变量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Test")
ETestEnum TestEnum;
然后,当您打开编辑器并查看TestEnum
变量时,您将看到一个下拉菜单。您应该注意到My Second Option
不会出现在下拉菜单中,因此无法选择。
注意
有关所有 UMETA 说明符的更多信息,请访问docs.unrealengine.com/en-US/Programming/UnrealArchitecture/Reference/Metadata/#enummetadataspecifiers
。
在下一节中,我们将看一下UENUM
宏的BlueprintType
说明符。
BlueprintType
这个UENUM
修饰符将枚举暴露给蓝图。这意味着在创建新变量或函数的输入/输出时,下拉菜单中将有一个枚举条目,就像以下示例中一样:
图 17.6:将变量设置为使用 ETestEnum 变量类型。
它还将显示您可以在编辑器中调用的枚举的其他函数,就像这个例子中一样:
图 17.7:在使用 BlueprintType 时可用的其他函数列表
最大
在使用枚举时,通常希望知道它有多少个值。在 Unreal Engine 4 中,标准的做法是将MAX
添加为最后一个值,它将自动隐藏在编辑器中。
看一下以下示例:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1,
EnumValue2,
EnumValue3,
MAX
};
如果你想知道 C++中ETestEnum
有多少个值,你只需要做以下操作:
const int32 MaxCount = (int32)ETestEnum::MAX;
这是因为在 C++中,枚举内部存储为数字,第一个值为0
,第二个为1
,依此类推。这意味着只要MAX
是最后一个值,它将始终具有枚举中的总值。需要考虑的一个重要事项是,为了使MAX
给出正确的值,你不能改变枚举的内部编号顺序,如下所示:
UENUM()
enum class ETestEnum : uint8
{
EnumValue1 = 4,
EnumValue2 = 78,
EnumValue3 = 100,
MAX
};
在这种情况下,MAX
将是101
,因为它将使用紧接前一个值的数字,即EnumValue3 = 100
。
使用MAX
只能在 C++中使用,而不能在编辑器中,因为MAX
值在蓝图中是隐藏的,如前所述。要在蓝图中获取枚举的条目数,应在UENUM
宏中使用BlueprintType
修饰符,以便在上下文菜单中公开一些有用的函数。之后,你只需要在上下文菜单中输入你的枚举名称。如果选择Get number of entries in ETestEnum
选项,你将得到一个返回枚举条目数的函数。
在下一个练习中,你将在 Unreal Engine 4 编辑器中使用 C++枚举。
练习 17.02:在 Unreal Engine 4 编辑器中使用 C++枚举
在这个练习中,我们将创建一个使用Third Person
模板的新 C++项目,并添加以下内容:
-
一个名为
EWeaponType
的枚举,包含3
种武器 - 手枪、霰丨弹丨枪和火箭发射器。 -
一个名为
EAmmoType
的枚举,包含3
种弹药类型 - 子弹、弹壳和火箭。 -
一个名为
Weapon
的变量,使用EWeaponType
告诉当前武器的类型。 -
一个名为
Ammo
的整数数组变量,保存每种类型的弹药数量,初始化为10
。 -
当玩家按下1、2和3键时,它将分别设置
Weapon
为Pistol
、Shotgun
和Rocket Launcher
。 -
当玩家按下左鼠标按钮时,这将消耗当前武器的弹药。
-
每次
Tick
函数调用时,角色将显示当前武器类型和相应的弹药类型和数量。
以下步骤将帮助你完成练习:
- 使用
C++
创建一个名为Enumerations
的新Third Person
模板项目,并将其保存到你选择的位置。
项目创建后,应该打开编辑器以及 Visual Studio 解决方案。
-
关闭编辑器,返回 Visual Studio。
-
打开
Enumerations.h
文件。 -
创建一个名为
ENUM_TO_INT32
的宏,它将把枚举转换为int32
数据类型:
#define ENUM_TO_INT32(Value) (int32)Value
- 创建一个名为
ENUM_TO_FSTRING
的宏,它将获取enum
数据类型的值的显示名称,并将其转换为FString
数据类型:
#define ENUM_TO_FSTRING(Enum, Value) FindObject<UEnum>(ANY_PACKAGE, TEXT(Enum), true)- >GetDisplayNameTextByIndex((int32)Value).ToString()
- 声明枚举
EWeaponType
和EAmmoType
:
UENUM(BlueprintType)
enum class EWeaponType : uint8
{
Pistol UMETA(Display Name = «Glock 19»),
Shotgun UMETA(Display Name = «Winchester M1897»),
RocketLauncher UMETA(Display Name = «RPG»),
MAX
};
UENUM(BlueprintType)
enum class EAmmoType : uint8
{
Bullets UMETA(DisplayName = «9mm Bullets»),
Shells UMETA(Display Name = «12 Gauge Shotgun Shells»),
Rockets UMETA(Display Name = «RPG Rockets»),
MAX
};
- 打开
EnumerationsCharacter.h
文件,包括Enumerations.h
头文件:
#include "Enumerations.h"
- 声明受保护的
Weapon
变量,保存所选武器的武器类型:
UPROPERTY(BlueprintReadOnly, Category = "Enumerations Character")
EWeaponType Weapon;
- 声明受保护的
Ammo
数组,保存每种类型的弹药数量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Enumerations Character")
TArray<int32> Ammo;
- 声明
Begin Play
和Tick
函数的受保护覆盖:
virtual void BeginPlay() override;
virtual void Tick(float DeltaSeconds) override;
- 声明受保护的输入函数:
void OnPressedPistol();
void OnPressedShotgun();
void OnPressedRocketLauncher();
void OnPressedFire();
- 打开
EnumerationsCharacter.cpp
文件,包括DrawDebugHelpers.h
头文件:
#include "DrawDebugHelpers.h"
- 在
SetupPlayerInputController
函数的末尾绑定新的动作绑定,如下面的代码片段所示:
PlayerInputComponent->BindAction("Pistol", IE_Pressed, this, &AEnumerationsCharacter::OnPressedPistol);
PlayerInputComponent->BindAction("Shotgun", IE_Pressed, this, &AEnumerationsCharacter::OnPressedShotgun);
PlayerInputComponent->BindAction("Rocket Launcher", IE_Pressed, this, &AEnumerationsCharacter::OnPressedRocketLauncher);
PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &AEnumerationsCharacter::OnPressedFire);
- 接下来,实现
BeginPlay
的重写,执行父逻辑,但还使用EAmmoType
枚举中的条目数初始化Ammo
数组的大小。数组中的每个位置也将初始化为10
:
void AEnumerationsCharacter::BeginPlay()
{
Super::BeginPlay();
const int32 AmmoCount = ENUM_TO_INT32(EAmmoType::MAX);
Ammo.Init(10, AmmoCount);
}
- 实现
Tick
的重写:
void AEnumerationsCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
}
- 将
Weapon
变量转换为int32
,将Weapon
变量转换为FString
:
const int32 WeaponIndex = ENUM_TO_INT32(Weapon);
const FString WeaponString = ENUM_TO_FSTRING("EWeaponType", Weapon);
- 将弹药类型转换为
FString
,并获取当前武器的弹药计数:
const FString AmmoTypeString = ENUM_TO_FSTRING("EAmmoType", Weapon);
const int32 AmmoCount = Ammo[WeaponIndex];
我们使用Weapon
来获取弹药类型字符串,因为EAmmoType
中的条目与等效的EWeaponType
的弹药类型匹配。换句话说,Pistol = 0
使用Bullets = 0
,Shotgun = 1
使用Shells = 1
,RocketLauncher = 2
使用Rockets = 2
,因此这是一个我们可以利用的一对一映射。
- 在角色位置显示当前武器的名称及其相应的弹药类型和弹药计数,如下面的代码片段所示:
const FString String = FString::Printf(TEXT("Weapon = %s\nAmmo Type = %s\nAmmo Count = %d"), *WeaponString, *AmmoTypeString, AmmoCount);
DrawDebugString(GetWorld(), GetActorLocation(), String, nullptr, FColor::White, 0.0f, true);
- 实现设置
Weapon
变量为相应值的装备输入函数:
void AEnumerationsCharacter::OnPressedPistol()
{
Weapon = EWeaponType::Pistol;
}
void AEnumerationsCharacter::OnPressedShotgun()
{
Weapon = EWeaponType::Shotgun;
}
void AEnumerationsCharacter::OnPressedRocketLauncher()
{
Weapon = EWeaponType::RocketLauncher;
}
- 实现使用武器索引获取相应弹药类型计数并减去
1
的开火输入函数,只要结果值大于或等于 0:
void AEnumerationsCharacter::OnPressedFire()
{
const int32 WeaponIndex = ENUM_TO_INT32(Weapon);
const int32 NewRawAmmoCount = Ammo[WeaponIndex] - 1;
const int32 NewAmmoCount = FMath::Max(NewRawAmmoCount, 0);
Ammo[WeaponIndex] = NewAmmoCount;
}
-
编译代码并运行编辑器。
-
转到
项目设置
,然后转到引擎
,然后转到输入
,并添加新的动作绑定
:
图 17.8:添加手枪、霰丨弹丨枪、火箭发射器和开火绑定
-
关闭
项目设置
。 -
在单人模式下(一个客户端和禁用的专用服务器)中播放
New Editor Window (PIE)
:
图 17.9:练习的最终结果
通过完成此练习,您将能够使用1、2和3键选择当前武器。您会注意到每次都会显示当前武器的类型及其相应的弹药类型和弹药计数。如果按下开火键,这将减去当前武器的弹药计数,但不会低于0
。
在下一节中,您将学习双向循环数组索引。
双向循环数组索引
有时,当您使用数组存储信息时,您可能希望以双向循环的方式迭代它。一个例子是射击游戏中的上一个/下一个武器逻辑,您在其中有一个包含武器的数组,并且希望能够以特定方向循环遍历它们,当您达到第一个或最后一个索引时,您希望分别循环回到最后和第一个索引。执行此示例的典型方法如下:
AWeapon * APlayer::GetPreviousWeapon()
{
if(WeaponIndex - 1 < 0)
{
WeaponIndex = Weapons.Num() - 1;
}
else WeaponIndex--;
return Weapons[WeaponIndex];
}
AWeapon * APlayer::GetNextWeapon()
{
if(WeaponIndex + 1 > Weapons.Num() - 1)
{
WeaponIndex = 0;
}
else WeaponIndex++;
return Weapons[WeaponIndex];
}
在上述代码中,我们调整武器索引以在新武器索引超出武器数组限制时循环回去,这可能发生在两种情况下。第一种情况是当玩家装备了库存中的最后一件武器并要求下一件武器。在这种情况下,应该返回到第一件武器。
第二种情况是当玩家装备了库存中的第一件武器并要求上一件武器。在这种情况下,应该转到最后一件武器。
虽然示例代码有效,但仍然是相当多的代码来解决这样一个琐碎的问题。为了改进这段代码,有一个数学公式将帮助您在一个函数中自动考虑这两种情况。它被称为取模(在 C++中表示为%
运算符),它给出两个数字之间的余数。
那么我们如何使用取模来进行双向循环数组索引?让我们使用取模重写上一个示例:
AWeapon * APlayer::GetNewWeapon(int32 Direction)
{
const int32 WeaponCount = Weapons.Num();
const int32 NewIndex = WeaponIndex + Direction;
const in32 ClampedNewIndex = NewIndex % WeaponCount;
WeaponIndex = (ClampedNewIndex + WeaponCount) % WeaponCount;
return Weapons[WeaponIndex];
}
这是新版本,您可以立即看出它更难理解,但更加功能齐全和紧凑。如果您不使用变量来存储每个操作的中间值,您可能可以将整个函数编写为一两行代码。
让我们分解前面的代码片段:
const int WeaponCount = Weapons.Num()
:我们需要知道数组的大小,以确定它应该循环回0
的索引。换句话说,如果WeaponCount = 4
,那么数组有索引0
、1
、2
和3
,这告诉我们索引 4 是应该回到0
的截止索引。
const int32 NewIndex = WeaponIndex + Direction
:这是没有将其限制在数组限制内的新原始索引。Direction
变量用于指示我们要导航数组的偏移量,如果我们想要前一个索引,则为-1
,如果我们想要下一个索引,则为1
。
const int32 ClampedNewIndex = NewIndex % WeaponCount
:这将确保NewIndex
在0
到WeaponCount - 1
的区间内,因为模的属性。
如果Direction
始终为1
,那么ClampedNewIndex
就足够了。问题是,当WeaponIndex
为0
且Direction
为-1
时,模运算与负值不太适用,这会导致NewIndex
为-1
。为了解决这个限制,我们需要进行一些额外的计算。
WeaponIndex = (ClampedNewIndex + WeaponCount) % WeaponCount
:这将向ClampedNewIndex
添加WeaponCount
以使其为正,并再次应用模以获得正确的限制索引,从而解决了问题。
return Weapons[WeaponIndex]
:这将返回计算出的WeaponIndex
索引位置的武器。
让我们看一个实际的例子,帮助您理解所有这些是如何工作的:
Weapons =
-
[0] 刀
-
[1] 手枪
-
[2] 霰丨弹丨枪
-
[3] 火箭发射器
Weapons.Num() = 4
。
假设WeaponIndex = 3
,Direction = 1
。
然后:
NewIndex
= WeaponIndex + Direction = 3 + 1 = 4
ClampedIndex
= NewIndex % WeaponCount = 4 % 4 = 0
WeaponIndex
= (ClampedIndex + WeaponCount) % WeaponCount = (0 + 4) % 4 = 0
在这个例子中,武器索引的起始值为3
(即火箭发射器),我们想要下一个武器(因为Direction
是1
)。进行计算,WeaponIndex
现在将是0
(即刀)。这是期望的行为,因为我们有 4 种武器,所以我们又回到了。在这种情况下,由于Direction
是1
,我们可以直接使用ClampedIndex
而不进行额外的计算。
让我们再次使用不同的值进行调试。
假设WeaponIndex = 0
,Direction = -1
:
NewIndex
= WeaponIndex + Direction = 0 + -1 = -1
ClampedIndex
= *NewIndex % WeaponCount *= -1 % 4 = -1
WeaponIndex
= (ClampedIndex + WeaponCount) % WeaponCount = (-1 + 4) % 4 = 3
在这个例子中,武器索引的起始值为 0(即刀),我们想要上一个武器(因为Direction
是-1
)。进行计算,WeaponIndex
现在将是 3(即火箭发射器)。这是期望的行为,因为我们有 4 种武器,所以我们又回到了 3。在这种特定情况下,NewIndex
为负数,所以我们不能只使用ClampedIndex
;我们需要进行额外的计算以获得正确的值。
练习 17.03:使用双向循环数组索引在枚举之间循环
在这个练习中,我们将使用Exercise17.02中的项目,即在虚幻引擎 4 编辑器中使用 C++枚举,并为循环武器添加两个新的动作映射。鼠标向上滚动将转到上一个武器类型,鼠标向下滚动将转到下一个武器类型。
以下步骤将帮助您完成练习:
- 首先,打开Exercise 17.02中的 Visual Studio 项目,即在虚幻引擎 4 编辑器中使用 C++枚举。
接下来,您将更新Enumerations.h
并添加一个宏,该宏将以非常方便的方式处理双向数组循环,如以下步骤所示。
- 打开
Enumerations.h
并添加GET_CIRCULAR_ARRAY_INDEX
宏,该宏将应用我们之前已经讨论过的模数公式:
#define GET_CIRCULAR_ARRAY_INDEX(Index, Count) (Index % Count + Count) % Count
- 打开
EnumerationsCharacter.h
并声明武器循环的新输入函数:
void OnPressedPreviousWeapon();
void OnPressedNextWeapon();
- 声明
CycleWeapons
函数,如下面的代码片段所示:
void CycleWeapons(int32 Direction);
- 打开
EnumerationsCharacter.cpp
并在SetupPlayerInputController
函数中绑定新的动作绑定:
PlayerInputComponent->BindAction("Previous Weapon", IE_Pressed, this, &AEnumerationsCharacter::OnPressedPreviousWeapon);
PlayerInputComponent->BindAction("Next Weapon", IE_Pressed, this, &AEnumerationsCharacter::OnPressedNextWeapon);
- 现在,实现新的输入函数,如下面的代码片段所示:
void AEnumerationsCharacter::OnPressedPreviousWeapon()
{
CycleWeapons(-1);
}
void AEnumerationsCharacter::OnPressedNextWeapon()
{
CycleWeapons(1);
}
在上述代码片段中,我们定义了处理“上一个武器”和“下一个武器”的动作映射的函数。每个函数使用CycleWeapons
函数,对于上一个武器使用方向-1
,对于下一个武器使用方向1
。
- 实现
CycleWeapons
函数,根据当前武器索引使用Direction
参数进行双向循环:
void AEnumerationsCharacter::CycleWeapons(int32 Direction)
{
const int32 WeaponIndex = ENUM_TO_INT32(Weapon);
const int32 AmmoCount = Ammo.Num();
const int32 NextRawWeaponIndex = WeaponIndex + Direction;
const int32 NextWeaponIndex = GET_CIRCULAR_ARRAY_INDEX(NextRawWeaponIndex , AmmoCount);
Weapon = (EWeaponType)NextWeaponIndex;
}
在上述代码片段中,我们实现了CycleWeapons
函数,该函数使用取模运算符根据提供的方向计算下一个有效的武器索引。
-
编译代码并运行编辑器。
-
转到“项目设置”,然后转到“引擎”,然后转到“输入”,并添加新的动作“绑定”:
图 17.10:添加上一个武器和下一个武器绑定
-
关闭“项目设置”。
-
现在,在单人模式下(一个客户端和禁用的专用服务器)中的“新编辑器窗口(PIE)”中进行游戏:
图 17.11:练习的最终结果
通过完成这个练习,您将能够使用鼠标滚轮在武器之间进行循环。如果您选择火箭发射器并使用鼠标滚轮向下滚动到下一个武器,它将返回到手枪。如果您使用鼠标滚轮向下滚动到上一个武器并选择手枪,它将返回到火箭发射器。
在下一个活动中,您将向我们在第十六章“多人游戏基础”中开始的多人 FPS 项目中添加武器和弹药的概念。
活动 17.01:向多人 FPS 游戏添加武器和弹药
在这个活动中,您将向我们在上一章活动中开始的多人 FPS 项目中添加武器和弹药的概念。您需要使用本章介绍的不同类型的 RPC 来完成这个活动。
以下步骤将帮助您完成这个活动:
-
从Activity 16.01“为多人 FPS 项目创建角色”中打开
MultiplayerFPS
项目。 -
创建一个名为
Upper Body
的新AnimMontage
插槽。 -
从
Activity17.01\Assets
文件夹导入动画(Pistol_Fire.fbx
、MachineGun_Fire.fbx
和Railgun_Fire.fbx
)到Content\Player\Animations
。
注意
Activity17.01\Assets
文件夹可以在我们的 GitHub 存储库中找到packt.live/2It4Plb
。
- 为
Pistol_Fire
、MachineGun_Fire
和Railgun_Fire
创建一个动画蒙太奇,并确保它们具有以下配置:
Blend In
时间为0.01
,Blend Out
时间为0.1
,并确保它使用Upper Body
插槽。
Blend In
时间为0.01
,Blend Out
时间为0.1
,并确保它使用“Upper Body”插槽。
Upper Body
插槽。
-
从
Activity17.01\Assets
文件夹导入SK_Weapon.fbx
、NoAmmo.wav
、WeaponChange.wav
和Hit.wav
到Content\Weapons
。 -
从
Activity17.01\Assets
导入Pistol_Fire_Sound.wav
到Content\Weapons\Pistol
并在Pistol_Fire
动画中使用AnimNotify
播放声音。 -
创建一个名为
M_Pistol
的简单绿色材质并将其放置在Content\Weapons\Pistol
上。 -
从
Activity17.01\Assets
导入MachineGun_Fire_Sound.wav
到Content\Weapons\MachineGun
并在MachineGun_Fire
动画中使用AnimNotify
播放声音。 -
创建一个名为
M_MachineGun
的简单红色材质并将其放置在Content\Weapons\MachineGun
上。 -
从
Activity17.01\Assets
导入Railgun_Fire_Sound.wav
到Content\Weapons\Railgun
,并在Railgun_Fire
动画中的AnimNotify
Play Sound 中使用它。 -
创建一个名为
M_Railgun
的简单的白色材质,并将其放置在Content\Weapons\Railgun
上。 -
编辑
SK_Mannequin
骨骼网格,并从hand_r
创建一个名为GripPoint
的插槽,相对位置(X=-10.403845,Y=6.0,Z=-3.124871)和相对旋转(X=0.0,Y=0.0,Z=90.0)。 -
在
Project Settings
中添加以下输入映射,使用第四章,玩家输入中获得的知识:
-
射击(动作映射):鼠标左键
-
上一个武器(动作映射):鼠标滚轮向上
-
下一个武器(动作映射):鼠标滚轮向下
-
手枪(动作映射):1
-
机关枪(动作映射):2
-
电磁炮(动作映射):3
-
在
MultiplayerFPS.h
中创建ENUM_TO_INT32(Enum)
宏,将枚举转换为int32
,并创建GET_CIRCULAR_ARRAY_INDEX(Index, Count)
,该宏使用双向循环数组索引将索引转换为在0
和-1
计数之间的索引。 -
创建一个名为
EnumTypes.h
的头文件,其中包含以下枚举:
EWeaponType:手枪,机关枪,电磁炮,最大
EWeaponFireMode:单发,自动
EAmmoType:子弹,弹丸,最大
-
创建一个 C++类
Weapon
,它继承自Actor
类,具有一个名为Mesh
的骨骼网格组件作为根组件。在变量方面,它存储名称、武器类型、弹药类型、射击模式、击中扫描范围、击中扫描伤害、射速、开火时使用的动画蒙太奇以及没有弹药时播放的声音。在功能方面,它需要能够开始射击(也需要停止射击,因为是自动射击模式),检查玩家是否能够射击。如果可以,它会在所有客户端上播放射击动画,并使用提供的长度在摄像机位置和方向上进行射线跟踪,以对其击中的角色造成伤害。如果没有弹药,它将仅在拥有客户端上播放声音。 -
编辑
FPSCharacter
以支持Fire
,Previous/Next Weapon
,Pistol
,Machine Gun
和Railgun
的新映射。在变量方面,它需要存储每种类型的弹药数量,当前装备的武器,所有武器类和生成的实例,击中另一个玩家时播放的声音,以及更换武器时的声音。在功能方面,它需要能够装备/循环/添加武器,管理弹药(添加、移除和获取),处理角色受到伤害时,在所有客户端上播放动画蒙太奇,并在拥有客户端上播放声音。 -
从
AWeapon
创建BP_Pistol
,将其放置在Content\Weapons\Pistol
上,并配置以下值:
-
骨骼网格:
Content\Weapons\SK_Weapon
-
材质:
Content\Weapons\Pistol\M_Pistol
-
名称:
手枪 Mk I
-
武器类型:
手枪
,弹药类型:子弹
,射击模式:自动
-
击中扫描范围:
9999.9
,击中扫描伤害:5.0
,射速:0.5
-
火焰动画蒙太奇:
Content\Player\Animations\Pistol_Fire_Montage
-
NoAmmoSound:
Content\Weapons\NoAmmo
- 从
AWeapon
创建BP_MachineGun
,将其放置在Content\Weapons\MachineGun
上,并配置以下值:
-
骨骼网格:
Content\Weapons\SK_Weapon
-
材质:
Content\Weapons\MachineGun\M_MachineGun
-
名称:
机关枪 Mk I
-
武器类型:
机关枪
,弹药类型:子弹
,射击模式:自动
-
击中扫描范围:
9999.9
,击中扫描伤害:5.0
,射速:0.1
-
火焰动画蒙太奇:
Content\Player\Animations\MachineGun_Fire_Montage
-
NoAmmoSound:
Content\Weapons\NoAmmo
- 从
AWeapon
创建BP_Railgun
,将其放置在Content\Weapons\Railgun
上,并配置以下值:
-
骨骼网格:
Content\Weapons\SK_Weapon
-
材质:
Content\Weapons\Railgun\M_Railgun
-
名称:电磁炮
Mk I
,武器类型:电磁炮
,弹药类型:弹丸
,射击模式:单发
-
命中扫描范围:
9999.9
,命中扫描伤害:100.0
,射速:1.5
-
开火动画蒙太奇:
Content\Player\Animations\Railgun_Fire_Montage
-
无弹药声音:
Content\Weapons\NoAmmo
- 使用以下值配置
BP_Player
:
-
武器类(索引 0:
BP_Pistol
,索引 1:BP_MachineGun
,索引 2:BP_Railgun
)。 -
命中声音:
Content\Weapons\Hit
。 -
武器切换声音:
Content\Weapons\WeaponChange
。 -
使网格组件阻止可见性通道,以便可以被武器的命中扫描击中。
-
编辑
ABP_Player
,使用Layered Blend Per Bone
节点,在spine_01
骨骼上启用Mesh Space Rotation Blend
,以便上半身动画使用上半身插槽。 -
编辑
UI_HUD
,使其在屏幕中央显示白色点状准星,并在生命和护甲指示器下显示当前武器和弹药数量:
图 17.12:活动的预期结果
结果应该是一个项目,其中每个客户端都将拥有带有弹药的武器,并且能够使用它们向其他玩家开火并造成伤害。您还可以通过使用1、2和3键以及使用鼠标滚轮向上和向下来选择武器。
注意
此活动的解决方案可在以下网址找到:https://packt.live/338jEBx。
总结
在本章中,您学习了如何使用 RPC 允许服务器和客户端在彼此上执行逻辑。我们还学习了在虚幻引擎 4 中如何使用UENUM
宏以及如何使用双向循环数组索引,这有助于您在两个方向上迭代数组,并在超出其索引限制时循环。
完成本章的活动后,您将拥有一个基本的可玩游戏,玩家可以互相射击和切换武器,但我们仍然可以添加更多内容,使其更加有趣。
在下一章中,我们将学习多人游戏中最常见的游戏框架类的实例存在的位置,以及了解我们尚未涵盖的 Player State 和 Game State 类。我们还将介绍一些在多人游戏中使用的游戏模式中的新概念,以及一些有用的通用内置功能。
第十八章:多人游戏中的游戏框架类
概述
在本章中,您将学习游戏框架类的实例在多人游戏中的存在位置。您还将学习如何使用游戏状态和玩家状态类,以及游戏模式中的一些新概念,包括比赛状态。我们还将介绍一些可用于不同类型游戏的有用内置功能。
在本章结束时,您将能够使用游戏状态和玩家状态类来存储关于游戏和特定玩家的信息,这些信息可以被任何客户端访问。您还将了解如何充分利用游戏模式类和其他相关功能。
介绍
在上一章中,我们介绍了远程过程调用,它允许服务器和客户端在彼此上执行远程函数。我们还介绍了枚举和双向循环数组索引。
在本章中,我们将看看最常见的游戏框架类,并了解它们在多人游戏环境中的实例存在位置。这很重要,这样您就知道在特定游戏实例中可以访问哪些实例。例如,只有服务器应该能够访问游戏模式实例,因此如果您在玩《堡垒之夜》,玩家就不应该能够访问它并修改游戏规则。
在本章中,我们还将涵盖游戏状态和玩家状态类。顾名思义,这些类存储有关游戏状态和每个玩家的信息。最后,在本书的末尾,我们将介绍游戏模式中的一些新概念,以及一些有用的内置功能。
我们将从多人游戏中游戏框架类的工作方式开始。
多人游戏中的游戏框架类
虚幻引擎 4 带有一个游戏框架,它是一组类,允许您更轻松地创建游戏。游戏框架通过提供内置的常见功能来实现这一点,这些功能在大多数游戏中都存在,例如定义游戏规则的方法(游戏模式),以及控制角色的方法(玩家控制器和 pawn/character 类)。在多人游戏环境中创建游戏框架类的实例时,它可以存在于服务器、客户端和拥有客户端,即其玩家控制器作为该实例的所有者的客户端。这意味着游戏框架类的实例将始终属于以下类别之一:
-
仅服务器:该类的实例将只存在于服务器。
-
服务器和客户端:该类的实例将存在于服务器和客户端。
-
服务器和拥有客户端:该类的实例将存在于服务器和拥有客户端。
-
仅拥有客户端:该类的实例只存在于拥有客户端。
看一下以下图表,显示了每个类别以及游戏框架中最常见类的目的:
图 18.1:最常见的游戏框架类分成类别
让我们更详细地了解前面图表中每个类:
-
游戏模式(仅服务器):游戏模式类定义游戏规则,其实例只能被服务器访问。如果客户端尝试访问它,实例将始终无效,以防止客户端更改游戏规则。
-
游戏状态(服务器和客户端):游戏状态类存储游戏的状态,其实例可以被服务器和客户端访问。游戏状态将在未来的主题中更深入地讨论。
-
Player State(服务器和客户端):Player State 类存储玩家的状态,其实例可以被服务器和客户端访问。Player State 将在未来的主题中更深入地介绍。
-
Pawn(服务器和客户端):Pawn 类是玩家的视觉表示,其实例可以被服务器和客户端访问。
-
使用
UGameplayStatics::GetPlayerController
函数并指定除0
以外的索引(将返回其玩家控制器),返回的实例将始终无效。这意味着服务器是唯一可以访问所有玩家控制器的地方。您可以通过调用AController::IsLocalController
函数来查找玩家控制器实例是否在其拥有客户端。 -
HUD(仅拥有客户端):HUD 类用作在屏幕上绘制基本形状和文本的即时模式。由于它用于 UI,因此其实例仅在拥有客户端上可用,因为服务器和其他客户端不需要知道它。
-
UMG 小部件(仅拥有客户端):UMG 小部件类用于在屏幕上显示复杂的 UI。由于它用于 UI,因此其实例仅在拥有客户端上可用,因为服务器和其他客户端不需要知道它。
为了帮助您理解这些概念,我们可以以 Dota 2 为例。游戏模式定义了游戏的不同阶段(英雄选择的前期游戏,实际游戏以及赢家的后期游戏),最终目标是摧毁对方团队的远古。由于这是对游戏玩法至关重要的类,客户端不能访问它:
-
游戏状态存储经过的时间、白天或黑夜、每个队伍的得分等,因此服务器和客户端需要能够访问它。
-
玩家状态存储玩家的名称、选择的英雄以及击杀/死亡/协助比率,因此服务器和客户端需要能够访问它。
-
角色将是英雄、信使、幻象等,由玩家控制,因此服务器和客户端需要能够访问它。
-
玩家控制器是将输入信息传递给受控制的 pawn 的组件,因此只有服务器和拥有客户端需要能够访问它。
-
UI 类(
HUD
和User
小部件)将在拥有客户端上显示所有信息,因此只需要在那里访问。
在下一个练习中,您将显示最常见的游戏框架类的实例值。
练习 18.01:显示游戏框架实例值
在这个练习中,我们将创建一个使用第三人称模板的新 C++项目,并添加以下内容:
-
在拥有客户端上,玩家控制器创建并添加到视口一个简单的 UMG 小部件,用于显示菜单实例的名称。
-
在
Tick
函数中,角色显示其自己实例的值(作为 pawn),以及是否具有游戏模式、游戏状态、玩家状态、玩家控制器和 HUD 的有效实例。
注意
如果需要,您可以回顾一下第一章,虚幻引擎介绍,以了解Tick
函数。
以下步骤将帮助您完成练习:
-
使用
C++
创建一个名为GFInstances
(如游戏框架实例)的新Third Person
模板项目,并将其保存在您选择的位置。创建项目后,它应该会打开编辑器以及 Visual Studio 解决方案。 -
在编辑器中,创建一个名为
GFInstancePlayerController
的新C++
类,该类派生自PlayerController
。等待编译结束,关闭编辑器,然后返回 Visual Studio。 -
打开
GFInstancesCharacter.h
文件,并声明Tick
函数的受保护覆盖:
virtual void Tick(float DeltaSeconds) override;
- 打开
GFInstancesCharacter.cpp
文件,并包括DrawDebugHelpers.h
和PlayerController.h
:
#include "DrawDebugHelpers.h"
#include "GameFramework/PlayerController.h"
- 实现
Tick
函数:
void AGFInstancesCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
}
- 获取游戏模式、游戏状态、玩家控制器和 HUD 的实例:
AGameModeBase* GameMode = GetWorld()->GetAuthGameMode();
AGameStateBase* GameState = GetWorld()->GetGameState();
APlayerController* PlayerController = Cast<APlayerController>(GetController());
AHUD* HUD = PlayerController != nullptr ? PlayerController- >GetHUD() : nullptr;
在前面的代码片段中,我们将游戏模式、游戏状态、玩家控制器和 HUD 的实例存储在单独的变量中,以便我们可以检查它们是否有效。
- 为每个游戏框架类创建一个字符串:
const FString GameModeString = GameMode != nullptr ? TEXT("Valid") : TEXT("Invalid");
const FString GameStateString = GameState != nullptr ? TEXT("Valid") : TEXT("Invalid");
const FString PlayerStateString = GetPlayerState() != nullptr ? TEXT("Valid") : TEXT("Invalid");
const FString PawnString = GetName();
const FString PlayerControllerString = PlayerController != nullptr ? TEXT("Valid") : TEXT("Invalid");
const FString HUDString = HUD != nullptr ? TEXT("Valid") : TEXT("Invalid");
在这里,我们创建字符串来存储角色的名称以及其他游戏框架实例是否有效。
- 在屏幕上显示每个字符串:
const FString String = FString::Printf(TEXT("Game Mode = %s\nGame State = %s\nPlayerState = %s\nPawn = %s\nPlayer Controller = %s\nHUD = %s"), *GameModeString, *GameStateString, *PlayerStateString, *PawnString, *PlayerControllerString, *HUDString);
DrawDebugString(GetWorld(), GetActorLocation(), String, nullptr, FColor::White, 0.0f, true);
在此代码片段中,我们打印了在前面的代码中创建的字符串,这些字符串指示了角色的名称以及其他游戏框架实例是否有效。
- 在我们可以继续使用
AGFInstancesPlayerController
类之前,我们需要告诉虚幻引擎我们想要使用 UMG 功能,以便能够使用UUserWidget
类。为此,我们需要打开GFInstances.Build.cs
并将UMG
添加到PublicDependencyModuleNames
字符串数组中,如下所示:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "UMG" });
如果尝试编译并从添加新模块中获得错误,则清理并重新编译项目。如果这样不起作用,请尝试重新启动您的 IDE。
- 打开
GFInstancesPlayerController.h
并添加保护变量以创建 UMG 小部件:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "GF Instance Player Controller")
TSubclassOf<UUserWidget> MenuClass;
UPROPERTY()
UUserWidget* Menu;
- 声明
BeginPlay
函数的受保护覆盖:
virtual void BeginPlay() override;
- 打开
GFInstancesPlayerController.cpp
并包括UserWidget.h
:
#include "Blueprint/UserWidget.h"
- 实现
BeginPlay
函数:
void AGFInstancePlayerController::BeginPlay()
{
Super::BeginPlay();
}
- 如果不是拥有客户端或菜单类无效,则中止函数:
if (!IsLocalController() || MenuClass == nullptr)
{
return;
}
- 创建小部件并将其添加到视口:
Menu = CreateWidget<UUserWidget>(this, MenuClass);
if (Menu != nullptr)
{
Menu->AddToViewport(0);
}
-
编译并运行代码。
-
在
Content Browser
中,转到Content
文件夹,创建一个名为UI
的新文件夹,并打开它。 -
创建一个名为
UI_Menu
的新小部件蓝图并打开它。 -
在根画布面板中添加一个名为
tbText
的Text Block
并通过在详细面板顶部的其名称旁边点击复选框Is Variable
来将其设置为变量。 -
将
tbText
设置为Size To Content
为true
。 -
转到
Graph
部分,在Event Graph
中以以下方式实现Event Construct
:
图 18.2:显示 UI_Menu 实例名称的 Event Construct
注意
您可以在以下链接找到前面的完整分辨率截图以便更好地查看:packt.live/38wvSr5
。
-
保存并关闭
UI_Menu
。 -
转到
Content
文件夹并创建一个名为BP_PlayerController
的蓝图,该蓝图派生自GFInstancesPlayerController
。 -
打开
BP_PlayerController
并设置Menu
Class
以使用UI_Menu
。 -
保存并关闭
BP_PlayerController
。 -
转到
Content
文件夹并创建一个名为BP_GameMode
的蓝图,该蓝图派生自GFInstancesGameMode
。 -
打开
BP_GameMode
并设置Player Controller
Class
以使用BP_PlayerController
。 -
保存并关闭
BP_GameMode
。 -
转到
项目设置
并从左侧面板选择地图和模式
,该面板位于项目
类别中。 -
将
Default
GameMode
设置为使用BP_GameMode
。 -
关闭
项目设置
。
最后,您可以测试项目。
-
运行代码并等待编辑器完全加载。
-
转到
多人游戏选项
并将客户端数量设置为2
。 -
将窗口大小设置为
800x600
。 -
在
新编辑器窗口(PIE)
中播放。
完成此练习后,您将能够在每个客户端上进行游戏。您会注意到角色是否显示游戏模式、游戏状态、玩家状态、玩家控制器和 HUD 的实例是否有效。它还显示了角色实例的名称。
现在,让我们分析在服务器
和客户端 1
窗口中显示的值。让我们先从服务器
窗口开始。
服务器窗口
在“服务器”窗口中,您可以看到“服务器角色”的值,在背景中,您可以看到“客户端 1 角色”的值。您应该能够在左上角看到“服务器角色”,“客户端 1 角色”和UI_Menu
UMG 小部件。UMG 小部件实例仅为“服务器角色”的玩家控制器创建,因为它是该窗口中实际控制角色的唯一玩家控制器。
让我们首先分析“服务器角色”的值。
服务器角色
这是监听服务器的角色,它是一个同时具有客户端的服务器,也可以玩游戏。显示在该角色上的值如下:
-
游戏模式=有效,因为游戏模式实例只存在于服务器上,这是当前游戏实例。
-
游戏状态=有效,因为游戏状态实例存在于客户端和服务器上,这是当前游戏实例。
-
玩家状态=有效,因为玩家状态实例存在于客户端和服务器上,这是当前游戏实例。
-
Pawn=ThirdPersonCharacter_2,因为 pawn 实例存在于客户端和服务器上,这是当前游戏实例。
-
玩家控制器=有效,因为玩家控制器实例存在于拥有的客户端和服务器上,这是当前游戏实例。
-
HUD=有效,因为 HUD 实例只存在于拥有的客户端上,这是情况。
接下来,我们将在同一窗口中查看“客户端 1 角色”。
客户端 1 角色
这是“客户端 1”正在控制的角色。显示在该角色上的值如下:
-
游戏模式=有效,因为游戏模式实例只存在于服务器上,这是当前游戏实例。
-
游戏状态=有效,因为游戏状态实例存在于客户端和服务器上,这是当前游戏实例。
-
玩家状态=有效,因为玩家状态实例存在于客户端和服务器上,这是当前游戏实例。
-
Pawn=ThirdPersonCharacter_0,因为 pawn 实例存在于客户端和服务器上,这是当前游戏实例。
-
玩家控制器=有效,因为玩家控制器实例存在于拥有的客户端和服务器上,这是当前游戏实例。
-
HUD=无效,因为 HUD 实例只存在于拥有的客户端上,这不是情况。
客户端 1 窗口
在“客户端 1”窗口中,您可以看到“客户端 1 角色”的值,在背景中,您可以看到“服务器角色”的值。您应该能够在左上角看到“客户端 1 角色”,“服务器角色”和UI_Menu
UMG 小部件。UMG 小部件实例仅为“客户端 1 角色”的玩家控制器创建,因为它是该窗口中实际控制角色的唯一玩家控制器。
让我们首先分析“客户端 1 角色”的值。
客户端 1 角色
这是“客户端 1”正在控制的角色。显示在该角色上的值如下:
-
游戏模式=无效,因为游戏模式实例只存在于服务器上,这不是当前游戏实例。
-
游戏状态=有效,因为游戏状态实例存在于服务器和客户端上,这是当前游戏实例。
-
玩家状态=有效,因为玩家状态实例存在于服务器和客户端上,这是当前游戏实例。
-
Pawn=ThirdPersonCharacter_0,因为 pawn 实例存在于服务器和客户端上,这是当前游戏实例。
-
玩家控制器=有效,因为玩家控制器实例存在于服务器和拥有的客户端上,这是当前游戏实例。
-
HUD=有效,因为 HUD 实例只存在于拥有的客户端上,这是情况。
接下来,我们将在同一窗口中查看“服务器角色”。
服务器角色
这是监听服务器控制的角色。显示在该角色上的值如下:
-
游戏模式 = 无效,因为游戏模式实例仅存在于服务器,而不是当前游戏实例。
-
Game State = 有效,因为游戏状态实例存在于服务器和客户端,即当前游戏实例。
-
Player State = 有效,因为玩家状态实例存在于服务器和客户端,即当前游戏实例。
-
Pawn = ThirdPersonCharacter_2,因为 pawn 实例存在于服务器和客户端,即当前游戏实例。
-
Player Controller = 无效,因为玩家控制器实例存在于服务器和拥有的客户端,而不是当前游戏实例。
-
HUD = 无效,因为 HUD 实例仅存在于拥有的客户端,这不是情况。
通过完成这个练习,您应该更好地理解游戏框架类的每个实例存在和不存在的位置。接下来,我们将介绍玩家状态和游戏状态类,以及游戏模式和有用的内置功能的一些额外概念。
游戏模式、玩家状态和游戏状态
到目前为止,我们已经涵盖了游戏框架中的大部分重要类,包括游戏模式、玩家控制器和 pawn。在本章中,我们将涵盖玩家状态、游戏状态以及游戏模式的一些额外概念,以及一些有用的内置功能。
游戏模式
我们已经讨论了游戏模式及其工作原理,但还有一些概念尚未涵盖。
构造函数
要设置默认类值,可以使用构造函数如下:
ATestGameMode::ATestGameMode()
{
DefaultPawnClass = AMyCharacter::StaticClass();
PlayerControllerClass = AMyPlayerController::StaticClass();
PlayerStateClass = AMyPlayerState::StaticClass();
GameStateClass = AMyGameState::StaticClass();
}
前面的代码允许您指定在使用此游戏模式时生成 pawn、player controller、player state 和 game state 时要使用的类。
获取游戏模式实例
如果要访问游戏模式实例,您需要使用以下代码从GetWorld
函数中获取它:
AGameModeBase* GameMode = GetWorld()->GetAuthGameMode();
前面的代码允许您访问当前游戏模式实例,以便您可以运行函数并查询某些变量的值。您必须确保仅在服务器上调用它,因为出于安全原因,这将在客户端上无效。
比赛状态
到目前为止,我们只使用了AGameModeBase
类,这是框架中最基本的游戏模式类,虽然对于某些类型的游戏来说已经足够了,但在某些情况下,您可能需要更多的功能。例如,如果我们想要做一个大厅系统,只有当所有玩家标记为准备好时比赛才开始。使用AGameModeBase
类无法实现这个例子。对于这些情况,最好使用AGameMode
类,它是AGameModeBase
的子类,通过使用比赛状态来支持多人比赛。比赛状态的工作方式是使用状态机,该状态机在给定时间内只能处于以下状态之一:
-
EnteringMap
:这是当世界仍在加载并且角色尚未开始计时时的起始状态。一旦世界加载完成,它将转换到WaitingToStart
状态。 -
WaitingToStart
:当世界加载完成并且角色正在计时时,设置此状态,尽管玩家的 pawn 尚未生成,因为游戏尚未开始。当状态机进入此状态时,它将调用HandleMatchIsWaitingToStart
函数。如果ReadyToStartMatch
函数返回true
,或者在代码中的某个地方调用了StartMatch
函数,状态机将转换到InProgress
状态。 -
InProgress
:这是实际游戏发生的状态。当状态机进入此状态时,它将为玩家生成 pawn,对世界中的所有角色调用BeginPlay
,并调用HandleMatchHasStarted
函数。如果ReadyToEndMatch
函数返回true
或者在代码中的某个地方调用了EndMatch
函数,状态机将转换到WaitingPostMatch
状态。 -
WaitingPostMatch
:比赛结束时设置此状态。当状态机进入此状态时,它将调用HandleMatchHasEnded
函数。在此状态下,角色仍在进行计时,但新玩家无法加入。当开始卸载世界时,它将转换到LeavingMap
状态。 -
LeavingMap
:在卸载世界时设置此状态。当状态机进入此状态时,它将调用HandleLeavingMap
函数。当开始加载新级别时,状态机将转换到EnteringMap
状态。 -
Aborted
:这是一个失败状态,只能通过调用AbortMatch
函数来设置,该函数用于标记某些阻止比赛进行的错误。
为了帮助您更好地理解这些概念,我们可以再次以 Dota 2 为例:
-
EnteringMap
:地图加载时,状态机将处于此状态。 -
WaitingToStart
:一旦地图加载并且玩家正在选择他们的英雄,状态机将处于此状态。ReadyToStartMatch
函数将检查所有玩家是否已选择他们的英雄;如果是,则比赛可以开始。 -
InProgress
:当游戏实际进行时,状态机将处于此状态。玩家控制他们的英雄进行农场和与其他玩家的战斗。ReadyToEndMatch
函数将不断检查每个远古生命值,以查看它们是否被摧毁;如果是,则比赛结束。 -
WaitingPostMatch
:游戏结束时,状态机将处于此状态,您将看到被摧毁的远古和每个玩家的最终得分。 -
LeavingMap
:在卸载地图时,状态机将处于此状态。 -
Aborted
:如果其中一个玩家在初始阶段未能连接,因此中止整个比赛,状态机将处于此状态。
重新生成玩家
当玩家死亡并且您想要重新生成它时,通常有两种选择。第一种选择是重用相同的 pawn 实例,手动将其状态重置为默认值,并将其传送到重生位置。第二个选择是销毁 pawn 并生成一个新的,它将已经重置其状态。如果您喜欢后一种选择,那么AGameModeBase::RestartPlayer
函数将处理为某个玩家控制器生成一个新的 pawn 实例的逻辑,并将其放置在玩家起始点。
需要考虑的一件重要事情是,该函数仅在玩家控制器尚未拥有 pawn 的情况下生成新的 pawn 实例,因此在调用RestartPlayer
之前,请务必销毁受控 pawn。
看一下以下示例:
void ATestGameMode::OnDeath(APlayerController* VictimController)
{
if(VictimController == nullptr)
{
return;
}
APawn* Pawn = VictimController->GetPawn();
if(Pawn != nullptr)
{
Pawn->Destroy();
}
RestartPlayer(VicitimController);
}
在上述代码中,我们有OnDeath
函数,它获取死亡玩家的玩家控制器,销毁其受控 pawn,并调用RestartPlayer
函数在玩家起始点生成一个新实例。默认情况下,使用的玩家起始点 actor 将始终与第一次生成玩家的玩家相同。如果要使函数在随机玩家起始点生成,则需要重写AGameModeBase::ShouldSpawnAtStartSpot
函数并强制其返回false
,如下所示:
bool ATestGameMode::ShouldSpawnAtStartSpot(AController* Player)
{
return false;
}
上述代码将使游戏模式使用随机玩家起始点,而不是始终使用相同的。
注意
有关游戏模式的更多信息,请访问docs.unrealengine.com/en-US/Gameplay/Framework/GameMode/#gamemodes
和docs.unrealengine.com/en-US/API/Runtime/Engine/GameFramework/AGameMode/index.html
。
玩家状态
玩家状态类存储玩家的状态,例如当前得分、击杀/死亡和拾取的金币。它主要用于多人模式,以存储其他客户端需要了解的有关玩家的信息,因为它们无法访问其玩家控制器。最常用的内置变量是PlayerName
、Score
和Ping
,分别提供玩家的名称、得分和延迟。
多人射击游戏中的记分牌条目是使用玩家状态的一个很好的例子,因为每个客户端都需要知道所有玩家的名称、击杀/死亡和延迟。玩家状态实例可以通过以下方式访问:
AController::PlayerState
此变量与控制器相关联的玩家状态,只能由服务器和拥有的客户端访问。以下示例将演示如何使用该变量:
APlayerState* PlayerState = Controller->PlayerState;
AController::GetPlayerState()
该函数返回与控制器相关联的玩家状态,只能由服务器和拥有的客户端访问。该函数还有一个模板版本,因此您可以将其转换为自定义的玩家状态类。以下示例将演示如何使用该函数的默认版本和模板版本。
// Default version
APlayerState* PlayerState = Controller->GetPlayerState();
// Template version
ATestPlayerState* MyPlayerState = Controller->GetPlayerState<ATestPlayerState>();
APawn::GetPlayerState()
该函数返回与控制器相关联的玩家状态,可以由服务器和客户端访问。该函数还有一个模板版本,因此您可以将其转换为自定义的玩家状态类。以下示例将演示如何使用该函数的默认版本和模板版本:
// Default version
APlayerState* PlayerState = Pawn->GetPlayerState();
// Template version
ATestPlayerState* MyPlayerState = Pawn- >GetPlayerState<ATestPlayerState>();
上述代码演示了您可以使用GetPlayerState
函数的两种方式。您可以使用默认的APlayerState
版本,也可以使用自动为您转换的模板版本。
AGameState::PlayerArray
此变量存储每个玩家的玩家状态实例,可以在服务器和客户端上访问。以下示例将演示如何使用此变量:
TArray<APlayerState*> PlayerStates = GameState->PlayerArray;
为了帮助您更好地理解这些概念,我们可以再次以 Dota 2 为例。玩家状态至少应具有以下变量:
名称:玩家的名称
英雄:所选英雄
生命值:英雄的生命值
法力:英雄的法力
统计:英雄统计
等级:英雄当前所在的等级
击杀/死亡/助攻:玩家的击杀/死亡/助攻比例
注意
有关玩家状态的更多信息,请访问docs.unrealengine.com/en-US/API/Runtime/Engine/GameFramework/APlayerState/index.html
。
游戏状态
游戏状态类存储游戏的状态,包括比赛经过的时间和赢得比赛所需的得分。它主要用于多人模式,以存储其他客户端需要了解的有关游戏的信息,因为它们无法访问游戏模式。最常用的变量是PlayerArray
,它是一个包含每个连接客户端的玩家状态的数组。多人射击游戏中的记分牌是使用游戏状态的一个很好的例子,因为每个客户端都需要知道赢得比赛所需的击杀数,以及每个玩家的名称和延迟。
游戏状态实例可以通过以下方式访问:
UWorld::GetGameState()
此函数返回与世界关联的游戏状态,并且可以在服务器和客户端上访问。此函数还有一个模板化版本,因此您可以将其转换为自己的自定义游戏状态类。以下示例将演示如何使用此函数的默认和模板版本:
// Default version
AGameStateBase* GameState = GetWorld()->GetGameState();
// Template version
AMyGameState* MyGameState = GetWorld()->GetGameState<AMyGameState>();
AGameModeBase::GameState
此变量具有与游戏模式关联的游戏状态,并且只能在服务器上访问。以下示例将演示如何使用该变量:
AGameStateBase* GameState = GameMode->GameState;
AGameModeBase::GetGameState()
此函数返回与游戏模式关联的游戏状态,只能在服务器上访问。此函数还有一个模板化版本,因此您可以将其转换为自己的自定义游戏状态类。以下示例将演示如何使用此函数的默认和模板版本:
// Default version
AGameStateBase* GameState = GameMode->GetGameState<AGameStateBase>();
// Template version
AMyGameState* MyGameState = GameMode->GetGameState<AMyGameState>();
为了帮助您更好地理解这些概念,我们可以再次以 Dota 2 为例。游戏状态将具有以下变量:
Elapsed Time: 比赛进行了多长时间
Radiant Kills: Radiant 团队杀死了多少 Dire 英雄
Dire Kills: Dire 团队杀死了多少 Radiant 英雄
Day/Night Timer: 用于确定是白天还是黑夜
注意
有关游戏状态的更多信息,请访问docs.unrealengine.com/en-US/Gameplay/Framework/GameMode/#gamestate
和docs.unrealengine.com/en-US/API/Runtime/Engine/GameFramework/AGameState/index.html
。
有用的内置功能
Unreal Engine 4 自带许多有用的功能。以下是一些函数和组件的示例,在开发游戏时将会很有用:
AActor::EndPlay(const EEndPlayReason::Type EndPlayReason) void
当角色停止播放时调用此函数,这与BeginPlay
函数相反。您有EndPlayReason
参数,它告诉您角色停止播放的原因(如果被销毁,如果您停止了 PIE 等)。看下面的例子,它打印到屏幕上角色停止播放的事实:
void ATestActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
const FString String = FString::Printf(TEXT(«The actor %s has just stopped playing"), *GetName());
GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Red, String);
}
ACharacter::Landed(const FHitResult& Hit) void
当玩家从空中着陆时调用此函数。看下面的例子,当玩家着陆时播放声音:
void ATestCharacter::Landed(const FHitResult& Hit)
{
Super::Landed(Hit);
UGameplayStatics::PlaySound2D(GetWorld(), LandSound);
}
UWorld::ServerTravel(const FString& FURL, bool bAbsolute, bool bShouldSkipGameNotify)
此函数将使服务器加载新地图并将所有连接的客户端一起带来。这与使用其他加载地图的方法不同,例如UGameplayStatics::OpenLevel
函数,因为它不会带上客户端;它只会在服务器上加载地图并断开客户端。
需要考虑的一件重要事情是,服务器旅行只在打包版本中正常工作,因此在编辑器中游玩时不会带上客户端。看下面的例子,它获取当前地图名称并使用服务器旅行重新加载它并带上连接的客户端:
void ATestGameModeBase::RestartMap()
{
const FString URL = GetWorld()->GetName();
GetWorld()->ServerTravel(URL, false, false);
}
TArray::Sort(const PREDICATE_CLASS& Predicate) void
TArray
数据结构带有Sort
函数,它允许您使用返回值A
是否应该首先排序,然后是值B
的lambda
函数来对数组的值进行排序。看下面的例子,它将整数数组从最小值排序到最大值:
void ATestActor::SortValues()
{
TArray<int32> SortTest;
SortTest.Add(43);
SortTest.Add(1);
SortTest.Add(23);
SortTest.Add(8);
SortTest.Sort([](const int32& A, const int32& B) { return A < B; });
}
上述代码将对SortTest
数组进行排序,值为[43, 1, 23, 8],从最小到最大[1, 8, 23, 43]。
AActor::FellOutOfWorld(const UDamageType& DmgType) void
在虚幻引擎 4 中,有一个称为Kill Z
的概念,它是在Z
中的某个值上的一个平面(在“世界设置”面板中设置),如果一个角色低于该Z
值,它将调用FellOutOfWorld
函数,默认情况下,销毁角色。看一下以下示例,它在屏幕上打印出角色掉出世界的事实:
void AFPSCharacter::FellOutOfWorld(const UDamageType& DmgType)
{
Super::FellOutOfWorld(DmgType);
const FString String = FString::Printf(TEXT("The actor %s has fell out of the world"), *GetName());
GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Red, String);
}
URotatingMovementComponent
这个组件会以每个轴上的一定速率沿着时间旋转拥有的角色,速率由RotationRate
变量定义。要使用它,您需要包含以下头文件:
#include "GameFramework/RotatingMovementComponent.h"
声明组件变量:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Test Actor")
URotatingMovementComponent* RotatingMovement;
最后,在角色构造函数中初始化它,如下所示:
RotatingMovement = CreateDefaultSubobject <URotatingMovementComponent>("Rotating Movement");
RotatingMovement->RotationRate = FRotator(0.0, 90.0f, 0);
在上述代码中,RotationRate
被设置为在“偏航”轴上每秒旋转90
度。
练习 18.02:制作一个简单的多人拾取游戏
在这个练习中,我们将创建一个使用第三人称模板的新 C++项目,并添加以下内容:
-
在拥有客户端上,玩家控制器创建并添加到视口一个 UMG 小部件,对于每个玩家,按从高到低排序显示分数以及收集了多少拾取物。
-
创建一个简单的拾取物角色类,为拾取到它的玩家提供 10 分。拾取物还会在“偏航”轴上每秒旋转 90 度。
-
将
Kill Z
设置为-500
,并使玩家在从世界中掉落时重生并失去 10 分。 -
当没有更多的拾取物可用时,游戏将结束。游戏结束后,所有角色将被销毁,并且在 5 秒后,服务器将进行服务器旅行调用以重新加载相同的地图并带上连接的客户端。
以下步骤将帮助您完成练习:
-
使用 C++创建一个名为
Pickups
的新“第三人称”模板项目,并将其保存到您选择的位置。 -
项目创建后,应该打开编辑器以及 Visual Studio 解决方案。
现在,让我们创建我们将要使用的新的 C++类:
-
创建一个从
Actor
派生的Pickup
类。 -
创建一个从
GameState
派生的PickupsGameState
类。 -
创建一个从
PlayerState
派生的PickupsPlayerState
类。 -
创建一个从
PlayerController
派生的PickupsPlayerController
类。 -
关闭编辑器并打开 Visual Studio。
接下来,让我们来处理Pickup
类。
-
打开
Pickup.h
并清除所有现有函数。 -
声明受保护的
Static Mesh
组件称为Mesh
:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Pickup")
UStaticMeshComponent* Mesh;
- 声明受保护的旋转运动组件称为
RotatingMovement
:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Pickup")
class URotatingMovementComponent* RotatingMovement;
- 声明受保护的
PickupSound
变量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickup")
USoundBase* PickupSound;
- 声明受保护的构造函数和
BeginPlay
重写:
APickup();
virtual void BeginPlay() override;
- 声明受保护的
OnBeginOverlap
函数:
UFUNCTION()
void OnBeginOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& Hit);
- 打开
Pickup.cpp
并包括PickupsCharacter.h
、PickupsGameState.h
、StaticMeshComponent.h
和RotatingMovementComponent.h
:
#include "PickupsCharacter.h"
#include "PickupsGameState.h"
#include "Components/StaticMeshComponent.h"
#include "GameFramework/RotatingMovementComponent.h"
- 在构造函数中,将“静态网格”组件初始化为与所有内容重叠,并在重叠时调用
OnBeginOverlap
函数:
Mesh = CreateDefaultSubobject<UStaticMeshComponent>("Mesh");
Mesh->SetCollisionProfileName("OverlapAll");
RootComponent = Mesh;
- 仍然在构造函数中,初始化旋转运动组件,使其在“偏航”轴上每秒旋转
90
度:
RotatingMovement = CreateDefaultSubobject <URotatingMovementComponent>("Rotating Movement");
RotatingMovement->RotationRate = FRotator(0.0, 90.0f, 0);
- 为了完成构造函数,启用复制并禁用
Tick
函数:
bReplicates = true;
PrimaryActorTick.bCanEverTick = false;
- 实现
BeginPlay
函数,将开始重叠事件绑定到OnBeginOverlap
函数:
void APickup::BeginPlay()
{
Super::BeginPlay();
Mesh->OnComponentBeginOverlap.AddDynamic(this, &APickup::OnBeginOverlap);
}
- 实现
OnBeginOverlap
函数,检查角色是否有效并具有权限,在游戏状态上移除拾取物,在拥有客户端上播放拾取声音,添加10
分和拾取物给角色。完成所有这些后,拾取物将销毁自身。
void APickup::OnBeginOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& Hit)
{
APickupsCharacter* Character = Cast<APickupsCharacter>(OtherActor);
if (Character == nullptr || !HasAuthority())
{
return;
}
APickupsGameState* GameState = Cast<APickupsGameState>(GetWorld()->GetGameState());
if (GameState != nullptr)
{
GameState->RemovePickup();
}
Character->ClientPlaySound2D(PickupSound);
Character->AddScore(10);
Character->AddPickup();
Destroy();
}
接下来,我们将处理PickupsGameState
类。
- 打开
PickupsGameState.h
并声明受保护的复制整数变量PickupsRemaining
,告诉所有客户端关卡中剩余多少拾取物:
UPROPERTY(Replicated, BlueprintReadOnly)
int32 PickupsRemaining;
- 声明受保护的
BeginPlay
函数的重写:
virtual void BeginPlay() override;
- 声明受保护的
GetPlayerStatesOrderedByScore
函数:
UFUNCTION(BlueprintCallable)
TArray<APlayerState*> GetPlayerStatesOrderedByScore() const;
- 实现公共的
RemovePickup
函数,该函数从PickupsRemaining
变量中移除一个道具:
void RemovePickup() { PickupsRemaining--; }
- 实现公共的
HasPickups
函数,该函数返回是否仍有剩余的道具:
bool HasPickups() const { return PickupsRemaining > 0; }
- 打开
PickupsGameState.cpp
并包括Pickup.h
,GameplayStatics.h
,UnrealNetwork.h
和PlayerState.h
:
#include "Pickup.h"
#include "Kismet/GameplayStatics.h"
#include "Net/UnrealNetwork.h"
#include "GameFramework/PlayerState.h"
- 实现
GetLifetimeReplicatedProps
函数,并使PickupRemaining
变量复制到所有客户端:
void APickupsGameState::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(APickupsGameState, PickupsRemaining);
}
- 实现
BeginPlay
覆盖函数,并通过获取世界中的所有道具来设置PickupsRemaining
的值:
void APickupsGameState::BeginPlay()
{
Super::BeginPlay();
TArray<AActor*> Pickups;
UGameplayStatics::GetAllActorsOfClass(this, APickup::StaticClass(), Pickups);
PickupsRemaining = Pickups.Num();
}
- 实现
GetPlayerStatesOrderedByScore
函数,该函数复制PlayerArray
变量并对其进行排序,以便得分最高的玩家首先出现:
TArray<APlayerState*> APickupsGameState::GetPlayerStatesOrderedByScore() const
{
TArray<APlayerState*> PlayerStates(PlayerArray);
PlayerStates.Sort([](const APlayerState& A, const APlayerState& B) { return A.Score > B.Score; });
return PlayerStates;
}
接下来,让我们来处理PickupsPlayerState
类。
- 打开
PickupsPlayerState.h
,并声明受保护的复制整数变量Pickups
,该变量表示玩家收集了多少个道具:
UPROPERTY(Replicated, BlueprintReadOnly)
int32 Pickups;
- 实现公共的
AddPickup
函数,该函数将一个道具添加到Pickups
变量:
void AddPickup() { Pickups++; }
- 打开
PickupsPlayerState.cpp
并包括UnrealNetwork.h
:
#include "Net/UnrealNetwork.h"
- 实现
GetLifetimeReplicatedProps
函数,并使Pickups
变量复制到所有客户端:
void APickupsPlayerState::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(APickupsPlayerState, Pickups);
}
接下来,让我们来处理PickupsPlayerController
类。
- 打开
PickupsPlayerController.h
并声明受保护的ScoreboardMenuClass
变量,该变量允许我们选择用于记分牌的 UMG 小部件:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickup Player Controller")
TSubclassOf<class UUserWidget> ScoreboardMenuClass;
- 声明受保护的
ScoreboardMenu
变量,该变量存储我们在BeginPlay
函数中创建的记分牌 UMG 小部件实例:
UPROPERTY()
class UUserWidget* ScoreboardMenu;
- 声明
BeginPlay
函数的受保护覆盖:
virtual void BeginPlay() override;
- 打开
PickupsPlayerController.cpp
并包括UserWidget.h
:
#include "Blueprint/UserWidget.h"
- 实现
BeginPlay
覆盖函数,对于拥有的客户端,创建并将记分牌 UMG 小部件添加到视口:
void APickupsPlayerController::BeginPlay()
{
Super::BeginPlay();
if (!IsLocalController() || ScoreboardMenuClass == nullptr)
{
return;
}
ScoreboardMenu = CreateWidget<UUserWidget>(this, ScoreboardMenuClass);
if (ScoreboardMenu != nullptr)
{
ScoreboardMenu->AddToViewport(0);
}
}
现在,让我们编辑PickupsGameMode
类。
- 打开
PickupsGameMode.h
并用GameMode.h
替换GameModeBase.h
的include
:
#include "GameFramework/GameMode.h"
- 使该类从
AGameMode
派生而不是AGameModeBase
:
class APickupsGameMode : public AGameMode
- 声明受保护的游戏状态变量
MyGameState
,该变量保存APickupsGameState
类的实例:
UPROPERTY()
class APickupsGameState* MyGameState;
-
将构造函数移动到受保护区域。
-
声明
BeginPlay
函数的受保护覆盖:
virtual void BeginPlay() override;
- 声明
ShouldSpawnAtStartSpot
函数的受保护覆盖:
virtual bool ShouldSpawnAtStartSpot(AController* Player) override;
- 声明游戏模式的比赛状态函数的受保护覆盖:
virtual void HandleMatchHasStarted() override;
virtual void HandleMatchHasEnded() override;
virtual bool ReadyToStartMatch_Implementation() override;
virtual bool ReadyToEndMatch_Implementation() override;
- 声明受保护的
RestartMap
函数:
void RestartMap();
- 打开
PickupsGameMode.cpp
并包括GameplayStatics.h
,PickupGameState.h
,Engine/World.h
,TimerManager.h
和Engine.h
:
#include "Kismet/GameplayStatics.h"
#include "PickupsGameState.h"
#include "Engine/World.h"
#include "Engine/Public/TimerManager.h"
#include "Engine/Engine.h"
- 实现
BeginPlay
覆盖函数,该函数存储APickupGameState
实例:
void APickupsGameMode::BeginPlay()
{
Super::BeginPlay();
MyGameState = GetGameState<APickupsGameState>();
}
- 实现
ShouldSpawnAtStartSpot
覆盖函数,该函数指示我们希望玩家重新生成在一个随机的玩家起始点上,而不总是在同一个上:
bool APickupsGameMode::ShouldSpawnAtStartSpot (AController* Player)
{
return false;
}
- 实现
HandleMatchHasStarted
覆盖函数,该函数向屏幕打印信息,通知玩家比赛已经开始:
void APickupsGameMode::HandleMatchHasStarted()
{
Super::HandleMatchHasStarted();
GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, "The game has started!");
}
- 实现
HandleMatchHasEnded
覆盖函数,该函数向屏幕打印信息,通知玩家比赛已经结束,销毁所有角色,并安排一个计时器来重新开始地图:
void APickupsGameMode::HandleMatchHasEnded()
{
Super::HandleMatchHasEnded();
GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Red, "The game has ended!");
TArray<AActor*> Characters;
UGameplayStatics::GetAllActorsOfClass(this, APickupsCharacter::StaticClass(), Characters);
for (AActor* Character : Characters)
{
Character->Destroy();
}
FTimerHandle TimerHandle;
GetWorldTimerManager().SetTimer(TimerHandle, this, &APickupsGameMode::RestartMap, 5.0f);
}
- 实现
ReadyToStartMatch_Implementation
覆盖函数,该函数指示比赛可以立即开始:
bool APickupsGameMode::ReadyToStartMatch_Implementation()
{
return true;
}
- 实现
ReadyToEndMatch_Implementation
覆盖函数,该函数指示当游戏状态没有剩余道具时比赛结束:
bool APickupsGameMode::ReadyToEndMatch_Implementation()
{
return MyGameState != nullptr && !MyGameState->HasPickups();
}
- 实现
RestartMap
函数,该函数指示服务器前往相同的级别并带着所有客户端一起(仅在打包版本中):
void APickupsGameMode::RestartMap()
{
GetWorld()->ServerTravel(GetWorld()->GetName(), false, false);
}
现在,让我们编辑PickupsCharacter
类。
- 打开
PickupsCharacter.h
并声明下落和着陆的受保护声音变量:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickups Character")
USoundBase* FallSound;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickups Character")
USoundBase* LandSound;
- 声明受保护的
override
函数:
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void Landed(const FHitResult& Hit) override;
virtual void FellOutOfWorld(const UDamageType& DmgType) override;
- 声明添加分数和道具到玩家状态的公共函数:
void AddScore(const float Score);
void AddPickup();
- 声明在拥有的客户端上播放声音的公共客户端 RPC:
UFUNCTION(Client, Unreliable)
void ClientPlaySound2D(USoundBase* Sound);
- 打开
PickupsCharacter.cpp
并包括PickupsPlayerState.h
,GameMode.h
和GameplayStatics.h
:
#include "PickupsPlayerState.h"
#include "GameFramework/GameMode.h"
#include "Kismet/GameplayStatics.h"
- 实现
EndPlay
覆盖函数,如果角色被销毁,则播放跌落声音:
void APickupsCharacter::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
if (EndPlayReason == EEndPlayReason::Destroyed)
{
UGameplayStatics::PlaySound2D(GetWorld(), FallSound);
}
}
- 实现
Landed
覆盖函数,该函数播放着陆声音:
void APickupsCharacter::Landed(const FHitResult& Hit)
{
Super::Landed(Hit);
UGameplayStatics::PlaySound2D(GetWorld(), LandSound);
}
- 实现
FellOutOfWorld
覆盖函数,该函数存储控制器,从分数中减去10
分,销毁角色(使控制器无效),并告诉游戏模式使用先前的控制器重新启动玩家:
void APickupsCharacter::FellOutOfWorld(const UDamageType& DmgType)
{
AController* PreviousController = Controller;
AddScore(-10);
Destroy();
AGameMode* GameMode = GetWorld()->GetAuthGameMode<AGameMode>();
if (GameMode != nullptr)
{
GameMode->RestartPlayer(PreviousController);
}
}
- 实现
AddScore
函数,该函数将分数添加到玩家状态中的Score
变量中:
void APickupsCharacter::AddScore(const float Score)
{
APlayerState* MyPlayerState = GetPlayerState();
if (MyPlayerState != nullptr)
{
MyPlayerState->Score += Score;
}
}
- 实现
AddPickup
函数,将拾取物品添加到我们自定义玩家状态中的Pickup
变量中:
void APickupsCharacter::AddPickup()
{
APickupsPlayerState* MyPlayerState = GetPlayerState<APickupsPlayerState>();
if (MyPlayerState != nullptr)
{
MyPlayerState->AddPickup();
}
}
- 实现
ClientPlaySound2D_Implementation
函数,该函数在拥有客户端上播放声音:
void APickupsCharacter::ClientPlaySound2D_Implementation(USoundBase* Sound)
{
UGameplayStatics::PlaySound2D(GetWorld(), Sound);
}
- 打开
Pickups.Build.cs
并将UMG
模块添加到PublicDependencyModuleNames
中,如下所示:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "UMG" });
如果尝试编译并从添加新模块中获得错误,则清理并重新编译您的项目。如果这样不起作用,请尝试重新启动您的 IDE。
- 编译并运行代码,直到编辑器加载完成。
首先,让我们导入声音文件。
-
在
内容浏览器
中,创建并转到Content\Sounds
文件夹。 -
从
Exercise18.02\Assets
文件夹导入Pickup.wav
,Footstep.wav
,Jump.wav
,Land.wav
和Fall.wav
。 -
保存新文件。
接下来,让我们向一些角色的动画中添加Play Sound
动画通知。
-
打开位于
Content\Mannequin\Animations
中的ThirdPersonJump_Start
动画,并在帧0
处使用Jump
声音添加一个Play Sound
动画通知。 -
保存并关闭
ThirdPersonJump_Start
。 -
打开位于
Content\Mannequin\Animations
中的ThirdPersonRun
动画,并在 0.24 秒和 0.56 秒处添加两个Play Sound
动画通知,使用Footstep
声音。 -
保存并关闭
ThirdPersonRun
。 -
打开位于
Content\Mannequin\Animations
中的ThirdPersonWalk
动画,并在 0.24 秒和 0.79 秒处添加两个Play Sound
动画通知,使用Footstep
声音。 -
保存并关闭
ThirdPersonWalk
。
现在,让我们为角色蓝图设置声音。
-
打开位于
Content\ThirdPersonCPP\Blueprints
中的ThirdPersonCharacter
蓝图,并将Fall
Sound
和Land
Sound
设置为分别使用Fall
和Land
的声音。 -
保存并关闭
ThirdPersonCharacter
。
现在,让我们为拾取物品创建蓝图。
-
创建并打开
Content\Blueprints
文件夹。 -
创建一个名为
BP_Pickup
的新蓝图,该蓝图派生自Pickup
类并打开它。 -
以以下方式配置
Static Mesh
组件:
Scale = 0.5, 0.5, 0.5
Static Mesh = Engine\BasicShapes\Cube
Material Element 0 = CubeMaterial
注意
要显示引擎内容,您需要转到静态网格下拉菜单右下角的视图选项,并确保将显示引擎内容标志设置为 true。
-
将
Pickup
Sound
变量设置为使用Pickup
声音。 -
保存并关闭
BP_Pickup
。
接下来,让我们创建记分牌 UMG 小部件。
-
创建并转到
Content\UI
文件夹。 -
创建一个名为
UI_Scoreboard_Header
的新小部件蓝图:
-
将一个名为
tbName
的文本块添加到根画布面板,将Is Variable
设置为true
,Size To Content
设置为true
,Text
设置为Player Name
,Color and Opacity
设置为使用颜色green
。 -
将一个名为
tbScore
的文本块添加到根画布面板,将Is Variable
设置为true
,Position X = 500
,Alignment = 1.0, 0.0
,Size To Content
设置为true
,Text
设置为Score
,Color and Opacity
设置为使用颜色green
。 -
将一个名为
tbPickups
的文本块添加到根画布面板,将Is Variable
设置为true
,Position X = 650
,Alignment = 1.0, 0.0
,Size To Content
设置为true
,Text
设置为Pickups
,Color and Opacity
设置为使用颜色green
。
-
从
层次结构
面板中选择三个新的文本块并复制它们。 -
保存并关闭
UI_Scoreboard_Header
。 -
返回到
Content\UI
,创建一个名为UI_Scoreboard_Entry
的新 UMG 小部件,并打开它。 -
将复制的文本块粘贴到根画布面板上,并将它们更改为
white
而不是green
,并将它们全部设置为变量。 -
转到
Graph
部分,并创建以下配置的Player State
变量:
图 18.3:创建 Player State 变量
- 返回到设计师部分,并为
tbName
创建一个绑定,执行以下操作:
图 18.4:显示玩家名称
注意
您可以在以下链接找到完整分辨率的前一个截图,以便更好地查看:packt.live/3pCk9Nt
。
- 为
tbScore
创建一个绑定,执行以下操作:
图 18.5:显示玩家得分
注意
您可以在以下链接找到完整分辨率的前一个截图,以便更好地查看:packt.live/3nuckYv
。
- 为
tbPickups
创建一个绑定,执行以下操作:
图 18.6:显示拾取物计数
注意
您可以在以下链接找到完整分辨率的前一个截图,以便更好地查看:packt.live/36pEGMz
。
- 创建一个名为
Get Typeface
的纯函数,执行以下操作:
图 18.7:确定条目是否应以粗体或常规显示
注意
您可以在以下链接找到完整分辨率的前一个截图,以便更好地查看:packt.live/2JW9Zam
。
在上述代码中,我们使用了选择节点,可以通过从返回值拖动线并将其释放到空白处,然后在过滤器上键入“select”来创建。从那里,我们从列表中选择了选择节点。在这个特定的函数中,我们使用选择节点来选择我们将要使用的字体的名称,因此如果玩家状态的 pawn 与拥有小部件的 pawn 不同,它应该返回Regular
,如果相同则返回Bold
。我们这样做是为了以粗体突出显示玩家状态条目,以便玩家知道他们的条目是什么。
- 以以下方式实现
Event Construct
:
图 18.8:设置名称、得分和拾取物计数的事件图
注意
您可以在以下链接找到完整分辨率的前一个截图,以便更好地查看:packt.live/2JOdP58
。
在上述代码中,我们将tbName
、tbScore
和tbPickups
的字体设置为使用Bold
字体,以突出显示当前客户端玩家的记分板条目。对于其余的玩家,请使用Regular
字体。
-
保存并关闭
UI_Scoreboard_Entry
。 -
返回到
Content\UI
,然后创建一个名为UI_Scoreboard
的新 UMG 小部件并打开它。 -
将一个名为
vbScoreboard
的垂直框添加到根画布面板,并启用Size To Content
。 -
向
vbScoreboard
添加一个文本块,名为tbGameInfo
,其Text
值默认为Game Info
。 -
转到
Graph
部分,并创建一个名为Game State
的Pickups Game State
类型的新变量。 -
以以下方式实现
Event Construct
:
图 18.9:设置每 0.5 秒更新记分板的事件构造
注意
您可以在以下链接找到完整分辨率的前一个截图,以便更好地查看:packt.live/3kemyu0
。
在上面的代码中,我们获取游戏状态实例,更新记分牌,并安排一个定时器,以自动每 0.5 秒更新一次记分牌。
- 返回设计部分,并为
vbScoreboard
进行以下绑定:
图 18.10:显示世界中剩余拾取物的数量
注意
您可以在以下链接找到前面的屏幕截图的全分辨率,以便更好地查看:packt.live/38xUDTE
。
- 向
vbScoreboard
添加一个垂直框,称为vbPlayerStates
,并将Is Variable
设置为true
,顶部填充为50
,因此您应该有以下内容:
图 18.11:UI_Scoreboard 小部件层次结构
- 返回图形部分,并以以下方式实现“更新记分牌”事件:
图 18.12:更新记分牌函数,清除并重新创建条目小部件
注意
您可以在以下链接找到前面的屏幕截图的全分辨率,以便更好地查看:packt.live/3pf8EeN
。
在上面的代码中,我们做了以下事情:
-
清除
vbPlayerStates
中的所有先前条目。 -
创建一个记分牌标题条目,并将其添加到
vbPlayerStates
中。 -
循环遍历所有按分数排序的玩家状态,并为每个创建一个条目,并将其添加到
vbPlayerStates
中。
- 保存并关闭
UI_Scoreboard
。
现在,让我们为玩家控制器创建蓝图。
-
转到“内容\蓝图”并创建一个名为
BP_PlayerController
的新蓝图,该蓝图派生自PickupPlayerController
类。 -
打开新的蓝图,并将“记分牌菜单”“类”设置为使用
UI_Scoreboard
。 -
保存并关闭
BP_PlayerController
。
接下来,让我们为游戏模式创建蓝图。
- 转到“内容\蓝图”并创建一个名为
BP_GameMode
的新蓝图,该蓝图派生自PickupGameMode
类,打开它,并更改以下变量:
Game State Class = PickupsGameState
Player Controller Class = BP_PlayerController
Player State Class = PickupsPlayerState
接下来,让我们配置“项目设置”以使用新的游戏模式。
-
转到“项目设置”并从左侧面板中选择“地图和模式”,该面板位于“项目”类别中。
-
将“默认游戏模式”设置为使用
BP_GameMode
。 -
关闭“项目设置”。
现在,让我们修改主要级别。
-
确保您已经打开了
ThirdPersonExampleMap
,位于Content\ThirdPersonCPP\Maps
中。 -
添加一些立方体演员作为平台,并确保它们之间有间隙,以迫使玩家跳上它们,并可能从级别中掉下来。
-
向地图的不同部分添加一些玩家起始演员。
-
至少添加 50 个
BP_Pickup
的实例,并将它们分布在整个地图上。 -
这是一种可能的配置地图的示例:
图 18.13:地图配置示例
-
运行代码并等待编辑器完全加载。
-
转到“多人游戏选项”并将客户端数量设置为
2
。 -
将窗口大小设置为
800x600
。 -
在“新编辑器窗口(PIE)”中播放:
图 18.14:监听服务器和客户端 1 在世界中拾取立方体
完成此练习后,您将能够在每个客户端上进行游戏,并且您会注意到角色可以收集拾取物并获得10
分,只需与它们重叠。如果角色从级别掉下来,它将重新生成在随机的玩家起始点,并且失去10
分。
一旦所有拾取物品都被收集,游戏将结束,并在5
秒后执行服务器转移以重新加载相同的级别并将所有客户端带上(仅在打包版本中)。您还可以看到 UI 显示了级别中剩余的拾取物品数量,以及每个玩家的名称、得分和拾取物品的记分板信息。
活动 18.01:向多人游戏 FPS 游戏添加死亡、重生、记分板、击杀限制和拾取物品
在这个活动中,您将为角色添加死亡、重生和使用拾取物品的概念。我们还将添加一种方法来检查记分板和游戏的击杀限制,以便它有一个结束目标。
以下步骤将帮助您完成此活动:
-
从活动 17.01中打开
MultiplayerFPS
项目,向多人游戏 FPS 游戏添加武器和弹药。编译代码并运行编辑器。 -
接下来,您将创建我们需要的 C++类。创建一个名为
FPSGameState
的 C++类,它派生自GameState
类,并具有一个击杀限制变量和一个按击杀排序的玩家状态函数。 -
创建一个名为
FPSPlayerState
的 C++类,它派生自PlayerState
类,并存储玩家的击杀数和死亡数。 -
创建一个名为
PlayerMenu
的 C++类,它派生自UserWidget
类,并具有一些BlueprintImplementableEvent
函数来切换记分板的可见性、设置记分板的可见性,并在玩家被杀时通知。 -
创建一个名为
FPSPlayerController
的 C++类,它派生自APlayerController
,在拥有客户端上创建PlayerMenu
UMG 小部件实例。 -
创建一个名为
Pickup
的 C++类,它派生自Actor
类,并具有一个静态网格,在Yaw
轴上每秒旋转 90 度,并且可以被玩家在重叠时拾取。一旦被拾取,它会播放拾取声音,并禁用碰撞和可见性。一定时间后,它将重新变得可见并能够再次发生碰撞。 -
创建一个名为
AmmoPickup
的 C++类,它派生自Pickup
类,并向玩家添加一定数量的弹药类型。 -
创建一个名为
ArmorPickup
的 C++类,它派生自Pickup
类,并向玩家添加一定数量的护甲。 -
创建一个名为
HealthPickup
的 C++类,它派生自Pickup
类,并向玩家添加一定数量的生命值。 -
创建一个名为
WeaponPickup
的 C++类,它派生自Pickup
类,并向玩家添加特定的武器类型。如果玩家已经拥有该武器,它将添加一定数量的弹药。 -
编辑
FPSCharacter
类,使其执行以下操作:
-
角色受到伤害后,检查是否已死亡。如果已死亡,它会为杀手角色注册击杀和角色死亡,并重新生成玩家。如果角色尚未死亡,则在拥有客户端上播放疼痛声音。
-
当角色死亡并执行
EndPlay
函数时,它应销毁所有武器实例。 -
如果角色从世界中掉落,将注册玩家的死亡并重新生成。
-
如果玩家按下Tab键,它将切换记分板菜单的可见性。
- 编辑
MultiplayerFPSGameModeBase
类,使其执行以下操作:
-
存储赢得比赛所需的击杀数。
-
使用新的玩家控制器、玩家状态和游戏状态类。
-
使其实现比赛状态函数,以便比赛立即开始,并在有玩家达到所需的击杀数时结束。
-
比赛结束时,将在 5 秒后执行服务器转移到相同的级别。
-
处理玩家死亡时通过向相应的玩家状态添加击杀(当被其他玩家杀死时)和死亡,并在随机玩家开始点重新生成玩家。
-
从
Activity18.01\Assets
导入AmmoPickup.wav
到Content\Pickups\Ammo
。 -
从
AAmmoPickup
创建BP_PistolBullets_Pickup
,放置在Content\Pickups\Ammo
,并配置以下值:
-
缩放:
(X=0.5, Y=0.5, Z=0.5)
-
静态网格:
Engine\BasicShapes\Cube
-
材质:
Content\Weapon\Pistol\M_Pistol
-
弹药类型:
手枪子弹
,弹药数量:25
-
拾取声音:
Content\Pickup\Ammo\AmmoPickup
- 从
AAmmoPickup
创建BP_MachineGunBullets_Pickup
,放置在Content\Pickups\Ammo
,并配置以下值:
-
缩放:
(X=0.5, Y=0.5, Z=0.5)
-
静态网格:
Engine\BasicShapes\Cube
-
材质:
Content\Weapon\MachineGun\M_MachineGun
-
弹药类型:
机枪子弹
,弹药数量:50
-
拾取声音:
Content\Pickup\Ammo\AmmoPickup
- 从
AAmmoPickup
创建BP_Slugs_Pickup
,放置在Content\Pickups\Ammo
,并配置以下值:
-
缩放:
(X=0.5, Y=0.5, Z=0.5)
-
静态网格:
Engine\BasicShapes\Cube
-
材质:
Content\Weapon\Railgun\M_Railgun
-
弹药类型:
弹丸
,弹药数量:5
-
拾取声音:
Content\Pickup\Ammo\AmmoPickup
-
从
Activity18.01\Assets
导入ArmorPickup.wav
到Content\Pickups\Armor
。 -
在
Content\Pickups\Armor
中创建材质M_Armor
,将Base Color
设置为蓝色
,金属
设置为1
。 -
从
AArmorPickup
创建BP_Armor_Pickup
,放置在Content\Pickups\Armor
,并配置以下值:
-
缩放:
(X=1.0, Y=1.5, Z=1.0)
-
静态网格:
Engine\BasicShapes\Cube
-
材质:
Content\Pickup\Armor\M_Armor
-
护甲数量:
50
-
拾取声音:
Content\Pickup\Armor\ArmorPickup
-
从
Activity18.01\Assets
导入HealthPickup.wav
到Content\Pickups\Health
。 -
在
Content\Pickups\Health
中创建材质M_Health
,将Base Color
设置为蓝色
,金属
/粗糙度
设置为0.5
。 -
从
AHealthPickup
创建BP_Health_Pickup
,放置在Content\Pickups\Health
,并配置以下值:
-
静态网格:
Engine\BasicShapes\Sphere
-
材质:
Content\Pickup\Health\M_Health
-
生命值:
50
-
拾取声音:
Content\Pickup\Health\HealthPickup
-
从
Activity18.01\Assets
导入WeaponPickup.wav
到Content\Pickups\Weapon
。 -
从
AWeaponPickup
创建BP_Pistol_Pickup
,放置在Content\Pickups\Weapon
,并配置以下值:
-
静态网格:
Content\Pickup\Weapon\SM_Weapon
-
材质:
Content\Weapon\Pistol\M_Pistol
-
武器类型:
手枪
,弹药数量:25
-
拾取声音:
Content\Pickup\Weapon\WeaponPickup
- 从
AWeaponPickup
创建BP_MachineGun_Pickup
,放置在Content\Pickups\Weapon
,并配置以下值:
-
静态网格:
Content\Pickup\Weapon\SM_Weapon
-
材质:
Content\Weapon\MachineGun\M_MachineGun
-
武器类型:
机枪
,弹药数量:50
-
拾取声音:
Content\Pickup\Weapon\WeaponPickup
- 从
AWeaponPickup
创建BP_Pistol_Pickup
,放置在Content\Pickups\Weapon
,并配置以下值:
-
静态网格:
Content\Pickup\Weapon\SM_Weapon
-
材质:
Content\Weapon\Railgun\M_Railgun
-
武器类型:
Railgun
,弹药数量:5
-
拾取声音:
Content\Pickup\Weapon\WeaponPickup
-
从
Activity18.01\Assets
导入Land.wav
和Pain.wav
到Content\Player\Sounds
。 -
编辑
BP_Player
,使其使用Pain
和Land
声音,并删除所有在Begin Play
事件中创建和添加UI_HUD
实例的节点。 -
在
Content\UI
中创建名为UI_Scoreboard_Entry
的 UMG 小部件,显示AFPSPlayerState
的名称、击杀数、死亡数和 ping。 -
创建名为
UI_Scoreboard_Header
的 UMG 小部件,显示名称、击杀数、死亡数和 ping 的标题。 -
创建一个名为
UI_Scoreboard
的 UMG 小部件,显示游戏状态中的杀敌限制,一个垂直框,其中UI_Scoreboard_Header
作为第一个条目,然后为游戏状态实例中的每个AFPSPlayerState
添加一个UI_Scoreboard_Entry
。垂直框将通过定时器每 0.5 秒更新一次,通过清除其子项并再次添加它们。 -
编辑
UI_HUD
,使其添加一个名为tbKilled
的新文本块,其Visibility
设置为Hidden
。当玩家杀死某人时,它将使文本块可见,显示被杀玩家的名称,并在 1 秒后隐藏。 -
从
UPlayerMenu
创建一个名为UI_PlayerMenu
的新蓝图,并将其放置在Content\UI
中。使用一个小部件切换器,在索引0
中使用UI_HUD
的一个实例,在索引1
中使用UI_Scoreboard
的一个实例。在事件图中,确保覆盖在 C++中设置为BlueprintImplementableEvent
的Toggle Scoreboard
、Set Scoreboard Visibility
和Notify Kill
事件。Toggle Scoreboard
事件在0
和1
之间切换小部件切换器的活动索引,Set Scoreboard Visibility
事件将小部件切换器的活动索引设置为0
或1
,Notify Kill
事件告诉UI_HUD
实例设置文本并淡出动画。 -
从
AFPSPlayerController
创建BP_PlayerController
,将其放置在Content
文件夹中,并设置PlayerMenuClass
变量以使用UI_PlayerMenu
。 -
编辑
BP_GameMode
并设置Player Controller Class
以使用BP_PlayerController
。 -
在
项目设置
的输入
部分,创建一个名为Scoreboard
的动作映射,使用TAB
键。 -
编辑
DM-Test
关卡,以便至少放置三个新的玩家开始点在不同的位置,将Kill Z
设置为-500
在世界设置
中,并放置每个不同拾取物品的实例。
预期输出:
图 18.15:活动的预期输出
结果应该是一个项目,其中每个客户端的角色都可以拾取、使用和在三种不同的武器之间切换。如果一个角色杀死另一个角色,它应该注册杀死和死亡,以及在随机玩家开始处重生死亡的角色。您应该有一个计分板,显示每个玩家的名称、杀敌数、死亡数和 ping。角色可以从关卡中掉落,这应该只算作一次死亡,并在随机玩家开始处重生。角色还应该能够拾取关卡中的不同物品,以获得弹药、盔甲、健康和武器。当达到杀敌限制时,游戏应该结束,并在 5 秒后显示计分板并服务器转移到相同的关卡。
注意
此活动的解决方案可在以下网址找到:packt.live/338jEBx
。
摘要
在本章中,您了解到游戏框架类的实例存在于某些游戏实例中,但在其他游戏实例中不存在。了解这一点将有助于您了解在特定游戏实例中可以访问哪些实例。您还了解了游戏状态和玩家状态类的目的,以及学习了游戏模式和一些有用的内置功能的新概念。
在本章末尾,您已经制作了一个基本但功能齐全的多人射击游戏,可以作为进一步构建的基础。您可以添加新的武器、弹药类型、射击模式、拾取物品等,使其更加完整和有趣。
完成了这本书后,您现在应该更好地了解如何使用 Unreal Engine 4 让自己的游戏变得生动起来。在本书中,我们涵盖了许多主题,从简单到更高级的内容。您首先学习了如何使用不同模板创建项目,以及如何使用蓝图创建角色和组件。然后,您看到了如何从头开始创建一个完全功能的第三人称模板,通过导入和设置所需的资产,设置动画蓝图和混合空间,并创建自己的游戏模式和角色,以及定义和处理输入。
然后,您开始了您的第一个项目;一个使用游戏物理和碰撞、投射物移动组件、角色组件、接口、蓝图函数库、UMG、声音和粒子效果的简单潜行游戏。在此之后,您学会了如何使用 AI、动画蒙太奇和可破坏网格创建一个简单的横向卷轴游戏。最后,您了解了如何使用服务器-客户端架构、变量复制和网络框架提供的 RPC 来创建第一人称多人射击游戏,并学会了 Player State、Game State 和 Game Mode 类的工作原理。
通过参与使用引擎不同部分的各种项目,您现在对 Unreal Engine 4 的工作原理有了深刻的理解,尽管这本书到此结束,但这只是您进入使用 Unreal Engine 4 进行游戏开发世界的开始。