Unity-2018-RPG-构建指南-全-
Unity 2018 RPG 构建指南(全)
原文:
zh.annas-archive.org/md5/bcf5e87c9fae3dfa0479510ce06d2d21译者:飞龙
前言
现在,每个人都想制作游戏——由于游戏产业的民主化和用于设计和开发游戏的工具,这比以往任何时候都更容易实现。本书的编写有几个目的。在编写本书的过程中,Unity 经历了多次重大更新和发布。在本书编写时,Unity 处于 2018.1.1f1 版本,新版本为游戏引擎添加了许多优秀功能。简单来说,在一本书中涵盖所有内容是不可能的!
这本书旨在作为 Unity 学习者的参考指南,帮助他们将技能应用于创建角色扮演游戏(RPG)。
本书面向的对象
这本书是为那些想要学习和应用他们的 Unity 技能来创建 RPG 的个人而编写的。假设读者对编程概念有基本的理解,并且对 Unity 的 IDE 基础感到舒适。本书为构建您自己的游戏体验提供了强大而坚实的基础,涵盖了核心概念和主题。
本书涵盖的内容
第一章,什么是 RPG?,提供了关于 RPG 的良好背景信息。它涵盖了历史方面,并给出了现有 RPG 的例子。它讨论了 RPG 的主要方面,涵盖了相关术语,并为本书的其余部分做好了准备。
第二章,游戏规划,是我们查看角色定义、角色类属性、角色状态以及如何设置和绑定角色模型的地方,这包括对运动、控制器和逆运动学的探索。
第三章,RPG 角色设计,继续扩展玩家角色自定义,并探讨了保存角色状态、非玩家角色(NPC)的设置以及 NPC人工智能(AI)和交互。
第四章,游戏机制,是我们开始规划游戏的地方。我们讨论了在游戏创建过程中我们将需要的不同类型资源和资产,引入了第三人称角色控制器,并创建了我们的初始关卡和脚本。我们还探讨了用于地形生成的地形工具包。
第五章,游戏大师和游戏机制,探讨了增强游戏大师脚本,介绍了等级控制器和音频控制器脚本,讨论了角色数据的存储和角色自定义状态,并探讨了主菜单初始用户界面的选项。
第六章, 库存系统,涵盖了通用库存系统的创建;创建表示库存物品所需的脚本、资源和预制体;设计库存用户界面;以及如何表示库存系统及其物品。
第七章, 用户界面和系统反馈,讨论了抬头显示、玩家角色信息面板和活动库存物品面板的设计与实现。特别库存物品面板的设计和实现,以及非玩家角色生命条和 UI。
第八章,多人设置,讨论了使用 Unity 的 Unet 架构进行多人编程。本章使用两个示例项目来说明这些概念。初始项目是一个坦克游戏,它说明了服务器客户端和数据同步的概念。第二个项目将我们所学应用到创建支持我们的角色模型的场景。
要充分利用本书
您需要具备良好的 C#语言理解能力。您还需要对 Unity IDE 的基础知识有良好的理解。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择支持选项卡。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Building-an-RPG-with-Unity-2018-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还提供来自我们丰富的图书和视频目录中的其他代码包。这些代码包可在github.com/PacktPublishing/找到。您可以查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/BuildinganRPGwithUnity2018SecondEdition_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的 WebStorm-10*.dmg 磁盘映像文件挂载为系统中的另一个磁盘。”
代码块设置如下:
using UnityEngine;
using UnityEngine.SceneManagement;
namespace com.noorcon.rpg2e
{
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解有关 Packt 的更多信息,请访问 packtpub.com.
第一章:什么是 RPG?
在我们开始之前,最好简要了解该类型的历史,并了解在设计你的 RPG 时需要考虑的一些关键元素。
下面是本章我们将要介绍的内容概述:
-
该类型的简短历史
-
RPG 的特点
-
故事与背景
-
探索与任务
-
背包系统
-
角色发展
-
经验与等级提升
-
战斗系统
-
用户交互与图形
-
现有或即将推出的 RPGs
-
RPG 中的模式
你可能刚刚开始迄今为止最具挑战性的任务。
该类型的简短历史
什么是 RPG?简而言之,它是一种游戏,玩家在虚构的设定中扮演角色,参与者扮演角色并共同创造故事。每个玩家根据他们的角色设定决定他们角色的行为,并根据游戏规则的成功或失败采取行动。
RPG 有三种类型,如下所示:
-
桌面
-
实景表演
-
计算机 RPG(cRPG)
桌面角色扮演游戏(桌面与纸笔 RPGs,PnP) 通过小规模社交聚会中的讨论进行。通常有一个游戏主持人(GM)来描述游戏世界及其居民。其他玩家描述他们角色的意图行为,而 GM 描述结果。这是 RPG 最初流行化的格式,即通过《龙与地下城》(D&D)。
实景角色扮演(LARP)更像是即兴表演。参与者通过表演他们角色的行为而不是描述它们,并使用真实环境来代表游戏世界的虚构场景。一些 LARP 使用剪刀石头布或属性比较来象征性地解决,而其他 LARP 则使用模拟武器进行物理战斗。一部电影可以被认为是一个简单的 LARP;区别在于,在电影中,所有行为都是剧本化的,玩家没有太多决策要做,而在 LARP 中,角色可以根据他们的决策改变他们行为的结果。
cRPGs 是将桌面 RPG 翻译成电子格式的游戏。早期的 cRPG 影响了所有类型的电子游戏,并跨越了角色扮演视频游戏类型。简而言之,cRPG 是一个玩家在沉浸在一个定义良好的世界中控制主要角色行为的视频游戏类型。
本书将专注于 cRPG 的设计和开发。
注意:从现在开始,当我们在书中提到 RPG 时,我们指的是 cRPG。
计算机角色扮演游戏起源于该类别的桌面版本。许多相同的术语、设定和游戏机制都从原始桌面游戏中继承而来。其中一些相似之处包括叙事和叙事元素,在整个故事中,玩家角色将不断发展和提升他们的技能和能力,以满足游戏的目标。
RPG 的特点
角色扮演游戏通常依赖于高度发展的故事和设定,这些被划分为多个任务或等级。玩家通常通过发布命令来控制一个或多个角色,这些命令随后由玩家角色根据其定义的能力和属性执行。在整个游戏过程中,这些属性会增减,并设定角色的性格。
RPG 通常还拥有一个复杂且动态的交互机制,这个机制在玩家角色和沉浸其中的世界中定义和开发。这包括与世界环境的交互,以及世界内定义的其他非角色玩家。由于这些因素,通常会有更多的时间用于设计和开发处理游戏过程中行为和人工智能(AI)处理此类事件的代码库。
RPG 的关键元素如下:
-
故事和设定
-
探索和任务
-
物品和库存
-
角色动作和能力
-
经验和等级
-
战斗
-
界面和图形
故事和设定
大多数角色扮演游戏的前提是让玩家拯救世界,或者任何受到威胁的社会阶层。随着故事的发展,常常会有意想不到的转折,比如出现疏远的亲戚,或者敌人变成朋友,反之亦然。游戏世界往往设定在历史、幻想或科幻宇宙中,这允许玩家做他们在现实生活中无法做到的事情,并帮助玩家暂时放下对角色快速成长的怀疑。
如前所述,RPG 在叙事方面投入巨大。这是该类型的主要娱乐因素之一。由于这个原因,当你开发你的 RPG 时,你需要密切关注你如何发展你的故事以及你故事中的角色。这反过来又转化为你游戏中的环境和设定,以及游戏中的角色。
传统上,RPG 根据玩家角色在游戏中的决策推进剧情。这给游戏设计师带来了巨大的压力,他们需要能够将游戏中的这些分支与游戏的主线剧情整合起来。这也引发了如何编程游戏以考虑故事中所有不同路径的问题。
为了使游戏更加有趣和吸引人,游戏设计师可以在故事中引入特殊的触发器,使其更加有趣或具有挑战性。这通常是通过在现有级别中引入新的角色和/或新区域来实现的。以下是我们将为我们的 cRPG 构建的故事情节和设置的简化描述。
cRPG 故事的一瞥
从前,有一个伟大的王国,由伟大的国王扎扎统治。王国的统治者对他的臣民慷慨大方。扎扎统治下的王国和平繁荣;然而,随着时间的推移,内部家族的竞争和斗争在维持王国完整性的强大纽带中造成了裂痕。
由于神秘事件,伟大的国王决定将他的家人从王国中搬走,并信任他信任的一位明智的长者。王国从此不再一样。直到现在!在扩展我们的故事中,在第二版中,我们将实现更多的游戏玩法,以及任务的引入。
探索和任务
RPG 背后的整个想法是玩家在沉浸的世界中拥有探索的自由。世界定义得越清晰,玩家探索的兴趣就越大,他们就能在整个游戏过程中保持好奇心和参与度。
这是通过为 RPG 开发的故事叙述实现的。玩家将有机会在世界上四处走动,探索周围环境以实现他们的目标。
在开放世界 RPG 中,玩家在完成由故事情节设定的目标后可以自由地在世界中漫游。在这种情况下,玩家仍然可以探索不再需要继续任务的区域,但他们可以在探索区域的同时花费时间,也许会遇到他们在完成任务时之前未曾遇到的非玩家角色。但一般来说,这不是玩家做的;一旦他们达到目标,他们就渴望继续下一个任务。因此,问题是,游戏设计师和开发者会在主要目标达成后对区域投入多少时间和精力?答案可能不会太多。
历史上,玩家遵循线性序列的任务以实现他们在游戏中的目标和目的。为了使游戏更具吸引力,开发者可以在游戏的主剧情中引入迷你任务,在该特定地点让玩家能够探索并获得更多技能和/或能力。由于这些任务不是主线故事的一部分,它们可以在玩家进入特定区域时随时触发。
例如,假设玩家已经完成了该级别的首要目标,并准备进行下一个目标。现在,想象一下我们创建了一个开放世界环境,用户可以随时重新访问这个世界。如果玩家决定回到他们刚刚完成的世界的一个特定区域进行探索,并且意外触发了启动这个迷你任务的事件,这对玩家来说不是一件令人惊喜的事情吗?请记住,这些迷你任务不应该影响主线故事,但它们可以用来增强玩家的体验。当你在开发游戏时,这类决策非常重要。如果他们选择不接受挑战,你不应该惩罚他们,除非你真的想表现得非常刻薄。
任务可能包括击败一个或多个敌人、营救非玩家角色、物品收集任务或位置谜题,例如神秘上锁的门。
探索和任务的一瞥
我们的游戏将总共包含四个任务。每个任务都将有独特的目标,玩家需要完成。随着我们前进,本书将讨论每个任务的设计和开发。
这里是我们将要设计的等级列表:
-
觉醒
-
村庄
-
破碎森林 - 地平线
-
王国
游戏将通过让玩家沉浸在这个环境中开始,我们的英雄将接受完成使命所需的基本训练。
库存系统
RPG 的主要功能和特性之一是库存系统。在整个游戏过程中,用户会遇到大量可收集的物品,这些物品可以在游戏的不同用途中使用,以帮助他们完成旅程。因此,RPG 需要提供一种机制来帮助玩家存储、检索和组织与他们的旅程相关的内容。请看以下截图:

资产清单
当玩家在 RPG 游戏中进行旅程时,他们会与沉浸其中的世界互动。游戏的故事情节通常迫使玩家与周围的世界和其他非玩家角色互动。这些互动通常以某种形式的交换形式出现。这种交换是通过叙述来提供玩家更好的任务感,还是通过真实交换,即物品交换,取决于游戏设计师和开发者。
游戏需要一种方式来跟踪玩家角色与所有其他人和事物之间的所有互动。用于跟踪这些互动的一个系统是库存系统。
在游戏过程中,玩家通常从一个非常简单的角色开始,游戏的一部分是通过探索世界和收集可以帮助他们提高技能和能力的物品来提升他们的角色。
例如,玩家可能开始他们的旅程时穿着非常基本的衣服。在整个任务过程中,他们可能会与一个非角色玩家互动,比如商人,商人会为他们提供一套更好的衣服,以及某种武器以帮助他们开始。这些物品将由库存系统进行存储和管理,以下截图是一个例子:

物品将由库存系统进行存储和管理
库存系统的简单性或复杂性将由游戏本身的复杂性和游戏内角色的复杂性来定义。在我们的游戏中,我们将设计和开发一个通用的库存系统,该系统可以应用于许多不同类型的物品。
这里有一些在游戏中通常收集到的物品:
-
武器
-
防具
-
服装
-
特殊物品
一些物品是通过世界探索收集或发现的,而一些物品则是在游戏过程中专门进行交易的。如果你在游戏中设置交易系统,那么你需要提供交易机制。交易通常在与非玩家角色互动时进行,通常是与商人,并使用一个特殊的窗口来启用交易的互动。
通常,任何交易都会有一定的成本。一般来说,玩家在游戏中所做的任何事情都会产生成本,而且这种成本通常会提高或降低玩家角色的能力和/或经验。如果你深入挖掘,这可能会变得相当复杂。
需要牢记的主要观点是,玩家需要收集和/或管理的所有东西都将通过库存系统来完成。因此,这是作为游戏设计师和开发者需要投入大量精力的一项最重要的功能之一。
另一个可以用来增强玩家游戏体验并推动他们战略性地进行任务的因素是限制他们在库存中携带的物品数量。
例如,在现实生活中,战士携带不同类型武器的能力是有限的。让我们假设在现实世界中,战士在任何时候最多只能携带五种不同类型的武器。现在,在游戏世界中,可能会有 20 种不同类型的武器。你是允许玩家在发现它们时携带所有 20 种不同的武器,还是将数量限制为五种?
这些是当计划得当时会使游戏玩法更有趣的小东西。库存系统还有更多内容,我们将在后面的章节中更详细地探讨。
角色属性和动作
与任何其他 RPG 开发部分一样,角色属性和动作高度由游戏的故事情节定义。这些动作是在玩家命令角色执行特定任务时在游戏中间接执行的。
例如,在一个特定的 RPG 游戏中,至少会有几个角色类别。以下是一些示例角色类型:
-
野蛮人
-
兽人
-
魔法师 / 巫师
-
丧尸
-
人类
每个角色类别甚至可能有自己的子类别,具有自己独特定义的属性。再次强调,这将与你的 RPG 游戏剧情紧密相关。
例如,我们将有玩家角色,从技术上讲,他是我们故事和游戏中的英雄。英雄通常属于某个角色类别;让我们假设英雄是人类类别的一部分。
因此,人类类别或种族将具有一些特定的特征,这些特征将被玩家角色继承,或者任何其他相同类型或类别的非玩家角色继承。
注意: 角色类别和种族通常决定了游戏内角色的能力,进而定义了角色可以执行的动作类型。
游戏中角色的力量由其所属的角色类别和可以执行的动作类型定义。角色的表现由角色类别和种族中定义的属性值决定。
例如,如果我们比较两个不同的角色类别,例如人类和兽人,兽人将拥有远超人类的强大力量和蛮力。然而,人类可能拥有更高的智力和解决问题的能力,如果运用得当,可能会超过兽人的力量。请看以下截图:

这又是 RPG 设计师必须花费大量时间定义和指定游戏内角色设计和开发规格的关键领域。在设计和发展你的角色时,天空是极限,但你需要考虑一些关键属性,无论对于任何 RPG 都是必要的。
大多数 RPG 游戏允许玩家在游戏开始前或游戏过程中修改他们的角色。默认情况下,每个角色类别都将有一些默认属性,玩家可以根据某些修改器调整这些值。允许修改的基本基本功能包括角色的性别、类别或种族。
所有这些都取决于你在游戏制作过程中可用的预算和资源。在某些游戏中,你还可以将道德属性引入角色的特征中。例如,如果你允许在游戏中杀害或抢劫无辜的旁观者,那么玩家将不会受到友好非玩家角色的喜爱,他们可能不会像完成任务那样友好或有帮助。换句话说,你将根据你的行为承担后果!
作为最后的总结,角色类别定义了你的角色属性,从而决定了角色的优势和劣势。这些物理属性可以简化为以下几种:敏捷和力量,它们决定了角色在战斗中的表现!
经验和等级
为了吸引玩家并让他们对游戏产生兴趣,游戏设计师使用机制来增强玩家角色的表现。这种进步被称作等级提升或角色扮演游戏中的经验值。
等级提升和经验是任何角色扮演游戏的关键要素。任何角色扮演游戏都将定义一个良好的等级提升或经验树。这允许玩家通过游戏玩法来发展他们的化身,通过获得更多技能、点数和其他完成任务所需的资源,使他们在功能上变得更加强大。
能够获得新的武器、盔甲、服装以及/或游戏中定义的任何其他游戏物品,玩家需要在游戏中达到一些特定的阈值。这些阈值可以是玩家获得的经验点数、经济收益和/或战斗经验的组合。在设计这些层次结构或系统时,没有对错之分。你需要看到哪一种最适合你的特定需求,以及如何最好地应用它们。请看以下截图:

在角色扮演游戏中,玩家角色的进步是通过计算游戏设计师指定的某些定义属性来衡量的。通常,进步是通过玩家完成特定任务以获得经验点数来定义的,随着游戏的进行,任务和点数奖励会逐渐增加。然后,玩家可以使用这些经验点数来增强他们在游戏中的化身。
再次强调,这与故事情节、角色类别和/或玩家选择的种族高度集成。获得点数的一些常见方式是通过击败敌人、与非重要非玩家角色战斗,以及完成游戏中定义的任务。
就像现实生活中一样,你玩得越多,运用你的技能越多,你的经验就越丰富。经验的积累将使你能够获得更好的武器和/或盔甲,以增强你在下一次任务中的攻击或防御能力。有些游戏可能会给玩家 100+点数,并允许玩家将点数分配到他们化身可用的角色属性中。有时,游戏会自动将所有经验应用到特定区域,例如力量。
获得经验还将允许用户解锁更多功能和技能,以便在游戏玩法中获得。这是一种很好的游戏盈利方式。实际上,大多数免费游戏都使用这一原则。他们免费提供世界的基调和角色,并通过所谓的游戏内购买来盈利,以增加资源或角色表现。
这是如何实现的?就像库存系统一样,我们需要一种方法来跟踪玩家技能的进度。这通常是通过技能树来完成的。在树中学习或获得特定技能将解锁更强大的技能,并赋予玩家在游戏中使用这些技能的能力。
战斗系统
战斗时刻!这是每个玩家在他们的旅程中期待的时刻,无论是为了杀死 Boss、坏人还是邪恶的战争领主!每个 RPG 都内置了某种战斗或战斗组件,这是玩家能够使用他们所获得的所有技能和经验来摧毁对手或被对手摧毁的时候,这取决于当天的结果。
历史上,RPG 战斗系统主要有三种基本类型。你选择为你的游戏实现哪种战斗系统将对游戏玩法以及游戏的实现产生重大影响。
三种战斗系统如下:
-
传统回合制系统
-
实时战斗
-
带有暂停的实时
历史上,角色扮演游戏通常采用基于回合制的战斗系统。这种战斗系统的特点如下:在给定时间内,只能有一个角色行动。在此期间,所有其他角色都必须保持静止。换句话说,他们不能采取任何行动。这种战斗系统旨在更加重视奖励战略规划。
下一种类型是带有暂停功能的实时战斗系统。这种战斗系统也是严格基于回合制的,但有一个例外。如果玩家等待超过一定时间才移动或下达命令,游戏将自动将命令传递给另一名玩家。这将允许另一名玩家,即敌人,进行回合并攻击玩家。
在这本书中,我们将使用实时战斗系统。实时战斗从动作游戏中引入了功能,创造了一个混合动作 RPG 游戏类型。动作 RPG 战斗系统结合了角色扮演的 RPG 机制与动作游戏的直接、反应导向、街机风格的实时战斗系统,而不是 RPG 中更传统的战斗系统。
世嘉宫本茂最著名的身份是任天堂众多深受喜爱的角色和系列的创造者,包括马里奥、大金刚、《塞尔达传说》等。他也是任天堂触摸!系列游戏机的首席设计师,该系列包括任天堂 DS、Wii 和 3DS。
更多信息,请访问 www.giantbomb.com/action-rpg/3015-8592/。
用户交互和图形
问题是,我们如何向玩家展示我们的游戏世界?我们将为我们的游戏提供什么样的用户界面?我们将允许玩家以什么样的视角玩游戏?我们将设计一个俯视摄像机视角的世界吗?我们将创建一个等距视图的世界吗?或者我们将创建一个第一人称或第三人称视角的世界?
回答这些问题至关重要,因为当你设计游戏资产时,你需要了解它们在游戏世界中的呈现方式。例如,当设计你的角色和/或游戏中的 3D 模型时,如果你知道你将使用等距视图,那么你的建模方法将与设计用于第一人称或第三人称摄像机的情况不同。
在我们的游戏中,我们将使用第三人称摄像机视角来展示我们的世界。
接下来的问题将是如何以简单且有意义的方式向玩家提供关键信息。角色扮演游戏要求玩家管理大量信息,并经常使用窗口化界面来为玩家安排数据。这通常是通过抬头显示(HUD)来设计和实现的。请看以下截图:

HUD 经常用于同时显示多个信息,包括主要角色的健康、物品和游戏进度指示。你可以把 HUD 看作是用户在游戏过程中需要访问和交互的所有信息的入口点。
HUD 的设计对于角色扮演游戏至关重要。通常,有几个关键数据元素你希望在游戏过程中持续与玩家沟通,如下所示:
-
生命值
-
能量
-
耐力
-
激活武器
-
激活护盾
-
特殊物品
-
生命值数量
-
进入主菜单
-
进入物品栏
-
进入技能菜单
再次强调,你的 HUD 设计取决于你正在设计的游戏类型,以及玩家在游戏过程中需要获取的信息类型。请看以下截图:

由于大多数角色扮演游戏需要收集和存储大量玩家角色的数据,因此创建一个易于使用且简洁的 HUD(头部显示单元)非常重要。
设计 HUD 时,要记住的一件非常重要的事情是它永远不应该占据屏幕或成为干扰。通常需要多次尝试才能为你的游戏设计出一个出色的 HUD 设计,从最初的美术概念,到实际的实现和测试,以及从玩家那里获得反馈,以便在最终确定设计和内部工作原理之前。
最后,HUD 应该简化玩家的游戏体验,而不是使其更加混乱。如今,许多游戏正在远离传统的 HUD,更多地倾向于在游戏过程中提供电影般的或极其简化的体验。这使得游戏设计师能够将玩家沉浸于世界之中,而不是用持续的、静态的 HUD 来分散他们的注意力。
制作一个适合您游戏玩法和风格的 HUD 是基本的。虽然元素丰富的 HUD 可能对某些游戏来说很出色,但过于简化的 HUD 同样可以成功,甚至更成功。一切取决于您希望提供的玩家体验。因此,当您准备为您的下一款游戏制作抬头显示时,请确保您正在设计 HUD 以提升玩家的参与度,并且永远不要让玩家负担过重。
现有或即将推出的 RPG 游戏
这本书的这一部分将探讨市场上一些现有或即将推出的 RPG 游戏。这一部分的主要思想是为您提供多个 RPG 的参考,以及实施的游戏设计。研究现有或即将推出的游戏以获取您自己的灵感也是一个好主意!
MU 传说
这是一款流行的韩国 MMORPG 游戏的续作。MU2 是由 Webzen Inc. 制作和发行的游戏。游戏设定在神话中的穆大陆,正如詹姆斯·丘奇沃德假说所指出的,那里居住着古老的纳阿卡尔人类文明。
玩家们期待在这个令人惊叹的领域中寻找财富、冒险,以及追逐一些野兽。MU2 的游戏玩法围绕强大的战斗展开,遵循常规的砍杀机制。请看以下截图:

MU 传说
游戏讲述了光明之神鲁加德与被称为塞克纽姆的毁灭之神之间持续多年的战争。这场战争以阿克尼亚,至高创造者的手下的后者被打败而告终,但和平并没有持续太久,塞克纽姆获得了人类身体并回到了凡间。然而,伟大的法师昆顿看穿了堕落之神的花招,将他监禁在自己的身体里。然而,最终,他在塞克纽姆的力量下崩溃,变成了黑暗领主,加速了世界的终结。这就是玩家进入世界的地方,他们作为最后的智者伊卡鲁斯,作为德维阿斯骑士,在最后的努力中拯救穆免于即将到来的毁灭。
您可以在mulegend.webzen.com/en找到更多信息。
泰坦要塞
一款允许进行小规模交易的 MMORPG 梦想游戏。泰坦要塞的奇幻场景融合了北欧、希腊和埃及民间传说的元素。玩家扮演传奇人物,保护人类免受相互武装的泰坦集团侵害。旅程从角色创建开始——玩家可以挑选他的英雄的性别、外观和战斗细节。
游戏玩法扩展了开放世界探索,完成 NPC 分配的任务,与经验丰富的对手战斗,以及按照传统 MMORPG 的方式提升基本英雄技能。在整个游戏过程中,玩家可以获得新物品,如魔法卷轴和防护装备碎片,这些物品在战斗中帮助他们的角色。通过获得坐骑可以加速穿越广阔地区的速度。两个宠物和邀请的玩家可以帮助我们在战斗中。玩家还可以向我们发起决斗——胜利者获得倒下的传奇的装备。请查看以下截图:

泰坦要塞的故事背景设定在一个奇幻世界,其中北欧、希腊和埃及的传说元素交织在一起,形成了一个独一无二的场景。领域已经变成了战场,两个泰坦集团在这里相互武装。人类选择自己动手,宣布战争,目的是结束泰坦战争造成的混乱。人类武装力量由圣人驱动——这些圣人由玩家控制。
您可以在steamcommunity.com/sharedfiles/filedetails/?id=561159376找到更多信息。
塔城:火炼
由 Blue Isle 开发和发行的多玩家梦幻 RPG。该生成仅针对在线游戏,具有沙盒性质,允许玩家选择自己的职业道路。
玩家扮演魔法大师,利用他们的魔法学习来探索充满威胁和秘密的奇幻领域,旅行、建造堡垒和攻击车辆,以及挑战他人。玩家可以找到自己的家园,这些家园是组织的对应物,并建立自己的规则。然后,我们可以与附近的盟友进行类似团体的战斗,为了影响力、领土和附近的财富。游戏玩法机制由物理引擎控制,因此各种可用的魔法合理地影响地球上的物品。由于可以飞行的可能性,游戏更加有趣。请查看以下截图:

活动设定在一个名为 Ignus 的奇幻领域,给玩家一个机会扮演神秘的术士,他们开始寻找任务和辉煌。游戏发生在面积为 36 平方公里的地图上。它包括田野、茂密的森林、山脉、沼泽、冰冻的苔原和古老的废墟,这些地方隐藏着重要的财富和珍贵的古董。
你可以在www.citadelgame.com/找到更多信息。
网络朋克 2077
一款桌面网络朋克风格的科幻 RPG 游戏。这款游戏由 CD Projekt RED 工作室制作。故事带你进入 2077 年,包括实际上进步了,但被污染的无限宇宙。游戏设定在夜之城,这是网络朋克宇宙的粉丝所熟知的地方,那里阴暗的兴趣和秘密盛行。
在探索游戏的开放世界时,你将通过各种交流吸收复杂的多线索故事,这些交流通常会迫使你做出艰难的决定。游戏中的职业和角色移动框架、战斗展示以及许多游戏内扩展都受到了最初模拟框架的启发。游戏的视觉方面基于升级版的红色引擎。请看以下截图:

网络朋克 2077 对主题采取了强烈和发展的方法,从《银翼杀手》等作品中汲取灵感。标题本身带我们进入 2077 年,展示了一个无边的梦想世界,既恐怖又充满兴趣;标题的移动让我们置身于著名的夜之城,这是网络朋克体系的粉丝所熟知的。有趣的是,CD Projekt RED 工作室选择围绕开放世界游戏玩法模式来构建游戏,这允许玩家随时自由探索世界并完成支线任务。
你可以在www.cyberpunk.net/en找到更多信息。
RPG 中的模式
就像任何可能定义模式的工程项目一样,RPG 也可以利用 Whitson John Kirk III 在其名为《成功角色扮演游戏设计模式》的书中记录的类似模式。
注意:Whitson 受到了《设计模式:可重用面向对象软件元素》这本书的启发。他的目标是看看现有的 RPG 中是否存在任何特殊的模式,他通过检查该流派成功游戏中的特定模式来检测和识别它们。
在本节中,我们将探讨一些已经识别出的设计模式,这些模式可以用于你自己的游戏。
无论你开始什么类型的新项目,你都需要有一个清晰的想法,确切地知道你试图完成什么。这尤其适用于设计游戏。由于设计游戏包含许多不同的组成部分,你需要确定你的游戏将围绕什么展开。以下是一些开始思考的问题:
-
你试图实现什么?
-
你想要唤起什么样的情绪?
-
角色们做什么?
-
在多人环境中,玩家或玩家们会做什么?
-
你想奖励什么样的活动,你想提供什么样的奖励?
-
你的游戏针对哪个年龄组?
-
你的游戏会有序列吗?
-
游戏和故事是否会通过补充资产扩展?
这些都是影响你游戏设计的重要问题。当你阅读本章和本书时,请随身携带笔和纸,以便将所有闪现的想法写下来。这样,你可以跟踪所有想法,如果需要进一步扩展,可以在以后的时间进行。
术语
每个学科都有自己的术语。以下是在 RPG 游戏中使用的术语列表。花点时间研究它们是个好主意,以扩大你的词汇量或刷新你的记忆:
-
属性: 一个常见的特征,一个共性。
-
角色: 由玩家扮演的游戏中的人物,可能包括游戏主持人。
-
特征: 角色的一个方面。角色的名字、身高、年龄、美丽和力量是一些可能的特征。
-
常见特征: 游戏中给定类型所有角色共有的特征。角色的名字、身高、年龄、美丽和力量通常是常见特征。
-
冲突: 角色之间、玩家之间和/或游戏力量之间的争执,尤其是塑造游戏情节的争执。这包括两个或更多玩家之间关于应将哪些事实引入游戏世界的对立。
-
竞赛: 通过机械手段解决的冲突。
-
派生属性: 其值由公式确定的属性。通常,该公式使用其他属性值来生成一个数字。
-
剧情: 基于故事考虑的纯粹结果。剧情中的结果完全由对参与者来说最有趣的内容决定。
-
缺陷: 一个特定的、不是量度的选择特征。一个角色要么有缺陷,要么没有。缺陷在结构上与天赋非常相似。但是,缺陷通常被认为对角色有害,而不是有益。
-
幸运: 至少部分基于随机因素的结果。这可能包括掷骰子、抽牌或某些其他随机值生成器。
-
游戏主持人: 传统的游戏中,玩家被分配职责来管理游戏流程。在电脑角色扮演游戏中,游戏主持人(GM)是使一切紧密结合的粘合剂。
-
量表: 通常与名称相关联的分级值。常见的分级值是数值。
-
赠品:一个特定的特征,它不是一个量表。一个角色要么有赠品,要么没有。一般来说,赠品被认为对角色的福祉有益。
-
因果:基于非随机值比较的结果。基于因果的竞赛直接比较两个值以确定结果。
-
非玩家角色(NPC):游戏主持人作为角色扮演的一部分所扮演的任何角色。
-
可选特征: 不是给定类型所有角色都共有的特征。
-
玩家:任何参与角色扮演游戏的人。
-
玩家角色(PC):任何玩家扮演的角色,但不担任游戏主持人角色。
-
主要属性: 其值直接由玩家设置,而不是通过其他属性的公式推导出来的属性。通常,主要属性用于公式中,以确定派生属性的价值,但它们自己的值不是由公式确定的。通常,它们是通过随机数生成或通过消耗一些资源来设置的。
-
等级:量表技能、障碍或排名特质的特定值。在描述此类技能和特质时,也用作形容词,代替量表。
-
排名特质:也是一个量表的特质。
-
选择特征: 从预定义的选择列表中选取的特征。
-
共享量表:许多角色共享的量表。
-
技能:也是一个量表,并且通常被认为对角色有益的特征。
-
特质:玩家自己创造的特征,而不是从预定义的选择列表中选取。
为了更好地理解属性和特征之间的关系,我们制作了一个视觉辅助工具来解释它:

竞赛树
竞赛树的目的在于提供一种机械手段,在游戏中创造上升的紧张感。这也被称为升级冲突。
竞赛树是由许多级别竞赛组成的、以分层方式排列的高级冲突解决系统。它们的工作方式是,低级别竞赛为高级别竞赛提供输入,从而影响高级别竞赛的结果。
换句话说,高级别的挑战可能是击败大 Boss,但在你到达大 Boss 之前,可能会有其他小战斗需要完成,而小战斗的结果将决定大战斗的结果。一个简单的例子就是在到达主要 Boss 之前获得的经验点数。
由于高级比赛与低级比赛在某种程度上有关联,玩家确实会关注低级比赛的结果;因此,当低级比赛成功或失败时,关于高级比赛最终成功或失败的压力就会产生。
当你想在游戏中创造上升的紧张感时,最好使用竞赛树。这可以通过在玩家通过不同级别时应用不同的机制来实现。在 cRPG 中创造悬念非常简单,因为你有很多控制级别和游戏玩法设计的方式。由于我们有能力按照自己的喜好创建 3D 世界,因此将悬念融入游戏会很容易。
在你的游戏中创造紧张感的一些关键点如下:
-
英雄和敌人应该实力相当。
-
英雄和敌人应该定期在他们的尝试中失败,前提是他们是有价值的对手。
-
英雄和敌人的成功与失败永远不会如此之大,以至于任何一方都失去了实现高级目标的所有希望。
-
关于竞赛树的一个亮点是,它只能解决涉及系统机械输入的高级冲突。也就是说,如果伤害和剩余生命值是作为冲突解决输入的唯一度量标准,那么机制只能解决涉及伤害和生命值的问题。为了设计一个灵活的竞赛树,你需要考虑输入和输出。
最后一人幸存
“最后一人幸存”冲突系统提供了一个通用的竞赛树来解决在战斗中哪一方获得胜利。
“最后一人幸存”也是通用竞赛树的最传统形式之一。该模式背后的基本思想是通过谁成功摧毁对手的事实来识别赢家。做到这一点的人就是赢家。这也是实现竞赛树的最简单方法之一。
当战术战斗被高度重视时,就会使用它。请记住,你不必使用其中任何一种模式。你可以很好地将几种模式结合起来,你应该这样做,以使其更有趣。例如,如果你的游戏非常注重战斗,但你还想将一些谈判引入冲突解决中,你完全可以这样做。再次强调,这完全取决于你和你的游戏设计。
需要记住的是:如果解决高级比赛的唯一方式是通过战斗,那么玩家将集中精力在尽可能赢得战斗。换句话说,如果一个游戏只提供一种解决争端的方法,那么你可以确信玩家将非常熟练和专注于使用该工具。
谈判竞赛
谈判竞赛提供了一种机械手段来解决争端,其中玩家和非玩家角色具体协商冲突的输入和可能的结果。
设计和开发协商竞赛机制相当复杂。为了使该模式正常工作,你需要考虑所有协商的输入和输出。开发此类系统的挑战并不仅仅是实际的技术实现,而是基于玩家可用的选择和每个输入的结果,你必须创建并保留的数据库。
机制的实施可以非常简单,仅涉及几个选项和结果,也可以非常复杂,涉及多种选项及其最终结果。重要的是,在冲突引入后、任何行动采取之前,你必须引入一个协商机制!结果显然是基于冲突前的协商,关注的是胜负。
注意:协商竞赛模式要求在冲突通过机制解决之前,允许玩家协商成功和失败的效果。
当你的设计目标包括以下一个或多个时,请使用协商竞赛模式:
-
希望明确决定竞赛的结果意味着玩家是否赢得了他声明的目标,而不是他的角色是否成功执行了离散的行动。
-
需要将竞赛的解决尺度扩展到不同于单个行动的粒度级别。
-
愿意允许玩家在描述竞赛结果(无论是好是坏)方面拥有很大的叙事自由。
对于计算机角色扮演游戏,协商竞赛将有一些限制,因为我们无法承担创建一个开放式的 AI 系统的费用。但我们可以设计一种更简单的方法,让玩家在游戏中对协商有一定的控制感。
协商可以是一种很好的交换信息的方式,在角色扮演游戏中与非玩家角色一起使用。协商模式有三个部分,如下所述:
-
启动:这是将角色行动引入游戏世界的过程。
-
执行:这是确定角色行动成功或失败的过程。
-
效果:这是确定角色行动结果的过程。
在设计协商系统时,以下是一些需要考虑的问题:
-
胜利者能得到什么?
-
败者能得到什么?
-
我们如何知道谁是胜者,谁是败者?
-
在解决开始之前,我们需要建立什么,以及如何建立?
概述
在本章中,我们详细介绍了角色扮演游戏。我们简要地覆盖了该流派的历史方面,并探讨了不同类型的 RPG。我们讨论了设计角色扮演游戏的关键要素,并提供了一些示例以示说明。
我们探讨了 RPG 的特点,并讨论了如何规划你的游戏,例如故事和背景,游戏内的探索和任务,不同的库存系统,角色发展,用户交互,以及一些战斗系统模式。我们还介绍了一些在 RPG 中使用的术语。
到本章结束时,你应该对需要准备的内容以及制作角色扮演游戏所需付出的努力有一个清晰的认识。
在下一章中,我们将开始规划我们 RPG 的设计和开发。
第二章:规划游戏
在上一章中,你得到了 RPG 的良好概述和历史背景,也许这也激发了你想象力。在本章中,我们将开始为我们自己的 RPG 打下基础。我们将从定义我们游戏的故事、情节和使游戏可玩性的任务开始。我们将查看创建我们的环境、角色所需的资产,最后,我们将设计关卡。
以下是我们将在本章中讨论的主题概述:
-
构建我们的 RPG
-
Zazar 王朝的故事
-
资产清单
-
关卡设计
-
苏醒
-
测试关卡
-
创建主菜单
激发你的创造力,让你的想象力自由驰骋!
构建我们的 RPG
正如所讨论的,构建一款角色扮演游戏并非易事,但一旦你踏上这条路,你就会意识到它并不像最初看起来那么困难。关键是开始行动,随着你将想法写在纸上并开始设计过程,越来越多的想法将变得清晰。
正如我们所学的,我们需要为我们的 RPG 建立一些关键元素。让我们回顾一下,也许在过程中还可以对它们进行微调。
我们游戏的关键元素如下:
-
故事和设定
-
探索和任务
-
项和库存
-
角色动作和能力
-
经验和等级
-
战斗系统
-
用户界面
Zazar 王朝的故事
大多数角色扮演游戏的基本前提是让玩家拯救世界。随着故事的发展,常常会有意想不到的转折,比如出现疏远的亲戚,或者敌人变成朋友,反之亦然。我们将基于这样的故事创建我们的故事和游戏。
前传
从前,有一个伟大的王国,由伟大的国王 Zazar 统治。王国的统治者对他的臣民慷慨大方。在 Zazar 的统治下,王国和平繁荣;然而,随着时间的推移,内部的家族争斗和斗争在维持王国完整性的强大纽带中造成了裂痕。
由于神秘事件,伟大的国王决定将他的家人从王国中搬走,并信任他的儿子——将成为玩家角色——以及他信任的一位智慧长者。王国从此不再相同...直到现在!
探索和任务
现在我们已经定义了游戏的环境,我们可以开始进一步发展故事,并将其分解为不同的关卡。为了简单起见,我们将专注于基本的任务和关卡设计;重要的是要理解这些概念并将它们应用到自己的故事中。
苏醒
游戏将从让玩家沉浸在我们英雄被长者抚养和训练的环境中开始,这位长者是伟大的国王 Zazar 所信任的。
本关的主要目标将是让玩家与环境互动,学习如何与周围环境互动,如下所示:
-
目标:
-
向玩家介绍用户界面
-
移动角色
-
与非玩家角色互动
-
与环境互动
-
-
结果:
-
玩家通过完成游戏任务获得积分
-
玩家获得他的第一件武器
-
玩家学习如何与周围世界互动
-
-
总体草图:

村庄
我们的英雄开始了他的自我实现之旅。他将旅行在王国的外围,到达一个被邪恶领主沙基尔雇佣的暴徒和雇佣兵恐吓过的村庄。
我们的英雄,他自己并不知道他是谁以及他为什么在这个旅程上,将会了解到自从他父亲离开以来一直在进行的节俭。这主要将通过与村民的互动来完成。
这个级别的首要目标将是让玩家学习社交技能,与村民互动,并建立关系。
有传言说村子里有间谍,每个人都互相怀疑,曾经是村庄力量的团结正在瓦解。我们角色的目标现在如下:
-
目标(s):
-
与村民互动以获得社交技能
-
在英雄和村民之间建立信任
-
在村民中寻找间谍是谁
-
-
结果:
-
提高社交技能
-
可以在以后某个时刻利用的关系
-
学到了基本的战斗技能
-
-
总体草图:

破碎的森林——地平线
我们的英雄将沿着他的任务之旅前往地平线。地平线是首次接触主要王国的边界,那里主要城堡和内城都在可触及范围内。
这基本上是一个广阔、茂密的森林,保护着王国的主体免受外界威胁。它也为那些未经世事的过客准备了一些秘密和惊喜。森林是野蛮人居住的地方,他们在周围地区制造混乱。当时不明显的是野蛮人和王国当前领主之间的联系。
就英雄而言,他或她需要能够安全地穿过森林。
地平线将为英雄带来几个意想不到的惊喜。任务的结局将严重依赖于玩家与周围环境以及非玩家角色的互动方式。
现在,我们英雄的目标如下:
-
目标(s):
- 穿过森林而不被杀死
-
结果(s):
-
英雄可以被野蛮人俘虏
-
英雄可能会面临其他生命威胁的场景和/或非玩家角色
-
英雄成功穿过森林并准备好迎接下一个挑战
-
英雄建立新的关系以增强他的技能和能力
-
-
总体草图:

王国
英雄已经完成了之前的任务,现在准备好击败邪恶的领主并夺回他应得的一切。在整个任务中,我们的英雄已经进步并获得了大量的技能和能力,现在他将承担游戏中最困难、最史诗般的战斗之一。
我们的英雄被领主的庞大军队惊呆了。他需要想出一个办法穿过城市进入主城堡以击败敌人。他的目标如下:
-
目标(s):
- 杀死领主并夺回他的王国
-
结果(s):
-
在游戏过程中建立的关系中发出行动号召
-
使用他的谈判技巧和智慧来欺骗更强大的敌人
-
消灭敌人
-
-
一般草图:

啊,击败你的宿敌并接管你的王国是多么纯粹的快乐!
资产清单
现在是讨论我们 RPG 开发所需的一些基本资产的好时机。我们的游戏资产由我们为游戏描述的场景定义。对于我们的 RPG,我们已经描述了四个独特的场景。每个场景都描述得足够详细,让我们能够了解我们需要哪些类型的资产。
环境资产
我们游戏的主题将是中世纪。有几种方法可以做到这一点。首选的第一种方法是自己或与队友一起创建环境模型;第二种是找到第三方创建的免费模型;第三种是购买第三方创建的 3D 模型。
如果您没有能力创建自己的 3D 模型,资产商店是您开始寻找优质内容的好地方。您可以使用资产商店搜索可用于游戏的具有中世纪主题的环境。
我最喜欢的之一叫做 Medieval Environment Pack。您可能还想考虑搜索一些您喜欢的更多类似资源。
以下是一些您在环境资产中需要考虑的事项:
-
建筑
-
配件和附加功能:
-
旗帜
-
木桶
-
窗户
-
盒子
-
四轮马车
-
-
岩石/植物/树木
-
粒子资产:
-
火
-
雾
-
烟雾
-
水
-
-
天空盒
这个列表只是一个起点,但它是您环境资产的一个起点。
我正在使用以下环境资产作为本书的演示,但您完全可以选择任何其他您想要的套餐。
Medieval Environment Pack
如果您认真想要制作一款具有高质量图形的 RPG,这是一个非常好的套餐。它非常全面,非常适合满足您对中世纪或幻想场景的环境需求。
该套餐包括以下内容:
-
70 个模块化建筑
-
180 个附加组件 – 小屋、木桶、手推车等等
-
25 种岩石形态
-
粒子
-
纹理
它有点贵,但如果您没有 3D 建模的资源,那么它物有所值。这个套餐非常推荐。如果您正在制作桌面或控制台 RPG,那么它非常棒,但不适合移动游戏。
请查看以下截图:

中世纪环境资产
地形工具包 2017
此工具包是 Unity 中地形生成的优秀实用工具。它来自 2009 年 Unity 夏日代码,由 Sándor Moldán 制作。工具包已升级,可在 Unity 5.x 及以上版本中使用。它是原始工具(V1.0.2)的增强。如果您一直在跟随本书的第一版,您还会注意到工具的实际用户界面有重大改进。以下截图显示了地形工具包资产:

地形工具包资产
自然入门包 2
自然入门包 2 是一个包含与内置树生成器兼容的树木和灌木的出色资产。这使您能够生成各种新的变体!该包包含以下内容:
-
六棵灌木
-
四棵树
-
地面纹理
-
草地纹理
-
天空盒
以下截图展示了如果您使用此资产,您的游戏自然环境的样貌:

自然入门包 2 资产
角色资产
角色扮演游戏(RPGs)高度依赖于角色。因此,下一个重要的游戏资产将是角色本身。您需要为游戏定义的模型与您的剧情和设定密切相关。资产商店提供了丰富的角色模型,您可以下载并用作您游戏概念验证。
对于我们的游戏,以下是需要的角色:
-
人类:这些将代表英雄以及村民和其他非玩家角色类型的人类
-
巴比伦人:这些是英雄在游戏过程中必须面对的一些角色
-
兽人:这些是它们自己的动物
您可以选择免费模型或付费模型来代表您的角色。我们将在未来的章节中更深入地探讨角色资产。
我一直在与一个名为 Polygon 的创作者合作,为书中演示将使用的特定模型工作。这些是很好的角色,可以帮助您开始。您可以在资产商店中查看它们。
巴比伦人
巴比伦包包含 15 种独特的纹理。每个模型从 1,700 到 3,000 个三角形(无装备到全装备),包含超过 20 个动画。它们是 Mecanim 就绪的,并包括面部绑定。您可以在以下截图看到一些巴比伦人的示例:

巴比伦资产
兽人
兽人包包含独特的模型、配件、盔甲和武器。每个模型从 1,600 到 2,500 个三角形(无装备到全装备),包含 28 个动画。它也适用于 Mecanim。请查看以下截图:

兽人资产
村民
此包提供了一群可供使用的中世纪人类。非常适合填充物和非玩家角色。它们可以被用来在英雄进展故事中整合。它们的总多边形计数从 1,500 到 2,000(无装备到全装备),并且是带有 20 个动画的全装角色。请参阅以下截图:

村民资产
免费资产
最后,你还有能力从资产商店获取大量免费资产。其中一些质量确实很好。然而,如果你正在寻找特定类型的资产,你可能需要花一点钱:

顶级免费包
关卡设计
现在我们已经将游戏故事写在纸上,并且有了我们想要实现的想法,是时候将我们的技能应用到实际制作中去了。
由于本书的目标读者已经熟悉 Unity 的基础知识,因此我们不会涵盖软件的基本方面。
要开始,我们需要启动 Unity。我使用的是 Unity 2017.x Pro 的 64 位版本。你不需要拥有 Unity 的 Pro 版本来完成本书中的项目。请参阅以下截图:

选择你想要的地点和项目名称,然后点击创建项目按钮。此时,Unity 将为你创建一个空项目并显示 Unity IDE。它应该看起来像以下截图:

新项目
为了提高图像质量,请从www.packtpub.com/sites/default/files/downloads/BuildinganRPGwithUnity2018_ColorImages.pdf下载图形包。
你的视图可能略有不同,这取决于你如何配置你的 Unity 布局。如果你是第一次启动 Unity,你需要熟悉基础知识,因为我们不会在本书中涵盖它们。
注意:如果你之前没有使用过 Unity,你应该在继续阅读之前熟悉 IDE。
准备阶段
我们首先想做的事情是为我们的第一个关卡创建一个景观,这个关卡被称为觉醒。
Unity 本身有一些创建地形的良好工具,但说实话,这不是实现游戏中的漂亮、美丽地形的一种实用方法。为此,我们将使用另一套工具,称为 Terrain Toolkit 2009,这是由Sánder作为Unity Summer of Code 2009的一部分开发的。
工具包可在资产商店中找到:
www.assetstore.unity3d.com/en/#!/content/83490.
我还把库作为本书提供的下载的一部分包含在内,以防原始链接在未来被弃用。
一旦你从资源商店下载了资产,你需要将其导入到项目中。如果你还没有这样做,只需点击导入按钮开始导入包:

在这一点上,让我们花点时间回到 Unity 中,实际上创建一个Terrain游戏对象,并查看地形修改的内置工具。要创建地形,你需要从主菜单中选择以下选项:游戏对象 | 3D 对象 | 地形。这将创建一个默认的地形在你的场景中,应该看起来像以下这样:

地形演示
当你在层次窗口中选择地形游戏对象时,你会看到检查器窗口显示通过设计器可访问的地形游戏对象的属性和组件。正如你所看到的,有很多属性你可以修改,通过这样做可以创建一个看起来很不错的地形。当你开始尝试使用地形工具时,你很快就会意识到它对于大型地形模型来说并不实用,对于自然外观的地形来说也是如此。
为了增强我们的地形生成,我们将使用Terrain Toolkit 2017。如果你还没有这样做,你需要将包导入到项目中。如果你还没有从资源商店下载该包,请继续下载,然后将其导入到项目中。
有时候当你导入较旧的 Unity 资源时,Unity 会提示你自动升级到最新版本。这通常是可以接受的,所以只需接受它,让 Unity 做它需要做的事情。
当 Unity 导入 Terrain Toolkit 2017 时,你会在项目窗口下注意到一个新的文件夹,名为 TerrainToolkit。如果你想要对其做任何修改,所有代码都将列在该文件夹下。还有一个readme文件,你可以用它来开始。请参见以下截图:

你还会注意到,在组件菜单下添加了一个新的Unity 编辑器功能,名为地形 | 地形工具包。要应用地形工具包到现有的地形上,你只需要选择该选项,它将自动为你将正确的组件附加到地形游戏对象:

如果你已经使用过较旧的地形工具包版本,你会立即看到用户界面上有显著的改进。
你会注意到一些现在通过地形工具包可以使用的选项,用于生成更自然和逼真的地形。你应该花时间熟悉每个属性,并尝试不同的值,以了解它们如何影响地形生成算法。
新的工具包预定义了可以用于快速开始的地形模型。以下是可以用的模型列表:
-
沙漠高原
-
沙漠沙丘
-
海岸
-
雪山
-
潘帕
-
肉体之路
我将让您自己尝试它们,并亲自看到结果。一个提示是,将您的地形属性从原始值调整一点,以获得更好的视觉效果。
地形工具包概述
工具包中为地形创建提供了一些预定义的生成器。它们是 Voronoi、分形和 Perlin。以下是对每个的简要说明:
-
Voronoi:这使用 Voronoi 图创建一系列山峰状峰顶的任意高度轮廓,并将其应用于地形抗议。
-
分形:这产生了一个云或等离子体分形计算的任意高度轮廓,并将其应用于地形抗议。
-
Perlin:这生成一个不规则的 Perlin 噪声高度轮廓,并将其应用于地形抗议。
在地形生成之后,还可以应用两种过滤器类型。这些是平滑和归一化过滤器,如下所示:
-
平滑:这是一个通道,它多次对地形抗议应用平滑处理,跨越多个重点。
-
归一化:这是一个标准化领土抗议的通道,通过将当前景观高度指南中最惊人的点设置为最大值,最小点设置为基准值。其他所有点都添加在最大值和最小值之间。
下一步是对地形应用一些侵蚀。工具包中内置了三种侵蚀类型:热侵蚀、水力侵蚀和潮汐侵蚀。您可以通过画笔或实际的侵蚀过滤器来应用这些侵蚀类型:
-
热侵蚀:这从倾斜度大于基准倾斜度的地区移除材料,并将其存储在斜坡下方。这倾向于使地形的斜坡变得平滑和笔直。
-
水力侵蚀:这从倾斜度小于最大倾斜度的区域移除材料,并将其存储在斜坡下方。这倾向于使地形的斜坡变得更加陡峭,并进一步平滑和笔直其他区域。
注意:有三种不同的水力侵蚀类型。
- 潮汐侵蚀:这将在选定的海平面处应用平滑处理,除了那些倾斜度超过给定值的地区。这模拟了波浪在海岸线周围的侵蚀活动,并形成海岸线。
最后一步是应用纹理。这将使我们的地形在运行时具有更真实的外观和感觉。工具包提供了程序化地形纹理,它自动使用地形的坡度和高度属性来确定将使用哪种纹理。
苏醒
我们第一级的设置和氛围将位于森林中的一个隐蔽区域。我们现在将使用上一节中讨论的“地形工具包”来生成我们的地形。
让我们创建一个新的场景,并将其命名为觉醒。默认情况下,场景将只定义一个相机和一个方向光游戏对象。
您可以在资产文件夹内保存您的场景和资产,无需过多思考。然而,通常有一个文件结构是很好的主意,这样可以使您的资产组织更简单,以及更快地找到它们。
一个首选的文件夹结构将包括场景、预制件、纹理、音频和模型。在每个文件夹内,您可以根据自己的组织需求创建子文件夹等。
现在我们准备将地形游戏对象添加到场景中。前往 GameObject | 3D Object | Terrain。这将在地形中放置一个地形游戏对象。在层次窗口中双击地形游戏对象,使其成为场景视图的中心。
默认情况下,Terrain对象将非常大,所以在我们做其他任何事情之前,让我们先做一些调整。以下截图显示了如何访问设置:

要调整地形大小,请选择设置图标,如图中所示。这将显示地形的基本属性。正如您所看到的,有许多属性可以调整以使其符合您的喜好。我们主要关注地形的尺寸以及地形可以升高的最大高度。因此,向下滚动直到到达分辨率部分。
将地形宽度和地形长度更改为 100。将地形高度更改为 333。这将改变尺寸,以便我们可以轻松处理场景。我们原始的地形大小非常大,设计它将花费我们很长时间。
现在我们有一个合适大小的地形。假设您已经导入了地形工具包,请前往主菜单中的 Component | Terrain | Terrain Toolkit。
我们有两种方法可以处理这个问题。由于新的工具包为我们提供了一些预构建的地形模型设置,我们可以使用这些设置快速生成随机地形,或者我们可以使用默认方法生成我们自己的自定义地形。
使用地形模型
选择地形游戏对象,并使用检查器窗口进入地形工具包,选择地形模型标签,如图所示:

如您所见,我们有六个预配置的地形模型可以使用。让我们尝试使用 SNOWY MOUNTS 模型。点击它,Unity 将开始生成模型。
这就是我的地形模型看起来像:

看起来相当酷。现在让我们尝试使用自定义函数创建地形。
使用自定义工具包
使用工具包生成随机地形时,我使用了 Delta 为0.4和 Blend 为0.445的分形地形生成器功能。这将生成一个看起来很漂亮的地形,山丘和山谷的比例很好。由于地形是随机生成的,你的可能不会完全像我的一样,但应该看起来类似以下截图:

我通常在生成地形后应用平滑过滤器,使事物看起来更加美观。
你一旦应用了过滤器,就会看到它带来的差异。让我们继续,现在应用一些纹理让它看起来更漂亮。在地形工具包中选择纹理选项卡,你将有多项选择。我们希望至少应用两种纹理,以使地形看起来更逼真。如果你选择的话,可以应用多达四种纹理!点击添加纹理按钮两次以创建纹理占位符。
由于我使用了地形模型来生成原始地形,然后应用了提到的工具包设置,模型的默认纹理已经就位。你可以根据需要更新它们。
看看下面的截图:

在图形学,尤其是游戏中,纹理非常重要。你的纹理越好、分辨率越高,你的场景看起来就越好。然而,这是一个两难的问题。通常,更高分辨率的纹理会占用更多资源。因此,你必须为你的游戏找到合适的平衡点。
现在是停下来讨论 Unity 主要优势之一的好时机:资产商店。资产商店是一个优秀的在线社区,Unity 开发者可以在这里获取用于游戏的资产,或者开发其他开发者将使用的资产。你可以从资产商店免费获取资产,或者花一点钱购买更高品质的资产。
对于我们的游戏,我将使用一些免费资产和一些付费资产。如果你想使用我在书中使用的相同资产,你需要购买它们。
看看下面的截图:

生成的地形样本
我接下来要做的事情是在地面上找到一个位置,我将在这里创建必要的场景对象以进行关卡演出。对于这个特定的场景,我想使用一个代表森林中古老小屋的资产,英雄将在游戏开始时在这里醒来。看看下面的截图:

这是一个来自中世纪环境包的模型。这个模型没有内部结构。这没关系,因为我没有计划在结构内部进行任何游戏玩法。它将被用作场景中的一个引人注目的对象和参考点。
你可以使用地形工具在将对象放入场景之前对地形进行平整。如果你注意到,我们的地形没有任何可以适当放置小屋的水平区域。我们将需要使用地形对象的地面组件进行一些更改,如下面的截图所示:

再次,在“层次结构”视图中选择地形 GameObject,并使用“检查器”窗口选择“绘制高度”选项卡,如图中所示(1),以启用该功能。
这是一个在特定点采样地形高度并使用画笔将相同高度应用到任何其他区域的好功能。这将使地形达到采样的高度。如果这些物品需要位于平坦的地面上,如房屋或避难所,这是一种快速平整区域并放置物品的绝佳方式。
下一步是在我们的地面上放置一些树木模型,以创建森林的外观和感觉。为了实现这一点,我们需要在场景中选择地形对象,并使用检查器窗口选择“树木放置”功能。然后您需要选择“编辑树木...”按钮来添加一个树木模型,如图所示:

使用检查器窗口,您需要选择“树木放置”选项卡,选择“编辑树木... 添加树木”功能,定位一个树木预制件,然后您就完成了!
看看设置,并将画笔大小调整到满足您的需求。在我的情况下,我将其调整为 40。我将其他属性保留在默认值,但您可以根据需要更改它们。
现在,当您在场景视图中移动鼠标时,您会注意到一个像画笔一样的突出显示。这就是在设计时树木将被放置的位置。
以下截图显示了我在调整了树木和建筑物的位置后,场景看起来是怎样的:

下一步是填充关卡以包含其他环境资产,如岩石、植被和其他道具,使关卡生动起来。这里的想法是使其既有趣又实用。有一个关卡设计的草图是个好主意。这样,你可以有一个很好的想法,了解你将如何开发你的关卡。
请记住,这也可以作为与您的团队、关卡设计师和艺术家沟通的一种方式,为他们提供方向。以下图是我们要设计的关卡设计的俯视图:

墓地
我现在将根据我已有的布局来构建我的关卡。现在是时候发挥创意,运用你的想象力来设计你的关卡了。这个练习的部分是自由形式的;你是设计师,所以你将决定如何放置和创建你的关卡,只要它符合要求。
请记住以下重要的一点:玩家将在指定的兴趣点与环境或非玩家角色互动。确保在设计关卡时,他们能够轻松访问他们需要到达的区域,以便他们可以执行给定的任务。
看看下面的截图:

关卡设计
需要注意的一点是,我们在这里定义的地形限制。当玩家走到地形边缘时,他们会掉下去!是的,他们会永远自由落体!我们不希望这种情况发生。我们需要在关卡设计中加入一些边界,以防止玩家越界,基本上。
在这种情况下创建一些限制和边界非常简单。我们可以使用木栅栏,或者我们可以使用实际的环境来限制玩家对关卡中危险区域的访问。如果你的关卡很大,这种方法可能会非常耗时。
解决这个问题的另一种方法是创建四个碰撞器,这些碰撞器将用于地形的每一侧。这些碰撞器将附加到一个空 GameObject 上,并在接触时阻止玩家前进。这是一个简单的方法,放置在场景中所需的时间会更少。
接下来,通过选择 GameObject | 创建空对象来创建一个空 GameObject。使用检查器窗口,调整 Transform 组件将 位置 设置为 <100,50,100>。接下来,我们需要为新建的 GameObject 添加组件。使用添加组件按钮选择 Physics | 箱体碰撞器。这将添加一个箱体碰撞器,现在我们可以使用检查器窗口更改碰撞器的 大小,设置为 <200,50,1>。
一旦你对外观和感觉满意,你需要复制它并将其放置在所有侧面边缘,以防止玩家掉落,如图所示:

关卡设计
天空盒
由于我们已经有了初步的关卡设计,我们可以继续添加另一个不错的细节。让我们使用天空盒来给游戏玩法带来更逼真的大气效果。
和往常一样,你可以使用资产商店搜索现有的天空盒。搜索你喜欢的天空盒并将其导入到你的项目中。
在层次结构窗口中,选择 主摄像机 并使用添加组件按钮将天空盒组件附加到摄像机:渲染 | 天空盒。将天空盒材质分配给 自定义天空盒 属性。查看以下截图:

添加天空盒
是时候进行测试运行了。我们可以使用标准资产中提供的第三人称角色控制器,快速放置我们的角色占位符并在关卡中四处游走,以获得对关卡的感觉。
测试关卡
在某个时候,你可能想要测试关卡并从摄像机的视角查看。我们可以使用标准资产中的内置第三人称角色控制器,快速在关卡中走一圈。
如果你创建项目时没有导入标准资产,你需要通过选择资产然后选择导入包 | 角色,来导入它们。
在您的项目窗口中,您将看到一个名为 Standard Assets 的文件夹;有一个名为 Character Controllers 的子文件夹。您需要选择 3rd Person Controller Prefab 并将其拖放到当前场景的某个位置。一个不错的选择是在小屋旁边。确保第三人称控制器(3rdPC)在地面之上,以免掉落!
您需要将 Rigidbody 组件附加到 3rdPC GameObject 上。这是确保我们的玩家角色(PC)使用内置的物理进行碰撞检测所必需的。
在您运行关卡之前,让我们确保相机正在跟随角色。只需将主相机设置为ThirdPersonController的子对象,并调整相机的方向,使其看起来像以下这样:

标准角色控制器
现在运行关卡并遍历场景。测试并确保当你在环境中导航时,PC 的行为符合预期。确保角色尊重所有碰撞体,并且在设计部分没有小错误。
创建主菜单
现在是创建游戏起点的好时机。保存当前场景。我们将创建一个新的场景,该场景将用作游戏起点。要创建新场景,您需要选择文件 | 新场景。保存场景。我给我的场景命名为 MainMenu。
现在我们有一个清晰的画布,我们可以用它来创建我们的主菜单。在层次结构窗口中右键单击,选择 UI | 面板。这将创建一个 Canvas GameObject 和一个 EventSystem GameObject,并将它们放置在层次结构窗口中。你会注意到面板UI 对象是画布的子对象。所有 UI 元素都将是一个画布的子对象。你的层次结构应该看起来像以下截图:

我们想要确保设置正确的一些关键方面。这些主要是在 Canvas GameObject 上。选择 Canvas GameObject 并查看检查器窗口。
对于这个特定的画布,请确保将渲染模式设置为屏幕空间 - 叠加。接下来需要检查的属性是 UI 缩放模式。将其更改为与屏幕大小缩放。这将确保 UI 始终缩放以适应运行游戏的设备的屏幕大小。
为了获得最佳效果,您可能需要为不同类型的设备创建多个菜单。
现在,让我们创建一个按钮,它将基本加载我们的觉醒关卡。
在层次结构窗口中右键单击面板对象,选择 UI | 按钮。这将在一个按钮上放置画布,作为Panel对象的子对象。在构建用户界面时,考虑父子关系非常重要。当你将 UI 元素作为另一个 UI 元素的子元素放置时,子元素将根据父元素的缩放和位置进行缩放和移动。

你可以在《游戏编程入门:使用 C#和 Unity 3D》的第五章 5,“游戏大师和游戏机制”中了解更多关于 UI 开发的内容。最终,我们希望有一个类似于前一张截图的主菜单。
我们将在未来的章节中花费时间微调我们的菜单。将按钮的标题更改为开始游戏。将场景对象适当地命名也是一个好主意,以保持事情整洁有序。我已经将按钮的名称更改为butStartGame。这可以通过在层次结构窗口中选择Button对象并在检查器窗口中更改名称来完成。
创建 GameMaster 脚本
如同在第一章 1“什么是 RPG?”中讨论的那样,我们需要一种管理游戏的方式。我们将创建一个名为 GameMaster 的脚本。这将是我们游戏的基石,将一切粘合在一起。随着我们继续阅读本书,你将看到我们将如何修改核心以适应我们的需求。
现在,我们只是创建一个简单的 C#脚本,并将其命名为GameMaster.cs。然后我们将创建用于处理我们想要在此阶段执行的一些基本事件的代码,即从一个场景导航到另一个场景。
在你的项目窗口中,在scripts文件夹下,右键单击并选择创建 | C# 脚本。将其命名为GameMaster.cs。双击你的脚本以启动代码编辑器,并将以下代码放入其中:
using UnityEngine; using UnityEngine.SceneManagement; namespace com.noorcon.rpg2e {
public class GameMaster : MonoBehaviour
{
// Use this for initialization
void Start()
{
}
// Update is called once per frame
void Update()
{
}
public void StartGame()
{
// You should put in the name of the scene that represents your
// start level
SceneManager.LoadScene("Awakening");
}
}
}
在层次结构窗口中,你需要创建一个空 GameObject。最好的方法是右键单击并选择创建空 GameObject。一个空 GameObject 将被创建。选择它并将名称更改为_GameMaster。
我们需要将我们的脚本附加到场景中的_GameMaster GameObject 上。选择GameMaster.cs脚本并将其拖放到_GameMaster上。这将把脚本附加到_GameMaster对象上,使其在场景中可用。
下一步是从按钮创建事件调用。这可以通过选择butStartGame按钮元素,并在检查器窗口中为OnClick()组件添加一个新的事件调用轻松实现。点击 (+) 按钮创建一个新事件,如下面的截图所示:

我们需要调用在GameMaster.cs脚本中创建的函数。为此,我们需要以某种方式引用它。这非常容易完成。我们可以将_GameMaster GameObject 拖放到槽中,就像前一张截图中的数字 2 所示。
一旦你将你的_GameMaster GameObject 放入槽中,你需要从下拉菜单中选择脚本,就像截图中的数字 3 所示。
就这么简单!我们现在已经将我们的按钮点击事件连接到了负责加载我们第一个级别的代码。
现在是保存你的场景并测试你的应用程序的好时机。当你第一次运行应用程序时,你会得到一个错误。不要惊讶,我们并没有做错什么。但在我们能够成功运行我们的游戏之前,我们还需要做一步。
为了能够在游戏中加载场景,你需要确保它们列在构建设置中。为此,选择文件 | 构建设置...,并通过选择添加打开的场景将当前场景添加到列表中。你的构建设置应如下所示:

再次加载 MainMenu 场景,并运行应用程序。太棒了!它按预期工作。在我们继续之前,我想在本章中添加的唯一其他项目是GameMaster.cs脚本中的以下代码:
using UnityEngine;
using UnityEngine.SceneManagement;
namespace com.noorcon.rpg2e
{
public class GameMaster : MonoBehaviour
{
// Use this for initialization
void Start()
{
DontDestroyOnLoad(this);
}
// Update is called once per frame
void Update()
{
}
public void StartGame()
{
// You should put in the name of the scene that represents
your
// start level
SceneManager.LoadScene("Awakening");
}
}
}
Start()函数中的单行代码将确保当我们从一个场景移动到下一个场景时,_GameMaster游戏对象不会被销毁。这是很重要的,因为我们将把所有的游戏配置、统计数据等存储在这个特定的游戏对象中。当你现在从主菜单场景运行游戏时,你会注意到当你加载第 1 级时,_GameMaster游戏对象会自动从主菜单中过来。这很酷!
摘要
在本章中,我们建立了一个很好的 RPG 感觉。我们定义了我们的等级和每个等级的设置,并为每个等级定义了明确的目标和每个等级的结果。我们还创建了第一个等级,称为觉醒,并创建了环境。我们研究了如何使用我们的资产和资产商店将 3D 模型纳入我们的场景。我们还研究了如何规划等级布局。我们引入了一个第三人称角色控制器到场景中,以帮助我们从玩家的视角可视化等级的外观,并按需对其进行微调。
下面是用于本等级设计的资产列表:
-
对于地形,我使用了 Terrain Toolkit 2017 资产
-
对于等级内的模型,我使用了 Medieval Environment Pack 资产
到本章结束时,我们还开发了我们的主菜单场景和初始的GameMaster脚本,该脚本将用于将游戏的核心粘合在一起。在下一章中,我们将开始创建我们的玩家角色,并增强我们的游戏大师和主菜单系统。
第三章:RPG 角色设计
我们现在处于开发过程中的一个有趣阶段。在本章中,我们将讨论我们 RPG 角色的设计,并查看我们需要设计和实现的一些属性和特征。
下面是本章涵盖的主题概述:
-
角色定义
-
基础角色类属性
-
角色状态
-
角色模型:
-
为你的模型绑定
-
角色动作
-
动画控制器
-
动画状态
-
角色控制器
-
动画修改
-
-
反向动力学
角色定义
要有一个有意义且有趣的 RPG,游戏通常应该有多个角色类。在第一章“什么是 RPG?”中,我们定义了以下类类型:
-
野蛮人
-
兽人
-
村民
由于时间限制,我们无法实现所有角色类型。展示一两个角色类型的实现应该为你开发自己的角色类提供一个良好的基础。毕竟,这是本书的整体目标。
当然,主要角色之一是玩家角色(PC)。让我们先集中精力实现 PC,然后我们可以开始定义和设计野蛮人类、村民类,也许还有兽人类。
我的角色模型将从资源商店获取。你可以下载相同的角色,或者设计自己的角色。你也可以使用不同类型的角色模型。关键是要根据规格实现角色,这些规格将在本章及以后定义。
角色资源
我将使用以下资源商店的资源来创建我的角色模型:
-
幻想部落野蛮人
-
幻想部落 - 村民
-
幻想部落 - 兽人
让我们看看玩家通常将拥有的某些属性。
基础角色类属性
让我们开始奠定我们将需要用于实现角色类的基石。以下是一个将作为基础角色类一部分的属性列表:
-
角色类名称
-
角色类描述
-
属性列表:
-
力量
-
敏捷性
-
耐力
-
智力
-
社会地位
-
敏捷性
-
警觉性
-
生命力
-
意志力
-
你为角色定义的属性取决于角色类型,但所有角色属性之间将存在一些相似之处。我们希望在所有角色类共享的一个基础类中实现这些相似之处。
提供的列表只是一个示例,你可以根据自己的需要添加或删除。
让我们保持简单。现在我们将只使用以下五个主要统计数据,如下所示:
-
力量:力量是衡量角色身体坚固程度的指标。质量控制角色可以传达的最极端重量,包括小规模攻击以及伤害,有时还有击中点数。防护层和武器也可能有力量要求。
-
防御:防御是衡量角色灵活程度的指标。保护通常通过每次打击的速率或固定金额来减少所受伤害。
-
敏捷性:敏捷性是衡量角色灵巧程度的指标。技能控制攻击、发展速度和精度,以及躲避敌人的攻击。
-
智力:智力是衡量角色批判性思维能力的指标。知识通常控制角色理解方言的能力和他们在魔法方面的才能。有时,洞察力控制角色在升级时获得的技能点数。在一些游戏中,它控制获得经验点的速率或升级所需的金额。这通常与智慧相结合,以及自律。
-
健康:健康决定角色是生是死。
列出的属性是所有角色类将继承的属性。现在让我们将其放入代码中。创建一个新的 C# 脚本,命名为 BaseCharacter.cs。打开脚本并将以下代码放入文件中:
namespace com.noorcon.rpg2e
{
public class BaseCharacter
{
public string Name;
public string Description;
public float Strength;
public float Defense;
public float Dexterity;
public float Intelligence;
public float Health;
}
}
角色状态
状态是角色设计的重要组成部分。它们还将驱动为每个状态创建的动作和移动类型。例如,我们的角色至少需要实现以下状态:
-
空闲
-
行走
-
跑步
-
跳跃
-
攻击
-
死亡
您的角色可能定义了更多状态;这是您作为游戏设计师需要识别并最终实现的事情。每个识别出的状态都需要实现为动画。创建角色模型的个人通常也会为角色开发动画。
例如,兽人模型定义了以下状态/动画:

您可以考虑实现所有状态或部分状态。无论是理论还是实践,都是一样的。
我还可以使用 RawMocap 数据来为模型动画,因为我在使用的模型是 Mecanim 兼容的,包括面部绑定。实际上,我们将使用一些 RawMocap 数据来为模型动画。
随着 Unity 5 的发布,引入了 Mecanim 动画系统,该系统用于创建在类人角色上创建动画的简单工作流程和设置,将动画从一角色重定向到另一角色,预览动画剪辑,使用可视化工具管理动画之间的复杂交互,以及使用不同的逻辑为不同的身体部位进行动画处理。
实际上,我们现在就下载 Asset Store 上的资产吧。在 Asset Store 中搜索 Mecanim 的原始 mocap 数据。

该包包含几个原始动作捕捉数据文件供您使用。请注意,您可能需要自行进行一些调整。
在创建您的角色模型时,遵循为您的角色设置的适当骨骼结构是一个好主意。这将有助于使控制角色的状态和动画更容易,以及在不同的角色上重用您的动画控制器。如果您打算使用资产商店中的角色,这也同样适用。
角色模型
您现在应该考虑您的玩家角色将如何看起来。可以采取几种不同的方法。一种简单的方法是提供一个预定义的英雄,玩家在角色自定义方面没有太多选择或自由度。另一种方法是让玩家能够在一定程度上或完全改变和修改他们的角色。这完全取决于您的预算!
在这里,我们将采取介于两者之间的方法,以获得两个世界的优点。
您可以使用资产商店下载预定义的角色,这些角色可以在您创建自己的角色时作为占位符使用。您甚至可以使用资产商店中免费提供的某些角色,并根据您的需求进行修改。
一旦您确定了您的角色模型,下一步就是对其进行配置和自定义,以便在您的游戏中使用。我拥有的角色模型可以视觉上修改,以代表多个独特的角色。
例如,让我们看一下我们将要使用的默认角色模型,以下章节将详细介绍。
默认角色模型
这里是一套从资产商店购买的字符模型。我喜欢这一套模型,因为它们简单且易于使用。
野蛮人
此模型包含几种身体类型——肥胖、普通和瘦弱,这些类型是通过身体和布料上的混合形状设置的。它包含 15 种不同的身体和配件纹理,一种武器纹理和两种盾牌纹理。这为我们提供了一系列独特的角色定义和自定义选项,以增强 RPG 中不同 NPC 的范围。
我们将探讨如何在角色自定义时利用它们。以下截图展示了野蛮人的一个示例:

野蛮人模型
村民
村民模型为我们提供了两组模型:儿童和成人。在成人组中,我们有男性、女性和僧侣类型。提供了 16 个男性、3 个僧侣、8 个女性、4 个儿童和 2 个装备纹理。对于女性类型,有两种纹理类型:标准发型,用于散发的头发,和帽子,与帽子等头部配件一起使用。
有两组不同的动画集:一组用于成人网格类型,另一组用于儿童网格类型。与成人相比,儿童类型的状态较少。以下截图展示了男性、女性和儿童村民:

村民模型
精灵
兽人模型在其结构上可能略有不同。所有身体类型网格都将属于主要结构;有三个主要模型:肥胖、平均和健壮。也可能有一些遗留动画,我们在开始配置和编程或Orc类时需要处理。请参阅以下兽人的截图:

兽人模型
让我们开始吧
将您的项目中的野蛮人模型拖放到场景中。您需要仔细研究您的角色模型,了解它是如何构建的,这样您就可以在设计时间以及必要时在运行时对其进行修改。
这个特定的模型有几个视觉元素附加在武器、服装等上。您的模型可能配置不同;如果是这样,您需要创建自己的附加点并相应地实例化武器和其他与角色相关的资产。请参阅以下截图:

模型层级
选择您的角色模型并研究模型的架构。您会注意到模型层级中存在一定的模式和命名约定,如前一张截图所示。某些模型可能附加了动画。要检查它们,您需要从项目窗口中选择模型,并在检查器窗口中选择动画选项卡,以获取模型的嵌入动画列表,如以下截图所示:

在检查器窗口中,选择前一张截图所示的动画选项卡,并注意所有为您的角色模型开发的动画的剪辑部分,如以下截图所示:

注意到动画片段有开始时间和结束时间。实际的角色模型在检查器窗口的底部以视觉方式显示。
为您的模型设置骨架
有时候您可能需要为您的模型设置骨架以使其适合您的游戏。这可以通过选择模型源,并在检查器窗口中选择骨架选项卡来实现,如以下截图所示:

在骨架选项卡中,有一些选项您可以应用于您的模型。假设您的角色是类人型,如果您尚未选择,您需要选择类人动画类型。Avatar 定义也可以从模型创建或分配,如果您已经定义了 Avatar。最后,您可以点击“配置...”按钮以查看设置好的骨架模型的配置。请参阅以下截图:

类人骨骼结构
注意从前面的截图可以看出,你的模型为其骨骼定义了映射。如果你的模型是 Humanoid 类型,并且如果你的模型结构已经被正确命名,系统将自动分配正确的骨骼和关节。如果你的命名不符合 Unity 规范,你可以导航你的模型结构并手动为身体、头部、左手和右手中的每个点分配。
肌肉和设置选项卡将允许你定义和限制模型关节的运动。这些对于创建更逼真的角色动作非常有用和实用。你可以自己进一步研究这些主题,因为它们可能需要一整章或两章来涵盖。
角色动作
传统上,角色的动作和运动是通过代码分别完成的。随着 Mecanim 的引入,你现在可以应用所谓的 根运动。这根据根运动中的数据修改了角色的游戏内变换。
我们将为我们的角色使用根运动。根运动与 Animator Controller 和动画状态机一起工作。身体变换和方向存储在动画剪辑中。这使得创建一个通过 Animator Controller 播放适当动画剪辑的状态机变得更容易。
动画控制器
在本节中,我们将使用新的 Animator Controller 来创建我们的角色状态并确定状态变化的准则。让我们在项目窗口中创建一个新的文件夹并命名为 Animator。选择新创建的文件夹。
要创建一个 Animator Controller,在项目窗口中,右键单击并选择 创建 | 动画控制器。给它起一个名字。我称之为 BaseAnimatorController。双击控制器以打开 Animator 窗口。
Animator Controller 是一个非常复杂的工具,你需要花一些时间来研究通过它可用的不同方面和功能。以下截图是空控制器的快照。我已经标记了 Animator 窗口的主要部分。有两个可见的选项卡,即层选项卡和 参数 选项卡。在 层 选项卡中,你可以创建不同的层,这些层包含你的动画状态以及从一种状态到另一种状态的相关 转换。参数 选项卡是定义你的参数的地方,这些参数将由 Animator Controller 以及你的代码访问和修改。
要完全理解 Mecanim 系统,你需要了解一系列的主题。在这本书中,我们不会涵盖所有方面,但会涉及一些对我们游戏所需的关键方面进行探讨。请看以下截图:

动画状态
要创建一个新的状态,您只需从项目窗口拖放一个动画即可。这将命名并分配相关的动画到层的该状态。您也可以通过在层中右键单击并选择创建状态 | 空状态来创建一个空状态。当状态创建后,您可以通过点击状态并在以下屏幕截图中所示,在检查器窗口中观察其属性:

您的模型可能或可能没有附加动画。Mecanim 系统的整个想法是使角色模型师能够在他们的模型上工作,同时动画师可以使用类人形角色的骨架来动画化角色。这反过来使得将一系列动画应用于不同类型的角色模型变得更加容易和更好!
为了识别状态,最好给它提供一个独特且易于在状态图中识别的名称。您需要给它分配一个动作;这是当状态激活时将播放的动画剪辑。下一个重要的属性将是转换属性。转换将确定状态将移动到另一个状态的条件,如果存在这样的要求。
例如,当角色处于空闲状态时,什么条件会使角色将其状态更改为行走状态、跑步状态等等,如以下屏幕截图所示:

在前面的屏幕截图中,您将看到我定义了三个不同的状态:空闲、行走和跑步。您还会注意到,在参数选项卡中,我定义了一些参数。这些参数用于确定何时从空闲状态移动到行走状态、跑步状态,以及返回。这些参数旨在帮助您为状态机创建条件。
要从当前状态创建到下一个状态的转换,请右键单击您的状态,然后选择创建转换,然后选择它将转换到的状态。这将创建从起始状态到结束状态的视觉箭头。选择转换箭头以获取其属性,并在检查器窗口中设置条件。
在这个例子中,行走和跑步状态实际上是一个混合树。混合树用于使从一个动画状态到下一个动画状态的过渡更加自然。为了使混合动作有意义,混合的动作必须是相似性质和时序的。
混合树用于通过将它们的所有部分以不同程度地结合在一起,允许多个动画平滑地混合。每个动作对最终效果贡献的程度是通过一个混合参数控制的,这个参数只是与 Animator Controller 关联的数字动画参数之一。
例如,行走状态可能看起来像以下屏幕截图:

混合树示例
在我们的第一个混合树节点中,我们有五个输出:HumanoidWalkLeftSharp、HumanoidWalkLeft、WalkFWD、HumanoidWalkRight和HumanoidWalkRightSharp。这些是根据名为水平的参数值播放的动画剪辑。
这些动画来自Mecanim 的原始 Mocap 数据。
在行为区域,你会注意到为参数设置了一些阈值;这些阈值决定了要播放的动画。水平参数的值通过我们的 C#代码通过传递水平轴的值来设置,该值在输入管理器中定义。请看以下截图:

当你选择一个混合树节点时,你的检查器窗口将允许你添加或删除不同的动画状态,以及参数和参数的阈值,这将决定哪个动画将被渲染。
在动画中实现平滑混合的关键是注意你的动画数据。
让我们看看我们的最终状态图:

状态图
在这个阶段,我已经提前实现了空闲、行走、奔跑、空闲跳跃、奔跑跳跃、攻击 1/2/3、拳头和死亡的状态图。
定义从空闲状态到行走和奔跑状态的转换的参数是速度参数。如果速度值大于 0.1,它将从空闲状态转换为行走状态;如果它大于 0.6,它将从行走状态转换为奔跑状态。从奔跑状态到行走状态,以及从行走状态到奔跑状态的转换情况相反。
然而,请注意,角色只能从空闲状态或奔跑状态进入跳跃状态。控制这种转换的参数是Jump参数,它是一个通过按键盘上的空格键设置的布尔值。
从任意状态可以触发三种攻击状态,以及可以从任意状态进入的死亡状态。嗯,这是因为如果你不小心,你的角色在任何时候都可能死亡!
让我们看看我们如何控制这些参数。
角色控制器
是时候让我们的角色在场景中移动了。这通常由角色控制器处理。角色控制器将被用来处理玩家在游戏中与角色的大部分交互。
创建一个新的 C#脚本,并将其命名为BarbarianCharacterController.cs。在BarbarianCharacterController类中输入以下代码。目前代码非常基础。让我们列出代码,然后我们可以开始讨论代码的不同部分:
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class BarbarianCharacterController : MonoBehaviour
{
public Animator animator;
public float directionDampTime;
public float speed = 6.0f;
public float h = 0.0f;
public float v = 0.0f;
bool attack = false;
bool punch = false;
bool run = false;
bool jump = false;
bool die = false;
bool dead = false;
// Use this for initialization
void Start()
{
this.animator = GetComponent<Animator>() as Animator;
}
// Update is called once per frame
private Vector3 moveDirection = Vector3.zero;
void Update()
{
if (dead)
{
if (die)
{
die = !die;
animator.SetBool("Die", die);
}
return;
}
if (Input.GetKeyDown(KeyCode.C))
{
attack = true;
}
if (Input.GetKeyUp(KeyCode.C))
{
attack = false;
}
animator.SetBool("Attack", attack);
if (Input.GetKeyDown(KeyCode.P))
{
punch = true;
}
if (Input.GetKeyUp(KeyCode.P))
{
punch = false;
}
animator.SetBool("Punch", punch);
if (Input.GetKeyDown(KeyCode.LeftShift))
{
this.run = true;
}
if (Input.GetKeyUp(KeyCode.LeftShift))
{
this.run = false;
}
animator.SetBool("Run", run);
if (Input.GetKeyDown(KeyCode.Space))
{
jump = true;
}
if (Input.GetKeyUp(KeyCode.Space))
{
jump = false;
}
animator.SetBool("Jump", jump);
if (Input.GetKeyDown(KeyCode.I))
{
die = true;
dead = true;
}
animator.SetBool("Die", die);
}
在Start()函数中,我们将获取 Animator Controller 的引用。我们将使用FixedUpdate()函数来执行角色移动的更新,如下面的代码所示:
void FixedUpdate()
{
// The Inputs are defined in the Input Manager
// get value for horizontal axis
h = Input.GetAxis("Horizontal");
// get value for vertical axis
v = Input.GetAxis("Vertical");
speed = new Vector2(h, v).sqrMagnitude;
// Used to get values on console
Debug.Log(string.Format("H:{0} - V:{1} - Speed:{2}", h, v,
speed));
animator.SetFloat("Speed", speed);
animator.SetFloat("Horizontal", h);
animator.SetFloat("Vertical", v);
}
}
}
Update() 函数和 FixedUpdate() 函数的区别是什么?Update() 函数在每一帧都会被调用,通常用于更新非物理对象的移动、简单的计时器和输入处理。Update() 函数的更新间隔时间会有所不同。FixedUpdate() 函数在物理步骤中调用。间隔是固定的,用于调整 Rigidbody 上的物理。
在 FixedUpdate() 函数中,我们获取水平轴和垂直轴的输入,计算 速度 值,并使用 animator.SetFloat() 函数设置在动画控制器中定义的参数。然后,动画控制器使用这些参数来决定角色处于哪个状态。
例如,要从空闲状态过渡到行走状态,速度 参数需要大于 0.1,从行走状态过渡到跑步状态,速度 参数需要大于 0.6,并且 run 参数需要为 true。当你想从跑步状态回到行走状态,以及从行走状态回到空闲状态时,情况相反。水平方向和垂直方向参数控制转向左或右的移动。这三个参数的组合控制角色渲染的状态和动画。
下一步是为我们启用 跳跃、死亡 和 攻击 状态。跳跃状态可以在角色空闲或跑步时进入,并且跳跃布尔变量设置为 true。跳跃条件在玩家按下空格键时设置在 Update() 函数中。这会将变量设置为 true 并将其传递给动画控制器。
攻击和拳套状态使用相同的机制。这映射到键盘上的以下按键:C 和 P。每个按键都会将其布尔值设置为 true 并传递给动画控制器。玩家只能从 Any 状态进入这些状态。我们现在就保持这个状态。
最后,实现了死亡状态,目前我们使用键盘输入 I 来测试它。死亡状态与其他状态的主要区别在于,死亡状态可以从任何状态进入。
我们没有为这些状态使用 Blend Trees,因为这些状态只有一种动画。你也会注意到,状态只能从空闲状态过渡到。这是由于动画和模型最初设置的方式。你的可能不同。
看看下面的截图:

动画参数
角色可以从任何状态进入死亡和攻击状态。也就是说,你的角色玩家可以在游戏中的任何时间死亡,无论他或她当时处于什么状态。跳跃状态可以从两种状态触发,空闲和跑步。你可以根据你的动画复杂度级别改进这些过渡和状态,但就目前而言,这样应该足够了。
看看下面的截图:

这些状态是通过动画器中定义的布尔参数控制的。在这个阶段,你应该能够使用你的模型来测试场景,以及你的角色动画和状态。
动画修改
有时候,你可能需要做一些更改和/或修改现有的修改,以便它能够与你的游戏和状态机正常工作。
为我的角色模型准备好的攻击动画需要调整,以便在角色仍然处于那个特定状态时循环播放。例如,如果我使用现有的动画,并且角色状态进入攻击模式,动画将只播放一次。这不是我想要的效果;我正在构建攻击输入,以便在攻击键按下时执行攻击。更改动画循环设置很简单。为此,从你的项目窗口中选择动画,然后从检查器窗口中选择编辑...按钮,如图下面的屏幕截图所示:

现在,你将进入动画的编辑模式,如图下面的屏幕截图所示。我把它放在旁边,以展示动画选项卡,选择我们想要修改的每个动画,一次一个,并将循环时间属性设置为 True。
在这个特定的屏幕截图中,你还会注意到动画的几个其他重要属性,例如根变换旋转、镜像、曲线、事件、遮罩和运动。当我们为我们的角色动画设置逆运动学时,我们会使用曲线属性。这基本上设置了预定义参数的值,这些值可以通过Mecanim来设置或获取。看看下面的屏幕截图:

如果你的动画附加到你的模型上,并且你的动画和模型较旧,你很可能需要对其进行一些修改。
例如,你可能需要为特定动画剪辑设置的一个主要属性可能是循环时间属性,如图前面所示的屏幕截图。这将确保动画将在你处于运行动画的状态下循环播放。如果循环未启用,动画将只播放一次并停止,即使你仍然处于表示动画的状态。
确保空闲、行走、跑步和攻击动画的循环时间属性已设置。同时,并非所有动画剪辑都需要循环,例如,跳跃和死亡动画只需要播放一次。你需要做足功课,检查所有这些属性。
其他动画需要修改以启用将变换烘焙到模型中。例如,投掷和跳跃动画具有以下属性被勾选:根变换旋转和根变换位置(Y);确保烘焙到姿态属性被勾选。这很重要,以确保动画和角色的骨骼运动在根变换位置上和谐一致。
如果这些属性设置不正确,你的动画可能会显得很奇怪。如果有任何异常情况发生,请务必仔细检查这些属性。
如果你还没有这样做,你应该将你的BarbarianharacterController.cs脚本附加到你的玩家角色上。
逆运动学
逆运动学(IK)在游戏编程中非常重要。它通常用于使角色的动作更加逼真。逆运动学的一个主要用途是计算玩家的脚以及它们与站立地面的关系。
简而言之,IK 用于根据空间中的给定位置确定角色的关节位置和旋转。例如,确保玩家的脚在行走的地面上的着陆位置正确。
Unity 有一个内置的逆运动学系统,可以用来进行这方面的基本计算。让我们继续为我们的角色实现脚部逆运动学。在我们为我们的拟人角色启用逆运动学之前,有一些事情需要设置。
首件事是检查你的动画控制器中的层级,并使用引擎图标进入设置窗口。确保已勾选IK 传递,如图下所示。如果你还没有这样做,你还需要提供一个遮罩。遮罩用于指定哪些骨骼部分受到逆运动学的影响。请参考以下截图:

逆运动学遮罩
一旦设置好,乐趣就开始了。我们需要创建一个 C#脚本来处理我们的 IK。创建一个名为IKHandle.cs的 C#脚本。将以下代码输入到脚本中:
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class IKHandle : MonoBehaviour
{
Animator anim;
#region USED FOR MANUAL TESTING
Transform leftIKTarget;
Transform rightIKTarget;
Transform hintLeft;
Transform hintRight;
float ikWeight = 1f;
#endregion
// to make it dynamic
[Header("Dynamic IK Values")]
Vector3 LeftFootPosition;
Vector3 RightFootPosition;
Quaternion LeftFootRotation;
Quaternion RightFootRotation;
float LeftFootWeight;
float RightFootWeight;
public Transform LeftFoot;
public Transform RightFoot;
[Header("Adjustment Properties for IK")]
public float OffsetY;
public float LookIkWeight = 1.0f;
public float BodyWeight = 0.7f;
public float HeadWeight = 0.9f;
public float EyesWeight = 1.0f;
public float ClampWeight = 1.0f;
public Transform LookPosition;
// Use this for initialization
void Start()
{
anim = GetComponent<Animator>();
LeftFoot = anim.GetBoneTransform(HumanBodyBones.LeftFoot);
RightFoot = anim.GetBoneTransform(HumanBodyBones.RightFoot);
LeftFootRotation = LeftFoot.rotation;
RightFootRotation = RightFoot.rotation;
}
这个脚本有些复杂。为了使逆运动学,即 IK,正常工作,我们需要在空间中识别几个重要的点。其中一个点是我们要让脚移动到的目标位置在空间中的位置,另一个点是提示。这两个空间点用于控制特定关节的骨骼运动和变换,以便成功完成目标位置的 IK。请参考以下代码:
// Update is called once per frame
void Update()
{
// we can set the look position here somewhere
Ray ray = new Ray(Camera.main.transform.position,
Camera.main.transform.forward);
Debug.DrawRay(Camera.main.transform.position,
Camera.main.transform.forward * 15, Color.cyan);
//lookPosition.position = ray.GetPoint(15);
RaycastHit leftHit;
RaycastHit rightHit;
Vector3 lpos = LeftFoot.TransformPoint(Vector3.zero);
Vector3 rpos = RightFoot.TransformPoint(Vector3.zero);
if (Physics.Raycast(lpos, -Vector3.up, out leftHit, 1))
{
LeftFootPosition = leftHit.point;
LeftFootRotation = Quaternion.FromToRotation(transform.up,
leftHit.normal) * transform.rotation;
}
if (Physics.Raycast(rpos, -Vector3.up, out rightHit, 1))
{
RightFootPosition = rightHit.point;
RightFootRotation = Quaternion.FromToRotation(transform.up,
rightHit.normal) * transform.rotation;
}
}
void OnAnimatorIK()
{
LeftFootWeight = anim.GetFloat("MyLeftFoot");
RightFootWeight = anim.GetFloat("MyRightFoot");
anim.SetIKPositionWeight(AvatarIKGoal.LeftFoot,
LeftFootWeight);
anim.SetIKPositionWeight(AvatarIKGoal.RightFoot,
RightFootWeight);
anim.SetIKPosition(AvatarIKGoal.LeftFoot, LeftFootPosition +
new Vector3(0f, OffsetY, 0f));
anim.SetIKPosition(AvatarIKGoal.RightFoot, RightFootPosition +
new Vector3(0f, OffsetY, 0f));
anim.SetIKRotationWeight(AvatarIKGoal.LeftFoot,
LeftFootWeight);
anim.SetIKRotationWeight(AvatarIKGoal.RightFoot,
RightFootWeight);
anim.SetIKRotation(AvatarIKGoal.LeftFoot, LeftFootRotation);
anim.SetIKRotation(AvatarIKGoal.RightFoot, RightFootRotation);
}
}
}
LeftFootPosition和RightFootPosition变量用于在运行时表示左右脚的目标位置。LeftFootRotation和RightFootRotation用于存储左右脚的旋转。
我们还需要两个变量来在模型中实际引用我们的左右脚。这是通过LeftFoot和RightFoot变量完成的。
其中一些变量在Start()函数中初始化。具体来说,我们从为人类角色定义的 Animator Controller 骨骼结构中获取左右脚的引用。
在Update()函数中,我们使用Physics.Raycast()执行一些射线投射,以确定左右脚的位置。然后,这些数据被使用并存储在LeftFootPosition和RightFootPosition变量中,以及它们在LeftFootRotation和RightFootRotation变量中的等效旋转数据。请看以下截图:

动画曲线
实际的 IK 动画是在OnAnimatorIK()函数中应用的。LeftFootWeight和RightFootWeight变量用于通过 Animator Controller 中的动画剪辑Curve函数获取为MyLeftFoot和MyRightFoot设置的参数值。
关键在于正确定义将用于驱动 IK 权重的动画剪辑曲线。前面的截图只显示了空闲状态的曲线。两只脚都放在地面上,因此值设置为 1。对于你的行走和跑步剪辑,你的曲线将不同。
最后,使用SetIKPositionWeight()和SetIKPosition()函数来正确调整脚相对于地面的位置和旋转!请注意,这是为每只脚分别执行的。
将IKHandle.cs脚本附加到你的角色上并执行测试运行。注意你的角色以及它与地面或你设置的地面或地形交互的方式有何不同。
设置动画曲线
这一步对于 IK 的正常工作非常重要。我将使用空闲动画来演示需要配置的内容,以确保动画控制器中的参数设置正确。请看以下截图:

动画曲线修改
为了使 IK 正常工作并看起来不错,你需要为与脚部运动相关的每个动画设置曲线。由于我们有五套行走和跑步的动画,你需要为每个动画曲线执行相同的操作,以正确设置传递给 IK 脚本的权重值。
概述
我们在本章中涵盖了大量内容。我们讨论了我们将在游戏中使用的不同角色定义,查看所有角色将共享的基础角色类属性,并创建了BaseCharacter类,以便在游戏后期使用。我们还讨论了角色在游戏中的主要状态,以及如何使用 Animator Controller 来实现它们。
我们探讨了如何为我们的角色模型绑定,使其为 Mecanim 系统做好准备,以及如何使用 Mecanim 系统创建动画和状态图,这些图将决定角色在游戏中的行为。然后我们实现了我们的初始角色控制器脚本,该脚本处理我们角色的状态。这给了我们机会查看混合树以及使用参数从一个状态过渡到下一个状态。然后我们探讨了如果需要的话如何修改动画剪辑。
最后,我们学习了逆运动学,这将帮助我们的角色在游戏环境中表现得更加真实。
章节结束,你应该已经很好地掌握了所有共同作用使你的角色在游戏环境中看起来、表现和移动的不同组件。
在下一章中,我们将介绍非角色行为。
第四章:游戏机制
在第三章,“RPG 角色设计”中,我们涵盖了广泛的主题,以准备你的角色模型用于游戏。我们探讨了如何导入和设置我们的角色模型,创建了BaseCharacter类,使用动画控制器设置状态图,创建了初始角色控制器来处理我们角色模型的运动和行为,最后,查看了一些基本逆运动学用于脚部。
在本章中,我们将扩展角色玩家和非角色玩家,涵盖以下主题:
-
定制玩家角色:
-
可定制部分(模型)
-
定制化的 C#代码
-
保留角色状态
-
回顾
-
-
非玩家角色:
-
非玩家角色基础
-
设置非玩家角色
-
导航网格设置
-
NPC 动画控制器
-
NPC 攻击
-
NPC AI
-
-
PC 和 NPC 交互
定制玩家角色
一个 RPG 的关键特性是能够定制你的角色玩家。在本节中,我们将探讨如何提供实现这一点的手段。
再次强调,方法和概念是通用的,但实际实现可能根据你的模型结构略有不同。
创建一个新的场景并将其命名为CharacterCustomization。创建一个立方体预制件并将其设置为原点。将立方体的缩放设置为<5, 0.1, 5>。你还可以将 GameObject 的名称更改为 Base。这将是我们角色模型站立的平台,在玩家在游戏开始前定制他的/她的角色时。
我使用我的环境资源来创建舞台。这需要更多的时间,但更加吸引人。这完全取决于你,游戏创造者和设计师,天空才是极限!
将代表你的角色模型的预制件拖放到场景视图中。接下来的几个步骤将完全取决于你设计的模型层次结构和结构。
我正在使用野蛮人模型来展示结构。
为了说明这一点,我在场景中放置了相同的模型两次。左边的是配置为仅显示基础的模型,而右边的模型是其原始状态,如下面的截图所示:

野蛮人模型:简单且全副武装
注意,我正在使用的特定模型将所有内容都附加在上面。这包括不同类型的武器、鞋子、头盔、盔甲和皮肤。左侧的实例化预制件已关闭模型层次结构中的所有额外内容。以下是Hierarchy View中的层次结构看起来是这样的:

野蛮人模型结构
该模型在其结构中具有非常广泛的层次结构。前面的截图是一个小片段,以展示你需要导航结构并手动识别和启用或禁用表示模型特定部分的网格。
模型根:
骨盆:
左大腿:
- 左腿
右大腿:
- 右小腿
脊柱:
-
胸廓:
-
左锁骨:
-
左上臂:
- 左前臂
-
-
颈部:
- 头部
-
右锁骨:
-
右上臂:
-
右前臂:
- 右手掌
-
-
-
每个角色模型都将有自己的独特层次结构和骨骼结构。你需要研究这一点,如前所述,以了解和计划你如何在游戏过程中配置和编程它们。
可自定义部分
使用我的野蛮人模型,我可以使用它自定义一些物品。我可以自定义肩垫、体型、武器、盔甲、头盔、鞋子,最后,模型的纹理或皮肤,以赋予它不同和独特的样子。
让我们列出我们为角色拥有的所有可自定义物品:
-
盾牌:有两种类型
-
体型:有三种体型:瘦、健壮和胖
-
盔甲:护膝、腿板
-
靴子:有两种类型
-
头盔:有四种类型
-
武器:有 13 种不同的类型
-
皮肤:有 13 种不同的类型

模型资产 1
你可以轻松地从主模型中提取每个配件,并为单个武器、盔甲和服装创建预制件:

模型资产 2
我们以这种方式分离物品,以便玩家在游戏过程中能够升级或找到所需的武器或盔甲:

模型资产 3
一旦拾取了物品,我们就会将其放入我们的库存中,玩家可以轻松访问它。
用户界面
现在我们知道了我们可以如何自定义我们的玩家角色,我们可以开始考虑用户界面(UI)。UI 将用于角色的自定义。
以下是一个 UI 想法的草图。当我们开始实现 UI 时,我们可能需要做一些调整以适应原始概念的可用性:

为了设计我们的 UI,我们需要创建一个 Canvas GameObject。这是通过在层次视图中右键单击并选择创建 | UI | Canvas 来完成的。这将在一个 Canvas GameObject 和一个EventSystem GameObject 中放置层次视图。
假设你已经知道如何在 Unity 中创建 UI。如果你不知道,请参阅《游戏编程入门:使用 C#和 Unity 3D》 第五章,游戏大师和游戏机制,在www.amazon.com/Introduction-Game-Programming-Using-Unity-ebook/dp/B01BCPRRCU/.
我将使用面板来分组可自定义的物品。目前,我将使用复选框来处理一些物品,并使用滚动条来处理武器和皮肤纹理。以下截图说明了我的自定义 UI 看起来:

角色自定义 UI
这些 UI 元素需要与事件处理器集成,以便执行启用或禁用角色模型某些部分所需的操作。
例如,使用 UI,我可以选择肩垫 4 号,使用滚动条增加或减少身体的圆润度,并将武器类型滚动条移动到锤子武器出现的位置。选择第二个头盔复选框,选择盾牌 1 号和靴子 2 号,我的角色将看起来像以下截图。
我们需要一种方式来引用模型上代表不同可定制对象类型的每个网格。这将通过 C#脚本完成。脚本需要跟踪我们将要管理的所有定制部分。
一些模型可能没有附加额外的网格。你可以在模型的特定位置创建空的 GameObject,并且可以在给定点动态实例化代表你的自定义对象的预制体。这也可以应用于我们的当前模型。例如,如果我们有一个特殊的太空武器,某种方式被游戏世界中的外星人丢弃,我们可以通过 C#代码将武器附加到我们的模型上。重要的是要理解这个概念,其余的则由你决定!

角色定制化实战
角色定制化代码
事情不会自动发生。我们需要创建一些 C#代码来处理我们的角色模型的定制化。我们在这里创建的脚本将处理驱动模型网格不同部分启用和禁用的 UI 事件。
创建一个新的 C#脚本,命名为BarbarianCharacterCustomization.cs。创建一个名为__Base的空 GameObject,并将脚本附加到场景中的__Base GameObject。以下是脚本的列表:
代码路径
using System;
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class BarbarianCharacterCustomization : MonoBehaviour
{
public GameObject PLAYER_CHARACTER;
public PlayerCharacter PlayerCharacterData;
public Material[] PLAYER_SKIN;
public GameObject CLOTH_01LOD0;
public GameObject CLOTH_01LOD0_SKIN;
public GameObject CLOTH_02LOD0;
// Player Character Defense Weapons
public GameObject SHIELD_01LOD0;
public GameObject SHIELD_02LOD0;
public GameObject QUIVER_LOD0;
public GameObject BOW_01_LOD0;
// Player Character Calf - Right / Left
public GameObject KNEE_PAD_R_LOD0;
public GameObject LEG_PLATE_R_LOD0;
public GameObject KNEE_PAD_L_LOD0;
public GameObject LEG_PLATE_L_LOD0;
public GameObject BOOT_01LOD0;
public GameObject BOOT_02LOD0;
// Use this for initialization
void Start()
{
PlayerCharacterData = PLAYER_CHARACTER.GetComponent<PlayerAgent>().playerCharacterData;
}
public bool ROTATE_MODEL = false;
// Update is called once per frame
void Update()
{
if (Input.GetKeyUp(KeyCode.R))
{
ROTATE_MODEL = !ROTATE_MODEL;
}
if (ROTATE_MODEL)
{
PLAYER_CHARACTER.transform.Rotate(new Vector3(0, 1, 0), 33.0f * Time.deltaTime);
}
if (Input.GetKeyUp(KeyCode.L))
{
Debug.Log(PlayerPrefs.GetString("Name"));
}
}
void DisableShoulderPads()
{
SHOULDER_PAD_R_01LOD0.SetActive(false);
SHOULDER_PAD_R_02LOD0.SetActive(false);
SHOULDER_PAD_R_03LOD0.SetActive(false);
SHOULDER_PAD_R_04LOD0.SetActive(false);
SHOULDER_PAD_L_01LOD0.SetActive(false);
SHOULDER_PAD_L_02LOD0.SetActive(false);
SHOULDER_PAD_L_03LOD0.SetActive(false);
SHOULDER_PAD_L_04LOD0.SetActive(false);
}
这是一个很长的脚本,但它很简单。在脚本顶部,我们定义了所有将引用模型角色中不同网格的变量。所有变量都是 GameObject 类型,除了PLAYER_SKIN变量,它是一个Material数据类型的数组。数组用于存储为角色模型创建的不同类型的纹理:
public void SetShoulderPad(Toggle id)
{
try
{
PlayerCharacter.ShoulderPad name
= (PlayerCharacter.ShoulderPad)Enum.Parse(typeof(PlayerCharacter.ShoulderPad), id.name, true);
if (id.isOn)
{
PlayerCharacterData.SelectedShoulderPad = name;
}
else
{
PlayerCharacterData.SelectedShoulderPad
= PlayerCharacter.ShoulderPad.none;
}
}
catch
{
// if the value passed is not in the enumeration set it to none
PlayerCharacterData.SelectedShoulderPad
= PlayerCharacter.ShoulderPad.none;
}
// disable before new selection
DisableShoulderPads();
switch (id.name)
{
case "SP01":
{
SHOULDER_PAD_R_01LOD0.SetActive(id.isOn);
SHOULDER_PAD_L_01LOD0.SetActive(id.isOn);
break;
}
...
case "SP04":
{
SHOULDER_PAD_R_04LOD0.SetActive(id.isOn);
SHOULDER_PAD_L_04LOD0.SetActive(id.isOn);
break;
}
}
}
public void SetShoulderPad(PlayerCharacter.ShoulderPad id)
{
// disable before new selection
DisableShoulderPads();
switch (id.ToString())
{
case "SP01":
{
SHOULDER_PAD_R_01LOD0.SetActive(true);
SHOULDER_PAD_L_01LOD0.SetActive(true);
break;
}
...
case "SP04":
{
SHOULDER_PAD_R_04LOD0.SetActive(true);
SHOULDER_PAD_L_04LOD0.SetActive(true);
break;
}
}
}
...https://github.com/PacktPublishing/Building-an-RPG-with-Unity-2018-Second-Edition
已定义了一些函数,这些函数由 UI 事件处理器调用。这些函数是:SetShoulderPad(Toggle id)、SetBodyType(Toggle id)、SetKneePad(Toggle id)、SetLegPlate(Toggle id)、SetWeaponType(Slider id)、SetHelmetType(Toggle id)、SetShieldType(Toggle id)、SetSkinType(Slider id)、SetBodyFat(Slider id)和SetBodySkinny(Slider id);
所有这些函数都接受一个参数,用于标识应启用或禁用的特定类型。
我们刚刚创建了一个工具,可以快速可视化角色定制。
您还可以使用我们刚刚构建的系统来创建您玩家角色的所有不同变体或非玩家角色模型,并将它们作为预制体存储!哇!这将为您节省大量创建代表不同野蛮人的角色的时间和精力!
保留我们的角色状态
现在我们已经花费时间定制了我们的角色,我们需要保留我们的角色并在我们的游戏中使用它。在 Unity 中,有一个名为 DontDestroyOnLoad() 的函数。
这是一个现在可以使用的优秀功能。它做什么?它将指定的 GameObject 在场景之间保持内存中。我们现在可以使用这些机制。然而,最终,您可能希望创建一个可以保存和加载用户数据的系统。
继续创建一个新的 C# 脚本,并将其命名为 DoNotDestroy.cs。这个脚本将会非常简单。以下是代码列表:
using UnityEngine;
using System.Collections;
public class DoNotDestroy : MonoBehaviour
{
// Use this for initialization
void Start()
{
DontDestroyOnLoad(this);
}
// Update is called once per frame
void Update()
{
}
}
在创建脚本后,将其附加到场景中的角色模型预制体上。不错;让我们快速回顾一下到目前为止我们已经做了什么。
概述
到现在为止,您应该有三个功能性的场景。我们有代表主菜单的场景,我们有代表初始级别的场景,我们刚刚创建了一个用于角色定制的场景。以下是到目前为止我们游戏的流程:

我们开始游戏,看到主菜单,选择“开始游戏”按钮进入角色定制场景,进行定制,然后点击“保存”按钮,加载第 1 级。
为了实现这一点,我们创建了以下 C# 脚本:
-
GameMaster.cs: 这用作跟踪游戏状态的主要脚本 -
BarbarianCharacterCustomization.cs: 这仅用于定制我们的角色 -
DoNotDestroy.cs: 这用于保存给定对象的状态 -
BarbarianCharacterController.cs: 这用于控制我们的角色动作 -
IKHandle.cs: 这用于实现脚部的逆运动学
当您将这些结合起来,现在您就拥有了一个良好的框架和流程,我们可以随着进展进行扩展和改进。
非玩家角色
到目前为止,我们一直专注于玩家角色。在本节中,我们将开始考虑我们的非玩家角色。让我们从我们的野蛮人开始。我们可以使用角色定制场景快速创建几个预制体,这些预制体将代表我们的独特野蛮人。
使用我们刚刚开发的工具,您可以进行调整,当对模型满意时,将代表您的角色玩家的 GameObject 拖放到“预制体”文件夹中。这将创建一个与您所看到的 GameObject 实例的副本,并将其保存为预制体。以下截图展示了我已经创建并存储为预制体的两个角色:

使用工具创建独特的角色
我向您展示的,如果做得恰当,可以为您节省数小时手动遍历模型结构并逐个启用和禁用不同网格的繁琐工作。换句话说,我们不仅创建了一个允许我们自定义游戏内玩家角色的场景,我们还创建了一个可以帮助我们快速自定义游戏内自己的角色模型以供使用的工具!
这里需要强调的另一个点是预制件的强大功能。将预制件想象成一个存储库,可以用来保存给定 GameObject 的状态,并在您的游戏环境中重复使用。当您更新预制件时,所有预制件的实例将自动更新!这很好,但与此同时,您必须小心不要因为同样的原因破坏任何东西。当您在附加到预制件的脚本上更新代码逻辑时,所有预制件的实例都将使用更新的脚本,因此您的一点点规划可以从长远来看节省大量时间和头疼。
非玩家角色基础
我们将使用新创建的预制件来实现我们的非玩家角色。由于角色模型之间存在一些相似之处,我们可以重用我们迄今为止创建的一些资产。
例如,所有角色都将继承在第三章中定义的BaseCharacter类,RPG 角色设计。它们还将包含我们为玩家角色已经创建的相同状态,并扩展一些专门为 NPC 设计的额外状态,如搜索和寻找。
我们已经使用我们的角色定制工具创建并保存了我们的非玩家角色;因此,我们对建模部分感到满意。我们需要集中精力的是我们非玩家角色的动作。我们需要创建一个新的动画控制器来处理我们 NPC 的状态。
设置非玩家角色
实现 NPC 的主要困难之一是赋予它真实智能的能力。这可以通过识别和实现我们 NPC 的几个关键区域来轻松实现。
我们需要将一些新组件附加到我们的 NPC 上。使用我们已保存的预制件,我们需要添加以下组件:
-
新的球体碰撞器,这将用于实现我们 NPC 的视野范围。
-
我们已经附加了一个动画组件,但我们需要创建一个新的动画控制器来捕捉 NPC 的新状态。
-
我们还需要添加一个导航网格代理组件。我们将为我们的 NPC 使用内置的导航和路径查找系统。
要添加球体碰撞器,您需要选择为 NPC 定义的预制件,并在检查器窗口中。选择添加组件 | 物理 | 球体碰撞器。这将为我们预制件附加一个球体碰撞器。
接下来,我们需要添加 Nav Mesh Agent. 再次,从检查器窗口中选择添加组件 | 导航 | Nav Mesh Agent。好的,现在我们已经设置了用于 NPC 的主要内置组件。
由于我们的预制件是我们玩家角色的实例,我们需要移除一些被携带过来的脚本组件。如果您的 NPC 预制件上附加了任何脚本,请现在移除它们。
如果您还没有这样做,请确保将 Tag 属性更改为 Untagged。
以下截图说明了我们到目前为止在 NPC 上设置的组件。这包括现有的组件,包括我们从玩家角色带来的脚本,以及将用于 NPC 的新增组件:

在执行下一步之前,请确保您在一个级别场景中。我将使用觉醒场景。
切换到您的可玩游戏场景之一。
下一步是设置我们的 Navmesh。要创建 Navmesh,我们需要进入导航窗口,通过选择 Window | 导航:

为了使 navmesh 正确工作,我们需要将场景中所有将要设置为静态的 GameObject 标记为导航静态。这将基于场景中的静态对象创建 navmesh;也就是说,在整个场景生命周期中不会移动的 GameObject:

在您的活动场景中,选择将要设置为导航静态的 GameObject,如图中所示(1),使用静态下拉菜单(2),并选择导航静态选项(3)。如果您的 GameObject 是具有子对象的父 GameObject,Unity 将询问您是否要将属性更改应用于所有子对象。
注意,我已经将所有环境 GameObject 放在一个名为 __Structure* 的 GameObject 下,以及后来添加的一些其他 GameObject。这样,如果我有许多静态对象,我可以将属性更改应用于父对象,子对象将自动继承更改。但请确保组中的所有内容都将保持静态!
完成此操作后,我们需要回到导航窗口并做一些调整。在导航选项卡中,选择地形并确保它设置为导航静态,并且导航区域设置为可通行:

在烘焙选项卡中,将代理半径更改为 0.3,代理高度更改为 1。保持其他属性不变。这将给 NPC 带来更多的灵活性,使其能够通过狭窄的角落:

当您准备好后,您可以在导航窗口的底部选择烘焙按钮。
Unity 需要一些时间来为您场景生成 Navmesh。这取决于您级别的复杂度。如果一切操作正确,您将看到类似于以下截图所示的 Navmesh:

Navmesh 生成
你看到的蓝色区域都是 NPC 可以导航到的区域:

NPC 动画控制器
我们现在需要为我们的 NPC 创建 动画控制器(AC)。动画控制器将使用来自 MeshAgent 的输入来控制和改变 NPC 的状态。我们还需要为 NPC AC 定义一些参数。这些将是:
-
AngularSpeed: 这将用于方向移动 -
Speed: 这将用于确定 NPC 移动的速度 -
Attack: 这将用于确定是否需要攻击 -
AttackWeight: 这可能被使用 -
PlayerInSight: 这将用于确定 PC 是否在视线范围内
在你的项目中创建一个新的动画控制器,并将其命名为 NPC_BarbarianAnimatorController。打开动画窗口。通过在动画窗口中右键单击并选择创建状态 | 从新混合树创建一个新的混合树。将名称更改为 NPC_Locomotion。双击它以便编辑混合树。也将节点名称更改为 NPC_Locomotion:

在检查器窗口中,将 混合类型 更改为 2D 自由形式笛卡尔:

x 轴将由 AngularSpeed 表示,y 轴将由 Speed 参数表示。
混合树将包含所有不同的运动动画状态。这些将是空闲、行走和跑步状态。
我为我的 NPC 设置了 11 个不同的动画状态,用于移动。以下截图将为你提供一个混合树的概述:

NPC 混合树
一旦你在混合树中包含了所有动画状态,你需要计算你动画的位置。一个简单的方法是选择 计算位置 下拉菜单并选择 AngularSpeed 和 Speed。这将根据根运动放置动画位置,如图所示:

你可以使用鼠标拖动截图中的红色点来预览你的动画状态。
NPC 攻击
为了实现我们的攻击模式,我们需要在动画控制器中创建一个新的层。继续创建一个新的层,并将其命名为 NPC_Attack。这个层将负责在我们进入攻击模式时对角色进行动画处理。
我们需要为这个层创建一个新的遮罩。这个遮罩将用于确定哪些人体部位会受到层动画的影响。要创建遮罩,在项目窗口中右键单击并选择创建 | 角色遮罩。将新遮罩命名为 NPC_BarbarianAttackMask。使用检查器窗口禁用我们不想受层动画影响的身体部位,如图所示:

你的层设置应该看起来像以下截图:

确保将权重属性更改为1,将分配给我们所创建的 Avatar Mask 的遮罩属性,并且还要确保 IK 属性被选中。现在我们就可以创建我们的攻击状态机了。
在动画器窗口中右键单击并选择创建状态 | 空状态。将你的攻击动画(们)拖放到空状态中。空状态用于在主层和返回之间有一个良好的过渡。
在你将攻击动画(们)放入动画器后,你需要使用过渡条件将它们连接起来。我已经将三个额外的参数添加到参数列表中,分别命名为 attack1、attack2 和 attack3。这些参数与攻击参数一起,将确定 NPC 将过渡到哪个攻击状态。
以下截图显示了配置到这一点的NPC_Attack层:

新参数
最后,你想要将新的NPC_BarbarianAnimatorController分配给 NPC 预制件(们)。
NPC AI
现在是时候给我们的 NPC 添加一些智能了。我们需要创建的一个脚本是将 NPC 的能力赋予它来检测玩家。这个脚本将被命名为NPC_BarbarianMovement.cs。这个脚本将用于检测玩家是否在视野中,计算 NPC 的视野,以及计算 NPC 到玩家角色的路径。
这里是源代码的列表:
using UnityEngine;
using UnityEngine.AI;
namespace com.noorcon.rpg2e
{
public class NPC_BarbarianMovement : MonoBehaviour
{
// reference to the animator
public Animator animator;
// these variables are used for the speed
// horizontal and vertical movement of the NPC
public float speed = 0.0f;
public float h = 0.0f;
public float v = 0.0f;
public bool attack = false; // used for attack mode 1
public bool jump = false; // used for jumping
public bool die = false; // are we alive?
// used for debugging
public bool DEBUG = false;
public bool DEBUG_DRAW = false;
// Reference to the NavMeshAgent component.
private NavMeshAgent nav;
// Reference to the sphere collider trigger component.
private SphereCollider col;
// where is the player character in relation to NPC
public Vector3 direction;
// how far away is the player character from NPC
public float distance = 0.0f;
// what is the angle between the PC and NPC
public float angle = 0.0f;
// a reference to the player character
public GameObject player;
// is the PC in sight?
public bool playerInSight;
// what is the field of view for our NPC?
// currently set to 110 degrees
public float fieldOfViewAngle = 110.0f;
// calculate the angle between PC and NPC
public float calculatedAngle;
void Awake()
{
// get reference to the animator component
animator = GetComponent<Animator>() as Animator;
// get reference to nav mesh agent
nav = GetComponent<NavMeshAgent>() as NavMeshAgent;
// get reference to the sphere collider
col = GetComponent<SphereCollider>() as SphereCollider;
// get reference to the player
player = GameObject.FindGameObjectWithTag("Player") as GameObject;
// we don't see the player by default
playerInSight = false;
}
// Use this for initialization
void Start()
{
}
void Update()
{
// if player is in sight let's slerp towards the player
if (playerInSight)
{
transform.rotation =
Quaternion.Slerp(this.transform.rotation,
Quaternion.LookRotation(direction), 0.1f);
}
}
// let's update our scene using fixed update
void FixedUpdate()
{
h = angle; // assign horizontal axis
v = distance; // assign vertical axis
// calculate speed based on distance and delta time
speed = distance / Time.deltaTime;
if (DEBUG)
Debug.Log(string.Format("H:{0} - V:{1} - Speed:{2}", h, v, speed));
// set the parameters defined in the animator controller
animator.SetFloat("Speed", speed);
animator.SetFloat("AngularSpeed", v);
animator.SetBool("Attack", attack);
}
好的,那么让我们实际看看这段代码试图做什么。在Awake()函数中,我们初始化将在脚本中使用的变量。我们有一个指向 NPC 附加的NavMeshAgent、SphereCollider和Animator组件的引用。这些分别存储在nav、col和anim变量中:
// if the PC is in our collider, we want to examine the location
// of the player
// calculate the direction based on our position and the
// player's position
// use the DOT product to get the angle between the two vectors
// calculate the angle between the NPC forward vector and the PC
// if it falls within the field of view, we have the player in
// sight
// if the player is in sight, we will set the nav agent destination
// if we are within a certain distance from the PC, the NPC has
// the ability to attack
void OnTriggerStay(Collider other)
{
if (other.transform.tag.Equals("Player"))
{
// Create a vector from the enemy to the player and store
// the angle between it and forward.
direction = other.transform.position - transform.position;
distance = Vector3.Distance(other.transform.position, transform.position) - 1.0f;
float DotResult = Vector3.Dot(transform.forward, player.transform.position);
angle = DotResult;
if (DEBUG_DRAW)
{
Debug.DrawLine(transform.position + Vector3.up, direction * 50, Color.gray);
Debug.DrawLine(other.transform.position, transform.position, Color.cyan);
}
playerInSight = false;
calculatedAngle = Vector3.Angle(direction, transform.forward);
if (calculatedAngle < fieldOfViewAngle * 0.5f)
{
RaycastHit hit;
if (DEBUG_DRAW)
Debug.DrawRay(transform.position + transform.up, direction.normalized, Color.magenta);
// ... and if a raycast towards the player hits something...
if (Physics.Raycast(transform.position + transform.up, direction.normalized, out hit,
col.radius))
{
// ... and if the raycast hits the player...
if (hit.collider.gameObject == player)
{
// ... the player is in sight.
playerInSight = true;
if (DEBUG)
Debug.Log("PlayerInSight: " + playerInSight);
}
}
}
if (playerInSight)
{
nav.SetDestination(other.transform.position);
CalculatePathLength(other.transform.position);
if (distance < 1.1f)
{
attack = true;
}
else
{
attack = false;
}
}
}
}
我们还需要获取对玩家和玩家动画组件的引用。这是通过player变量来完成的。我们还默认将playerInSight变量设置为 false:
void OnTriggerExit(Collider other)
{
if (other.transform.tag.Equals("Player"))
{
distance = 0.0f;
angle = 0.0f;
attack = false;
playerInSight = false;
}
}
// this is a helper function at this point
// in the future we will use it to calculate distance around
// the corners
// it currently is also used to draw the path of the nav mesh
// agent in the
// editor
float CalculatePathLength(Vector3 targetPosition)
{
// Create a path and set it based on a target position.
NavMeshPath path = new NavMeshPath();
if (nav.enabled)
nav.CalculatePath(targetPosition, path);
// Create an array of points which is the length of the number
// of corners in the path + 2.
Vector3[] allWayPoints = new Vector3[path.corners.Length + 2];
// The first point is the enemy's position.
allWayPoints[0] = transform.position;
// The last point is the target position.
allWayPoints[allWayPoints.Length - 1] = targetPosition;
// The points inbetween are the corners of the path.
for (int i = 0; i < path.corners.Length; i++)
{
allWayPoints[i + 1] = path.corners[i];
}
// Create a float to store the path length that is by default 0.
float pathLength = 0;
// Increment the path length by an amount equal to the
// distance between each waypoint and the next.
for (int i = 0; i < allWayPoints.Length - 1; i++)
{
pathLength += Vector3.Distance(allWayPoints[i], allWayPoints[i + 1]);
if (DEBUG_DRAW)
Debug.DrawLine(allWayPoints[i], allWayPoints[i + 1], Color.red);
}
return pathLength;
}
}
}
目前Update()函数没有执行任何重大操作。它只是检查玩家角色是否在视野中,如果是的话,确保 NPC 正在调整自己的方向以看向玩家。
我们代码的大部分内容都在OnTriggerStay()函数中。我们首先需要做的是确保进入我们碰撞器的对象是玩家对象。这是通过检查另一个碰撞器的标签属性来完成的。
如果玩家在我们的碰撞器内,我们就继续计算玩家相对于 NPC 的方向、距离和角度。这是通过以下行来完成的:
direction = other.transform.position - transform.position;
distance = Vector3.Distance(other.transform.position, transform.position) - 1.0f;
float DotResult = Vector3.Dot(transform.forward,player.transform.position);
angle = DotResult;
然后,如果角度小于fieldOfViewAngle变量,我们可以使用射线投射来确定是否能击中玩家。如果是这样,玩家就在 NPC 的视野中:

调试射线
NPC 需要执行的一个更关键的计算是如何在到达范围内后到达玩家。一旦我们确定玩家在范围内,并且我们面对玩家,我们需要让 NPC 找到到达玩家的路径。这就是NavMesh和NavMeshAgent发挥作用的地方。
CalculatePathLength()是一个函数,它接受玩家的位置,并使用网格数据,计算出从 NPC 位置到玩家位置的最佳路径。
然而,我们还在执行一个额外的计算,那就是计算两点之间的路径长度。这个长度计算将在未来用于执行以下操作:
如果路径长度超过我们设定的阈值,那么我们不会让 NPC 发起攻击。如果它在平均值范围内,那么我们可以让 NPC 向玩家移动以进行战斗。
在最后一个函数OnTriggerExit()中,我们将playerInSight变量设置为 false。这将阻止 NPC 追逐玩家:

Navmesh path
上述截图展示了基于实时计算的 NPC 和玩家之间的路径。
如果你还没有这样做,请将脚本附加到 NPC 预制体上,并运行应用程序以测试它。如果一切正常,那么你将能够移动玩家角色在关卡中移动,一旦玩家角色进入 NPC 视野,NPC 将开始向玩家移动,并在足够接近时攻击:

到目前为止,你的 NPC 应该在其预制体上附加以下组件:
-
Animator
-
Rigidbody
-
Capsule 和 sphere colliders
-
NavMesh Agent
-
NPC_BarbarianMovement脚本
我们已经覆盖了大量的信息。我鼓励你花时间再次阅读它,并在继续之前理解这些概念。
PC 和 NPC 交互
到目前为止,我们已经为我们的 PC 和 NPC 创建了基本移动。我接下来想要完成的是 PC 和 NPC 角色的攻击机制。让我们先实现 NPC 的击中效果。
根据我们在上一节中创建的代码,我们的 NPC 能够检测到玩家角色。当玩家角色在视野中时,NPC 将找到到达玩家角色的最短路径,并在给定范围内攻击玩家角色。我们已经完成了移动和动画机制。下一个目标是跟踪 NPC 攻击时的生命值。
我们需要在NPC_Animator_Controller中进行一些调整。打开 Animator 窗口,并选择NPC_Attack层:

NPC 攻击层
双击attack1状态,或者你在状态机中定义的攻击状态。这将在检查器窗口中打开相关的动画。
在检查器窗口中,向下滚动到 曲线 部分。我们将通过在 曲线 部分的 (+) 符号下选择来创建一个新的曲线。我们还将创建一个新的参数,称为 Attack1C,以表示曲线的值。此参数应为 float 类型:

前一个屏幕截图显示的曲线将基于你的动画:

在前一个屏幕截图中,我标记了你需要与之交互以配置动画曲线的接口的重要部分。第一步是实际预览你的动画,并对其有一个感觉。
我特定的动画序列的下一步是确定模型的右手何时进入,我在曲线上设置了一个标记。我在动画中稍后位置又设置了一个标记,此时右手已经从右侧很好地跨到了左侧。
这些标记将在 NPC 攻击模式下的动画中指示击中点:

好的,我们为什么要这样做呢?简单。这将帮助我们仅根据动画曲线生成击中效果。这样,当武器远离玩家身体时,我们不会击中玩家并减少玩家的生命值。
接下来,我们需要更新我们的 NPC_BarbarianMovement.cs 代码来编程 NPC 攻击。
注意:我只列出了已更新的部分。
这是代码的更新列表:
using UnityEngine;
using System.Collections;
public class NPC_Movement : MonoBehaviour
{
...
void Update()
{
// if player is in sight let's slerp towards the player
if (playerInSight)
{
this.transform.rotation =
Quaternion.Slerp(this.transform.rotation,
Quaternion.LookRotation(direction), 0.1f);
}
if(this.player.transform.GetComponent<CharacterController>().die)
{
animator.SetBool("Attack", false);
animator.SetFloat("Speed", 0.0f);
animator.SetFloat("AngularSpeed", 0.0f);
}
}
// let's update our scene using fixed update
void FixedUpdate()
{
h = angle; // assign horizontal axis
v = distance; // assign vertical axis
// calculate speed based on distance and delta time
speed = distance / Time.deltaTime;
if (DEBUG)
Debug.Log(string.Format("H:{0} - V:{1} - Speed:{2}", h, v, speed));
// set the parameters defined in the animator controller
animator.SetFloat("Speed", speed);
animator.SetFloat("AngularSpeed", v);
animator.SetBool("Attack", attack1);
animator.SetBool("Attack1", attack1);
if(playerInSight)
{
if (animator.GetFloat("Attack1C") == 1.0f)
{
this.player.GetComponent<PlayerAgent>().playerCharacterData.HEALTH -= 1.0f;
}
}
}
...
}
代码的新增部分检查玩家是否在视线范围内,如果是这样,我们检查我们是否在攻击范围内。如果是这样,我们就进入攻击模式。如果我们处于攻击模式,就播放攻击动画。
在代码中,我们检查新创建的参数 Attack1 的值,如果它恰好是 1.0,我们就继续减少玩家角色的生命值。
如果玩家在 NPC 攻击时死亡,它将停止攻击并返回空闲状态。
好吧,你可能想知道我们是如何获得从玩家角色获取信息的能力的。这是因为我们需要创建一些额外的 C# 脚本。让我们继续这样做。创建以下 C# 脚本:
-
PlayerCharacter.cs: 这将是我们的玩家角色类,它将继承我们之前定义的BaseCharacter类 -
PlayerAgent.cs: 这将用于存储玩家数据并继承MonoBehaviour -
NPC.cs: 这将是我们的非玩家角色类,它将继承自BaseCharacter类 -
NPC_Agent.cs: 这将用于存储 NPC 数据并继承MonoBehaviour
我对 BaseCharacter.cs 脚本进行了一些修改,使其通过编辑器更容易访问。以下是新的列表:
using System;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class BaseCharacter
{
[SerializeField]
public string Name;
[SerializeField]
public string Description;
[SerializeField]
public float Strength;
[SerializeField]
public float Defense;
[SerializeField]
public float Dexterity;
[SerializeField]
public float Intelligence;
[SerializeField]
public float Health;
}
}
我已经创建了类和字段,使其可序列化。
让我们看看 PlayerCharacter.cs 的列表:
using System;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class PlayerCharacter : MonoBehaviour
{
}
}
目前那里没有什么动作。现在让我们看看PlayerAgent.cs:
using System;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class PlayerAgent : MonoBehaviour
{
public PlayerCharacter playerCharacterData;
void Awake()
{
PlayerCharacter tmp = new PlayerCharacter();
tmp.Name = "Maximilian";
tmp.Health = 100.0f;
tmp.Defense = 50.0f;
tmp.Description = "Our Hero";
tmp.Dexterity = 33.0f;
tmp.Intelligence = 80.0f;
tmp.Strength = 60.0f;
playerCharacterData = tmp;
}
// Use this for initialization
void Start()
{
}
// Update is called once per frame
void Update()
{
if (playerCharacterData.Health < 0.0f)
{
playerCharacterData.Health = 0.0f;
transform.GetComponent<BarbarianCharacterController>().die = true;
}
}
}
}
在玩家代理代码中,我们在Awake()函数中为我们的 PC 数据初始化了一些默认值。由于类已被序列化,我们实际上可以在运行时看到这些数据,用于调试目的。
在Update()函数中,我们检查我们的 PC 的健康值是否小于 0.0f,如果是,那么这表明玩家已经死亡。然后我们使用我们创建的CharacterController组件将死亡属性设置为 true。CharacterController随后将使用新值并与玩家角色的动画控制器通信,使玩家角色进入死亡状态。
注意,我们的NPC_BarbarianMovement.cs脚本正在通过脚本中创建的引用访问确切的 PC 数据。
您需要将PlayerAgent.cs脚本附加到场景中的玩家角色上:

玩家角色数据
在前面的屏幕截图中,您可以看到我们对脚本所做的添加以及它们在运行时的外观。我们将在未来的章节中列出NPC.cs和NPC_Agent.cs。目前,它们尚未使用。
摘要
这一章内容非常丰富。我们涵盖了章节中一些非常重要的主题和概念,这些可以用于并增强您的游戏。我们通过研究如何定制玩家角色开始了这一章。您从该部分学到的概念可以应用于各种不同的场景。
我们研究了如何理解您的人物模型的结构,以便您可以更好地确定定制方法。这些包括不同的武器、服装、盔甲、盾牌等等。
我们接着研究了如何创建用户界面,以帮助我们在游戏过程中定制我们的玩家角色。我们还了解到,我们开发的工具可以快速创建几个不同的角色模型(定制)并将它们作为预制件存储以供以后使用。节省了大量时间!我们还学习了如何在定制后保存玩家角色的游戏状态。
我们接下来研究了非玩家角色。我们了解了如何设置 NPC 的不同必要组件的基础知识。然后我们研究了如何创建导航网格以及如何使用导航网格与导航网格代理和路径查找进行交互。
我们为 NPC 创建了一个新的动画控制器。我们创建了一个用于 NPC 动画的 2D 自由形式笛卡尔混合树。我们研究了如何在动画控制器中创建多个层级并启用不同区域的人形骨骼的逆运动学(IK)。我们创建了初始的 NPC AI 脚本,用于检测并确定玩家是否足够接近,以便它进行移动和攻击。最后,我们创建了新的脚本,使 NPC 与玩家角色之间的交互成为可能。
到本章结束时,你应该对一切是如何相互关联的有一个很好的理解,并且对如何处理你的项目有一个想法。
在下一章中,我们将创造一种更好的方法来管理我们的游戏状态。
第五章:游戏大师和游戏机制
在第一章到第四章中,我们学习了如何制作我们 RPG 设计和实现所需的一些必要组件。例如,你应该对如何组织和安排你的玩家角色和非玩家角色资产和组件有一个很好的理解。
下面是本章内容的概述:
-
游戏大师
-
管理游戏设置和场景
-
场景管理
-
-
完善 GameMaster
-
级别控制器
-
音频控制器
-
-
玩家数据管理
-
PC 类增强
-
角色定制类更新
-
-
UI 控制器的更改
-
测试
在本章中,我们将对迄今为止所做的一切进行更多调整和更新。
游戏大师
尽管我们已经创建了 GameMaster.cs 脚本,但我们并没有真正利用它来管理我们的游戏。我们创建了一些游戏资产的部分,并使用它们进行快速测试。现在是时候开始考虑如何将所有这些结合起来,为我们的 RPG 创建一个更好的游戏管理器。
我希望 GameMaster.cs 执行以下几件事情。具体如下:
-
每个特定场景的 UI 控制器引用
-
在场景中拥有玩家角色的引用
-
在场景中拥有非玩家角色(NPC)的引用
-
拥有音频源的引用以进行控制
-
应始终有一个
GameMaster类的实例可用
在创建我们的 GameMaster 时,我们将根据需要添加或删除一些元素。让我们从将用户界面与 GameMaster 集成开始。
打开主菜单场景。它应该看起来像以下截图:

以下截图显示了为我们设计的 RPG 主菜单:

这相当简单,只是为了说明概念。当玩家点击选项按钮时,他们将获得选项窗口,在那里他们可以调整游戏中的音量。完成操作后,他们将点击关闭按钮并返回主菜单。
这是主菜单场景的层次结构窗口截图:

管理游戏设置和音频
创建一个名为 uiController 的空 GameObject。我们现在需要创建一个 UI 控制器脚本,该脚本将处理用户交互。创建一个新的 C# 脚本并命名为 UiController.cs。
注意:随着我们的进展,本章中的脚本将进行更新和修改。
UI 控制器的列表如下:
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class UiController : MonoBehaviour
{
public RectTransform OptionsPanel;
public Slider ControlMainVolume;
public Slider ControlFXVolume;
public void Update()
{
}
public void DisplaySettings()
{
GameMaster.instance.DisplaySettings = !GameMaster.instance.DisplaySettings;
OptionsPanel.gameObject.SetActive(GameMaster.instance.DisplaySettings);
}
public void MainVolume()
{
GameMaster.instance.MasterVolume(ControlMainVolume.value);
}
public void FXVolume()
{
GameMaster.instance.SoundFxVolume(ControlFXVolume.value);
}
}
}
目前,我们只定义了几个函数:DisplaySettings() 和 MainVolume()。这些函数非常简单;它们引用了用于显示设置面板以及检索音量控制滑块值的 UI 组件。然后,这些信息被传递到 GameMaster.cs 脚本以进行进一步处理。
我们需要对 GameMaster.cs 脚本进行一些更改。以下是代码列表:
using UnityEngine;
using UnityEngine.SceneManagement;
namespace com.noorcon.rpg2e
{
public static class SceneName
{
public const string MainMenu = "MainMenu";
public const string CharacterCustomization = "CharacterCustomization";
public const string Level_1 = "Awakening";
}
public class GameMaster : MonoBehaviour
{
public static GameMaster instance;
// let's have a reference to the player character
// and start position of player character
public GameObject Pc;
public GameObject StartPosition;
public GameObject CharacterCustomization;
// let's have a reference to the current scene/level
public Scene CurrentScene;
// Ref to UI Elements ...
public bool DisplaySettings = false;
public UiController uiController;
public int Level = 0;
// initial audio levels for background and
// sound FX
public float AudioLevel = 0.33f;
public float FxLevel = 0.33f;
void Awake()
{
// simple singleton
if (instance == null)
{
instance = this;
}
else if (instance != this)
{
Destroy(this);
}
// keep the game object when moving from
// one scene to the next scene
DontDestroyOnLoad(this);
}
// Use this for initialization
void Start()
{
// let's find a reference to the UI controller of the loaded scene
if (GameObject.FindGameObjectWithTag("UI") != null)
{
instance.uiController = GameObject.FindGameObjectWithTag("UI").GetComponent<UiController>();
}
instance.uiController.OptionsPanel.gameObject.SetActive(instance.DisplaySettings);
}
// Update is called once per frame
void Update()
{
}
public void MasterVolume(float volume)
{
instance.AudioLevel = volume;
instance.GetComponent<AudioSource>().volume = instance.AudioLevel;
}
public void StartGame()
{
// NOTE: Start the game, load the scene that allows the player
// to customize their character
SceneManager.LoadScene(SceneName.CharacterCustomization);
}
}
}
这段代码需要一点解释。首先要理解的最重要概念是 Singleton 的概念。这是通过首先定义一个静态变量来完成的,该变量将用于保存我们的 GameMaster 实例:
public static GameMaster instance;
然后,在我们的 Awake() 函数中,我们需要以下代码:
void Awake()
{
// simple singleton
if (instance == null)
{
instance = this;
}
else if (instance != this)
{
Destroy(this);
}
// keep the game object when moving from
// one scene to the next scene
DontDestroyOnLoad(this);
}
在 Awake() 函数中,我们正在检查 instance 变量是否已初始化。它将 instance 变量初始化一次。接下来的检查确保我们始终只有一个实例。换句话说,如果由于错误而再次实例化 GameMaster 对象,它将被销毁。代码的最后一条,DotDestroyOnLoad(),将确保当从当前场景移动到下一个场景时,GameObject 不会被销毁。
在 Start() 函数中,我们检查 uiController 是否存在,如果存在,我们就获取它的引用。一旦我们有了 uiController 的引用,我们确保设置面板默认是禁用的,也就是说,是隐藏的。
MasterVolume() 函数由 UIController.cs 脚本调用,然后传递从定义的滑块中获取的实际音量值来控制背景音乐的音量。
管理场景
下一个我想实现的是让 GameMaster 控制游戏的不同场景加载。让我们看看 GameMaster.cs 将如何看起来,随着场景管理的添加:
using UnityEngine;
using UnityEngine.SceneManagement;
namespace com.noorcon.rpg2e
{
public static class SceneName
{
public const string MainMenu = "MainMenu";
public const string CharacterCustomization =
"CharacterCustomization";
public const string Level_1 = "Awakening";
}
[RequireComponent(typeof(AudioSource))]
public class GameMaster : MonoBehaviour
{
public static GameMaster instance;
// let's have a reference to the player character
// and start position of player character
public GameObject Pc;
public GameObject StartPosition;
public GameObject CharacterCustomization;
// let's have a reference to the current scene/level
public Scene CurrentScene;
// Ref to UI Elements ...
public bool DisplaySettings = false;
public UiController Ui;
public int Level = 0;
// initial audio levels for background and
// sound FX
public float AudioLevel = 0.33f;
public float FxAudioLevel = 0.33f;
void Awake()
{
// simple singlton
if (instance == null)
{
instance = this;
}
else if (instance != this)
{
Destroy(this);
}
// keep the game object when moving from
// one scene to the next scene
DontDestroyOnLoad(this);
}
// for each level/scene that has been loaded
// do some of the preparation work
void OnLevelWasLoaded()
{
instance.CurrentScene = SceneManager.GetActiveScene();
if (instance.CurrentScene.name.Equals(SceneName.CharacterCustomization))
{
if (GameObject.FindGameObjectWithTag("BASE") != null)
{
instance.CharacterCustomization = GameObject.FindGameObjectWithTag("BASE") as GameObject;
}
}
// If we are at any other scene except character customization
// let's go ahead and get reference to player and player
// stat position
if (!this.CurrentScene.name.Equals(SceneName.CharacterCustomization))
{
// let's get a reference to our player character
if (instance.Pc == null)
{
if (GameObject.FindGameObjectWithTag("Player") != null)
{
instance.Pc = GameObject.FindGameObjectWithTag("Player") as GameObject;
}
}
if (GameObject.FindGameObjectWithTag("START_POSITION") != null)
...
我们已经讨论了 Awake() 函数的作用,现在让我们看看下一个重要的函数,OnLevelWasLoaded()。看看以下代码:
public void MasterVolume(float volume)
{
instance.AudioLevel = volume;
instance.GetComponent<AudioSource>().volume = instance.AudioLevel;
}
public void SoundFxVolume(float volume)
{
instance.FxAudioLevel = volume;
}
public void StartGame()
{
// NOTE: Start the game, load the scene that allows the player
// to customize their character
SceneManager.LoadScene(SceneName.CharacterCustomization);
}
public void LoadLevel()
{
switch (instance.Level)
{
// load level 1
case 1:
{
instance.Pc = GameObject.FindGameObjectWithTag("Player") as GameObject;
SceneManager.LoadScene(SceneName.Level_1);
break;
}
}
}
}
}
OnLevelWasLoaded() 函数是在场景加载后由 Unity 调用的。我们在 GameMaster 脚本中使用此函数执行一些任务。我们首先做的事情是获取我们当前所在的场景。这些信息将用于稍后确定 GameMaster 将执行什么操作。
我们检查是否处于角色定制场景。这是玩家在开始游戏之前可以定制 PC 的地方。如果我们处于角色定制场景,我们想要获取场景中 Base GameObject 的引用。如果你还记得,Base GameObject 上附加了 CharacterCutomization.cs 脚本,该脚本用于定制角色。
如果我们处于任何其他场景,那么我们想要获取玩家角色的引用,以及场景开始时玩家角色的起始位置(如果有的话)。
然后,我们使用 DetermineLevel() 函数来确定我们当前所在的级别,以便进行更多配置。
当前实现用于启动游戏和加载级别的两个函数由 StartGame() 函数和 LoadLevel() 函数处理。
public static class SceneName
{
public const string MainMenu = "MainMenu";
public const string CharacterCustomization = "CharacterCustomization";
public const string Level_1 = "Awakening";
}
SceneName 类旨在使在 C# 代码中引用场景名称变得更加容易。这使得在项目中更改实际场景名称变得更容易,但代码中的调用名称保持一致。
到目前为止,一切都很顺利,但我们可以尝试让它变得更好。
改进 GameMaster
我们目前拥有的代码是可行的,但它并不十分整洁。让我们继续改进代码结构。让我们创建一个新的脚本,命名为GameLevelController.cs。这个新的脚本将处理我们的等级管理逻辑。
等级控制器
GameLevelController.cs的代码列表如下:
using UnityEngine;
using UnityEngine.SceneManagement;
namespace com.noorcon.rpg2e
{
public static class SceneName
{
public const string MainMenu = "MainMenu";
public const string CharacterCustomization = "CharacterCustomization";
public const string Level_1 = "Awakening";
}
public class GameLevelController
{
// let's have a reference to the current scene/level
public Scene CurrentScene
{
get { return SceneManager.GetActiveScene(); }
}
// keep the numerical level value
public int Level = 0;
public void OnLevelWasLoaded()
{
// if we are in the character customization scene,
// let's get a reference to the Base game object for future use.
if (CurrentScene.Equals(SceneName.CharacterCustomization))
{
if (GameObject.FindGameObjectWithTag("Base") != null)
{
GameMaster.instance.CharacterCustomization = GameObject.FindGameObjectWithTag("Base") as GameObject;
}
}
// If we are at any other scene except character customization
// let's go ahead and get reference to player and player
// stat position
if (CurrentScene.name.Equals(SceneName.CharacterCustomization))
{
// let's get a reference to our player character
if (GameMaster.instance.Pc == null)
{
if (GameObject.FindGameObjectWithTag("Player") != null)
{
GameMaster.instance.Pc = GameObject.FindGameObjectWithTag("Player") as GameObject;
}
}
if (GameObject.FindGameObjectWithTag("StartPosition") != null)
{
GameMaster.instance.StartPosition = GameObject.FindGameObjectWithTag("StartPosition") as GameObject;
}
if (GameMaster.instance.StartPosition != null && GameMaster.instance.Pc != null)
{
GameMaster.instance.Pc.transform.position = GameMaster.instance.StartPosition.transform.position;
GameMaster.instance.Pc.transform.rotation = GameMaster.instance.StartPosition.transform.rotation;
}
}
// determine what level we are on
DetermineLevel();
}
// this function will set a numerical value for our levels
private void DetermineLevel()
{
switch (CurrentScene.name)
{
case SceneName.MainMenu:
case SceneName.CharacterCustomization:
{
Level = 0;
break;
}
case SceneName.Level_1:
{
Level = 1;
GameMaster.instance.Pc.GetComponent<IKHandle>().enabled = true;
break;
}
default:
{
Level = 0;
break;
}
}
}
.....
我所做的是基本上将所有处理等级管理的代码移动到了GameLevelController.cs文件中。我们的GameMaster脚本来驱动 LevelController 类。我们稍后会看到这一点。
音频控制器
下一个代码清理工作是我想要对音频进行的。让我们创建一个新的脚本,命名为GameAudioController.cs。新脚本的代码如下:
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class GameAudioController : MonoBehaviour
{
// initial audio levels for background and
// sound FX
public float AudioLevel = 0.33f;
public float FxAudioLevel = 0.33f;
public AudioSource audioSource;
public void SetDefaultVolume()
{
audioSource.volume = AudioLevel;
}
public void MasterVolume(float volume)
{
AudioLevel = volume;
audioSource.volume = AudioLevel;
}
public void SoundFxVolume(float volume)
{
FxAudioLevel = volume;
}
}
}
代码相当直接。现在,让我们看看GameMaster.cs的样子:
using UnityEngine;
namespace com.noorcon.rpg2e
{
[RequireComponent(typeof(AudioSource))]
public class GameMaster : MonoBehaviour
{
public static GameMaster instance;
// let's have a reference to the player character
// and start position of player character
public GameObject Pc;
public GameObject StartPosition;
public GameObject CharacterCustomization;
public GameLevelController LevelController;
public GameAudioController AudioController;
// Ref to UI Elements ...
public bool DisplaySettings = false;
public UiController Ui;
void Awake()
{
// simple singlton
if (instance == null)
{
instance = this;
// initialize level controller
instance.LevelController = new GameLevelController();
// initialize audio controller
instance.AudioController = new GameAudioController();
instance.AudioController.audioSource = instance.GetComponent<AudioSource>();
instance.AudioController.SetDefaultVolume();
}
else if (instance != this)
{
Destroy(this);
}
// keep the game object when moving from
// one scene to the next scene
DontDestroyOnLoad(this);
}
// for each level/scene that has been loaded
// do some of the preparation work
void OnLevelWasLoaded()
{
instance.LevelController.OnLevelWasLoaded();
}
// Use this for initialization
void Start()
{
// let's find a reference to the UI controller of the loaded scene
if (GameObject.FindGameObjectWithTag("Ui") != null)
{
instance.Ui = GameObject.FindGameObjectWithTag("Ui").GetComponent<UiController>();
}
instance.Ui.OptionsPanel.gameObject.SetActive(instance.DisplaySettings);
}
// Update is called once per frame
void Update()
{
}
public void MasterVolume(float volume)
{
instance.AudioController.MasterVolume(volume);
}
public void SoundFxVolume(float volume)
{
instance.AudioController.SoundFxVolume(volume);
}
public void StartGame()
{
instance.LoadLevel();
}
public void LoadLevel()
{
instance.LevelController.LoadLevel();
}
}
}
如你所见,代码更容易阅读,并且结构也更好。GameMaster 正在使用控制器来执行每个具体任务。这也使得维护我们游戏中不同任务中的代码更容易。例如,所有与音频相关的代码现在可以实现在控制器中,等等。
为了使所有这些工作,你必须确保你已经正确地将你的uiControllerGameObject 连接到UiController.cs类,以便当用户与选项/设置菜单交互时进行通信,如下面的截图所示:

UI 元素事件
你的项目现在应该已经定义了以下标签:

这些在 C#代码中用于在运行时识别 GameObject。
玩家数据管理
我们还没有保存代表玩家定制的数据的实际数据。下一步是增强我们的PlayerCharacter.cs和BarbarianCharacterCustomization.cs脚本,以便实际上在我们的 PC 对象中保存所选数据。
PC 类增强
要做到这一点,我们需要修改我们的PlayerCharacter.cs代码。新的代码列表如下:
using System;
namespace com.noorcon.rpg2e
{
[Serializable]
public class PlayerCharacter : BaseCharacter
{
public enum ShoulderPad
{
none = 0,
SP01 = 1,
SP02 = 2,
SP03 = 3,
SP04 = 4
};
public enum BodyType { normal = 1, BT01 = 2, BT02 = 3
};
// Shoulder Pad
public ShoulderPad selectedShoulderPad = ShoulderPad.none;
public BodyType selectedBodyType = BodyType.normal;
public bool kneePad = false;
public bool legPlate = false;
public enum WeaponType
{
none = 0,
axe1 = 1,
axe2 = 2,
club1 = 3,
club2 = 4,
falchion = 5,
gladius = 6,
mace = 7,
maul = 8,
scimitar = 9,
spear = 10,
sword1 = 11,
sword2 = 12,
sword3 = 13
};
public WeaponType selectedWeapon = WeaponType.none;
public enum HelmetType { none = 0, HL01 = 1, HL02 = 2, HL03 = 3, HL04 = 4 };
public HelmetType selectedHelmet = HelmetType.none;
public enum ShieldType { none = 0, SL01 = 1, SL02 = 2 };
public ShieldType selectedShield = ShieldType.none;
public int SKIN_ID = 1;
public enum ShoeType { none = 0, BT01 = 1, BT02 = 2 };
public ShoeType selectedBoot = ShoeType.none;
}
}
我们定义了几个枚举类型,用于描述玩家角色定制的不同部分。使用枚举在我们的代码中有几个优点,其中一些是命名常量,名称描述了它们的作用,类型安全,并且更容易更改枚举的值,而无需检查代码中的数百个不同位置。
如前几章所述,角色定制代码与你的角色模型以及你如何为游戏使用角色模型绑定紧密相关。
你需要修改你的 UI 元素的名称,以匹配新的代码。
看看下面的截图:

连接 UI 元素事件
为了确保代码能够正常工作,你需要配置一些事情。首先,你需要正确命名你的 UI 元素,以匹配枚举。前面的截图展示了代表肩垫的一个 UI 元素。
角色定制类更新
驱动角色定制的活动附加到具有 CharacterCustomization.cs 脚本组件的 Base 预制件上。CharacterCustomization.cs 脚本如下所示:
using System;
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class BarbarianCharacterCustomization : MonoBehaviour
{
public GameObject PLAYER_CHARACTER;
public PlayerCharacter PlayerCharacterData;
public Material[] PLAYER_SKIN;
...
// Use this for initialization
void Start()
{
PlayerCharacterData = PLAYER_CHARACTER.GetComponent<PlayerAgent>().playerCharacterData;
}
public bool ROTATE_MODEL = false;
// Update is called once per frame
void Update()
{
if (Input.GetKeyUp(KeyCode.R))
{
ROTATE_MODEL = !ROTATE_MODEL;
}
if (ROTATE_MODEL)
{
PLAYER_CHARACTER.transform.Rotate(new Vector3(0, 1, 0), 33.0f * Time.deltaTime);
}
if (Input.GetKeyUp(KeyCode.L))
{
Debug.Log(PlayerPrefs.GetString("Name"));
}
}
void DisableShoulderPads()
{
SHOULDER_PAD_R_01LOD0.SetActive(false);
SHOULDER_PAD_R_02LOD0.SetActive(false);
SHOULDER_PAD_R_03LOD0.SetActive(false);
SHOULDER_PAD_R_04LOD0.SetActive(false);
SHOULDER_PAD_L_01LOD0.SetActive(false);
SHOULDER_PAD_L_02LOD0.SetActive(false);
SHOULDER_PAD_L_03LOD0.SetActive(false);
SHOULDER_PAD_L_04LOD0.SetActive(false);
}
public void SetShoulderPad(Toggle id)
{
try
{
PlayerCharacter.ShoulderPad name
= (PlayerCharacter.ShoulderPad)Enum.Parse(typeof(PlayerCharacter.ShoulderPad), id.name, true);
if (id.isOn)
{
PlayerCharacterData.SelectedShoulderPad = name;
}
else
{
PlayerCharacterData.SelectedShoulderPad
= PlayerCharacter.ShoulderPad.none;
}
}
catch
{
// if the value passed is not in the enumeration set it to none
PlayerCharacterData.SelectedShoulderPad
= PlayerCharacter.ShoulderPad.none;
}
// disable before new selection
DisableShoulderPads();
switch (id.name)
{
case "SP01":
{
SHOULDER_PAD_R_01LOD0.SetActive(id.isOn);
SHOULDER_PAD_L_01LOD0.SetActive(id.isOn);
break;
}
...
case "SP04":
{
SHOULDER_PAD_R_04LOD0.SetActive(id.isOn);
SHOULDER_PAD_L_04LOD0.SetActive(id.isOn);
break;
}
}
}
...
在列出的代码中,我们所做的是添加了一个名为 PlayerCharacterData 的新变量,其类型为 PlayerCharacter。PlayerCharacter 类是我们定义并增强的玩家角色类,用于包含我们的玩家角色的数据,如下所示:
public void SetKneePad(Toggle id)
{
KNEE_PAD_R_LOD0.SetActive(id.isOn);
KNEE_PAD_L_LOD0.SetActive(id.isOn);
}
public void SetLegPlate(Toggle id)
{
LEG_PLATE_R_LOD0.SetActive(id.isOn);
LEG_PLATE_L_LOD0.SetActive(id.isOn);
}
void DisableWeapons()
{
AXE_01LOD0.SetActive(false);
AXE_02LOD0.SetActive(false);
CLUB_01LOD0.SetActive(false);
CLUB_02LOD0.SetActive(false);
FALCHION_LOD0.SetActive(false);
GLADIUS_LOD0.SetActive(false);
MACE_LOD0.SetActive(false);
MAUL_LOD0.SetActive(false);
SCIMITAR_LOD0.SetActive(false);
SPEAR_LOD0.SetActive(false);
SWORD_BASTARD_LOD0.SetActive(false);
SWORD_BOARD_01LOD0.SetActive(false);
SWORD_SHORT_LOD0.SetActive(false);
}
public void SetWeaponType(Slider id)
{
try
{
PlayerCharacter.WeaponType weapon = (PlayerCharacter.WeaponType)Convert.ToInt32(id.value);
PlayerCharacterData.SelectedWeapon = weapon;
}
catch
{
PlayerCharacterData.SelectedWeapon = PlayerCharacter.WeaponType.none;
}
// disable weapons
DisableWeapons();
switch (Convert.ToInt32(id.value))
{
case 0:
{
DisableWeapons();
break;
}
case 1:
{
AXE_01LOD0.SetActive(true);
break;
}
...
case 13:
{
SWORD_SHORT_LOD0.SetActive(true);
break;
}
}
}
public void SetWeaponType(PlayerCharacter.WeaponType id)
{
// disable Weapons
DisableWeapons();
switch (Convert.ToInt32(id))
{
case 0:
{
DisableWeapons();
break;
}
case 1:
{
AXE_01LOD0.SetActive(true);
break;
}
...
case 13:
{
SWORD_SHORT_LOD0.SetActive(true);
break;
}
}
}
我们接下来需要实现的是检测玩家通过角色定制 UI 选择的哪个选项,并适当地设置 PlayerCharacter 对象中的数据。请看以下代码:
void DisableHelmets()
{
HELMET_01LOD0.SetActive(false);
HELMET_02LOD0.SetActive(false);
HELMET_03LOD0.SetActive(false);
HELMET_04LOD0.SetActive(false);
}
public void SetHelmetType(Toggle id)
{
try
{
PlayerCharacter.HelmetType helmet
= (PlayerCharacter.HelmetType)Enum.Parse(typeof(PlayerCharacter.HelmetType), id.name, true);
if (id.isOn)
{
PlayerCharacterData.SelectedHelmet = helmet;
}
else
{
PlayerCharacterData.SelectedHelmet
= PlayerCharacter.HelmetType.none;
}
}
catch
{
// if the value passed is not in the enumeration set it to none
PlayerCharacterData.SelectedHelmet
= PlayerCharacter.HelmetType.none;
}
// disable helmets
DisableHelmets();
switch (id.name)
{
case "HL01":
{
HELMET_01LOD0.SetActive(id.isOn);
break;
}
case "HL02":
{
HELMET_02LOD0.SetActive(id.isOn);
break;
}
case "HL03":
{
HELMET_03LOD0.SetActive(id.isOn);
break;
}
case "HL04":
{
HELMET_04LOD0.SetActive(id.isOn);
break;
}
}
}
...
public void SetShieldType(PlayerCharacter.ShieldType id)
{
switch (id.ToString())
{
case "SL01":
{
SHIELD_01LOD0.SetActive(true);
SHIELD_02LOD0.SetActive(false);
break;
}
case "SL02":
{
SHIELD_01LOD0.SetActive(false);
SHIELD_02LOD0.SetActive(true);
break;
}
}
}
public void SetSkinType(Slider id)
{
PlayerCharacterData.SkinId = Convert.ToInt32(id.value);
SKN_LOD0.GetComponent<Renderer>().material = PLAYER_SKIN[System.Convert.ToInt32(id.value)];
}
...
对于所有可以定制的玩家角色不同部分,实现概念是相同的。以下是一个例子:
public void SetClothingType(Toggle id)
{
try
{
PlayerCharacter.ClothingType clothing
= (PlayerCharacter.ClothingType)Enum.Parse(typeof(PlayerCharacter.ClothingType), id.name, true);
if (id.isOn)
{
PlayerCharacterData.selectedClothing = clothing;
}
else
{
PlayerCharacterData.selectedClothing
= PlayerCharacter.ClothingType.none;
}
}
catch
{
// if the value passed is not in the enumeration set it to none
PlayerCharacterData.selectedClothing
= PlayerCharacter.ClothingType.none;
}
switch (id.name)
{
case "CT01":
{
CLOTH_01LOD0.SetActive(id.isOn);
CLOTH_02LOD0.SetActive(false);
CLOTH_03LOD0.SetActive(false);
break;
}
case "CT02":
{
CLOTH_01LOD0.SetActive(false);
CLOTH_02LOD0.SetActive(id.isOn);
CLOTH_03LOD0.SetActive(false);
break;
}
case "CT03":
{
CLOTH_01LOD0.SetActive(false);
CLOTH_02LOD0.SetActive(false);
CLOTH_03LOD0.SetActive(id.isOn);
break;
}
case "CT04":
{
BELT_LOD0.SetActive(id.isOn);
break;
}
}
}
上述代码是用于定制玩家角色身体类型的。它首先尝试解析并转换由 UI 组件传递给函数的值。接下来,它设置 PlayerCharacter 对象中的 selectedClothing 变量。如果由于某种原因,传递的值在枚举中不存在,我们将为 selectedClothing 变量分配默认值。同时也有调试语句来提供关于当前值的反馈。
游戏关卡控制器的更改
游戏关卡控制器现在也需要更新,以便对 GameMaster 对象进行必要的更改。我们需要更新 LoadLevel() 函数,如下所示:
public void LoadLevel()
{
switch (GameMaster.instance.LevelController.Level)
{
case 0:
{
SceneManager.LoadScene(SceneName.CharacterCustomization);
break;
}
// load level 1
case 1:
{
GameMaster.instance.PlayerCharacterGameObject = GameObject.FindGameObjectWithTag("Player") as GameObject;
SceneManager.LoadScene(SceneName.Level_1);
break;
}
}
}
这将确保 GameMaster 被更新为正确的玩家角色数据。让我们继续测试代码。
测试
从 Main Menu 场景开始,确保场景中有以下 GameObjects:uiController 和 _GameMaster。uiController GameObject 应该附加 UiController.cs,而 _GameMaster 应该附加以下组件:GameMaster.cs 和一个用于背景音乐的 AudioSource 组件。
在层次结构窗口中选择 _GameMaster GameObject,并运行游戏。选择开始游戏按钮。这将加载角色定制场景。_GameMaster GameObject 应仍然被选中。如果不是,请从层次结构窗口中选择它,进行一些角色定制,然后点击保存按钮。请参考以下截图:

第一级应该已经加载了你在上一步中为角色所做的定制和你的角色。因此,从视觉上看,你的角色保留了你所做的所有定制,从数据角度来看,当你查看检查器窗口中的 _GameMaster GameObject 时,你会注意到数据已经正确保存,如前一张截图所示。
摘要
本章主要是代码。我们增强了GameMaster类以处理游戏设置和场景管理。我们开始本章时让GameMaster处理用户界面、玩家角色数据和游戏设置,目前只是背景音乐的音量。
我们添加了一个新的 UI 元素,用于显示游戏的设置面板。目前,它只包含主音量控制。接下来,我们在UiController类和GameMaster类中添加了必要的代码,以处理设置窗口的显示,以及从 UI 组件传递到UiController再到GameMaster类的滑块值。
我们还将GameMaster类设计为单例模式。在软件工程中,单例模式是一种设计模式,它限制一个类的实例只能有一个对象。这种模式非常适合GameMaster,因为我们只需要在任何给定时间内游戏生命周期中有一个活跃的实例。
我们还研究了如何执行场景管理。我们定义了一个名为SceneName的静态类,其中包含标识我们游戏中场景引用的常量字符串变量。
然后,我们采取下一步改进GameMaster和代码内部结构。我们创建了一个新类,称为GameLevelController.cs,该类处理场景管理,这反过来又由GameMaster驱动。我们实际上从GameMaster类中提取了级别处理的逻辑,并在GameLevelController类中重新工作并改进了它。
接下来,我们开发了一个AudioController类,该类基本上管理我们游戏中的音频。这个类也是由GameMaster驱动的。到这时,GameMaster已经是一个精简的脚本,负责管理所有其他组件。
接下来的重大挑战是如何处理玩家角色数据。具体来说,如何在玩家定制角色后,内部保存角色定制数据。为了保存数据,我们必须修改PlayerCharacter.cs类。
我们创建了几个枚举,代表可以定制的角色各个部分,例如护肩、体型、武器类型、头盔类型等等。我们使用枚举来使它们在代码中更容易引用。
这种方法迫使我们修改了之前实现的现有角色定制设置。我们必须更新 UI 组件以反映为每个可定制类型定义的枚举,还必须修改BarbarianCharacterCustomization.cs类以处理新的更改。
BarbarianCharacterCustomization类实现了一个 PC 类型变量来跟踪定制,并最终将数据传递给GameMaster。在这个过程中,我们还改进了BarbarianCharacterCustomization类的默认值等案例处理。
最后,我们对游戏进行了测试运行,以确保一切按设计实施的方式正常工作。
在本章中,我们编写了大量的代码。在下一章,我们将开始构建我们的库存系统,是的,这将涉及更多的代码!
第六章:库存系统
库存系统是 RPG 中最关键的组件之一。它将用于存储玩家在游戏环境中需要的所有重要游戏元素。本章将指导您如何创建一个简单通用的库存系统,您可以按需利用和扩展。
下面是本章的概述:
-
设计库存系统
-
加权库存
-
确定物品类型
-
-
创建库存物品
-
创建预制件
-
添加库存物品代理
-
将库存物品定义为预制件
-
-
设计库存界面
-
创建库存 UI 框架
-
设计动态物品查看器
-
添加滚动视图
-
向 PanelItem 和 Scroll View 添加元素
-
动态添加 txtItemElement
-
-
构建最终的库存物品 UI
-
-
将用户界面与实际库存系统集成
-
连接分类按钮并显示数据
-
测试库存系统
-
-
库存物品和玩家角色
-
应用库存物品
-
它看起来如何
-
本章前有许多工作要做。让我们开始吧!
设计库存系统
与我们迄今为止讨论的每一件事一样,设计您的库存系统也将严重依赖于您的游戏。有许多不同类型的库存系统机制可以研究并选择,这取决于其与您游戏的关联性。
加权库存
我将倾向于实现所谓的加权库存。在这种类型的库存系统中,每个物品或装备都被分配一个数值,代表物品的重量。反过来,这个数值用于确定玩家在游戏过程中任何给定时间可以携带多少库存。如果您这么想,这对我们的 RPG 来说是有意义的。
以以下内容为例:假设你是一名想要攀登阿勒山(Mount Ararat)的徒步者。攀登本身将花费一定的时间。在攀登过程中,你需要携带完成旅程或攀登所需的必要装备。从现实的角度来看,作为徒步者,你需要携带一些关键物品,如下列所示:
-
服装
-
帐篷
-
睡袋
-
鞋子
-
打破僵局的物品
-
食物
-
光源
-
个人物品
列出的每个类别在现实生活中都与特定的重量相关联。因此,在规划您的徒步旅行时,您需要提前规划,看看您如何满足攀登需求,同时在此期间减少需要携带在背上的物品数量和总重量。实际的物流要复杂一些,但您应该明白了。
在我们的 RPG 游戏中,情况并无不同。玩家角色只能携带一定数量的物品和/或装备进行旅行。例如,玩家角色在任何时候都不能携带二十种不同的武器!从现实的角度来看,这是不可能的。因此,在游戏玩法中加入一些现实感将是一个很好的触点。
同样,就像现实生活中一样,一个人携带的装备越重,所需的能量就越多。因此,我们也可以在我们的游戏中加入这样的系统。例如,携带过多的武器将对玩家角色在长时间内产生重大影响。首先,它将大大降低其速度和移动能力;其次,它可能对玩家的健康产生重大影响。这就是你的创造力和设计技能发挥作用的地方。你是游戏的主宰,你决定如何实现它!
为了演示的目的,我会保持简单!
确定项目类型
首先,我们将专注于在我们的游戏中定义的一些基本项目类型,例如武器、盔甲和服装。在此基础上,我们还可以添加健康包、药水以及可收集物品。
我们将创建三个新的脚本,名为BaseItem.cs、InventoryItem.c和InventorySystem.cs。BaseItem类将包含所有项目的通用属性,就像我们之前定义的BaseCharacter类一样。InventoryItem类将继承BaseItem类并定义项目类型。
下面是BaseItem.cs的列表:
using System;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class BaseItem
{
public enum ItemCatrgory
{
Weapon = 0,
Armour = 1,
Clothing = 2,
Health = 3,
Potion = 4
}
[SerializeField]
private string name;
[SerializeField]
private string description;
public string Name
{
get { return name; }
set { name = value; }
}
public string Description
{
get { return description; }
set { description = value; }
}
}
}
上一段代码中的主要思想是ItemCateogry。目前,我将其保留为仅五种不同的类别,库存将跟踪这些类别。
一个类别可以包含多个项目类型。例如,有不同类型的武器等等。
下面是InventoryItem.cs的列表:
using System;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class InventoryItem : BaseItem
{
[SerializeField]
private ItemCatrgory category;
[SerializeField]
private float strength;
[SerializeField]
private float weight;
public ItemCatrgory Category
{
get { return category; }
set { category = value; }
}
public float Strength
{
get { return strength; }
set { strength = value; }
}
public float Weight
{
get { return weight; }
set { weight = value; }
}
}
}
上述代码为库存中使用的项目实现了更多属性或属性。目前,我们只需保持现状;我们以后总是可以更改它。
下一个重要的脚本就是实际用于管理库存的脚本。实现库存系统逻辑的方法有很多。为了保持简单,当前的脚本将包含五种List数据类型,类型为InventoryItem,每种项目类别一个。
下面是InventorySystem.cs的列表:
using System.Collections.Generic;
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class InventorySystem
{
[SerializeField]
private List<InventoryItem> weapons = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> armour = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> clothing = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> health = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> potion = new List<InventoryItem>();
private InventoryItem selectedWeapon;
private InventoryItem selectedArmour;
public InventoryItem SelectedWeapon
{
get { return selectedWeapon; }
set { selectedWeapon = value; }
}
public InventoryItem SelectedArmour
{
get { return selectedArmour; }
set { selectedArmour = value; }
}
public InventorySystem()
{
ClearInventory();
}
public void ClearInventory()
{
weapons.Clear();
armour.Clear();
clothing.Clear();
health.Clear();
potion.Clear();
}
// this function will add an inventory item
public void AddItem(InventoryItem item)
{
switch (item.Category)
{
case BaseItem.ItemCatrgory.Armour:
{
armour.Add(item);
break;
}
case BaseItem.ItemCatrgory.Clothing:
{
clothing.Add(item);
break;
}
case BaseItem.ItemCatrgory.Health:
{
health.Add(item);
break;
}
case BaseItem.ItemCatrgory.Potion:
{
potion.Add(item);
break;
}
case BaseItem.ItemCatrgory.Weapon:
{
weapons.Add(item);
break;
}
}
}
我们将无法直接访问将用于包含库存项目的列表。目前,我们已经实现了两个函数,AddItem()和DeleteItem(),它们将处理库存的两个基本功能,即向其中添加项目或从其中删除项目。这两个函数将接受一个InventoryItem对象,并根据ItemCategory将其添加或删除到库存中适当的列表中,如下面的代码所示:
// this function will remove an inventory item
public void DeleteItem(InventoryItem item)
{
switch (item.Category)
{
case BaseItem.ItemCatrgory.Armour:
{
armour.Remove(item);
break;
}
case BaseItem.ItemCatrgory.Clothing:
{
clothing.Remove(item);
break;
}
case BaseItem.ItemCatrgory.Health:
{
health.Remove(item);
break;
}
case BaseItem.ItemCatrgory.Potion:
{
potion.Remove(item);
break;
}
case BaseItem.ItemCatrgory.Weapon:
{
weapons.Remove(item);
break;
}
}
}
}
}
基础设施已经到位。现在我们需要将其与GameMaster.cs脚本集成。为此,我们需要在GameMaster.cs脚本的Awake()函数中创建一个名为INVENTORY的新变量,其类型为InventorySystem。
下面的列表仅展示了新增内容:
public static GameMaster instance;
// let's have a reference to the player character
// and start position of player character
public GameObject PlayerCharacterGameObject;
public GameObject StartPosition;
public GameObject CharacterCustomization;
public PlayerCharacter PlayerCharacterData;
public InventorySystem Inventory;
public GameLevelController LevelController;
public GameAudioController AudioController;
// Ref to UI Elements ...
public bool DisplaySettings = false;
public UiController Ui;
void Awake()
{
// simple singlton
if (instance == null)
{
instance = this;
// initialize level controller
instance.LevelController = new GameLevelController();
// initialize audio controller
instance.AudioController = new GameAudioController();
instance.AudioController.audioSource = instance.GetComponent<AudioSource>();
instance.AudioController.SetDefaultVolume();
// initialize Inventory System
instance.Inventory = new InventorySystem();
InventoryItem tmpInvItem = new InventoryItem();
tmpInvItem.Category = BaseItem.ItemCatrgory.Clothing;
tmpInvItem.Name = "Testing";
tmpInvItem.Description = "Testing clothing item type";
tmpInvItem.Strength = 0.5f;
tmpInvItem.Weight = 0.2f;
instance.Inventory.AddItem(tmpInvItem);
}
else if (instance != this)
{
Destroy(this);
}
// keep the game object when moving from
// one scene to the next scene
DontDestroyOnLoad(this);
}
注意我们实际上正在创建一个InventoryItem对象并将其插入到InventorySystem中,用于测试目的。另一个很棒的功能是,由于我们已经序列化了类和字段,您可以在设计时看到InventorySystem。请看以下截图:

上述截图显示了在您选择GameMaster对象时在检查器窗口中看到的库存系统。当您运行游戏进行测试时,您将看到以下更新:

注意数据如何如预期地在库存系统中适当反映!服装列表的大小已增加到 1,列表中的InventoryItem对象被正确存储和显示以供测试和调试。我们有一个名为 Testing 的服装项目,具有给定的描述,力量为 0.5,重量为 0.2。
到目前为止,一切顺利。现在我们需要实际创建用于视觉表示库存物品的项目!这将在下一节中讨论。
创建库存物品
现在是实际创建我们将用于库存系统的项目(资产)的时候了。我将从每个项目类别创建一个项目类型以保持简单。本节将再次高度依赖于您如何建模您的角色模型。正如本书前面所讨论的,在我的特定模型中,角色的所有基本部分都嵌入在fbx中。在这种情况下,您需要导航到您的模型层次结构并提取特定装甲或武器的网格,或者您将用于库存的任何其他东西。
您还可以使用代表您的库存物品的独立模型,这些模型可能与您的角色模型网格相关或不相关。这些物品仅用于在世界上进行视觉表示,以便玩家可以捡起它们。

创建资产预制件
如果您还记得角色定制场景,我们已经通过了模型并确定了玩家可以通过界面选择启用或禁用的部分。
创建预制件
如果您还没有这样做,请在您的项目窗口中创建一个名为“Prefab”的文件夹。在此文件夹内,创建一个新的文件夹并命名为“InventoryItems”,然后创建一个名为 ShoulderPads 的子文件夹。如果您选择不同的命名和文件夹结构,只要您感到舒适并且它对您的工作是有组织的,您都可以这样做。
要创建一个预制体,你只需要将存在于场景窗口中的现有 GameObject 拖入项目窗口。为了保持组织有序,我们将使用前面段落中定义的结构。所以你需要导航到项目窗口中的 ShoulderPads 文件夹,然后简单地将你的模型中的一个肩垫网格拖到 ShoulderPads 文件夹中。看看下面的屏幕截图:

创建资产预制体
观察一下,当你创建一个预制体时,该预制体将是活动场景中 GameObject 的精确副本!在这种情况下,我的网格在场景中是禁用的;因此,当我为网格创建预制体时,它也将被禁用!由于它是禁用的,当你将新创建的预制体作为新的 GameObject 拖入场景时,它将是不可见的;你需要启用它。
添加库存物品代理
我们需要与我们的库存物品进行交互的手段。为了做到这一点,我们需要创建一个新的脚本,该脚本将在游戏过程中处理我们与库存物品的交互。这将在InventoryItemAgent.cs脚本中编码。目前,这个脚本将使我们能够通过 IDE 与InventoryItem对象进行交互。
脚本列表如下:
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class InventoryItemAgent : MonoBehaviour
{
public InventoryItem Item;
}
}
非常简单,为了我们能够与 GameObject 进行交互,我们需要使用一个继承自MonoBehaviour的脚本。现在将这个脚本附加到你的预制体上。现在你可以轻松地通过视觉设置你的库存物品。看看下面的屏幕截图:

库存物品代理
在前面的屏幕截图中,你可以看到我们已经从一个预制体创建了一个 GameObject,并且使用 InventoryItemAgent 组件,我们可以访问 InventoryItem 对象的属性。利用这个概念,你现在可以创建不同类型库存物品的预制体。
如果你正在场景窗口中应用更改,请确保你将它们应用到原始预制体上,以便将其保留在内存中。
注意:当你对一个预制体应用更改时,所有预制体的实例都将更新为新属性。
目前我们已经实现了一种定义我们的库存物品的简单方法,但我们仍然需要实现用户与物品的交互。交互的逻辑将在InventoryItemAgent.cs脚本中实现。首先,我们需要确定我们与谁发生了碰撞;在这种情况下,我们想确保是玩家将要收集的物品。其次,我们需要在GameMaster中存储数据,并从活动场景中删除GameObject。最后两部分将由GameMaster处理,正如你将看到的。
InventoryItemAgent.cs的新代码列表如下:
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class InventoryItemAgent : MonoBehaviour
{
public InventoryItem Item;
public void OnTriggerEnter(Collider c)
{
// make sure we are colliding with the player
if (c.gameObject.tag.Equals("Player"))
{
// Make a copy of the Inventory Item Object
InventoryItem myItem = new InventoryItem();
myItem.CopyInventoryItem(Item);
// Add the item to our inventory
GameMaster.instance.Inventory.AddItem(myItem);
// Destroy the GameObject from the scene
GameMaster.instance.RpgDestroy(gameObject);
}
}
}
}
我在InventoryItem.cs脚本中创建了一个名为CoptInventoryItem()的新函数。此函数用于将一个InventoryItem对象复制到另一个对象中。InventoryItem类中新添加的函数的代码如下:
public void CopyInventoryItem(InventoryItem item)
{
Category = item.Category;
Description = item.Description;
Name = item.Name;
Strength = item.Strength;
Weight = item.Weight;
}
我们已经看到了如何使用GameMaster添加项目到库存中。然而,我们需要添加一个新函数来处理我们游戏中GameObjects的销毁。这是通过RPG_Destroy()函数完成的。
由于Destroy()、DestroyImmediate()或DestroyObject()是 Unity 中所有GameObjects的一部分,因此你不能使用它们。因此,请谨慎地在自己的类中使用命名约定。
新函数的列表如下:
public void RpgDestroy(GameObject obj)
{
Destroy(obj);
}
需要添加到代表库存物品的预制件中的最后一个组件是一个碰撞器。请看下面的截图:

网格碰撞器
我使用了一个网格碰撞器来简化事物。可以通过从检查器窗口中选择“添加组件 | 物理 | 网格碰撞器”来添加碰撞器,如下面的截图所示:

定义为预制件的库存物品
下图显示了为演示目的创建的一些库存物品预制件:

库存预制件样本
所有这一切能够工作的关键是要确保你的预制件具有InventoryItemAgent.cs脚本以及附加到预制件上的Collider组件。然后你需要通过 IDE 提供独特的库存项目数据,以标识每个项目。
下表列出了每个定义的库存物品的数据:
| 预制件 | 名称 | 描述 | 类别 | 强度 | 重量 |
|---|---|---|---|---|---|
| 头盔 | HL01 | 带有两个角的黄铜头盔 | 防具 | 0.2 | 0.2 |
| HL02 | 黄铜头盔(面部防护) | 防具 | 0.3 | 0.25 | |
| HL03 | 青铜头盔(保护面部) | 防具 | 0.3 | 0.3 | |
| HL04 | 青铜头盔 | 防具 | 0.2 | 0.25 | |
| 盾牌 | SL01 | 铁盾 | 防具 | 0.3 | 0.3 |
| SL02 | 木盾 | 防具 | 0.2 | 0.2 | |
| 肩部护甲 | SP01 | 肩部护甲 01 | 防具 | 0.1 | 0.2 |
| SP02 | 肩部护甲 02 | 防具 | 0.1 | 0.2 | |
| SP03 | 肩部护甲 03 | 防具 | 0.15 | 0.25 | |
| SP04 | 肩部护甲 04 | 防具 | 0.2 | 0.25 | |
| 武器 | Axe1 | 单头斧 | 武器 | 0.2 | 0.1 |
| Axe2 | 双头斧 | 武器 | 0.25 | 0.2 | |
| Club1 | 木棍 | 武器 | 0.2 | 0.1 |
再次强调,数据是任意的:你决定什么最适合你的游戏和游戏设计。通常,你将有一个表格,该表格将加载此类信息,并且你的游戏管理器将在运行时从服务器中预加载它。
设计库存界面
现在是时候考虑我们如何在游戏过程中可视化库存了。为任何游戏创建用户界面都是一个具有挑战性的任务。你需要对在游戏时间显示的信息量有一个平衡的方法,同时不干扰游戏玩法。同时,你想要确保玩家手头有完成他们任务所必需的最关键和最重要的信息。
话虽如此,让我们看看我们如何设计一个简单的用户界面,使玩家能够与库存系统进行基本的交互。以下是一个玩家应该能够执行的最小功能列表:
-
在游戏过程中任何时间都可以显示库存
-
根据类别导航
-
查看每个类别下列出的物品
-
能够从库存中移除物品
-
能够从库存中消耗物品
-
查看玩家已经使用的库存物品
上述列表将为我们实现库存界面提供一个良好的起点。让我们首先确定需要显示的类别。这些类别在BaseItem类中定义为名为ItemCategory的枚举。
我们有以下几种:武器、盔甲、服装、健康和药水。请查看以下截图:

上一张截图显示了我在实现库存界面时所倾向于的一个概念。界面可以通过以下 UI 元素构建:
-
按钮
-
面板
-
文本
-
图片
每个类别将有一个按钮,将有一个包含每个类别物品列表的主要面板,如图中所示。每个物品都将包含在其自己的面板中,该面板将包含库存物品的图片、物品描述以及两个按钮,可以用来将物品添加到或从库存系统中移除。
创建库存 UI 框架
让我们先从实现我们的库存系统图形界面的初始框架开始。在你的项目主场景中,如果你还没有这样做,请创建一个新的Canvas GameObject。
要这样做,右键单击层次结构*窗口,然后选择 UI | 面板。这将自动创建一个 Canvas GameObject 和一个作为画布子项的 Panel UI 元素。
将此面板重命名为 PanelInventory。这将是一个包含所有其他内容的主体面板。现在,让我们开始构建代表我们主要类别的按钮。
同样,右键单击 PanelInventory GameObject,然后选择 UI | 按钮。这将确保新创建的按钮成为 PanelInventory 的子项。如果在创建按钮(s)后,在层次结构窗口中由于任何原因这不是这种情况,只需将新创建的按钮(s)拖到 PanelInventory 面板下。为所有五个类别都这样做。适当地重命名按钮,例如 butWeaponsCategory 等等。
更改按钮的标题,使其反映按钮的功能。同时,将文本元素重命名为类似以下的内容:txtWeaponsCategory 等等。查看以下截图:

库存 UI
最后,再次在 PanelInventory 中添加一个新的 Panel 元素,通过选择 PanelInventory GameObject 并右键点击选择 UI | Panel。将新创建的面板重命名为 PanelCategory。查看以下截图:

你的库存用户界面应该看起来像前面的截图。在我们进一步深入之前,让我们先为显示和隐藏玩家库存界面的一些基本操作建立连接。为此,我们需要修改UiController.cs、GameLevelController.cs和GameMaster.cs脚本。
我不会列出整个源文件,因为我们将在本章的后面部分进行。目前每个脚本的更改如下:
UiController.cs: 添加了一个名为DisplayInventory()的新函数和一个名为InventoryCanvas的新变量,用于引用库存画布。请看以下代码:
public void DisplayInventory()
{
InventoryCanvas.gameObject.SetActive(GameMaster.instance.DisplayInventory);
Debug.Log("Display Inventory Function");
}
GameLevelController.cs: 更新了OnlevelWasLoaded()函数,将uiControllerGameObject 分配给GameMaster实例(如果存在)。请看以下代码:
if (GameObject.FindGameObjectWithTag("Ui"))
{
GameMaster.instance.Ui
= GameObject.FindGameObjectWithTag("Ui").GetComponent<UiController>();
}
GameMaster.cs: 修改了Update()函数,以检查 J 键是否被按下和释放。这会切换一个布尔变量,以查看我们是否应该显示或隐藏库存界面。请看以下代码:
void Update()
{
if (instance.LevelController.CurrentScene.name != SceneName.MainMenu)
{
if (Input.GetKeyUp(KeyCode.I))
{
instance.DisplayInventory = !DisplayInventory;
instance.Ui.DisplayInventory();
}
}
}
如果你从主菜单测试场景,你将能够测试界面并切换其开关。
不要忘记,在设计时或在游戏最初加载时,你需要禁用库存系统的 Canvas。
设计动态物品查看器
我们接下来的挑战是创建一种方法,以动态填充库存物品并在用户界面上正确显示它们。我们将使用两个之前未曾使用过的新 UI 元素。我们将使用一个滚动视图来提供在需要时滚动浏览物品的能力。
让我们先设置好滚动视图,并查看如何将一个简单的 UI 预制件添加到滚动视图中。一旦完成,我们就可以继续增强 UI 预制件,以处理上一节中概述的内容。
添加滚动视图
接下来的几个截图将展示如何将滚动视图功能添加到你的用户界面中:

添加滚动视图
前往你创建库存 UI 的场景,然后在Canvas中选择PanelCategory。右键点击并选择UI | 滚动视图以添加一个滚动视图 UI 元素。查看以下截图:

添加滚动视图
你现在应该在 PanelCategory 面板下拥有一个带有相关子元素的滚动视图 UI 元素。这些子元素将是 Viewport、Scrollbar Horizontal 和 Scrollbar Vertical。
在删除子元素之前,调整滚动视图 UI 元素的设置。
我们将对默认的滚动视图进行一些修改。请从 Scroll View: 中删除以下内容:Scrollbar Horizontal、Scrollbar Vertical 和 Viewport 子元素。完成后,你的屏幕应该看起来像上一张截图。现在请看以下截图:

布局调整
接下来,我们需要将一个面板元素作为子元素添加到我们的 Scroll View 中。请选择滚动视图,右键单击,并选择 UI | Panel。将新添加的面板重命名为 PanelItem。我们需要向 PanelItem 添加两个布局组件。为此,选择 PanelItem,从检查器窗口中选择添加组件 | 布局 | 垂直布局组,然后再次选择添加组件 | 布局 | 内容大小过滤器。请看以下截图:

布局调整
在垂直布局组组件下进行以下属性的修改。将左、右、上、下内边距设置为 3,将间距设置为 0,将子元素对齐方式更改为左上角,并将子元素强制扩展设置为 True(宽度和高度)。
对于内容大小过滤器组件,将水平适配设置为非约束,将垂直适配设置为最小大小。
最后,在 Rect Transform 组件中,将锚点设置为顶部居中,并将 Pos Y 修改为 -10。
到目前为止,我们已经建立了基本框架。下一步是填充我们新创建的 ScrollView!
向 PanelItem 和 Scroll View 添加元素
首先,让我们在 PanelItem 面板下添加一个 Text 元素。再次选择 PanelItem 元素,右键单击并选择 UI | Text。然后选择文本元素,将其重命名为 txtItemElement。我们需要向文本元素添加一个新的组件,从检查器窗口中选择添加组件 | 布局 | 布局元素。
将布局元素组件的 Min Height 属性修改为 20。请看以下截图:

修改
上一张截图展示了 InventoryItem 元素和 UI 的设置。现在请看以下截图:

添加库存项目
我们需要一个方法来访问和修改新 Text UI 元素的 Text 属性。为了做到这一点,我们需要创建一个新的脚本,名为 InventoryItemUI.cs。代码将只有一个公共变量,它将引用 Text 元素。代码如下:
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class InventoryItemUi : MonoBehaviour
{
public Text txtItemElement;
}
}
最后,将层次窗口中的 Text 元素拖放到 txtItemElement 对象附加的 InventoryItemUI 组件的 TextItemElement 属性中。参看上一张截图。
该脚本用于自引用。我们将用它来修改Text UI 元素的文本组件。
现在我们需要通过拖放它到指定的文件夹来创建 txtItemElement 的 Prefab。我在我的 Prefab 文件夹下创建了一个名为UI的新文件夹,并在该文件夹中创建了 Prefab。参考前面的截图。
你现在可以从层次结构窗口下的 PanelItem 对象中删除 txtItemElement。我们将在运行时动态添加它们。
在我们继续之前,你还需要进行一项最后的配置。你需要在ScrollView UI 元素上添加一个Mask组件。从层次结构窗口中选择滚动视图,从检查器窗口中选择添加组件 | UI | 遮罩。在添加遮罩组件后,确保 Show Mask Graphics 属性是未选中的。
动态添加 txtItemElement
现在是时候将我们的库存项目占位符动态添加到PanelItem UI 元素中。为此,我们将使用UiController.cs脚本。打开脚本并添加以下变量到类中:
public Transform InventoryPanelItem
public GameObject InventoryItemElement;
在设计器中,你需要从Canvas游戏对象分配PanelItem UI 元素,以及从预制件文件夹中的txtItemElement预制件。
接下来,我们将修改Update()函数,以便当我们按下键盘上的 H 键时,它会创建一个新的InventoryItemElement实例,并将其作为PanelItem对象的子元素。
代码列表如下:
public void Update()
{
if (Input.GetKeyUp(KeyCode.H))
{
GameObject newButton
= Instantiate(InventoryItemElement) as GameObject;
InventoryItemUi txtItem
= newButton.GetComponent<InventoryItemUi>();
txtItem.txtItemElement.text
= string.Format("New Item {0}", Time.time);
newButton.transform.SetParent(InventoryPanelItem);
}
}
上述代码列表仅实例化了预制件并将其作为PanelItem元素的子元素。我们还在元素上更改了标题,并放置了一个时间戳以查看每个 UI 元素的唯一性。
结果如以下截图所示:

库存视图
到目前为止,我们已经组装了主要元素,以便我们的库存界面可以动态列出项目,并且能够滚动浏览它们。
构建最终的库存项目 UI
要创建实际的库存项目用户界面,我们需要使用几个 UI 元素。我们需要一个面板作为项目的容器。在Panel中,我们需要使用一个Image、一个Text和两个Button UI 元素。
注意:我将不会讲解如何组装面板的步骤。你现在应该知道如何创建用户界面。
确保你将Layout Element组件和Inventory Item UI脚本添加到将成为库存项目基础的面板中。

包含库存项目 UI 元素的面板
下图展示了为显示库存项目而开发的 UI 组件。由于 UI 组件已修改,我们还需要更新InventoryItemUI.cs脚本,以包含对Panel中所有新 UI 元素的引用。
新的InventoryItemUi.cs的列表如下:
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class InventoryItemUi : MonoBehaviour
{
public Image ItemElementImage;
public Text ItemElementText;
public Button AddButton;
public Button DeleteButton;
}
}
我们还需要更新UiController.cs脚本以相应地处理新的预制件。
新 UI 预制件在UiController.cs中的列表如下:
public void Update()
{
if (Input.GetKeyUp(KeyCode.H))
{
GameObject newItem
= Instantiate(InventoryItemElement) as GameObject;
InventoryItemUi txtItem
= newItem.GetComponent<InventoryItemUi>();
txtItem.ItemElementText.text
= string.Format("Adding New Item {0}", Time.time);
// button triggers
txtItem.AddButton.GetComponent<Button>().onClick.AddListener(() => {
Debug.Log(string.Format("You have clicked ADD Button for {0}",
txtItem.ItemElementText.text));
});
txtItem.DeleteButton.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("You have clicked DELETE Button for {0}",
txtItem.ItemElementText.text));
Destroy(newItem);
});
newItem.transform.SetParent(InventoryPanelItem);
}
}
在前面的列表中,我想指出的主要概念是预制件中按钮的onClick()事件处理器的实现。
由于我们正在动态生成我们的 UI,因此按钮,我们需要能够以某种方式触发onClick()函数;这通过添加监听器来完成,如代码所示。
目前,当你点击 AddButton 按钮时,你将在控制窗口中看到带有适当标题的输出。当你点击 DeleteButton 按钮时,你将在控制窗口中看到另一个输出,带有适当的标题。然后,物品将被销毁,即从库存中移除。
将 UI 与实际库存系统集成
我们已经看到并实现了使我们的库存系统 UI 正常工作的必要概念。现在,是时候用存储在GameMaster中的实际数据填充用户界面了。
将类别按钮挂钩并显示数据
使用UiController.cs脚本,我们将创建五个新方法来处理我们库存系统的适当可视化。我们将添加以下五个函数:
-
DisplayWeaponsCategory() -
DisplayArmourCategory() -
DisplayClothingCategory() -
DisplayHealthCategory() -
DisplayPotionsCategory()
当用户从一个类别切换到下一个类别时,我们还需要从面板中清除现有的库存物品。这需要名为ClearInventoryItemPanel()的私有函数,它只会做这件事。
新的UiController.cs脚本的列表如下:
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class UiController : MonoBehaviour
{
[Header("Settings Window")]
public RectTransform OptionsPanel;
public Slider ControlMainVolume;
public Slider ControlFXVolume;
[Header("Inventory Window")]
public RectTransform InventoryCanvas;
[Tooltip("root for inventory items")]
public Transform InventoryPanelItem;
[Tooltip("prefab representing invenotry item UI")]
public GameObject InventoryItemElement;
public void Update()
{
if (Input.GetKeyUp(KeyCode.H))
{
GameObject newItem
= Instantiate(InventoryItemElement) as GameObject;
InventoryItemUi txtItem
= newItem.GetComponent<InventoryItemUi>();
txtItem.ItemElementText.text
= string.Format("Adding New Item {0}", Time.time);
// button triggers
txtItem.AddButton.GetComponent<Button>().onClick.AddListener(() => {
Debug.Log(string.Format("You have clicked ADD Button for {0}",
txtItem.ItemElementText.text));
});
txtItem.DeleteButton.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("You have clicked DELETE Button for {0}",
txtItem.ItemElementText.text));
Destroy(newItem);
});
newItem.transform.SetParent(InventoryPanelItem);
}
}
public void DisplaySettings()
{
GameMaster.instance.DisplaySettings = !GameMaster.instance.DisplaySettings;
OptionsPanel.gameObject.SetActive(GameMaster.instance.DisplaySettings);
}
public void MainVolume()
{
GameMaster.instance.MasterVolume(ControlMainVolume.vale);
}
public void FXVolume()
{
GameMaster.instance.SoundFxVolume(ControlFXVolume.value);
}
#region INVENTORY UI FUNCTIONS
public void DisplayInventory()
{
InventoryCanvas.gameObject.SetActive(GameMaster.instan
e.DisplayInventory);
Debug.Log("Display Inventory Function");
}
public void DisplayWeaponsCategory()
{
if (GameMaster.instance.DisplayInventory)
{
ClearInventoryPanelItems();
foreach (InventoryItem item in GameMaster.instance.Inventory.Weapons)
{
GameObject newItem
= Instantiate(InventoryItemElement) as GameObject;
InventoryItemUI txtItem
= newItem.GetComponent<InventoryItemUI>();
txtItem.txtItemElement.text =
string.Format("Name: {0}, Description: {1}, Strength: {2}, Weight: {3}",
item.Name,
item.Description,
item.Strength,
item.Weight);
...
我们还必须对InventorySystem.cs脚本进行一些修改,以便我们更容易访问存储数据的属性。
脚本的新列表如下:
using System;
using System.Collections.Generic;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class InventorySystem
{
[SerializeField]
private List<InventoryItem> weapons = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> armour = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> clothing = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> health = new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> potion = new List<InventoryItem>();
public List<InventoryItem> Weapons
{
get { return weapons; }
}
public List<InventoryItem> Armour
{
get { return armour; }
}
public List<InventoryItem> Clothing
{
get { return clothing; }
}
public List<InventoryItem> Health
{
get { return health; }
}
public List<InventoryItem> Potion
{
get { return potion; }
}
private InventoryItem selectedWeapon;
private InventoryItem selectedArmour;
public InventoryItem SelectedWeapon
{
get { return selectedWeapon; }
set { selectedWeapon = value; }
}
public InventoryItem SelectedArmour
{
get { return selectedArmour; }
set { selectedArmour = value; }
}
public InventorySystem()
{
ClearInventory();
}
public void ClearInventory()
{
weapons.Clear();
armour.Clear();
clothing.Clear();
health.Clear();
potion.Clear();
}
// this function will add an inventory item
public void AddItem(InventoryItem item)
{
switch (item.Category)
{
case BaseItem.ItemCatrgory.Armour:
{
armour.Add(item);
break;
}
case BaseItem.ItemCatrgory.Clothing:
{
clothing.Add(item);
break;
}
case BaseItem.ItemCatrgory.Health:
{
health.Add(item);
break;
}
case BaseItem.ItemCatrgory.Potion:
...
注意,我已经从UiController.cs脚本中的Update()函数中删除了代码,因为它是仅用于测试目的的。
测试库存系统
为了测试目的,我在本章中创建的一些库存物品预制件中放置了一些。看看下面的截图:

要收集的库存物品
从主菜单开始游戏,并通过角色定制场景保存角色玩家并开始游戏。一旦你进入可玩场景,就继续收集场景中放置的一些物品。看看下面的截图:

存入库存的物品 - 1
注意以下截图,我已经选择了_GameMaster游戏对象,在检查器窗口中显示库存数据:

存入库存的物品 - 2
我们已经选择了两种武器类型和两种盔甲类型。我们选择的武器物品是:斧头 2。我们选择的盔甲物品包括:腿甲、SP1、护膝和 SP3,如检查器窗口中所示。请看下面的截图:

插入到库存中的物品 - 3
注意,在游戏窗口中,当我们打开用于显示的库存窗口并点击武器按钮时,我们会得到两个列表。这些列表显示了每个库存物品在类别中的正确数据。请看下面的截图:

盔甲类别
要说明添加按钮的onClick()事件,请参阅以下来自控制台窗口的截图:

库存物品属性
要在库存中列出盔甲物品,我们将点击盔甲按钮。以下截图显示了基于我们的数据的库存中盔甲类别的物品:

我们已经走了很长的路。让我们花点时间来审视一下。
我们首先创建了以下脚本,为我们在游戏中的库存系统奠定基础:
BaseItem.cs
InventoryItem.cs
InventoryItemAgent.cs
InventorySystem.cs
下一步是创建每个库存物品的预制件并将它们添加到InventoryItemAgent.cs脚本中。这反过来又允许我们在游戏过程中将必要的数据分配给预制件,以将其识别为库存物品。
接下来,我们开始设计和开发库存系统的用户界面。我们绘制了我们希望库存窗口看起来如何的草图,并使用内置的 UI 架构实现了框架。
逐步添加到 UI 并应用不同的概念和新元素,我们构建了库存系统的最终用户界面。
最后,我们使用预制件来测试从用户界面中完全添加和删除库存物品。
我们面临的下一个挑战是如何实际将库存物品应用到玩家角色上。
库存物品和玩家角色
现在我们已经看到了如何创建库存系统,我们需要能够在游戏过程中使用它来对我们的玩家角色应用更改。在本节中,我们将探讨如何做到这一点!
这里是我们需要工作的新功能之一:
-
将选定的库存物品应用到玩家角色上
-
对玩家角色和库存系统进行会计处理
-
根据情况更新游戏状态
应用库存物品
我们需要在如何处理将库存物品应用到玩家角色上以及系统如何处理该事件方面做出一些设计决策。例如,让我们假设玩家角色已经获得了几件武器;比如说武器 A、B 和 C。
让我们再假设,最初,玩家没有任何激活的武器。现在,玩家选择激活武器 A。对于这种情况,我们只需使用库存物品数据并激活武器 A,同时考虑到武器带来的所有会计问题。
现在,玩家想要将他的/她的武器换成武器 B,因为它更强大,他们需要它来击败 Boss。由于玩家已经激活了武器 A,在我们激活武器 B 之前,我们该如何处理它?我们是将其放回游戏世界,还是将其放回我们的库存中以便以后使用?
在我们的案例中,一旦你在库存中有物品,它将一直陪伴你,直到你实际上从库存中删除它,在这种情况下,它将被销毁。我们需要进行一些代码修改,以及一些预制体修改,以确保一切协同工作。
让我们从InventoryItem.cs脚本开始。我们将添加新的数据来存储库存物品的类型。这是必要的,因为我们有一个类别,在类别中我们有不同类型的物品。这对盔甲类别尤其如此!例如,我们有头盔、盾牌、肩垫等等。
代码列表如下:
using System;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class InventoryItem : BaseItem
{
public enum ItemType
{
Helmet = 0,
Shield = 1,
ShoulderPad = 2,
KneePad = 3,
Boots = 4,
Weapon = 5
}
[SerializeField]
private ItemCatrgory category;
[SerializeField]
private ItemType type;
[SerializeField]
private float strength;
[SerializeField]
private float weight;
public ItemCatrgory Category
{
get { return category; }
set { category = value; }
}
public ItemType Type
{
get { return type; }
set { type = value; }
}
public float Strength
{
get { return strength; }
set { strength = value; }
}
public float Weight
{
get { return weight; }
set { weight = value; }
}
public void CopyInventoryItem(InventoryItem item)
{
Category = item.Category;
Description = item.Description;
Name = item.Name;
Strength = item.Strength;
Weight = item.Weight;
}
}
}
当你更新你的脚本时,确保回到 IDE 并选择我们创建的每个预制体的正确类型,以表示你的库存物品。看看下面的截图:

库存物品类型字段
你需要更新你为库存物品创建的每个预制体的类型字段。
我们还需要更新PlayerCharacter.cs脚本。我们将使原始数据变量为私有,并创建公共属性来访问它们。这样,如果我们需要在设置或获取属性值之前或之后执行任何额外的工作,我们可以轻松地做到这一点。
PlayerCharacter.cs脚本的列表如下:
using System;
using UnityEngine;
namespace com.noorcon.rpg2e
{
public delegate void WeaponChangedEventHandler(PlayerCharacter.WeaponType weapon);
[Serializable]
public class PlayerCharacter : BaseCharacter
{
public enum ShoulderPad
{
none = 0,
SP01 = 1,
SP02 = 2,
SP03 = 3,
SP04 = 4
};
// Older version of the model
public enum BodyType { normal = 1, BT01 = 2, BT02 = 3 };
// New support for character model
public float BodyFat = 0.0f;
public float BodySkinny = 0.0f;
// Shoulder Pad
public ShoulderPad selectedShoulderPad = ShoulderPad.none;
public BodyType selectedBodyType = BodyType.normal;
public bool kneePad = false;
public bool legPlate = false;
public enum WeaponType
{
none = 0,
axe1 = 1,
axe2 = 2,
club1 = 3,
club2 = 4,
falchion = 5,
gladius = 6,
mace = 7,
maul = 8,
scimitar = 9,
spear = 10,
sword1 = 11,
sword2 = 12,
sword3 = 13
};
[SerializeField]
private WeaponType selectedWeapon = WeaponType.none;
public WeaponType SelectedWeapon
{
get { return selectedWeapon; }
set { selectedWeapon = value; }
}
public enum HelmetType { none = 0, HL01 = 1, HL02 = 2, HL03 = 3, HL04 = 4 };
[SerializeField]
private HelmetType selectedHelmet = HelmetType.none;
public HelmetType SelectedHelmet
{
get { return selectedHelmet; }
set { selectedHelmet = value; }
}
public enum ShieldType { none = 0, SL01 = 1, SL02 = 2 };
[SerializeField]
private ShieldType selectedShield = ShieldType.none;
public ShieldType SelectedShield
{
get { return selectedShield; }
set { selectedShield = value; }
}
public int SkinId = 1;
public enum ClothingType { none=0, CT01=1, CT02=2, CT03=3, CT04=4 };
[SerializeField]
private ClothingType selectedClothing = ClothingType.none;
public ClothingType SelectedClothing
{
get { return selectedClothing; }
set { selectedClothing = value; }
}
public enum ShoeType { none = 0, BT01 = 1, BT02 = 2 };
[SerializeField]
private ShoeType selectedShoe = ShoeType.none;
public ShoeType SelectedShoe
{
get { return selectedShoe; }
set { selectedShoe = value; }
}
[SerializeField]
private InventoryItem selectedArmour;
public InventoryItem SelectedArmour
{
get { return selectedArmour; }
set { selectedArmour = value; }
}
}
}
下一个代码修改将是在BarbarianCharacterCustomization.cs脚本上。由于这个脚本已经在角色定制场景中使用过,我们可以利用这个脚本并将其扩展以应用于我们的玩家角色。但在我们能够利用这个脚本之前,我们需要从我们在角色定制场景中定义的BaseGameObject 中复制实际的组件,并将其粘贴到代表我们的玩家角色的PC_C6GameObject 中!
当你使用“检查器窗口”中的齿轮菜单复制组件时,所有配置、链接和引用都保持完整!再次使用“检查器窗口”中的齿轮菜单粘贴组件时,你将得到组件的精确副本。这将消除我们重新布线所有 GameObject 到脚本中引用的需要。
以下两个截图说明了从_BaseGameObject 到PC_C6GameObject 的组件复制过程:

使用齿轮图标复制和粘贴组件
如前述截图所示,_Base游戏对象上附加了定制脚本,将值从_Base游戏对象复制并传输到 PC-C6 对象。看看下面的截图:

使用齿轮图标复制和粘贴组件
这之所以有效,是因为脚本实际上首先引用了PC_C6游戏对象层次结构的不同部分。区别在于它以前是附加到用于定制的_Base游戏对象上的。
注意:它们现在可以在同一场景中同时激活吗?是的!然而,如果你有时间,你可能想重新设计你的 UI 事件触发器以使用PC_CC游戏对象,然后你可以从_Base游戏对象中移除BarbarianCharacterCustomization.cs脚本。
现在,我们实际上需要修改BarbarianCharacterCustomization.cs脚本,以便使用从GameMaster.cs脚本接收到的数据激活玩家角色模型的不同部分。
CharacterCustomization.cs脚本的局部列表如下:
void DisableWeapons()
{
AXE_01LOD0.SetActive(false);
AXE_02LOD0.SetActive(false);
CLUB_01LOD0.SetActive(false);
CLUB_02LOD0.SetActive(false);
FALCHION_LOD0.SetActive(false);
GLADIUS_LOD0.SetActive(false);
MACE_LOD0.SetActive(false);
MAUL_LOD0.SetActive(false);
SCIMITAR_LOD0.SetActive(false);
SPEAR_LOD0.SetActive(false);
SWORD_BASTARD_LOD0.SetActive(false);
SWORD_BOARD_01LOD0.SetActive(false);
SWORD_SHORT_LOD0.SetActive(false);
}
public void SetWeaponType(Slider id)
{
try
{
PlayerCharacter.WeaponType weapon = (PlayerCharacter.WeaponType)Convert.ToInt32(id.value);
PlayerCharacterData.SelectedWeapon = weapon;
}
catch
{
PlayerCharacterData.SelectedWeapon = PlayerCharacter.WeaponType.none;
}
// disable weapons
DisableWeapons();
switch (Convert.ToInt32(id.value))
{
case 0:
{
DisableWeapons();
break;
}
case 1:
{
AXE_01LOD0.SetActive(true);
break;
}
case 2:
{
AXE_02LOD0.SetActive(true);
break;
}
case 3:
{
CLUB_01LOD0.SetActive(true);
break;
}
case 4:
{
CLUB_02LOD0.SetActive(true);
break;
}
case 5:
{
FALCHION_LOD0.SetActive(true);
break;
}
case 6:
{
GLADIUS_LOD0.SetActive(true);
break;
}
case 7:
{
MACE_LOD0.SetActive(true);
break;
}
case 8:
{
MAUL_LOD0.SetActive(true);
break;
}
case 9:
{
SCIMITAR_LOD0.SetActive(true);
break;
}
case 10:
{
SPEAR_LOD0.SetActive(true);
break;
}
case 11:
{
SWORD_BASTARD_LOD0.SetActive(true);
break;
}
case 12:
{
SWORD_BOARD_01LOD0.SetActive(true);
break;
}
case 13:
{
SWORD_SHORT_LOD0.SetActive(true);
break;
}
}
}
我没有列出整个脚本,因为这会占用很多页面。但基本概念是重载SetXXXXX()函数,以便它们根据传入的参数执行必要的任务,就像前面的例子PlayerCharacter.WeaponType一样。
下一个需要修改的脚本是UiController.cs脚本。这是我们之前创建的五个函数将实际应用于玩家角色的地方。让我们看看已经被修改的一个函数,不列出整个代码,如下所示:
public void DisplayWeaponsCategory()
{
if(GameMaster.instance.DISPLAY_INVENTORY)
{
this.ClearInventoryItemsPanel();
foreach (InventoryItem item in GameMaster.instance.INVENTORY.WEAPONS)
{
GameObject objItem = GameObject.Instantiate(this.InventoryItemElement) as GameObject;
InventoryItemUI invItem = objItem.GetComponent<InventoryItemUI>();
invItem.txtItemElement.text =
string.Format("Name: {0}, Description: {1}, Strength: {2}, Weight: {3}",
item.NAME,
item.DESCRIPTION,
item.STRENGTH,
item.WEIGHT);
invItem.item = item;
// add button triggers
invItem.butAdd.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("You have clicked button add for {0}, {1}", invItem.txtItemElement.text, invItem.item.NAME));
// let's apply the selected item to the player character
GameMaster.instance.PC_CC.SELECTED_WEAPON = (PC.WEAPON_TYPE)Enum.Parse(typeof(PC.WEAPON_TYPE), invItem.item.NAME);
GameMaster.instance.PlayerWeaponChanged();
});
// delete button triggers
invItem.butDelete.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("You have clicked button delete for {0}", invItem.txtItemElement.text));
Destroy(objItem);
});
objItem.transform.SetParent(this.PanelItem);
}
}
}
注意,我们还将从foreach循环中保存的*item*放入invItem.item变量中。这很重要,以确保OnClick()监听器能够从列表中获取当前的InventoryItem变量。
大部分工作是在每个按钮的onClick.AddListener()中完成的。我们基本上使用GameMaster.instance设置选定的武器以存储,然后调用PlayerWeaponChanged()函数来处理更多功能。这将在下一个代码列表中演示。
你需要以类似的方式处理每个添加按钮监听器,这取决于你如何设计和实现你的代码以及你的预制件。
最后,我们将对GameMaster.cs脚本进行一些修改。列表如下:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using System.Collections;
using System;
public class GameMaster : MonoBehaviour
{
public static GameMaster instance;
// let's have a reference to the player character GameObject
public GameObject PC_GO;
// reference to player Character Customization
public PC PC_CC;
public InventorySystem INVENTORY;
public GameObject START_POSITION;
public GameObject CHARACTER_CUSTOMIZATION;
public LevelController LEVEL_CONTROLLER;
public AudioController AUDIO_CONTROLLER;
// Ref to UI Elements ...
public bool DISPLAY_SETTINGS = false;
public bool DISPLAY_INVENTORY = false;
public UIController UI;
void Awake()
{
// simple singlton
if (instance == null)
{
instance = this;
instance.DISPLAY_INVENTORY = false;
instance.DISPLAY_SETTINGS = false;
// initialize Level Controller
instance.LEVEL_CONTROLLER = new LevelController();
// initialize Audio Controller
instance.AUDIO_CONTROLLER = new AudioController();
instance.AUDIO_CONTROLLER.AUDIO_SOURCE = GameMaster.instance.GetComponent<AudioSource>();
instance.AUDIO_CONTROLLER.SetDefaultVolume();
// initialize Inventory System
instance.INVENTORY = new InventorySystem();
}
else if (instance != this)
{
Destroy(this);
}
// keep the game object when moving from
// one scene to the next scene
DontDestroyOnLoad(this);
}
....
我要你注意的唯一函数是PlayerArmourChanged()函数。正是因为这个函数,我们才需要在InventoryItem类中添加新的*Type*数据变量和数据类型。看看下面的代码:
// for each level/scene that has been loaded
// do some of the preparation work
void OnLevelWasLoaded()
{
GameMaster.instance.LEVEL_CONTROLLER.OnLevelWasLoaded();
}
// Use this for initialization
void Start()
{
// let's find a reference to the UI controller of the loaded scene
if (GameObject.FindGameObjectWithTag("UI") != null)
{
GameMaster.instance.UI = GameObject.FindGameObjectWithTag("UI").GetComponent<UIController>();
}
GameMaster.instance.UI.SettingsCanvas.gameObject.SetActive(GameMaster.instance.DISPLAY_SETTINGS);
}
// Update is called once per frame
void Update()
{
// only when we are in the game level
if(instance.LEVEL_CONTROLLER.CURRENT_SCENE.name==SceneName.Level_1)
{
if (Input.GetKeyUp(KeyCode.J))
{
//Debug.Log("Pressing J");
instance.DISPLAY_INVENTORY = !instance.DISPLAY_INVENTORY;
instance.UI.DisplayInventory();
}
}
}
public void MasterVolume(float volume)
{
GameMaster.instance.AUDIO_CONTROLLER.MasterVolume(volume);
}
public void StartGame()
{
GameMaster.instance.LoadLevel();
}
public void LoadLevel()
{
GameMaster.instance.LEVEL_CONTROLLER.LoadLevel();
}
public void RPG_Destroy(GameObject obj)
{
Destroy(obj);
}
}
我们有很多不同类型的盔甲,我们需要一种方法来区分它们。根据盔甲类型,然后调用适当的函数在玩家角色上激活它们。
看起来是这样的
本章在配置 GameObject 和预制件方面有些复杂,更重要的是,与之相关的代码,用于将所有这些粘合在一起。我已经尽量使事情尽可能简单。话虽如此,我们本章没有讨论和包含一些项目——与升级、经验和任务相关的话题。如果您真正学习和理解了库存系统,您可以非常容易地驱动和扩展它以涵盖所有其他部分。不幸的是,我们无法在一本书中实现所有内容。
以下是一个截图,展示了在玩家拾取放置在关卡上的物品之前的玩家角色。注意,在GameMaster对象中INVENOTRY是空的。同样,在PC-C6对象中也没有选中的物品:

玩家角色数据
在我将玩家角色移动到拾取库存物品之后,我将使用编程的热键来打开库存窗口。在我的情况下,是 J 键。以下截图捕捉了交互:

玩家角色数据库存
现在,让我们看看当我们将一些库存物品应用到玩家角色上时,事情会有怎样的变化。您可以在前面的截图中的库存集合中看到所应用的变化。
摘要
本章涵盖了大量的内容。本章的核心是创建一个可用的库存系统。我们本章开始时讨论了加权库存,并简要概述了该概念。然后我们确定了我们将为游戏使用的项目类型。
我们创建了BaseItem.cs、InventoryItem.cs和InventorySystem.cs脚本。然后,我们将这些脚本用作设计和发展我们库存的起点。接着,我们更新了GameMaster.cs脚本,以测试新创建的脚本的基本功能,并通过序列化属性在 Unity IDE 中查看数据。我们通过实例化一个InventoryItem并将其插入到InventorySystem中来实现这一点,并通过 IDE 直观地验证了操作。
下一步是实际创建库存物品预制件。本节介绍了如果您的模型包括了实际的 fbx 模型上的所有内容,如何导航和找到可定制的库存物品,如何从模型中提取它,以及如何将其转换为预制件以供以后使用。然后我们创建了一个新的脚本,称为InventoryItemAgent.cs,它被附加到每个创建的预制件上,以表示库存物品。这个脚本基本上让我们能够在 IDE 中设置每个库存物品的数据。非常有用!我们还必须将一个碰撞器附加到每个预制件上,以处理碰撞并在玩家与对象碰撞时触发拾取调用。
一旦我们建立了基础,我们就开始考虑如何设计和实现库存系统的用户界面。我们讨论了想要表示的分类,以及每个分类中的项目在游戏过程中如何列出/显示给玩家。我们实现了库存窗口的初始框架,并将其与游戏集成。
现在,我们准备讨论如何创建一个动态的项目查看器,它可以在运行时填充,并正确表示我们收集的库存项目。我们介绍了一些新的用户界面概念,例如滚动视图以及如何在我们的界面中利用布局。我们用一个简单的占位符进行了快速测试,仅显示项目的名称。一旦我们使机制正常工作,我们就实现了主要的库存项目控制面板,并将其转换为预制件,以便在需要时在运行时实例化。
最后,我们着手将库存系统与库存用户界面以及游戏大师和玩家角色集成,以实现最终的实现。这需要我们更新和修改更多的脚本。
到本章结束时,你将拥有一个功能齐全的库存系统,可以根据需要扩展。
第七章:用户界面和系统反馈
你是否见过冰山顶部?到目前为止,这就是我们在前六章中所做的一切。在本章中,我们将继续改进我们游戏的用户界面和反馈系统。我们将创建一个负责管理玩家与系统菜单交互以及系统向玩家提供反馈的抬头显示(HUD)。
在本章中,我们将涵盖以下内容:
-
设计抬头显示(HUD)
-
HUD 基础
-
我们的设计
-
HUD 框架
-
完成我们的 HUD 设计
-
角色信息面板
-
活动物品面板
-
特殊物品面板
-
集成代码
-
HUD 中的敌人统计数据
-
NPC 统计数据用户界面
-
创建 NPC 画布
-
NPC 受到攻击
-
优化代码
让我们开始吧!
设计抬头显示(HUD)
设计抬头显示(HUD)是一个非常具有挑战性的任务。HUD 是玩家与虚拟世界交互并从虚拟世界环境接收反馈的界面。与迄今为止我们设计的所有其他内容一样,HUD 设计也与您试图制作的游戏的类型和需求密切相关。
例如,实时策略(RTS)游戏将具有与第一人称射击(FPS)或角色扮演游戏(RPG)非常不同的 HUD 设计。它们有一些共同点,但它们的设计非常独特,以及一些功能和特性。
我们可以有一整本书专门讲述用户界面设计和开发,以及如何以科学的方式处理它们。但这超出了本书的范围,我们关注的是理论。
HUD 基础
任何简单的抬头显示至少应有一种显示以下信息的方式:
-
玩家角色的基本信息
-
生命值
-
魔法值
-
力量
-
等级
-
玩家角色当前消耗的库存物品
-
玩家当前使用的武器
-
玩家当前使用的盔甲
-
可用的药水及/或生命值
-
来自游戏环境的反馈
-
与游戏相关的任何有用信息
-
能力提升
-
等级提升
让我们开始设计我们的 HUD。再次强调,我们将从一个简单的框架开始,并在需要时逐步构建。
我们的设计
考虑到所有因素,让我们继续设计一个对我们游戏有用的 HUD。同时,我们将保持其简单但实用。我们应该有一个 HUD,它将以不干扰游戏玩法的方式显示基本玩家角色信息,同时向玩家提供有关其角色状态的临界信息。
我们还应该设计一种方式来显示玩家当前激活并准备使用的当前库存物品,例如武器或盔甲。最后,我们还应该有一个简单的方法让玩家在游戏过程中使用他们可能拥有的任何健康包和/或药水。
下图展示了我想我的 HUD 看起来是什么样的快速草图。再次提醒,你可以自由发挥,事实上,我鼓励你提出自己的设计:

我已经清楚地标出了我希望在游戏过程中我的 HUD(抬头显示)看起来大致如何。
注意,我保持了它的简单性。在左上角,我放置了玩家将需要立即了解的信息,例如他们的健康和可能的力量。
在屏幕左下角,我放置了一个可滚动的面板,将列出玩家可能在其角色上激活的所有活动库存物品,而在屏幕的右侧,我有三个槽位,将用于立即访问玩家在游戏过程中可能需要使用的物品,如健康包和/或药水。
HUD 框架
既然我们已经了解了我们希望我们的 UI(用户界面)看起来是什么样子,让我们开始在 Unity 中实现它。我们需要创建一个新的画布来容纳我们的 HUD。要创建画布,在层次结构窗口中右键单击,选择 UI | 画布。将新画布 GameObject 重命名为CanvasHUD。
让我们继续放置我们 UI 的所有不同部分。每个部分我们需要三个主要面板,如下面的截图所示。
我们需要一个面板来在屏幕左上角存放角色信息。我们还需要一个面板来在屏幕左下角存放活动库存物品,以及另一个面板来在屏幕右侧存放特殊物品。
通过在层次结构窗口中右键单击并选择 UI | 面板来创建每个面板。确保面板是CanvasHUDGameObject 的子项。相应地重命名每个面板。我命名为:PanelCharacterInfo、PanelActiveItems和PanelSpecialItems。请看下面的截图:

HUD 初始轮廓
上述截图给你一个关于 HUD 框架的感觉。
完成我们的 HUD 设计
现在我们已经建立了框架,让我们继续完成每个部分的单独设计。我想从PanelCharacterInfo开始。
角色信息面板
从设计角度来看,将包含角色视觉组件的面板将非常复杂。面板将由五个图像组成。
主要图像将用于存放角色的头像。其他四个图像将用于显示角色的健康和法力。由于这些值将以条形图格式显示,我们将为每个项目使用两个图像。一个图像将包含边框,另一个将包含实际值的表示。
为了制作图像,我将使用外部工具,如 Photoshop;Microsoft Expression Design 是创建框架等的好工具。请看下面的截图:

在前面的截图中,我制作了一个很好的图像,描绘了玩家角色的头像。你应该考虑你将放置在PanelCharacterInfo面板内的图像的实际大小。我生成的图像大小是 301 × 301 像素。
为了创建代表角色玩家健康和法力的条形图的图形,我们实际上需要三个图像。一个图像代表条的负值,一个图像代表条的正值,第三个将是条的边框图像。它们将叠加在一起,以产生我们图形条的错觉,如下面的截图所示:

健康条图形
创建三个不同的精灵并将它们叠加,将给你一个很好的错觉,让你找到你想要的效果。
在导出我们的图像后,我们需要将它们导入到 Unity 中。使用你的文件系统将你的图像从原始位置移动到 Unity 项目下的Assets文件夹。
我将我的纹理放置在以下目录:Assets | RPG_2E | Textures。
一旦将它们移动到你的 Unity 项目中的期望位置,你将需要将图像转换为精灵。选择所有将被用于 GUI 的图像,并在检查器窗口中,将纹理类型更改为精灵(2D 和 UI)。看看下面的截图:

图像导入属性
是时候将我们的纹理应用到我们在Canvas对象下的PanelCharacterInfo面板内定义的实际 UI 元素上了。
注意:在我们能够完全将 UI 元素应用到 HUD 之前,需要执行几个步骤。
如果你还没有这样做,你应该首先在PanelCharacterInfo面板下创建三个新的 UI 图像元素,通过右键单击面板并选择 UI | 图像。
我将我的三个图像命名为imgHealthRebBackground、imgHealthGreenBackground和imgHealthBorder。图像的顺序很重要。在设计 UI 时你应该注意这一点。一般来说,如果 UI 元素在层次结构中位置较低,它将渲染在其他元素之上。
看看下面的截图:

健康条连接
注意健康条表示图像的顺序。代表绿色条的图像需要使用检查器窗口进行修改。选择它,将图像类型更改为填充,将填充方法更改为水平,将填充起点更改为左端。我们将使用填充量来控制健康条的视觉部分。请注意,我将其设置为0.77以供演示。
默认情况下,当游戏开始时,我们将从Fill Amount的1开始,这对于玩家角色的健康来说相当于 100%。0.77相当于 77%,依此类推。
我们将为法力条应用相同的技巧。继续创建另外两个图像,它们将代表法力条的两个背景。
注意:我们将使用相同的边框图像为两个条。
再次提醒,不要忘记你需要在 Unity 中导入的纹理中进行适当的更改。将它们转换为 Sprite (2D 和 UI) 纹理类型。
在面板下创建必要的图像 UI 元素,并将纹理应用到画布中的图像元素上。你应该有如下截图所示的内容:

法力条连接
这就是全部内容!对于一个没有艺术背景的人来说,还不错!
活动库存项目面板
创建活动库存项目的 UI 与我们在第六章,“库存系统”中所做的是类似的。不同之处在于,我们将只列出玩家使用库存系统消耗的项目。
换句话说,活动库存项目显示是库存中已激活项目的视觉指示。重要的是要记住,我们更感兴趣的是学习概念并将它们应用于一个简单的示例中,你可以在此基础上扩展和改进。
基本的想法是创建一个可滚动的面板,用于添加所需的项目。我们已经看到了如何设置可滚动视图以及如何配置 UI 组件以支持我们试图实现的目标。我不会再次深入细节;如果需要,请参考第六章,“库存系统”中的必要步骤。
在层次结构窗口中,右键单击 PanelActiveInventoryItems,然后选择UI | 滚动视图。继续删除由“滚动视图”元素创建的Viewport、Scrollbar Horizontal和Scrollbar Vertical子元素。
你只需确保你应用的布局配置是水平的,而不是垂直的,就像我们在第六章,“库存系统”中所做的那样。请查看以下截图:

活动库存面板
在 UI 元素中检查你的锚点和对齐方式:

最后,请查看以下截图:

前三个图展示了活动库存项目面板配置的不同部分。如果你不确定如何组合它们,请回到第六章,“库存系统”,并阅读设计动态物品查看器部分。
不要忘记制作一个预制体,这个 UI 元素将代表你在面板中的活动库存项目。
你还需要一个脚本来引用为你的物品指定的 UI 元素。我称这个脚本为ActiveInventoryItemUi.cs,目前有两个属性;一个是Image元素的引用,另一个是Text元素的引用。
脚本列表如下:
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class ActiveInventoryItemUi : MonoBehaviour
{
public InventoryItem item;
public Image imgActiveItem;
public Text txtActiveItem;
}
}
我们最终需要将这些脚本整合在一起,以确保一切正常工作。
特殊物品面板
现在,我们将看看我们最后设计的面板。这个面板与最后一个我们开发的面板的主要区别是方向。其他所有内容都将完全相同。然而,对于这个面板,我们的方向将是垂直而不是水平。
这里是一张捕捉到一切的截图:

特殊物品面板
创建面板的流程已经被讨论了好几次,你应该不会在创建它时遇到任何麻烦。
我让你自己制作纹理和图像,以应用于 UI 元素。
在设计特殊物品面板时,我想出了一个改进面板 UI 的更好方法。你可能希望有一个静态图标代表每个特殊物品,并且有一个计数器附加到 UI 上,表示你有多少个这样的物品。每次你收集一个,它就会增加,每次你消耗一个,它就会减少。
以下截图显示了根据我们的设计当前 HUD 的外观:

活动库存和特殊库存运行时
我们现在需要开始考虑将 HUD 用户界面与我们迄今为止开发的代码库整合。
代码整合
现在我们已经将 HUD 设计搭建并运行起来,我们需要将 UI 元素与实际代码整合,这些代码将生成这些 UI 元素。将创建几个脚本以支持新的 UI 功能,还有几个脚本将更新以将一切粘合在一起。
以下脚本已被创建:ActiveInventoryItemUi.cs、ActiveSpecialItemUi.cs和HudElementUi.cs。
这些脚本的列表如下:
using UnityEngine.EventSystems;
namespace com.noorcon.rpg2e
{
public class ActiveSpecialItemUi : EventTrigger
{
public override void OnPointerClick(PointerEventData data)
{
InventoryItem iia =
gameObject.GetComponent<ActiveInventoryItemUi>().item;
switch (iia.Category)
{
case BaseItem.ItemCatrgory.Health:
{
// add the item to the special items panel
GameMaster.instance.Ui.ApplySpecialInventoryItem(iia);
Destroy(gameObject);
break;
}
case BaseItem.ItemCatrgory.Potion:
{
break;
}
}
}
}
}
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class HudElementUi : MonoBehaviour
{
public Image imgHealthBar;
public Image imgManaBar;
public GameObject activeInventoryItem;
public GameObject activeSpecialItem;
public Transform panelActiveInventoryItems;
public Transform panelActiveSpecialItems;
}
}
这些脚本将在 HUD 用户界面上使用,以给我们访问元素的能力。例如,你需要将HudElementUi.cs脚本附加到CanvasHUD游戏对象上。请看以下截图:

HUD 画布
上述截图说明了如何使用HUDElementsUI.cs脚本配置 HUD 画布。
现在让我们看看我们创建的预制件,以表示用于面板的 UI 元素。有两个;我称它们为PanelActiveItem和PanelSpecialItem。
我将讨论PanelSpecialItem,因为它包含PanelActiveItem包含的所有内容,以及一个附加的事件处理脚本。请看以下截图:

HUD 事件触发
我们刚刚讨论的是实现用于访问 HUD 画布中适当 UI 元素的脚本的实现。
你会注意到,对于PanelSpecialItem预制件,我们为其添加了两个新且非常重要的组件。一个是 Unity 中的事件触发器,另一个是ActiveSpecialItemUi.cs脚本,该脚本用于处理特殊物品的PointerClick事件。
这意味着我们基本上使物品可点击,当玩家点击物品时,会发生某些事情。在这种情况下,它将特殊物品应用于玩家角色。
现在我们已经准备好更新我们已开发的脚本以包含 HUD 功能。需要修改的脚本有InventorySystem.cs和UiController.cs。
InventorySystem.cs的列表如下:
using System;
using System.Collections.Generic;
using UnityEngine;
namespace com.noorcon.rpg2e
{
[Serializable]
public class InventorySystem
{
[SerializeField]
private List<InventoryItem> weapons
= new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> armour
= new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> clothing
= new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> health
= new List<InventoryItem>();
[SerializeField]
private List<InventoryItem> potion
= new List<InventoryItem>();
public List<InventoryItem> Weapons
{
get { return weapons; }
}
public List<InventoryItem> Armour
{
get { return armour; }
}
public List<InventoryItem> Clothing
{
get { return clothing; }
}
public List<InventoryItem> Health
{
get { return health; }
}
public List<InventoryItem> Potion
{
get { return potion; }
}
private InventoryItem selectedWeapon;
private InventoryItem selectedArmour;
public InventoryItem SelectedWeapon
{
get { return selectedWeapon; }
set { selectedWeapon = value; }
}
public InventoryItem SelectedArmour
{
get { return selectedArmour; }
set { selectedArmour = value; }
}
public InventorySystem()
{
ClearInventory();
}
ClearInventory()和AddItem()的列表如下:
public void ClearInventory()
{
weapons.Clear();
armour.Clear();
clothing.Clear();
health.Clear();
potion.Clear();
}
// this function will add an inventory item
public void AddItem(InventoryItem item)
{
switch (item.Category)
{
case BaseItem.ItemCatrgory.Armour:
{
armour.Add(item);
break;
}
case BaseItem.ItemCatrgory.Clothing:
{
clothing.Add(item);
break;
}
case BaseItem.ItemCatrgory.Health:
{
health.Add(item);
GameMaster.instance.Ui.AddSpecialInventoryItem(item);
break;
}
case BaseItem.ItemCatrgory.Potion:
{
potion.Add(item);
break;
}
case BaseItem.ItemCatrgory.Weapon:
{
weapons.Add(item);
break;
}
}
}
DeleteItem()将从列表中删除一个给定的InventoryItem。请参阅以下代码:
// this function will remove an inventory item
public void DeleteItem(InventoryItem item)
{
switch (item.Category)
{
case BaseItem.ItemCatrgory.Armour:
{
armour.Remove(item);
break;
}
case BaseItem.ItemCatrgory.Clothing:
{
clothing.Remove(item);
break;
}
case BaseItem.ItemCatrgory.Health:
{
// let's find the item and mark it for removal
InventoryItem tmp = null;
foreach (InventoryItem i in this.health)
{
if (item.Category.Equals(i.Category)
&& item.Name.Equals(i.Name)
&& item.Strength.Equals(i.Strength))
{
tmp = i;
}
}
health.Remove(tmp);
break;
}
case BaseItem.ItemCatrgory.Potion:
{
// let's find the item and mark it for removal
InventoryItem tmp = null;
foreach (InventoryItem i in this.health)
{
if (item.Category.Equals(i.Category)
&& item.Name.Equals(i.Name)
&& item.Strength.Equals(i.Strength))
{
tmp = i;
}
}
potion.Remove(tmp);
break;
}
case BaseItem.ItemCatrgory.Weapon:
{
weapons.Remove(item);
break;
}
}
}
}
}
UiController.cs的列表如下:
using System;
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class UiController : MonoBehaviour
{
[Header("Main Menu Canvas")]
public RectTransform MainMenuCanvas;
[Header("Settings Window")]
public RectTransform OptionsPanel;
public Slider ControlMainVolume;
public Slider ControlFXVolume;
[Header("Inventory Window")]
public RectTransform InventoryCanvas;
[Tooltip("root for inventory items")]
public Transform InventoryPanelItem;
[Tooltip("prefab representing invenotry item UI")]
public GameObject InventoryItemElement;
[Header("HUD Window")]
public RectTransform HudCanvas;
public HudElementUi HudUi;
...
public void DisplaySettings()
{
GameMaster.instance.DisplaySettings = !GameMaster.instance.DisplaySettings;
OptionsPanel.gameObject.SetActive(GameMaster.instance.DisplaySettings);
}
public void MainVolume()
GameMaster.instance.MasterVolume(ControlMainVolume.value);
}
public void FXVolume()
{
GameMaster.instance.SoundFxVolume(ControlFXVolume.value);
}
#region INVENTORY UI FUNCTIONS
public void DisplayInventory()
{
InventoryCanvas.gameObject.SetActive(GameMaster.instance.DisplayInventory);
Debug.Log("Display Inventory Function");
}
public void DisplayWeaponsCategory()
{
if (GameMaster.instance.DisplayInventory)
{
ClearInventoryPanelItems();
foreach (InventoryItem item in GameMaster.instance.Inventory.Weapons)
{
GameObject newItem
= Instantiate(InventoryItemElement) as GameObject;
InventoryItemUi InventoryItemControl
= newItem.GetComponent<InventoryItemUi>();
InventoryItemControl.ItemElementText.text =
string.Format("Name: {0}, Description: {1}, Strength: {2}, Weight:
{3}",
item.Name,
item.Description,
item.Strength,
item.Weight);
InventoryItemControl.Item = item;
// button triggers
InventoryItemControl.AddButton.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("ADD button for {0}",
InventoryItemControl.ItemElementText.text));
// apply selected weapon
GameMaster.instance.PlayerCharacterData.SelectedWeapon
= (PlayerCharacter.WeaponType)Enum.Parse(
typeof(PlayerCharacter.WeaponType), InventoryItemControl.Item.Name);
GameMaster.instance.PlayerWeaponChanged();
AddActiveInventoryItem(InventoryItemControl.Item);
});
InventoryItemControl.DeleteButton.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("DELETE button for {0}",
InventoryItemControl.ItemElementText.text));
Destroy(newItem);
});
newItem.transform.SetParent(InventoryPanelItem);
}
}
}
以下列出了一个脚本的部分内容。请参考下载包中的完整列表:
public void DisplayArmourCategory()
{
if (GameMaster.instance.DisplayInventory)
{
ClearInventoryPanelItems();
foreach (InventoryItem item in GameMaster.instance.Inventory.Armour)
{
GameObject newItem
= Instantiate(InventoryItemElement) as GameObject;
InventoryItemUi InventoryItemControl
= newItem.GetComponent<InventoryItemUi>();
InventoryItemControl.ItemElementText.text =
string.Format("Name: {0}, Description: {1}, Strength: {2}, Weight:
{3}",
item.Name,
item.Description,
item.Strength,
item.Weight);
InventoryItemControl.Item = item;
// button triggers
InventoryItemControl.AddButton.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("ADD button for {0}",
InventoryItemControl.ItemElementText.text));
// apply selected weapon
GameMaster.instance.PlayerCharacterData.SelectedArmour = InventoryItemControl.Item;
GameMaster.instance.PlayerArmourChanged(InventoryItemControl.Item);
AddActiveInventoryItem(InventoryItemControl.Item);
});
InventoryItemControl.DeleteButton.GetComponent<Button>().onClick.AddListener(() =>
{
Debug.Log(string.Format("DELETE button for {0}",
InventoryItemControl.ItemElementText.text));
Destroy(newItem);
});
newItem.transform.SetParent(InventoryPanelItem);
}
}
}
...
现在你已经一切就绪,你可以继续测试运行游戏以确保一切按预期工作。这也是一个很好的时间来测试/调试你的代码和项目设置,如果你还没有这样做的话。
我必须再次强调以下观点:目的是掌握概念。我们正在查看实现我们想要达到目标的一种方法;你可能在这个过程中想出更好的方法,或者决定做完全不同的事情。我鼓励这样做!
看看下面的截图:

初始状态
上述截图展示了玩家角色和库存在关卡最初加载时的状态。我已经指出了我们正在测试和跟踪的关键部分,以确保我们的代码能够正常工作。
在下一幅图中,玩家角色已经捡起了一些我们在关卡上放置的库存物品。当你打开库存窗口并点击任何定义的分类,例如武器,你将得到我们库存中所有武器的列表,等等。
我们收集了一种武器类型、一个健康包和一些防御物品。请注意,我们的特殊物品面板正在显示一个物品。这是我们捡到的健康包:

特殊物品交互
下一张截图展示了当玩家在游戏中通过使用库存窗口添加库存物品开始消耗一些库存物品时,HUD 如何更新自己。
注意,我们已经激活了三个库存物品:两把武器,分别命名为 axe2 和 club1,以及两种类型的盔甲 SP04 和 SP03。
你可以在玩家角色以及持有活动库存物品的面板上直观地看到它们。非常酷:

激活面板交互
是时候去见敌人了。我们还没有讨论过玩家角色和非玩家角色(NPC)之间的交互。我们很快就会讨论这个问题。
我们已经将一些库存物品从我们的库存中应用到玩家角色上,现在我们可以实际上去面对敌人了。我们将允许敌人攻击我们,以查看我们的健康如何减少。然后我们将使用特殊物品面板中的健康包再次增加我们的健康。
以下两个截图说明了这个场景:

以下截图显示了我们在逃跑并应用我们的健康包:

注意,当我们应用健康包时,它会从库存系统和特殊物品面板中移除自己。
在 HUD 中显示敌人统计数据
我们还没有真正讨论过如何处理和管理 NPC 的统计数据及其视觉表示。现在是时候做这件事了!我们需要决定我们想要向玩家显示哪些信息。目前,让我们保持简单,只显示敌人的基本健康和力量。
问题是,显示这些信息的最佳方式是什么?我们应该根据玩家角色和 NPC 之间的距离阈值来显示信息,还是我们应该在玩家在游戏过程中某个时间请求时显示它?
让我们先从第一个场景开始。当玩家角色和 NPC 之间有一定距离时,我们将显示 NPC 的信息。我们甚至可以使这个距离与为 NPC 设置的视线距离相同!这是好事,因为,如果他们能看到我们,那么他们离我们就足够近,我们可以看到他们的统计数据!让我们开始工作!
NPC 统计数据用户界面
我们将使用我们为玩家角色创建的一些现有纹理,例如健康条和力量条的纹理。我们只需要创建一个将在世界空间中并且附加到 NPC 角色上的画布。
创建 NPC 画布
我们将为 NPC 创建的画布和我们已经为玩家创建的画布之间的主要区别是一些配置。
主要区别之一将是画布的渲染模式。NPC 画布将具有世界空间渲染模式。这将允许我们将画布定位为场景中的另一个 GameObject。下一个重要区别将是矩形变换属性,更重要的是,缩放和旋转属性。
看看下面的截图:

NPC 健康条
为了简化操作,你只需要创建画布并更改画布的属性,如前一张截图所示。对于下一步,你可以复制我们在前几节中开发的整个 PanelCharacterInfo,并将其粘贴为新画布的子元素。这样,你就不必逐个重新创建每个 UI 元素,这将节省大量时间。然而,你需要在 PanelCharacterInfo 面板——新面板上更改 Scale 和 Transform 属性,以便将其排列在 NPC 头部上方渲染!
下一步是我们能够从代码中控制状态条的值。为此,我们将创建一个新的脚本,命名为 NPCStatUi.cs,并将其附加到我们刚刚为 NPC 状态创建的画布对象上。
我已将画布重命名为 CanvasNPCStats。
脚本的列表如下:
using UnityEngine;
using UnityEngine.UI;
namespace com.noorcon.rpg2e
{
public class NpcStatusUi : MonoBehaviour
{
public Image imgHealthBar;
public Image imgManaBar;
}
}
我们刚刚创建的脚本只会给我们提供图像元素的引用。我们仍然需要有一种方法来更新值。
我们需要找到一种方法来引用特定场景中的所有 NPC 角色。一旦确定,我们就需要设置健康和力量条的初始值。然后,在游戏过程中,我们需要能够根据游戏状态更新每个 NPC 的统计数据。
为了让我们能够识别特定场景中的 NPC,我们将使用每个 GameObject 中定义的 Tag 元素。我们需要创建一个新的 Tag 元素,命名为 Enemy,并且所有敌对类型的 NPC 都需要被标记为敌对。这是一种快速搜索并根据它们的 Tag 值获取 GameObject 列表的方法。
你还应该开始考虑如何在运行时动态地将 NPC 状态画布附加到 NPC 上。目前,出于测试目的,我将将其附加到模型上。但问题是,你实际上在哪里附加它?嗯,我们有一个名为 Follow 的空 GameObject 附加到我们的模型预制件上。由于这是由我们的玩家角色模型驱动的,我们在游戏过程中将其嵌入 Follow 作为主相机的占位符。对于 NPC,我们将使用它将 NPC 画布作为子 GameObject 附加到模型层次结构中的 Follow GameObject 上。你可以在前一张截图中看到这些。
我们将使用 NpcAgent.cs 脚本来初始化 NPC 状态画布预制件以及 UI 元素的相关值。这是放置初始化的最佳位置,因为它将是一个自包含的单元。该脚本的最新列表如下:
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class NpcAgent : MonoBehaviour
{
[SerializeField]
public Npc NpcData;
[SerializeField]
public Transform CanvasAttachmentPoint;
[SerializeField]
public Canvas CanvasNpcStats;
[SerializeField]
public GameObject CanvasNpcStatsPrefab;
public void SetHealthValue(float value)
{
CanvasNpcStats.GetComponent<NpcStatusUi>().imgHealthBar.fillAmount =
value;
}
public void SetStrengthValue(float value)
{
CanvasNpcStats.GetComponent<NpcStatusUi>().imgManaBar.fillAmount =
value;
}
//// Use this for initialization
void Start()
{
// let's go ahead and instantiate our stats
GameObject tmpCanvasGO = Instantiate(
CanvasNpcStatsPrefab,
new Vector3(CanvasAttachmentPoint.position.x, 2, 0),
CanvasNpcStatsPrefab.transform.rotation) as GameObject;
tmpCanvasGO.transform.SetParent(CanvasAttachmentPoint, false);
CanvasNpcStats = tmpCanvasGO.GetComponent<Canvas>();
CanvasNpcStats.GetComponent<NpcStatusUi>().imgHealthBar.fillAmount = 1.0f;
CanvasNpcStats.GetComponent<NpcStatusUi>().imgManaBar.fillAmount = 1.0f;
Npc tmp = new Npc();
tmp.Tag = "Enemy";
tmp.CharacterGameObject = transform.gameObject;
tmp.Name = "B1";
tmp.Health = 100.0f;
tmp.Defense= 50.0f;
tmp.Description = "The Beast";
tmp.Dexterity = 33.0f;
tmp.Intelligence = 80.0f;
tmp.Strength = 60.0f;
NpcData = tmp;
}
//// Update is called once per frame
void Update()
{
if (NpcData.Health < 0.0f)
{
NpcData.Health = 0.0f;
transform.GetComponent<NpcBarbarianMovement>().die = true;
}
}
}
}
注意,你需要分配 canvasNPCStatsAttachment,,它将用于存储我们将附加到 NPC 画布上的 GameObject 的引用。canvasNPCStatsPrefab 将在设计时用于分配代表 NPC 状态画布的预制件。如果你现在运行游戏,你将有一个预制件被动态实例化并附加到层次结构中的 Follow GameObject 上,填充值设置为 1f,即 100%。
NPC 受击
我们需要花点时间回顾一下我们在早期章节中的一些初始脚本和配置,其中我们定义了玩家角色的动画控制器和BarbarianCharacterController.cs脚本。
注意:请参阅第三章,“RPG 角色设计”,以刷新你对动画控制器和曲线的记忆。
打开我们在第三章“RPG 角色设计”中创建的动画控制器,命名为BarbarianAnimatorController。选择参数选项卡,创建名为Attack1, Attack2,和Attack3的新参数,数据类型为float。看看下面的截图:

动画状态机
为了复习,请回到第四章,“游戏机制”,部分PC 和 NPC 交互,你将回想起我们如何定义和配置曲线,根据动画分配参数。
我们只为其中一个攻击动画定义了曲线。
一旦你为玩家角色配置了动画控制器上的参数,那么我们就需要更新BarbarianCharacterController.cs脚本,根据参数值触发攻击。
以下列出的是脚本的局部列表,仅显示修改的部分:
void FixedUpdate()
{
// The Inputs are defined in the Input Manager
// get value for horizontal axis
h = Input.GetAxis("Horizontal");
// get value for vertical axis
v = Input.GetAxis("Vertical");
speed = new Vector2(h, v).sqrMagnitude;
animator.SetFloat("Speed", speed);
animator.SetFloat("Horizontal", h);
animator.SetFloat("Vertical", v);
if (animator.GetFloat("Attack1") == 1.0f)
{
GameMaster.instance.AttackEnemy();
}
}
在代码中,我们检查是否有任何攻击模式处于活动状态,如果是的话,我们检查动画时刻曲线参数Attack1的值。如果我们处于1.0f,那么我们调用GameMaster对象来执行剩余的操作。
现在我们需要查看我们在GameMaster.cs脚本中定义/修改的几个函数:
// for each level/scene that has been loaded
// do some of the preparation work
private void OnLevelWasLoaded(int level)
{
instance.LevelController.OnLevelWasLoaded();
// find all NPC GameObjects of Enemy type
if (GameObject.FindGameObjectsWithTag("Enemy").Length > 0)
{
var tmpGONPCEnemy = GameObject.FindGameObjectsWithTag("Enemy");
instance.NpcEnemyListGameObjects.Clear();
foreach (GameObject goTmpNPCEnemy in tmpGONPCEnemy)
{
instance.NpcEnemyListGameObjects.Add(goTmpNPCEnemy);
}
}
}
public void AttackEnemy()
{
Npc npc =
instance.ClosestNpcEnemy.GetComponent<NpcAgent>().NpcData;
npc.Health -= 1;
}
这里需要一些解释。所以,每次在运行时加载新场景时,都会调用OnLevelWasLoaded()函数。这是查询所有标记为Enemy的 GameObject 的地方。然后我们将其内部存储,以供后续处理。
由于测试目的和场景的简单性,测试中只有一个敌人。我还将closestNPCEnemy对象设置为最后一个标记为Enemy的 GameObject。这个变量随后在PlayerAttachEnemy()函数中使用,以设置 NPC 的Health属性。
当调用PlayerAttackEnemy()函数时,我们获取 NPC 角色的 NPC 组件,并根据攻击减少其健康值。
现在,这也迫使我们更新BaseCharacter.cs脚本。以下是修改的列表:
[SerializeField]
private float health;
public float Health
{
get { return health; }
set
{
health = value;
if (Tag.Equals("Player"))
{
if (GameMaster.instance.Ui.HudUi != null)
{
GameMaster.instance.Ui.HudUi.imgHealthBar.fillAmount
= health / 100.0f;
}
}
else
{
CharacterGameObject.GetComponent<NpcAgent>().SetHealthValue(health / 100.0f);
}
}
}
在Health属性中,我们检查是否是玩家,或者 NPC。如果是玩家,我们需要使用GameMaster来更新我们的Stats UI,如果我们要更新自己的 NPC Stats UI。
这意味着当你创建你的玩家角色和/或 NPC 时,你需要确保你正确地分配数据元素;请参阅以下代码:
void Awake()
{
PlayerCharacter tmp = new PlayerCharacter();
tmp.Name = "Maximilian";
tmp.Tag = transform.gameObject.tag;
tmp.CharacterGameObject = transform.gameObject;
tmp.Health = 100.0f;
tmp.Defense = 50.0f;
tmp.Description = "Our Hero";
tmp.Dexterity = 33.0f;
tmp.Intelligence = 80.0f;
tmp.Strength = 60.0f;
playerCharacterData = tmp;
}
Awake() from the PlayerAgent.cs script. You will need to perform the same for the NpcAgent.cs script; see the following code:
void Start()
{
// let's go ahead and instantiate our stats
GameObject tmpCanvasGO = Instantiate(
CanvasNpcStatsPrefab,
new Vector3(CanvasAttachmentPoint.position.x, 2, 0),
CanvasNpcStatsPrefab.transform.rotation) as GameObject;
tmpCanvasGO.transform.SetParent(CanvasAttachmentPoint, false);
CanvasNpcStats = tmpCanvasGO.GetComponent<Canvas>();
CanvasNpcStats.GetComponent<NpcStatusUi>().imgHealthBar.fillAmount = 1.0f;
CanvasNpcStats.GetComponent<NpcStatusUi>().imgManaBar.fillAmount = 1.0f;
Npc tmp = new Npc();
tmp.Tag = "Enemy";
tmp.CharacterGameObject = transform.gameObject;
tmp.Name = "B1";
tmp.Health = 100.0f;
tmp.Defense= 50.0f;
tmp.Description = "The Beast";
tmp.Dexterity = 33.0f;
tmp.Intelligence = 80.0f;
tmp.Strength = 60.0f;
NpcData = tmp;
}
我们查看的代码和脚本已被用于测试我们提出的思想。结果是积极的。你可能已经注意到,当玩家角色攻击时,我们没有考虑其相对于敌人的位置。我们还在自动将 GameMaster 中最近的 NPC 角色分配为每次加载关卡时查询的最后一个元素。
优化代码
在结束本章之前,我想要实现最后一个代码实现,确保在玩家角色处于攻击模式时,生命值会自动影响目标 NPC。换句话说,根据我们对 NPC 的距离和视角角度确定最近的 NPC。
我们已经创建了用于确定 NPC 角色的这些数量的逻辑。现在我们需要为玩家角色实现类似的功能。让我们看看我们需要对 BarbarianCharacterMovement.cs 脚本进行的代码更改的部分列表:
using UnityEngine;
namespace com.noorcon.rpg2e
{
public class BarbarianCharacterController : MonoBehaviour
{
public Animator animator;
public float directionDampTime;
public float speed = 6.0f;
public float h = 0.0f;
public float v = 0.0f;
bool attack = false;
bool punch = false;
bool run = false;
bool jump = false;
[HideInInspector]
public bool die = false;
bool dead = false;
public bool EnemyInSight;
public GameObject EnemyToAttack;
Quaternion StartingAttackAngle = Quaternion.AngleAxis(-25, Vector3.up);
Quaternion StepAttackAngle = Quaternion.AngleAxis(5, Vector3.up);
Vector3 AttackDistance = new Vector3(0, 0, 2);
// Use this for initialization
void Start()
{
animator = GetComponent<Animator>() as Animator;
EnemyInSight = false;
}
...
void FixedUpdate()
{
// The Inputs are defined in the Input Manager
// get value for horizontal axis
h = Input.GetAxis("Horizontal");
// get value for vertical axis
v = Input.GetAxis("Vertical");
speed = new Vector2(h, v).sqrMagnitude;
animator.SetFloat("Speed", speed);
animator.SetFloat("Horizontal", h);
animator.SetFloat("Vertical", v);
#region used for attack range
RaycastHit hitAttack;
var angleAttack = transform.rotation * StartingAttackAngle;
var directionAttack = angleAttack * AttackDistance;
var posAttack = transform.position + Vector3.up;
for (var i = 0; i < 10; i++)
{
Debug.DrawRay(posAttack, directionAttack, Color.yellow);
if (Physics.Raycast(posAttack, directionAttack, out hitAttack, 1.0f))
{
var enemy = hitAttack.collider.GetComponent<NpcAgent>();
if (enemy)
{
//Enemy was seen
EnemyInSight = true;
EnemyToAttack = hitAttack.collider.gameObject;
GameMaster.instance.ClosestNpcEnemy = hitAttack.collider.gameObject;
}
else
{
this.EnemyInSight = false;
}
}
directionAttack = StepAttackAngle * directionAttack;
}
#endregion
if (EnemyInSight)
{
if (animator.GetFloat("Attack1") == 1.0f)
{
PlayerCharacter pc
= gameObject.GetComponent<PlayerAgent>().playerCharacterData;
float impact = (pc.Strength + pc.Health) / 100.0f;
GameMaster.instance.AttackEnemy(impact);
}
}
}
}
}
我们计算敌方 NPC 的视野和距离的方式是通过 Raycasting。这仅在攻击模式下进行;我们检查 NPC 是否在我们前方,如果是,则在 GameMaster 上设置 ClosestNpcEnemy 对象,并设置 enemyInSight 标志,然后从 NPC 的健康值中进行必要的减法操作。
注意,我还改变了计算击中影响的简单方程的方式,如下所示:

在这里,pc 是我们玩家角色的对象引用。NPC 对象也使用相同的方程。这只是一个简单的演示,说明玩家或 NPC 的伤害影响是基于场景中演员的力量和健康值。请查看以下截图:

确定 NPC 是否在视野中
上述截图说明了我们如何检测 NPC 是否在攻击范围内。
相应地,你可以从玩家或 NPC 在游戏过程中激活的组件中推导出力量值。
BaseCharacter.cs 文件中关于 HEALTH 属性的部分列表如下:
[SerializeField]
private float health;
public float Health
{
get { return health; }
set
{
health = value;
if (Tag.Equals("Player"))
{
if (GameMaster.instance.Ui.HudUi != null)
{
GameMaster.instance.Ui.HudUi.imgHealthBar.fillAmount
= health / 100.0f;
}
}
else
{
CharacterGameObject.GetComponent<NpcAgent>().SetHealthValue(health / 100.0f);
}
}
}
有更多的代码更改和更新;请参阅提供的关联文件以获取详细信息。
在实现过程中,我修改了一些其他代码位置,但由于篇幅限制,这些内容未在书中列出。以下是已修改的脚本:BaseCharacter.cs、BarbarianCharacterController.cs、GameMaster.cs、NpcAgent.cs、PlayerAgent.cs 和 NpcBarbarianMovement.cs。请查看以下截图:

游戏大师处理多个 NPC
鼓励你进行一些研究,尝试不同的机制和实现方式来提高你的技能。
摘要
在本章中,我们扩展了我们的想法,并探讨了如何将所有主要部分整合在一起。本章的主要目标是创建我们游戏的一个抬头显示(HUD)。
我们从一个对我们感兴趣的设计概念开始,在实现之前为我们的 HUD 创建了一个布局。一旦我们确定了 HUD 应该是什么样子,我们就开始构建它的框架。我们设计了 HUD 的三个主要部分,并称它们为以下名称:PanelCharacterInfo、PanelActiveItems 和 PanelSpecialItems。
接下来,我们开始构建 UI 元素和使面板与我们的代码一起工作的必要代码。我们从 PanelCharacterInfo 开始,它代表我们的角色玩家的统计数据,即对玩家头像的引用、对健康的引用和对角色力量的引用。在这个过程中,我们必须创建或更新几个脚本以与新的 UI 一起工作。
接下来,我们设计和开发了 PanelActiveItems 面板。这个特定面板的实现和方法是稍微复杂一些。这个面板的目的是显示玩家当前消耗的所有活动库存物品。由于我们不知道玩家在任何给定时间会消耗多少物品,我们必须使面板可滚动。我们创建了必要的预制件作为库存物品的占位符,以及使它们一起工作的脚本。
PanelSpecialItems 的设计非常类似于 PanelActiveItems,主要有两个不同点。首先,我们必须确保面板是垂直的而不是水平的,因此我们必须确保应用了正确的配置。其次,这个面板的主要功能与之前不同。显示的物品应该是不可交互的,这意味着我们必须创建自定义事件处理程序,将必要的值应用到玩家角色上,并更新整个游戏状态。
一旦我们对 HUD 的设计感到满意,我们就开始编写必要的脚本来将 UI 元素与 GameMaster 和其他脚本集成。这基本上是确保我们的 UI 总是反映我们感兴趣的对象的状态。健康、耐力和库存是我们用来传达概念的主要项目。
在本章的最后部分,我们专注于玩家角色移动的实现和 NPC 的检测,以及如何在玩家角色和 NPC 之间跟踪生命值,这在之前的章节中没有完成。
我们还必须回溯并调整我们为玩家角色定义的动画控制器,为攻击动画值定义基于运动的曲线。
在这个过程中,我们必须解决以下挑战:我们如何知道我们是否已经接近到足够近的距离,可以实际攻击并击中 NPC 角色?我们将如何检测哪个 NPC 离我们更近?更重要的是,数据将如何从攻击动作传递到 NPC 实际被击中的情况?
我们在短时间内做了很多工作,并且内容紧凑。有些功能留给了读者自己解决。例如,我们没有讨论如何删除库存项目等等。我觉得这些内容很 trivial,而且一旦读者看到了更大的范围以及如何将一切连接起来,他们应该会足够舒适地自己实现这些功能。
话虽如此,让我们继续前进到下一章,最后一章,涵盖多人游戏!
第八章:多人设置
每个独立游戏开发者的愿望都是制作一个多人游戏。现实是,创建多人游戏是困难的。作为游戏设计师/开发者,您需要考虑很多场景。除了创建在线多人游戏所固有的技术复杂性外,还有游戏玩法元素需要考虑。
本章的目的是为您提供一个使用新的 Unity 网络范式对开箱即用的网络功能的好概述。这是一个复杂的话题,因此我们无法在本章中涵盖所有内容。要真正深入了解细节,将需要一本全新的书籍。
话虽如此,我已经准备了这一章,包括一个简单的项目,该项目将用于说明网络的基础知识。然后我会向您展示如何使我们的游戏对象具备网络功能。
在本章中,我们将涵盖以下内容:
-
头戴式显示器
-
完成 HUD 设计
-
集成代码
这里我们开始!
多人游戏的挑战
一个普遍的规则是,如果您不需要使您的游戏成为多人游戏,那么就别这么做!这只会增加大量的复杂性和额外的需求和规范,您将需要开始担心。但如果是必须的,那么就必须这么做!
您现在可能已经知道,即使是创建最简单的多人游戏也有其挑战,作为游戏设计师,您需要解决这些问题。存在不同类型的在线多人游戏设计,如下所示:
-
实时多人游戏
-
轮流制多人游戏
-
异步多人游戏
-
本地多人游戏
在所有不同类型的多人游戏中,最具挑战性的是实时多人游戏。这是因为所有玩家必须在任何给定时间以适当和有效的方式与最新的游戏状态同步。
也就是说,如果我们让玩家 A 执行特定的动作,玩家 B 将同时在他的或她的屏幕上看到这个动作。现在,考虑我们还有另一个玩家加入,比如说玩家 C;玩家 A 和 B 需要与玩家 C 同步,而玩家 C 反过来需要将其自己的环境与玩家 A 和玩家 B 的状态同步。
不仅玩家的实际位置/旋转需要同步,所有玩家数据也需要同步。现在,想象一下当您将这个数字乘以 100、1,000 或 1,000,000 个连接的玩家时会发生什么。
对于现实世界的多人游戏,我们在这里涵盖的内容是不够的,Unity 提供的开箱即用的功能也不够。很可能您需要编写自己的服务器端代码来处理玩家数据。
现在,您可以看到设计和开发多人游戏所涉及的挑战,我们可以从构建我们的第一个多人游戏开始。
初始多人游戏
了解多人游戏的最佳方式是通过查看一个简单的示例。以下项目基于 Unity 网络教程,但已经扩展了一些其他功能,这些功能将有助于我们在 RPG 中实现网络功能。
基本网络组件
自从本书第一版以来,Unity 引擎内部发生了许多技术和架构上的变化。自第一版出版以来,网络领域有了显著改进。
让我们看看如何快速开始。首先你需要做的是从资源商店下载 网络大厅 资产,如下截图所示:

这是一个很好的起点,因为我们有一个通用的网络大厅可以开始,所以我们不必浪费时间从头开始创建一切!
我们需要熟悉一些将用于创建我们的网络游戏的一些网络组件。这些组件如下:
-
网络管理器:
NetworkManager是一个高级类,允许你控制网络游戏的网络状态。它提供了一个编辑器界面来控制网络的配置、用于实例化的预制体以及用于不同网络游戏状态的场景。 -
网络大厅管理器:
NetworkLobbyManager是一种特殊的NetworkManager类型,在进入游戏的主玩场景之前提供多人游戏大厅。非常适合匹配。 -
网络管理器 HUD:这提供了一个默认的用户界面来控制游戏的网络状态。它还显示了编辑器中
NetworkManager的当前状态信息。 -
网络身份:
NetworkIdentity组件是新的网络系统的核心。该组件控制对象的网络身份,并使网络系统了解它。 -
网络变换:
NetworkTransform组件同步网络中游戏对象的移动。该组件考虑了权限,因此LocalPlayer对象从客户端同步位置到服务器,然后同步到其他客户端。其他对象(具有服务器权限)从服务器同步位置到客户端。
我的坦克网络项目
假设你已经下载并导入了网络大厅资产。我们将使用两个场景来展示这个概念。第一个场景将是大厅场景,第二个场景将是游戏进行的场景。
继续创建一个场景,并将其命名为 NetworkingGameLobby。游戏将从这里开始。请看以下截图:

我的坦克网络大厅
将LobbyManager预制体添加到场景中。在场景中选择LobbyManager游戏对象,并在检查器窗口中注意LobbyManager组件。这里有几个需要注意的地方。首先,你需要分配大厅场景和游戏场景属性。我们需要将我们的NetworkingGameLobby场景分配给大厅场景属性,将NetworkingGamePlay场景分配给游戏场景属性。接下来,你需要将Game Player预制体属性分配给Player预制体。别担心,我们将在下一部分创建Player预制体。最后,我们需要配置出生信息部分,并注册游戏过程中将生成的预制体(游戏对象);在我们的例子中,这些将是子弹和敌方坦克。
添加玩家角色
我们现在将添加一个简单的玩家角色。你可以使用任何原始游戏对象来表示你的 PC;我将创建一个玩家,使其形状为一个简单的坦克。请看以下截图:

下面的截图将展示Tank游戏对象的层次结构:

我不会介绍如何创建游戏对象,因为你现在应该能够非常容易地做到这一点。我会介绍的是如何启用新的网络启用型坦克游戏对象。
我们将在Tank游戏对象上附加两个网络组件。第一个将是NetworkIdentity,可以通过选择Tank游戏对象,并在检查器窗口中选择添加组件 | 网络 | 网络身份来添加。
当你完成组件添加后,请确保检查本地玩家权限属性复选框,如图所示:

本地玩家权限允许对象被拥有它的客户端控制。
接下来,我们需要将NetworkTransform组件添加到坦克游戏对象上。再次选择Tank游戏对象,在检查器窗口中,点击添加组件 | 网络 | 网络变换来添加组件:

我们将保留NetworkTransform组件的默认值。除了旋转属性外,因为我们只沿Y轴旋转,所以我们不需要发送所有 XYZ 信息,因此将其更改为 Y(俯视 2D)并增加插值旋转字段到 15,并将压缩比更改为高。你可以通过在线文档自行了解更多关于不同属性的信息。你可能想要调整的主要属性是网络发送速率。
接下来,我们想要创建一个脚本,使我们能够控制坦克的移动。现在就创建一个新的 C#脚本,并将其命名为MyPlayerController.cs。
这里是脚本列表:
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
public class MyPlayerController : NetworkBehaviour
{
public Transform mainCamera;
public float cameraDistance = 16f;
public float cameraHeight = 16f;
public Vector3 cameraOffset;
[SyncVar]
public Color myColor;
public GameObject bulletPrefab;
public Transform bulletSpawn;
private void Start()
{
GetComponent<MeshRenderer>().material.color = myColor;
cameraOffset = new Vector3(0f, cameraHeight, -cameraDistance);
mainCamera = Camera.main.transform;
MoveCamera();
}
void Update()
{
// only execute the following code if local player ...
if (!isLocalPlayer)
return;
#if UNITY_EDITOR
var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
#else
var x = ETCInput.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = ETCInput.GetAxis("Vertical") * Time.deltaTime * 3.0f;
#endif
transform.Rotate(0, x, 0);
transform.Translate(0, 0, z);
#if UNITY_EDITOR
if (Input.GetKeyDown(KeyCode.Space))
{
CmdFire();
}
#else
if (ETCInput.GetButtonDown("ButtonFire"))
{
CmdFire();
}
#endif
MoveCamera();
}
[Command]
void CmdFire()
{
// Create the Bullet from the Bullet Prefab
var bullet = Instantiate(
bulletPrefab,
bulletSpawn.position,
bulletSpawn.rotation) as GameObject;
// Add velocity to the bullet
bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward *6;
bullet.GetComponent<Bullet>().myColor = myColor;
// Spawn the bullet on the Clients
NetworkServer.Spawn(bullet);
// Destroy the bullet after 2 seconds
Destroy(bullet, 2.0f);
}
void MoveCamera()
{
mainCamera.position = transform.position;
mainCamera.rotation = transform.rotation;
mainCamera.Translate(cameraOffset);
mainCamera.LookAt(transform);
}
}
代码很简单,但有一些重要的概念我们需要讨论。首先,你会注意到我们是从NetworkBehaviour继承,而不是从MonoBehaviour继承。
NetworkBehaviour用于与具有NetworkIdentity组件的对象一起工作。这允许你执行与网络相关的函数,如Commands、ClientRPCs、SyncEvents和SyncVars。
变量同步
同步变量是多玩家游戏的重要方面之一。如果你还记得,多玩家游戏的一个挑战是确保游戏的所有关键数据在服务器和客户端之间同步。这是通过SyncVar属性实现的。你将在下一个脚本中看到这一点,我们将为 Unity 的健康状态创建这个脚本。
网络回调
网络回调是针对NetworkBehaviour脚本在多种网络事件上调用的函数。它们列示如下:
-
OnStartServer()在服务器上创建对象时或当服务器为场景中的对象启动时被调用 -
OnStartClient()在客户端上创建对象时或当客户端连接到服务器以处理场景中的对象时被调用 -
OnSerialize()被调用以收集从服务器发送到客户端的状态 -
OnDeSerialize()被调用以将状态应用于客户端上的对象 -
OnNetworkDestroy()在服务器告诉对象被销毁时在客户端上被调用 -
OnStartLocalPlayer()在本地客户端的玩家对象上被调用 -
OnRebuildObservers()在服务器上被调用,当对象的观察者集合被重建时 -
OnSetLocalVisibility()在主机上被调用,当对象的可见性对本地客户端发生变化时 -
OnCheckObserver()在服务器上被调用,用于检查新客户端的可见状态
在PlayerController.cs脚本中,你会注意到我们正在使用OnStartClient()通过将材质颜色更改为蓝色来突出显示本地玩家。
发送命令
命令是客户端请求在服务器上执行函数的方式。在服务器权威系统中,客户端只能通过命令做事。命令在服务器上对应发送命令的客户端的玩家对象上运行。这种路由是自动发生的,因此客户端不可能为不同的玩家发送命令。
命令必须以前缀“Cmd”开始,并在其上具有[Command]自定义属性。
在我们的PlayerController.cs脚本中,当玩家开火时,它使用CmdFire()函数向服务器发送命令。
客户端 RPC 调用
客户端 RPC 调用是服务器对象在客户端对象上引发事件的一种方式。这与命令发送消息的方向相反,但概念是相同的。然而,客户端 RPC 调用不仅可以在玩家对象上调用;它们也可以在任何NetworkIdentity对象上调用。它们必须以“Rpc”前缀开始,并具有[ClientRPC]自定义属性。
你将在我们接下来要创建的Health.cs脚本中看到这个示例。
我们还需要一种方法来跟踪玩家角色的健康状态。这将通过一个新的脚本Health.cs来实现。
脚本列表如下:
using UnityEngine;
using UnityEngine.Networking;
public class Health : NetworkBehaviour
{
public const int maxHealth = 100;
[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;
public RectTransform healthBar;
public bool destroyOnDeath;
public GameObject[] listOfPlayers;
private void Start()
{
healthBar.sizeDelta = new Vector2(currentHealth, healthBar.sizeDelta.y);
}
public void TakeDamage(int amount)
{
currentHealth -= amount;
if (currentHealth <= 0)
{
if (destroyOnDeath)
{
RpcDied();
listOfPlayers = GameObject.FindGameObjectsWithTag("Player");
if (listOfPlayers.Length < 1)
{
Invoke("BackToLobby", 3.0f);
}
}
else
{
currentHealth = maxHealth;
// called on the Server, will be invoked on the Clients
RpcRespawn();
}
}
}
void OnChangeHealth(int health)
{
healthBar.sizeDelta = new Vector2(health, healthBar.sizeDelta.y);
}
[ClientRpc]
void RpcRespawn()
{
if (isLocalPlayer)
{
// move back to zero location
transform.position = Vector3.zero;
}
}
[ClientRpc]
void RpcDied()
{
gameObject.tag = "Untagged";
GetComponent<Renderer>().material.color = Color.black;
if (GetComponent<MyPlayerController>() != null)
{
GetComponent<MyPlayerController>().enabled = false;
}
if (GetComponent<EnemyController>() != null)
{
GetComponent<EnemyController>().enabled = false;
}
}
void BackToLobby()
{
FindObjectOfType<NetworkLobbyManager>().ServerReturnToLobby();
}
}
注意,在这个脚本中,我们还在继承自NetworkBehaviour。我想重点介绍的两个主要项目是SyncVar、ClientRpc和Start()函数。
我们希望在网络中同步玩家的健康状态。为此,我们使用SyncVarNetworkBehaviour。SyncVar可以是任何基本类型,但不能是类、列表或其他集合。
当服务器上SyncVar的值发生变化时,它将被发送到游戏中所有准备好的客户端。当对象被实例化时,它们将在客户端上创建,并具有来自服务器的所有SyncVars的最新状态。
OnStartClient()函数确保所有附加了Health.cs脚本的物体都将具有最新的值,以便在健康条 UI 上显示。
我想在这里花点时间确保我给你一个关键提示。假设我们正在运行一个网络游戏会话,我们有主机、玩家 A和玩家 B连接并正在忙于自己的事情。在游戏过程中,玩家 A和玩家 B的健康值发生变化。现在,有第三个玩家连接到游戏,即玩家 C。如果没有实现Start(),玩家 C的客户端将具有所有带有Health.cs脚本的 GameObject 的正确数据同步;然而,数据将不会正确反映在 UI 上,因为我们需要一个触发器来实现这一点。这可以在Start()函数中处理,如代码所示。
下一个函数是RpcRespawn()函数。在TakeDamage()函数中,我们检查当前 GameObject 的健康值。如果健康值降至零以下,我们检查destroyOnDeath布尔变量是否设置。如果没有设置,我们将currentHealth值重置为maxHealth值,并使用RpcRespawn()方法在原点重新生成玩家。记住,这个函数在所有客户端上都会执行!
在函数内部,我们通过检查isLocalPlayer变量来查看调用者是否是本地玩家。是的,创建多人游戏确实会让人感到困惑!当你开始更多地进行实验时,这一点将变得更加明显。
创建坦克的炮弹
让我们创建一个预制体来代表我们的炮弹!非常简单,创建一个球体并使其与坦克炮的喷嘴大小相同。
我们需要将以下组件附加到炮弹游戏对象上:NetworkIdentity、NetworkTransform、Rigidbody和Bullet.cs脚本。
确保你在Rigidbody组件中将Use Gravity属性设置为False。同时,确保在NetworkIdentity组件中,Server Only和Local Player Authority属性都设置为False。在NetworkTransform组件中,将Network Send Rate改为0。一旦我们在服务器上生成了对象,物理引擎将负责每个客户端上的运动。
创建一个新的 C#脚本,命名为Bullet.cs。
脚本列表如下:
using UnityEngine;
using UnityEngine.Networking;
public class Bullet : NetworkBehaviour
{
[SyncVar]
public Color myColor;
private void Start()
{
GetComponent<MeshRenderer>().material.color = myColor;
}
void OnCollisionEnter(Collision collision)
{
var hit = collision.gameObject;
var health = hit.GetComponent<Health>();
if (health != null)
{
health.TakeDamage(10);
}
Destroy(gameObject);
}
}
我们在这里所做的只是检测碰撞。如果有碰撞,我们获取Health组件。如果Health组件不为空,我们调用TakeDamage()函数并传递一个值。
如果你还记得Health.cs脚本,TakeDamage()函数会减少玩家的currentHealth,这反过来是一个SyncVar,会在所有活动客户端上更新。
我们没有讨论的一个概念是hook。一个SyncVar可以有一个hook。将hook想象成一个事件处理器。hook属性可以用来指定当SyncVar在客户端上更改值时要调用的函数:
[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;
OnChangeHealth()函数负责更新 UI 画布以显示我们的健康值:
void OnChangeHealth(int health)
{
healthBar.sizeDelta = new Vector2(health, healthBar.sizeDelta.y);
}
接下来,也创建炮弹的预制体,并将其实例从场景中删除。
确保你已经为每个脚本分配了所需的正确预制体关联。例如,Tank游戏对象的PlayerController.cs脚本需要一个对炮弹预制体的引用,以及大炮的生成位置。Health.cs脚本需要一个对健康条前景图像的引用等等。
创建坦克预制体并配置网络大堂管理员
现在我们已经创建了我们的Tank游戏对象,并将其所有必要的组件和脚本附加到它上面,我们需要为其创建一个预制体。这是因为我们将让NetworkLobbyManger生成我们的玩家角色,为了使其能够这样做,它需要引用一个代表你的玩家角色的预制体。
NetworkLobbyManager有一个Spawn Info部分,你可以在这里分配Player预制体,确定NetworkLobbyManager是否可以自动创建玩家以及Player Spawn方法。
也有一个Registered Spawnable预制体部分。我们需要注册所有将由NetworkServer生成的游戏对象。例如,炮弹预制体需要在这里注册,这样我们就可以在不同的客户端上通过网络生成它。
在场景中选择Network Lobby Manager游戏对象,在检查器窗口中根据需要分配适当的预制体。
下面是此时NetworkLobbyManager的截图:

大堂管理员
到目前为止,你已经准备好测试我们所构建的内容。使用构建设置窗口创建游戏的独立版本。一旦你的构建就绪,启动两个应用程序实例。我们将使用一个实例来托管游戏,另一个作为客户端连接,如图所示:

以下截图展示了运行游戏实例时的外观:

以下截图展示了运行时屏幕的外观。游戏是在 Android 平板电脑上创建的:

第二个玩家选择“列出服务器”并获取可用匹配,然后点击“加入”进入房间,如图所示:

一旦所有人都准备好了,游戏就开始。注意,每个玩家都有一个独特的坦克颜色,这是基于他们在大厅中的颜色选择分配的。
在前面的截图中,请注意每个客户端都突出显示了它控制的玩家角色,即它控制的坦克。捕捉火命令将很困难,但你可以使用空格键发射加农炮,它将在所有活动客户端上相应触发。
你还会注意到,如果坦克受到攻击,它们的健康值将准确反映。现在我们准备创建一个敌方角色来展示游戏中的非玩家角色。
添加敌方坦克
现在是时候在我们的多人演示中添加一些非玩家角色了。添加敌方坦克将很简单,因为我们将以坦克预制体为基础。将坦克预制体拖放到场景中,并将其名称更改为TankEnemy。
从 GameObject 中移除MyPlayerCharacter.cs脚本。我们将创建一个独立的脚本作为敌方坦克的控制脚本。我还在敌方坦克上应用了不同的材质,以便我们可以从视觉上区分哪些坦克将由玩家控制,哪些不是。请看以下截图:

敌方坦克设置
前面的截图展示了你的Tank和TankEnemy预制体应该看起来是什么样子。两者之间的主要区别是控制脚本。坦克有MylayerController.cs脚本,而TankEnemy有EnemyController.cs脚本。
EnemyController.cs脚本的列表如下:
using UnityEngine;
using UnityEngine.Networking;
public class EnemyController : NetworkBehaviour
{
public GameObject bulletPrefab;
public Transform bulletSpawn;
public float distance = 1000;
public GameObject[] listOfPlayers;
[SyncVar(hook = "OnChangePlayerToAttack")
public GameObject playerToAttack;
float coolOffTime = 0.0f;
void Update()
{
// only execute the following code if local player ...
if (!isServer)
return;
listOfPlayers = GameObject.FindGameObjectsWithTag("Player");
if (listOfPlayers.Length > 0)
{
float distance = 100f;
foreach (var player in listOfPlayers)
{
float d = Vector3.Distance(transform.position, player.transform.position);
if (d < distance)
{
distance = d;
playerToAttack = player;
}
}
if (playerToAttack != null)
{
Vector3 direction = playerToAttack.transform.position - transform.position;
transform.rotation =
Quaternion.Slerp(transform.rotation,
Quaternion.LookRotation(direction), 0.1f);
float d = Vector3.Distance(transform.position, playerToAttack.transform.position);
if (d < 15.0f)
{
if(coolOffTime<Time.time)
{
CmdFire();
coolOffTime = Time.time + 1.0f;
}
}
}
}
}
void OnChangePlayerToAttack(GameObject player)
{
playerToAttack = player;
}
[Command]
void CmdFire()
{
// Create the Bullet from the Bullet Prefab
var bullet = Instantiate(
bulletPrefab,
bulletSpawn.position,
bulletSpawn.rotation) as GameObject;
// Add velocity to the bullet
bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 6;
// Spawn the bullet on the Clients
NetworkServer.Spawn(bullet);
// Destroy the bullet after 2 seconds
Destroy(bullet, 2.0f);
}
}
脚本持续搜索场景中所有活跃的玩家,并为他们创建一个列表。然后它找到离自己最近的一个。一旦确定哪个玩家最近,它就会旋转以面对玩家。
之后,它计算自己与所选玩家的距离;如果距离小于可接受的阈值,它就开始向玩家开火。每次敌方坦克开火时,它实际上会调用一个名为[Command]的CmdFire()函数。
此函数在服务器上运行;它实例化一个炮弹预制体,并在网络上生成它。
EnemyController.cs 脚本也有一个 SyncVar 用于 playertoAttack 变量,并附加了一个作为 OnChangePlayerToAttack() 函数的 hook。这反过来确保所有客户端都更新了每个敌方 Tank GameObject 的最新数据。
Health.cs 脚本在 Tank GameObject 上的工作方式相同。
我们还需要讨论另一个项目:服务器通过生成 Enemy Tanks。我们可以通过创建另一个空的 GameObject 并将其命名为 Enemy Spawner 来轻松完成此操作。我们需要附加一个 NetworkIdentity 组件,并确保将 Server Only 属性设置为 True。这将确保只有服务器可以实例化敌方对象。
下一步是创建 EnemySpawner.cs 脚本。如下所示:
using UnityEngine;
using UnityEngine.Networking;
public class EnemySpawner : NetworkBehaviour {
public GameObject enemyPrefab;
public int numberOfEnemies;
public override void OnStartServer()
{
for (int i = 0; i < numberOfEnemies; i++)
{
var spawnPosition = new Vector3(
Random.Range(-20.0f, 20.0f),
0.0f,
Random.Range(-20.0f, 20.0f));
var spawnRotation = Quaternion.Euler(
0.0f,
Random.Range(0, 180),
0.0f);
var enemy = (GameObject)Instantiate(enemyPrefab, spawnPosition, spawnRotation);
NetworkServer.Spawn(enemy);
}
}
}
这段代码在技术上以提供的预制件作为敌方坦克,并在网络上随机在范围内生成每个敌方坦克。
确保所有预制件都已分配到检查器窗口中的 Enemy Spawner GameObject 和 TankEnemy GameObject。如果你还没有这样做,创建一个 TankEnemy 的预制件,并将其从场景中删除。不要删除 Enemy Spawner。
我们需要将 TankEnemy 预制件注册到 NetworkLobbyManager。请继续选择 Network Manager GameObject,并在检查器窗口中,将一个新的预制件添加到 Registered Spawnable 预制件选项中。
你的 Network Manager 应该如下所示:

大厅网络管理器设置
构建和测试
我们已经准备好进行最终测试。请继续构建项目的独立版本,并启动一个新的游戏实例。玩家 1 将创建房间,其余玩家将加入游戏,如下面的截图所示:

加入游戏演示
你也会注意到,初始化后,所有敌方坦克都会转向玩家角色坦克,如果在其射程内,它们将开始对其开火。
以下截图说明了游戏的运行时状态:

注意,当我试图捕获屏幕时,敌方坦克无情地连续向玩家 4 开火。你可以看到我的生命条大幅减少。你也可以看到敌方坦克之一也受到了一些伤害。
我向你保证,我与敌方坦克受到的伤害无关;实际上,这是由友军火力造成的。是的,目前,敌方坦克还不够聪明,无法在另一名团队成员处于火力线时停止开火!
我将让你自己处理这个实现的细节。不应该太复杂。
使用射线投射来确保在开火之前敌方坦克和玩家之间没有物体。
恭喜!你刚刚创建了你第一个多人游戏!如前所述,创建、维护和托管多人游戏是一项不小的任务,在几页纸上涵盖每个方面是如何做的,简直是不可行的。
这里的想法是给你提供一个基础和基础,你可以在此基础上扩展。我鼓励你花些时间研究我们刚刚覆盖的内容,并对材料进行更多阅读,即使资料不多。事实是,你将需要自己进行大量的实验和尝试。
现在我们已经了解了基础知识,让我们将所学应用到我们的 RPG 资产上。
网络化 RPG 角色
为了让生活更简单,我决定创建一个新的场景,该场景将用于测试和实现我们的网络启用角色。这个例子将向你展示如何使玩家角色网络化,以及如何同步玩家角色数据,如库存物品,通过网络,以及提供你使非玩家角色网络化并使其数据在客户端之间同步的能力。
为我们的 RPG 创建一个场景
就像上一节中的坦克演示一样,我们将使用两个新的场景来展示 RPG 网络概念。我们将创建两个场景,一个叫做 NetworkingMenu,另一个叫做 DeathMatchMultiplayer。
NetworkingMenu 场景是我们的大厅场景,所以它将与坦克多人游戏大厅场景完全相同,除了玩家角色将被野蛮人预制件替换,Registered Spawnable 预制件将不同。我们稍后会看到这一点。
在 DeathMatchMultiplayer 场景中,我的以下级别设计如下:

级别设计生成敌人
你的大厅场景应该看起来像以下这样:

下一步是确保我们的 GameObject 启用网络功能。
网络化玩家角色
请将你创建的玩家预制件拖入场景。我们将将其用作创建新预制件的基础,该预制件将用于游戏的网络版本。
请从实例中移除现有的 BarbarianCharacterController.cs 和 BarbarianCharacterCustomization.cs 组件。我们将创建新的脚本,这些脚本将启用网络功能并使用它们。将 PC GameObject 实例重命名为 PC-C6-Network。现在创建实例的预制件。你现在应该有一个名为 PC-C6-Network 的新预制件。
请使用“检查器”窗口,选择添加组件 | 网络 | <组件名称>,将以下组件附加到预制件:NetworkIdentiy、NetworkTransform 和 NetworkAnimator。
在 NetworkIdentity 组件上,将 Local Player Authority 设置为 True。在 NetworkTransform 组件中,将变换同步模式更改为同步 Rigidbody 3D。在 NetworkAnimator 组件中,您需要将附加到 GameObject 上的 Animator 组件拖放到 Animator 槽中。
您需要选择 Animator 组件,并将其拖放到 NetworkAnimator 组件上的 Animator 槽中。
接下来,我们需要创建一个新的角色控制器,使其具有网络兼容性。
创建一个新的 C# 脚本,命名为 BarbarianCharacterNetworkController.cs。将脚本附加到 PC-C6-Network 预制件上。新的角色控制器是原始角色控制器的一个简化版本。
它的列表如下:
using UnityEngine;
using UnityEngine.Networking;
namespace com.noorcon.rpg2e
{
public class BarbarianCharacterNetworkController : NetworkBehaviour
{
public Transform mainCamera;
public float cameraDistance = 16f;
public float cameraHeight = 16f;
public Vector3 cameraOffset;
public Animator animator;
public float directionDampTime;
public float speed = 6.0f;
public float h = 0.0f;
public float v = 0.0f;
bool attack = false;
bool punch = false;
bool run = false;
bool jump = false;
[HideInInspector]
public bool die = false;
bool dead = false;
[SyncVar]
public bool EnemyInSight;
public GameObject EnemyToAttack;
Quaternion StartingAttackAngle = Quaternion.AngleAxis(-25, Vector3.up);
Quaternion StepAttackAngle = Quaternion.AngleAxis(5, Vector3.up);
Vector3 AttackDistance = new Vector3(0, 0, 2);
// Use this for initialization
void Start()
{
cameraOffset = new Vector3(0f, cameraHeight, -cameraDistance);
mainCamera = Camera.main.transform;
MoveCamera();
animator = GetComponent<Animator>() as Animator;
EnemyInSight = false;
}
// Update is called once per frame
private Vector3 moveDirection = Vector3.zero;
void Update()
{
if (!isLocalPlayer)
return;
if (dead)
{
animator.SetBool("Die", false);
return;
}
if (Input.GetKeyDown(KeyCode.C))
{
attack = true;
this.GetComponent<IKHandle>().enabled = false;
}
if (Input.GetKeyUp(KeyCode.C))
{
attack = false;
this.GetComponent<IKHandle>().enabled = true;
}
animator.SetBool("Attack", attack);
if (Input.GetKeyDown(KeyCode.P))
{
punch = true;
this.GetComponent<IKHandle>().enabled = false;
}
if (Input.GetKeyUp(KeyCode.P))
{
punch = false;
this.GetComponent<IKHandle>().enabled = true;
}
animator.SetBool("Punch", punch);
if (Input.GetKeyDown(KeyCode.LeftShift))
{
this.run = true;
this.GetComponent<IKHandle>().enabled = false;
}
if (Input.GetKeyUp(KeyCode.LeftShift))
{
this.run = false;
this.GetComponent<IKHandle>().enabled = true;
}
animator.SetBool("Run", run);
if (Input.GetKeyDown(KeyCode.Space))
{
jump = true;
this.GetComponent<IKHandle>().enabled = false;
}
if (Input.GetKeyUp(KeyCode.Space))
{
...
你首先应该注意到的是,我们正在从 NetworkBehaviour 继承,而不是 MonoBehaviour。如果我们想在 GameObject 上启用某些网络行为,这是必要的。看看下面的截图:
![img/00186.jpeg]
RPG 网络级别设置
接下来,让我们看看需要同步到网络上的某些变量,这些变量适用于每个连接的玩家角色。这些变量是 enemyToAttack 和 Health。还有两个其他变量,Shield 和 Helmet,我们将在稍后讨论。
在 Update() 函数中,我们需要一种方法来检查并确保在给控制器机会执行玩家之前,它是本地玩家。这是通过以下代码检查当前客户端是否是本地玩家来完成的:
if (!isLocalPlayer)
return;
这将确保代码只为当前客户端(玩家)运行。Update() 函数中的其余代码检查敌人是否在视野中,并确保玩家角色面向敌人进行攻击。
如果玩家处于攻击模式,并且敌人在我们的视野中,我们将 enemyInSight 设置为 True,并将 enemyToAttack 设置为存储在 hitAttack 变量中的敌人 GameObject,该变量类型为 RacastHit。这里的重要元素是 CmdEnemyToAttack() 函数。客户端需要向服务器发送一个命令,告诉服务器攻击的目标是谁:
[Command]
void CmdEnemyToAttack(GameObject go)
{
this.enemyInSight = true;
this.enemyToAttack = go;
}
这将确保数据在服务器上正确注册,并且与其他客户端同步。我们还有一个名为 CmdEnemyTakeDamage() 的函数,用于在服务器上减少敌人角色的健康值。然后服务器调用 RpcEnemyTakeDamage() 函数来同步所有客户端上的敌人健康值:
[Command]
void CmdEnemyTakeDamage(float value)
{
RpcEnemyTakeDamage(value);
}
[ClientRpc]
void RpcEnemyTakeDamage(float value)
{
if(this.enemyToAttack != null)
this.enemyToAttack.GetComponent<NPC_Movement_Network>().Damage(value);
}
这个想法一开始有点令人困惑,但随着你开始更仔细地研究它,它将变得更加清晰。
我们还有一个函数用于在玩家死亡时向服务器发送命令:
[Command]
void CmdPlayerCharacterIsDead()
{
RpcPlayerCharacterIsDead();
}
[ClientRpc]
void RpcPlayerCharacterIsDead()
{
this.die = true;
Destroy(this.gameObject, 2.0f);
}
前面的函数确保在游戏时刻,玩家角色在所有连接的客户端上死亡并被销毁。
最后,以下钩子函数被用于健康和 enemyToAttack 变量的 SyncVar:
// Var Sync hook function ...
void OnChangePlayerHealth(float health)
{
this.Health = health;
}
// Var Sync hook function
void OnChangeEnemyToAttack(GameObject enemy)
{
this.enemyToAttack = enemy;
}
这个想法一开始可能有点令人困惑,但随着你开始更仔细地研究它,它将会变得清晰起来。
如果你还没有这样做,请应用并保存你所有的更改到你的PC-CC-Network预制件。
在这个阶段,你的角色已经准备好与NetworkManager;集成;你可以将预制件拖放到Player预制件槽中,并构建一个独立版本来测试你的角色移动和同步。
网络化非玩家角色
就像玩家角色网络启用预制件一样,我们将使用非玩家角色预制件作为我们的基础来开始。请创建场景中 NPC 的一个实例。
现在请从预制件中移除现有的NPC_Movement.cs组件。将预制件重命名为B1-Network,并通过从检查器窗口中选择Add Component | Network | NetworkIdentity、NetworkTransform和NetworkAnimator。
在NetworkIdentity组件中,将Local Player Authority设置为True;在NetworkTransform组件中,将Transform Sync Mode设置为Sync Transform;对于NetworkAnimator组件,将Animator槽设置为附加到预制件的Animator控制器,通过将其拖放到槽中,如图所示:

我们现在需要为我们的 NPC 移动创建一个新的网络启用脚本。请创建一个新的 C#脚本,并将其命名为NPC_Movement_Network.cs。脚本列表如下:
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
public class NPC_Movement_Network : NetworkBehaviour {
// reference to the animator
public Animator animator;
public bool jump = false; // used for jumping
[SyncVar(hook ="OnNPCIsDead")]
public bool die = false; // are we alive?
...
// what is the field of view for our NPC?
// currently set to 110 degrees
[SyncVar]
public float fieldOfViewAngle = 110.0f;
// calculate the angle between PC and NPC
[SyncVar]
public float calculatedAngle;
[SyncVar(hook = "OnChangePlayerToAttackInNPC")]
public GameObject playerToAttack;
[SyncVar(hook = "OnChangeNPCHealth")]
public float Health = 100.0f;
void Awake()
{
// get reference to the animator component
this.animator = GetComponent<Animator>() as Animator;
// get reference to nav mesh agent
this.nav = GetComponent<NavMeshAgent>() as NavMeshAgent;
// get reference to the sphere collider
this.col = GetComponent<SphereCollider>() as SphereCollider;
// we don't see the player by default
this.playerInSight = false;
}
void Update()
{
// only execute the following code if local player ...
if (!isServer)
return;
this.CmdUpdateNetwork();
}
[Command]
void CmdUpdateNetwork()
{
this.RpcUpdateNetwork();
}
[ClientRpc]
void RpcUpdateNetwork()
{
// if player is in sight let's slerp towards the player
if(this.playerToAttack!=null)
{
if (playerInSight)
{
this.transform.rotation =
Quaternion.Slerp(this.transform.rotation,
Quaternion.LookRotation(direction), 0.1f);
if (this.playerToAttack.transform.GetComponent<CharacterController_Network>().die)
{
animator.SetBool("Attack", false);
animator.SetFloat("Speed", 0.0f);
animator.SetFloat("AngularSpeed", 0.0f);
this.playerInSight = false;
this.playerToAttack = null;
}
}
}
if(this.Health<=0.0f)
{
this.die = true;
this.Health = 0.0f;
animator.SetBool("Attack", false);
animator.SetFloat("Speed", 0.0f);
animator.SetFloat("AngularSpeed", 0.0f);
this.playerInSight = false;
this.playerToAttack = null;
}
animator.SetBool("Die", die);
}
...
有几个变量已被标记为SyncVars;这些是:die、distance、direction、angle、playerInSight、fieldOfViewAngle、calculatedAngle、playerToAttack和Health。
看看下面的代码:
void OnTriggerExit(Collider other)
{
if (other.transform.tag.Equals("Player"))
{
distance = 0.0f;
angle = 0.0f;
this.attack1 = false;
this.playerInSight = false;
this.playerToAttack = null;
}
}
// this is a helper function at this point
// in the future we will use it to calculate distance around the corners
// it currently is also used to draw the path of the nav mesh agent in the
// editor
float CalculatePathLength(Vector3 targetPosition)
{
// Create a path and set it based on a target position.
NavMeshPath path = new NavMeshPath();
if (nav.enabled)
nav.CalculatePath(targetPosition, path);
// Create an array of points which is the length of the number of corners in the path + 2\.
Vector3[] allWayPoints = new Vector3[path.corners.Length + 2];
// The first point is the enemy's position.
allWayPoints[0] = transform.position;
// The last point is the target position.
allWayPoints[allWayPoints.Length - 1] = targetPosition;
// The points inbetween are the corners of the path.
for (int i = 0; i < path.corners.Length; i++)
{
allWayPoints[i + 1] = path.corners[i];
}
// Create a float to store the path length that is by default 0\.
float pathLength = 0;
// Increment the path length by an amount equal to the distance between each waypoint and the next.
for (int i = 0; i < allWayPoints.Length - 1; i++)
{
pathLength += Vector3.Distance(allWayPoints[i], allWayPoints[i + 1]);
if (DEBUG_DRAW)
Debug.DrawLine(allWayPoints[i], allWayPoints[i + 1], Color.red);
}
return pathLength;
}
}
一些SyncVars有hook;这些是Health、playerToAttack、playerInSight和die。
在Update()函数中,我们通过以下行来确保我们是服务器:
// only execute the following code if server ...
if (!isServer)
return;
如果我们是服务器,我们使用CmdUpdateNetwork()和RpcUpdateNetwork()函数来执行我们的职责。这些只是用于 NPC 的移动和动作。关键在于用于将 NPC 数据同步到所有客户端的SyncVars和hook函数:
public void OnChangePlayerPlayerInSight(bool value)
{
this.playerInSight = value;
}
// Var Sync hook function ...
void OnChangeNPCHealth(float health)
{
this.Health = health;
}
void OnNPCIsDead(bool value)
{
die = true;
}
void OnChangePlayerToAttackInNPC(GameObject player)
{
this.playerToAttack = player;
}
对于 NPC 来说,这就足够了。请将脚本添加到预制件中并应用更改。保存它。
你的新 NPC 预制件应该附加以下组件:
同步玩家自定义和物品
为了使这起作用,我们需要配置并创建几个更多的新的库存物品预制件。我将使用两个库存物品来演示这个特定的点。
我将使用我库存物品中的一个Helmet预制件。复制它,并移除InventoryItemAgent.cs组件。我们将创建一个新的网络启用脚本,就像我们为我们的 PC 和 NPC 所做的那样。
将以下组件附加到实例上:NetworkIdentity和NetworkTransform,从检查器窗口使用添加组件 | 网络 | <组件名称>:*

创建一个名为InventoryItemAgent_Network.cs的新脚本。如下所示:
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
public class InventoryItemAgent_Network : NetworkBehaviour {
public InventoryItem ItemDescription;
public void OnTriggerEnter(Collider c)
{
// make sure we are colliding with the player
if (c.gameObject.tag.Equals("Player"))
{
// Make a copy of the Inventory Item Object
InventoryItem myItem = new InventoryItem();
myItem.CopyInventoryItem(this.ItemDescription);
c.gameObject.GetComponent<CharacterController_Network>().PlayerArmourChanged(myItem);
}
}
}
所有这些脚本所做的只是使用CharacterController_Network.cs脚本中的PlayerArmourChanged()函数将库存物品分配给玩家角色。
PlayerArmourChanged()函数使用我们需要创建的另一个脚本,这是一个网络启用脚本,即CharacterCustomization_Network.cs脚本。我不会在这里列出脚本,因为它非常长。您可以在书中提供的代码中查看脚本。
生成 NPC 和其他物品
我们需要一种方法来生成我们的 NPC 以及我们将用于下一个演示的库存物品。
在层次结构窗口中,右键单击并选择创建空对象;这将创建一个EmptyGameObject。将其重命名为SpawnEnemy,并通过从检查器窗口选择添加组件 | 网络 | 网络身份将其添加一个NetworkIdentity组件。
我们将创建一个名为EnemySpawn_Network.cs的新脚本。如下所示:
using UnityEngine;
using UnityEngine.Networking; // used for chapter 8
using System.Collections;
public class EnemySpawn_Network : NetworkBehaviour
{
public GameObject enemyPrefab;
public Transform spawnLocation;
public GameObject inventoryItemPrefab;
public GameObject inventoryItemShield;
public override void OnStartServer()
{
GameObject go = GameObject.Instantiate(enemyPrefab, spawnLocation.position, Quaternion.identity) as GameObject;
NetworkServer.Spawn(go);
GameObject goInventoryItem1 = GameObject.Instantiate(inventoryItemPrefab, new Vector3(2, 1, 2), Quaternion.identity) as GameObject;
NetworkServer.Spawn(goInventoryItem1);
GameObject goInventoryItem2 = GameObject.Instantiate(inventoryItemShield, new Vector3(3, 1, 2), Quaternion.identity) as GameObject;
NetworkServer.Spawn(goInventoryItem2);
}
}
如您所见,脚本非常简单。我们只是引用代表 NPC 和库存物品预制件的 GameObject。
在层次结构窗口中,将新脚本附加到SpawnEnemy预制件上。
测试我们的网络启用 PC 和 NPC
到目前为止,我们已经拥有了测试我们的网络启用 RPG 角色所需的全部资产。如果您还没有这样做,我们需要执行一个最后的步骤。
在层次结构窗口中选择NetworkManagerGameObject,并在检查器窗口中,您需要确保在生成信息部分已经分配了某些内容。
玩家预制件应该分配给您的玩家角色预制件。我的名字叫PC-CC-Network-1。确保自动创建玩家设置为True。
你还需要在“已注册可生成预制件”中注册你的 NPC 预制件和其他网络启用非角色预制件。我已经将名为B1-Network-1的野蛮人预制件分配,barbarian_helmet_01_LOD0_Network,以及shield_01_LOD0_Network。
看看下面的屏幕截图:

好吧,最后,我们可以进行构建。让我们继续制作我们游戏的独立构建。确保当前场景在构建配置中:

好吧,继续启动两个构建实例。其中一个作为主机,另一个作为客户端。
看看下面的屏幕截图:

在前面的屏幕截图中,我们已经启动了一个作为服务器的客户端,玩家角色已经拿起了一个库存物品,一个盾牌。当我们连接第二个客户端时,它应该正确考虑游戏中所有活跃的 PC 和 NPC 的当前状态:

保持两个实例运行,使用 Unity IDE 连接第三个客户端。你可以使用客户端在客户端端进行调试,并查看正在发生的事情。
看看下面的截图:

在前面的截图中,你可以看到所有玩家角色以及它们是如何被准确同步的。从层次窗口中选择 B1-Network-1 GameObject,并使用一个客户端实例来控制玩家角色攻击 NPC。
我们将暂停编辑器,检查变量以及它们是如何被正确同步的,如下面的截图所示:

接下来是什么?
正如你所见证的,网络编程很简单,但同时也可能很困难。困难在于以高效和有意义的方式管理和理解所有玩家之间的数据同步。
如果你真正考虑创建一个拥有大量客户端的游戏,实际上可能会更加复杂。Unity 网络将无法处理这种情况;你需要创建自己的后端服务器管理器和消息系统。
本章所涵盖的内容将为你提供一个很好的理解,以便将其提升到下一个层次。继续编码,直到我们再次见面!
概述
在本章中,我们使用了 Unity 网络组件来研究网络编程。本章的主要目标是通过对两个示例的实现,向您介绍 Unity 网络的基础。
我们本章开始时讨论了作为多人游戏的游戏设计师和开发者可能会面临的挑战。提出的主要问题之一是,你是否真的需要投入时间和精力为你的游戏创建多人模式。假设你真的想要或需要创建多人游戏,我们首先研究了目前流行的不同类型的多人游戏。
然后,我们继续我们的第一个简化版多人游戏的示例。我们开发的多人游戏是实时的,也就是说,所有客户端都是基于每个活跃玩家状态的活动同步的;也就是说,位置、旋转、移动和其他重要数据需要与所有连接到游戏会话的客户端同步。
我们研究了 Unity 网络组件的基础,例如网络管理器、网络管理器 HUD、网络身份和网络变换。这些是用于说明 Unity 中多人编程的基础组件。一旦我们了解了这些组件的用途,我们就从一个简单的示例开始。
我们创建了一个简单的坦克游戏,展示了如何将多人游戏所需的所有基本组件组合在一起。我们创建了必要的玩家角色预制体,并添加了适当的网络启用脚本和组件。我们还创建了非玩家角色预制体,并为其添加了自身的网络启用脚本。游戏演示了如何生成玩家角色和非玩家角色,以及如何在它们之间进行同步。
在构建坦克游戏的过程中,我们介绍了如何同步变量和使用对多人游戏开发至关重要的网络回调。我们还解释了什么是命令和ClientRPC调用,以及如何正确使用它们。
接下来,我们将所学知识应用到之前章节中开发的 RPG 角色上。我们以现有的预制体为基础,扩展了它们以包含网络组件,并创建了新的网络启用脚本以处理角色移动、角色定制和非玩家角色移动脚本。
我们讨论的一个关键元素是,如何将每个玩家角色的库存物品在视觉上与其他玩家同步。我们在章节的最后通过测试和讨论如何在开发多人游戏时调试客户端和服务器上的代码来结束本章。


浙公网安备 33010602011771号