原文:zh.annas-archive.org/md5/d1bb0b6c3c4894a24115bdcf45cb23ba

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

现在,随着虚幻引擎 4 已成为世界上最前沿的游戏引擎之一,无论是 AAA 还是独立开发者都在寻找使用该引擎创建任何类型游戏的最佳方法。在虚幻引擎首次发布时,它以一款优秀的第一人称射击游戏引擎而闻名,但随着像 WB 的《无限格斗》、Chair Entertainment 的《阴影复杂》和 Epic Games 的《战争机器》等游戏的成功,以及备受期待的即将到来的游戏,如 Capcom 的《街头霸王 5》、Comcept 的《无敌 9 号》和 Square Enix 的《最终幻想 7 重制版》,虚幻引擎已经证明了自己是创建几乎任何类型游戏时最伟大的引擎之一。本书将为在虚幻引擎 4 中创建回合制 RPG 奠定基础。

本书涵盖的内容

第一章, 在虚幻引擎中开始 RPG 设计,提醒读者在跳入虚幻引擎之前需要进行的各种准备工作。为了避免可能阻碍进展的障碍,提供了示例内容并进行简要介绍。

第二章, 虚幻引擎中的脚本和数据,引导读者使用 C++在虚幻引擎中编程游戏元素,创建蓝图图,以及与虚幻引擎中的自定义游戏数据一起工作。

第三章, 探索和战斗,引导读者创建一个在游戏世界中四处奔跑的角色,定义角色数据和团队成员,定义敌人遭遇,并创建基本的战斗引擎。

第四章, 暂停菜单框架,介绍了如何创建带有库存和装备子菜单的暂停菜单。

第五章, 连接角色统计数据,介绍了如何在菜单系统中跟踪玩家的统计数据。

第六章, NPC 和对话,介绍了如何向游戏世界添加交互式 NPC 和对话。读者将学习如何使用蓝图来定义当与对象或 NPC 交互时会发生什么,包括使用一组自定义蓝图节点来创建对话树。

第七章, 金币、物品和商店,介绍了如何向游戏世界添加交互式 NPC 和对象。读者将学习如何使用蓝图来定义当与对象或 NPC 交互时会发生什么,包括使用一组自定义蓝图节点来创建对话树。用户还将创建可以使用敌人掉落金币购买的物品。

第八章, 库存填充和物品使用,介绍了如何在非战斗状态下使用物品填充库存屏幕。

第九章,装备,涵盖了从装备屏幕创建装备以及装备武器和盔甲。

第十章,等级提升、能力和保存进度,涵盖了向游戏中添加能力、跟踪每个团队成员的经验、战斗后向团队成员颁发经验、为角色类定义等级和属性更新以及保存和加载玩家进度。

您需要为此书准备的内容

所需的软件:所有章节都需要 Unreal Engine 4 版本 4.12 或更高版本,以及 Visual Studio 2015 Enterprise/Community 或更高版本或 XCode 7.0 或更高版本。

所需的操作系统:Windows 7 64 位或更高版本,或 Mac OS X 10.9.2。

所需的硬件:四核 2.5 GHz 或更快,8 GB 的 RAM,以及 Nvidia GeForce 470 GTX 或 AMD Radeon 6870 HD 或更高。

本书面向的对象

如果您是 Unreal Engine 的新手,并且一直想编写 RPG 脚本,您就是本书的目标读者。课程假设您了解 RPG 游戏的约定,并对使用 Unreal 编辑器构建等级的基础知识有所了解。本书结束时,您将能够构建核心 RPG 框架元素,以创建您自己的游戏体验。

术语约定

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

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

if( DataTable != NULL )
{
  FTestCustomData* row = DataTable->FindRow<FTestCustomData>( TEXT( "2" ), TEXT(" LookupTestCustomData"  ) );
  FString someString = row->SomeString;
  UE_LOG( LogTemp, Warning, TEXT( "%s" ), *someString );
}

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

LogTemp: Combat started

新术语重要词汇将以粗体显示。屏幕上显示的词汇,例如在菜单或对话框中,将以如下方式显示:“编译并保存蓝图,然后按播放。”

注意

警告或重要注意事项将以如下方式显示在框中。

小贴士

小贴士和技巧将以如下方式显示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们非常重要,因为它帮助我们开发出您真正能从中受益的书籍。

要发送给我们一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

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

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有一些东西可以帮助您充分利用您的购买。

下载示例代码

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

下载本书的彩色图像

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

勘误

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

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入本书的名称。所需信息将出现在勘误部分下。

盗版

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

请通过电子邮件联系我们的 <copyright@packtpub.com> 并附上疑似盗版材料的链接。

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

问题和建议

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

第一章. 在 Unreal 中开始 RPG 设计

角色扮演游戏是非常复杂的事物。即使在 RPG 类型中,也有各种各样、机制和控制系统截然不同的游戏。

在写下任何一行代码之前,重要的是要弄清楚你想要制作哪种类型的 RPG,游戏是如何玩的,游戏应该是回合制还是实时,以及玩家需要关注哪些统计数据。

在本章中,我们将介绍以下主题,展示在开始制作 RPG 之前如何设计它:

  • 游戏设计工具

  • 设计和概念阶段

  • 描述游戏的功能和机制

  • 现有 RPG 中的陈词滥调

  • RPG 设计概述

游戏设计工具

虽然你总是可以在记事本中输入所有内容并以此方式跟踪设计决策,但在处理设计文档时,有各种各样的工具可以帮助你。

特别值得注意的是 Google 工具套件。这些工具随 Google 账户免费提供,并且有许多应用,但在这个案例中,我们将探讨将它们应用于游戏设计。

Google Drive

Google Drive 是一个类似于 Dropbox 的基于云的文件存储系统。它随 Google 账户免费提供,并拥有高达 15 GB 的存储空间。只要其他人也有 Google 账户,Google Drive 就可以非常容易地与他人共享文件。你还可以设置权限,例如允许谁修改数据(可能你只想让某人阅读但不更改你的设计文档)。

Google Docs

与 Google Drive 集成的是 Google Docs,这是一个功能齐全的在线文字处理应用程序。它包括许多功能,如实时协作编辑、注释和内置聊天侧边栏。

你的大部分设计文档都可以在 Google Docs 中编写,并且可以轻松地与任何潜在的合作者共享。

Google Spreadsheets

就像 Google Docs 一样,Google Spreadsheets 也直接集成到 Google Drive 中。Google Spreadsheets 提供了一个类似于 Excel 的界面,可以用来以方便的行列格式跟踪数据。你还可以在单元格中输入方程式和公式,并计算它们的值。

例如,电子表格可以用来跟踪游戏的战斗公式,并用一系列输入值进行测试。

此外,你可以使用电子表格来跟踪事物列表。例如,你可能有一个用于游戏中武器的电子表格,包括名称、类型、伤害、元素等列。

铅笔和纸张

有时候,没有什么能比得上可靠的将事物写下来的方法。如果你脑海中突然闪过一个想法,快速记下来可能值得。否则,你很可能后来会忘记这个想法(即使你认为你不会——相信我,你很可能会的)。你不必考虑这个想法是否值得写下——你总是可以在之后给予它更多思考。

设计和概念阶段

正如作家从提纲或思维导图工作,或艺术家从草图工作一样,几乎所有游戏都是从某种粗略概念或设计文档开始的。

设计文档的目的是描述关于游戏几乎所有的内容。在 RPG 的情况下,它将描述玩家如何在游戏世界中移动,玩家如何与敌人和 NPC 互动,战斗如何进行,等等。设计文档成为构建所有游戏代码的基础。

概念

通常,游戏从一个非常粗略的概念开始。

例如,让我们考虑本书中我们将要制作的 RPG 游戏。我可能会有这样的想法,这款游戏将是一款线性的回合制 RPG 冒险游戏。这是一个非常粗略的概念,但没关系——虽然这可能不是一个特别原创的概念,但它足以开始具体化并创建一个设计文档。

设计

游戏的设计文档基于之前的粗略概念。其目的是详细阐述粗略概念并描述其工作原理。例如,虽然粗略概念是“线性的回合制 RPG 冒险”,但设计文档的任务是进一步阐述并描述玩家如何在世界中移动,回合制战斗如何进行,战斗统计数据,游戏结束条件,玩家如何推进剧情,以及更多内容。

你应该能够将你的设计文档交给任何一个人,这份文档应该能让他们对游戏的外观和工作方式有一个很好的了解。实际上,这是设计文档的一个大优点——它非常有用,例如,作为确保团队中每个人都处于同一页面的方式。

描述游戏的功能和机制

那么,假设你有一个非常粗略的游戏概念,现在处于设计阶段,你实际上是如何描述游戏的工作方式的?

实际上并没有关于如何进行战斗的具体规则,但你可以将你的理论游戏分为重要的核心部分,并思考每个部分将如何工作,规则是什么等等。信息越详细,越具体,越好。如果某事模糊不清,你将想要对其进行扩展。

例如,让我们以我们假设的回合制 RPG 中的战斗为例。

战斗者轮流选取行动,直到一方战斗者阵亡。

战斗者的战斗顺序是什么?有多少个队伍?

战斗者分为两个队伍:玩家队伍和敌人队伍。战斗者由所有玩家排序,由所有敌人跟随。他们轮流选取行动,直到一方战斗者阵亡(无论是敌人队伍还是玩家队伍)。

战斗者可以选取哪些行动?

战斗者分为两个队伍:玩家队伍和敌人队伍。战斗者由所有玩家排序,由所有敌人跟随。战斗者轮流选取行动(攻击目标、施放技能或消耗物品),直到一方战斗者阵亡(无论是敌人队伍还是玩家队伍)。

等等。

现有角色扮演游戏中的陈词滥调

尽管 RPG 可能千差万别,但它们仍然有很多常见的主题,它们经常共享——玩家期望从你的游戏中获得的功能。

统计和进步

这一点不言而喻。每个 RPG——我确实是指每个RPG——都有这些基本概念。

统计数据,或统计数据,是控制游戏中所有战斗的数字。虽然实际的统计数据可能有所不同,但通常会有最大生命值、最大 MP、力量、防御等统计数据。

随着玩家在游戏中的进步,这些统计数据也会提高。他们的角色在各个方面都会变得更好,在游戏(或接近游戏)的末尾达到最大潜力。处理这一点的确切方式可能有所不同,但大多数游戏都会实施在战斗中获得的经验点或 XP;当获得足够的 XP 时,角色的等级会增加,随之而来的是,他们的统计数据也会增加。

职业

在 RPG 中拥有职业是很常见的。一个职业可以意味着很多,但通常它决定了角色的能力以及角色如何发展。

例如,士兵职业可能定义,例如,一个角色能够挥舞剑,并且主要专注于随着等级的提升增加攻击力和防御力。

特殊能力

很少有角色扮演游戏可以避免没有魔法咒语或某种特殊能力。

通常,角色会拥有某种魔法计量表,每次使用特殊能力时都会消耗。此外,如果角色没有足够的魔法(这个术语可能有所不同——它也可能被称为魔力耐力力量——实际上,任何适合游戏场景的东西都可以),则不能施展这些能力。

RPG 设计概述

在所有这些之外,我们将探讨本书中将要开发的 RPG 的设计,我们将称之为虚幻 RPG

环境

游戏设定在一个开阔的田野上。玩家会遇到敌人,他们会掉落经验值,这将增加玩家的统计数据。

探索

在非战斗状态下,玩家以等距视角探索世界,类似于《暗黑破坏神》等游戏。在这个视角中,玩家可以与世界中的 NPC 和道具互动,并且可以暂停游戏来管理他们的队伍成员、库存和装备。

对话

与 NPC 和道具互动时,可能会触发对话。游戏中的对话主要是基于文本的。对话框可以是线性的,玩家只需按按钮即可进入下一个对话页面,或者多选。在多选的情况下,玩家会看到一个选项列表。然后每个选项将进入不同的对话页面。例如,NPC 可能会问玩家一个问题,并允许玩家回答“是”或“否”,对每个回答都有不同的回应。

购物

从对话中也可以触发商店用户界面。例如,店主可能会问玩家是否想要购买物品。如果玩家选择“是”,则显示商店用户界面。

在商店中,玩家可以从 NPC 那里购买物品。

金币

通过在战斗中击败怪物可以获得金币。这种金币被称为敌人掉落物。

暂停屏幕

在游戏暂停时,玩家可以执行以下操作:

  • 查看队伍成员及其状态(生命值、魔法值、等级、效果等)

  • 查看每个队伍成员学到的技能

  • 查看当前携带的金币数量

  • 浏览库存并使用物品(如药水、以太等)在他们的队伍成员上

  • 管理每个队伍成员配备的物品(如武器、盔甲等)

队伍成员

玩家有一个队伍成员列表。这些都是目前玩家队伍中的角色。例如,玩家可能在塔中遇到一个角色,该角色加入他们的队伍以协助战斗。请注意,在这本书中,我们只会创建一个队伍成员,但这将为你在未来的开发中创建更多队伍成员奠定基础。

装备

玩家队伍中的每个角色都有以下装备槽位:

  • 盔甲:角色的盔甲通常会增加防御力

  • 武器:角色的武器通常会增加他们的攻击力(如本章“战斗”部分中的攻击公式所示)

职业

玩家角色有不同的职业。角色的职业定义以下元素:

  • 升级的经验曲线

  • 随着等级的提升,他们的属性如何增加

  • 他们随着等级提升学会的技能

游戏将包含一个玩家角色和一个职业。然而,基于这个玩家角色,我们可以轻松地将更多角色和职业,如治疗师或黑魔导,实现到游戏中。

士兵

士兵类专注于增加攻击力、最大生命值和幸运。此外,特殊技能围绕对敌人造成大量伤害。

因此,随着士兵类等级的提升,他们对敌人造成的伤害更多,能承受更多打击,也能造成更多致命一击。

战斗

在探索游戏世界时,可能会触发随机遭遇战。此外,战斗遭遇战也可以从过场动画和故事事件中触发。

当触发遭遇战时,视图从游戏世界(战场)切换到一个专门用于战斗的区域(战斗区域),类似于一个竞技场。

战斗者分为两个队伍:敌人队伍和玩家队伍(由玩家的队伍成员组成)。

每个队伍都排成一行,从战斗区域的对面面对彼此。

战斗者轮流进行,玩家队伍先行动,然后是敌人队伍。一场战斗分为两个阶段:决策和行动。

首先,所有战斗者选择他们的行动。他们可以攻击一个敌人目标或施展一个技能。

在所有参战者决定之后,每个参战者将依次执行他们的行动。大多数行动都有特定的目标。如果参战者在执行行动时,这个目标已经不可用,那么如果可能的话,参战者将选择下一个可用的目标,否则行动将失败,参战者将不会做任何事情。

这个循环会一直持续到所有敌人或玩家死亡为止。如果所有敌人死亡,玩家的队伍成员将获得经验值,并且可以从被打败的敌人那里获得战利品(通常是随机数量的金币)。

然而,如果所有玩家都已阵亡,那么游戏就结束了。

战斗统计

每个参战者都有以下统计数据:

  • 生命值:角色的生命值HP)代表角色可以承受的伤害。当 HP 达到零时,角色就会死亡。

    HP 可以通过物品或法术来补充,只要角色仍然存活。然而,一旦角色死亡,HP 就无法补充——角色必须首先通过特殊物品或法术复活。

  • 最大生命值:这是角色在任何给定时间可以拥有的最大 HP 量。治疗物品和法术只能补充到这个限制,不会超过。最大生命值可能会随着角色等级的提升而增加,也可以通过装备某些物品临时增加。

  • 魔法值:角色的魔法值MP)代表他们拥有的魔法力量。能力会消耗一定数量的 MP,如果玩家没有足够 MP 来使用能力,那么该能力就无法执行。MP 可以通过物品来补充。

    应该注意的是,敌人的 MP 实际上是无限的,因为他们的能力不会消耗任何 MP。

  • 最大魔法值:这是角色在任何给定时间可以拥有的最大 MP 量。补充物品只能补充到这个限制,不会超过。最大魔法值可能会随着角色等级的提升而增加,也可以通过装备某些物品临时增加。

  • 攻击力:角色的攻击力代表他们在攻击敌人时可以造成的伤害。武器有单独的攻击力,它会被加到普通攻击上。造成伤害的确切公式如下:

    max (player.ATK – enemy.DEF, 0) + player.weapon.ATK

    因此,首先从玩家的攻击力中减去敌人的防御力。如果这个值小于零,则将其改为零。然后,将武器的攻击力加到结果上。

  • 防御:角色的防御力可以减少他们从敌人攻击中受到的伤害。

    确切的公式如之前所述(从敌人的基础攻击值中减去防御力,然后加上敌人的武器攻击力)。

  • 幸运:角色的幸运会影响角色命中暴击的概率,这将使对敌人的伤害加倍。

    幸运代表造成暴击的百分比概率。幸运的范围从 0 到 100,代表从 0%到 25%的范围,因此公式如下:

    isCriticalHit = random( 0, 100 ) <= ( player.Luck * 0.25 )

    因此,如果玩家的幸运值是 10,并且随机数落在 0 到 100 的范围内的 10 这个数字上,那么造成致命一击的概率是 2.5%。

    致命一击倍数是在计算伤害之后应用的,如下所示:

    2 * (max( player.ATK – enemy.DEF, 0 ) + player.weapon.ATK )

战斗动作

战斗中的动作分为三个类别:攻击和技能。

攻击

每个角色都有一个不消耗 MP 的攻击技能,对于玩家角色,在回合决策阶段的动作菜单中,它显示为第一个选项。

通常,攻击针对单个敌人目标并对该敌人造成伤害。伤害公式如之前给出的攻击力统计值。

技能

每个角色,如前所述,都有一套他们所知道的技能。除了攻击外,技能需要消耗一些 MP,并具有各种效果。技能可以有不同的目标类型,如下所示:

  • 单个敌人

  • 所有敌人

  • 单个盟友

  • 所有盟友

技能可以治疗目标,复活已死亡的目标,移除某些效果,召唤临时盟友,暂时提高角色的属性,等等。然而,技能永远不会恢复 MP。

技能有一定的 MP 消耗。这是角色必须拥有的 MP 数量,以便执行该技能,以及施放技能时将消耗的 MP 数量。

战斗/胜利后

当所有敌人战斗者都已死亡时,玩家赢得战斗。在赢得战斗后,玩家将获得随机战利品,经验点数将在队伍成员之间分配。

获得的战利品

每个敌人定义了击败敌人后获得的战利品。这包括击败这个敌人所获得的金币数量。

经验

每个敌人定义了它的经验值。战斗后,每个被击败的敌人的经验值总和。然后,这个值将平均分配给所有当前存活的玩家(任何已死亡的队伍成员都不会获得任何经验值),并四舍五入到最接近的整数(例如,如果总经验值是 100,且有三位队伍成员,那么 100/3 = 33.3333,四舍五入到 34)。

经验和升级

随着队伍成员获得经验,他们将会升级。

从一个等级提升到下一个等级所需的经验值由以下公式给出:

f(x) = (xa) + c

在这里,x 是当前等级,a 是大于 1 的正值(影响曲线增加的陡峭程度),而 c 是基础偏移量,即从等级 1 提升到等级 2 所需的经验值。这定义了一个简单的指数值增加。ac 的值由角色的职业定义。

要获得从当前等级升级所需的总经验值,需要计算并累加到当前等级的每个等级的前述公式。例如,如果我们想知道达到 31 级(从 30 级)所需的总经验值,我们可以按以下方式计算:

f(1) + f(2) + f(3) + … + f(30)

当玩家升级时,他们的统计数据会增加,他们也可能学会一项新能力。统计数据增加和学会的能力在角色类别中定义。

游戏中任何角色的最大等级为 50。

统计数据增加

对于给定的角色类别,对于每个角色统计数据,类别定义了 1 级时的起始值和 50 级时的结束值。例如,使用标准数学库函数,任何给定等级的攻击值将是起始值和结束值之间简单的线性插值,使用角色的等级(除以最大等级)作为插值值(结果将向上取整以确保它是一个整数)。

因此,例如,如果一个士兵在 1 级时的最大生命值(HP)为 100,而在 50 级时为 1,000,那么在 25 级时士兵的最大生命值将是 550。

学习能力

每个角色类别定义了一个能力表。表中的每一项都引用了将学习哪种能力以及该能力将在哪个等级学习。当角色升级时,表中具有给定等级的任何能力都将添加到该角色的已知能力中。

在 1 级时学习的能力将自动添加到角色的技能集中。

游戏结束

如果所有玩家都死亡,无论是在战斗中还是在战场上,那么游戏结束。

选择正确的公式

在前面的章节中,本示例游戏的设计描述了游戏中角色的少量统计数据。它还概述了计算伤害、升级等各种公式的多种方法。

需要记住的一点是,这些统计数据、值和公式仅仅是为了展示如何实现 RPG 的核心功能。这些并不是统计数据或公式的全部。实际上,设计有意使用了有限的统计数据和简单的公式来保持范围简单。

考虑到这一点,当你自己制作游戏时,你必须自己决定这些事情——你的角色使用哪些统计数据,战斗如何进行,以及游戏将使用哪些公式来计算战斗结果。那么,你是如何自己想出所有这些事情的?

不幸的是,答案是“视情况而定”。没有一劳永逸的方法来平衡你的游戏并保持其乐趣。你使用的统计数据取决于你的战斗如何进行(如果你的游戏是从第一人称视角使用枪械进行,那么拥有“命中概率”统计数据就没有意义)。

另一点需要记住的是,您实际使用的数值和公式并不重要。重要的是最终结果是有趣的、公平的、平衡的。如果最终结果仍然有趣且感觉公平,那么升级所需的经验点数是一百、一千还是一百万都无关紧要。

小贴士

下载示例代码

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

摘要

在本章中,我们探讨了您可用于设计梦想中的 RPG 游戏的各种工具,讨论了在设计开发之前设计游戏的重要性,以及如何构思初步的概念和设计,以及如何描述您游戏机制。我们还概述了本书中我们将要开发的游戏。

在下一章中,我们将开始深入了解 Unreal,学习在 Unreal Engine 中编写游戏脚本元素以及与游戏数据一起工作的方法。

第二章。Unreal 中的脚本和数据

现在我们有了可以工作的设计,我们可以开始开发游戏了。

然而,在我们能够这样做之前,我们将探索各种方法,了解我们如何在 Unreal 游戏引擎中与游戏代码和游戏数据一起工作。

本章将指导您完成安装 Unreal 和 Visual Studio 以及创建新的 Unreal Engine 项目的步骤。此外,您还将学习如何创建新的 C++游戏代码,使用蓝图和蓝图图,以及使用自定义数据为您的游戏工作。在本章中,我们将涵盖以下主题:

  • 下载 Unreal

  • 为 Unreal 设置 Visual Studio

  • 设置新的 Unreal 项目

  • 创建新的 C++类

  • 创建蓝图和蓝图图

  • 使用数据表导入电子表格数据

下载 Unreal

在开始之前,请确保您的计算机上至少有 18 GB 的空闲磁盘空间。您需要这些磁盘空间来存储 Unreal 的开发环境和项目文件。

我们现在需要下载 Unreal。为此,请访问www.unrealengine.com并点击GET UNREAL按钮。

在您下载 Unreal 之前,您需要创建一个 Epic Games 账户。GET UNREAL按钮将带您到一个账户创建表单,所以填写并提交它。

登录后,您将看到下载按钮。这将下载 Epic Games Launcher 的安装程序(从这个启动器,您可以下载 Unreal 版本 4.12)。

下载 Visual Studio

我们很快就需要开始编程,所以如果您还没有,现在是下载 Visual Studio 的时候了,这是我们编写引擎和游戏逻辑框架所需的集成开发环境。幸运的是,Microsoft 免费提供了 Visual Studio Community。

要下载 Visual Studio Community,请访问www.visualstudio.com/并下载 Community 2015。这将下载 Visual Studio 的安装程序。下载后,只需运行安装程序即可。请注意,Visual Studio Community 2015 默认不安装 C++,所以请确保在功能下安装 Visual C++、Visual C++ 2015 的通用工具和 C++的 Microsoft Foundation Classes。如果您没有安装 C++,您将无法在 Visual Studio 中编写或编译为 UE4 编写的代码,因为 UE4 是基于 C++构建的。

为 Unreal 设置 Visual Studio

安装 Visual Studio 后,您可以采取一些步骤来使在 Unreal 中使用 C++代码更容易。这些步骤并非绝对必要,可以安全地跳过。

添加“解决方案平台”下拉列表

工具栏的右侧有一个下拉箭头,如下面的截图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_01.jpg

点击此按钮,将鼠标悬停在AddRemove按钮上,然后点击Solution Platforms以将菜单添加到工具栏。

Solution Platforms下拉列表允许你在目标平台之间切换项目(例如,Windows、Mac 等)。

禁用错误列表标签

Visual Studio 中的错误列表在你编译项目之前显示它检测到的错误。虽然这通常非常有用,但在 Unreal 中,它可能会频繁检测到假阳性,并且比有帮助还要令人烦恼。

要禁用错误列表,首先关闭Error List标签(你可以在下面的面板中找到,如下面的截图所示):

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_10.jpg

然后,导航到Tools | Options,展开Projects and Solutions组,并取消选中Always show Error List if build finishes with errors选项:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_11.jpg

设置新的 Unreal 项目

现在你已经下载并安装了 Unreal 和 Visual Studio,我们将为我们的游戏创建一个项目。

Unreal 提供了一系列你可以使用的入门套件,但为了我们的游戏,我们将从头开始编写所有脚本。

在登录 Epic Games Launcher 后,你首先想要下载 Unreal Engine。本书使用版本 4.12。你可以使用更高版本,但根据版本的不同,一些代码和引擎的导航可能会有所不同。创建新项目的步骤如下:

  1. 首先,在Unreal Engine标签下,选择Library。然后,在Engine Versions下,点击Add Versions并选择你想要下载的版本。

  2. 下载完引擎后,点击Launch按钮。

  3. 一旦 Unreal Engine 启动,点击New Project标签。然后,点击C++标签并选择Basic Code

  4. 最后,选择你的项目位置并给它命名(在我的情况下,我给项目命名为RPG)。

在我的情况下,创建项目后,引擎会自动关闭并打开 Visual Studio。在这个时候,我发现最好关闭 Visual Studio,回到 Epic Games Launcher,并重新启动引擎。然后,从这里打开你的新项目。最后,在编辑器启动后,转到File | Open Visual Studio

原因是,虽然你可以通过编译 Visual Studio 项目来启动编辑器,但在某些罕见情况下,你可能每次想要编译新的更改时都必须关闭编辑器。另一方面,如果你从编辑器(而不是反过来)启动 Visual Studio,你可以在 Visual Studio 中做出更改,然后从编辑器内部编译代码。

到目前为止,你已经有一个空白的 Unreal 项目和准备就绪的 Visual Studio。

创建新的 C++类

我们现在将按照以下步骤创建一个新的 C++类:

  1. 要做到这一点,从 Unreal 编辑器中,点击 文件 | 新建 C++ 类。我们将创建一个演员类,因此选择 Actor 作为基类。演员是放置在场景中的对象(从网格到灯光,再到声音等等)。

  2. 接下来,为你的新类输入一个名称,例如 MyNewActor。点击 创建类。在它将文件添加到项目后,在 Visual Studio 中打开 MyNewActor.h 文件。当你使用此界面创建新类时,它将为你的类生成一个头文件和一个源文件。

  3. 让我们的演员在游戏开始时向输出日志打印一条消息。为此,我们将使用 BeginPlay 事件。BeginPlay 在游戏开始后调用(在多人游戏中,这可能在初始倒计时之后调用,但在我们的情况下,它将立即调用)。

  4. 在此点应该已经打开的 MyNewActor.h 文件应该在 GENERATED_BODY() 行之后包含以下代码:

    public:   virtual void BeginPlay();
    
  5. 然后,在 MyNewActor.cpp 文件中,添加一个日志,在 void AnyNewActor::BeginPlay() 函数中打印 Hello, world!,该函数在游戏开始时运行:

    void AnyNewActor::BeginPlay()
    {
        Super::BeginPlay();
    
        GEngine->AddOnScreenDebugMessage(-1, 15.0f, FColor::Yellow, TEXT("Hello World!"));
    }
    
  6. 然后,切换回编辑器并点击主工具栏中的 编译 按钮。

  7. 现在既然你的演员类已经编译,我们需要将其添加到场景中。为此,导航到屏幕底部的 内容浏览器 选项卡。搜索 MyNewActor(有一个搜索栏帮助你找到它),并将其拖入场景视图,即级别视口。它是不可见的,所以你看不到它或无法点击它。然而,如果你将右侧的 场景/世界大纲 窗格(在右侧)滚动到底部,你应该会看到 MyNewActor1 演员已经被添加到场景中:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_02.jpg

  8. 要测试你的新演员类,点击 播放 按钮。你应该在控制台看到一条黄色的 Hello, world! 消息,如下面的截图所示。这可以在屏幕底部的 内容浏览器 选项卡右侧的 输出日志 选项卡中看到:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_03.jpg

恭喜你,你在 Unreal 中创建了第一个演员类。

蓝图

Unreal 中的蓝图是一种基于 C++ 的专有可视化脚本语言。蓝图将允许我们创建代码,而无需在 Visual Studio 等集成开发环境(IDE)中触摸任何一行文本。相反,蓝图允许我们通过拖放可视化节点来创建代码,并将它们连接起来以创建你想要的几乎所有功能。那些从 UDK 过来的人可能会在 Kismet 和蓝图之间发现一些相似之处,但与 Kismet 不同,蓝图允许你对函数和变量的创建和修改拥有完全控制权。它还会进行编译,这是 Kismet 所没有的功能。

蓝图可以继承自 C++ 类,或者从其他蓝图继承。例如,你可能有一个 Enemy 类。敌人可能有一个 健康 字段,一个 速度 字段,一个 攻击 字段和一个 网格 字段。然后,你可以通过创建继承自你的 Enemy 类的蓝图并更改每种敌人的健康、速度、攻击和网格来创建多个敌人模板。

你还可以将你的 C++ 代码的部分暴露给蓝图图,以便你的蓝图图和核心游戏代码可以相互通信并协同工作。例如,你的库存代码可能是在 C++ 中实现的,并且它可能向蓝图暴露函数,以便蓝图图可以给玩家提供物品。

创建新的蓝图

创建新蓝图的操作步骤如下:

  1. 内容浏览器 面板中,通过点击 添加新内容 下拉列表并选择 新建文件夹,然后重命名文件夹为 Blueprint,来创建一个新的蓝图文件夹。在这个文件夹中,右键单击并选择 蓝图 | 蓝图类。将蓝图作为父类选择 Actor

  2. 接下来,给你的新蓝图命名,例如 MyNewBlueprint。要编辑这个蓝图,双击 内容浏览器 选项卡中它的图标。

  3. 接下来,切换到 事件图 选项卡。

  4. 如果 事件开始播放 节点还没有在那里,右键单击图并展开 添加事件;然后,点击 事件开始播放。如果你需要移动 事件开始播放 等节点,只需在节点上左键单击并拖动到图上你想要的位置。你还可以通过按住鼠标右键并拖动屏幕来在图中导航:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_05.jpg

    这将在图中添加一个新的事件节点。

  5. 接下来,右键单击并开始在搜索栏中输入 print。你应该会在列表中看到 打印字符串 选项。点击它以将一个新的 打印字符串 节点添加到你的图中。

  6. 接下来,我们希望当 事件开始播放 节点被触发时,这个节点被触发。为此,从 事件开始播放 节点的输出箭头拖动到 打印字符串 节点的输入箭头:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_06.jpg

  7. 现在,打印字符串 节点将在游戏开始时被触发。但是,让我们更进一步,给我们的蓝图添加一个变量。

  8. 在左侧的 我的蓝图 面板中,点击 变量 按钮。给你的变量命名(例如 MyPrintString)并将 变量类型 下拉列表更改为 字符串

  9. 要将这个变量的值输入到我们的 打印字符串 节点中,右键单击并搜索 MyPrintString。你应该会在列表中看到一个可用的 获取我的打印字符串 节点。点击它以将节点添加到你的图中:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_07.jpg

  10. 接下来,就像您将事件开始播放打印字符串连接在一起一样,从获取我的打印字符串节点的输出箭头拖动到紧挨着字符串输入标签的打印字符串节点的输入引脚。

  11. 最后,切换到默认选项卡。在最顶部,在默认部分下,应该有一个用于编辑MyPrintString变量值的文本字段。将您想要的任何文本输入到这个字段中。然后,要保存您的蓝图,首先在蓝图窗口中按编译按钮,然后点击旁边的保存按钮。

将蓝图添加到场景中

现在您已经创建了蓝图,只需将其从内容浏览器选项卡拖动到场景中。就像我们的自定义实体类一样,它将是不可见的,但如果您将场景大纲滚动到底部,您会在列表中看到MyNewBlueprint项。

要测试我们新的蓝图,请按播放按钮。您应该看到您输入的文本被短暂地打印到屏幕上(它也会出现在输出日志中,但可能难以在其他输出消息中找到)。

实体类蓝图

您可以为蓝图选择其他类进行继承。例如,让我们创建一个新的蓝图,从我们之前创建的定制MyNewActor类继承:

  1. 要这样做,首先像之前一样创建一个新的蓝图。然后,在选择父类时,搜索MyNewActor。点击列表中的MyNewActor条目:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_12.jpg

  2. 您可以为这个实体命名任何您想要的名称。接下来,打开蓝图并点击保存。现在,将蓝图添加到您的场景中并运行游戏。您现在应该在控制台看到两条**Hello, world!**消息记录(一条来自我们放置的实体,另一条来自我们新的蓝图)。

使用数据表导入电子表格数据

在虚幻引擎中,数据表是导入和使用从电子表格应用程序导出的自定义游戏数据的方法。为此,您首先确保您的电子表格遵循一些格式指南;此外,您编写一个包含电子表格一行数据的 C++结构体。然后,您导出一个 CSV 文件,并将您的 C++结构体选择为该文件的数据类型。

电子表格格式

您的电子表格必须遵循一些简单的规则,才能正确导出到虚幻引擎。

非常第一个单元格必须保持空白。在此之后,第一行将包含字段的名称。这些将与您稍后 C++结构体中的变量名相同,因此不要使用空格或其他特殊字符。

第一列将包含每个条目的查找键。也就是说,如果这个电子表格中第一项的第一单元格是 1,那么在虚幻引擎中,您将使用 1 来查找该条目。这必须对每一行都是唯一的。

然后,以下列包含每个变量的值。

一个示例电子表格

让我们创建一个简单的工作表导入到 Unreal 中。它应该看起来像这样:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_08.jpg

如前所述:

  • A包含每行的查找键。第一个单元格是空的,后面的单元格包含每行的查找键。

  • B包含SomeNumber字段的值。第一个单元格包含字段名称(SomeNumber),后面的单元格包含该字段的值。

  • C包含SomeString字段的值。就像列B一样,第一个单元格包含字段名称(SomeString),后面的单元格包含该字段的值。

我使用 Google 表格——使用这个,你会点击文件 | 另存为 | **逗号分隔值 (.csv, 当前工作表)**来导出为 CSV。大多数电子表格应用程序都有导出为 CSV 格式的功能。

到目前为止,你有一个可以导入 Unreal 的 CSV 文件。然而,现在不要导入它。在我们这样做之前,我们需要为它创建一个 C++结构体。

数据表结构体

正如您之前创建演员类一样,让我们创建一个新的类。选择Actor作为父类,并给它一个像TestCustomData这样的名字。我们的类实际上不会从Actor继承(并且,就这个而言,它不会是一个类),但这样做允许 Unreal 在后台为我们生成一些代码。

接下来,打开TestCustomData.h文件,并用以下代码替换整个文件:

#pragma once

#include "TestCustomData.generated.h"

USTRUCT(BlueprintType)
struct FTestCustomData : public FTableRowBase
{
  GENERATED_USTRUCT_BODY()

  UPROPERTY( BlueprintReadOnly, Category = "TestCustomData" )
  int32 SomeNumber;

  UPROPERTY( BlueprintReadOnly, Category = "TestCustomData" )
  FString SomeString;
};

注意变量名与工作表中的表头单元格完全相同——这很重要,因为它显示了 Unreal 如何将工作表中的列与该结构体的字段匹配。

接下来,从TestCustomData.cpp文件中删除所有内容,除了#include语句。

现在,切换回编辑器并点击编译。它应该没有问题编译。

现在你已经创建了结构体,是时候导入你的自定义工作表了。

导入工作表

接下来,只需将你的 CSV 文件拖放到内容浏览器标签页中。这将弹出一个窗口,询问你想要如何导入数据以及数据的类型。将第一个下拉列表保留为数据表,展开第二个下拉列表以选择TestCustomData(你刚刚创建的结构体)。

点击确定,它将导入 CSV 文件。如果你在内容浏览器标签页中双击该资产,你会看到工作表中包含的项目列表:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_02_09.jpg

查询工作表

你可以通过名称查询工作表以找到特定的行。

我们将把它添加到我们的自定义演员类MyNewActor中。首先,我们需要将一个字段暴露给蓝图,这样我们就可以为我们的演员分配一个数据表。

首先,在GENERATED_BODY行之后添加以下代码:

public:
  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "My New Actor")
  UDataTable* DataTable;

上述代码将数据表暴露给蓝图,并允许在蓝图内对其进行编辑。接下来,我们将获取第一行并记录其SomeString字段。在MyNewActor.cpp文件中,将以下代码添加到BeginPlay函数的末尾:

if( DataTable != NULL )
{
  FTestCustomData* row = DataTable->FindRow<FTestCustomData>( TEXT( "2" ), TEXT(" LookupTestCustomData"  ) );
  FString someString = row->SomeString;
  UE_LOG( LogTemp, Warning, TEXT( "%s" ), *someString );
}

您还需要在MyNewActor.cpp文件的顶部添加#include TestCustomData.h,这样您就可以在其中看到数据表属性。

在编辑器中编译代码。接下来,打开您从 actor 类创建的蓝图。切换到类默认值选项卡,找到My New Actor组(这应该在最顶部)。这应该显示一个可以展开以选择您导入的 CSV 文件的数据表字段。

编译并保存蓝图,然后按播放。你应该能在控制台中看到条目2SomeString值。

摘要

在本章中,我们设置了 Unreal 和 Visual Studio,并创建了一个新项目。此外,我们学习了如何在 C++中创建新的 actor 类,什么是蓝图,以及如何创建和使用蓝图图进行可视化脚本编写。最后,我们学习了如何从电子表格应用程序导入自定义数据,并在游戏代码中查询它们。

在下一章,我们将开始深入研究一些实际的游戏代码,并开始原型化我们的游戏。

第三章. 探索与战斗

我们已经为我们的游戏设计了游戏界面,并设置了用于游戏的 Unreal 项目。现在是时候深入实际的游戏代码了。

在这一章中,我们将制作一个在世界上移动的游戏角色,定义我们的游戏数据,并为游戏原型设计一个基本的战斗系统。本章将涵盖以下主题:

  • 创建玩家角色

  • 定义角色、类和敌人

  • 跟踪活跃的队伍成员

  • 创建一个基本的回合制战斗引擎

  • 触发游戏结束屏幕

这一部分是本书中最注重 C++的部分,并为本书的其余部分提供了一个基本框架。由于这一章节提供了我们游戏的后端大部分内容,因此在这一章节中的代码必须完整无误地工作,才能继续阅读本书的其余内容。如果你购买这本书是因为你是一名程序员,正在寻找更多关于创建 RPG 框架的背景知识,那么这一章节就是为你准备的!如果你购买这本书是因为你是一名设计师,更关心在框架上构建而不是从头开始编程,你可能对即将到来的章节更感兴趣,因为那些章节包含更少的 C++和更多的 UMG 和蓝图。无论你是谁,下载前言中提供的源代码都是一个好主意,以防你遇到困难或想根据你的兴趣跳过某些章节。

创建玩家角色

我们将要做的第一件事是创建一个新的 Pawn 类。在 Unreal 中,Pawn是角色的表示。它处理角色的移动、物理和渲染。

这就是我们的角色 Pawn 将要如何工作。玩家分为两部分:有一个 Pawn,如前所述,负责处理移动、物理和渲染。然后是 Player Controller,负责将玩家的输入转换为让 Pawn 执行玩家想要的动作。

Pawn

现在,让我们创建实际的 Pawn。

创建一个新的 C++类,并将其父类设置为Character。我们将从这个Character类中派生这个类,因为Character类有很多内置的移动函数,我们可以为我们的场地上玩家使用。将类命名为RPGCharacter。打开RPGCharacter.h,并使用以下代码更改类定义:

UCLASS(config = Game)
class ARPGCharacter : public ACharacter
{
  GENERATED_BODY()

  /** Camera boom positioning the camera behind the character */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true")) class USpringArmComponent* CameraBoom;

  /** Follow camera */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true")) class UCameraComponent* FollowCamera;
public:
  ARPGCharacter();

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

protected:

  /** Called for forwards/backward input */
  void MoveForward(float Value);

  /** Called for side to side input */
  void MoveRight(float Value);

  /**
  * Called via input to turn at a given rate.
  * @param Rate  This is a normalized rate, i.e. 1.0 means 100% of desired turn rate
  */
  void TurnAtRate(float Rate);

protected:
  // APawn interface
virtual void SetupPlayerInputComponent(class UInputComponent* InputComponent) override;
  // End of APawn interface

public:
  /** Returns CameraBoom subobject **/
FORCEINLINE class USpringArmComponent* GetCameraBoom() const { return CameraBoom; }
  /** Returns FollowCamera subobject **/
FORCEINLINE class UCameraComponent* GetFollowCamera() const { return FollowCamera; }
};

接下来,打开RPGCharacter.cpp,并用以下代码替换它:

#include "RPG.h"
#include "RPGCharacter.h"

ARPGCharacter::ARPGCharacter()
{
  // Set size for collision capsule
  GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f);

  // set our turn rates for input
  BaseTurnRate = 45.f;

// Don't rotate when the controller rotates. //Let that just affect the camera.
  bUseControllerRotationPitch = false;
  bUseControllerRotationYaw = false;
  bUseControllerRotationRoll = false;

  // Configure character movement
// Character moves in the direction of input...
GetCharacterMovement()->bOrientRotationToMovement = true; // ...at this rotation rate  
GetCharacterMovement()->RotationRate = FRotator(0.0f, 540.0f, 0.0f); 

  // Create a camera boom
CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
  CameraBoom->SetupAttachment(RootComponent);
// The camera follows at this distance behind the character  CameraBoom->TargetArmLength = 300.0f; 
CameraBoom->RelativeLocation = FVector(0.f, 0.f, 500.f);// Rotate the arm based on the controller
CameraBoom->bUsePawnControlRotation = true; 

// Create a follow camera
FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName); // Camera does not rotate relative to arm
FollowCamera->bUsePawnControlRotation = false;  FollowCamera->RelativeRotation = FRotator(-45.f, 0.f, 0.f);

/* Note: The skeletal mesh and anim blueprint references on the Mesh component (inherited from Character) are set in the derived blueprint asset named MyCharacter (to avoid direct content references in C++)*/
}

//////////////////////////////////////////////////////////////////
// Input

void ARPGCharacter::SetupPlayerInputComponent(class UInputComponent* InputComponent)
{
  // Set up gameplay key bindings
  check(InputComponent);

InputComponent->BindAxis("MoveForward", this, &ARPGCharacter::MoveForward);
InputComponent->BindAxis("MoveRight", this, &ARPGCharacter::MoveRight);

/* We have 2 versions of the rotation bindings to handle different kinds of devices differently "turn" handles devices that provide an absolute delta, such as a mouse. "turnrate" is for devices that we choose to treat as a rate of change, such as an analog joystick*/
InputComponent->BindAxis("Turn", this, &APawn::AddControllerYawInput);
InputComponent->BindAxis("TurnRate", this, &ARPGCharacter::TurnAtRate);
}

void ARPGCharacter::TurnAtRate(float Rate)
{
  // calculate delta for this frame from the rate information
AddControllerYawInput(Rate * BaseTurnRate * GetWorld()->GetDeltaSeconds());
}

void ARPGCharacter::MoveForward(float Value)
{
  if ((Controller != NULL) && (Value != 0.0f))
  {
    // find out which way is forward
    const FRotator Rotation = Controller->GetControlRotation();
    const FRotator YawRotation(0, Rotation.Yaw, 0);

    // get forward vector
    const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
    AddMovementInput(Direction, Value);
  }
}

void ARPGCharacter::MoveRight(float Value)
{
  if ((Controller != NULL) && (Value != 0.0f))
  {
    // find out which way is right
    const FRotator Rotation = Controller->GetControlRotation();
    const FRotator YawRotation(0, Rotation.Yaw, 0);

    // get right vector 
    const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
    // add movement in that direction
    AddMovementInput(Direction, Value);
  }
}

如果你曾经创建并使用过 C++的ThirdPerson游戏模板,你会注意到我们在这里并没有重新发明轮子。RPGCharacter类应该看起来很熟悉,因为它是我们创建 C++ ThirdPerson 模板时提供的Character类代码的修改版本,由 Epic Games 提供给我们使用。

由于我们不是在制作快节奏的动作游戏,而是简单地将 Pawn 用作 RPG 角色在战场上移动,因此我们消除了与动作游戏经常相关的机制,例如跳跃。但我们保留了对我们重要的代码,这包括向前、向后、向左和向右移动的能力;Pawn 的旋转行为;一个以等距视角跟随角色的相机;一个用于角色能够与可碰撞对象碰撞的碰撞胶囊;角色移动的配置;以及一个相机吊杆,它允许相机在角色遇到墙壁或其他网格等碰撞时靠近角色,这对于不让玩家视线受阻非常重要。如果您想编辑角色机制,请随意通过遵循代码中的注释来更改某些特定机制的价值,例如将TargetArmLength的值更改以改变相机与玩家之间的距离,或者添加跳跃,这可以在与引擎一起提供的 ThirdPerson 角色模板中看到。

由于我们是从Character类派生出了RPGCharacter类,其默认相机在等距视角下没有旋转;相反,相机的旋转和位置默认为零,设置为 Pawn 的位置。所以我们做的是在RPGCharacter.cpp中添加了一个相对位置CameraBoomCameraBoom->RelativeLocation = FVector(0.f, 0.f, 500.f););这使相机在Z轴上向上偏移了 500 个单位。与旋转跟随玩家的相机-45 单位在俯仰(FollowCamera->RelativeRotation = FRotator(-45.f, 0.f, 0.f);)一起,我们得到了传统的等距视角。如果您想编辑这些值以进一步自定义相机,建议这样做;例如,如果您仍然认为您的相机离玩家太近,您只需将CameraBoomZ轴上的相对位置更改为大于 500 个单位的值,或者调整TargetArmLength到一个大于 300 的值。

最后,如果你查看 MoveForwardMoveRight 移动函数,你会注意到,除非传递给 MoveForwardMoveRight 的值不等于 0,否则不会向 pawn 添加任何移动。在本章的后面部分,我们将把键 WASD 绑定到这些函数上,并将每个输入设置为传递 1 或 -1 的标量值给相应的移动函数。这个 1 或 -1 的值随后用作 pawn 方向的乘数,这将允许玩家根据其行走速度向特定方向移动。例如,如果我们把 W 作为 MoveForward 的键绑定并使用标量 1,把 S 作为 MoveFoward 的键绑定并使用标量 -1,当玩家按下 W 时,MoveFoward 函数中的值将等于 1,从而使 pawn 向正前方移动。相反,如果玩家按下 S 键,-1 就会被传递到 MoveForward 函数使用的值中,这将使 pawn 向负前方移动(换句话说,向后)。关于 MoveRight 函数的类似逻辑也可以这么说,这就是为什么我们没有 MoveLeft 函数——仅仅是因为按下 A 键会导致玩家向负右方向移动,这实际上是向左。

游戏模式类

现在,为了使用这个 pawn 作为玩家角色,我们需要设置一个新的游戏模式类。这个游戏模式将指定默认的 pawn 和玩家控制器类。我们还将能够创建游戏模式的蓝图并覆盖这些默认设置。

创建一个新的类,并将 GameMode 作为父类。将这个新类命名为 RPGGameMode(如果 RPGGameMode 在你的项目中已经存在,只需导航到你的 C++ 源代码目录,然后继续打开 RPGGameMode.h,如下一步所述)。

打开 RPGGameMode.h 并使用以下代码更改类定义:

UCLASS()
class RPG_API ARPGGameMode : public AGameMode
{
  GENERATED_BODY()

  ARPGGameMode( const class FObjectInitializer& ObjectInitializer );
};

就像我们之前做的那样,我们只是在定义一个用于实现 CPP 文件的构造函数。

现在,我们将在这个 RPGGameMode.cpp 中实现那个构造函数:

#include "RPGCharacter.h"

ARPGGameMode::ARPGGameMode( const class FObjectInitializer& ObjectInitializer )
  : Super( ObjectInitializer )
{

  DefaultPawnClass = ARPGCharacter::StaticClass();
}

在这里,我们包含 RPGCharacter.h 文件,以便我们可以引用这些类。然后,在构造函数中,我们将该类设置为 Pawn 的默认类。

现在,如果你编译这段代码,你应该能够将你的新游戏模式类作为默认游戏模式。为此,转到 编辑 | 项目设置,找到 默认模式 框,展开 默认游戏模式 下拉菜单,并选择 RPGGameMode

然而,我们并不一定想直接使用这个类。相反,如果我们创建一个蓝图,我们可以暴露游戏模式中可以修改的属性。

因此,让我们在 内容 | 蓝图 中创建一个新的蓝图类,将其父类选择为 RPGGameMode,并将其命名为 DefaultRPGGameMode

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_01.jpg

如果你打开蓝图并导航到默认选项卡,你可以修改游戏模式设置,包括默认兵类HUD 类玩家控制器类等更多设置:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_02.jpg

然而,在我们能够测试我们新的兵之前,我们还需要额外的一步。如果你运行游戏,你将完全看不到兵。实际上,它看起来就像什么都没发生一样。我们需要给我们的兵一个带皮肤的网格,并且让摄像机跟随兵。

添加带皮肤的网格

现在,我们只是将要导入与 ThirdPerson 示例一起提供的原型角色。为此,基于 ThirdPerson 示例创建一个新的项目。在内容 | ThirdPersonCPP | 蓝图中找到 ThirdPersonCharacter 蓝图类,通过右键单击 ThirdPersonCharacter 蓝图类并导航到资产操作 | 迁移…将其迁移到 RPG 项目的内容文件夹。此操作应将 ThirdPersonCharacter 及其所有资产复制到你的 RPG 项目中:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_03.jpg

现在,让我们为我们的兵创建一个新的蓝图。创建一个新的蓝图类,并将RPGCharacter作为父类。将其命名为FieldPlayer

当从组件选项卡中选择网格组件时,在详细信息选项卡中展开网格,并将SK_Mannequin作为兵的骨骼网格选择。接下来,展开动画并选择要使用的ThirdPerson_AnimBP动画蓝图。你很可能会需要将角色的网格沿 z 轴向下移动,以便角色的脚底与碰撞胶囊的底部对齐。同时确保角色网格面向与组件中蓝色箭头相同的方向。你可能还需要在 z 轴上旋转角色,以确保角色面向正确的方向:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_04.jpg

最后,打开你的游戏模式蓝图,并将兵更改为你的新FieldPlayer蓝图。

现在,我们的角色将变得可见,但我们可能还不能移动它,因为我们还没有将任何按键绑定到我们的移动变量上。要做到这一点,请进入项目设置并找到输入。展开绑定然后展开轴映射。通过按**+按钮添加一个轴映射。将第一个轴映射命名为MoveRight**,它应该与您在本章 earlier 创建的 MoveRight 移动变量相匹配。通过按**+按钮添加两个MoveRight的键绑定。让其中一个键是 A,缩放为 -1,另一个是 D,缩放为 1。为MoveForward**添加另一个轴映射;这次,有一个 W 键绑定,缩放为 1,一个 S 键绑定,缩放为 -1:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_05.jpg

一旦进行游戏测试,你应该会看到你的角色使用你绑定的 WASD 键移动和动画。

当你运行游戏时,摄像机应该以俯视角度跟踪玩家。现在我们有一个可以探索游戏世界的角色,让我们来看看如何定义角色和队伍成员。

定义角色和敌人

在上一章中,我们介绍了如何使用数据表导入自定义数据。在此之前,我们决定哪些属性会影响战斗以及如何影响。现在,我们将结合这些属性来定义我们的游戏角色、类别和敌人遭遇。

类别

记住,在第一章中,我们确立了我们的角色具有以下属性:

  • 生命值

  • 最大生命值

  • 魔法

  • 最大魔法值

  • 攻击力

  • 防御力

  • 幸运值

在这些属性中,我们可以丢弃生命值和魔法值,因为它们在游戏过程中会变化,而其他值是基于角色类别预先定义的。剩余的属性是我们将在数据表中定义的。如第一章中所述,我们还需要存储在 50 级(最大等级)时的值。角色还将有一些初始能力,以及随着等级提升而学习的能力。

我们将在角色类别电子表格中定义这些属性,包括类名。因此,我们的角色类别架构将类似于以下内容:

  • 类名(字符串)

  • 初始最大生命值(整数)

  • 50 级最大生命值(整数)

  • 初始最大魔法值(整数)

  • 50 级最大魔法值(整数)

  • 初始攻击力(整数)

  • 50 级攻击力(整数)

  • 初始防御力(整数)

  • 50 级防御力(整数)

  • 初始幸运值(整数)

  • 50 级幸运值(整数)

  • 初始能力(字符串数组)

  • 学到的能力(字符串数组)

  • 学到的能力等级(整数数组)

能力字符串数组将包含能力的 ID(UE4 中保留的name字段的值)。此外,还有两个单独的单元格用于学习到的能力——一个包含能力 ID,另一个包含学习这些能力时的等级。

在一个生产游戏中,你可能考虑写一个自定义工具来帮助管理这些数据并减少人为错误。然而,编写这样的工具超出了本书的范围。

现在,我们不是为这个创建电子表格,而是首先在 Unreal 中创建类,然后创建数据表。这样做的原因是,在撰写本文时,指定数据表单元格中数组的正确语法并未得到很好的记录。然而,数组仍然可以从 Unreal 编辑器内部进行编辑,因此我们只需在那里创建表格并使用 Unreal 的数组编辑器。

首先,像往常一样,创建一个新的类。这个类将用作你可以从中调用的对象,因此选择Object作为父类。将此类命名为FCharacterClassInfo,为了组织目的,将新类路径到你的Source/RPG/Data文件夹。

打开FCharacterClassInfo.h并将类定义替换为以下代码:

USTRUCT( BlueprintType )
struct FCharacterClassInfo : public FTableRowBase
{
  GENERATED_USTRUCT_BODY()

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    FString Class_Name;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartMHP;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartMMP;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartATK;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartDEF;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartLuck;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndMHP;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndMMP;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndATK;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndDEF;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndLuck;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    TArray<FString> StartingAbilities;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    TArray<FString> LearnedAbilities;

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    TArray<int32> LearnedAbilityLevels;
};

这段代码中的大部分您可能已经熟悉;然而,您可能不认识最后三个字段。这些都是TArray类型,这是 Unreal 提供的一种动态数组类型。本质上,TArray可以动态地向其中添加元素并从中移除元素,这与标准 C++数组不同。

编译此代码后,在您的“内容”文件夹内创建一个名为Data的新文件夹,以便通过将您创建的数据表保存在Data文件夹中保持组织有序。在内容浏览器中导航到内容 | 数据,通过右键单击内容浏览器并选择杂项 | 数据表来创建一个新的数据表。然后,从下拉列表中选择角色类信息。将您的数据表命名为CharacterClasses,然后双击打开它。

要添加新条目,请点击**+按钮。然后,在行名称**字段中输入新条目的名称并按Enter键。

添加条目后,您可以在数据表面板中选择条目,并在行编辑器面板中编辑其属性。

让我们在列表中添加一个士兵类。我们将给它命名为S1(我们将用它来引用其他数据表中的角色类)并且它将具有以下属性:

  • 类名:士兵

  • 开始 MHP:100

  • 开始 MMP:100

  • 开始 ATK:5

  • 开始 DEF:0

  • 开始幸运:0

  • 结束 MHP:800

  • 结束 MMP:500

  • 结束 ATK:20

  • 结束 DEF:20

  • 结束幸运:10

  • 起始能力:(目前留空)

  • 学习能力:(目前留空)

  • 学习能力等级:(目前留空)

当你完成时,你的数据表应该看起来像这样:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_06.jpg

如果您想定义更多的角色类,请继续将它们添加到您的数据表中。

角色

在定义了类之后,让我们来看看角色。由于大多数重要的战斗相关数据已经作为角色类的一部分定义,因此角色本身将会相当简单。实际上,目前我们的角色将由以下两点定义:角色的名称和角色的类。

首先,创建一个名为FCharacterInfo的新 C++类,其父类为Object,并将其路径设置为Source/RPG/Data文件夹。现在,将FCharacterInfo.h中的类定义替换为以下内容:

USTRUCT(BlueprintType)
struct FCharacterInfo : public FTableRowBase
{
  GENERATED_USTRUCT_BODY()

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "CharacterInfo" )
  FString Character_Name;

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "CharacterInfo" )
  FString Class_ID;
};

就像我们之前做的那样,我们只是定义了角色的两个字段(角色名称和类 ID)。

编译后,在您之前创建的内容浏览器中的Data文件夹内创建一个新的数据表,并选择CharacterInfo作为类;命名为Characters。添加一个名为S1的新条目。您可以给这个角色起任何名字(我们给我们的角色命名为士兵Kumo),但请在类 ID 中输入S1(因为这是我们之前定义的士兵类的名称)。

敌人

至于敌人,我们不会为单独的角色和职业信息定义一个类,而是为这两部分信息创建一个简化的组合表。敌人通常不需要处理经验和升级,因此我们可以省略与此相关的任何数据。此外,敌人不会像玩家那样消耗 MP,因此我们也可以省略这部分数据。

因此,我们的敌人数据将具有以下属性:

  • 敌人名称(字符串数组)

  • 最大生命值(整数)

  • 攻击力(整数)

  • 防御力(整数)

  • 幸运值(整数)

  • 能力(字符串数组)

与之前的数据类创建类似,我们创建一个新的从 Object 派生的 C++ 类,但这次我们将它命名为 FEnemyInfo 并将其放置在 Source/RPG/Data 目录中的其他数据旁边。

在这个阶段,你应该已经了解了如何为这些数据构建类,但无论如何,让我们看一下结构头文件。在 FEnemyInfo.h 中,将你的类定义替换为以下内容:

USTRUCT( BlueprintType )
struct FEnemyInfo : public FTableRowBase
{
  GENERATED_USTRUCT_BODY()

  UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "EnemyInfo" )
    FString EnemyName;

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 MHP;

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 ATK;

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 DEF;

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 Luck;

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    TArray<FString> Abilities;
};

编译完成后,创建一个新的数据表,选择 EnemyInfo 作为类,并将数据表命名为 Enemies。添加一个名为 S1 的新条目,并具有以下属性:

  • 敌人名称: 哥布林

  • 最大生命值: 20

  • 攻击力: 5

  • DEF: 0

  • 幸运值: 0

  • 能力:(目前留空)

到目前为止,我们已经有了角色的数据、角色的职业以及角色要与之战斗的单一敌人。接下来,让我们开始跟踪哪些角色在活动队伍中,以及他们的当前状态。

队伍成员

在我们能够跟踪队伍成员之前,我们需要一种方法来跟踪角色的当前状态,比如角色有多少 HP 或者装备了什么。

为了做到这一点,我们将创建一个新的类名为 GameCharacter。像往常一样,创建一个新的类并将 Object 作为父类。

这个类的头文件看起来像以下代码片段:

#pragma once

#include "Data/FCharacterInfo.h"
#include "Data/FCharacterClassInfo.h"

#include "GameCharacter.generated.h"

UCLASS( BlueprintType )
class RPG_API UGameCharacter : public UObject
{
  GENERATED_BODY()

public:
  FCharacterClassInfo* ClassInfo;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  FString CharacterName;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  int32 MHP;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  int32 MMP;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  int32 HP;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  int32 MP;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  int32 ATK;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  int32 DEF;

  UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
  int32 LUCK;

public:
  static UGameCharacter* CreateGameCharacter( FCharacterInfo* characterInfo, UObject* outer );

public:
  void BeginDestroy() override;
};

目前,我们正在跟踪角色的名字、角色的来源职业信息以及角色的当前状态。稍后,我们将使用 UCLASSUPROPERTY 宏将信息暴露给蓝图。我们将在开发战斗系统时添加其他信息。

对于 .cpp 文件,它看起来像这样:

#include "RPG.h"
#include "GameCharacter.h"

UGameCharacter* UGameCharacter::CreateGameCharacter( FCharacterInfo* characterInfo, UObject* outer )
{
  UGameCharacter* character = NewObject<UGameCharacter>( outer );

  // locate character classes asset
  UDataTable* characterClasses = Cast<UDataTable>( StaticLoadObject( UDataTable::StaticClass(), NULL, TEXT( "DataTable'/Game/Data/CharacterClasses.CharacterClasses'" ) ) );

  if( characterClasses == NULL )
  {
    UE_LOG( LogTemp, Error, TEXT( "Character classes datatable not found!" ) );
  }
  else
  {
    character->CharacterName = characterInfo->Character_Name;
    FCharacterClassInfo* row = characterClasses->FindRow<FCharacterClassInfo>( *( characterInfo->Class_ID ), TEXT( "LookupCharacterClass" ) );
    character->ClassInfo = row;

    character->MHP = character->ClassInfo->StartMHP;
    character->MMP = character->ClassInfo->StartMMP;
    character->HP = character->MHP;
    character->MP = character->MMP;

    character->ATK = character->ClassInfo->StartATK;
    character->DEF = character->ClassInfo->StartDEF;
    character->LUCK = character->ClassInfo->StartLuck;
  }

  return character;
}

void UGameCharacter::BeginDestroy()
{
  Super::BeginDestroy();
}

我们 UGameCharacter 类的 CreateGameCharacter 工厂函数接受一个指向 FCharacterInfo 结构的指针,该结构由数据表返回,并且还接受一个 Outer 对象,该对象传递给 NewObject 函数。然后它尝试从一个路径中找到角色类数据表,如果结果不为空,它将定位到数据表中的正确行,存储结果,并初始化状态和 CharacterName 字段。在前面的代码中,你可以看到角色类数据表所在的路径。你可以通过在内容浏览器中右键单击你的数据表,选择 复制引用,然后将结果粘贴到你的代码中。

虽然这目前是一个非常基本的骨架式的人物表示,但暂时可以工作。接下来,我们将存储这些人物的列表作为当前党派。

游戏实例类

我们已经创建了一个GameMode类,这似乎是跟踪党派成员和库存等信息的一个完美的位置,对吧?

然而,GameMode在关卡加载之间不会持久化!这意味着除非你将一些信息保存到磁盘上,否则每次加载新区域时都会丢失所有这些数据。

GameInstance类是为了解决这类问题而引入的。GameInstance类在整个游戏过程中都保持持久,与GameMode不同。我们将创建一个新的GameInstance类来跟踪我们的持久数据,例如党派成员和库存。

创建一个新的类,这次选择GameInstance作为父类(你可能需要搜索它)。将其命名为RPGGameInstance

在头文件中,我们将添加一个UGameCharacter指针的TArray,一个表示游戏是否已初始化的标志,以及一个Init函数。你的RPGGameInstance.h文件应该看起来像这样:

#pragma once

#include "Engine/GameInstance.h"
#include "GameCharacter.h"
#include "RPGGameInstance.generated.h"
UCLASS()
class RPG_API URPGGameInstance : public UGameInstance
{
  GENERATED_BODY()

  URPGGameInstance( const class FObjectInitializer& ObjectInitializer );

public:
  TArray<UGameCharacter*> PartyMembers;

protected:
  bool isInitialized;

public:
  void Init();
};

在游戏实例的Init函数中,我们将添加一个默认的党派成员,并将isInitialized标志设置为true。你的RPGGameInstance.cpp应该看起来像这样:

#include "RPG.h"
#include "RPGGameInstance.h"
URPGGameInstance::URPGGameInstance(const class FObjectInitializer& 
ObjectInitializer)
: Super(ObjectInitializer)
{
  isInitialized = false;
}

void URPGGameInstance::Init()
{
  if( this->isInitialized ) return;

  this->isInitialized = true;

  // locate characters asset
  UDataTable* characters = Cast<UDataTable>( StaticLoadObject( UDataTable::StaticClass(), NULL, 
TEXT( "DataTable'/Game/Data/Characters.Characters'" ) ) );
        if( characters == NULL )
  {
    UE_LOG( LogTemp, Error, TEXT( "Characters data table not found!" ) );

    return;
  }

  // locate character
  FCharacterInfo* row = characters->FindRow<FCharacterInfo>( TEXT( "S1" ), TEXT( "LookupCharacterClass" ) );

  if( row == NULL )
  {
    UE_LOG( LogTemp, Error, TEXT( "Character ID 'S1' not found!" ) );
    return;
  }

  // add character to party
  this->PartyMembers.Add( UGameCharacter::CreateGameCharacter( row, this ) );
}

如果你尝试编译,可能会遇到链接错误。建议在继续之前保存并关闭所有内容。然后重新启动你的项目。之后,编译项目。

要将此类设置为你的GameInstance类,在 Unreal 中,打开编辑 | 项目设置,转到地图与模式,滚动到游戏实例框,并从下拉列表中选择RPGGameInstance。最后,从游戏模式中重写BeginPlay以调用此Init函数。

打开RPGGameMode.h,并在你的类末尾添加virtual void BeginPlay() override;,这样你的头文件现在看起来像这样:

#pragma once

#include "GameFramework/GameMode.h"
#include "RPGGameMode.generated.h"

UCLASS()
class RPG_API ARPGGameMode : public AGameMode
{
  GENERATED_BODY()

  ARPGGameMode(const class FObjectInitializer& ObjectInitializer);
  virtual void BeginPlay() override;
};

并且在RPGGameMode.cpp中,在BeginPlay时将RPGGameInstance进行转换,这样RPGGameMode.cpp现在看起来像这样:

#include "RPG.h"
#include "RPGGameMode.h"
#include "RPGCharacter.h"
#include "RPGGameInstance.h"

ARPGGameMode::ARPGGameMode(const class FObjectInitializer& 
ObjectInitializer)
: Super(ObjectInitializer)
{
  DefaultPawnClass = ARPGCharacter::StaticClass();
   }

void ARPGGameMode::BeginPlay()
{
  Cast<URPGGameInstance>(GetGameInstance())->Init();
}

一旦编译代码,你现在就有一个活跃的党派成员列表。现在是时候开始原型设计战斗引擎了。

回合制战斗

所以,如第一章中提到的,在 Unreal 中开始 RPG 设计,战斗是回合制的。所有角色首先选择要执行的动作;然后,按照顺序执行这些动作。

战斗将分为两个主要阶段:决策,在这个阶段,所有角色决定他们的行动方案;和行动,在这个阶段,所有角色执行他们选择的行动方案。

让我们创建一个没有父类的类来为我们处理战斗,我们可以将其称为CombatEngine,并将其放置在Source/RPG/Combat的新目录中,我们可以在这里组织所有与战斗相关的类。将头文件制定如下:

#pragma once
#include "RPG.h"
#include "GameCharacter.h"

enum class CombatPhase : uint8
{
  CPHASE_Decision,
  CPHASE_Action,
  CPHASE_Victory,
  CPHASE_GameOver,
};

class RPG_API CombatEngine
{
public:
  TArray<UGameCharacter*> combatantOrder;
  TArray<UGameCharacter*> playerParty;
  TArray<UGameCharacter*> enemyParty;

  CombatPhase phase;

protected:
  UGameCharacter* currentTickTarget;
  int tickTargetIndex;

public:
  CombatEngine( TArray<UGameCharacter*> playerParty, TArray<UGameCharacter*> enemyParty );
  ~CombatEngine();

  bool Tick( float DeltaSeconds );

protected:
  void SetPhase( CombatPhase phase );
  void SelectNextCharacter();
};

这里有很多事情在进行,所以我将进行解释。

首先,我们的战斗引擎设计为在遭遇开始时分配,在战斗结束时删除。

CombatEngine 实例维护了三个 TArray:一个用于战斗顺序(所有参与战斗的成员列表,按照他们轮流行动的顺序),另一个用于玩家列表,第三个用于敌人列表。它还跟踪 CombatPhase。战斗有两个主要阶段:DecisionAction。每一轮从 Decision 阶段开始;在这个阶段,所有角色都可以选择他们的行动方案。然后,战斗过渡到 Action 阶段;在这个阶段,所有角色将执行他们之前选择的行动方案。

当所有敌人死亡或所有玩家死亡时,将过渡到 GameOverVictory 阶段(这就是为什么玩家和敌人列表被分开保留的原因)。

CombatEngine 类定义了一个 Tick 函数。这个函数将在战斗未结束的情况下,由每一帧的游戏模式调用,当战斗结束时返回 true(否则返回 false)。它接受上一帧的持续时间作为参数。

还有 currentTickTargettickTargetIndex。在 DecisionAction 阶段,我们将保持对单个角色的指针。例如,在 Decision 阶段,这个指针从战斗顺序中的第一个角色开始。在每一帧,都会要求角色做出决策——这将是一个返回 true 如果角色已经做出决策,否则返回 false 的函数。如果函数返回 true,指针将移动到下一个角色,依此类推,直到所有角色都做出决策,此时战斗过渡到 Action 阶段。

这个文件的 CPP 代码相当大,所以让我们分块来看。首先,构造函数和析构函数如下:

CombatEngine::CombatEngine( TArray<UGameCharacter*> playerParty, TArray<UGameCharacter*> enemyParty )
{
  this->playerParty = playerParty;
  this->enemyParty = enemyParty;

  // first add all players to combat order
  for( int i = 0; i < playerParty.Num(); i++ )
  {
    this->combatantOrder.Add( playerParty[i] );
  }

  // next add all enemies to combat order
  for( int i = 0; i < enemyParty.Num(); i++ )
  {
    this->combatantOrder.Add( enemyParty[i] );
  }

  this->tickTargetIndex = 0;
  this->SetPhase( CombatPhase::CPHASE_Decision );
}

CombatEngine::~CombatEngine()
{
}

构造函数首先分配玩家团体和敌人团体字段,然后添加所有玩家,接着添加所有敌人到战斗顺序列表中。最后,它将 tick 目标索引设置为 0(战斗顺序中的第一个角色)并将战斗阶段设置为 Decision

接下来,Tick 函数如下:

bool CombatEngine::Tick( float DeltaSeconds )
{
  switch( phase )
  {
    case CombatPhase::CPHASE_Decision:
      // todo: ask current character to make decision

      // todo: if decision made
      SelectNextCharacter();

      // no next character, switch to action phase
      if( this->tickTargetIndex == -1 )
      {
        this->SetPhase( CombatPhase::CPHASE_Action );
      }
      break;
    case CombatPhase::CPHASE_Action:
      // todo: ask current character to execute decision

      // todo: when action executed
      SelectNextCharacter();

      // no next character, loop back to decision phase
      if( this->tickTargetIndex == -1 )
      {
        this->SetPhase( CombatPhase::CPHASE_Decision );
      }
      break;
    // in case of victory or combat, return true (combat is finished)
    case CombatPhase::CPHASE_GameOver:
    case CombatPhase::CPHASE_Victory:
      return true;
      break;
  }

  // check for game over
  int deadCount = 0;
  for( int i = 0; i < this->playerParty.Num(); i++ )
  {
    if( this->playerParty[ i ]->HP <= 0 ) deadCount++;
  }

  // all players have died, switch to game over phase
  if( deadCount == this->playerParty.Num() )
  {
    this->SetPhase( CombatPhase::CPHASE_GameOver );
    return false;
  }

  // check for victory
  deadCount = 0;
  for( int i = 0; i < this->enemyParty.Num(); i++ )
  {
    if( this->enemyParty[ i ]->HP <= 0 ) deadCount++;
  }

  // all enemies have died, switch to victory phase
  if( deadCount == this->enemyParty.Num() )
  {
    this->SetPhase( CombatPhase::CPHASE_Victory );
    return false;
  }

  // if execution reaches here, combat has not finished - return false
  return false;
}

首先,我们切换到当前的战斗阶段。在 Decision 的情况下,它目前只是选择下一个角色,如果没有下一个角色,则切换到 Action 阶段。对于 Action 也是如此——除非没有下一个角色,它会循环回到 Decision 阶段。

之后,这将修改为调用角色的函数以做出和执行决策(此外,“选择下一个角色”的代码只有在角色完成决策或执行后才会被调用)。

GameOverVictory的情况下,Tick返回true表示战斗结束。否则,它首先检查是否所有玩家都已死亡(在这种情况下,游戏结束)或是否所有敌人都已死亡(在这种情况下,玩家赢得战斗)。在这两种情况下,函数将返回true,因为战斗已经结束。

函数的最后一部分返回false,这意味着战斗尚未结束。

接下来,我们有SetPhase函数:

void CombatEngine::SetPhase( CombatPhase phase )
{
  this->phase = phase;

  switch( phase )
  {
    case CombatPhase::CPHASE_Action:
    case CombatPhase::CPHASE_Decision:
      // set the active target to the first character in the combat order
      this->tickTargetIndex = 0;
      this->SelectNextCharacter();
      break;
    case CombatPhase::CPHASE_Victory:
      // todo: handle victory
      break;
    case CombatPhase::CPHASE_GameOver:
      // todo: handle game over
      break;
  }
}

这个函数设置战斗阶段,在ActionDecision的情况下,将tick目标设置为战斗顺序中的第一个角色。VictoryGameOver都有处理相应状态的存根。

最后,我们有SelectNextCharacter函数:

void CombatEngine::SelectNextCharacter()
{
  for( int i = this->tickTargetIndex; i < this->combatantOrder.Num(); i++ )
  {
    GameCharacter* character = this->combatantOrder[ i ];

    if( character->HP > 0 )
    {
      this->tickTargetIndex = i + 1;
      this->currentTickTarget = character;
      return;
    }
  }

  this->tickTargetIndex = -1;
  this->currentTickTarget = nullptr;
}

这个函数从当前的tickTargetIndex开始,并从那里找到战斗顺序中的第一个非死亡角色。如果找到了一个,它将tick目标索引设置为下一个索引,并将tick目标设置为找到的角色。否则,它将tick目标索引设置为-1,并将tick目标设置为空指针(这被解释为战斗顺序中没有剩余的角色)。

在这一点上,缺少了一个非常重要的事情:角色还不能做出或执行决策。

让我们将其添加到GameCharacter类中。目前,它们只是存根。

首先,我们将向GameCharacter.h添加testDelayTimer字段。这只是为了测试目的:

protected:
  float testDelayTimer;

接下来,我们向类中添加几个公共函数:

public:
  void BeginMakeDecision();
  bool MakeDecision( float DeltaSeconds );

  void BeginExecuteAction();
  bool ExecuteAction( float DeltaSeconds );

我们将DecisionAction分成两个函数每个——第一个函数告诉角色开始做出决策或执行动作,第二个函数本质上会查询角色直到决策做出或动作完成。

目前,这两个函数在GameCharacter.cpp中的实现只是记录一条消息和 1 秒的延迟:

void UGameCharacter::BeginMakeDecision()
{
  UE_LOG( LogTemp, Log, TEXT( "Character %s making decision" ), *this->CharacterName );
  this->testDelayTimer = 1;
}

bool UGameCharacter::MakeDecision( float DeltaSeconds )
{
  this->testDelayTimer -= DeltaSeconds;
  return this->testDelayTimer <= 0;
}

void UGameCharacter::BeginExecuteAction()
{
  UE_LOG( LogTemp, Log, TEXT( "Character %s executing action" ), *this->CharacterName );
  this->testDelayTimer = 1;
}

bool UGameCharacter::ExecuteAction( float DeltaSeconds )
{
  this->testDelayTimer -= DeltaSeconds;
  return this->testDelayTimer <= 0;
}

我们还将添加一个指向战斗实例的指针。由于战斗引擎引用角色,而角色引用战斗引擎会产生循环依赖。为了解决这个问题,我们将在GameCharacter.h的顶部直接在我们的包含之后添加一个前向声明:

class CombatEngine;

然后,用于战斗引擎的include语句实际上会被放置在GameCharacter.cpp文件中,而不是头文件中:

#include "Combat/CombatEngine.h"

接下来,我们将使战斗引擎调用DecisionAction函数。首先,我们在CombatEngine.h中添加一个受保护的变量:

bool waitingForCharacter;

这将用于在例如BeginMakeDecisionMakeDecision之间切换。

接下来,我们将修改Tick函数中的DecisionAction阶段。首先,我们将修改Decision的 switch case:

case CombatPhase::CPHASE_Decision:
{
  if( !this->waitingForCharacter )
  {
    this->currentTickTarget->BeginMakeDecision();
    this->waitingForCharacter = true;
  }

  bool decisionMade = this->currentTickTarget->MakeDecision( DeltaSeconds );

  if( decisionMade )
  {
    SelectNextCharacter();

    // no next character, switch to action phase
    if( this->tickTargetIndex == -1 )
    {
      this->SetPhase( CombatPhase::CPHASE_Action );
    }
  }
}
break;

如果waitingForCharacterfalse,它将调用BeginMakeDecision并将waitingForCharacter设置为true

记住整个情况语句括号内的内容——如果你不添加这些括号,你将得到关于decisionMade初始化被情况语句跳过的编译错误。

接下来,它调用 MakeDecision 并传递帧时间。如果此函数返回 true,则选择下一个角色,或者如果没有成功,则切换到 Action 阶段。

Action 阶段看起来与以下内容相同:

case CombatPhase::CPHASE_Action:
{
  if( !this->waitingForCharacter )
  {
    this->currentTickTarget->BeginExecuteAction();
    this->waitingForCharacter = true;
  }

  bool actionFinished = this->currentTickTarget->ExecuteAction( DeltaSeconds );

  if( actionFinished )
  {
    SelectNextCharacter();

    // no next character, switch to action phase
    if( this->tickTargetIndex == -1 )
    {
      this->SetPhase( CombatPhase::CPHASE_Decision );
    }
  }
}
break;

接下来,我们将修改 SelectNextCharacter 以将其 waitingForCharacter 设置为 false

void CombatEngine::SelectNextCharacter()
{
  this->waitingForCharacter = false;
  for (int i = this->tickTargetIndex; i < this->combatantOrder.
    Num(); i++)
  {
    UGameCharacter* character = this->combatantOrder[i];

    if (character->HP > 0)
    {
      this->tickTargetIndex = i + 1;
      this->currentTickTarget = character;
      return;
    }
  }

  this->tickTargetIndex = -1;
  this->currentTickTarget = nullptr;
}

最后,还有一些剩余的细节:我们的战斗引擎应该将所有角色的 CombatInstance 指针设置为指向自身,我们将在构造函数中这样做;然后,在析构函数中清除指针,并释放敌人指针。所以首先,在 GameCharacter.h 中创建一个指向 combatInstance 的指针,在你的 UProperty 声明之后和受保护的变量之前:

CombatEngine* combatInstance;

然后,在 CombatEngine.cpp 中,将你的构造函数和析构函数替换为以下内容:

CombatEngine::CombatEngine( TArray<UGameCharacter*> playerParty, TArray<UGameCharacter*> enemyParty )
{
  this->playerParty = playerParty;
  this->enemyParty = enemyParty;

  // first add all players to combat order
  for (int i = 0; i < playerParty.Num(); i++)
  {
    this->combatantOrder.Add(playerParty[i]);
  }

  // next add all enemies to combat order
  for (int i = 0; i < enemyParty.Num(); i++)
  {
    this->combatantOrder.Add(enemyParty[i]);
  }

  this->tickTargetIndex = 0;
  this->SetPhase(CombatPhase::CPHASE_Decision);

  for( int i = 0; i < this->combatantOrder.Num(); i++ )
  {
    this->combatantOrder[i]->combatInstance = this;
  }

  this->tickTargetIndex = 0;
  this->SetPhase( CombatPhase::CPHASE_Decision );
}

CombatEngine::~CombatEngine()
{
  // free enemies
  for( int i = 0; i < this->enemyParty.Num(); i++ )
  {
    this->enemyParty[i] = nullptr;
  }

  for( int i = 0; i < this->combatantOrder.Num(); i++ )
  {
    this->combatantOrder[i]->combatInstance = nullptr;
  }
}

到目前为止,战斗引擎几乎完全可用。我们仍然需要将其连接到游戏的其他部分,但要以一种可以从游戏模式触发战斗并更新它的方式。

因此,首先在我们的 RPGGameMode 类中,我们将添加一个指向当前战斗实例的指针,并重写 Tick 函数;此外,跟踪一个敌人角色的列表(用 UPROPERTY 装饰,以便敌人可以被垃圾回收):

#pragma once
#include "GameFramework/GameMode.h"
#include "GameCharacter.h"
#include "Combat/CombatEngine.h"
#include "RPGGameMode.generated.h"

UCLASS()
class RPG_API ARPGGameMode : public AGameMode
{
  GENERATED_BODY()

  ARPGGameMode( const class FObjectInitializer& ObjectInitializer );
  virtual void BeginPlay() override;
  virtual void Tick( float DeltaTime ) override;

public:
  CombatEngine* currentCombatInstance;
  TArray<UGameCharacter*> enemyParty;
};

接下来,在 .cpp 文件中,我们实现 Tick 函数:

void ARPGGameMode::Tick( float DeltaTime )
{
  if( this->currentCombatInstance != nullptr )
  {
    bool combatOver = this->currentCombatInstance->Tick( DeltaTime );
    if( combatOver )
    {
      if( this->currentCombatInstance->phase == CombatPhase::CPHASE_GameOver )
      {
        UE_LOG( LogTemp, Log, TEXT( "Player loses combat, game over" ) );
                }
      else if( this->currentCombatInstance->phase == CombatPhase::CPHASE_Victory )
      {
        UE_LOG( LogTemp, Log, TEXT( "Player wins combat" ) );
      }

      // enable player actor
      UGameplayStatics::GetPlayerController( GetWorld(), 0 )->SetActorTickEnabled( true );

      delete( this->currentCombatInstance );
      this->currentCombatInstance = nullptr;
      this->enemyParty.Empty();
    }
  }
}

目前,这仅仅检查是否当前有战斗实例;如果有,它将调用该实例的 Tick 函数。如果它返回 true,游戏模式将检查 VictoryGameOver(目前,它只是将消息记录到控制台)。然后,它删除战斗实例,将指针设置为空,并清除敌人队伍列表(这将使敌人有资格进行垃圾回收,因为列表被 UPROPERTY 宏装饰)。它还启用了玩家角色的 Tick(我们将在战斗开始时禁用 Tick,以便玩家角色在战斗期间保持原地不动)。

然而,我们还没有准备好开始战斗遭遇战!玩家没有敌人可以战斗。

我们定义了一个敌人表,但我们的 GameCharacter 类不支持从 EnemyInfo 初始化。

为了支持这一点,我们将在 GameCharacter 类中添加一个新的工厂(确保你也添加了 EnemyInfo 类的 include 语句):

static UGameCharacter* CreateGameCharacter( FEnemyInfo* enemyInfo, UObject* outer );

此外,GameCharacter.cpp 中此构造函数重载的实现如下:

UGameCharacter* UGameCharacter::CreateGameCharacter( FEnemyInfo* enemyInfo, UObject* outer )
{
  UGameCharacter* character = NewObject<UGameCharacter>( outer );

  character->CharacterName = enemyInfo->EnemyName;
  character->ClassInfo = nullptr;

  character->MHP = enemyInfo->MHP;
  character->MMP = 0;
  character->HP = enemyInfo->MHP;
  character->MP = 0;

  character->ATK = enemyInfo->ATK;
  character->DEF = enemyInfo->DEF;
  character->LUCK = enemyInfo->Luck;

  return character;
}

与之相比,这非常简单;只需为 ClassInfo 分配名称和空值(因为敌人没有与之关联的类)以及其他统计数据(MMP 和 MP 都设置为零,因为敌人的能力不会消耗 MP)。

为了测试我们的战斗系统,我们将在 RPGGameMode.h 中创建一个可以从 Unreal 控制台调用的函数:

UFUNCTION(exec)
void TestCombat();

UFUNCTION(exec) 宏允许此函数作为控制台命令被调用。

这个函数的实现位于 RPGGameMode.cpp 中,如下所示:

void ARPGGameMode::TestCombat()
{
  // locate enemies asset
  UDataTable* enemyTable = Cast<UDataTable>( StaticLoadObject( UDataTable::StaticClass(), NULL, 
TEXT( "DataTable'/Game/Data/Enemies.Enemies'" ) ) );

  if( enemyTable == NULL )
  {
    UE_LOG( LogTemp, Error, TEXT( "Enemies data table not found!" ) );
    return;
  }

  // locate enemy
  FEnemyInfo* row = enemyTable->FindRow<FEnemyInfo>( TEXT( "S1" ), TEXT( "LookupEnemyInfo" ) );

  if( row == NULL )
  {
    UE_LOG( LogTemp, Error, TEXT( "Enemy ID 'S1' not found!" ) );
    return;
  }

  // disable player actor
  UGameplayStatics::GetPlayerController( GetWorld(), 0 )->SetActorTickEnabled( false );

  // add character to enemy party
  UGameCharacter* enemy = UGameCharacter::CreateGameCharacter( row, this );
  this->enemyParty.Add( enemy );

  URPGGameInstance* gameInstance = Cast<URPGGameInstance>( GetGameInstance() );

  this->currentCombatInstance = new CombatEngine( gameInstance->PartyMembers, this->enemyParty );

  UE_LOG( LogTemp, Log, TEXT( "Combat started" ) );
}

它定位敌人数据表,选择 ID 为 S1 的敌人,构建一个新的 GameCharacter,构建一个敌人列表,添加新的敌人角色,然后创建一个新的 CombatEngine 实例,传递玩家团体和敌人列表。它还禁用了玩家演员的 tick,这样当战斗开始时玩家就停止更新。

最后,你应该能够测试战斗引擎。启动游戏并按波浪号 (~) 键打开控制台命令文本框。输入 TestCombat 并按 Enter

查看输出窗口,你应该看到以下内容:

LogTemp: Combat started
LogTemp: Character Kumo making decision
LogTemp: Character Goblin making decision
LogTemp: Character Kumo executing action
LogTemp: Character Goblin executing action
LogTemp: Character Kumo making decision
LogTemp: Character Goblin making decision
LogTemp: Character Kumo executing action
LogTemp: Character Goblin executing action
LogTemp: Character Kumo making decision
LogTemp: Character Goblin making decision
LogTemp: Character Kumo executing action
LogTemp: Character Goblin executing action
LogTemp: Character Kumo making decision

这表明战斗引擎按预期工作——首先,所有角色做出决策,执行他们的决策,然后再次做出决策,依此类推。由于实际上没有人真正采取任何行动(更不用说造成任何伤害),目前战斗只是无限期地进行。

这个问题有两个问题:首先,上述问题实际上没有人真正采取任何行动。此外,玩家角色需要有一种不同于敌人的决策方式(玩家角色将需要一个用户界面来选择动作,而敌人应该自动选择动作)。

我们将在处理决策之前解决第一个问题。

执行动作

为了允许角色执行动作,我们将所有战斗动作简化为单个通用接口。一个好的开始是让这个接口映射到我们已有的东西——也就是说,角色的 BeginExecuteActionExecuteAction 函数。

让我们为这个创建一个新的 ICombatAction 接口,它可以从一个没有任何父类的类开始,并放在一个名为 Source/RPG/Combat/Actions 的新路径中;ICombatAction.h 文件应该看起来像这样:

#pragma once

#include "GameCharacter.h"

class UGameCharacter;

class ICombatAction
{
public:
  virtual void BeginExecuteAction( UGameCharacter* character ) = 0;
  virtual bool ExecuteAction( float DeltaSeconds ) = 0;
};

BeginExecuteAction 接收执行此动作的角色指针。ExecuteAction 如前所述,接收上一帧的持续时间(以秒为单位)。

ICombatAction.cpp 中,移除默认构造函数和析构函数,使文件看起来像这样:

#include "RPG.h"
#include "ICombatAction.h"

然后,我们可以创建一个新的空 C++ 类来实现这个接口。仅作为一个测试,我们将在一个名为 TestCombatAction 的新类中复制角色已经执行的功能(即,绝对不做任何事情),并将其路径设置为 Source/RPG/Combat/Actions 文件夹。

首先,头文件将如下所示:

#pragma once

#include "ICombatAction.h"

class TestCombatAction : public ICombatAction
{
protected:
  float delayTimer;

public:
  virtual void BeginExecuteAction( UGameCharacter* character ) override;
  virtual bool ExecuteAction( float DeltaSeconds ) override;
};

.cpp 文件将如下所示:

#include "RPG.h"
#include "TestCombatAction.h"

void TestCombatAction::BeginExecuteAction( UGameCharacter* character )
{
  UE_LOG( LogTemp, Log, TEXT( "%s does nothing" ), *character->CharacterName );
  this->delayTimer = 1.0f;
}

bool TestCombatAction::ExecuteAction( float DeltaSeconds )
{
  this->delayTimer -= DeltaSeconds;
  return this->delayTimer <= 0.0f;
}

接下来,我们将更改角色,使其能够存储和执行动作。

首先,让我们用战斗动作指针替换测试延迟计时器字段。稍后,我们将使其在 GameCharacter.h 中创建决策系统时公开:

public:
  ICombatAction* combatAction;

还记得在 GameCharacter.h 的顶部包含 ICombatAction,然后声明 ICombatAction 类:

#pragma once

#include "Data/FCharacterInfo.h"
#include "Data/FEnemyInfo.h"
#include "Data/FCharacterClassInfo.h"
#include "Combat/Actions/ICombatAction.h"
#include "GameCharacter.generated.h"

class CombatEngine;
class ICombatAction;

接下来,我们需要更改我们的决策函数以分配战斗动作,并将动作函数执行此动作在 GameCharacter.cpp 中:

void UGameCharacter::BeginMakeDecision()
{
  UE_LOG( LogTemp, Log, TEXT( "Character %s making decision" ), *( this->CharacterName ) );
  this->combatAction = new TestCombatAction();
}

bool UGameCharacter::MakeDecision( float DeltaSeconds )
{
  return true;
}

void UGameCharacter::BeginExecuteAction()
{
  this->combatAction->BeginExecuteAction( this );
}

bool UGameCharacter::ExecuteAction( float DeltaSeconds )
{
  bool finishedAction = this->combatAction->ExecuteAction( DeltaSeconds );
  if( finishedAction )
  {
    delete( this->combatAction );
    return true;
  }

  return false;
}

还要记得在GameCharacter.cpp的顶部使用include TestCombatAction

#include "Combat/Actions/TestCombatAction.h"

BeginMakeDecision现在分配一个新的TestCombatAction实例。MakeDecision仅返回trueBeginExecuteAction调用存储的战斗动作上的同名函数,并将角色作为指针传递。最后,ExecuteAction调用同名函数,如果结果是true,则删除指针并返回true;否则返回false

通过运行此代码并测试战斗,你应该得到几乎相同的输出,但现在它显示的是does nothing而不是executing action

现在我们有了一种让角色存储和执行动作的方法,我们可以着手为角色开发一个决策系统。

做出决策

就像我们对动作所做的那样,我们将创建一个用于决策的接口,其模式与BeginMakeDecisionMakeDecision函数相似。类似于ICombatAction类,我们将创建一个空的IDecisionMaker类,并将其放置到新的目录Source/RPG/Combat/DecisionMakers中。以下将是IDecisionMaker.h的内容:

#pragma once

#include "GameCharacter.h"

class UGameCharacter;

class IDecisionMaker
{
public:
  virtual void BeginMakeDecision( UGameCharacter* character ) = 0;
  virtual bool MakeDecision( float DeltaSeconds ) = 0;
};

此外,从IDecisionMaker.cpp中删除构造函数和析构函数,使其看起来像这样:

#include "RPG.h"
#include "IDecisionMaker.h"

现在,我们可以创建TestDecisionMaker C++类,并将其放置到Source/RPG/Combat/DecisionMakers目录中。然后,按照以下方式编程TestDecisionMaker.h

#pragma once
#include "IDecisionMaker.h"

class RPG_API TestDecisionMaker : public IDecisionMaker
{
public:
  virtual void BeginMakeDecision( UGameCharacter* character ) override;
  virtual bool MakeDecision( float DeltaSeconds ) override;
};

然后,按照以下方式编程TestDecisionMaker.cpp

#include "RPG.h"
#include "TestDecisionMaker.h"

#include "../Actions/TestCombatAction.h"

void TestDecisionMaker::BeginMakeDecision( UGameCharacter* character )
{
  character->combatAction = new TestCombatAction();
}

bool TestDecisionMaker::MakeDecision( float DeltaSeconds )
{
  return true;
}

接下来,我们将在游戏角色类中添加一个指向IDecisionMaker的指针,并修改BeginMakeDecisionMakeDecision函数以在GameCharacter.h中使用决策者:

public:
  IDecisionMaker* decisionMaker;

还要记得在GameCharacter.h的顶部包含ICombatAction,然后声明ICombatAction类:

#pragma once

#include "Data/FCharacterInfo.h"
#include "Data/FEnemyInfo.h"
#include "Data/FCharacterClassInfo.h"
#include "Combat/Actions/ICombatAction.h"
#include "Combat/DecisionMakers/IDecisionMaker.h"
#include "GameCharacter.generated.h"

class CombatEngine;
class ICombatAction;
class IDecisionMaker;

接下来,将GameCharacter.cpp中的BeginDestroyBeginMakeDecisionMakeDecision函数替换为以下内容:

void UGameCharacter::BeginDestroy()
{
  Super::BeginDestroy();
  delete( this->decisionMaker );
}

void UGameCharacter::BeginMakeDecision()
{
  this->decisionMaker->BeginMakeDecision( this );}

bool UGameCharacter::MakeDecision( float DeltaSeconds )
{
  return this->decisionMaker->MakeDecision( DeltaSeconds );
}

注意,我们在析构函数中删除决策者。决策者将在角色创建时分配,因此当角色释放时应该删除。

然后,我们将包含TestDecisionMaker实现,以便每个阵营都能做出战斗决策,因此请在类的顶部包含TestDecisionMaker

#include "Combat/DecisionMakers/TestDecisionMaker.h"

在这里,最终的步骤是为角色的构造函数分配一个决策者。为两个构造函数重载添加以下代码行:character->decisionMaker = new TestDecisionMaker();。当你完成时,玩家和敌人角色的构造函数应该看起来像这样:

UGameCharacter* UGameCharacter::CreateGameCharacter(
  FCharacterInfo* characterInfo, UObject* outer)
{
  UGameCharacter* character = NewObject<UGameCharacter>(outer);

  // locate character classes asset
  UDataTable* characterClasses = Cast<UDataTable>(
    StaticLoadObject(UDataTable::StaticClass(), NULL, TEXT(
      "DataTable'/Game/Data/CharacterClasses.CharacterClasses'"))
    );

  if (characterClasses == NULL)
  {
    UE_LOG(LogTemp, Error, 
TEXT("Character classes datatable not found!" ) );
  }
  else
  {
    character->CharacterName = characterInfo->Character_Name;
    FCharacterClassInfo* row = 
characterClasses->FindRow<FCharacterClassInfo>
(*(characterInfo->Class_ID), TEXT("LookupCharacterClass"));
    character->ClassInfo = row;

    character->MHP = character->ClassInfo->StartMHP;
    character->MMP = character->ClassInfo->StartMMP;
    character->HP = character->MHP;
    character->MP = character->MMP;

    character->ATK = character->ClassInfo->StartATK;
    character->DEF = character->ClassInfo->StartDEF;
    character->LUCK = character->ClassInfo->StartLuck;

    character->decisionMaker = new TestDecisionMaker();
  }

  return character;
}

UGameCharacter* UGameCharacter::CreateGameCharacter(FEnemyInfo* enemyInfo, UObject* outer)
{
  UGameCharacter* character = NewObject<UGameCharacter>(outer);

  character->CharacterName = enemyInfo->EnemyName;
  character->ClassInfo = nullptr;

  character->MHP = enemyInfo->MHP;
  character->MMP = 0;
  character->HP = enemyInfo->MHP;
  character->MP = 0;

  character->ATK = enemyInfo->ATK;
  character->DEF = enemyInfo->DEF;
  character->LUCK = enemyInfo->Luck;

  character->decisionMaker = new TestDecisionMaker();

  return character;
}

运行游戏并再次测试战斗,你应该得到与之前非常相似的输出。然而,最大的不同之处在于现在可以为不同的角色分配不同的决策者实现,并且这些决策者有简单的方法来分配要执行的战斗动作。例如,现在将我们的测试战斗动作处理目标伤害将变得很容易。然而,在我们这样做之前,让我们对GameCharacter类做一些小的更改。

选择目标

我们将在GameCharacter中添加一个字段来标识角色是玩家还是敌人。此外,我们还将添加一个SelectTarget函数,该函数从当前的战斗实例的enemyPartyplayerParty中选择第一个活着的角色,具体取决于该角色是玩家还是敌人。

首先,在GameCharacter.h中,我们将添加一个公共的isPlayer字段:

bool isPlayer;

然后,我们将添加一个SelectTarget函数,如下所示:

UGameCharacter* SelectTarget();

GameCharacter.cpp中,我们将在构造函数中分配isPlayer字段(这很简单,因为我们有玩家和敌人分开的构造函数):

UGameCharacter* UGameCharacter::CreateGameCharacter(
  FCharacterInfo* characterInfo, UObject* outer)
{
  UGameCharacter* character = NewObject<UGameCharacter>(outer);

  // locate character classes asset
  UDataTable* characterClasses = Cast<UDataTable>(
    StaticLoadObject(UDataTable::StaticClass(), NULL, TEXT(
      "DataTable'/Game/Data/CharacterClasses.CharacterClasses'"))
    );

  if (characterClasses == NULL)
  {
    UE_LOG(LogTemp, Error,
      TEXT("Character classes datatable not found!"));
  }
  else
  {
    character->CharacterName = characterInfo->Character_Name;
    FCharacterClassInfo* row =
      characterClasses->FindRow<FCharacterClassInfo>
(*(characterInfo->Class_ID), TEXT("LookupCharacterClass"));
    character->ClassInfo = row;

    character->MHP = character->ClassInfo->StartMHP;
    character->MMP = character->ClassInfo->StartMMP;
    character->HP = character->MHP;
    character->MP = character->MMP;

    character->ATK = character->ClassInfo->StartATK;
    character->DEF = character->ClassInfo->StartDEF;
    character->LUCK = character->ClassInfo->StartLuck;

    character->decisionMaker = new TestDecisionMaker();
  }
  character->isPlayer = true;
  return character;
}

UGameCharacter* UGameCharacter::CreateGameCharacter(FEnemyInfo* enemyInfo, UObject* outer)
{
  UGameCharacter* character = NewObject<UGameCharacter>(outer);

  character->CharacterName = enemyInfo->EnemyName;
  character->ClassInfo = nullptr;

  character->MHP = enemyInfo->MHP;
  character->MMP = 0;
  character->HP = enemyInfo->MHP;
  character->MP = 0;

  character->ATK = enemyInfo->ATK;
  character->DEF = enemyInfo->DEF;
  character->LUCK = enemyInfo->Luck;

  character->decisionMaker = new TestDecisionMaker();
  character->isPlayer = false;
  return character;
}

最后,SelectTarget函数如下所示:

UGameCharacter* UGameCharacter::SelectTarget()
{
  UGameCharacter* target = nullptr;

  TArray<UGameCharacter*> targetList = this->combatInstance->enemyParty;
  if( !this->isPlayer )
  {
    targetList = this->combatInstance->playerParty;
  }

  for( int i = 0; i < targetList.Num(); i++ )
  {
    if( targetList[ i ]->HP > 0 )
    {
      target = targetList[i];
      break;
    }
  }

  if( target->HP <= 0 )
  {
    return nullptr;
  }

  return target;
}

这首先确定使用哪个列表(敌人或玩家)作为潜在的目标,然后遍历该列表以找到第一个非死亡目标。如果没有目标,此函数返回一个空指针。

造成伤害

现在有了选择目标的方法,让我们让TestCombatAction类最终造成一些伤害!

我们将添加一些字段来维护对角色和目标的引用,并添加一个接受目标作为参数的构造函数:

protected:
  UGameCharacter* character;
  UGameCharacter* target;

public:
  TestCombatAction( UGameCharacter* target );

此外,实现方式是通过在TestCombatAction.cpp中创建和更新BeginExecuteAction函数,如下所示:

void TestCombatAction::BeginExecuteAction( UGameCharacter* character )
{
  this->character = character;

  // target is dead, select another target
  if( this->target->HP <= 0 )
  {
    this->target = this->character->SelectTarget();
  }

  // no target, just return
  if( this->target == nullptr )
  {
    return;
  }

  UE_LOG( LogTemp, Log, TEXT( "%s attacks %s" ), *character->CharacterName, *target->CharacterName );

  target->HP -= 10;

  this->delayTimer = 1.0f;
}

然后让类的构造函数设置目标:

TestCombatAction::TestCombatAction(UGameCharacter* target)
{
  this->target = target;
}

首先,构造函数分配目标指针。然后,BeginExecuteAction函数分配角色引用并检查目标是否存活。如果目标是死亡的,它将通过我们刚刚创建的SelectTarget函数选择一个新的目标。如果目标指针现在是空,则没有目标,此函数仅返回空。否则,它记录一个类似*[character] attacks [target]*的消息,从目标中减去一些 HP,并设置延迟计时器,就像之前一样。

下一步是将我们的TestDecisionMaker更改为选择一个目标并将其传递给TestCombatAction构造函数。这在TestDecisionMaker.cpp中是一个相对简单的更改:

void TestDecisionMaker::BeginMakeDecision( UGameCharacter* character )
{
  // pick a target
  UGameCharacter* target = character->SelectTarget();
  character->combatAction = new TestCombatAction( target );
}

到目前为止,你应该能够运行游戏,开始测试遭遇战,并看到以下类似的输出:

LogTemp: Combat started
LogTemp: Kumo attacks Goblin
LogTemp: Goblin attacks Kumo
LogTemp: Kumo attacks Goblin
LogTemp: Player wins combat

最后,我们有一个战斗系统,其中我们的两个阵营可以互相攻击,一方或另一方可以获胜。

接下来,我们将开始将其连接到用户界面。

使用 UMG 的战斗 UI

要开始,我们需要设置我们的项目以正确导入 UMG 和 Slate 相关的类。

首先,打开 RPG.Build.cs(或 [ProjectName].Build.cs)并将构造函数的第一行代码更改为以下代码:

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

这将 UMGSlateSlateCore 字符串添加到现有的字符串数组中。

接下来,打开 RPG.h 并确保以下代码行存在:

#include "Runtime/UMG/Public/UMG.h"
#include "Runtime/UMG/Public/UMGStyle.h"
#include "Runtime/UMG/Public/Slate/SObjectWidget.h"
#include "Runtime/UMG/Public/IUMGModule.h"
#include "Runtime/UMG/Public/Blueprint/UserWidget.h"

现在编译项目。这可能需要一段时间。

接下来,我们将创建一个用于战斗 UI 的基类。基本上,我们将使用这个基类来允许我们的 C++ 游戏代码通过在头文件中定义蓝图可实现的函数与蓝图 UMG 代码进行通信,这些函数是蓝图可以实现的函数,可以从 C++ 中调用。

创建一个名为 CombatUIWidget 的新类,并将其父类选择为 UserWidget;然后将其路径设置为 Source/RPG/UI。用以下代码替换 CombatUIWidget.h 中的内容:

#pragma once
#include "GameCharacter.h"

#include "Blueprint/UserWidget.h"
#include "CombatUIWidget.generated.h"

UCLASS()
class RPG_API UCombatUIWidget : public UUserWidget
{
  GENERATED_BODY()

public:
  UFUNCTION( BlueprintImplementableEvent, Category = "Combat UI" )
  void AddPlayerCharacterPanel( UGameCharacter* target );

  UFUNCTION( BlueprintImplementableEvent, Category = "Combat UI" )
  void AddEnemyCharacterPanel( UGameCharacter* target );
};

在大多数情况下,我们只是在定义几个函数。AddPlayerCharacterPanelAddEnemyCharacterPanel 函数将负责接受一个角色指针并为该角色生成一个小部件(以显示角色的当前状态)。

编译代码后,回到编辑器中,在 Contents/Blueprints 目录中创建一个名为 UI 的新文件夹。在 Content/Blueprints/UI 目录中,创建一个名为 CombatUI 的新 Widget 蓝图。在创建并打开蓝图后,转到 文件 | 重新父化蓝图 并选择 CombatUIWidget 作为父类。

设计师 界面中,创建两个水平框小部件并将它们命名为 enemyPartyStatusplayerPartyStatus。这些将分别持有敌人和玩家的子小部件,以显示每个角色的状态。对于这两个,务必确保启用 Is Variable 复选框,这样它们就会作为变量对蓝图可用。保存并编译蓝图。

我们将 enemyPartyStatus 水平框定位在画布面板的顶部。首先设置一个顶部水平锚点会有所帮助。

然后将水平框的值设置为以下内容,偏移左: 10,位置 Y: 10,偏移右: 10,大小 Y: 200。

以类似的方式定位 playerPartyStatus 水平框;唯一的重大区别是我们将框锚定在画布面板的底部,并定位使其跨越屏幕底部:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_07.jpg

接下来,我们将创建用于显示玩家和敌人角色状态的小部件。首先,我们将创建一个基小部件,每个小部件都将从中继承。

创建一个新的 Widget 蓝图并将其命名为 BaseCharacterCombatPanel。在这个蓝图里,导航到图表,然后从 MyBlueprint 选项卡添加一个新的变量,CharacterTarget,并从 对象引用 类别中选择 Game Character 变量类型。

接下来,我们将为敌人和玩家创建单独的小部件。

创建一个新的 Widget 蓝图并将其命名为 PlayerCharacterCombatPanel。将新蓝图的父母设置为 BaseCharacterCombatPanel

Designer界面中添加三个文本小部件。一个标签用于角色的名称,另一个用于角色的 HP,第三个用于角色的 MP。将每个文本块定位在屏幕的左下角,并且完全在playerPartyStatus框大小的 200 高像素内,这是我们之前在CombatUI小部件中创建的:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_08.jpg

确保检查每个文本块的Details面板中的Size to Content,以便文本块可以根据内容调整大小,如果内容不适合文本块参数。

通过选择小部件并点击Details面板中Text输入旁边的Bind来为这些创建新的绑定:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_09.jpg

这将创建一个新的蓝图函数,该函数将负责生成文本块。

要绑定 HP 文本块,例如,您可以执行以下步骤:

  1. 在网格的空白区域右键单击,搜索Get Character Target,然后选择它。

  2. 将此节点的输出引脚拖动并选择Variables | Character Info下的Get HP

  3. 创建一个新的Format Text节点。将文本设置为HP: {HP},然后将Get HP的输出连接到Format Text节点的HP输入。

  4. 最后,将Format Text节点的输出连接到Return节点的Return值。

您可以重复类似的步骤为角色名称和 MP 文本块。

在您创建PlayerCharacterCombatPanel之后,您可以重复相同的步骤来创建EnemyCharacterCombatPanel,除了不需要 MP 文本块(如前所述,敌人不消耗 MP)。唯一的重大区别是EnemyCharacterCombatPanel中的文本块需要放置在屏幕顶部,以匹配CombatUI小部件中的enemyPartyStatus水平框的位置。

显示 MP 的结果图将类似于以下截图:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_10.jpg

现在我们已经有了玩家和敌人的小部件,让我们在CombatUI蓝图实现AddPlayerCharacterPanelAddEnemyCharacterPanel函数。

首先,我们将创建一个辅助蓝图函数来生成角色状态小部件。将此新函数命名为SpawnCharacterWidget,并将以下参数添加到输入中:

  • 目标角色(类型为 Game Character Reference)

  • 目标面板(类型为 Panel Widget Reference)

  • Class(类型为 Base Character Combat Panel Class)

此函数将执行以下步骤:

  1. 使用Create Widget创建给定类的新小部件。

  2. 将新小部件投射到BaseCharacterCombatPanel类型。

  3. 将结果的Character Target设置为TargetCharacter输入。

  4. 将新小部件作为TargetPanel输入的子项添加。

在蓝图中的样子如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_11.jpg

接下来,在CombatUI蓝图的事件图中,右键单击并添加EventAddPlayerCharacterPanelEventAddEnemyCharacterPanel事件。将每个事件连接到SpawnCharacterWidget节点,将目标输出连接到目标角色输入,将适当的面板变量连接到目标面板输入,如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_12.jpg

最后,我们可以在战斗开始时从我们的游戏模式中生成这个 UI,并在战斗结束时销毁它。在RPGGameMode的头部添加一个指向UCombatUIWidget的指针,并添加一个用于生成战斗 UI 的类(这样我们就可以选择继承自我们的CombatUIWidget类的 Widget 蓝图);这些应该是公共的:

UPROPERTY()
UCombatUIWidget* CombatUIInstance;

UPROPERTY( EditDefaultsOnly, BlueprintReadOnly, Category = "UI" )
TSubclassOf<class UCombatUIWidget> CombatUIClass;

还要确保RPGGameMode.h包含CombatWidget;在这个时候,RPGGameMode.h顶部的内容列表应该看起来像这样:

#include "GameFramework/GameMode.h"
#include "GameCharacter.h"
#include "Combat/CombatEngine.h"
#include "UI/CombatUIWidget.h"
#include "RPGGameMode.generated.h"

RPGGameMode.cpp中的TestCombat函数结束时,我们将生成这个小部件的新实例,如下所示:

this->CombatUIInstance = CreateWidget<UCombatUIWidget>( GetGameInstance(), this->CombatUIClass );
this->CombatUIInstance->AddToViewport();

UGameplayStatics::GetPlayerController(GetWorld(), 0)
->bShowMouseCursor = true;

for( int i = 0; i < gameInstance->PartyMembers.Num(); i++ )
  this->CombatUIInstance->AddPlayerCharacterPanel( gameInstance->PartyMembers[i] );

for( int i = 0; i < this->enemyParty.Num(); i++ )
  this->CombatUIInstance->AddEnemyCharacterPanel( this->enemyParty[i] );

这将创建小部件,将其添加到视图中,添加鼠标光标,然后分别调用其AddPlayerCharacterPanelAddEnemyCharacterPanel函数,为所有玩家和敌人。

战斗结束后,我们将从视图中移除小部件,并将引用设置为 null,以便它可以被垃圾回收;你的Tick函数现在应该看起来像这样:

void ARPGGameMode::Tick(float DeltaTime)
{
  if (this->currentCombatInstance != nullptr)
  {
    bool combatOver = this->currentCombatInstance->Tick(DeltaTime
    );
    if (combatOver)
    {
      if (this->currentCombatInstance->phase == CombatPhase::
        CPHASE_GameOver)
      {
        UE_LOG(LogTemp, Log, 
        TEXT("Player loses combat, game over" ) );
      }
      else if 
      (this->currentCombatInstance->phase == 
      CombatPhase::  CPHASE_Victory)
      {
        UE_LOG(LogTemp, Log, TEXT("Player wins combat"));
      }
      UGameplayStatics::GetPlayerController(GetWorld(),0)
      ->bShowMouseCursor = false;

      // enable player actor
      UGameplayStatics::GetPlayerController(GetWorld(), 0)->
        SetActorTickEnabled(true);

      this->CombatUIInstance->RemoveFromViewport();
      this->CombatUIInstance = nullptr;

      delete(this->currentCombatInstance);
      this->currentCombatInstance = nullptr;
      this->enemyParty.Empty();
    }
  }
}

在这个阶段,你可以编译,但如果测试战斗,游戏将会崩溃。这是因为你需要设置DefaultRPGGameMode类的默认值,使用CombatUI作为你在RPGGameMode.h中创建的CombatUIClass。否则,系统将不知道CombatUIClass变量应该指向CombatUI,这是一个小部件,因此无法创建它。请注意,编辑器在执行此步骤时可能会崩溃。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_13.jpg

现在,如果你运行游戏并开始战斗,你应该能看到哥布林的状态和玩家的状态。两者的生命值都应该会减少,直到哥布林的生命值达到零;在这个时候,用户界面将消失(因为战斗已经结束)。

接下来,我们将进行一些更改,使得玩家角色不再是自动做出决策,而是玩家可以通过用户界面选择他们的行动。

UI 驱动的决策制定

一个想法是改变决策者分配给玩家的方式——而不是在玩家首次创建时分配,我们可以在战斗开始时让我们的CombatUIWidget类实现决策者,并在战斗开始时分配它(在战斗结束时清除指针)。

我们需要对GameCharacter.cpp进行一些更改。首先,在CreateGameCharacter的玩家重载中,删除以下代码行:

character->decisionMaker = new TestDecisionMaker();

然后,在BeginDestroy函数中,我们将delete行包裹在一个if语句中:

if( !this->isPlayer )
  delete( this->decisionMaker );

原因是玩家的决策者将是 UI——我们不希望手动删除 UI(这样做会导致 Unreal 崩溃)。相反,只要没有用UPROPERTY装饰的指针指向它,UI 将自动进行垃圾回收。

接下来,在CombatUIWidget.h中,我们将使类实现IDecisionMaker接口,并添加BeginMakeDecisionMakeDecision作为公共函数:

#pragma once
#include "GameCharacter.h"
#include "Blueprint/UserWidget.h"
#include "CombatUIWidget.generated.h"

UCLASS()
class RPG_API UCombatUIWidget : public UUserWidget, public IDecisionMaker
{
  GENERATED_BODY()

public:
  UFUNCTION(BlueprintImplementableEvent, Category = "Combat UI")
    void AddPlayerCharacterPanel(UGameCharacter* target);

  UFUNCTION(BlueprintImplementableEvent, Category = "Combat UI")
    void AddEnemyCharacterPanel(UGameCharacter* target);

  void BeginMakeDecision(UGameCharacter* target);
  bool MakeDecision(float DeltaSeconds);
};

我们还将添加几个辅助函数,这些函数可以在我们的 UI 蓝图图中调用:

public:
  UFUNCTION( BlueprintCallable, Category = "Combat UI" )
  TArray<UGameCharacter*> GetCharacterTargets();

  UFUNCTION( BlueprintCallable, Category = "Combat UI" )
  void AttackTarget( UGameCharacter* target );

第一个函数检索当前角色的潜在目标列表。第二个函数将为角色提供一个带有指定目标的新的TestCombatAction

此外,我们还将添加一个在蓝图中实现的功能,用于显示当前角色的操作集:

UFUNCTION( BlueprintImplementableEvent, Category = "Combat UI" )
void ShowActionsPanel( UGameCharacter* target );

我们还将添加一个标志和currentTarget的定义,如下所示:

protected:
 UGameCharacter* currentTarget;
  bool finishedDecision;

这将用于表示已做出决策(并且MakeDecision应该返回true)。

这些四个函数的实现相当简单,在CombatUIWidget.cpp中:

#include "RPG.h"
#include "CombatUIWidget.h"
#include "../Combat/CombatEngine.h"
#include "../Combat/Actions/TestCombatAction.h"

void UCombatUIWidget::BeginMakeDecision( UGameCharacter* target )
{
  this->currentTarget = target;
  this->finishedDecision = false;

  ShowActionsPanel( target );
}

bool UCombatUIWidget::MakeDecision( float DeltaSeconds )
{
  return this->finishedDecision;
}

void UCombatUIWidget::AttackTarget( UGameCharacter* target )
{
  TestCombatAction* action = new TestCombatAction( target );
  this->currentTarget->combatAction = action;

  this->finishedDecision = true;
}

TArray<UGameCharacter*> UCombatUIWidget::GetCharacterTargets()
{
  if( this->currentTarget->isPlayer )
  {
    return this->currentTarget->combatInstance->enemyParty;
  }
  else
  {
    return this->currentTarget->combatInstance->playerParty;
  }
}

BeginMakeDecision设置当前目标,将finishedDecision标志设置为false,然后调用ShowActionsPanel(这将在我们的 UI 蓝图图中处理)。

MakeDecision简单地返回finishedDecision标志的值。

AttackTarget将一个新的TestCombatAction分配给角色,并将finishedDecision设置为true以表示已做出决策。

最后,GetCharacterTargets返回一个包含此角色可能对手的数组。

由于 UI 现在实现了IDecisionMaker接口,我们可以将其分配为玩家角色的决策者。首先,在RPGGameMode.cpp中的TestCombat函数,我们将改变遍历角色的循环,使其将 UI 分配为决策者:

for( int i = 0; i < gameInstance->PartyMembers.Num(); i++ )
{
  this->CombatUIInstance->AddPlayerCharacterPanel( gameInstance->PartyMembers[i] );
  gameInstance->PartyMembers[i]->decisionMaker = this->CombatUIInstance;
}

然后,当战斗结束时,我们将玩家的决策者设置为 null:

for( int i = 0; i < this->currentCombatInstance->playerParty.Num(); i++ )
{
  this->currentCombatInstance->playerParty[i]->decisionMaker = nullptr;
}

现在,玩家角色将使用 UI 来做出决策。然而,UI 目前什么也不做。我们需要在蓝图编辑器中添加这个功能。

首先,我们将创建一个用于攻击目标选项的小部件。命名为AttackTargetOption,添加一个按钮,并在按钮中放置一个文本块。勾选大小适应内容,以便按钮可以动态调整大小以适应按钮中的任何文本块。然后将其放置在画布面板的左上角。

在图中添加两个新的变量。一个是战斗 UI 引用类型的targetUI。另一个是游戏角色引用类型的target。从设计师视图,点击你的按钮,然后滚动到详情面板并点击OnClicked来为按钮创建一个事件。按钮将使用targetUI引用来调用攻击目标函数,并将target引用(即此按钮代表的目标)传递给攻击目标函数。

按钮点击事件的图相当简单;只需将执行路由到分配的targetUI攻击目标函数,并将target引用作为参数传递:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_14.jpg

接下来,我们将为主战斗 UI 添加一个用于角色动作的面板。这是一个包含单个用于攻击的按钮子项和用于目标列表的垂直框的画布面板:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_15.jpg

攻击按钮命名为attackButton。将垂直框命名为targets。将封装这些项的画布面板命名为characterActions。这些应该启用是变量,以便它们对蓝图可见。

然后,在蓝图图中,我们将实现显示动作面板事件。这首先将执行路由到设置可见性节点,该节点将启用动作面板,然后路由执行到另一个设置可见性节点,该节点将隐藏目标列表:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_16.jpg

当点击攻击按钮时的蓝图图相当大,所以我们将分块查看它。

首先,通过在设计师视图中选择按钮并点击详情面板的事件部分的OnClicked来为你的attackButton创建一个OnClicked事件。在图中,我们然后使用一个清除子项节点在按钮点击时清除可能之前添加的任何目标选项:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_17.jpg

然后,我们使用一个ForEachLoop和一个CompareInt节点结合使用,遍历由Get Character Targets返回的所有 HP > 0(未死亡)的角色。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_18.jpg

CompareInt节点的**>(大于)引脚,我们创建一个新的AttackTargetOption**小部件实例,并将其添加到攻击目标列表的垂直框中:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_19.jpg

然后,对于我们刚刚添加的小部件,我们将一个Self节点连接到它,以设置其targetUI变量,并将ForEachLoop数组元素引脚传递给它以设置其target变量:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_20.jpg

最后,从完成ForEachLoop引脚,我们将目标选项列表的可见性设置为可见

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_21.jpg

在完成所有这些之后,我们仍然需要在选择动作时隐藏动作面板。我们将在CombatUI中添加一个名为隐藏动作面板的新函数。这个函数非常简单;它只是将动作面板的可见性设置为隐藏

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_22.jpg

此外,在AttackTargetOption图中的点击处理程序中,我们将攻击目标节点的执行引脚连接到这个隐藏动作面板函数:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_23.jpg

最后,你需要将位于 AttackTargetOption 小部件中的按钮中的文本块绑定。所以进入 设计器 视图并创建一个与本章中之前创建的文本块相同的绑定。现在在图中,将 目标 连接到 角色名称,并调整文本的格式以显示 CharacterName 变量,并将其连接到文本的 返回 节点。这个 Blueprint 应该在按钮上显示当前目标的角色名称:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_24.jpg

在完成所有这些之后,你应该能够运行游戏并开始测试遭遇战,在玩家的回合,你会看到一个 攻击 按钮允许你选择攻击哥布林。

我们的游戏引擎现在完全功能化。本章的最后一步将是创建一个游戏结束界面,这样当所有团队成员都死亡时,玩家将看到 游戏结束 信息。

创建游戏结束界面

第一步是创建屏幕本身。创建一个新的 Widget Blueprint,命名为 GameOverScreen。我们只需添加一个图像,我们可以将其设置为全屏锚点,并在 详细信息 面板中将偏移量设置为 0。你也可以将颜色设置为黑色。还可以添加一个带有文本 Game Over 的文本块和一个带有子文本块 Restart 的按钮:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_25.jpg

Restart 按钮创建一个 OnClicked 事件。在 Blueprint 图中,将按钮的事件链接到重启游戏,其目标是 获取游戏模式(你可能需要取消选中 上下文相关 以找到此节点):

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_26.jpg

你还需要在这里显示鼠标光标。最好的方法是使用 事件构造;链接 设置显示鼠标光标,其目标是 获取玩家控制器。务必勾选 显示鼠标光标 复选框。在 事件构造设置显示鼠标光标 之间放置一个 0.2 秒的延迟,以确保在战斗结束后移除鼠标后鼠标重新出现:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_27.jpg

接下来,在 RPGGameMode.h 中,我们添加一个用于游戏结束的公共属性来指定小部件类型:

UPROPERTY( EditDefaultsOnly, BlueprintReadOnly, Category = "UI" )
TSubclassOf<class UUserWidget> GameOverUIClass;

在游戏结束的情况下,我们创建小部件并将其添加到视图中,这可以作为 void ARPGGameMode::Tick(float DeltaTime) 中的 if(combatOver) 条件嵌套条件添加,该文件位于 RPGGameMode.cpp

if( this->currentCombatInstance->phase == CombatPhase::CPHASE_GameOver )
{
  UE_LOG( LogTemp, Log, TEXT( "Player loses combat, game over" ) );

  Cast<URPGGameInstance>( GetGameInstance() )->PrepareReset();

  UUserWidget* GameOverUIInstance = CreateWidget<UUserWidget>( GetGameInstance(), this->GameOverUIClass );
  GameOverUIInstance->AddToViewport();
}

如你所见,我们还在游戏实例上调用了一个 PrepareReset 函数。这个函数尚未定义,所以我们现在在 RPGGameInstance.h 中创建它,作为一个公共函数:

public:
  void PrepareReset();

然后在 RPGGameInstance.cpp 中实现它:

cpp.void URPGGameInstance::PrepareReset()
{
  this->isInitialized = false;
  this->PartyMembers.Empty();
}

在这种情况下,PrepareReset的作用是将isInitialized设置为false,以便下次调用Init时,小组成员将被重新加载。我们还在清空partyMembers数组,这样当小组成员被重新添加到数组中时,我们不会将它们附加到我们上次游玩中的小组成员实例(我们不希望带着已死亡的小组成员重置游戏)。

到目前为止,你可以进行编译。但在我们能够测试之前,我们需要设置我们创建的游戏结束 UIClass,并将其设置为GameOverScreen作为DefaultRPGGameMode中的类默认值:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-rpg-ue4x/img/B04548_03_28.jpg

就像上次你做的那样,编辑器可能会崩溃,但当你回到DefaultRPGGameMode时,你应该会看到GameOverScreen被正确设置。

为了测试这一点,我们需要给哥布林比玩家更多的生命值。打开敌人表格,给哥布林分配超过 100 HP 的任何数值(例如,200 就足够了)。然后,开始一场遭遇战并玩到主要小组成员的生命值耗尽。此时,你应该会看到一个游戏结束屏幕弹出,点击重新开始,你将重新开始这一关卡,主要小组成员的生命值将恢复到 100 HP。

摘要

在本章中,我们为 RPG 的核心玩法打下了基础。我们有一个可以探索世界地图的角色,一个跟踪小组成员的系统,一个回合制战斗引擎,以及一个游戏结束条件。

在接下来的章节中,我们将通过添加库存系统来扩展这一功能,允许玩家消耗物品,并为他们的小组成员提供装备以提升他们的属性。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐