虚幻-4-RPG-构建指南-全-

虚幻 4 RPG 构建指南(全)

原文: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++代码更容易。这些步骤并非绝对必要,可以安全地跳过。

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

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

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

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

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

禁用错误列表标签

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

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

禁用错误列表标签

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

禁用错误列表标签

设置新的 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 演员已经被添加到场景中:创建新的 C++ 类

  8. 要测试你的新演员类,点击 播放 按钮。你应该在控制台看到一条黄色的 Hello, world! 消息,如下面的截图所示。这可以在屏幕底部的 内容浏览器 选项卡右侧的 输出日志 选项卡中看到:创建新的 C++ 类

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

蓝图

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

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

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

创建新的蓝图

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

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

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

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

  4. 如果 事件开始播放 节点还没有在那里,右键单击图并展开 添加事件;然后,点击 事件开始播放。如果你需要移动 事件开始播放 等节点,只需在节点上左键单击并拖动到图上你想要的位置。你还可以通过按住鼠标右键并拖动屏幕来在图中导航:创建新的蓝图

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

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

  6. 接下来,我们希望当 事件开始播放 节点被触发时,这个节点被触发。为此,从 事件开始播放 节点的输出箭头拖动到 打印字符串 节点的输入箭头:创建新的蓝图

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

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

  9. 要将这个变量的值输入到我们的 打印字符串 节点中,右键单击并搜索 MyPrintString。你应该会在列表中看到一个可用的 获取我的打印字符串 节点。点击它以将节点添加到你的图中:创建新的蓝图

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

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

将蓝图添加到场景中

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

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

实体类蓝图

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

  1. 要这样做,首先像之前一样创建一个新的蓝图。然后,在选择父类时,搜索MyNewActor。点击列表中的MyNewActor条目:实体类蓝图

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

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

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

电子表格格式

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

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

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

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

一个示例电子表格

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

一个示例工作表

如前所述:

  • 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 文件。如果你在内容浏览器标签页中双击该资产,你会看到工作表中包含的项目列表:

导入工作表

查询工作表

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

我们将把它添加到我们的自定义演员类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

游戏模式类

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

游戏模式类

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

添加带皮肤的网格

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

添加带皮肤的网格

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

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

添加带皮肤的网格

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

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

添加带皮肤的网格

一旦进行游戏测试,你应该会看到你的角色使用你绑定的 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

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

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

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

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

类

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

角色

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

首先,创建一个名为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 水平框;唯一的重大区别是我们将框锚定在画布面板的底部,并定位使其跨越屏幕底部:

带有 UMG 的战斗 UI

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

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

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

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

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

战斗 UI 与 UMG

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

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

战斗 UI 与 UMG

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

要绑定 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 的结果图将类似于以下截图:

战斗 UI 与 UMG

现在我们已经有了玩家和敌人的小部件,让我们在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输入的子项添加。

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

战斗 UI 与 UMG

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

带有 UMG 的战斗 UI

最后,我们可以在战斗开始时从我们的游戏模式中生成这个 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,这是一个小部件,因此无法创建它。请注意,编辑器在执行此步骤时可能会崩溃。

带有 UMG 的战斗 UI

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

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

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引用作为参数传递:

UI 驱动的决策制定

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

UI 驱动的决策制定

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

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

UI 驱动的决策制定

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

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

UI 驱动的决策制定

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

UI 驱动的决策制定

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

UI 驱动的决策制定

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

UI 驱动的决策制定

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

UI 驱动的决策制定

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

UI 驱动的决策制定

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

UI 驱动的决策制定

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

UI 驱动的决策制定

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

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

创建游戏结束界面

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

创建游戏结束界面

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

创建游戏结束界面

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

创建游戏结束界面

接下来,在 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中的类默认值:

创建游戏结束屏幕

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

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

摘要

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

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

第四章:暂停菜单框架

到目前为止,我们已经为我们的游戏创建了一个基本的战斗引擎。现在我们可以深入到战斗外的操作,例如创建一个暂停菜单屏幕,在那里我们可以查看玩家统计数据和编辑库存。

在本章中,我们将创建菜单系统的第一部分,即设计和创建一个暂停菜单的框架。本章将涵盖以下主题:

  • UMG 暂停屏幕初始设置

  • UMG 背景颜色

  • UMG 文本

  • UMG 按钮

  • UMG 库存子菜单

  • UMG 设备子菜单

  • 键绑定

  • 按钮编程

UMG 暂停屏幕初始设置

对于我们的暂停屏幕,我们需要考虑很多关于设计的问题。如前一章所述,暂停屏幕将允许玩家查看团队成员、装备和卸载装备、使用物品等。因此,我们必须在设计暂停屏幕时考虑到这种功能。

为了设计暂停屏幕,我们将使用 Unreal Motion Graphics (UMG),这是 UE4 的一个独立部分,允许我们设计虚拟用户界面,而无需使用 Adobe Flash 等程序。UMG 非常直观,使用它不需要编程知识。

要开始设计,我们首先将导航到已经创建的 蓝图 | UI 文件夹,并为暂停菜单创建一个 Widget 蓝图。为此,右键单击您的 UI 文件夹,然后导航到 用户界面 | Widget 蓝图

UMG 暂停屏幕初始设置

将 Widget 蓝图命名为 Pause

UMG 暂停屏幕初始设置

Widget 蓝图将允许您使用 UMG 设计任何用户界面;我们将使用此 Widget 为暂停菜单设计我们自己的 UI。

要开始设计暂停菜单,双击 内容浏览器 中的 暂停 Widget 蓝图。您应该会看到类似于以下截图的 设计师 屏幕:

UMG 暂停屏幕初始设置

我们首先将创建一个区域,用于放置我们希望暂停菜单所在的第一个屏幕。我们首先将添加一个 Canvas Panel,它作为一个容器,允许在内部布局多个 Widget。这是一个很好的开始,因为我们需要展示几个导航点,我们将以按钮的形式在我们的暂停屏幕中设计它们。要添加 Canvas Panel,导航到 调色板 | 面板 | Canvas Panel。然后,将 Canvas Panel 拖动到您的 设计师 视图中(注意,如果您在 层次结构 选项卡中默认已经有 Canvas Panel,您可以跳过此步骤):

UMG 暂停屏幕初始设置

你应该在暂停菜单中看到一些新内容。首先,你会在层次结构选项卡下看到现在在中有一个CanvasPanel。这意味着暂停屏幕的根目录是我们刚刚添加的 Canvas Panel。你也会注意到,当 Canvas Panel 被选中时,它包含可以在详情选项卡中看到的详细信息。详情选项卡将允许你编辑任何选中项的属性。我们将在开发过程中频繁使用 Widget Blueprint 的这些区域。

现在我们需要考虑当玩家按下暂停按钮时,屏幕上需要显示哪些导航点和信息。根据功能,以下是我们将在第一个屏幕上布局的项目:

  • 角色及其统计数据(等级、HP、MP 和经验/下一级)

  • 物品栏按钮

  • 装备按钮

  • 退出按钮

  • 金色

UMG 背景颜色

在我们开始为菜单创建文本和按钮之前,我们首先应该制作一个背景颜色,这个颜色将放置在暂停屏幕的文本和按钮后面。为此,导航到调色板 | 常用 | 图像。然后,将图像拖放到 Canvas Panel 上,使图像位于 Canvas Panel 内。从这里,在详情 | 槽位下找到锚点下拉菜单。选择创建四个角落锚点的锚点选项。

这是在锚点下拉菜单底部右侧的一个看起来像覆盖整个画布的大正方形的图标:

UMG 背景颜色

完成后,将偏移右偏移下值设置为 0。这将确保,就像图像的左上角一样,图像的右下角也将从 0 开始,从而允许图像拉伸到我们画布四个角落的所有锚点:

UMG 背景颜色

为了使背景图像对眼睛更友好,我们应该将其颜色调得暗一些。要调整颜色,导航到详情 | 外观 | 颜色和透明度,然后点击旁边的矩形框。这将打开一个颜色选择器框,我们可以从中选择任何颜色。在我们的例子中,我们将使用暗蓝色:

UMG 背景颜色

完成后按确定。你会注意到你的图像名称类似于Image_###;我们应该调整这个名字,使其更具描述性。要重命名图像,只需导航到详情并更改名称。我们将名称更改为BG_Color。最后,在详情 | 槽位中更改ZOrder值到-1。这将确保背景绘制在其他小部件之后:

UMG 背景颜色

UMG 文本

现在我们已经完成了菜单背景的创建,是时候布局我们的文本和导航了。我们将通过导航到常用 | 文本来添加文本。然后,我们将拖放文本到位于层次结构选项卡中的画布面板:

UMG 文本

记下一些重要细节。首先,你会在层次结构选项卡中看到文本块位于画布面板内。这意味着画布面板正在充当文本的容器;因此,只有当玩家在画布面板中导航时才能看到它。你还会注意到详细信息选项卡已更改,以包含特定于文本的属性。这里列出了几个非常重要的细节,例如位置、大小、文本和锚点。最后,你应该会看到选定的文本以可移动和可调整大小的文本框的形式出现,这意味着我们可以根据需要放置和编辑它。现在,我们不会担心使暂停屏幕看起来很漂亮,我们只需要关注布局和功能。常见的布局将是导航从左到右和从上到下。由于我们是从角色开始的,所以我们将第一个文本设置为角色名称。此外,我们将它们从暂停菜单的左上角开始。

首先,我们将添加必要的文本以显示角色名称或类别。在选择文本时,导航到详细信息 | 内容 | 文本,并输入第一个类别的名称——Soldier。你会注意到你在内容选项卡中输入的文本现在已出现在设计师视图中的文本块中。然而,它很小,这使得文本难以看清。通过导航到详细信息 | 外观 | 字体来更改其大小。在这里,你可以将其大小更改为更大的数字,例如48

UMG 文本

将文本定位到详细信息 | 槽位,然后移动文本使其位于左上角,但给它留一些填充空间。在我们的例子中,我们将位置 X设置为100,将位置 Y设置为100,这样士兵就有 100 像素的填充空间。最后,我们将文本重命名为Header_Soldier

UMG 文本

你会注意到字体大小没有变化,这是因为你必须按下窗口左上角的编译按钮。每次你在设计视图中进行此类技术更改时,都需要按下编译按钮。编译完成后,你应该会看到你的字体大小已调整。然而,文本对于文本块来说太大。你可以通过简单地勾选大小适应内容来修复这个问题,它位于详细信息 | 槽位

UMG 文本

现在我们已经为我们的第一个角色创建了标题,我们可以继续为其状态创建更多文本。我们将首先创建一个用于 HP 的字体。为此,你需要在画布上添加另一个文本块:

UMG 文本

从这里,你可以调整你的文本块的位置,使其位于Header_Soldier文本的下方。在这个例子中,我们将将其放置在Position X值为200Position Y值为200的位置:

UMG 文本

然后,我们将为文本编写内容;在这种情况下,内容将是HP。在这里,我们将文本的字体大小设置为32并编译它;然后,我们将勾选Size to Content

UMG 文本

最后,我们将这个小部件命名为Menu_HP

UMG 文本

如你所见,我们只是添加了在菜单中显示HP的文本;然而,你还需要在屏幕上显示的实际数字。现在,我们只是在 HP 文本的右侧创建一个空的文本块。在本章的后面部分,我们将将其与上一章中为角色 HP 创建的代码联系起来。所以现在,将一个文本块拖放到你的 Canvas 面板下:

UMG 文本

将其重命名为Editable_Soldier_HP。然后,将其放置在Menu_HP的右侧。在这种情况下,我们可以将Position X值设置为300,将Position Y值设置为200

UMG 文本

最后,我们可以将字体样式改为Regular,字体大小设置为32,勾选Size to Content,并编译:

UMG 文本

现在你已经了解了布局的样子以及我们如何为角色及其属性创建文本块,你可以继续创建其他必要的属性,例如等级、魔法值(MP)和经验值/下一级。在你完成角色及其属性的布局后,你的最终结果应该看起来像以下这样:

UMG 文本

在这一点上,你也可以继续创建更多角色。例如,如果你想创建一个治疗者,你可以轻松地复制我们在暂停屏幕中为士兵创建的大部分内容和布局。你的暂停屏幕中可能有士兵和治疗者属性的占位符,可能看起来像以下这样:

UMG 文本

在这个屏幕上,我们还需要创建一个用于玩家收集的黄金的占位符。就像我们为团队属性所做的那样,创建一个文本块,并确保文本内容为Gold。将其放置在角色属性之外的地方,例如,在暂停屏幕的左下角。然后,将文本块重命名为Menu_Gold。最后,创建第二个文本块,将其放置在Menu_Gold的右侧,并命名为Editable_Gold

UMG 文本

就像角色属性中的空白文本框一样,我们将Editable_Gold与游戏后期积累的黄金联系起来。

我们现在可以继续创建菜单的按钮,这些按钮最终将导航到子菜单。

UMG 按钮

到目前为止,我们已经创建了暂停菜单的第一屏,其中包括所有角色以及他们统计数据和金币的占位符。接下来我们需要设计的是按钮,这将是第一屏的最后一部分。与其他软件包中的按钮类似,它们通常用于触发围绕鼠标点击构建的事件。程序员可以让按钮监听鼠标按钮的按下,并基于该按钮点击执行一系列动作。我们创建的按钮将被用作子菜单的导航,因为我们需要一种在物品栏和装备屏幕之间导航的方法。因此,在我们的主屏幕上,我们需要一个用于库存和装备的按钮。我们还需要一个按钮来进入暂停菜单并继续玩游戏。

让我们从创建第一个按钮开始。导航到调色板 | 常用 | 按钮,并在层次选项卡下的画布面板中放置它:

UMG 按钮

为了组织起见,我们将第一个按钮放置在菜单的右上角。所以最好的做法是导航到详情 | 槽位 | 锚点,并将按钮锚定在右上角。这将确保当屏幕或对象调整大小时,按钮会与右侧对齐:

UMG 按钮

你应该会注意到屏幕上的锚点图标移动到了屏幕的右上角。你还会注意到位置 X的值变成了一个负数,这反映了屏幕的大小,因为按钮位置的原点被放置在屏幕的另一端;这个特定按钮的位置 X值现在被反转。这个概念一开始可能有些令人困惑,但从长远来看,它将使每个按钮的放置计算变得更容易:

UMG 按钮

位置 X值更改为-200(因为按钮的位置 X现在是-1920,以便从左侧定位,而-100用于定位到右侧,为了添加100像素的填充,应该是-200),并将位置 Y值更改为100。将此按钮命名为Button_Inventory

UMG 按钮

现在我们将向按钮添加文本。因此,在调色板 | 常用 | 文本下选择文本,并将其拖入按钮中。你会注意到文本在层次选项卡和设计器视图中都在按钮内:

UMG 按钮

你可能还会注意到文本的大小并不符合我们的喜好,它也没有完全适应按钮。我们不必立即调整按钮的大小,而是先调整文本到我们喜欢的尺寸,然后再调整按钮以适应文本,这样文本才能被清晰地阅读。导航到详情 | 外观 | 字体,并将字体大小更改为48

UMG 按钮

然后,在详细信息 | 内容 | 文本下,将文本更改为库存,并将文本小部件的名称更改为Menu_Inventory

UMG 按钮

点击按钮 _ 库存。你可能认为在这里检查大小到内容是个好主意,但在这个情况下并不适用,因为你会创建多个按钮,每个按钮中都有不同的文本。因此,如果它们都根据内容(即按钮内的文本)来调整大小,所有按钮的大小都会不同,这看起来非常不吸引人。相反,你应该选择一个可以轻松容纳所有文本的按钮大小,即使是对于最长的文本。对于这个按钮,我们将将大小 X值更改为350大小 Y值更改为100

UMG 按钮

你会注意到按钮被绘制在屏幕之外,这是因为按钮,就像其他所有对象一样,仍然是从对象的左上角开始绘制的,所以我们需要再次调整我们的位置 X值;然而,由于我们锚定在右上角,数学运算很简单。我们只需要取按钮的水平尺寸,350,然后从按钮认为屏幕右边缘的位置减去它,由于锚定,这个位置是 0。所以这给出了0 - 350 = -350。然后,我们取-350 并减去我们想要的 100 像素的填充,这给出了-350 - 100 = -450,这就是我们应该更改位置 X的值:

UMG 按钮

现在我们已经将按钮放置得恰到好处,我们可以放置更多按钮。我们将使用相同的步骤在库存按钮下方创建一个装备按钮。一旦你完成了装备按钮的创建,它就可以放置在库存按钮的正下方:

UMG 按钮

我们还可以创建一个退出按钮,我们将将其放置在屏幕的右下角:

UMG 按钮

就这样——我们已经完成了暂停菜单的第一屏的设计。你会注意到我们还没有为按钮编写任何程序,这是因为我们还没有为按钮导航到屏幕,所以现在编写按钮程序还没有意义。下一步将是设计我们的子菜单。

UMG 库存子菜单

如前所述,我们需要为按钮创建子菜单以便导航。使用 UMG,有几种创建子菜单的方法,但最直接的方法是为每个子菜单创建一个新的 Widget Blueprint,然后将 Widget Blueprints 绑定在一起。

由于我们将在子菜单中需要许多相同的物品,如角色名称和大部分统计数据,我们可以通过仔细复制我们的主暂停菜单、重命名它,然后编辑以适应我们需要的任何子菜单来节省大量时间。由于我们最初将主暂停菜单保存为Pause,我们可能想要首先将其重命名,使其更具描述性。因此,返回到您的内容浏览器,找到您保存暂停菜单的位置,通过右键单击暂停菜单小部件并选择重命名来重命名它。将此文件重命名为Pause_Main

UMG 库存子菜单

接下来,通过右键单击文件并选择复制来复制Pause_Main

UMG 库存子菜单

将其重命名为Pause_Inventory

UMG 库存子菜单

现在,我们将能够设计一个库存屏幕。打开您新创建的Pause_Inventory Widget Blueprint。您会注意到它是对Pause_Main的精确复制。从这里,我们可以编辑掉不需要的内容。首先,我们计划不包含任何影响经验值的物品,因此我们可以从角色中移除经验值文本块:

UMG 库存子菜单

此外,我们也不需要在库存屏幕中跟踪金币。因此,我们可以移除金币。

为了便于创建,我们还将通过使用Pause_Main作为所有子菜单(如Pause_InventoryPause_Equipment)的中心枢纽,并将导航设置为“老式”方式,只允许玩家在退回到Pause_Main并从那里按下装备按钮时进入装备屏幕。基于这种设计背后的想法,我们可能还会从该屏幕中移除库存装备按钮:

UMG 库存子菜单

然而,我们可以保留退出按钮,但根据我们屏幕导航背后的想法,我们应该将此按钮及其文本块重命名为反映按下时退出屏幕并进入Pause_Main。因此,我们可以选择Button_Exit并将其重命名为Button_Back

UMG 库存子菜单

然后,选择退出按钮内的文本块,将其重命名为Menu_Back,并将文本更改为返回

UMG 库存子菜单

在上一章中,我们定义的统计数据不仅仅是 HP 和 MP;我们还定义了攻击力、防御力和幸运。虽然健康和魔法药水通常不会影响除了 HP 或 MP 之外的任何统计数据,但你可能稍后想要创建一些可使用并影响幸运、防御力或攻击力的物品。为此,我们将以创建 HP 和 MP 文本块相同的方式为每个角色创建这三个其他统计数据的占位符。我们将将这些统计数据定位在 HP 和 MP 统计数据下方。请注意,如果你为这些统计数据没有足够的空间,你可能需要调整间距。此外,记得用非常描述性的名称命名你创建的所有文本块,这样你就可以在需要引用它们的时候识别它们。

当你完成添加统计数据后,你的文本块应该看起来像以下截图:

UMG 库存子菜单

现在我们需要一个地方来填充我们的库存。由于我们不确定游戏中的玩家会携带多少物品,因此创建一个将填充我们库存的滚动框是最安全的。我们还想创建一个足够宽的滚动框,以防我们有一些非常长的物品名称。如果你设计的屏幕像我一样,你应该有足够的空间放置滚动框。要创建滚动框,导航到调色板 | 面板 | 滚动框

UMG 库存子菜单

然后,将其拖动到层次选项卡下的 Canvas 面板中:

UMG 库存子菜单

目前,将滚动框重命名为ScrollBox_Inventory。然后,调整位置使其位于屏幕中间,同时占据屏幕上的大量空间。我将我的位置 X值更改为700位置 Y值更改为200大小 X值更改为600大小 Y值更改为600。完成时,你的滚动框应该看起来像以下截图:

UMG 库存子菜单

在下一章中,我们将动态地将物品插入滚动框,并创建将物品效果应用于每个角色的逻辑。

为了完成这个屏幕,你应该通知玩家他们目前正在查看哪个屏幕。因此创建另一个文本块,并将字体大小调整为 48 像素。为你的文本块选择一个中心顶部的锚点。这将使得你的文本块将 0 X位置识别为屏幕中间,将0 Y 位置识别为屏幕顶部。因此,你现在可以将0作为位置 X值,并填充位置 Y值:

UMG 库存子菜单

你会注意到库存并没有正好位于屏幕中间,因此调整位置 X值直到它居中。我将我的位置 X值调整为文本块大小的一半,结果是-135:

UMG 库存子菜单

到目前为止,您可以保存并编译您的库存屏幕。我们现在就到这里。

UMG 设备子菜单

我们需要设计的最后一个子菜单是设备子菜单。由于我们的设备子菜单将与库存子菜单非常相似,因此最简单的方法是返回到内容浏览器,复制Pause_Inventory,并将其重命名为Pause_Equipment,这样Pause_Equipment就成为了Pause_Inventory的直接副本。接下来,打开Pause_Equipment

我们将以与库存屏幕类似的方式设计这个屏幕。我们仍然会使用滚动框来填充项目(在这种情况下,是装备)。我们将主要保持每个角色的相同属性;我们将继续使用 Back 按钮,最终将导航回暂停屏幕。让我们编辑差异。首先,将屏幕标题从库存更改为装备,并重新定位使其水平居中屏幕:

UMG 设备子菜单

接下来,我们需要编辑角色属性。我们可能在这个游戏中有一些装备,当装备时,会改变 AP、DP 和 Lk 属性。然而,我们很可能不会有影响 HP 和 MP 的装备。我们还知道,我们需要为每个角色提供武器和盔甲。因此,我们可以轻松地将 HP 和 MP 的文本编辑为武器和盔甲(我将它们称为WeapArm以节省空间)。在细节方面,我将Menu_HP文本块的名称更改为Menu_Weapon,并将文本块的内容更改为Weap。我们将对Menu_MP做类似处理,将其更改为盔甲槽位:

UMG 设备子菜单

在替换任何其他角色的 HP 和 MP 值时,使用武器和盔甲占位符时,遵循类似的命名约定。当你完成时,你的屏幕应该看起来像下面的截图:

UMG 设备子菜单

由于我们的角色将装备武器和盔甲,我们需要为这些槽位提供占位符。最终,我们将允许玩家选择他们想要装备的装备,装备将出现在适当的武器或盔甲槽位中。最合适的小部件类型是Border。这将包含一个文本块,当装备武器或盔甲时,文本块将发生变化。为此,从调色板 | 常用 | Border中选择Border。将 Border 拖入画布面板:

UMG 设备子菜单

然后,调整画布面板的位置,使其与放置在士兵Menu_Weapon右侧的文本块相同。此时,您可以删除Menu_Weapon右侧的文本块。它最初用作 HP 的文本,我们现在不再需要它:

UMG 设备子菜单

我们仍然需要在边框中添加文本,所以从调色板 | 常用 | 文本中拖拽文本到你的边框中:

UMG 装备子菜单

目前你可以保留文本块的默认设置,但你会注意到边框没有调整大小,一切仍然是白色。返回到你的边框,并检查大小调整到内容。在外观 | 画笔颜色下,将A值改为0A值是透明度。当透明度为 0 时,颜色完全透明,当透明度为 1 时,颜色完全不透明;介于两者之间的颜色则只有轻微的透明度。我们并不关心看到块的颜色,我们希望它对玩家不可见:

UMG 装备子菜单

最后,将边框名称更改为描述性的名称,例如Border_Weapon

UMG 装备子菜单

返回到边框内的文本块。将文本块命名为Text_Weapon,并将字体改为 32 像素的常规样式,以匹配其他文本块:

UMG 装备子菜单

现在你已经知道了如何为士兵的武器设计边框和文本块,你还可以为士兵的盔甲以及任何其他角色的武器和盔甲设计边框和文本块。完成之后,你应该会有以下截图所示的内容:

UMG 装备子菜单

到目前为止,我们已经完成了所有当玩家按下暂停按钮时出现的屏幕的设计。下一步将是编写这些屏幕的功能程序。

按键绑定

我们现在将绑定一个键来打开暂停菜单,并且只有在玩家不在战斗中(换句话说,玩家在野外)时才允许这样做。由于我们已经在上一章中设置了FieldPlayer,我们可以在我们的FieldPlayer蓝图类中轻松创建控制暂停菜单的动作。首先,导航到蓝图 | 打开蓝图类… | FieldPlayer

按键绑定

到目前为止,我们希望在玩家按下某个键时弹出暂停屏幕;在这种情况下,我们将使用P键来暂停。为此,我们首先需要创建一个按键事件,在按下特定键后触发我们选择的一系列动作。要开始这个按键事件,右键点击你的事件图,这将打开所有可以与这个蓝图关联的动作,然后导航到输入 | 按键事件 | P

按键绑定

完成后,这将创建一个针对P的按下和释放的关键事件。你会注意到,这个事件具有按下和释放的可执行文件,它们按描述工作,当玩家按下P或玩家释放P时可以执行动作。对于我们的暂停游戏和弹出暂停菜单的操作,我们将使用释放的可执行文件,因为只有当释放的可执行文件被调用时,这意味着玩家已经完成了按下和释放键的动作。对于玩家来说,像在棋盘上放下棋子一样按下按钮是一种常见的最佳实践。在我们弹出暂停菜单之前,让我们通过在事件图中右键单击并导航到游戏 | 设置游戏暂停来创建一个调用设置游戏暂停函数:

键绑定

游戏暂停节点中,检查暂停以确保它设置为 true,并将关键事件P释放可执行链接到设置游戏暂停的输入端口。现在,当玩家按下并释放P键时,你的游戏应该会暂停:

键绑定

从这里,我们将弹出暂停菜单。为此,在游戏暂停时,我们将通过在事件图上右键单击并导航到用户界面 | 创建小部件来创建主暂停屏幕。这允许我们创建我们制作的任何小部件的实例。我们可以在创建小部件中创建Pause_Main,方法是按选择类下拉菜单,然后选择Pause_Main

键绑定

接下来,我们可以将设置游戏暂停的输出引脚链接到创建 Pause_MainWidget的输入引脚:

键绑定

这样,在游戏暂停后,将创建Pause_Main。尽管我们正在创建Pause_Main,但它仍然不会在屏幕上弹出,直到我们告诉它绘制到屏幕上。为此,我们需要创建一个调用添加到视口函数的调用,该函数可以将任何图形添加到视口中。为此,在创建 Pause_Main小部件节点上左键单击并拖出返回值引脚,然后选择用户界面 | 视口 | 添加到视口。这将创建一个新的添加到视口节点:

键绑定

如果你测试这个,你会注意到游戏暂停,暂停菜单弹出,但缺少鼠标光标。为了添加鼠标光标,我们首先需要通过在事件图上右键单击并导航到游戏 | 获取玩家控制器 | 获取玩家控制器来获取玩家控制器:

键绑定

从这里,只需左键单击并拖出Get Player Controller节点的返回值引脚,然后选择变量 | 鼠标界面 | 设置显示鼠标光标

键绑定

完成后,将添加到视图节点的输出引脚连接到设置显示鼠标光标的输入引脚。这将做的是在视图中显示暂停菜单后,将显示鼠标光标变量(这是一个布尔值)设置为 true 或 false。设置显示鼠标光标变量还需要获取玩家控制器,因为玩家控制器持有鼠标输入信息。

如果你现在进行测试,你会注意到鼠标光标仍然没有显示;这是因为设置显示鼠标光标中的显示鼠标光标未勾选,这意味着显示鼠标光标被设置为 false,所以每次你想显示鼠标光标时都要勾选复选框。

到目前为止,按下P键后,你的菜单应该会完美弹出,鼠标应该完全可见且可控制。你的关卡蓝图现在应该看起来像以下截图:

键绑定

你会注意到实际菜单中的所有按钮都无法工作,因此我们无法退出暂停菜单或查看任何子菜单。这是因为我们还没有对任何菜单按钮进行编程。现在,我们将专注于编程这些按钮。

按钮编程

现在我们已经完成了玩家对暂停菜单的访问,接下来我们将专注于主暂停菜单及其子菜单内的导航。此时,返回到你的Pause_Main部件。让我们首先创建到Pause_Inventory的导航。为此,点击库存按钮:

按钮编程

导航到详情 | 事件 | OnClicked,然后按下+按钮:

按钮编程

点击+按钮将自动打开Pause_Main的事件图,并创建一个OnClicked事件:

按钮编程

OnClicked 事件的工作方式将与我们在上一节中创建的键绑定相似,那里我们创建了一个允许我们按下一个键的东西,这个键可以触发一个事件,进而触发一系列动作。但这次,OnClicked 事件绑定到我们的库存按钮上,并且只有在用户左键点击了库存按钮时才会触发。我们想要做的是,当我们点击库存按钮时,创建一个Pause_Inventory小部件,并在屏幕上显示。这听起来非常熟悉,因为我们刚刚用Pause_Main做了类似的事情。所以首先,创建一个部件并将其附加到OnClicked事件。接下来,你会注意到部件中的Class引脚是空的,因此我们需要选择一个类。你会选择Pause_Inventory,因为我们希望在按钮被按下时创建库存小部件:

按钮编程

最后,只需将此部件添加到视图中,以便用户可以看到显示的库存。最终,你的蓝图应该看起来像以下截图:

按钮编程

如果你现在测试你的暂停屏幕,你应该会注意到你可以导航到你的库存屏幕,但你不能导航回你的主暂停屏幕。这很容易修复。只需打开你的Pause_Inventory小部件,按下Back按钮,导航到Details | Events | OnClicked,然后按下+按钮:

按钮编程

就像我们上一个按钮一样,事件图将自动打开,并为我们的按钮创建一个OnClicked事件;只是这次,事件绑定到了我们的Back按钮上:

按钮编程

从这里,你将通过将OnClicked事件链接到Remove from Parent来设置屏幕移除自己。

当你完成创建Back按钮的蓝图后,它应该看起来像下面的截图:

按钮编程

你现在可以通过确保当我们点击Equipment按钮时创建一个Pause_Equipment小部件并显示它;当我们点击Pause_Equipment中的Back按钮时,导航回Pause_Main并移除Pause_Inventory屏幕来为Pause_Equipment创建相同的导航设置。

下一步是允许玩家在点击Exit按钮时退出。为此,你必须在Pause_Main中的Exit按钮上创建一个OnClicked事件。再次,当你按下Design视图中的OnClicked+按钮时,事件图中会创建一个Exit按钮的OnClicked按钮:

按钮编程

从这里,你将通过将OnClicked事件链接到Remove from Parent来设置屏幕移除自己:

按钮编程

你的屏幕现在应该可以完美导航,但我们还没有完成。你最后会注意到,当退出暂停菜单时,游戏仍然是暂停状态。我们需要取消暂停游戏。这个修复非常简单。在Pause_Main蓝图内部,只需将Set Game Paused链接到Remove from Parent,这样当小部件被移除时,游戏就会取消暂停:

按钮编程

你可能会注意到,当你离开暂停菜单时,鼠标光标仍然存在。你可以通过创建一个Set Show Mouse Cursor节点并将其连接到你在取消暂停游戏后添加的OnClicked Button_Exit事件来移除鼠标光标,这与你最初添加鼠标光标的方式类似,这次确保Set Show Mouse Cursor节点内的复选框未勾选,意味着Set Show Mouse Cursor被设置为 false,并将其连接到一个Get Player Controller

就这样。我们现在已经完成了暂停菜单及其子菜单的导航。

摘要

在本章中,我们完成了暂停菜单的占位符,用于游戏中的其他重要方面,例如库存装备滚动框,这些框将保存我们在游戏中获得的库存/装备。在接下来的几章中,我们将继续扩展这个暂停菜单,涵盖统计数据、金币、物品和装备的跟踪。

第五章。连接角色统计信息

现在我们已经为我们的暂停菜单设置了一个基本框架,我们将现在专注于暂停菜单的编程方面。

在本章中,你将学习如何将角色统计信息链接到暂停菜单,正如在第四章 Chapter 4 中讨论的暂停菜单框架。到本章结束时,你将能够将任何其他你希望链接到 UMG 菜单或子菜单的游戏统计信息链接起来。本章我们将涵盖以下主题:

  • 获取角色数据

  • 获取玩家实例

  • 显示状态

获取角色数据

到目前为止,暂停菜单已经完全设计完成,并准备好进行数据集成。在第三章 Chapter 3 中,探索与战斗,我们开发了一些方法来显示一些玩家参数,例如玩家的名字、HP 和 MP,通过将文本块与Game Character变量绑定到 CombatUI,以便访问Character Info中持有的角色状态值。我们将以与上一章非常相似的方式完成这项工作,首先打开Pause_Main小部件,然后点击我们将要更新其值的文本块。

在这种情况下,我们已经为所有状态值指定了位置,因此我们将从名为Editable_Soldier_HP的 HP 状态值开始:

获取角色数据

导航到Content | Text,然后点击旁边的下拉菜单中的Bind下拉菜单。在下拉菜单中点击Create Binding

获取角色数据

完成此过程后,将创建一个新的函数Get_Editable_Soldier_HP_Text_0,你将自动被拉入新函数的图表。与之前的绑定一样,新函数也将自动具有FunctionEntry及其标记的返回值:

获取角色数据

我们现在可以创建一个新的Game Character引用变量,我们再次将其命名为Character Target

获取角色数据

然后,我们将Character Target变量拖入Get_Editable_Soldier_HP_Text_0图表,并将其设置为Get

获取角色数据

接下来,我们将创建一个名为Get HP的新节点,它位于Variables | Character Info下,并将它的Target引脚链接到Character Target变量引脚:

获取角色数据

最后,将Get Editable Soldier HP Text 0节点中的 HP 状态值链接到ReturnNodeReturn Value引脚。这将自动创建一个To Text (Int)转换节点,该节点负责将任何整数转换为字符串。完成之后,你的Get_Editable_Soldier_HP_Text_0函数应该看起来像这样:

获取角色数据

获取玩家实例

如果你现在进行测试,你会看到在我们的暂停菜单中创建了一个值,但这个值是0。这是不正确的,因为根据角色的当前属性,我们的角色应该从 100 HP 开始:

获取玩家实例

问题发生是因为访问暂停菜单的Field Player从未将我们的任何角色数据分配给Character Target。我们可以在蓝图(Blueprint)中轻松设置正确的角色目标,但如果没有将我们的添加的队伍成员暴露给蓝图,我们将无法分配任何角色数据。因此,我们首先需要进入RPGGameInstance.h,并允许我们的当前游戏数据在UProperty参数中暴露给蓝图中的游戏数据类别:

UPROPERTY( EditDefaultsOnly, BlueprintReadOnly, Category = "Game Data" )

你的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:
  UPROPERTY( EditDefaultsOnly, BlueprintReadOnly, Category = "Game Data" )
  TArray<UGameCharacter*> PartyMembers;

protected:
  bool isInitialized;

public:
  void Init();
  void PrepareReset();
};

保存并编译你的代码后,你应该能够在蓝图(Blueprint)中正确调用任何创建和添加的队伍成员,因此我们应该通过Field Player蓝图获得读取访问权限。

现在,你可以导航回Field Player蓝图,并通过创建位于游戏下的Get Game Instance函数节点来获取RPGGameInstance

获取玩家实例

Get Game Instance返回值转换为RPGGameInstance,它位于实用工具 | 转换 | RPGGameInstance。现在你已经得到了RPGGameInstance类的实例,你可以通过导航到在GameData下的变量中为它创建的类别,让这个实例引用包含所有队伍成员的TArray队伍成员

获取玩家实例

在这里,我们需要指向包含我们的士兵角色属性的数组元素,这是我们的第一个元素或数组的0索引,通过将队伍成员数组链接到一个GET函数来实现,该函数可以通过导航到实用工具 | 数组找到:

获取玩家实例

注意

对于额外的角色,你需要将另一个GET函数链接到队伍成员,并让GET函数指向数组中指向任何其他角色的元素(例如,如果你有一个位于索引 1 的治疗师,你的第二个GET函数只需将其索引列为 1 而不是 0,以从治疗师的属性中提取)。现在,我们只是专注于士兵的属性,但你将想要获取队伍中每个角色的属性。

最后,一旦我们完成了RPGGameInstance的施法,我们需要将我们在暂停菜单中创建的Character Target设置为我们的队伍成员。为此,右键单击你的事件图以创建一个新动作,但取消选中上下文相关,因为我们正在寻找在另一个类(Pause_Main)中声明的变量。如果你导航到 | Pause Main,你会找到Set Character Target

获取玩家实例

在这里,只需将 Character Target 链接到你的 GET 函数的输出引脚:

获取玩家实例

然后,设置 Character Target 以在 RPGGameInstance 被调用后触发:

获取玩家实例

显示属性

现在,我们需要选择一个合适的位置来调用 RPGGameInstance。最好在创建暂停菜单后调用 RPGGameInstance,因此将 Set Show MouseCursor 节点的输出引脚链接到 Cast To RPGGameInstance 节点的输入引脚。然后,将 Create Pause_Main WidgetReturn Value 链接到 Set Character TargetTarget。当你完成时,你的 FieldPlayer 下的 EventGraph 应该看起来像这样:

显示属性

当你完成时,你会看到士兵的 HP 正确显示为当前 HP:

显示属性

现在,你可以通过绑定函数并将这些函数返回值(如角色目标的 MP 和名称)添加到暂停菜单中的 Pause_Main 中的文本块。当你完成你的士兵角色后,你的 Pause_Main 应该看起来像这样:

显示属性

注意

我们还没有等级或经验,我们将在后面的章节中介绍等级和经验。

如果你还有其他角色,请确保你也添加它们。如前所述,如果你的队伍中有其他角色,你需要回到你的 FieldPlayer 事件图并创建另一个 GET 函数,该函数将获取其他队伍成员的索引并将它们分配给新的 Character Targets

现在我们回到 Pause_Inventory 小部件,并将角色属性绑定到相应的文本块。就像在 Pause_Main 中一样,选择一个你想要绑定的文本块;在这种情况下,我们将获取 HP 右侧的 Text Block

显示属性

然后,简单地为文本块创建一个绑定,就像你为其他文本块所做的那样。这将当然为一个新的函数创建一个绑定,我们将返回 Character Target 的 HP 状态。问题是我们在 Pause_Main 中创建的 Character Target 是一个局部于 Pause_MainGame Character 变量,因此我们不得不在 Pause_Inventory 中重新创建 Character Target 变量。幸运的是,步骤是相同的;我们只需要添加一个新的变量并将其命名为 Character Target,然后将其类型设置为指向 Game Character 的对象引用:

显示属性

当你完成时,添加Character Target变量作为 getter,将Character Target变量链接到获取你角色的 HP,并将该值链接到你的ReturnNodeReturn Value,就像你之前做的那样。你应该有一个看起来与以下截图非常相似的 Event Graph:

显示统计信息

如果你此时测试库存屏幕,你会看到 HP 值为 0,但不要慌张,现在由于FieldPlayer为我们的人物提供了一个通用的框架,你不需要进行太多关键的思考来纠正这个值。如果你记得,当我们创建FieldPlayer类中的Pause_Main小部件后,我们从游戏实例中拉取了我们添加的团队成员,并将其设置在Pause_Main中的Character Target。我们需要执行类似的步骤,但不是在FieldPlayer中开始检索团队成员,而是在创建Pause_Inventory的类中执行,该类是在Pause_Main中创建的。所以,导航到Pause_Main小部件的事件图:

显示统计信息

在前面的截图中,我们看到我们通过点击相应的按钮创建了Pause_InventoryPause_Equipment小部件。当屏幕创建完成后,我们移除当前视口。这是一个创建我们的RPGGameInstance的完美位置。所以,如前所述,创建一个位于Game下的Get Game Instance。然后,通过转到Utilities | Casting将返回值设置为Cast to RPGGameInstance,这将引用位于Variables下的Game Data中的Party Members数组。在这里,你将通过转到Utilities | Array使用Get函数,并将其链接到Party Members数组,拉取索引 0。这就是你应该做的,到目前为止,步骤与你之前在FieldPlayer中做的相同:

显示统计信息

当你设置Character Target时,会设置不同的差异。如前所述,我们将设置我们新创建的Character Target变量的Character Target变量为Pause_Inventory

显示统计信息

一旦完成,将Cast To RPGGameInstance的输出引脚链接到Set Character Target的输入引脚。同时,将Get链接到Character Target

显示统计信息

最后,将来自Pause_InventoryAdd to Viewport的输出引脚链接到Cast To RPGGameInstance的输入引脚,以触发角色统计信息的检索,并将Create Pause_Inventory WidgetReturn Value链接到Set Character TargetTarget

显示统计信息

在这一点上,如果你测试库存屏幕,你会注意到 HP 值被正确显示:

显示统计信息

现在你已经知道了如何从Pause_Main创建对党派成员的引用,你可以遵循相同的步骤将每个党派成员设置为Pause_Inventory中的角色目标。但首先,我们需要通过在每个属性的相应文本块中创建绑定并设置每个文本块的返回值为从角色目标检索到的值来完成Pause_Inventory中所有属性值显示。

一旦你在Pause_Inventory中的士兵上完成操作,你将看到类似这样的东西:

显示属性

到目前为止,你可以轻松地返回到Pause_Equipment,创建一个新的Character Target变量,然后在Pause_Main中显示Pause_Equipment时将Party Members设置为Character Target变量,就像你在Pause_Inventory中做的那样。当你完成时,Pause_Main事件图中的InventoryEquipment按钮应该看起来像这样:

显示属性

Pause_Equipment小部件中,我们只能绑定APDPLkName文本块,因为我们将在稍后处理武器。如果你以与绑定Pause_Inventory文本块完全相同的方式使用新创建的Character Target绑定这些文本块,你的Equipment屏幕在测试时将看起来像这样:

显示属性

到目前为止,我们已经完成了将角色属性绑定到我们的暂停菜单屏幕的工作。

摘要

在本章中,我们将当前的角色属性添加到了暂停菜单。现在我们已经熟悉了 UMG,我们将继续通过对话框与 NPC 进行通信,并添加一个商店到游戏中。

第六章:NPC 和对话

到目前为止,你已经有了一个默认的玩家角色,它可以与战场上的战斗交互,但游戏迫切需要非玩家角色(NPC)。

在本章中,你将创建一个 NPC,它将充当信息中心和店主。由于我们尚未为角色提供可用的物品和装备,或者让角色拥有金币的理由,下一步合乎逻辑的步骤是创建一个店主,他将通过与玩家交谈并当玩家决定与 NPC 交互时提供商业交易来扮演 NPC 的角色。

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

  • 创建 NPC 角色蓝图

  • 与 NPC 交互

  • 对话框设置

  • 创建 NPC 欢迎框

  • 添加 NPC 对话框

创建 NPC 角色蓝图

要开始,我们需要创建一个新的角色蓝图类。由于我们已经有了一个角色的位置,导航到位于内容浏览器下的内容 | 蓝图 | 角色中的character文件夹(如果还没有,请在content/blueprints中创建一个新的character文件夹,以保持更整洁;如果你愿意,甚至可以将你的FieldPlayer蓝图拖到character文件夹中),然后通过点击添加新 | 蓝图类来添加一个新的角色:

创建 NPC 角色蓝图

选择父类窗口将弹出。这将允许你为你的蓝图类选择一个通用的父类或创建你自己的类:

创建 NPC 角色蓝图

UE4 内置了许多常见的类,这些类已经为我们构建了许多不同类型类的框架。其中之一是角色类,它允许我们使用一个通用的角色框架来与任何类型的角色棋子一起使用。因此,从此窗口中选择角色

一旦你选择了角色,一个新的角色蓝图现在应该在内容 | 蓝图 | 角色中。将角色重命名为NPC_ShopOwner,因为我们将会使用这个角色作为店主:

创建 NPC 角色蓝图

从这里,打开NPC_ShopOwner以进入你新角色的视口:

创建 NPC 角色蓝图

你应该看到,如前所述,这个类继承了Character.h,它已经为许多组件提供了一个框架。这将使我们能够轻松地制作一个可见的非玩家角色(NPC)。在组件面板中,你会看到胶囊组件,它包含继承的数据,例如箭头组件,它决定了对象指向的方向,以及网格,它包含一个骨骼网格的实例。

让我们先为我们的网格组件应用网格和动画。由于我们在这本书中没有创建角色模型和动画,我们可以简单地使用内置资源。首先,点击组件 | CapsuleComponent中的网格

创建 NPC 角色蓝图

从这里,你会注意到你的细节面板会改变,以便显示网格变量及其组件。目前,我们希望在这里保留大多数默认设置,因为角色已经拥有了从 NPC 中期望得到的一切。然而,角色没有网格,所以让我们通过导航到细节 | 网格,并在骨骼网格中选择包含骨骼网格的下拉菜单来给它一个网格。然后,你应该能看到我们项目中所有可用的骨骼网格。在我们的游戏中,有一个名为SK_Mannequin的骨骼网格,我们将选择它作为我们的骨骼网格:

创建 NPC 角色蓝图

现在我们已经选择了一个骨骼网格,你应该能在视口中看到它。在这个阶段,你可能注意到你的骨骼网格比胶囊底端要高得多,因为骨骼网格的起点在其脚部,并且连接到位于胶囊中间的胶囊原点。解决这个问题有很多方法,但最快的方法是通过将角色在Z轴上向下移动来手动重新定位角色。在这个例子中,Z 位置-90值似乎效果很好:

创建 NPC 角色蓝图

你还希望角色面向正确的方向。你可以看到角色面向错误的方向,因为箭头组件与角色面向的方向垂直。只需调整角色在Z轴上的旋转。Z 旋转-90值似乎很有效:

创建 NPC 角色蓝图

最后,我们需要将角色从放松的姿态中解脱出来。因此,导航到细节 | 动画并选择Anim Class下拉菜单。在这个下拉菜单中,选择为所选角色提供的动画类ThirdPerson_AnimBP。如果你使用的是具有不同动画类的另一个角色,请确保选择为你的角色构建的动画类:

创建 NPC 角色蓝图

我们还需要一种与这个角色交互的方法。为此,我们将创建一个在角色前方扩展一定距离的体积;这将是在玩家能够与 NPC 交互的区域。为此,我们需要向CapsuleComponent添加另一个组件。导航到组件面板,并选择添加组件 | 碰撞 | 箱形碰撞

创建 NPC 角色蓝图

这将在角色的原点创建一个盒子碰撞:

创建 NPC 角色蓝图

我们将通过计算玩家是否在盒子内或盒子外来使用这个碰撞。如果他们在盒子内,玩家将能够与 NPC 交互;如果玩家在盒子外,玩家将无法与 NPC 交互。由于我们希望使其尽可能真实,玩家只有在角色站在 NPC 前面时才能与 NPC 交互。因此调整盒子的位置和缩放,直到它位于角色前面,并且大小使得玩家可以轻松地走到 NPC 前面与它交互。

对于这个角色,我将编辑位置 X值为60缩放 X值为2缩放 Y值为2,以及缩放 Z值为3

创建 NPC 角色蓝图

最后,我们希望给碰撞盒一个类型,它不会阻止玩家,但允许玩家进入碰撞盒。导航到详情 | 碰撞,在碰撞预设下,选择触发

创建 NPC 角色蓝图

这将基本上将碰撞盒转换为可以触发如对话和商店菜单等事件的触发体积。

在这一点上,你可以将你的NPC_ShopOwner拖放到你的关卡中:

创建 NPC 角色蓝图

如果你进行测试,你应该注意到你会与骨骼网格发生碰撞,但你不会与触发体积发生碰撞。你现在可以准备创建蓝图来使这个 NPC 交互式。

与 NPC 交互

现在你已经创建了将触发与 NPC 交互的 NPC 和体积,是时候通过使用触发体积来编程 NPC 的交互了。

让我们先思考一下逻辑。我们希望做到的是,只有当玩家在 NPC 的视线范围内(在这种情况下,触发体积)时,才允许玩家与 NPC 交互。如果玩家不在触发体积内,我们不想允许玩家与 NPC 交互。在这种情况下,我们需要某种布尔值,当玩家在触发体积内时返回 true,当角色不在触发体积内时返回 false。我们还希望允许玩家按下一个键与 NPC 交互,但只有在创建的布尔值设置为 true 时才允许,因为我们创建的用于跟踪 NPC 触发体积的布尔值可能跨越多个类。就像前面的章节一样,让我们在 RPGGameInstance.h 中声明这个全局变量。我们将把这个变量放在与我们的其他全局变量相同的 Game Data 类别中,但这次,我们不仅需要允许蓝图读取变量,还需要允许蓝图写入变量,因为我们将在 true 和 false 之间切换变量。我们将添加一个名为 TalkShop 的布尔值作为我们的公共变量之一:

UPROPERTY( EditDefaultsOnly, BlueprintReadWrite, Category = "Game Data" )
bool TalkShop;

当你完成编辑 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:
  UPROPERTY( EditDefaultsOnly, BlueprintReadOnly, Category = "Game Data" )
  TArray<UGameCharacter*> PartyMembers;

  UPROPERTY( EditDefaultsOnly, BlueprintReadWrite, Category = "Game Data" )
  bool TalkShop;

protected:
  bool isInitialized;

public:
  void Init();
  void PrepareReset();
};

编译代码后,进入 NPC_ShopOwner 角色蓝图。在 Components 面板中选择 Box 组件,然后在 Details 面板中向下滚动到 Events。你会注意到,根据如何与盒子交互,这里可以创建许多不同类型的事件:

与 NPC 交互

我们最感兴趣的是 On Component Begin OverlapOn Component End Overlap,因为这些事件将在有东西与盒子相交或不相交时触发。让我们首先处理如果玩家与盒子相交时触发事件的情况。因此,在 Details | Events | On Component Begin Overlap 中选择 +。这将自动打开 Event Graph 并创建一个 OnComponentBeginOverlap 事件:

与 NPC 交互

在这里我们只需要简单地设置之前创建的 TalkShop 布尔值为 true,如果玩家与盒子相交。要做到这一点,首先使用 Utilities | Casting 下的 Cast To FieldPlayerFieldPlayer 强制转换为,并通过将 OnComponentBeginOverlap 中的 OtherActor 插针链接到 Cast To FieldPlayer 节点中的 Object 插针来设置交互组件为 FieldPlayer 对象:

与 NPC 交互

从这里,我们有 FieldPlayer 触发使用位于 Utilities | Casting 下的 Cast To RPGGameInstance 节点的 RPGGameInstance 强制转换,其 Object 插针是 Get Game Instance,因为 TalkShop 变量位于 RPGGameInstance 中:

与 NPC 交互

最后,通过取消选择Context Sensitive,导航到Class | RPGGameInstance,并选择Set Talk Shop来创建一个Set Talk Shop动作:

与 NPC 交互

Cast To RPGGameInstance触发Set Talk Shop动作,并确保我们通过将Cast To RPGGameInstanceAs RPGGameInstance引脚链接到Set Talk ShopTarget引脚来引用位于RPGGameInstance中的TalkShop变量。此外,确保在Set Talk Shop节点中检查Talk Shop布尔值,将TalkShop变量设置为true。完成时,你的 Blueprint 应该看起来像以下截图:

与 NPC 交互

现在我们已经完成了创建Begin Overlap事件,让我们创建玩家/键交互并检查Talk Shop布尔值是否为真或假。由于玩家控制器无法直接访问NPC_ShopOwner,我们需要在 Field Player 类或 Level Blueprint 类中创建键交互。因为 NPC 是特定级别的特定部分,而不同的级别可能包含不同的 NPC,所以将键和布尔检查放在 Level Blueprint 中最为合理。因此,此时导航到Blueprints | Open Level Blueprint以进入 Level Blueprint。

在 Level Blueprint 中,我们将通过导航到Input | Key Events | E创建一个字母E的键事件。然后,在E键释放时(因为我们希望玩家对按键做出承诺),触发Cast To RPGGameInstance,其对象为Get Game Instance,因为在按键时,我们想要检查位于RPGGameInstance中的TalkShop变量的状态:

与 NPC 交互

通过在Cast To RPGGameInstance中拖出As RPGGameInstance引脚来引用TalkShop变量,导航到Variables | Game Data然后选择Get Talk Shop,因为我们将会检查Talk Shop

与 NPC 交互

现在我们正在引用TalkShop变量,我们可以通过创建位于Utilities | Flow Control下的Branch语句来检查Talk Shop的条件:

与 NPC 交互

Talk Shop引脚链接到Branch节点中的Condition引脚以检查TalkShop的条件,并让Cast To RPGGameInstance激活Branch

与 NPC 交互

现在我们已经设置了这个框架,我们可以根据 TalkShop 条件为真或假来做一些事情。现在,我们将通过导航到 实用工具 | 文本 | 打印文本 来运行一个测试,这将创建一个 打印文本 函数。将 分支 节点的 True 插针链接到 打印文本In 插针。完成操作后,你的关卡蓝图应该看起来像以下截图:

与 NPC 交互

如果你现在测试这个功能,你应该会注意到,如果玩家在 NPC 触发体积外按下 E 键,将不会发生任何事情;然而,如果玩家在触发体积内按下 E 键,屏幕上会出现文本。但是,如果我们退出体积并继续按下 E 键,文本将继续出现在屏幕上。这是因为我们从未将 TalkShop 布尔值设置回 false。这样做非常简单。导航回 NPC_ShopOwner 并在 详情 | On Component End Overlap 下的 + 处创建一个 OnComponentEndOverlap 事件:

与 NPC 交互

由于我们在创建 OnComponentBeginOverlap 事件时已经创建了一个对 Talk Shop 的引用,并将其设置为 true,因此我们可以简单地创建一个与 OnComponentBeginOverlap 完全相同功能的 OnComponentEndOverlap 事件;然而,不是将 TalkShop 设置为 true,而是通过确保 Set Talk Shop 节点内的 Talk Shop 插针未被勾选来将 TalkShop 设置为 false。你的 OnComponentEndOverlap 事件现在应该看起来像以下截图:

与 NPC 交互

当你现在测试这个功能时,通过 E 键与 NPC 的交互应该只在没有玩家意图中穿过 NPC 的触发体积时才有效。

对话框设置

我们现在准备好创建 NPC 将对角色说的话的对话。为此,我们将首先创建一个 Widget 蓝图,该蓝图将负责存放所有 NPC 的父变量,例如游戏中的对话,这样我们就可以通过在函数中调用对话变量来随时获取对话。这个过程将比在 UMG 中硬编码文本更好,因为它将允许我们只需要一个动态放置文本的单一对话 UMG。

因此,让我们首先通过导航到 内容浏览器 | 内容 | 蓝图 | UI 并选择 添加新 | 用户界面 | Widget 蓝图 来创建一个新的 Widget 蓝图。然后,将其命名为 NPC_Parent

对话框设置

一旦创建,打开新的 Widget 蓝图,然后导航到图。从这里,转到 我的蓝图 面板,并选择 变量 右侧的 +;这将创建一个新的变量。将此变量命名为 NPCDialog 并通过点击变量名右侧的眼睛将其设置为公共变量:

对话框设置

详细信息面板中,将变量类型更改为文本,因为我们将会使用文本来显示对话框。同时,点击变量类型右侧的方形图标,使该变量成为文本数组:

对话框设置

接下来,在详细信息面板内的默认值选项卡中向下滚动到包含数组元素的区域。默认情况下,它没有任何元素:

对话框设置

详细信息 | 默认值中,点击元素旁边的+来添加一个元素,该元素将在0元素旁边创建一个文本框。在这个元素中输入一些文本作为值。您可以在这里写入任何形式的文本;由于我计划让 NPC 在玩家离开战斗时向玩家提供信息,我将使对话框显示当您在战斗外按 P 键时,您可以检查您的角色状态

对话框设置

由于我们的 NPC 是店主,他们可以像这样问候我们,例如,说问候。我是罗伊,店主,我能为您做什么?您可以将此文本作为NPCDialog数组中的第二个元素添加:

对话框设置

每当我们需要一个新的 NPC 变量,而我们可能不希望它被硬编码时,我们可以回到这个 Widget Blueprint,并像刚才那样添加对话框。接下来,我们可以通过导航回我们的内容浏览器来为我们的 NPC 创建实际的对话框。由于我们最终可能会有许多不同的角色使用相同的对话框,只是里面的文本不同,我们可能想要创建另一个 Widget Blueprint,它只包含一个基本窗口和一个退出对话框的按钮。在内容浏览器中,导航到内容 | 蓝图 | UI,然后选择添加新项 | 用户界面 | Widget Blueprint。然后,将其命名为DialogBox

对话框设置

打开新的 Widget Blueprint。从这里,导航到文件 | 重新父化蓝图,并将其重新父化到NPC_Parent,它包含我们所有的变量:

对话框设置

由于对话框的大小很少会覆盖整个屏幕,我们将在默认的画布面板内创建一个画布面板,通过导航到面板 | 画布面板并拖动新的画布面板到父画布面板内来完成:

对话框设置

将这个新的画布面板重命名为CanvasPanel_DialogBox。此外,将这个画布面板锚定在屏幕中间:

对话框设置

您可能还想调整文本框的大小,以便容纳适量的文本。我将调整这个文本框的Size X值为1024Size Y值为512。您还应该通过将Position X设置为-1024/2,即-512,以及将Position Y设置为-512/2,即-256来居中文本框:

对话框设置

CanvasPanel_DialogBox内,从调色板 | 常用 | 图像中添加一个图像,我们可以用它以类似我们在暂停菜单中做的方式添加背景颜色:

对话框设置

详情面板中,将此图像重命名为BGColor,并调整其位置和大小,使其位于屏幕中央。这可以通过选择中心锚点轻松完成:

对话框设置

调整大小和位置,使其与画布面板相同,即Size X值为1024Size Y值为512Position X值为-512Position Y值为256

对话框设置

最后,在详情 | 外观 | 颜色和透明度下,调整颜色以与其他菜单相同。在这种情况下,我们可以选择颜色选择器并传入线性十六进制值267FFFFF

对话框设置

接下来,让我们插入一个退出按钮,通过从调色板 | 常用 | 按钮中选择并拖动一个按钮到CanvasPanel_DialogBox中,以离开此菜单:

对话框设置

将此按钮重命名为Button_Exit,并通过首先将按钮的大小调整为与暂停菜单按钮的大小相匹配,将按钮定位在画布面板的右侧。这些按钮的Size X值为300Size Y值为100。然后,通过将锚点更改为右下对齐,将按钮定位在画布面板的右下角。然后,使用简单的位置,例如提供 20 像素填充的位置,即Position X-320Position Y-120。你还会注意到按钮位于BGColor之后;只需将ZOrder值更改为1

对话框设置对话框设置

现在你已经创建并定位了一个按钮,向其中添加一个文本块。将文本重命名为TextBlock_Exit,然后在详情 | 外观 | 字体下,将字体大小更改为48。同时,将文本块的内容更改为退出

对话框设置

编程按钮以退出,就像你在之前的菜单创建中做的那样,通过选择按钮,在详情 | 事件中向下滚动到OnClicked,然后点击+按钮。这将打开事件图并为退出按钮填充OnClicked事件。从OnClicked事件中拖出Out引脚,并选择位于小部件下的从父级移除

对话框设置

返回到设计器视图,向CanvasPanel_DialogBox添加一个文本块,命名为TextBlock_Dialog,并使其占据大部分画布面板。为此,我们可以通过将Position X设置为20Position Y设置为20来定位文本块,使其具有 20 像素的填充。我们还可以设置文本块的大小,将Size X设置为986Size Y设置为300。最后,将ZOrder值设置为1

对话框设置

到目前为止,我们已经完成了对话框模板的创建。现在我们可以继续为我们的 NPC 创建对话框。

创建 NPC 欢迎框

现在我们已经有了对话框模板,让我们通过为我们的 NPC 创建基于我们刚刚创建的内容的定制对话框来使用它们。为了保持组织有序,我们应该为 NPC 创建一个单独的文件夹,因为我们很可能会拥有更多的 UMG 和机会在 NPC 创建之外使用我们的对话框。所以,在内容浏览器中,导航到内容 | 蓝图 | UI,并在添加新内容下创建一个新文件夹。将此文件夹命名为NPC,然后进入该文件夹。创建之前章节中制作的DialogBox Widget 蓝图的一个副本,并将其移动到NPC文件夹中。将复制的控件命名为Shop_Welcome

创建 NPC 欢迎框

打开Shop_Welcome控件,并选择TextBlock_Dialog文本块。在详细信息 | 内容中创建一个新的文本绑定,这将打开图表:

创建 NPC 欢迎框

在这个阶段,您可以右键点击以找到此蓝图的所有操作,然后在变量 | 默认下,您应该找到可以使用的Get NPCDialog变量:

创建 NPC 欢迎框

从这里,拖出NPCDialog数组引脚,并在实用工具 | 数组下选择获取函数:

创建 NPC 欢迎框

从这里,您可以通过选择正确的元素来选择NPCDialog中的任何文本。由于欢迎文本在元素 1 中,将获取函数中的0更改为1。为了使此文本返回到文本块,将GET链接到ReturnNode返回值

创建 NPC 欢迎框

由于这是欢迎对话框,我们仍然允许玩家退出,但我们也应该允许他们从 NPC 那里获取一般信息或访问他们的商店。所以,让我们复制退出按钮,并为谈话和购物放置占位符。导航回设计器视图,在退出按钮的左侧再添加两个按钮,一个写着商店,另一个写着谈话。您不必现在就编程这些按钮,因为我们还没有商店或谈话 UMG:

创建 NPC 欢迎框

接下来,通过打开本章开头创建的关卡蓝图,使此屏幕在正确的时间出现。当谈话商店条件为真时,不要将文本打印到屏幕上,而是将用户界面下的创建 Widget链接到True

创建 NPC 欢迎框

对于类,选择Shop_Welcome

创建 NPC 欢迎框

最后,通过将Create Shop_Welcome Widget的返回值引脚链接到位于用户界面 | 视口下的添加到视口,将此内容显示到屏幕上:

创建 NPC 欢迎框

此外,通过在游戏下创建一个获取玩家控制器函数,并将它的返回值链接到位于 | 玩家控制器下的设置显示鼠标光标,给玩家访问鼠标光标的权限。最后,将添加到视口节点链接到设置显示鼠标光标节点,并检查显示鼠标光标。完成之后,你的关卡蓝图应该看起来像以下截图:

创建 NPC 欢迎框

如果现在进行游戏测试,您应该仍然可以走到 NPC 那里并按E键与他交互,但这次会弹出一个对话框:

创建 NPC 欢迎框

恭喜,您已经创建了您的第一个对话框。现在让我们继续制作打开其他交互式小部件的导航按钮。

添加 NPC 对话框

现在您已经创建了一个当玩家与 NPC 交互时弹出的对话框,您可以轻松地为玩家添加当他们在谈话按钮上点击时可以看到的对话。只需复制您之前创建的DialogBox Widget 蓝图,并将其放置在内容浏览器下的NPC文件夹中,该文件夹位于内容 | 蓝图 | UI。将复制的 Widget 蓝图重命名为Shop_Talk

现在,我们将通过打开Shop_Talk Widget 蓝图向此菜单添加一些适当的对话。然后在设计师视图中,选择已经放置到您的画布面板中的文本块。

一旦选择,导航到详情 | 内容,然后在文本中,选择绑定 | + 创建绑定

如往常一样,此操作将自动将您带到图形编辑器,并将获取文本函数设置为从返回节点返回空值。接下来的步骤与您在上一节中从NPCDialog变量调用对话框时所做的步骤相同。您必须导航到我的蓝图选项卡,并使用NPCDialog变量的GET版本。

然后,拖出NPCDialog数组引脚并选择位于Utilities | Array下的Get函数。最后,让GET函数选择NPCDialog数组中的正确元素。在这种情况下,我们会保持元素 0 被选中,因为我们之前在本章中设置了元素 0 的对话。一旦选择了正确的对话,将GET链接到ReturnNodeReturn Value。此时,你的 Graph Editor 应该看起来像以下截图:

添加 NPC 对话框

你现在已经完成了Shop_Talk Widget Blueprint。你需要现在将其绑定到Shop_Welcome Widget Blueprint 中的Talk按钮上,因此打开Shop_Welcome中的Designer视图并选择Talk按钮。在Details面板中,导航到Events并按下OnClicked旁边的+按钮:

添加 NPC 对话框

现在应该创建一个与你的Talk按钮绑定的OnClicked事件,并且事件图现在应该已经自动打开。从这里开始,我们需要做的是在按钮被点击时,我们需要关闭Shop_Welcome Widget Blueprint 并打开Shop_Talk Widget Blueprint。这些步骤应该与你之前多次在按钮点击后打开和关闭 Widget Blueprint 时所做的非常相似。将OnClicked事件链接到位于Widget下的Remove from Parent,这将关闭你的当前 Widget Blueprint。然后,将Create Widget节点链接到位于User Interface下的Remove from Parent。将Create Widget的类更改为Shop_Talk,以便生成你的Shop_Talk Widget Blueprint。从这里开始,将Add to Viewport链接到位于User Interface | Viewport下的Create Shop_Talk Widget节点的Return Value。同时,确保Add to Viewport链接到Create Shop_Talk Widget的输出引脚,这样只有在Shop_Talk widget 创建后,Widget Blueprint 才会在玩家的视图中显示。当你完成时,你的Talk按钮的EventGraph应该看起来像以下截图:

添加 NPC 对话框

你可能已经注意到,Talk按钮现在工作得非常完美,但文本会被截断:

添加 NPC 对话框

这是因为我们没有包裹文本。要包裹文本,请回到Shop_Talk Widget Blueprint,并在Designer视图中选择Dialog文本块。然后,在Details面板中,导航到Appearance并勾选Auto Text Wrap。这将确保文本始终围绕内容包裹,在这种情况下,当文本达到文本块的边界时,文本将移动到新的一行。如果你测试Talk按钮,单词应该现在这样包裹:

添加 NPC 对话框

到目前为止,你应该已经实现了玩家与 NPC 之间的交互,所有按钮都工作正常,除了商店按钮。

摘要

在本章中,我们创建了一个可以通过触发体积和按键绑定与玩家进行通信的非玩家角色(NPC)。现在,我们可以在游戏的任何时刻从字符串数组中显示对话。在下一章中,我们将我们的 NPC 转换成店主,并允许玩家从商店购买装备。

第七章. 金币、物品和商店

现在你已经创建了一个与玩家交谈的 NPC,是时候允许 NPC 帮助玩家了。在本章中,我们将使用 NPC 作为店主,向用户展示可以购买的物品。在我们这样做之前,用户将需要某种货币来购买物品。本章我们将涵盖以下主题:

  • 设置和获取金币实例

  • 物品数据

  • 商店屏幕框架

  • 物品按钮框架

  • 链接物品数据

设置和获取金币实例

当我们继续制作购物界面,通过Shop按钮时,我们首先必须能够提取货币,以便在商店中购买物品。在之前的一章中,我们讨论并创建了金币的占位符,但实际上我们没有创建金币值。在这个游戏中,我们希望金币在战斗结束后由敌人掉落。在这种情况下,敌人需要某种金币数据,我们可以将其添加到玩家的金币数据中(最终,物品也需要这种与它们关联的金币数据)。在第四章,暂停菜单框架中,我们创建了一个具有金币占位符的暂停菜单,现在我们将向这个暂停菜单添加金币。

首先,让我们在FEnemyInfo.h中添加一个Gold属性。导航到Source | RPG | Data,打开FEnemyInfo.h,并在你的EnemyInfo表中添加一个整型数据类型的Gold属性,如下所示:

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

我们现在需要将Gold属性与我们的标准GameCharacter属性关联起来,以便我们可以更新任何敌人的实例,使其具有正确的金币值。接下来,你将打开位于Source下的RPG文件夹中的GameCharacter.h文件,并在UCharacter类中添加一个与FEnemyInfo.h中类似的公共UProperty用于金币:

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

然后,进入GameCharacter.cpp来设置金币的返回值,使其等于在EnemyInfo中设置的值,这样这个特定敌人的每个实例都将返回敌人数据表中设置的金币数量:

character->Gold = enemyInfo->Gold;

当你完成时,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;
 character->Gold = enemyInfo->Gold;

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

我们现在需要选择何时累积金币,在这种情况下,我们将从战斗中累积金币。因此,导航到Source | RPG | Combat,打开CombatEngine.h,创建一个公共的金币变量,我们将用它来存储在战斗中赢得的所有金币:

int32 GoldTotal;

当你完成声明GoldTotal变量后,CombatEngine.h文件将看起来像这样:

#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;
 int32 GoldTotal;

protected:
 UGameCharacter* currentTickTarget;
 int tickTargetIndex;
 bool waitingForCharacter;

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

 bool Tick(float DeltaSeconds);

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

我们接下来需要执行的操作是告诉引擎何时给玩家金币。如前所述,我们希望玩家能够从可以轻松集成到我们的战斗引擎中的敌人那里赢得金币。导航到 | RPG | 战斗,打开CombatEngine.cpp文件。让我们首先滚动到我们在第三章中创建的for循环,检查胜利。在这个for循环上方,声明一个新的Gold整数,并将其设置为0

int32 Gold = 0;

这将确保,如果我们没有胜利并需要再次循环for循环,战斗中获得的金币将重置为 0。接下来,我们需要累积每个被击败的敌人的金币;因此,在for循环中,我们将Gold增加每个敌人的金币:

Gold += this->enemyParty[i]->Gold;

你的for循环现在将看起来像这样:

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

for循环之后,你仍然会有一个if条件来检查敌人党派是否已死亡;如果敌人党派已死亡,战斗阶段将变为胜利阶段。如果条件为true,这意味着我们赢得了战斗;因此,我们应该从for循环中获得金币奖励。由于我们想要添加的Gold变量在GoldTotal变量中,我们只需将局部Gold变量设置为GoldTotal的新值:

GoldTotal = Gold;

当你完成时,你的if条件现在将看起来像这样:

if (deadCount == this->enemyParty.Num())
 {
  this->SetPhase(CombatPhase::CPHASE_Victory);
  GoldTotal = Gold;
  return false;
 }

现在我们已经设置敌人在玩家在战斗中获胜后掉落金币,接下来我们需要做的是将金币添加到我们的游戏数据中;更具体地说,最好在RPGGameInstance.h中添加它,因为游戏实例始终处于活动状态。将金币数据添加到党派成员中是不明智的,除非有一个特定的党派成员将始终在游戏中。因此,让我们打开位于下的RPG目录中的RPGGameInstance.h文件。

作为公共属性,向游戏数据中添加另一个整数,我们将其称为游戏金币。同时,确保游戏金币是可读和可写的,因为我们希望能够添加和减去金币;因此,必须启用游戏金币的编辑:

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Game Data")
    int32 GameGold;

现在我们可以创建GameGold的实例,前往你原来设置游戏结束和胜利条件的RPGGameMode.cpp文件;在胜利条件中,创建一个指向URPGGameInstance的指针,我们将其称为gameInstance,并将其设置为GetGameInstance的转换:

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

我们现在可以使用gameInstance将我们从战斗中获得的总金币添加到GameGold

gameInstance->GameGold += this->currentCombatInstance->GoldTotal;

到目前为止,我们用作玩家金币的GameGold值现在将增加战斗中赢得的金币。RPGGameMode.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" ) );

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

        UUserWidget* GameOverUIInstance = CreateWidget<UUserWidget>( GetGameInstance(), this->GameOverUIClass );
        GameOverUIInstance->AddToViewport();
      }
      else if( this->currentCombatInstance->phase == CombatPhase::CPHASE_Victory )
      {
        UE_LOG( LogTemp, Log, TEXT( "Player wins combat" ) );
        //add gold to total gold
        URPGGameInstance* gameInstance = Cast<URPGGameInstance>(GetGameInstance());
        gameInstance->GameGold += this->currentCombatInstance->GoldTotal;

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

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

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

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

现在,你需要确保所有更改都已保存,并重新编译整个项目(你可能需要重新启动 UE4)。

我们现在可以调整每个敌人角色的金币值,这些值来自敌人的数据表。在内容浏览器中,导航到位于内容下的敌人数据表,在数据下。在数据表中,你现在将看到一个金币行。将任何你想要的值添加到金币行,并保存数据表:

设置和获取金币实例

现在既然敌人有了金币值,就有了一个与EnemyInfo中的Gold变量绑定的实际值,如果玩家在战斗中获胜,这个值就会被添加到GameGold中。然而,我们需要显示这些金币;幸运的是,我们暂停菜单中仍然有一个金币占位符。打开Pause_Main Widget Blueprint,点击我们在第四章中创建的Editable_Gold文本块,暂停菜单框架。在详细信息面板中,转到内容并创建一个文本块的绑定,这将打开获取可编辑金币文本的图表:

设置和获取金币实例

我们需要做的第一件事是通过在游戏下创建一个获取游戏实例函数来获取RPGGameInstance的游戏实例,并将其设置为Cast To RPGGameInstance的对象:

设置和获取金币实例

然后,我们可以从RPGGameInstance获取GameGold变量,这是存储游戏当前金币总数的变量。它位于游戏数据下的变量中。将其链接到Cast To RPGGameInstance中的As RPGGameInstance引脚:

设置和获取金币实例

最后,将游戏金币链接到ReturnNode中的返回值,并允许获取可编辑金币文本触发Cast To RPGGameInstance,这将触发ReturnNode。你的获取可编辑金币文本绑定现在将看起来像这样:

设置和获取金币实例

如果你现在测试,你将能够进入战斗,在胜利后从敌人那里赢得金币,现在你将能够在暂停菜单中看到你的金币积累。我们可以使用这些相同的变量来添加到任何菜单系统,包括商店。

物品数据

现在我们完成了金币的创建,在创建商店之前,我们还需要创建另一件事,那就是物品。制作物品有很多方法,但最好通过使用数据表来保持物品的库存和统计数据。因此,让我们首先创建一个新的 C++ FTableRowBase结构体,类似于你之前创建的CharacterInfo结构体。我们的文件将命名为ItemsData.hItemsData.cpp,我们将把这些文件放在我们的其他数据旁边;即通过导航到 | RPG | 数据ItemsData.cpp源文件将包含以下两个头文件:

#include "RPG.h"
#include "ItemsData.h"

ItemsData.h头文件将包含所有所需物品数据的定义。在这种情况下,物品数据将是玩家拥有的统计数据,因为物品很可能会影响统计数据。统计数据只需要是整数类型并且可读,因为我们不会直接更改任何物品的值。你的ItemsData.h文件看起来可能像这样:

#pragma once

#include "GameFramework/Actor.h"
#include "ItemsData.generated.h"

/**
 *
 */

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

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "ItemData" )
    int32 HP;

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "ItemData" )
    int32 MP;

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

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

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

  UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "ItemData" )
    int32 Gold;
};

到目前为止,你可以重新编译,现在你就可以创建你自己的数据表了。由于我们正在创建商店,让我们在内容浏览器数据文件夹中创建一个商店的数据表,方法是导航到杂项 | 数据表,然后使用物品数据作为结构。

物品数据

将你的新数据表命名为Items_Shop,然后打开数据表。在这里,你可以使用行编辑器选项卡添加尽可能多的物品,并使用你想要的任何类型的统计数据。要创建一个物品,首先点击行编辑器中的添加按钮以添加一个新行。然后,点击重命名旁边的文本框并输入药水。你会看到你有一个药水物品,其他统计数据都为零:

物品数据

接下来,给它一些值。我将把它变成一个治疗药水;因此,我将给它一个HP值为50和一个Gold值为10

物品数据

此数据表的目的也是为了存储我们的店主将携带的每一件物品。所以,请随意向此数据表添加更多物品:

物品数据

商店屏幕框架

现在我们已经完成了物品的创建,我们可以继续创建商店。在上一章中,我们为我们的店主创建了对话框,并在其中一个对话框中创建了一个商店按钮,点击该按钮将打开商店菜单。让我们通过首先创建一个新的 Widget Blueprint 来创建这个商店菜单,方法是导航到内容 | 蓝图 | UI | NPC。我们将把这个 Widget Blueprint 命名为商店并打开它:

商店屏幕框架

我们将使商店的格式与我们的暂停菜单类似,但我们会保持简单,因为我们现在只需要一个滚动框来存放商店的物品,以及一个存放金币的区域和一个退出按钮。

为了加快这个进程,你可以简单地复制并粘贴你希望重新使用的现有菜单系统中的元素到商店Widget Blueprint 中。我们可以通过导航到内容 | 蓝图 | UI并打开我们在上一章中创建的Pause_MainPause_InventoryWidget Blueprints 来实现这一点。从Pause_Main,我们可以复制Menu_GoldEditable_GoldButton_ExitMenu_ExitBG_Color,并将它们粘贴到商店Widget Blueprint 中。

我们还可以从Pause_Inventory小部件蓝图复制ScrollBox_InventoryTitle_Inventory并将其粘贴到商店小部件蓝图中。完成时,你的商店小部件蓝图将如下所示:

商店屏幕框架

在这里,编辑商店小部件蓝图,使其标题显示为商店而不是库存

商店屏幕框架

现在,你需要将商店小部件蓝图链接到Shop_Welcome小部件蓝图中的商店按钮。为此,通过导航到内容 | 蓝图 | UI | NPC打开Shop_Welcome小部件蓝图,选择Button_Shop,然后通过导航到细节 | 事件点击OnClicked事件右侧的+按钮:

商店屏幕框架

这将自动打开一个带有为Button_Shop创建的新OnClicked事件的图表:

商店屏幕框架

在这里,你可以简单地模仿你在玩家点击交谈按钮时使用的相同操作。唯一的区别是,你不需要创建一个新的Shop_Talk小部件,而是商店小部件会为你创建创建商店小部件Button_Shop的图表将如下截图所示:

商店屏幕框架

现在,你可以通过与 NPC 交谈并点击商店按钮来测试商店,这将打开商店:

商店屏幕框架

你会注意到商店中还没有任何东西可见,甚至没有金币。要在屏幕上显示金币,你需要重复本章前面在Pause_Main小部件蓝图显示金币时执行的步骤。但这次,打开商店小部件蓝图中的图表,然后通过导航到细节 | 上下文Editable_Gold文本块创建一个绑定:

商店屏幕框架

你的图表将自动打开,你会注意到一个带有ReturnNode获取可编辑金币文本函数。由于你将从与从Pause_Main小部件蓝图获取金币相同的游戏实例中获取金币,你可以简单地从获取可编辑金币文本函数中复制并粘贴所有节点到Pause_Main,并将它们链接到商店小部件蓝图中的获取可编辑文本函数。完成时,商店小部件蓝图中的获取可编辑金币文本函数将如下所示:

商店屏幕框架

接下来,我们将在商店小部件蓝图中创建Button_Exit功能,通过为Button_Exit创建一个OnClicked事件(通过导航到细节 | 事件):

商店屏幕框架

当图打开时,将OnClicked事件链接到从父级移除函数:

商店屏幕框架

到目前为止,当你测试商店时,你会看到金币并能够退出商店屏幕。

项目按钮框架

在我们将项目链接到商店之前,我们首先需要创建一个框架,其中项目被放置在商店中。我们希望为商店老板出售的每个项目创建一个按钮,但为了使界面可扩展,以便 NPC 可以持有不同的可选择物品,创建一个包含单个按钮的滚动框框架,该按钮具有项目文本/描述的默认值,将是明智的。然后我们可以动态地绘制按钮,以及商店老板携带的物品数量,以及每个按钮上的文本。

要做到这一点,我们首先需要通过导航到内容 | 蓝图 | UI来创建一个 Widget 蓝图,并将其命名为Item

项目按钮框架

打开Item。由于我们将使项目可点击,我们将编写一个按钮。为了创建按钮,我们只需要按钮本身和按钮上的文本;我们不需要 Canvas 面板,因为我们最终会将此按钮添加到商店的滚动框中。因此,从层次结构选项卡中删除 Canvas 面板,并从调色板中拖动一个按钮。我们将把这个按钮命名为Button_Item

项目按钮框架

最后,我们在刚刚创建的按钮中放置一个文本块,并将其命名为TextBlock_Item

项目按钮框架

完成后,导航到详细信息 | 内容,并为文本块中的文本创建一个绑定。这将自动打开带有获取文本函数的图:

项目按钮框架

创建一个新的Item变量,类型为文本

项目按钮框架

Item变量拖入图中,选择获取以在Item变量中放置一个获取器,并将其链接到ReturnNode返回值引脚:

项目按钮框架

链接项目数据

现在是时候将我们在本章开头创建的项目数据通过我们刚刚创建的Item按钮框架链接到商店了。为此,我们将添加一个功能,使用我们在上一节中创建的Item按钮框架显示我们Items_Shop数据表中的每个项目。首先,在Shop Widget 蓝图中打开事件图。将位于数据表中的获取数据表行名称函数链接到事件构造

链接项目数据

然后,从选择资产下拉菜单中选择Items_Shop

链接项目数据

这将获取我们在本章早期创建的Items_Shop数据表中每个项目的名称。在这里,我们需要为每个项目行创建一个Item小部件蓝图实例。这将为每个项目创建一个带有正确对应项目名称的按钮。为此,创建位于Utilities下的Array中的ForEachLoop,并允许获取数据表行名称函数执行它。将Out Row Names引脚链接到ForEachLoopArray引脚,以便数据表中的每一行都成为ForEachLoop中数组的元素:

链接项目数据

接下来,我们需要遍历行名称数组中的每个元素,并为每个行创建一个新的Item小部件蓝图实例。为此,将位于用户界面下的Create Item Widget操作链接到ForEachLoopLoop Body引脚。让类实例为Item,可以在Create Item Widget操作的下拉菜单中选择:

链接项目数据

然后,对于创建的每个项目,将每个Item小部件实例创建的Item变量设置为数组中每个元素的价值。您可以通过在事件图的任何位置右键单击,取消选中上下文相关,并通过导航到|Item来创建设置项目操作:

链接项目数据

创建项目小部件现在可以启动设置项目,并将创建项目小部件返回值引脚值设置为项目目标引脚值:

链接项目数据

在这一点上,我们还没有将数组中的元素设置为在Item小部件中设置的项目;因此,为了做到这一点,我们可以简单地从ForEachLoopArray Element引脚链接到Set Item操作中的Item引脚:

链接项目数据

最后,我们将把我们在商店小部件蓝图创建的滚动框用于存放所有的项目实例。为此,在将每个项目设置为正确的名称后,我们将把项目实例作为子项添加到我们在本章早期创建的ScrollBox_Inventory滚动框中。这可以通过在设置项目后,在Widget下的Panel中调用Add Child函数来完成:

链接项目数据

然后,我们将子项的内容值设置为项目的返回值引脚:

链接项目数据

最后,子项的目标引脚需要链接到ScrollBox_Inventory,这可以从变量拖入您的事件图。如果您在变量中看不到ScrollBox_Inventory变量,请返回到设计视图,选择ScrollBox_Inventory,并确保是变量被选中:

链接项目数据

到目前为止,如果你测试你的商店,你将看到商店中填充了你数据表中列出的每一个物品:

链接物品数据

你将能够向你的数据表中添加更多物品,这些物品将自动出现在你的商店中。

摘要

在本章中,我们为游戏创建了一个货币系统,并使我们的敌人能够掉落金币。我们还创建了一套新的数据集,包含物品及其属性,并且现在我们已经填充了店主商店,以显示商店中目前出售的物品。

在下一章中,我们将为商店添加购买功能,以及物品的使用和消耗。

第八章。库存填充和物品使用

在上一章中,我们学习了如何添加一个商店,该商店持有物品。在本章中,我们将更进一步,允许用户从商店购买物品,并在他们的动态填充的库存屏幕中使用这些购买的物品。完成后,我们将使用类似的想法为将用于提高穿戴者统计数据的小组成员装备物品。

到本章结束时,我们将学习如何在ShopWidget 蓝图中的ShopWidget 蓝图创建逻辑,以填充库存滚动框中的按钮,这些按钮是通过ItemWidget 蓝图中的Item数据表创建的。现在我们已经设置了逻辑,我们需要允许用户通过能够购买他们在商店中点击的任何物品来与按钮交互,只要他们有足够的钱。由于发行者与动态填充的按钮交互,当用户按下按钮时执行我们的逻辑非常重要,该按钮位于ItemWidget 蓝图中。

如果你之前在你的事件图中创建了其他蓝图,你可以忽略它们,因为交互将允许我们使用不同的方法重新开始。

首先,我们必须注意,Item蓝图将包含在任何按钮从该蓝图被点击时应该发生的逻辑。因此,目前我们计划让按钮填充商店,但在玩家的库存中,逻辑需要根据我们在哪个屏幕上而有所不同。这意味着我们首先需要找出玩家在哪个屏幕上,然后根据他们所在的屏幕执行一系列动作。这可以通过OnClicked事件中的布尔值轻松完成,该事件将检查玩家在哪个菜单中,并根据当前菜单分支不同的逻辑。

由于我们关注的是Pause_Inventory屏幕上的按钮与Shop屏幕上的按钮行为之间的差异,我们必须首先创建一个布尔值,该布尔值将在角色的整个生命周期中保持活跃。在这种情况下,我们将使用 Field Player 来保存我们的重要物品变量。

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

  • 创建 Field Player 布尔值

  • 确定库存屏幕是开启还是关闭

  • 库存和商店物品之间的逻辑差异

  • 完成库存屏幕

  • 使用物品

创建 FieldPlayer 布尔值

通过导航到内容 | 蓝图 | 角色,转到 Field Player。打开 Field Player 并导航到事件图

创建 FieldPlayer 布尔值

在这里,我们通过导航到+添加新 | 变量蓝图选项卡下创建一个新的变量。接下来,我们创建一个新的inventoryScreen布尔值。然后,我们需要使变量公开。这个布尔值将负责根据玩家是否在库存屏幕上保持 true 或 false 值。我们可能需要在图中使用更多这样的变量,但现在我们只使用这个变量:

创建 FieldPlayer 布尔值

完成创建inventoryScreen变量后,编译蓝图。

确定库存屏幕是否开启或关闭

现在,我们将设置inventoryScreen变量到合适的位置。最好的地方是在库存菜单弹出时。因此,通过导航到内容 | 蓝图 | UI,前往Pause_Inventory。在Pause_Inventory中,在事件图中定位事件构造(如果尚未存在,则创建一个),然后从这里,通过创建获取所有类别的演员来获取 Field Player 类中的每个演员,该功能位于动作菜单下的实用工具中:

确定库存屏幕是否开启或关闭

获取所有类别的演员函数的演员类别下,将演员更改为Field Player

确定库存屏幕是否开启或关闭

输出演员引脚,在获取所有类别的演员函数中,你需要附加一个GET函数。这将获取你的 Field Player 类中所有演员的数组,并允许访问类中的单个成员:

确定库存屏幕是否开启或关闭

最后,打开所有可能的行为并取消上下文相关的勾选。通过导航到 | Field Player,前往设置库存屏幕

确定库存屏幕是否开启或关闭

完成后,将你的设置库存屏幕目标引脚连接到GET的右侧引脚。同时,确保库存屏幕被勾选,这意味着我们在这里将库存屏幕设置为 true。在这个时候,你还可以将事件构造链接到触发获取所有类别的演员,这将激活设置库存屏幕

确定库存屏幕是否开启或关闭

我们还需要确保当玩家离开库存屏幕时布尔值设置为 false,因此克隆另一个设置库存屏幕布尔值,并将其设置为 false。将目标引脚重新链接到获取所有类别的演员GET,并在库存窗口关闭时激活它:

确定库存屏幕是否开启或关闭

我们将在稍后回到Pause_Inventory以添加按钮填充逻辑,类似于前一章中的商店。然而,现在我们已经设置了布尔值,我们将能够判断玩家是查看库存还是正在导航商店(如果布尔值为假)。

库存和商店物品之间的逻辑差异

现在,让我们通过导航到内容 | 蓝图 | UI来打开Item小部件蓝图:

库存和商店物品之间的逻辑差异

在这个阶段,我们不应该为按钮设置任何逻辑,这是必要的,因为它会告诉我们按钮与游戏结合时将执行哪些操作。要为按钮添加功能,请点击按钮,导航到详情 | 事件 | OnClicked,然后点击+

库存和商店物品之间的逻辑差异

在这里,我们需要做几件事情。首先,我们知道这个蓝图将负责所有与商店和角色库存相关的按钮机制,由于角色从商店购买物品并使用库存中的物品,所以机制将不同。由于这些不同的游戏屏幕提供不同的操作,因此首先检查用户是在商店还是在他们的库存中会是一个明智的选择。为此,我们应该首先引入获取所有类演员函数,并获取来自Field Player类的所有演员。然后,我们需要将输出演员引脚连接到GET。最后,让OnClicked事件触发获取所有类演员

库存和商店物品之间的逻辑差异

在这个阶段,我们可以打开我们的动作窗口,并通过导航到 | Field Player来访问获取库存屏幕。您需要取消选中上下文相关才能看到此选项:

库存和商店物品之间的逻辑差异

然后,您将把库存屏幕节点的目标引脚连接到蓝色的GET引脚。这将使我们能够从Field Player类访问库存屏幕布尔值:

库存和商店物品之间的逻辑差异

现在是时候创建一个分支系统,根据玩家是在购物还是在其库存中执行逻辑。我们将使用我们的库存屏幕布尔值来完成这个任务。让我们首先通过在动作菜单中导航到实用工具 | 流程控制来引入一个分支:

库存和商店物品之间的逻辑差异

在这里,我们将分支的条件链接到库存屏幕条件。然后,让获取所有类的动作函数激活分支。此时,当玩家点击按钮时,我们将检查是否库存屏幕为真(或者玩家是否在库存屏幕上)。如果他们不在库存屏幕上,那么这意味着玩家在其他屏幕上;在我们的例子中,是商店:

库存和商店物品之间的逻辑差异

在我们继续处理物品按钮的其他逻辑之前,我们需要考虑我们的逻辑流程。如果用户在商店,并且用户点击要购买的项目,那么如果那个人有足够的钱购买该项目,该项目应该被放入某种收集或数组中,以便填充用户的库存屏幕。由于这个机制,我们需要寻找某种全局数组,它将能够容纳玩家购买的项目数组。为此,转到FieldPlayer事件图,并添加一个名为arrayItem的新文本数组。同时,确保这个变量设置为公共且可编辑:

库存和商店物品之间的逻辑差异

完成库存屏幕

导航到Pause_Inventory事件图。当上下文敏感关闭时,通过导航到|Field Player动作窗口中引入获取数组项

完成库存屏幕

完成后,将数组项目标引脚连接到GET,这样我们就可以在物品蓝图中将数组填充后获取发送到该数组的每个项目:

完成库存屏幕

现在我们有了玩家库存中的项目数组,我们将遍历每个元素,并从数组中的每个元素创建一个项目。为此,通过导航到实用工具|数组创建一个ForEachLoop。然后,将您的arrayItem变量中的数组项链接到ForEachLoop数组标签。然后,让设置库存屏幕激活ForEachLoop

完成库存屏幕

就像我们在填充商店按钮时所做的,我们希望这个for循环负责添加来自物品小部件蓝图中的按钮。因此,在for循环体中,我们需要通过首先在动作窗口中导航到用户界面|创建小部件来创建Item小部件:

完成库存屏幕

然后,我们需要将下拉菜单更改为Item,并将其链接到ForEachLoop中的循环体

完成库存屏幕

然后,您需要为数组中的每个元素设置文本。因此,打开动作窗口,关闭上下文敏感,通过导航到|Item来引入设置项目

Item引脚链接到ForEachLoopArray Element引脚。然后,将Set ItemTarget引脚设置为Create Item WidgetReturn Value,并让Create Item Widget激活Set Item

完成库存屏幕截图

最后,我们需要将Item小部件添加到我们在Pause_Inventory中创建的滚动框中。只需创建一个位于WidgetPanels中的Add Child节点。然后,将你的变量中的ScrollBox_Inventory链接到Add ChildTarget引脚(如果你看不到ScrollBox_Inventory作为默认变量,请确保你回到Pause_Inventory的设计视图,选择ScrollBox_Inventory,并检查is variable,然后让Add ChildContent引脚成为Create Item WidgetReturn Value)。最后,让Set Item节点启动Add Child节点:

完成库存屏幕截图

当你完成时,你的Pause_Inventory蓝图将看起来像这样:

完成库存屏幕截图

购买物品

回到Item蓝图。在我们之前停止的地方,我们允许在点击按钮时获取来自Field Player类的所有演员。在这里,我们设置了一个分支,检查Inventory Screen布尔值是真是假(这意味着我们检查玩家是否在库存屏幕上;如果他们不在库存屏幕上,我们将在我们的商店中执行购买逻辑)。

让我们先从Actions窗口中的Utilities下引入一个Get Data Table Row函数:

购买物品截图

然后,将数据表设置为Items_Shop。这将使我们能够从Items_Shop数据表中获取每一行。然后,将我们创建的分支中的False引脚链接到Get Data Table Row的执行:

购买物品截图

你可能已经注意到,我们可以从数据表中选择任何行名。在这种情况下,我们只需要获取当前选中物品的行名。为此,将上一章在本课程中创建的Item文本变量的Get引入,并将其链接到Get Data Table Row函数中的Row Name,但这些引脚不兼容。因此,你需要首先通过左键单击并拖动它从Item节点,然后导航到Utilities | String | To String (Text)将文本项转换为字符串。这将创建你需要的第一个转换:

购买物品截图

最后,你只需将这个转换后的字符串链接到Get Data Table Row函数中的Row Name引脚:

购买物品截图

完成后,我们已经完成了在商店中选择特定物品的逻辑。现在,我们需要计算每个物品的价值的金币数量,并从我们的总金币中减去。为此,我们必须首先获取游戏实例,以便我们可以调用游戏金币。然而,由于我们将在本蓝图中的许多其他变量中需要此实例,我们可能希望将游戏实例称为构造函数的一部分。如果你还没有这样做,创建一个事件构造。接下来,将位于实用工具下的转换中的Cast To RPGGameInstance对象链接。然后,将位于游戏下的动作窗口中的获取游戏实例对象链接到Cast To RPGGameInstance对象:

购买物品

由于我们最终需要访问角色参数,例如 HP 和 MP,在将物品应用到玩家时,我们需要获取所有队伍成员,并设置一个类似于前几章中做的角色目标。为此,创建一个新的变量:

购买物品

然后,转到详细信息 | 变量,调用角色目标变量,将其类型更改为游戏角色,这将引用我们队伍中的游戏角色:

购买物品

然后,从As RPGGame Instance引脚拖出一条线,并通过导航到变量 | 游戏数据选择Get Party Members变量:

购买物品

将一个GET函数链接到队伍成员数组。你需要将GET链接到角色目标。所以,引入你创建的新角色目标的SET版本,并将GET函数链接到SET 角色目标中的角色目标引脚。最后,让转换为 RPGGameInstance执行SET 角色目标。当你完成设置游戏实例和游戏角色的引用后,你的构造函数将看起来像这样:

购买物品

现在我们已经为当前游戏实例设置了引用,我们可以操作金币。接下来你需要做的是导航到你的获取数据表行函数。在这里,左键单击并拖动函数内的输出行引脚,这将给你一些有限的选择;其中之一是创建Break ItemsData。这将允许你访问每个物品的所有数据。一旦完成,你将看到一个显示我们在Items_Shop数据表中创建的所有数据的框:

购买物品

逻辑非常简单。基本上,如果用户有足够的钱,允许他们购买物品,并通过他们的游戏金币减去物品的成本。如果他们没有足够的钱,则不允许他们购买物品。

要这样做,我们将创建一个获取游戏金币引用。这可以通过导航到 | RPGGame Instance来实现,如果未勾选上下文相关

购买物品

一旦创建,将引用链接到投射到 RPG 游戏实例中的As RPGGame Instance。您还可能注意到在下图中有一个将HP设置为5SET引脚;您可以添加一个或让它保持原样。这将仅表示玩家开始时有 5 点 HP;这是在测试玩家消耗药水时进行的测试目的;如果您决定为了测试目的使用Set HP,请记住在完成游戏测试后将其删除:

购买物品

现在,我们将从购买物品的成本中减去游戏金币。因此,只需创建一个从整数减去整数的数学函数。您可以通过导航到数学 | 整数来找到此数学函数:

购买物品

为了正确进行数学运算,我们需要将游戏金币链接到减法函数的顶部引脚,并将ItemsData中的金币链接到底部引脚。这将从物品的成本中减去我们的游戏金币:

购买物品

在这里,我们需要检查玩家是否有足够的钱购买物品。因此,我们将检查最终产品是否小于 0。如果是,则不允许玩家进行购买。为此检查,只需使用另一个名为整数 < 整数的数学函数,位于数学下的整数。然后,将减法最终产品与 0 进行比较,如下所示:

购买物品

接下来,通过导航到实用工具 | 流程控制创建一个分支,并将条件链接到您刚刚创建的整数 < 整数函数的条件。然后,将获取数据表行行找到引脚链接到执行分支,以便如果找到行,则可以进行数学运算:

购买物品

如果最终结果不小于 0,则需要将游戏金币设置为减法结果。为此,在动作窗口中,通过导航到 | RPG 游戏实例并关闭上下文相关,引入SET 游戏金币函数:

购买物品

游戏金目标引脚连接到作为 RPG 游戏实例的引脚,从投射到 RPG 游戏实例功能开始。然后,将游戏金币引脚连接到减法操作的最终产品,以获取剩余的游戏金币:

购买物品

最后,我们需要正确地链接所有内容。剩余的链接来自分支;如果小于条件返回 false,则表示我们有足够的钱购买产品,并且可以更改游戏金币。因此,接下来,将分支的False引脚链接到执行SET 游戏金币

购买物品

如果你现在测试这个程序,你会注意到物品可以从商店无缝购买。然而,问题是物品从未从商店填充到玩家的库存中。这是一个简单的修复。在本章的早期,我们已经设置了我们的库存屏幕,使其能够获取一个可以存储在玩家字段中的数组。我们将简单地使用这个数组来添加我们购买的物品到数组中,然后,在我们打开我们的库存时检索这些物品:

购买物品

由于我们已经有了一种从玩家字段收集变量的方法,我们将通过导航到|玩家字段来引入获取数组项变量。

我们将链接数组项目标引脚到获取所有类别的演员函数的GET,以便我们能够完全访问arrayItem变量。然后,我们将在动作窗口中导航到实用工具|数组来引入一个添加函数:

购买物品

添加函数将允许你在动态增加其大小(例如列表)的同时向数组中添加元素。要使用此功能,你需要链接你想要填充的数组;在这种情况下,数组项。然后,你需要链接你想要添加到数组中的项目;在这种情况下,项目。最后,你需要执行添加操作。我们将在设置黄金值之后执行它。本质上,在玩家购买物品后,该物品将被添加到他们的库存中:

购买物品

你的购买机制现在已经完成,你现在可以测试你的商店了。你会注意到物品可以被购买,并且这些购买的物品填充了你的库存。

使用物品

现在你已经允许物品填充库存,现在是时候让这些物品发挥作用了。此时,你应该仍然在你的物品的onClicked按钮的起始位置有一个分支。到目前为止,你的分支只是通过一个错误的程序,因为这个程序表示如果玩家在商店中,他们正在与按钮交互。现在是时候为当库存屏幕布尔值为真时创建一个程序,这意味着玩家正在库存屏幕上。

在我们创建获取数据表行函数并将其设置为商店项目数据表(该数据表接受项目行名称并将项目分解为项目数据)之间的初始步骤与我们的先前步骤相同。因此,我们可以简单地从我们的先前步骤中复制并粘贴这些部分到这个蓝图中的空白区域:

使用物品

接下来,我们将从我们的初始分支(由获取所有类别的演员函数激活)链接True引脚以执行获取数据表行函数:

使用物品

我们将实现与购买物品时实现的逻辑非常相似的逻辑;但这次,我们想确保用户在使用物品时得到正确的数量设置。让我们首先从药水开始。药水只使用 HP 数据。因此,我们需要做的是将药水的 HP 数据添加到角色的当前 HP 中。为此,我们首先需要一个角色目标变量。所以,从你的变量列表中引入一个获取角色目标函数:

使用物品

完成此操作后,将角色目标变量连接到获取 HP

使用物品

现在您已经可以访问当前玩家的 HP,可以通过导航到数学 | 整数引入整数 + 整数函数。只需将Break ItemsData节点中的HP引脚连接到整数 + 整数函数的顶部引脚,并将角色 HP 连接到整数 + 整数节点的底部引脚:

使用物品

在这里,我们需要检查加法的结果是否小于角色的最大 HP。如果是,我们可以使用药水。如果不是,我们不能使用药水。所以,让我们首先从角色目标中引入Get MHP变量,它显示了角色的最大 HP 是什么样的:

使用物品

现在,我们需要引入一个检查一个整数是否小于另一个整数的条件。这可以在动作窗口中通过导航到数学 | 整数找到:

使用物品

接下来,将最终加法结果连接到整数 < 整数条件的上部引脚,并将MHP连接到下部引脚:

使用物品

我们现在将创建一个检查我们条件的分支。这个分支应该只在找到行(或用户点击实际物品)时激活:

使用物品

如果总 HP 小于最大 HP,那么这意味着条件为真,我们需要使用位于小部件下的从父级移除函数从库存中移除物品。然后,我们需要使用SET HP函数,通过导航到 | 游戏角色并使其等于产品物品 HP 和角色 HP 的总和。我们还需要将SET HP函数的目标引脚连接到角色目标的引用:

使用物品

如果你现在测试这个,角色将能够使用药水,并且使用后药水会被移除,但用户无法完全恢复生命值,因为我们只是测试我们的添加是否超过了最大生命值,这仅适用于药水的治疗属性未完全使用的情况。因此,角色可能永远无法恢复到 100%。为了解决这个问题,我们将简单地创建一个用于False分支的例程,该例程将从父项中移除物品,然后自动将生命值设置为最大生命值。这将解决我们无法将角色恢复到最大健康值的问题:

使用物品

当你完成这个任务后,你的基于 HP 的物品蓝图将看起来像这样:

使用物品

如果你现在测试这个,你会注意到你的所有药水在你的库存中工作得非常完美。我们未完成的最后一种药水是以太,但以太的逻辑与药水逻辑完全相同,尽管你检查的是 MP 的效果而不是 HP 的效果。请注意,这种逻辑并不特定于任何单个物品,它是动态的,任何影响这些属性的物品都会使用这种逻辑。所以,如果你以后有一个超级药水,你不需要重做任何逻辑或添加新逻辑,超级药水仍然被视为一个物品,并且会应用通过数据表给予的正确数量的 HP。

摘要

到目前为止,你现在有了与 NPC 交互的货币系统。你能够从 NPC 那里购买物品,并在你的库存中存放尽可能多的物品,然后正确地使用它们。利用这些知识,你应该能够轻松地在游戏中创建更多物品,使用我们在上一章中讨论的策略。

在下一章中,我们将更深入地探讨可使用物品,并处理装备武器和盔甲,这将暂时改变玩家的属性。

第九章:装备

在上一章中,我们介绍了如何创建可用的物品,在由 NPC 控制的商店中填充它们,允许玩家从商店购买物品,并从他们的库存中使用这些物品。在本章中,我们将继续上一章的内容,向玩家提供武器和盔甲来装备自己。

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

  • 武器数据表

  • 设置武器和装备屏幕变量

  • 创建武器按钮

  • 再次访问装备屏幕

  • 设置装备屏幕文本块

  • 配装时修正角色属性

武器数据表

现在我们有了相当不错的物品创建框架,创建装备将会非常容易。由于物品数据表读取所有党派成员的属性,以便在使用物品时可以修改属性,我们可以预期装备将修改所有相同的属性;因此,我们可以使用与物品数据表相同的结构来为装备数据表使用相同的结构。

因此,到目前为止,我们将通过点击内容浏览器并导航到+添加新 | 杂项 | 数据表来创建一个武器数据表:

武器数据表

接下来,我们可以选择物品数据结构,因为我们将在装备数据表中调用与在物品数据表中相同的数据:

武器数据表

然后,将数据表命名为武器

武器数据表

我们将使用这个武器数据表来填充游戏中计划装备在角色上的每一件武器。目前,我们只为一个角色的武器创建一个数据表,但您可以使用以下所有步骤为其他角色创建更多的武器数据表。

您现在可以打开武器数据表,就像我们的物品数据表一样,我们通过点击添加按钮并命名数据来创建数据。在这种情况下,我们将制作我们的第一件武器,一把匕首,然后给匕首一些属性。由于匕首是武器,我们只需要修改攻击力属性。由于我们保持示例简单,我们没有利用许多其他 RPG 中具有的准确度敏捷度等属性,因为我们没有在我们的 RPG 框架中准确度或敏捷度。然而,如果您决定稍后向您的游戏添加这些额外的机制,您将需要修改与您的装备相关的数据表中的适当属性。您还可能希望给匕首一个金币价值,如果武器在您的游戏中具有可回收价值的话。

武器数据表

设置武器和装备屏幕变量

现在您已经在武器数据表中有了武器,我们可以设置一个框架来装备这件武器。

就像你创建的项目一样,你可以选择在商店中填充武器,你可以选择允许武器从场上的其他 NPC 自动拾取,或者你可以让它们成为敌人掉落物,就像你处理金币一样。无论你选择哪种方法,都要确保武器最终以与项目在库存屏幕中填充类似的方式填充到你的装备屏幕中。至少,我们需要角色持有武器数组。导航到FieldPlayer蓝图并打开事件图。然后,添加一个新的文本数组变量,类似于上一章中创建的arrayItem变量,并将其命名为arrayWeapons

设置武器和装备屏幕变量

项目和装备之间的主要区别在于,我们将能够装备和卸下装备,而不仅仅是使用装备,因此我们需要在我们的装备屏幕上为这一功能创建一个框架。当我们处于FieldPlayer蓝图时,我们可以通过创建一个equipmentScreen布尔值来开始创建这个框架,我们最终会将这个布尔值设置为让系统知道玩家何时访问装备屏幕。当装备武器和盔甲时,这将是必需的,就像inventoryScreen布尔值在访问库存屏幕时允许用户使用物品时是必需的一样:

设置武器和装备屏幕变量

现在玩家可以持有武器数组和装备屏幕布尔值,我们需要导航到Pause_Equipment事件图,并在事件构建时将equipmentScreen布尔值设置为 true,当玩家离开屏幕时将其设置为 false。这与我们在Pause_Inventory事件图中设置inventoryScreen布尔值的方式相同:

设置武器和装备屏幕变量

创建武器按钮

接下来,我们可以继续填充装备屏幕。为此,我们首先需要创建一个类似于之前创建的项目按钮的武器按钮。这个武器按钮将包含当用户按下武器按钮时将执行的所有逻辑。由于武器按钮在许多方面都类似于项目按钮,我们可以复制项目按钮并修改它以适应武器参数。因此,通过导航到Content Browser | Blueprints | UI,前往Item Widget 蓝图并复制Item Widget 蓝图:

创建武器按钮

然后,将其重命名为Weapon,如图所示:

创建武器按钮

我们现在可以打开武器小部件蓝图并导航到事件图。在这里,你会看到一个与Item小部件蓝图逻辑完全相同的副本,因为装备武器的逻辑将与使用物品相似,我们可以直接修改事件图以适应我们想要在用户按下武器按钮时发生的情况。

首先,我们需要编辑OnClicked事件所在的区域。我们希望OnClicked事件找到equipmentScreen布尔值并检查它是否为 true,因此我们可以移除对inventoryScreen布尔值的检查,并用equipmentScreen布尔值替换它:

创建武器按钮

如果equipmentScreen布尔值返回 false,则 false 分支将不执行任何操作,因此我们需要删除所有 false 分支逻辑:

创建武器按钮

如果分支返回 true,那么我们将设置Get Data Table Row Weapons函数以获取武器数据表:

创建武器按钮

然后,从Break ItemsData,将其设置为打破数据的ATK属性,并相应地设置角色的ATK属性:

创建武器按钮

目前,你的Weapon事件图中的OnClicked事件将看起来像这样:

创建武器按钮

此外,确保你仍然将Party Members属性设置到Character Target变量中;否则,角色的基础属性将无法正确继承:

创建武器按钮

重新访问设备屏幕

你可能会注意到,如果我们坚持使用这个事件图,我们仅仅会使用每一件武器而不是装备它,因为Remove from Parent函数的存在。我们将在本章后面回来编辑这个事件图,以正确设置按钮为装备而不是使用。

现在,我们将填充设备屏幕。如本章前面所述,你可以选择如何填充设备屏幕。为了简化这个示例,我们将像处理商店一样填充设备屏幕。因此,我们将导航回Pause_Equipment小部件蓝图的事件图,并使用我们用于填充商店屏幕的相同逻辑;这次将Get Data Table Row Names函数设置为获取武器数据表。然后,将Create Widget函数设置为获取武器小部件。请注意,你需要在设计师视图中确保is Variable的 Scroll Box 被勾选;否则,你的 Scroll Box 将产生错误,因为它找不到。

当你完成时,Pause_Equipment的事件图将看起来像这样:

重新访问设备屏幕

如果你现在测试设备屏幕,你会注意到武器被填充到滚动框中,如果你使用装备,角色的统计数据会上升并匹配武器的增加统计数据。然而,我们还有一些问题。武器正在被使用而不是装备,如果我们继续使用武器,统计数据最终会上升而不是被替换。让我们首先设置装备武器而不是替换它。

设置设备屏幕文本块

由于我们知道设备屏幕在武器护甲标题的右侧有文本块,这些文本块是在第五章“连接角色统计数据”中创建的,因此我们希望将这些文本块绑定到一个文本变量上,该变量将保存我们装备的武器和护甲的名称。让我们首先导航到我们的FieldPlayer蓝图事件图,并创建一个用于保存士兵武器名称的文本变量。我们将把这个文本变量命名为soldierWeapon

设置设备屏幕文本块

接下来,导航到Pause_Equipment小部件蓝图的设计器视图。选择武器标题右侧的文本块,导航到详细信息 | 内容 | 文本,然后点击绑定下拉菜单选择+创建绑定

设置设备屏幕文本块

在此绑定的事件图中,使用获取所有类别的演员函数来获取所有FieldPlayer的演员,获取soldierWeapon变量并将其链接到ReturnNode返回值引脚,以便文本块能够绘制保存在soldierWeapon变量中的文本:

设置设备屏幕文本块

我们现在可以通过回到武器小部件蓝图事件图,将从父级移除函数替换为设置 soldierWeapon来设置文本到soldierWeapon变量。soldierWeapon的文本值应设置为用户点击的项目变量:

设置设备屏幕文本块

如果你现在测试设备屏幕,你会注意到当按下装备按钮时,武器的名称会更新,武器将不会从装备屏幕中移除。

设置设备屏幕文本块

装备时修正角色统计数据

我们需要做的最后一件事是添加一些逻辑,以确保在装备屏幕中选择装备超过一次时,基本统计数据不会继续上升。为此,我们需要在FieldPlayer蓝图中创建两个变量。一个变量将是一个布尔值,用于跟踪士兵是否装备了武器。另一个将是一个整数,用于跟踪士兵的基本攻击统计数据。这些元素一起将允许我们在武器按钮中创建逻辑,防止每次点击武器时攻击统计数据上升。

因此,首先导航到FieldPlayer蓝图,创建一个名为soldierWeaponEquipped的布尔值。然后,创建一个名为soldierbaseAtk的整数:

装备时修正角色统计数据

我们指定这些统计数据为特定角色和武器装备,因为如果你的游戏中有更多角色在你的队伍中,每个角色都有武器和盔甲,你需要区分所有角色的状态。此外,你可能还想为每个统计数据创建一个基本状态,因为某些装备可能会改变除了攻击以外的统计数据。

我们现在可以使用我们创建的新变量来创建逻辑。导航到武器小部件蓝图事件图。我们需要修改一些逻辑,以便在士兵装备了武器时通知。创建一个设置士兵武器装备函数,并在装备了武器(或按下了武器按钮)后将其设置为true

装备时修正角色统计数据

记住,由于这个特定的按钮只有在按下时才会装备士兵的武器,如果你有额外的角色和/或不同类型的装备,你需要创建一个不同的 Widget 蓝图来适应这些角色和装备类型。

接下来,我们需要创建计算基本攻击的逻辑。由于我们已经使用布尔值来区分士兵是否在装备武器,我们可以使用这个逻辑来计算基本攻击统计数据。在我们的游戏中,默认情况下,角色没有装备武器。所以,我们可以定义基本攻击统计数据,当我们构造这个 Widget 蓝图时,但具体来说,当士兵没有装备武器时。

在这一点上,当 Widget 蓝图被构造时,使用获取所有类别的演员函数获取 Field Player 类的所有演员。在我们获取了这个类别的所有演员之后,获取soldierWeaponEquipped变量。让我们允许获取所有类别的演员函数触发一个分支,检查soldierWeaponEquipped变量是 true 还是 false:

装备时修正角色统计数据

如果条件为假,将soldierbaseAtk设置为角色的ATK变量。在选择武器时,而不是将ItemDataATK和当前角色的ATK统计数据相加,而是将ItemDataATKsoldierbaseAtk相加,这样我们就可以在装备武器时始终使用基础ATK变量而不是当前统计数据。这将防止ATK变量上升:

装备角色统计数据时的修正

你会发现的一个主要问题是,如果我们退出装备界面然后回来装备物品,攻击统计数据将继续增长。这是因为我们没有为已经装备的武器设置逻辑。因此,当soldierWeaponEquipped为真时,我们需要找到当前装备的武器,并从士兵的基础统计数据中减去其统计数据以重置基础统计数据。

要做到这一点,我们将简单地使用获取数据表行名称函数来获取武器数据表中的物品名称。对于数据表中的每个名称,我们需要将名称与字段玩家类的soldierWeapon变量进行比较。如果名称相等,我们使用获取数据表行函数从武器数据表中获取行名称,从soldierbaseAtk统计数据中减去武器的ATK统计数据,使用绝对值(整数)函数找到该操作的绝对值,最后将这个数字设置为soldierbaseAtk统计数据:

装备角色统计数据时的修正

在这一点上,如果你测试武器的装备,攻击统计数据将不再上升。

由于我们还没有设置等级系统,这种方法将有效。然而,当我们设置了等级系统后,我们希望用我们为等级创建的基础ATK变量来替换这个ATK变量。

摘要

现在你已经知道了如何为士兵的武器配置和设置统计数据,你可以使用类似的方法来创建数据表、蓝图以及其他角色及其装备的逻辑。

在下一章中,我们将为我们的游戏创建一个等级系统。我们将学习如何在敌人被击败后允许敌人给玩家经验值,然后这些经验值将被用来提升游戏中的角色等级。

第十章。等级提升、能力和保存进度

在上一章中,我们介绍了如何创建并应用装备到玩家身上,当装备后,会影响玩家的属性。在本章中,我们将允许玩家通过为每个团队成员设置经验系统来提升等级,使团队成员在战斗中获胜时从敌人那里获得经验。当每个团队成员获得足够经验时,他们将会提升等级,并且每提升一个等级,他们的属性都会增加。我们还将修复战斗伤害设置,以便战斗中的攻击将利用角色属性而不是硬编码的值。一旦我们修复了战斗逻辑,我们接下来将创建一个能力,该能力将在角色获得等级时激活。

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

  • XP 和等级提升源代码

  • 数据表起始值

  • 在暂停菜单中显示等级和经验

  • 在战斗中应用正确的伤害

  • 设置能力数组

  • 能力逻辑

  • 保存

  • 加载

XP 和等级提升源代码

为了使团队成员能够从战斗中获得经验值,我们需要在我们的代码中添加经验值(我们将称之为 XP)变量。此外,XP 变量需要累积到一个给定的 XP 上限(我们将称之为 MXP,即最大 XP),如果达到这个上限,玩家将获得一个等级。最好的方法是将这些变量添加到我们的源代码中,然后将其应用到游戏中我们拥有的每个团队成员和敌人身上。我们首先将做的是将 XP 和等级数据添加到我们的数据类中。导航到 UnrealRPG | Source | Data 并打开 FCharacterClassInfo.h。在 FCharacterClassInfo : public FTableRowBase 结构体中,为 XP 添加 UPROPERTY 以存储累积经验,为 MXP 添加 UPROPERTY 以存储达到下一个等级的经验上限,为 Lvl 添加 UPROPERTY 以存储团队成员的当前等级:

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

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

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

接下来,打开 FEnemyInfo.h,它位于 FCharacterClassInfo.h 相同的文件夹中。我们需要为敌人的信息添加 XP,因为每个敌人都会给团队成员提供一定数量的 XP。在 FEnemyInfo : public FTableRowBase 结构体中,为 XP 添加 UPROPERTY

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

我们现在需要在游戏的 GameCharacter 实例中使用这些变量。导航到 UnrealRPG | Source 并打开 GameCharacter.h。在 class RPG_API UGameCharacter : public UObject 结构体中,为 XPMXPLvl 添加 UPROPERTY

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

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

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

打开 GameCharacter.cpp 以设置游戏角色实例等于团队成员和敌人数据。首先,在 UGameCharacter* UGameCharacter::CreateGameCharacter( FCharacterInfo* characterInfo, UObject* outer ) 中,将角色的 XPMXPLvl 设置为团队成员的数据:

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->XP = character->ClassInfo->XP;

    character->MXP = character->ClassInfo->MXP;
    character->Lvl = character->ClassInfo->Lvl;
    character->isPlayer = true;
  }

  return character;
}

接下来,将每个敌人角色的 XP 设置等于 XP 敌人数据:

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->Gold = enemyInfo->Gold;
  character->XP = enemyInfo->XP;

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

  return character;
}

我们现在可以向我们的战斗引擎添加一个 XP 框架。打开 CombatEngine.h。添加 XPTotal 作为公共变量:

public:
  int32 XPTotal;

XPTotal将负责存储所有敌人阵亡时从战斗中获得的总经验值。

到目前为止,让我们使用我们创建的经验值变量来计算从战斗中获得的经验值数量。打开CombatEngine.cpp。在bool CombatEngine::Tick(float DeltaSeconds)中,将经验值添加到我们的胜利检查部分。为此,我们将局部XP变量设置为0,并且对于战斗中的每个敌人,我们将累积的经验值累加到XP变量中:

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

如果所有玩家成员都已阵亡,我们将把敌人的总经验值存储在我们的公共XPTotal变量中,以便在类外使用:

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

最后,我们可以在我们的游戏实例中为每个玩家成员添加获得的经验值。为此,打开RPGGameMode.cpp。在void ARPGGameMode::Tick(float DeltaTime)中,我们添加了对胜利阶段的检查,我们将创建一个for循环。这个for循环将遍历每个玩家成员,并且对于每个玩家成员,我们将他们的当前经验值设置为从战斗中获得的经验值的累积:

for (int i = 0; i < gameInstance->PartyMembers.Num(); i++)
{
  gameInstance->PartyMembers[i]->XP += this->currentCombatInstance->XPTotal;
}

在这个for循环中,我们还可以检查玩家当前所在等级的当前经验值与当前经验值上限。如果玩家成员的当前经验值超过或等于MXP,玩家将升级,获得增加的基础属性,并且获得下一个等级(MXP)的经验值上限将增加:

if (gameInstance->PartyMembers[i]->XP >= gameInstance->PartyMembers[i]->MXP){
  gameInstance->PartyMembers[i]->Lvl++;
  gameInstance->PartyMembers[i]->MHP++;
  gameInstance->PartyMembers[i]->MMP++;
  gameInstance->PartyMembers[i]->ATK++;
  gameInstance->PartyMembers[i]->DEF++;
  gameInstance->PartyMembers[i]->LUCK++;
  gameInstance->PartyMembers[i]->MXP += gameInstance->PartyMembers[i]->MXP;
}

在这个例子中,我们通过只允许玩家成员获得等级时属性增加 1,并将下一个等级的上限设置为上一个等级的两倍来简化了计算。如果你喜欢,你可以在你的游戏中提出更复杂的计算。请注意,用于区分属性数值和每个玩家成员的所有计算都可以在这里完成。

当你完成时,胜利条件将如下所示:

else if( this->currentCombatInstance->phase == CombatPhase::CPHASE_Victory )
{
  UE_LOG( LogTemp, Log, TEXT( "Player wins combat" ) );
  URPGGameInstance* gameInstance = Cast<URPGGameInstance>(GetGameInstance());
  gameInstance->GameGold += this->currentCombatInstance->GoldTotal;

  for (int i = 0; i < gameInstance->PartyMembers.Num(); i++)
  {
    gameInstance->PartyMembers[i]->XP += this->currentCombatInstance->XPTotal;

    if (gameInstance->PartyMembers[i]->XP>= gameInstance->PartyMembers[i]->MXP){
      gameInstance->PartyMembers[i]->Lvl++;
      gameInstance->PartyMembers[i]->MHP++;
      gameInstance->PartyMembers[i]->MMP++;
      gameInstance->PartyMembers[i]->ATK++;
      gameInstance->PartyMembers[i]->DEF++;
      gameInstance->PartyMembers[i]->LUCK++;

      gameInstance->PartyMembers[i]->MXP +=gameInstance->PartyMembers[i]->MXP;
    }

  }

UGameplayStatics::GetPlayerController( GetWorld(), 0 )->SetActorTickEnabled( true );
}

到目前为止,你可以编译你的源代码,并在 UE4 中重新启动/打开你的项目。

我们现在已经完成了在源代码中创建经验系统框架的工作,现在我们可以继续为游戏中的每个这些值提供特定的起始值。

数据表起始值

内容浏览器中,通过导航到内容 | 数据来打开CharacterClasses数据表。在这里,我们可以更改我们玩家成员的起始值。对于士兵,我们将起始经验值设为 0,因为玩家成员应该从 0 经验值开始。MXP值将是200,这意味着士兵必须获得 200 经验值才能达到下一个等级。Lvl值将是1,因为我们希望每个角色从等级 1 开始:

数据表起始值

我们现在应该设置敌人给予的多少经验值。在同一个文件夹中,打开敌人 数据表,其中至少有一个敌人。对于每个敌人,我们需要设置一个经验值,这将决定当敌人被杀死时掉落多少经验。对于这个特定的敌人,我们将经验值设置为50

数据表起始值

暂停菜单中显示等级和经验

在这个阶段,如果你测试构建,团队成员将在战斗中获得经验并相应地升级(如果你看到统计信息增长,就可以知道是否有团队成员获得了足够的经验以升级),但我们还没有显示团队成员的正确等级或经验值。我们可以通过将这些值绑定到我们的暂停菜单中轻松地做到这一点。导航到内容 | 蓝图 | UI。打开Pause_Main Widget 蓝图。在设计师视图中,选择Soldier Lvl右侧我们创建于第四章的Editable_Soldier_Level 文本块,暂停菜单框架

暂停菜单中显示等级和经验

内容下的详细信息选项卡中,通过点击绑定下拉菜单并选择+创建绑定来为此文本创建一个绑定:

暂停菜单中显示等级和经验

这将自动打开Get Editable_Soldier_Level_Text函数的图表。在图表中,我们需要简单地从RPGGameInstance获取变量,就像我们之前做的那样,但这次我们特别获取当前的Lvl变量并将其作为文本返回:

暂停菜单中显示等级和经验

在这个例子中,我们只获取一个团队成员(我们的士兵)的等级,这在数组中的索引是 0。如果你有多个团队成员,你只需在GET函数中更改索引到正确的索引;例如,索引 1 将找到你的团队成员数组中的第二个成员及其统计数据,因此将返回不同的统计数据集。

我们暂停菜单中唯一未定义的文本块是位于XP/Next Lvl文本右侧的Editable_Soldier_XP 文本块。选择此文本块,导航到详细信息选项卡,并在内容下添加一个绑定,就像我们为最后一个文本块所做的那样,并且将弹出标记为Get Editable_Soldier_XP_Text的函数图。就像上一个文本块一样,我们将获取正确的团队成员当前数据;特别是,我们将获取经验值和 MXP,因为我们希望这个文本块显示累积的经验值和达到下一级所需的经验值:

暂停菜单中显示等级和经验

你会注意到ReturnNode只能接受一个返回值引脚,而我们有两个不同的值。我们可以通过使用Append函数和追加文本来轻松解决这个问题。我们将通过在蓝图上右键单击,导航到实用工具 | 字符串,并选择Append来找到Append函数:

在暂停菜单中显示等级和经验

Append一次处理两个字符串。由于文本块应该有一个/来分隔当前经验值和达到下一级所需的经验值,我们需要两个Append函数。对于第一个Append,将XP连接到A引脚,并在B引脚中简单地追加一个/

在暂停菜单中显示等级和经验

接下来,创建另一个Append函数,并将第一个Append函数的返回值连接到第二个Append函数的A引脚。然后,将MXP连接到第二个Append函数的B引脚,以便MXP追加最后一组字符串:

在暂停菜单中显示等级和经验

完成后,只需将第二个Append函数的返回值连接到ReturnNode返回值

在暂停菜单中显示等级和经验

到目前为止,如果你通过进入战斗和升级来测试你的游戏,你将看到所有你的统计数据都会在暂停菜单中正确更新(以下截图是在我们测试了战斗和与敌人获得经验之后):

在暂停菜单中显示等级和经验

在战斗中应用正确的伤害

在战斗中,你会注意到无论发生什么情况,敌人和玩家都会造成 10 点伤害。当前的攻击力和防御力似乎没有被计算。这是因为,在第三章中,当我们创建战斗动作时,我们硬编码了伤害为target->HP -= 10,这意味着无论谁攻击,他们都会对玩家造成 10 点伤害。我们可以很容易地通过导航到 | RPG | 战斗 | 动作并打开TestCombatAction.cpp来修复这个问题。找到target->HP -= 10;并将其替换为target->HP -= (character->ATK - target->DEF) >= 0 ? (character->ATK - target->DEF):0;

这是一个三元运算符。当一个目标被攻击时,无论是团队成员还是敌人,只有当攻击者的攻击力减去目标防御力的结果等于或大于 0 时,目标的 HP 才会下降。如果结果是小于 0,则 HP 默认为 0。完成操作后,void TestCombatAction::BeginExecuteAction( UGameCharacter* character )将看起来像这样:

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 -= (character->ATK - target->DEF) >= 0 ? (character->ATK - target->DEF):0;

  this->delayTimer = 1.0f;
}

设置能力数组

在第三章中,我们在FCharacterClassInfo.h中创建了一个用于学习能力的角色类信息,这是一个用于为继承类的每个角色存放能力数组的数组。我们需要扩展这个数组,使其被任何游戏角色采用,以存放他们在游戏中学习的能力。为此,通过导航到 | RPG打开GameCharacter.h。在class RPG_API UGameCharacter : public UObject中,向学习能力添加一个公共UPROPERTY,并允许它在任何地方进行编辑:

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

接下来,打开位于同一文件夹中的GameCharacter.cpp,并将LearnedAbilities设置为等于我们在类信息中创建的变量的LearnedAbilities

character->LearnedAbilities = character->ClassInfo->LearnedAbilities;

这将允许每个团队成员实例都拥有自己的LearnedAbilities数组,我们可以现在在代码或蓝图中进行编辑。你的游戏角色现在看起来像这样:

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->XP = character->ClassInfo->XP;

    character->MXP = character->ClassInfo->MXP;
    character->Lvl = character->ClassInfo->Lvl;
    character->LearnedAbilities = character->ClassInfo->LearnedAbilities;
    character->isPlayer = true;
  }

  return character;
}

完成后,编译并重新启动编辑器。现在我们可以在游戏中创建一个位置来存放和使用能力。在这个游戏中,我们将选择只在战斗中使用能力,但如果你想在其他地方使用能力,例如,在战斗之外,你可以通过遵循类似的步骤轻松实现这一点。由于我们将在战斗中应用能力,让我们在战斗界面中添加一个新的能力按钮。在编辑器中,导航到内容 | 蓝图 | UI,并打开CombatUI Widget 蓝图。

设计器视图中,创建一个组合框,允许我们有一个具有多个条目的下拉菜单,我们将使用它来存放和选择我们的能力,并将它们放置在characterActions画布面板中:

设置能力数组

将组合框调整到与攻击按钮相同的大小,并将其放置在与攻击按钮对齐的位置:

设置能力数组

最后,将组合框重命名为适合其中包含的元素。我们可以将其命名为ComboBoxString_Abilities,并检查是否为变量

设置能力数组

现在我们有一个可以存放能力的组合框,现在是时候用适当的能力填充组合框了。打开CombatUI事件图。由于我们关心在战斗期间可访问的正确能力,最好在CombatUI创建时立即填充组合框。为此,通过导航到添加事件 | 用户界面创建一个事件构造

设置能力数组

事件构造连接到Cast To RPGGameInstance,这将获取所有团队成员,以便我们可以获取和设置适当的能力:

设置能力数组

我们可以在这里为党派成员之一(在这种情况下,是士兵)设置一个能力,通过获取Party Members数组的索引 0。如果士兵达到等级 2,我们将给士兵一个名为Attack x2的能力。为此,我们将使用Lvl变量获取目标当前等级,并使用CompareInt宏将其与整数 2 进行比较:

设置能力数组

如果Lvl变量大于或等于 2,我们可以将LearnedAbilities数组的第一个元素设置为Attack x2

设置能力数组

在我们将新的Attack x2能力填充到数组中之后,现在我们可以通过简单地执行ForEachLoop遍历数组的每个元素,并使用Add Option函数将其添加到组合框中,通过导航到组合框 | 添加选项来填充组合框中的每个能力:

设置能力数组

这是一种非常简单的方法,可以根据党派成员等级将战斗能力添加到能力下拉菜单中。如果你想创建更多能力,你只需要简单地使用CompareInt比较你的等级和另一个等级,然后你可以将更多能力添加到LearnedAbilities数组中。如果你最终在游戏中拥有额外的角色,最好为每个党派成员创建一个新的组合框,从他们在Party Members数组中的索引获取该党派成员,然后像我们对士兵所做的那样,将能力添加到他们自己的LearnedAbilities数组中。

你现在应该能够测试这个功能,并看到当玩家按下组合框时,如果士兵党派成员达到等级 2,Attack x2将会出现:

设置能力数组

能力逻辑

我们现在可以为我们 的Attack x2能力创建一个逻辑。正如其名所示,Attack x2应该执行造成双倍伤害的攻击。在我们应用这种逻辑之前,我们必须首先创建一个事件,该事件在从组合框中选择能力并按下它之后发生。返回到Designer视图。在Details选项卡中,导航到Events,然后按OnOpening事件旁边的+

能力逻辑

这将在你的事件图中创建一个OnOpening事件。通过从组合框中选择并点击能力,我们需要首先使用Clear Children函数清除Panel小部件的所有子项,类似于我们通过点击Attack按钮所做的那样。这将防止同一目标的多个按钮弹出:

能力逻辑

接下来,我们将检查Attack x2能力是否已被打开,首先调用位于组合框下的Get Selected Option函数(你需要关闭Context Sensitive才能这样做):

能力逻辑

我们将Get Selected Option目标设置为Get Combo Box String Abilities

能力逻辑

然后,检查所选选项是否等于攻击 x2

能力逻辑

如果相等,这意味着我们选择了攻击 x2,然后我们将获取RPGGameInstance。然而,我们需要首先检查党派成员是否有足够的 MP 来使用该能力。在这种情况下,我们将设置该能力使用 10 MP,所以让我们在使用能力之前确保党派成员至少有 10 MP:

能力逻辑

如果玩家有足够的 MP 来使用这个能力,我们将使用一种逻辑,允许玩家执行造成双倍伤害的攻击。由于玩家将造成双倍伤害,这意味着我们可以很容易地将玩家的ATK变量乘以二;然而,我们不想让ATK变量永远加倍,只在本回合加倍。为此,最好创建一个局部变量,暂时保存基础ATK值,这样在下个回合,我们可以将ATK值重置回正常值。我们可以通过创建一个名为Temp Atk的局部整数来轻松做到这一点,并将Temp Atk设置为党派成员的ATK值:

能力逻辑

接下来,我们将将党派成员的ATK值设置为原来的两倍,通过将其乘以二,并将ATK变量设置为该操作的结果:

能力逻辑

我们还需要设置一个布尔值来告诉我们何时使用了攻击 x2。因为如果我们使用了它,我们需要从党派成员那里减去 MP,并将我们的ATK变量恢复到正常状态。为此,我们需要创建一个局部布尔值,我们将称之为attackx2。在我们将攻击设置为双倍后,将attackx2设置为 true,并通过将SET attackx2连接到Get Character Targets函数来允许CombatUI显示所有可用的敌方目标:

能力逻辑

一旦完成,我们可以将attackx2布尔值重置为 false,将ATK变量恢复到正常值,并从党派成员那里减去 10 MP 以使用该能力。最好的地方是在Event Show Actions Panel再次发生时进行,当角色动作变得可见且目标变得不可见时。在目标变得不可见后,我们将检查attackx2布尔值是否为 true。如果是 true,我们将将其设置为 false,然后将ATK值设置为Temp Atk值。然后,我们从党派成员的 MP 变量中减去 10:

能力逻辑

保存和加载游戏进度

我们最后要关注的是保存和加载游戏进度。保存和加载可以通过许多方式完成,但本质上,保存进度围绕着你想要保存的特定变量。

保存

大多数游戏会保存很多不同的变量,例如玩家所在的关卡或区域、玩家的统计数据、玩家的库存和金币。在我们的例子中,我们将选择保存玩家的金币,但使用我们即将执行的方法,你可以轻松地找出如何保存游戏中的所有其他进度。

首先,通过导航到Content | Blueprints内容浏览器中创建一个新的 Blueprint 类。Pick Parent Class窗口将弹出,从All Classes中选择SaveGame

保存

将这个类命名为NewSaveGame,并打开这个类。这个类的目的是保存你想要保存的每个变量的值。如前所述,对于这个例子,我们将保存金币变量,但如果你想要保存更多的变量,你刚刚创建的NewSaveGame类也会存储这些变量。在这个时候,从My Blueprint选项卡中的Add New变量添加一个新的变量到这个类中。将其命名为Gold,并使其变量类型为Integer

保存

现在你已经完成了,是时候找到一个合适的地点来保存游戏以及保存金变量了。由于我们已经在之前的章节中有了暂停菜单,并且学习了如何向暂停菜单中添加按钮,因此创建一个名为保存的新按钮在Pause_Main Widget Blueprint 中并将一个OnClicked事件添加到它上面将会很容易:

保存

一旦你点击OnClicked事件旁边的+,事件图将打开,你将看到你的Save按钮的OnClicked事件。在这里,允许按钮在被按下时创建一个保存游戏对象。为此,创建一个Create Save Game Object,其Save Game Class设置为New Save Game,并允许它在点击按钮时启动:

保存

在这里,我们需要创建一个新的Save Game类型的变量,我们将称这个变量为save

保存

在这里,我们将在我们的 Blueprint 中创建一个SET Save变量,并将Create Save Game Object函数的Return Value传递给SET Save

保存

我们现在需要将NewSaveGame类进行转换,以便我们可以将我们创建的Gold变量设置到游戏的金上。为此,通过将Save值连接到Cast To NewSaveGameObject来将SET Save转换为NewSaveGame

保存

接下来,允许Cast To NewSaveGame触发一个Cast To RPGInstance,其Object是对Get Game Instance的引用。我们这样做是为了获取GameGold的实例,因此将Cast To RPGGameInstanceAs RPGGame Instance引脚链接到 RPG 实例中的Get GameGold变量:

保存

现在我们已经获取了游戏金币,我们可以通过将SET Gold连接到当RPGGameInstance被转换时触发,并将GameGold值引脚连接到Gold值引脚,以及将SET Gold目标引脚连接到Cast To NewSaveGameAs New Save Game引脚,将游戏金币的值设置为NewSaveGame类中的Gold变量:

Saving

这种特定方法将允许我们将当前游戏金币的任何值保存到NewSaveGame类中的Gold变量。请注意,如果您想保存更多变量,就像设置Gold变量的值一样设置这些变量的值,为每个单独的变量添加一个SET节点。

我们最后需要做的是创建一个存档游戏槽位,它将保存我们的存档游戏对象。为此,创建一个存档游戏到槽位动作,你可以在你的动作窗口中的游戏下找到它:

Saving

为此创建一个槽位名称;在这个例子中,我们将使用A作为槽位名称。将Save Game to SlotSave Game Object引脚连接到Save值引脚,并允许当Gold变量被设置时Save Game to Slot触发:

Saving

我们现在已经完成了游戏的存档部分。我们现在将转到加载游戏槽位。

加载

就像保存一样,加载也可以以多种方式完成。在我们的游戏中,我们将在游戏启动时简单地加载玩家的存档数据。为此,打开FieldPlayer蓝图,因为我们知道 FieldPlayer 将始终存在于我们的游戏中。

接下来,我们将创建一个与保存游戏时类似的Load变量,这样我们就可以正确地转换变量及其值从NewSaveGame

Loading

在这里,我们将创建一个事件开始播放,并且从事件开始播放,我们将调用动作窗口中游戏类别下的Does Save Game Exist函数,并且在槽位名称下,我们将寻找A,因为我们之前将我们的存档槽位命名为A

Loading

存档游戏是否存在,我们将调用一个分支;如果存档游戏是否存在为真,我们将调用从槽位加载游戏,并且它的槽位名称也将是A

Loading

在这一点上,我们已经创建了一个逻辑,当游戏开始时,我们检查槽位 A 中的存档游戏是否存在。如果它存在,我们就从槽位 A 加载游戏;如果它不存在,我们就不做任何事情。

现在,我们可以将我们在本节开头创建的Load变量设置为Save Game类型的数据,类似于我们保存游戏时所做的,这样我们就可以正确地将变量及其值从NewSaveGame转换过来:

Loading

注意,由于我们现在可以访问NewSaveGame中的所有变量,这意味着我们可以访问我们保存的金币值。所以从这里,我们从Cast To NewSaveGame获取金币值,这样你就有玩家上次保存时存储的任何值,并且你需要将RPGGameInstance中的GameGold值设置为NewSaveGame中的Gold

加载

就像我们在创建保存逻辑时一样,在这个加载逻辑中,如果你需要加载其他任何变量,你可以很容易地通过从NewSaveGame类获取更多变量并将其设置到RPG Game Instance中的其他变量来实现。

你现在可以通过简单地玩游戏来测试这个功能,进行战斗以获得游戏金币,使用我们在暂停菜单中创建的保存按钮保存游戏,然后关闭游戏。当你重新打开游戏时,你会注意到你在保存游戏时拥有的金币会自动加载。使用这个框架,你可以自由地保存其他游戏变量,比如你的状态和库存。

摘要

我们现在有一个解决方案,可以让队伍成员通过获得足够的经验来获得等级,并通过达到特定等级来获得能力。我们还修复了战斗系统,以便让队伍成员和敌人根据他们的统计数据造成伤害,并允许队伍成员在战斗中使用能力。此外,你现在可以在整个游戏过程中保存和加载玩家进度。使用本章中介绍的框架,每个队伍成员都可以升级,你可以轻松地为不同的队伍成员添加更多能力,在战斗中使用它们,并且玩家可以在任何时候保存游戏,然后回来继续他们离开的地方。

到目前为止,你已经成功完成了一个回合制 RPG 的工作框架。你有一个核心游戏玩法,可以允许一个角色队伍以等距 3D 的方式探索世界。你可以使用新获得的能力和装备与敌人战斗,通过与 NPC 交谈和用击败敌人获得的金币购买物品和装备来与他们互动。就像大多数其他 RPG 一样,你可以通过获得经验来升级,以及保存游戏状态,这样玩家就可以在以后的时间回来继续他们的游戏。

尽管你的探索之旅还没有结束!现在你已经掌握了基础知识,就可以开始为你的游戏添加更多内容了,比如额外的敌人、队伍成员、NPC 和装备。通过这个过程,你可以使用本书中创建的框架和内容来创建自己的关卡。

posted @ 2025-10-27 09:08  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报