Unity-2D-游戏开发教程-全-
Unity 2D 游戏开发教程(全)
一、游戏和游戏引擎
在这介绍性的一章中,我将谈一点关于游戏引擎的事情:它们是什么,以及为什么使用它们。我还将讨论几个具有历史意义的游戏引擎,并介绍 Unity 的高级功能。如果你想直接制作游戏,可以随意浏览或跳过这一章,以后再回来看。
游戏引擎——它们是什么?
游戏引擎是软件开发工具,旨在降低视频游戏开发所需的成本、复杂性和上市时间。这些软件工具在开发视频游戏的最常见任务之上创建了一个抽象层。抽象层被打包到工具中,这些工具被设计为可互操作的组件,可以直接替换或用其他第三方组件进行扩展。
游戏引擎通过减少制作游戏所需的知识深度,提供了巨大的效率优势。它们可以是预建功能最少的,也可以是全功能的,让游戏开发者可以完全专注于编写游戏代码。对于只想专注于尽可能做出最好游戏的单人开发者或团队来说,游戏引擎提供了一个超越从头开始的难以置信的优势。在构建本书中的示例游戏时,您不需要从头开始构建复杂的数学库,也不需要弄清楚如何在屏幕上呈现单个像素,因为创建 Unity 的开发人员已经为您完成了这些工作。
设计良好的现代游戏引擎在内部分离功能方面做得很好。游戏代码由描述玩家和库存的代码组成,与解压缩. mp3 文件并将其加载到内存中的代码分开保存。游戏代码将调用定义良好的引擎 API 接口来请求诸如“在这个位置绘制这个精灵”之类的事情。
一个设计良好的游戏引擎的基于组件的体系结构考虑到了鼓励采用的可扩展性,因为开发团队并不局限于一组预先确定的引擎功能。如果游戏引擎源代码不是开源的,或者许可费用非常昂贵,那么这种可扩展性就特别重要。Unity 游戏引擎是专为第三方插件而设计的。它甚至提供了一个包含插件的资产库,可以通过 Unity 编辑器访问。
许多游戏引擎也允许跨平台编译,这意味着你的游戏代码不局限于单个平台。该引擎通过不对底层计算机架构进行假设,并让开发人员指定他们使用的平台来实现这一点。如果你想发布你的游戏用于主机、桌面和移动设备,游戏引擎允许你切换几个开关来设置该平台的构建配置。
不过,对于跨平台编译的奇迹,也有一些警告。虽然跨平台编译是一项令人惊叹的功能,也证明了游戏技术的进步,但请记住,如果您正在为多个平台构建游戏,您需要提供不同的图像大小,并允许控件中的代码读取来接受不同类型的外围设备,如键盘。你可能需要调整游戏在屏幕上的布局以及许多其他任务。将一个游戏从一个平台移植到另一个平台实际上可能需要做很多工作,但是你可能不需要接触游戏引擎本身。
一些游戏引擎是如此的可视化,以至于他们允许不用写一行代码就能创建游戏。Unity 具有定制用户界面的能力,这些用户界面可以被开发团队的其他非程序员成员使用,例如关卡设计师、动画设计师、美术指导和游戏设计师。
有许多不同类型的游戏引擎,没有规则规定哪些功能是绝对需要的。最流行的游戏引擎包含以下部分或全部功能:
-
图形渲染引擎,支持 2D 或 3D 图形
-
支持碰撞检测的物理引擎
-
音频引擎加载和播放声音和音乐文件
-
实现游戏逻辑的脚本支持
-
定义游戏世界的内容和属性的世界对象模型
-
动画处理加载动画帧并播放它们
-
允许多人游戏、可下载内容和排行榜的网络代码
-
多线程允许游戏逻辑同时执行
-
内存管理,因为没有计算机有无限的内存
-
用于寻路和计算机对手的人工智能
如果你还没有完全接受使用游戏引擎,考虑下面的类比。
说你要盖房子。首先,这所房子将有一个混凝土地基,一个漂亮的木地板,坚固的墙壁,和一个风化处理的木屋顶。建造这座房子有两种方法:
建造房子的第一种方法
用手铲挖掘地面,直到你挖到足够的深度来种植地基。将石灰石和粘土放入窑中,在 2640 华氏度的温度下加热,研磨,并掺入少量石膏,制成混凝土。将你制作的粉末状混凝土,与水、碎石或细沙混合,然后打好地基。
在你打地基的同时,你需要钢筋来加固混凝土。收集制造钢筋所需的铁矿石,在高炉中熔炼,制成钢锭。将这些铝锭熔化并热轧成坚固的钢筋,用于混凝土基础。
之后,是时候搭建框架来悬挂墙壁了。拿起你的斧头,开始砍树。砍伐几百根左右的木材就足够供应原材料了,但是接下来你需要把每根木材加工成木材。完成后,别忘了处理木材,使其不受天气影响,不会腐烂或滋生昆虫。建造你的托梁和大梁,你将在上面铺设地板,你累了吗?我们才刚刚开始!
建造房子的第二种方法
购买袋装预拌混凝土、钢筋、从工厂加工的木材、一打纸带镀锌钉和气动钉枪。混合并浇注你的混凝土来建造你的地基,放下预制的钢筋,让混凝土凝固,然后用处理过的木材建造你的地板。
关于第一种方法
建造房子的第一种方法需要大量的知识,仅仅是创造建造房子所需的材料。这种方法要求你知道制造混凝土和钢材所需原材料的精确比例和技术。你需要知道如何砍伐树木而不被压在树下,你还需要知道处理木材所需的适当化学物质,你已经煞费苦心地将木材切割成数百根均匀的横梁。即使你拥有用这种方法建造房子所需的所有知识,它仍然会花费你数千个小时。
第一种方法类似于坐下来不使用游戏引擎来编写视频游戏。你必须从头开始做所有的事情:编写数学库、图形渲染代码、碰撞检测算法、网络代码、资产加载库、音频播放器代码等等。即使你一开始就知道如何做所有这些事情,你仍然需要花很长时间来编写游戏引擎代码并调试它。如果你不熟悉线性代数,渲染技术,以及如何优化剔除算法,你应该预料到,在你拥有足够的游戏引擎来实际开始编写游戏之前,你可能需要花费数年时间。
关于第二种方法
建造房子的第二种方式假设你不是完全从零开始。这并不要求你知道如何操作高炉,砍伐数百根木材,或者把它们磨成木材。第二种方法可以让你完全专注于建造房子,而不是制造建造房子所需的材料。如果你仔细选择材料并知道如何使用它们,你的房子会建造得更快,成本更低,而且质量可能更高。
第二种方法类似于坐下来编写一个视频游戏,并使用一个预建的游戏引擎。游戏开发者能够专注于游戏的内容,而不需要知道如何进行复杂的计算来判断两个物体在空中飞行时是否发生碰撞,因为游戏引擎会为他们完成这项工作。不需要构建资产加载系统、编写低级代码来读取用户输入、解压缩声音文件或解析动画文件格式。没有必要为所有视频游戏构建这种通用的功能,因为游戏引擎开发人员已经投入了数千小时来编写、测试、调试和优化代码来做这些事情。
总之…
游戏引擎给开发下一款热门游戏的独立开发者或大工作室团队带来的优势怎么强调都不为过。一些开发人员想要编写他们自己的游戏引擎作为编程练习,以了解一切是如何在引擎盖下工作的,他们将会学到大量的东西。但是如果你的目的是发布一款游戏,那么不使用预制的游戏引擎会给你自己带来伤害。
历史上的游戏引擎
历史上,游戏引擎有时与游戏本身紧密相关。1987 年,朗·吉尔伯特在芯片晨星公司的帮助下,在卢卡斯影业游戏公司工作时,为游戏引擎 Maniac Mansion 创建了脚本创建实用程序 SCUMM。SCUMM 是一个为特定的类型游戏定制的游戏引擎的很好的例子。SCUMM 中的“MM”代表狂魔大厦,这是一款广受好评的冒险游戏,也是第一款使用点击式界面的游戏,Gilbert 也发明了这款游戏。
SCUMM 游戏引擎负责将由人类可读的符号化单词组成的脚本(如“步行字符到门”)转换为字节大小的程序,以供游戏引擎解释器读取。翻译负责控制屏幕上游戏的演员,并呈现声音和图形。编写游戏而不是编码的能力,促进了快速原型制作,并允许团队从早期阶段就开始构建并专注于游戏。虽然 SCUMM 引擎是专门为狂人大厦(图 1-1 )开发的,但它也用于其他热门游戏,如全速、猴岛的秘密、印第安纳琼斯和最后的远征:图形冒险等。
图 1-1
卢卡斯影业游戏公司出品的 Maniac Mansion 使用了 SCUMM 引擎
与 Unity 等现代游戏引擎相比,SCUMM 引擎缺乏很大的灵活性,因为它是为点击式游戏定制的。然而,像 Unity 一样,SCUMM 引擎允许游戏开发者专注于游戏性,而不是不断地为每个游戏重写图形和声音代码,从而节省了大量的时间和精力。
有时候游戏引擎会对整个行业产生巨大的影响。1991 年年中,一家名为 id Software 的公司发生了行业内的巨大转变,当时 21 岁的约翰·卡马克为一款名为 Wolfenstein 3D 的游戏开发了一个 3D 游戏引擎。在此之前,3D 图形通常仅限于缓慢移动的飞行模拟游戏或简单多边形游戏,因为可用的计算机硬件太慢,无法计算和显示快节奏 3D 动作游戏所需的表面数量。卡马克能够通过使用一种叫做光线投射的图形技术来解决当前的硬件限制。这允许通过计算和显示玩家可见的表面而不是玩家周围的整个区域来快速显示 3D 环境。
这种独特的方法让卡马克与约翰·罗梅洛、设计师汤姆·霍尔和艺术家阿德里安·卡马克一起创作了一款暴力、快节奏的关于打倒纳粹的游戏,这催生了第一人称射击游戏(FPS)类型的视频游戏。id Software 将 Wolfenstein 3D 引擎授权给了其他几款游戏。迄今为止,他们已经生产了七款游戏引擎,这些引擎已经被用于一些有影响力的游戏,如雷神之锤 III 竞技场、末日重启和沃尔芬斯坦 II:新巨人。
如今,构建一个粗略的 3D FPS 游戏原型是一个有经验的游戏开发人员可以使用 Unity 这样强大的游戏引擎在几天内完成的事情。
当今的游戏引擎
现代的 AAA 游戏开发工作室,如 Bethesda 游戏工作室和暴雪娱乐公司通常有自己的内部专有游戏引擎。Bethesda 的内部游戏引擎名为:Creation Engine,用于创建上古卷轴 V:天际以及辐射 4 。暴雪有自己专有的游戏引擎,用于制作游戏,如《??》、《魔兽世界》和《守望先锋》。
专有的内部游戏引擎可能开始时是为特定的游戏项目而构建的。项目发布后,游戏引擎通常会在游戏工作室的下一个游戏中重新使用时获得新生。该引擎可能需要升级以保持最新并利用最新技术,但它不需要从头开始重建。
如果游戏开发公司没有内部引擎,他们通常会使用开源引擎,或者授权第三方引擎,如 Unity。如今,在不使用游戏引擎的情况下创建一个重要的 3D 游戏将是一项极其艰巨的任务——无论是在经济上还是在技术上。事实上,拥有内部游戏引擎的游戏工作室需要独立的编程团队,完全致力于构建引擎功能并优化它们。
说了这么多,为什么一个 AAA 工作室选择不使用像 Unity 这样的游戏引擎,而是选择建立自己的内部引擎?像 Bethesda 和 Blizzard 这样的公司有大量现成的代码可以利用,还有财政资源和大量才华横溢的程序员。对于某些类型的项目,他们希望完全控制游戏和游戏引擎的每个方面。
尽管比典型的小型游戏工作室有这些优势,Bethesda 仍然使用 Unity 来开发手机游戏:辐射避难所;而暴雪用 Unity 开发了一个小小的跨平台收藏卡牌游戏:炉石。当时间等于金钱时,像 Unity 这样的游戏引擎可以用来快速原型化、构建和迭代功能。如果你的计划是在多个平台上发布一款游戏,那么时间=金钱的等式就显得尤为重要。将内部引擎移植到 iOS 和 Android 等特定平台可能非常耗时。如果一个项目不需要你在开发像 Overwatch 这样的游戏时所需要的对游戏引擎的相同级别的控制,那么使用像 Unity 这样的交叉兼容的游戏引擎是显而易见的。
Unity 游戏引擎
Unity 是一款非常受欢迎的游戏引擎,与当今市场上的其他游戏引擎相比,它提供了大量的优势。Unity 提供了一个具有拖放功能的可视化工作流程,并支持用 C# 编写脚本,这是一种非常流行的编程语言。Unity 长期以来一直支持 3D 和 2D 图形,并且每一个版本的工具集都变得更加复杂和用户友好。
Unity 有几层许可证,对于收入高达 10 万美元的项目是免费的。它为 27 种不同的平台提供跨平台支持,并利用特定于系统架构的图形 API,包括 Direct3D、OpenGL、Vulkan、Metal 和其他几个。Unity Teams 提供基于云的项目协作和持续集成。
自 2005 年首次亮相以来,Unity 已被用于开发数以千计的桌面、移动和主机游戏和应用。这些年来 Unity 开发的一些知名游戏的小样本包括:Thomas Alone(2010)Temple Run(2011)The Room(2012)rim world(2013)炉石(2014)Kerbal Space Program(2015)pokémon GO(2014)
图 1-2
由 StudioMDHR 开发的 Cuphead 使用了 Unity 游戏引擎
对于想要定制工作流程的游戏开发者来说,Unity 提供了扩展默认可视化编辑器的能力。这种极其强大的机制允许创建定制工具、编辑器和检查器。想象一下,为你的游戏设计者创建一个可视化工具,轻松调整游戏中对象的值,如角色类别的生命值、技能树、攻击范围或物品掉落,而不必进入代码并修改值或使用外部数据库。Unity 提供的编辑器扩展功能使这一切变得可能和简单。
Unity 的另一个优势是 Unity 资产商店。资产商店是一个在线店面,艺术家、开发人员和内容创作者可以在这里上传内容进行买卖。资产商店包含数以千计的免费和付费的编辑器扩展、模型、脚本、纹理、着色器等,团队可以使用它们来加快开发进度并增强最终产品。
摘要
在这一章中,我们了解了使用预制游戏引擎比自己编写游戏引擎有很多优势。我们谈到了几个有趣的游戏引擎,以及它们对整个游戏开发的影响。我们还概述了 Unity 提供的具体优势,并提到了一些使用 Unity 引擎开发的更知名的游戏。也许很快有一天,有人会提到你的游戏,说它是用 Unity 制作的最著名的游戏之一!
二、Unity 简介
本章介绍 Unity 编辑器——安装、配置、导航其窗口、使用其工具集以及熟悉项目结构。并非所有这些材料都与您在 Unity 中的日常工作直接相关,无论如何,您将来都可能需要多次参考这一章,所以不要试图一次就记住所有内容。
安装 Unity
首先:去 https://store.unity.com
下载 Unity。因为我们只是在学习使用 Unity,获得个人版本,这是免费的。
就本书的目的而言,免费版和增强版的主要区别在于,免费版会在闪屏上显示“由 Unity 制作”,而增强版允许您创建自定义闪屏。Plus、Pro 和 Enterprise 版本逐渐变得更加昂贵,但提供了一些有趣的好处,如更好的数据分析和控制、多人游戏功能、使用 Unity 云服务的测试版本,甚至可以访问企业级的源代码。
你应该记住这些等级,你每一级的资格是由收入决定的。如果你或你的游戏公司每年的收入少于 10 万美元,你就有资格免费使用 Unity 个人版。如果您的公司年收入低于 20 万美元,您需要使用 Unity Plus 层。最后,如果您的公司年收入超过 20 万美元,您必须使用 Unity Pro。一点也不差。
安装 Unity 时,Unity 下载助手会提示您选择要安装的 Unity 编辑器组件。确保以下组件已勾选:Unity 2018(或最新版本)、文档、标准资产和示例项目。在本书中,我们将构建一个可以在你的桌面(PC、Mac 或 Linux)上独立运行的示例游戏。如果你愿意,你也可以勾选复选框来安装 WebGL、iOS 或 Android 构建支持的组件,以便为这些平台构建。
配置 Unity
安装 Unity 并首次运行后,会提示您登录您的帐户(图 2-1 )。除非你想利用一些更高级的功能,如云构建和广告,否则创建并登录一个帐户并不是真正必要的,但无论如何创建一个帐户并登录并没有什么坏处。如果您想使用 Unity 资产商店中的任何东西,您需要一个帐户。
图 2-1
Unity 登录屏幕
我们来过一遍 Unity 的项目和学习画面,如图 2-2 所示,指出几件事。在左上角,您会注意到两个选项卡——项目和学习。
图 2-2
Unity 项目和学习屏幕
选择项目,让我们浏览选项:
在磁盘上
将出现您最近参与的六个项目的历史记录,并且可以通过选择它们来打开。
在云端
这指的是使用基于云的协作项目,我们不会讨论这个。Unity Teams 有一个名为 Unity Collaborate 的功能,允许团队成员更新项目中的文件,并将这些更改发布到云中。然后,其他团队成员可以查看这些更改,并决定是将他们的本地项目与这些更改同步,还是忽略它们。如果你曾经使用过 Git,Unity Collaborate 是非常相似的,但是 Git 有一点学习曲线,Unity Collaborate 被有意设计成非常直观和易于使用。
现在选择“学习”选项卡。
学习部分有丰富的信息,您可以轻松地花几周时间浏览所有教程、示例项目、资源和链接。不要害怕打开看起来远远超出您已经知道的范围的示例项目。四处打探,调整东西,打破东西。学习就是这样发生的。如果您破坏了某些东西并且无法修复,您可以随时关闭并重新加载示例项目。
好了,让我们开始创建我们的项目。
从项目和学习屏幕的右上角选择“新建”。
您将看到一个屏幕,如图 2-3 所示,包含一些用于设置新项目的配置选项。
新 Unity 项目的默认名称是“新 Unity 项目”将项目名称改为“RPG”或“有史以来最伟大的 RPG”,如图 2-3 所示。选择“2D”旁边的单选按钮,将项目配置为始终显示 2D 的侧视图。如果您忘记设置它,也不用担心——一旦我们的项目被创建,就很容易切换。
请注意“位置”文本框中的文件路径。这就是 Unity 将拯救你的项目的地方。我喜欢把我电脑上的源代码放在一个名为“source”的父目录中,而 Unity 代码放在一个“Unity”子目录中,但是你可以随意组织你的目录结构。如果您已登录,您将看到一个切换开关来打开 Unity Analytics。您可以关闭此设置,因为我们不会使用它。
图 2-3
项目创建
点击“创建项目”按钮,用这些设置创建一个新项目,并在 Unity 编辑器中打开它。
脚本编辑器:Visual Studio
从 Unity 2018.1 开始,Visual Studio 现在是开发 C# 脚本的默认脚本编辑器。历史上,Unity 自带的内置脚本编辑器是 MonoDevelop,但从 Unity 2018.1 开始,Unity 自带了 Visual Studio for Mac,而不是 macOS 上的 MonoDevelop。在 Windows 上,Unity 附带 Visual Studio 2017 Community,不再附带 MonoDevelop。
接下来,我们将了解 Unity 编辑器。
导航 Unity 界面
横跨 Unity 编辑器顶部的是工具栏,它由转换工具集、工具手柄控制、播放、暂停和步进控制、云协作选择器、服务按钮、帐户选择器、层选择器和布局选择器组成。我们将在适当的时候讨论所有这些问题。
Unity 界面(图 2-4 )由许多窗口视图组成,我们接下来将回顾这些视图。
图 2-4
Unity 编辑器
了解不同的窗口视图
让我们浏览一下默认编辑器布局中显示的各种视图。除了我们下面讨论的视图之外,还有许多其他视图可用,我们将在本书的后面讨论其中的一些。
- 场景视图
场景可以被认为是 Unity 项目的基础,所以在 Unity 编辑器中工作时,大部分时间场景视图都是打开的。游戏中发生的一切都发生在一个场景中。场景视图是我们构建游戏和使用精灵和碰撞器完成大部分工作的地方。场景包含游戏对象,它们拥有与场景相关的所有功能。我们将在第三章中更详细地介绍游戏对象,但是现在我们只知道 Unity 场景中的每个对象都是游戏对象。
- 游戏视图
游戏视图从当前活动摄像机的视角渲染游戏。游戏视图也是你在 Unity Editor 中工作时查看和玩游戏的地方。在 Unity Editor 之外也有构建和运行游戏的方法,比如一个独立的应用,在网络浏览器中,或者在手机上,我们将在本书的后面介绍其中的一些平台。
- 资产商店
选择 Unity 构建游戏的一个引人注目的因素是 Unity 资产商店。正如在第一章中所讨论的,Unity 资产商店是一个在线店面,艺术家、开发者和内容创作者可以上传内容进行买卖。为了方便起见,Unity 编辑器有一个连接到资产商店的内置标签,但您也可以通过 Web 在 https://assetstore.unity.com
访问资产商店。虽然在你的布局中使用这个面板没有坏处,但是隐藏它并且只在你需要资产商店的东西时才打开它也没有坏处。
- 层级窗口
“层次”窗口以层次格式显示当前场景中所有对象的列表。层级窗口也允许通过左上角的“创建”下拉菜单创建新的游戏对象。搜索栏允许开发者通过名字搜索特定的游戏对象。
在 Unity 中,游戏对象可以包含其他游戏对象,这就是所谓的“亲子”关系。“层次结构”窗口将以有用的嵌套格式显示这些关系。图 2-5 描绘了示例场景中的层级窗口视图。
图 2-5
层次窗口
下面是对“层次结构”窗口中“父子”关系的简单解释。图 2-5 中的示例场景被称为 GameScene,它包含一个名为 Environment 的游戏对象。环境是几个游戏对象的父对象,包括一个叫地面的。地面是相对于环境的子对象。然而,地面包含几个自己的子对象,包括树、灌木和道路。地面是相对于这些子对象的父对象。
- 项目窗口
“项目”窗口概述了“资源”文件夹中的所有内容。在项目窗口中创建文件夹有助于整理音频文件、素材、模型、纹理、场景和脚本等项目。在项目的整个生命周期中,您将花费大量时间拖移和重新排列文件夹中的资源,并选择这些资源以在“检查器”窗口中查看它们。在本书中,我们将演示一个建议的项目文件夹结构,但是您应该可以自由地以一种对您和您喜欢的工作方式有逻辑意义的方式重新安排事情。
- 控制台视图
控制台视图将显示 Unity 应用的错误、警告和其他输出。有一些 C# 脚本函数可用于在运行时将信息输出到控制台视图,以帮助调试。我们将在稍后讨论调试时讨论这些内容。您可以通过控制台视图右上角的三个按钮打开和关闭各种形式的输出。
小费
有时你会得到一个错误信息,每次 Unity 框架更新时都会出现,这些信息会很快堵塞你的控制台视图。在这种情况下,点击折叠切换按钮将所有相同的错误消息折叠成一条消息会很有帮助。
- 检查器窗口
检查器窗口是 Unity 编辑器中最有用和最重要的窗口之一;一定要熟悉一下。Unity 中的场景由游戏对象组成,游戏对象由脚本、网格、碰撞器和其他元素组成。您可以选择一个游戏对象,并使用检查器来查看和编辑附加的组件及其各自的属性。甚至有技术可以在游戏对象上创建你自己的属性,然后可以修改。我们将在后面的章节中详细介绍这一点。您也可以使用检查器来查看和更改预设、摄影机、材质和资源的属性。如果选择了资源,如音频文件,检查器将显示详细信息,如文件的载入方式、导入的大小和压缩率。材质贴图等资源将允许您检查渲染模式和着色器。
小费
请注意,您可以通过快捷方式访问许多更常用的面板:Control (PC)或 Cmd / ⌘ (Mac) + number。例如,⌘ + 1 和⌘ + 2 分别在 Mac 上的场景视图和游戏视图之间切换。这是一个节省时间的好方法,可以避免使用鼠标进行更常见的窗格切换。
配置和自定义布局
通过抓住窗格左上角的选项卡并拖动它,可以重新排列每个窗格。Unity 允许用户创建一个自定义的编辑器布局,方法是拖动窗格,锁定它们,根据你的喜好调整它们的大小,然后保存布局。
要保存布局,您有两种选择:
-
进入菜单选项:窗口➤布局➤保存布局。出现提示时,为您的自定义布局命名,然后点击保存按钮。
-
点击 Unity 编辑器右上角的布局选择器(图 2-6 )。一开始会说违约。然后选择保存布局,给你的自定义布局一个名字,点击保存按钮。
您可以在将来从同一个菜单加载任何布局:窗口➤布局,或使用布局选择器。如果您想重置您的布局,只需从布局选择器中选择默认。
图 2-6
布局下拉菜单
变换工具集
接下来,我们将浏览组成工具栏的不同按钮和开关。现在工具栏需要注意的三件事是:转换工具集;工具手柄控制;以及播放、暂停和步进控件。工具栏上还有其他控件,但是我们在开始使用它们的时候会用到它们。
变换工具(图 2-7 )允许用户在场景视图中导航并与游戏对象互动。
图 2-7
变换工具集
六个变换工具从左到右分别是:
- 手
手形工具允许您在屏幕上左键单击并拖动鼠标来平移视图。请注意,当选择“手形工具”时,您将无法选择任何对象。
- 移动
选择移动工具并在层级或场景视图中选择一个游戏对象将允许你在屏幕上移动该对象。
- 辐状的
旋转工具旋转选定的对象。
- 规模
缩放工具缩放选定的对象。
- 矩形
Rect 工具允许使用 2D 手柄移动和调整所选对象的大小,该手柄将出现在所选对象上。
- 移动、旋转或缩放选定的对象
该工具是移动、旋转和缩放工具的组合,合并为一组手柄。
您可以随时通过按下 Option (Mac)或 Alt (PC)来临时切换到“抓手”工具(仅在 2D 项目中),并在场景中移动。
小费
变换工具集中的六个控件分别映射到以下六个键:Q、W、E、R、T、y。使用这些热键可以在工具之间快速切换。
使用移动工具(热键:w)时,一个有用的技巧是通过按住 Control (PC)或 Cmd / ⌘ (Mac)让游戏对象捕捉到特定的增量。在“编辑➤捕捉设置”菜单中调整捕捉增量设置。
手柄位置控制
在变换工具集的右边,你会发现手柄位置控件,如图 2-8 所示。
图 2-8
手柄位置控制
控制柄是对象上的 GUI 控件,用于在场景中操纵它们。手柄位置控制允许您调整选定对象的手柄位置以及它们的方向。
第一个切换按钮(见图 2-8 )允许您设置手柄的位置。
位置的两个选项是:
-
轴:这会将控制柄放置在选定对象的轴点。
-
中心:将控制柄放置在选定对象的中心。
第二个切换按钮允许您设置手柄的方向。请注意,如果选择了缩放工具,方向按钮将灰显,因为方向与缩放无关。两个方向选项是:
-
局部:选中时,变换工具功能将与游戏对象相关。
-
全局:选中时,变换工具功能将相对于世界空间方向。
小费
通过在项目窗口中选择精灵,在检查器中将精灵模式切换到多重,然后点按精灵编辑器按钮,可以更改精灵的轴心点。点击精灵编辑器中的“切片”按钮,并从下拉菜单中选择一个轴点。
播放、暂停和步进控制
Unity 编辑器有两种模式:播放模式和编辑模式。当按下播放按钮时,如果没有错误阻止游戏构建,Unity 编辑器将进入播放模式并切换到游戏视图(见图 2-9 )。进入播放模式的快捷键是 Control (PC)或 Cmd / ⌘ (Mac) + P
图 2-9
播放、暂停和步进控制
在游戏模式下,如果你想检查正在运行的场景中的游戏对象,你可以通过选择场景面板顶部的标签切换回场景视图。如果您需要调试场景,这很有帮助。在播放模式下,您也可以随时按下暂停按钮来暂停正在运行的场景。暂停场景的快捷键在 PC 上是 Control + Shift + P,在 Mac 上是 Cmd / ⌘ (Mac) + Shift + P。
步进按钮允许 Unity 前进一帧,然后再次暂停。这也有助于调试。在 PC 上向前移动一帧的快捷方式是 Control + Alt + P,在 Mac 上是 Cmd / ⌘ (Mac) + Option + P。
在播放模式下再次按下播放按钮将停止播放场景,将 Unity 编辑器切换回编辑模式,并切换回场景视图。
在播放模式下工作时,要始终记住的一件重要事情是,一旦编辑器切换回编辑模式,您对对象所做的任何更改都不会保存或反映在场景中。当一个场景正在运行时,很容易忘记这一点,进行一些更改和调整,直到它们变得完美,只有当你停止播放时,这些更改才会丢失。
小费
为了让你在播放模式下非常明显,配置 Unity 偏好设置以在进入播放模式时自动切换编辑器的背景色调颜色是很有用的。为此,进入如图 2-10 所示的菜单选项:Unity ➤首选项。从左侧的选项中选择颜色,并查找标题为“常规”的部分选择您喜欢的背景色调颜色并退出。现在点击播放按钮查看结果。Unity 编辑器的背景应该是你选择的颜色。
图 2-10
Unity 首选项菜单
Unity 项目结构
需要了解的两个主要 Unity 项目文件夹是 Assets/ folder 和 ProjectSettings/ folder。如果您使用任何形式的源代码版本控制,这是您应该签入的两个文件夹。
资产/文件夹是所有游戏资源所在的位置,包括脚本、图像、声音文件等等。
顾名思义,ProjectSettings/文件夹包含所有类型的项目设置,包括物理、音频、网络、标签、时间、网格等等。从菜单“编辑➤项目设置”中设置的所有内容都存储在该文件夹中。
Unity 项目结构中还有其他文件夹和文件,但它们都是基于 Assets/或 ProjectSettings/的内容生成的。库/文件夹是导入资源的本地缓存,Temp/用于在构建过程中生成的临时文件。以. csproj 扩展名结尾的文件是 C# 项目文件,而以。sln 是用于 Visual Studio IDE 的解决方案文件。
Unity 文档
Unity 有很好的文档,Unity 网站上的文档( https://docs.unity3d.com/
)涵盖了脚本 API 和 Unity 编辑器的使用。Unity 在 Learn portal ( https://unity3d.com/learn
)中还有几十个视频教程,内容适合所有级别的开发者体验。Unity 论坛( https://forum.unity.com/
)是讨论 Unity 主题的地方,Unity 回答( https://answers.unity.com
)是发布问题和从社区中的 Unity 开发者伙伴那里获得帮助的重要资源。
摘要
我们已经在这一章中介绍了很多与你作为一名 Unity 游戏开发者的未来相关的内容。我们在 Unity 编辑器中介绍了最常用的窗口和视图,比如场景视图,你可以在那里构建你的游戏,游戏视图,你可以在那里查看你的游戏运行。我们讨论了层次窗口如何给出当前场景中所有游戏对象的概述,如何在检查器中编辑这些游戏对象的属性,以及如何通过变换工具集操纵它们,并处理位置控制。在此过程中,我们讨论了如何更改这些窗口和视图的布局,并保存该布局以供将来使用。我们学习了控制台视图如何显示错误信息,并在游戏出现问题时用于调试。在本章的最后,我们指出了大量的 Unity 文档、视频教程、论坛和问答资源。
三、基础
现在我们已经熟悉了 Unity 编辑器,是时候开始制作我们的游戏了。这一章将带你了解如何构造对象和编写游戏代码。我们将讨论 Unity 中使用的软件设计模式,以及计算机科学中的一些高级原则,以及它们如何与制作游戏相关。您还将学习如何在屏幕上控制播放器和播放播放器动画。
游戏对象:我们的容器实体
Unity 中的游戏是由场景组成的,一个场景中的所有东西都被称为 GameObject。在你的 Unity 冒险中,你会遇到脚本、碰撞器和其他类型的元素,所有这些都是游戏对象。将游戏对象视为一种容器是有帮助的,它由许多独立实现的功能组成。正如我们在第二章中讨论的,游戏对象甚至可以包含父子关系中的其他游戏对象。
我们将创建我们的第一个游戏对象,然后讨论为什么 Unity 使用游戏对象作为构建游戏的一个基本方面。
在层级视图中,选择左上角的创建按钮(图 3-1 ),然后选择创建空。这在层级视图中创建了一个新的游戏对象。
图 3-1
在层级视图中创建新游戏对象的一种方法
有几种不同的方法来创建游戏对象。你也可以右击等级视图面板本身,或者去顶部菜单的游戏对象➤创建空白。
右键单击新的游戏对象并选择重命名。称之为“PlayerObject”这个 PlayerObject 将包含我们 RPG 中与勇敢玩家相关的所有逻辑!
制作第二个游戏对象,并将其命名为“敌人对象”这个敌人对象将包含与我们的玩家必须击败的敌人相关的所有逻辑。
当我们学习如何在 Unity 中构建游戏时,我们还将学习计算机科学概念,这些概念将使你成为一名更好的程序员,以及这些概念将如何使你作为游戏开发者的生活更轻松。
实体组件设计
计算机科学中有一个概念叫做“关注点分离”关注点分离是一种设计原则,它描述了如何根据软件执行的功能将软件划分为模块。每个模块负责一个应该被该模块完全封装的单一功能“关注点”。当涉及到实现时,关注点可能是一个有点松散和解释性的术语——这些关注点可能广泛到在屏幕上渲染图形的责任,或者具体到计算空间中的一个三角形何时与另一个三角形重叠。
在软件设计中分离关注点的主要动机之一是减少开发人员编写重复或重叠功能时看到的浪费。例如,如果您有在屏幕上呈现图像的代码,您应该只需要编写一次该代码。一个视频游戏会有几十或几百种需要将图形渲染到屏幕上的情况,但开发者只需编写一次代码,就可以在任何地方重用它。
Unity 建立在关注点分离的哲学之上,在游戏编程中有一个非常流行的设计模式,叫做实体-组件设计。实体组件设计倾向于“组合胜于继承”,即对象或“实体”应该通过包含封装特定功能的类的实例来鼓励代码重用。实体通过这些组件类的实例获得对功能的访问。如果使用得当,组合可以减少代码,更容易理解和维护。
这不同于普通的设计方法,在普通的设计方法中,对象从父类继承功能。使用继承的一个缺点是,它会导致继承树变得又深又宽,改变父类中的一件小事会产生连锁反应,带来意想不到的后果。
在 Unity 的实体组件设计中,一个叫做游戏对象的东西就是实体,而组件实际上叫做“组件”Unity 场景中的所有东西都被认为是游戏对象,但是游戏对象本身并不做任何事情。我们在组件中实现我们所有的功能,然后将这些组件添加到我们的游戏对象中,给它们我们想要的行为。向实体添加功能和行为变得像向实体添加组件一样简单。组件本身可以被认为是不同的模块,只关注一件事,与其他关注点和代码无关。
请看下图,以更好地理解我们如何在一个假想的游戏环境中使用实体组件设计。提供行为的组件在顶部的 x 轴上,游戏中的实体在左边的 y 轴上。
| |图形渲染器
|
碰撞检测
|
物理整合
|
音频播放器
|
| --- | --- | --- | --- | --- |
| 运动员 | X | X | X | X |
| 敌军 | X | X | X | X |
| 矛(武器) | X | X | X | |
| 树 | X | X | | |
| 村民 | X | X | | X |
如你所见,玩家和敌人都需要所有四个组件功能。矛武器将需要大部分功能,尤其是投掷时的物理功能,但不需要音频。这棵树不需要物理或音频——只需要图形渲染和碰撞检测来确保任何撞到它的东西都无法穿过它。上例中的村民需要图形和碰撞检测,但他们只是在场景中走动,所以他们不需要物理。如果我们希望我们的游戏播放村民与玩家互动的音轨,他们可能需要音频。
Unity 实体-组件设计并不是没有它的局限性,特别是对于大型项目来说,并且在许多年后已经开始显示出它的年龄。它将在未来被更加面向数据的设计所取代。
现在,让我们将这些新发现的知识付诸实践。
组件:构建基块
在层次视图中选择我们的 PlayerObject,注意检查器中的值是如何变化的。您应该会看到类似图 3-2 的东西。
图 3-2
变换组件
Unity 中所有游戏对象通用的一个元素是 Transform 组件,它用于确定场景中游戏对象的位置、旋转和缩放。当我们想移动我们的玩家角色时,我们将在游戏中使用转换组件。
鬼怪;雪碧
如果你是游戏开发新手,你可能会问,“什么是精灵?”视频游戏开发环境中的精灵只是 2D 的形象。如果你曾经在任天堂上看过超级马里奥兄弟(图 3-3 ),或者玩过像星谷(图 3-4 )、 Celeste、Thimbleweed Park 或者 Terraria 这样的游戏,你就玩过使用精灵的游戏。
图 3-4
在这幅星空谷的图像中,鸡、鸭、稻草人、蔬菜、树和所有其他的图像都是独立的精灵
图 3-3
超级马里奥兄弟(任天堂)中的英雄水管工马里奥的个人精灵
2D 游戏中的动画效果可以使用类似于制作动画电影、动画或卡通的技术来实现。就像卡通中的单个细胞(帧)一样,精灵会被提前显示并保存到磁盘中。以快速顺序显示单个精灵可以传达运动的印象,例如角色行走、战斗、跳跃或不可避免的死亡。
为了在屏幕上看到玩家角色,我们需要使用 Sprite 渲染器组件来显示图像。我们将把这个精灵渲染器组件添加到玩家游戏对象中。有一些不同的方法来添加一个组件到一个游戏对象,但是我们第一次将使用添加组件按钮。
从检查器中选择添加组件按钮,然后键入“sprite”并选择 Sprite 渲染器(图 3-5 )。这将组件添加到我们的玩家游戏对象中。相反,我们可以创建一个带有精灵渲染器的游戏对象,方法是转到游戏对象菜单,然后选择 2D 对象➤精灵。
图 3-5
将精灵渲染器组件添加到玩家游戏对象中
使用相同的技术将 Sprite 渲染器组件添加到 EnemyObject。
保存场景是一个需要养成的好习惯,所以让我们现在就保存场景。键入 Control (PC) / CMD (Mac) + s,然后新建一个文件夹,命名为“Scenes”。将场景保存为“LevelOne”。我们已经创建了一个新的文件夹来保存这个场景以及我们将为游戏创建的其他场景。
接下来,在项目视图中创建一个名为“Sprites”的文件夹。正如您可能已经猜到的,这将保存我们项目的所有 sprite 资产。在这个精灵文件夹下创建另一个名为“玩家”和“敌人”的文件夹。在项目视图中选择 Sprites 文件夹,然后转到下载目录中的文件夹,桌面,或者任何你放有本书下载的游戏资源的解压缩文件夹的地方。
在为第三章下载的资源中,选择名为 Player.png、EnemyWalk_1.png 和 EnemyIdle_1.png 的文件,并将它们拖到项目视图的 Sprites 文件夹中。一旦它们在主精灵文件夹中,把它们拖到各自的玩家和敌人文件夹中。你的项目视图应该类似于图 3-6 。
图 3-6
添加播放器精灵表后的项目视图。敌人的精灵在敌人文件夹里
现在在项目视图中选择播放器精灵表。注意它的属性是如何出现在右边的检查器中的。我们将在检查器中配置资产导入设置,然后使用精灵编辑器将这个精灵表分割成单独的精灵。
设置纹理类型为“精灵(2D 和用户界面)”,选择精灵模式下拉选择器,并选择“多个”这表明该子画面资产中有多个子画面。
将每单位像素更改为 32。当我们谈论相机时,我们将解释每单位像素或 PPU 设置。
将滤镜模式更改为“点(无滤镜)”这将使精灵纹理在近处看起来呈块状,这对于我们艺术作品的像素化外观来说是完美的。
在底部,按下默认按钮,选择“无”进行压缩。
再次检查检查器中的属性是否与图 3-7 相匹配。
按下“应用”按钮来应用我们的更改,然后按下检查器中的“精灵编辑器”按钮。是时候把我们的精灵表分割成精灵了。
图 3-7
播放器精灵表的属性,如检查器中所示
Unity 引擎中内置的精灵编辑器工具非常方便地获取由许多精灵组成的精灵表,并将它们分割成单独的精灵资产。
选择左上角的“切片”,并选择“按单元格大小划分网格”作为类型。这允许我们设置切片的尺寸。对于像素大小,分别为 X 和 Y 输入 32 和 32。
按下“切片”按钮。如果你仔细观察图 3-8 ,你会看到一条模糊的白线勾勒出我们每个玩家精灵的轮廓。这条白线表示 sprite 工作表被切片的位置。
图 3-8
为导入的播放器精灵表设置像素大小
现在按下“应用”按钮,将切片应用到精灵表。关闭精灵编辑器。
我们能够输入这个 sprite 工作表的精确尺寸,因为我们提前知道它们。当你在自己的游戏中工作时,你会遇到各种尺寸的精灵,你可能不得不调整一下尺寸以使它们恰到好处。Unity Sprite 编辑器还能够自动检测导入的 Sprite 表中的 sprite 尺寸,方法是在 sprite 编辑器➤切片菜单中选择“自动”。根据您使用的 sprite 工作表,这种技术可能会产生不同的结果,但这是一个起点。
那些切片和切块对我们有什么好处?单击播放器精灵表旁边的小三角形,查看从精灵表中提取的所有单个精灵(图 3-9 )。我们将使用新切割的玩家精灵创建一些动画。
图 3-9
从播放器精灵表得到的切片精灵
让我们把这些精灵的工作。选择播放器对象。在检查器视图中,一直到 Sprite 属性的右边,你会看到一个小圆圈(图 3-10 )。点击该圆圈,调出精灵选择器屏幕,如图 3-11 所示。
图 3-10
按此按钮调出选择精灵屏幕
在精灵选择器屏幕中,双击选择一个玩家精灵,当我们编辑我们的游戏时,作为我们的 PlayerObject 在场景中的替身(图 3-11 )。
图 3-11
当游戏停止时,选择一个玩家精灵来代表我们的玩家
现在我们有了所有的玩家精灵,让我们导入敌人的精灵表。选择“enemy idle _ 1”sprite 工作表,并在检查器中将其导入设置设置为与我们的 PlayerObject 相同:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:多重
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
压缩:无
按下应用按钮。
使用 sprite 编辑器将 sprite sheet 分割成单个 32 × 32 像素的 Sprite。确保白色切片线出现在正确的位置,然后按下应用按钮并关闭精灵编辑器。对“EnemyWalk_1”精灵表执行相同的步骤,将其分割为单个精灵。
动画片
让我们创建一个新文件夹来保存我们将要创建的动画。你还记得怎么做吧?从项目视图中选择资产,右键单击,然后选择创建➤文件夹。或者,您可以单击项目视图左上角的“创建”按钮。把这个文件夹叫做“动画”。选择动画文件夹,并在其中创建另外两个子文件夹,标题为“动画”和“控制器”。
在项目视图中单击播放器精灵旁边的小箭头,展开播放器精灵。选择第一个玩家精灵—这应该是向东走的玩家的精灵。按住 shift 键选择它旁边的三个精灵。将这四个精灵一起拖动到 PlayerObject 上,如图 3-12 所示。
图 3-12
将精灵拖动到 PlayerObject 上以创建新的动画
将出现一个屏幕,提示您创建新的动画(图 3-13 )。导航到动画➤动画子目录,我们以前创建的,并保存这个动画为“播放器-步行-东”。
图 3-13
创建并保存新的动画对象
现在选择 PlayerObject 并查看检查器视图。注意我们有两个新组件(图 3-14 ):精灵渲染器和动画器。
精灵渲染器组件负责显示或渲染精灵。Unity 还添加了一个 Animator 组件,它包含一个 Animator 控制器,允许播放动画。
图 3-14
自动添加了两个新组件:精灵渲染器和动画器
将精灵拖动到 PlayerObject 并创建新的动画导致这两个组件被添加到 PlayerObject。
当我们向 PlayerObject 添加动画时,Unity 编辑器足够聪明,知道我们需要某种方式来播放和控制动画。于是它自动创建了一个 Animator 组件来播放动画,并附加了一个动画控制器对象“PlayerObject”。我们也可以按下检查器中的 Add Component 按钮,搜索“Animator”,然后手动添加一个 Animator。
名为“PlayerObject”的动画控制器将默认出现在我们保存“player-walk-east”动画的文件夹中。动画控制器的默认名称是“PlayerObject”(图 3-15 ),这很容易混淆,因为我们的主玩家游戏对象也被称为“PlayerObject”。
图 3-15
自动创建的动画控制器:PlayerObject,以及我们的第一个动画对象:player-walk-east
让我们将动画控制器重命名为更具描述性的名称。选择 PlayerObject,按 Enter 键,或右键单击,并将该对象重命名为“PlayerController”。
选择、拖动 PlayerController 对象,并将其移动到我们创建的控制器文件夹中。
双击 PlayerController 对象打开 Animator 窗口。
动画师状态机
动画控制器维护一组称为状态机的规则,用于根据玩家所处的状态来确定为关联对象播放哪个动画剪辑。玩家对象使用的状态的一些例子可能是:行走、攻击、空闲、进食和死亡。我们进一步将这些状态划分为方向,因为当我们的玩家在这些状态时,他们可能面向北、南、东或西。这些状态的可视化流程图显示在 Animator 窗口中,如图 3-16 所示。
图 3-16
动画窗口
将动画控制器视为控制动画的“大脑”是很有帮助的。动画状态机中的每个状态都由一个附加到它的动画对象表示。该动画对象包含为该状态播放的实际动画剪辑。动画控制器还维护如何在动画状态之间转换的细节。
正如您在 Animator 窗口中看到的,我们的动画控制器有以下状态:进入状态、任意、退出和我们刚刚添加的状态:玩家-行走-东。当你想转换到一个状态时,使用“任何状态”,例如从任何其它状态“跳转”。
如果您没有看到退出状态,您可能需要稍微滚动窗口来找到它。您也可以使用鼠标或触控板上的滚动按钮来放大和缩小,以便更好地查看事物,并在拖移背景时按住 Option / Alt 键,以便在 Animator 窗口中移动。在任何时候,你都可以随意移动这些动画对象,并以你认为有意义的方式排列它们。
让我们添加其余的动画。回到精灵文件夹,选择接下来的四个精灵。这些是玩家向西行走时使用的精灵。将这四个拖动到 PlayerObject 上,就像我们创建之前的行走动画一样。当创建新的动画保存窗口提示时,键入“球员-步行-西方”并保存到动画➤动画文件夹。你应该看到这个新的动画出现在动画窗口。
按照相同的步骤为其他精灵创建新的动画。请注意,“向南走”和“向北走”动画只有两帧,而不是四帧。将他们的动画命名为“玩家走南”和“玩家走北”,并将它们保存到动画➤动画文件夹中。
此时,你的动画窗口应该类似于图 3-17 所示的四个动画对象。这四个动画对象代表四种不同的行走状态,并且也包含对动画剪辑的引用。
图 3-17
将所有四个玩家行走动画添加到 PlayerObject 后,显示这些动画的 Animator 窗口
我们已经做了所有这些工作,但我们仍然没有任何东西在屏幕上动画。还有最后一步——在层次视图中,选择主相机游戏对象,并将大小属性设置为 1。这是暂时的,所以你可以清楚地看到球员动画。我们将在本书后面解释更多关于相机的内容。
按下工具栏中的播放按钮。如果一切顺利,你会看到我们无畏的玩家在原地疯狂奔跑,如图 3-18 所示。
图 3-18
我们尝到了像素化胜利的甜蜜滋味
让我们让疯狂的玩家慢下来。通过双击 PlayerObject Animator 或选择 Animator 窗口选项卡打开 Animator 窗口。选择“player-walk-east”动画,并将速度值更改为 0.6,如图 3-19 所示。
图 3-19
更改动画速度
然后再次按下 play,观看她以更可持续的速度行走。你可以把这个速度调整到任何你觉得看起来自然的速度。
再次按下播放按钮,停止播放场景。
现在为我们的 EnemyWalk_1 和 EnemyIdle_1 动画创建并保存动画。每个动画都包含五个精灵。命名动画:敌人-步行-1,和敌人-闲置-1。将 EnemyObject 动画控制器重命名为 EnemyController,并将其移动到“动画➤控制器”子文件夹。移动敌人动画到动画➤动画子文件夹。
煤矿工人
接下来我们要学习对撞机。碰撞器被添加到游戏对象中,并被 Unity 物理引擎用来确定两个对象之间何时发生碰撞。碰撞器的形状是可调的,它们的形状通常或多或少像它们所代表的物体的轮廓。有时,勾画出一个物体的精确形状在计算上是不允许的,而且通常是不必要的,因为一个物体形状的近似值对于碰撞目的来说已经足够了,并且玩家在运行时无法区分。使用一种称为“原始碰撞器”的碰撞器来近似物体形状也不需要太多处理器。在 Unity 2D 有两种类型的原始对撞机:2D 箱式对撞机和 2D 圆形对撞机。
选择 PlayerObject,然后在检查器中选择“添加组件”按钮。搜索并选择“2D 箱式对撞机”,将 2D 箱式对撞机添加到 PlayerObject 中,如图 3-20 所示。
图 3-20
将长方体碰撞器 2D 添加到 PlayerObject
我们需要知道玩家何时与敌人碰撞,所以也给敌人添加一个 2D 碰撞器。
刚体组件
添加到游戏对象的刚体组件允许游戏对象与 Unity 物理引擎交互。这就是 Unity 知道如何对游戏对象施加重力之类的力。刚体也允许你通过脚本对游戏对象施加力。例如,你的游戏可能有一个名为“汽车”的游戏对象,它包含一个刚体。你可以对汽车物体施加一定的力,使其向当前方向移动,这取决于玩家按下的按钮:油门或涡轮。
选择 PlayerObject,单击检查器中的“添加组件”按钮,搜索“刚体 2D”,并将其添加到 PlayerObject。在刚体组件的 Body Type 下拉列表中,选择“ Dynamic ”动态刚体将与其他物体相互作用和碰撞。将刚体 2D 的以下属性设置为 0:线性阻力、角阻力和重力比例。将质量设定为 1。
下拉菜单中的第二种体型是运动学。运动学刚体 2D 组件不受重力等外部物理力的影响。它们确实有速度,但只有当我们移动它们的变换组件时才会移动,通常是通过脚本。这是一种不同于我们之前描述的通过施加力来移动游戏对象的方法。第三种体型是静态,针对游戏中根本不会移动的物体。
选择 EnemyObject,并添加一个动力学类型的刚体 2D 组件。
现在我们已经为玩家和敌人添加了刚体 2D,他们将受到重力的影响。因为我们的游戏使用自上而下的视角,所以让我们关闭重力,这样我们的玩家就不会飞出屏幕。转到编辑➤项目设置➤物理 2D,并将重力 y 的值从–9.81 更改为 0。
标签和层
标签
标签允许我们在游戏运行时标记游戏对象,以便于参考和比较。
选择播放器对象。在检查器左上方的标签下拉菜单下,选择播放器标签,为我们的 PlayerObject 添加一个标签,如图 3-21 所示。
图 3-21
在检查器中选择播放器标签,将其分配给我们的 PlayerObject
玩家标签是 Unity 中每个场景的默认标签,但是你也可以根据需要添加标签。
创建一个名为“敌人”的新标签,并使用它来设置 EnemyObject 标签。随着游戏的发展,我们将为其他物品添加标签。
层
层用于定义游戏对象的集合。这些集合用于冲突检测,以确定哪些层相互了解,从而可以进行交互。然后,我们可以在脚本中创建逻辑,以确定当两个游戏对象发生冲突时该做什么。正如我们在图 3-22 中看到的,我们想要创建一个新的“用户层”叫做“阻塞”。在用户第 8 层字段中键入“阻塞”。
选择层下拉菜单,然后选择“添加层”你应该会看到图层窗口如图 3-22 所示。
图 3-22
“层”窗口
现在再次选择 PlayerObject 以在检查器中查看其属性。从下拉菜单中选择我们刚刚创建的阻塞层(见图 3-23 )将我们的 PlayerObject 添加到该层。选择 EnemyObject,并在检查器中将图层设置为“阻挡”。
图 3-23
从下拉菜单中选择阻挡层
稍后,我们将配置我们的游戏,以强制某些游戏对象不能通过阻挡层中的任何对象。例如,玩家将会在阻挡层中,任何墙、树或敌人也是如此。敌人不能穿过玩家,玩家也不能穿过任何墙壁、树木或敌人。
排序层
现在让我们看一个不同类型的层:排序层。排序层不同于常规层,因为它们允许我们告诉 Unity 引擎我们在屏幕上的各种 2D 精灵应该以什么顺序“渲染”或绘制。因为排序层与渲染相关,所以您总是会在渲染器组件中看到排序层下拉菜单。
为了更好地理解我们所说的精灵渲染的“顺序”,请看一下点击式冒险树莓公园图 3-24 中的截图。截图显示两个玩家角色站在一个房间里。我们可以在房间里看到各种各样的家具,如文件柜和桌子。在树莓公园的截图中,女侦探雷探员似乎正站在文件柜前。这个效果是在游戏引擎渲染文件柜之后,通过渲染代理人雷的精灵来完成的。
图 3-24
Thimbleweed Park 的截图,显示人物站在对象前面
Thimbleweed Park 使用自己专有的游戏引擎,而不是 Unity,但所有引擎都必须有某种逻辑来描述渲染像素的顺序。
在我们的 RPG 游戏中,我们将从上往下看,也就是所谓的“正交”视角。当我们谈到相机时,我们会更多地讨论这意味着什么,但现在我们知道我们希望 Unity 首先为地面绘制像素,然后是地面上的任何角色,如玩家或敌人,所以这些角色看起来像是在地面上行走。
我们将添加一个名为“角色”的分类层,我们将为我们的玩家和所有的敌人使用。
在检查器的精灵渲染器组件中,选择排序层下拉菜单,选择“添加排序层”,如图 3-25 所示。我们创建的排序层将在整个游戏中可用,即使我们是从 PlayerObject 的菜单中创建的。
图 3-25
添加排序层
添加一个名为“Characters”的排序层(图 3-26 ,然后再次点击 PlayerObject 查看其检查器,并从排序层下拉菜单中选择我们新的 Characters 排序层,如图 3-27 所示。
图 3-27
在我们的 PlayerObject 中使用新的字符排序层
图 3-26
添加一个名为 Characters 的新排序层
选择我们的 EnemyObject 并设置它的排序层为 Characters,因为我们希望敌人也能被渲染在地面瓷砖上。
简介:预设
Unity 允许你构建嵌入组件的游戏对象,然后用这个游戏对象创建一个叫做“预置”的东西。预设可以被认为是预先制作的模板,你可以从中创建或“实例化”已经制作好的游戏对象的新副本。这个资源有一个非常有用的特性,允许你通过改变预设模板来一次编辑所有的预设。另一方面,你可以选择改变一个单独的预置,让其余的和原来的一样。
例如,想象一下,如果你有一个玩家在酒馆里的场景。酒馆里有许多道具,如椅子、桌子和啤酒杯。如果你为所有这些道具创建了单独的游戏对象,它们中的每一个都可以独立编辑。如果您想要更改每个表的某个属性,例如,将表的颜色从浅色改为深色,您必须选择并编辑每个表,然后更改该属性。如果表对象是预置实例,你只需要改变单个对象——预置——的属性,然后点击按钮,将改变应用到从预置派生的所有实例。
我们将在构建游戏的过程中不断使用这种简单的预置技术。
从游戏对象中创建一个预置真的很容易。首先,在项目视图的资产文件夹下创建一个预置文件夹。然后从层次视图中选择我们的 PlayerObject,并简单地把它拖到我们的 Prefabs 文件夹中。
图 3-28 中的截图显示了我们将 PlayerObject 放入 Prefabs 文件夹后的预置。
图 3-28
通过拖动任何游戏对象到预设文件夹来创建一个预设
看一下图 3-28 中的层级视图。您会注意到 PlayerObject 文本是浅蓝色的。这表明 PlayerObject 是基于预设的。这也意味着接下来,如果你对玩家对象预设做了任何更改,并且你想将更改应用到预设的所有实例,你需要在项目视图中选择游戏对象时按下检查器中的应用按钮(见图 3-29 )。
图 3-29
按下“应用”按钮,将对玩家对象所做的任何更改应用到预设的所有实例
您现在可以安全地从层次视图中删除 PlayerObject,因为我们现在有了一个预置的 PlayerObject,我们可以随时使用它来重新创建 PlayerObject。如果你想编辑预设的所有实例,只需将预设对象拖回层次视图并进行更改,然后按应用。
对 EnemyObject 执行相同的操作:将其拖动到 Prefabs 文件夹中,并从层次视图中删除原始的 EnemyObject。
现在是再次保存我们的场景的好时机,所以一定要这样做。
脚本:组件的逻辑
所以我们有玩家对象和敌人对象。让他们动起来。选择我们的 PlayerObject 预置,并将其拖入层次视图。您会注意到,检查器再次填充了 PlayerObject 的属性。
滚动到检查器的底部,然后按下“添加组件”按钮。输入单词, script ,选择“New Script”。将新脚本命名为“MovementController”,如图 3-30 所示。
图 3-30
将新脚本命名为:“MovementController”
在项目视图中创建一个名为“脚本”的新文件夹。新脚本将会在项目视图的顶层 Assets 文件夹中创建。将 MovementController 脚本拖到 Scripts 文件夹中,然后双击它以在 Visual Studio 中打开它。
是时候编写我们的第一个脚本了。Unity 中的脚本是用一种叫做 C# 的语言编写的。一旦你在 Visual Studio 中打开了我们的 MovementController 脚本,它应该类似于图 3-31 。
图 3-31
Visual Studio 中的 MovementController 脚本
注意
直到最近,Unity 才允许开发人员用两种不同的语言编写脚本:C# 和一种类似 JavaScript 的语言“UnityScript”。从 Unity 2017.2 测试版开始,Unity 开始了贬低 UnityScript 的过程,但你可能会在外面找到一些 UnityScript 样本。接下来,你应该只用 C# 来为 Unity 写脚本。你可以在 Unity 的博客中了解更多关于弃用的原因:blogs.unity3d.com
。
让我们来看看典型的 Unity 脚本的结构。接下来的所有行都应该完全按照你看到的样子输入,并且 C# 中的每一行都应该以分号结束。编程语言非常字面化,不喜欢省略分号、回车或额外的字母或数字。以//开头的行是注释,只是为了澄清而写的,您不必键入它们。C# 中的注释可以用两个正斜杠://或用一个:/后跟您的注释,并以:/结束
// 1
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 2
public class MovementController : MonoBehaviour
{
// 3
// Use this for initialization
void Start()
{
}
// 4
// Update is called once per frame
void Update()
{
}
}
下面是前面每个部分的分类:
// 1
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
命名空间用于组织和控制 C# 项目中的类的范围,以避免冲突,并使开发人员的生活更加轻松。使用的关键字用于描述。NET Framework,并省去开发人员每次使用该命名空间中的方法时都必须键入完全限定名的麻烦。
例如,如果我们包括系统命名空间,如下例所示:
using System;
而不必输入繁琐的:
System.Console.WriteLine("Greatest RPG Ever!");
我们可以简单地输入较短的版本:
Console.WriteLine("Greatest RPG Ever!");
这是可能的,因为:使用系统;声明阐明了该类文件中的代码将使用 System 命名空间。
C# 中的命名空间也是可嵌套的。这意味着您可以在名称空间中引用名称空间,就像在系统中引用集合一样。这是这样写的:
using System.Collections;
UnityEngine 名称空间包含许多特定于 Unity 的类,其中一些我们已经在场景中使用过,比如 MonoBehaviour、GameObject、Rigidbody2D 和 BoxCollider2D。通过声明 UnityEngine 名称空间,我们可以在 C# 脚本中引用和使用这些类。
// 2
public class MovementController : MonoBehaviour
对于作为组件附加到场景中游戏对象的类,它需要从 UnityEngine 类monobehavior继承。通过从 MonoBehaviour 继承,一个类可以访问诸如 Awake()、Start()、Update()、LateUpdate()和 OnCollisionEnter()之类的方法,并保证这些方法将在 Unity 事件函数执行周期的某个点被调用。
// 3
void Start()
父 MonoBehaviour 类提供的方法之一是 Start()。我们稍后将描述事件函数的执行周期,但是正如您从它的名字可以想象的那样,Start()函数是脚本执行时首先调用的方法之一。如果满足一些条件,则在第一次帧更新之前调用 Start()方法:
-
该脚本必须从 MonoBehaviour 继承。我们的 MovementController 确实继承了 MonoBehaviour。
-
该脚本必须在初始化时启用。默认情况下,脚本将被启用,但脚本在初始化时可能未被启用,这可能是一个错误源。
// 4
void Update()
Update()方法每帧调用一次,用于更新游戏行为。因为 Update()每帧调用一次,所以一个每秒 24 帧的游戏每秒将调用 Update() 24 次,但是更新调用之间的时间可能不同。如果您需要方法调用之间的时间一致,那么使用 FixedUpdate()方法。
既然我们已经熟悉了默认的 MonoBehaviour 脚本,请用下面的代码替换 MovementController 类:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MovementController : MonoBehaviour
{
//1
public float movementSpeed = 3.0f;
// 2
Vector2 movement = new Vector2();
// 3
Rigidbody2D rb2D;
private void Start()
{
// 4
rb2D = GetComponent<Rigidbody2D>();
}
private void Update()
{
// Keep this empty for now
}
// 5
void FixedUpdate()
{
// 6
movement.x = Input.GetAxisRaw(“Horizontal”);
movement.y = Input.GetAxisRaw(“Vertical”);
// 7
movement.Normalize();
// 8
rb2D.velocity = movement * movementSpeed;
}
}
// 1
public float movementSpeed = 3.0f;
声明一个公共浮动,我们将使用它来调整和设置角色的移动速度。通过将它声明为 public,我们允许这个变量 movementSpeed 在它所连接的游戏对象被选中时出现在检查器中。
看一下图 3-32 看看公共变量是如何出现在运动控制器(脚本)部分的检查器中的。Unity 会自动将公共变量的首字母大写,并在首个大写字母前添加一个空格。这意味着“运动速度”将在检查器中显示为“运动速度”。
图 3-32
公共变量 movementSpeed 以大写形式出现,并带有一个空格
// 2
Vector2 movement = new Vector2();
Vector2 是一种内置的数据结构,用于保存 2D 矢量或点。我们将使用它来表示玩家或敌方角色在 2D 空间中的位置或角色移动的方向。
// 3
Rigidbody2D rb2D;
声明一个变量来保存对 Rigidbody2D 的引用。
// 4
rb2D = GetComponent<Rigidbody2D>();
方法 GetComponent 接受一个类型的参数,并将返回附加到该类型的当前对象的组件(如果附加了一个的话)。我们调用 GetComponent
// 5
FixedUpdate()
正如我们在前面几页所讨论的,Unity 引擎以固定的时间间隔调用 FixedUpdate()。这与每帧调用一次的 Update()方法形成对比。在较慢的硬件设备上,游戏帧速率可能会降低,在这种情况下,Update()的调用频率可能会降低。
// 6
movement.x = Input.GetAxisRaw("Horizontal");
movement.y = Input.GetAxisRaw("Vertical");
Input 类为我们提供了几种捕捉用户输入的方法。我们使用 GetAxisRaw()方法捕获用户输入,并将这些值分配给 Vector2 结构的 x 和 y 值。GetAxisRaw()方法接受一个参数,该参数指定我们感兴趣的 2D 轴是水平的还是垂直的,并从 Unity 输入管理器中检索-1、0 或 1 并返回它。
“1”表示按下了右键或“d”(使用常见的 w、a、s、d 输入配置),而“-1”表示按下了左键或“a”。“0”表示没有按下任何键。这个输入键映射可以通过 Unity 输入管理器配置,我们将在后面解释。
// 7
movement.Normalize();
这将使我们的向量“正常化”,并使玩家以相同的速度移动,无论他们是斜向移动、垂直移动还是水平移动。
// 8
rb2D.velocity = movement * movementSpeed;
将 movementSpeed 乘以运动向量将设置附加到 PlayerObject 的刚体 2D 的速度并移动它。
回到 Unity 编辑器,确保你在层次视图中看到我们的 PlayerObject。如果没有,将 PlayerObject 从 Prefabs 文件夹拖动到层次视图中。
还有最后一个非常重要的步骤:我们需要将脚本添加到 PlayerObject 中。
要将脚本添加到我们的 PlayerObject,请将 MovementController 脚本从 Scripts 文件夹拖到层次视图中的 PlayerObject 上,或者在选择 PlayerObject 时将其拖到检查器中。这就是我们如何在 Unity 编辑器中将脚本附加到对象上。当 MovementController 脚本附加到特定对象时,它可以访问 PlayerObject 中的其他组件。
现在按播放键。你应该看到我们的玩家角色在原地行走。按下键盘上的箭头键或 W、A、S、D,看着她四处移动。
恭喜你!你刚刚给曾经只是电子脉冲的东西注入了生命。你知道他们怎么说强大的力量会带来什么吗...
状态和动画
更多状态机
现在我们知道了如何在屏幕上移动我们的角色,我们将讨论如何基于当前玩家状态在动画之间切换。
转到“动画➤控制器”文件夹,双击 PlayerController 对象。您应该看到 Animator 窗口,显示我们之前设置的状态机。正如我们之前讨论过的,Unity 的动画状态机允许我们查看所有不同的玩家状态和他们相关的动画片段。
单击并拖动您的动画状态对象,直到它类似于图 3-33 中的屏幕,玩家空闲关闭到一边,玩家行走动画组合在一起。当排列它们时,不需要太精确,因为唯一真正重要的是动画状态对象之间的方向箭头。
图 3-33
Animator 窗口中动画的组织
在图 3-33 中,你可以看到玩家向东走的动画状态是橙色的。橙色表示这是这个动画的默认状态。选择然后右键单击“玩家空闲”动画状态,选择“设为图层默认状态”,如图 3-34 所示。颜色应该变成橙色。
图 3-34
右键单击并选择“设置为层默认状态”,将播放器空闲动画设置为默认动画
我们希望 player-idle 成为默认状态,因为当我们没有触摸方向键时,我们希望玩家在空闲状态下面朝南。这将看起来好像玩家角色正在等待用户。
现在选择并右键单击“任何状态”,然后选择“进行转换”将出现一条带箭头的线,连接到鼠标并围绕鼠标。点击“玩家向东走”来创建任意状态对象和玩家向东走之间的过渡。
如果你做的正确,它看起来应该如图 3-35 所示。
图 3-35
创建一个从任何状态到玩家东行的过渡
现在对其余的动画状态执行相同的操作:右键单击任意状态,创建过渡,并选择每个动画状态来创建过渡。正如我们前面提到的,当你想转换到一个状态时,使用“任何状态”,比如从任何其他状态“跳转”。
您应该创建总共五个白色过渡箭头,从任意状态指向所有四个玩家行走动画状态和玩家空闲动画状态。还应该有一个橙色的默认状态箭头,从进入动画状态指向玩家空闲动画状态,如图 3-36 所示。
图 3-36
创建从任何状态到所有动画状态的转换
动画参数
为了使用这些转换和状态,我们想要创建一个动画参数。动画参数是在动画控制器中定义的变量,由脚本用来控制动画状态机。
我们将使用我们在过渡和 MovementController 脚本中创建的动画参数来控制 PlayerObject,并让她在屏幕上走动。
选择 Animator 窗口左侧的参数选项卡(图 3-37 )。按下加号并从下拉菜单中选择“Int”(图 3-38 )。将创建的动画参数重命名为“AnimationState”(图 3-39 )。
图 3-39
将动画参数命名为:AnimationState
图 3-38
从下拉菜单中选择 Int
图 3-37
动画窗口中的“参数”标签
我们将设置动画参数在每个过渡到一个特定的条件。如果在游戏过程中这个条件为真,那么动画师将转换到那个动画状态,相应的动画片段将会播放。因为此 Animator 组件附加到 PlayerObject,所以动画剪辑将显示在场景中变换组件的位置。我们使用一个脚本将这个动画参数条件设置为真,并触发状态转换。
选择将任何州连接到 player-walk-east 州的白色过渡线。在检查器中,更改设置,使其与图 3-40 相匹配。
图 3-40
在检查器中配置转场
我们希望取消选中诸如退出时间、固定持续时间和可以过渡到自我等框。确保将过渡持续时间(%)设置为 0,并将中断源设置为“当前状态,然后是下一个状态”
取消选中“退出时间”,因为我们希望在用户按下不同的键时中断动画。如果我们选择了退出时间,那么在下一个动画开始之前,动画必须播放到退出时间框中输入的百分比,这将导致玩家体验不佳。
在检查器的底部,您会看到一个标题为“条件”的区域点击右下角的加号,选择 AnimationState,Equals,输入 1(图 3-41 )。我们刚刚创建了一个条件,它说:如果名为“AnimationState”的动画参数等于 1,那么进入这个动画状态并播放动画。这就是我们如何从将要编写的脚本中触发状态变化。
图 3-41
设置动画参数的条件:AnimationState
注意
很容易不小心将 AnimationState 下拉框中的“Greater”改为“Equals ”,所以要小心这一点。如果不将条件设置为 Equals,我们的转换将无法正常工作。
我们要做的下一件事是在脚本中将 AnimationState 参数设置为 1。回到 Visual Studio 和我们的 MovementController.cs 脚本。
将 MovementController 类替换为:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MovementController : MonoBehaviour
{
public float movementSpeed = 3.0f;
Vector2 movement = new Vector2();
// 1
Animator animator;
// 2
string animationState = "AnimationState";
Rigidbody2D rb2D;
// 3
enum CharStates
{
walkEast = 1,
walkSouth = 2,
walkWest = 3,
walkNorth = 4,
idleSouth = 5
}
private void Start()
{
// 4
animator = GetComponent<Animator>();
rb2D = GetComponent<Rigidbody2D>();
}
private void Update()
{
// 5
UpdateState();
}
void FixedUpdate()
{
// 6
MoveCharacter();
}
private void MoveCharacter()
{
movement.x = Input.GetAxisRaw("Horizontal");
movement.y = Input.GetAxisRaw("Vertical");
movement.Normalize();
rb2D.velocity = movement * movementSpeed;
}
private void UpdateState()
{
// 7
if (movement.x > 0)
{
animator.SetInteger(animationState, (int)CharStates.walkEast);
}
else if (movement.x < 0)
{
animator.SetInteger(animationState, (int)CharStates.walkWest);
}
else if (movement.y > 0)
{
animator.SetInteger(animationState, (int)CharStates.walkNorth);
}
else if (movement.y < 0)
{
animator.SetInteger(animationState, (int)CharStates.walkSouth);
}
else
{
animator.SetInteger(animationState, (int)CharStates.idleSouth);
}
}
}
// 1
Animator animator;
我们创建了一个名为“animator”的变量,稍后我们将使用它来存储一个对游戏对象中 Animator 组件的引用,这个脚本是附加到这个对象上的。
// 2
string animationState = "AnimationState";
将一个字符串直接输入到将要使用它的代码中称为“硬编码”值。当不可避免的输入错误发生时,这也是一个常见的错误来源,所以让我们通过只输入一次来避免这种可能性,然后在需要引用字符串时使用变量。
// 3
enum CharStates
数据类型“enum”用于声明一组枚举常量。每个枚举常量对应于一个基础类型值,如 int (integer),您可以引用枚举来获取相应的值。
这里我们声明了一个名为 CharStates 的枚举,并使用它来映射角色的各种状态(向东走,向南走,等等)。)以及相应的 int。我们将很快使用这个 int 值来设置我们的动画状态。
// 4
animator = GetComponent<Animator>();
在这个脚本附加的游戏对象中获取一个动画组件的引用。我们希望保存这个组件引用,这样我们以后就可以通过这个变量快速访问它,而不必每次需要时都检索它。使用 GetComponent 是从脚本中访问其他组件的最常见方式。您甚至可以用它来访问其他脚本。
// 5
UpdateState();
调用我们编写的方法来更新动画状态机。我们将这种逻辑移到了一个单独的方法中,以保持代码库的整洁和易读性。单个方法中的代码越多,就越难阅读。越难阅读的代码越难调试、测试和维护。
// 6
MoveCharacter();
我们已经移动了代码,将播放器移动到另一个方法中,以保持代码的整洁和可读性。
// 7
这一系列 if-else-if 语句将决定我们对Input.GetAxisRaw()
的调用是返回-1、0 还是 1,并相应地移动字符。
例如:
if (movement.x > 0)
{
animator.SetInteger(animationState, (int)CharStates.walkEast);
}
如果沿 x 轴的移动大于 0,则玩家按下向右键。
我们想告诉 Animator 对象应该将状态改为 walk-east,所以我们调用SetInteger()
方法来设置我们之前创建的动画参数的值,并触发状态的转换。
SetInteger()
接受两个参数:一个字符串和一个 int 值。第一个值是我们之前在 Unity 编辑器中创建的动画参数(图 3-42 )
图 3-42
我们从脚本中设置这个动画参数
我们在脚本中将这个动画参数的名称方便地存储在一个名为“animationState”的字符串中,并将它作为第一个参数传递给SetInteger()
。
SetInteger()
的第二个参数是为 AnimationState 设置的实际值。因为 CharStates 枚举中的每个值都对应一个 int 值,所以当我们键入:
CharStates.walkEast
我们实际上使用了 walkEast 在 enum 中对应的任何值。在这种情况下,walkEast 对应于 1。我们仍然需要通过将(int)写到变量的左边来将它显式地转换为一个 int。我们需要转换枚举的原因超出了本书的范围,但是与 C# 语言在幕后实现的方式有关。
保存您的脚本并切换回 Unity 编辑器,这样我们就可以使用所有这些了。选择指向 player-walk-south 的白色过渡箭头,并在“条件”区域中单击加号。选择动画状态,等于,并输入值 2。这个值 2 对应于我们刚刚编写的脚本中枚举的值 2。
现在,为“玩家-向西走”、“玩家-向北走”和所有“玩家-空闲”状态转换箭头逐一选择每个白色转换箭头。通过 Inspector 窗口为它们添加一个条件,并从CharStates
枚举中输入相应的值:
enum CharStates
{
walkEast = 1,
walkSouth = 2,
walkWest = 3,
walkNorth = 4,
idleSouth = 5
}
在浏览每个过渡箭头时,请记住取消选中“退出时间”、“固定持续时间”、“可以过渡到自己”等框,并将过渡持续时间(%)设置为 0。
最后一件事,我保证!选择每个玩家行走动画状态对象并将速度调整为 0.6,将每个空闲动画调整为 0.25。这将使我们的球员动画看起来刚刚好。
你现在已经设置了我们游戏所需的大部分玩家动画。按下播放按钮,用箭头键或 W,A,S,d 在屏幕上移动我们的角色。
继续伸展你像素化的腿。
小费
如果您忘记了 C# 中某个方法的确切参数,Visual Studio 将显示一个有用的弹出窗口,其中包含此信息(图 3-43 )。您可以按下 return 键来自动完成方法调用。
图 3-43
Visual Studio 显示一个带有方法参数名称和类型的弹出窗口
摘要
在这一章中,我们已经介绍了很多制作 Unity 游戏所需的核心知识。我们讲述了 Unity 工作原理背后的一些设计哲学和计算机科学原理。我们介绍了 Unity 中的游戏是如何由场景组成的,场景中的所有东西都是游戏对象。我们了解了碰撞器和刚体组件如何一起工作来确定两个游戏对象何时碰撞,以及 Unity 的物理引擎应该如何处理交互。我们了解了标签是如何在游戏运行时从脚本中引用游戏对象(如 PlayerObject)的。我们添加到工具包中的另一个有用的工具是层,它用于将游戏对象组合在一起。然后,我们可以通过脚本将逻辑强加到这些层上。
我们在本章学到的最有用的概念之一是预置,我们认为它是预制的资源模板,我们用它来创建这些资源的新副本。例如,我们的游戏可能会在游戏过程中出现数百个敌人,甚至是同时出现(如果你真的想要杀死玩家)。我们创建了一个敌人预设,并从该预设实例化了敌人游戏对象的新副本,而不是创建数百个单独的敌人游戏对象。我们已经开始学习如何编写 Unity 脚本,我们将在本书中继续学习这些知识。我们甚至编写了第一个脚本,通过移动 PlayerObject 转换组件让玩家在屏幕上走动。我们的脚本还设置了 Animator 状态机用来控制玩家状态和动画剪辑之间的转换的动画参数。我们在这一章里讲了很多,但是我们才刚刚开始!
四、世界的构建
现在我们已经学会了如何创建基本的角色动画并改变它们之间的状态,是时候为这些角色创建一个居住的世界了。二维(2D)世界通常是通过将一系列瓷砖放在一起以绘制背景,然后将其他瓷砖放在该背景之上以创建深度幻觉来创建的。这些图块实际上只是被分割或“切片”成方便的尺寸的精灵,通常使用图块调色板来放置。设计者或开发者可以构建这些 Tilemaps 的多个层来创建诸如树、头顶上飞翔的鸟、甚至远处的山之类的效果。在本章中,我们将学习如何做这些事情。你甚至可以为我们的 RPG 游戏创建自己的自定义磁贴地图。您还将学习 Unity 相机如何工作,以及如何创建行为来跟随玩家在关卡中走动。
平铺地图和平铺调色板
随着 Tilemap 功能的引入,Unity 在 2D 工作流工具链方面向前迈出了重要的一步。Unity Tilemaps 使得在 Unity 编辑器中创建关卡变得简单,而不是依赖外部工具。Unity 也有许多工具来增强 Tilemap 的特性,其中一些我们将在本章中介绍。
Tilemaps 是以特定排列存储精灵的数据结构。Unity 抽象出了底层数据结构的细节,使得开发人员可以更容易地专注于 Tilemap 的工作。
首先,我们需要导入 Tilemap 资源,就像我们在第三章中导入玩家和敌人的精灵资源一样。
在我们开始导入之前,让我们组织一下:在精灵目录中创建新的文件夹,名为:“对象”和“户外”我们将使用这些文件夹来保存 spritesheets 和 sliced sprites,用于我们的室外 Tilemap 和我们将放置在我们的世界中的各种对象。
从下载的图书资源中,在章节 4 文件夹中,找到标题为“OutdoorsGround.png”的 spritesheet。将 spritesheet 拖动到 Sprites ➤户外文件夹中。检查器中的户外导入设置应设定为以下内容:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:多重
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
确保选择底部的默认按钮,并将压缩设置为:无
按下应用按钮。
现在,我们要切割刚刚导入的 spritesheet。在检查器中点击相应的按钮进入精灵编辑器。按左上角的切片按钮,然后从类型菜单中按像元大小选择网格。X 和 Y 像素大小使用 32 × 32。按切片按钮。
检查产生的切片线看起来不错,然后按下精灵编辑器右上角的应用按钮。我们现在有了户外瓷砖套装。
接下来,我们要创建我们的 Tilemap。在层次视图中,右键单击并选择 2D 对象➤ Tilemap 以创建 Tilemap 游戏对象。您应该会看到一个名为“Grid”的游戏对象,以及一个名为“Tilemap”的子游戏对象此网格对象用于配置其子 Tilemaps 的布局。子 Tilemap 由一个像所有游戏对象一样的转换组件、一个 Tilemap 组件和一个 Tilemap Renderer 组件组成。
这个 Tilemap 组件是我们实际“绘制”瓷砖的地方。
创建瓷砖调色板
在我们绘画之前,我们需要创建一个瓷砖调色板,它是由单独的瓷砖组成的。转到菜单窗口➤瓷砖调色板显示瓷砖调色板窗格。将“互动程序面板”面板停靠在与检查器相同的区域。
我们希望我们的项目保持有序,所以在我们的项目视图中,在主资产文件夹下创建一个名为“TilePalettes”的文件夹,然后在 Sprites 文件夹下创建另一个名为“Tiles”的文件夹。在图块文件夹中,创建两个名为“户外”和“对象”的文件夹您的项目视图应该类似于图 4-1 。
图 4-1
创建文件夹后的项目视图
在图块调板窗口中选择“创建新调板”按钮。将调色板命名为“Outdoor Tiles”,并保留网格和单元大小设置,如图 4-2 所示。
图 4-2
创建新的拼贴调色板
按“创建”并将图块调色板保存到新创建的图块调色板文件夹中。这将创建一个 TilePalette 游戏对象。
在项目视图中选择“精灵➤户外”文件夹,然后从停靠的位置选择“平铺调色板”视图。我们将使用之前导入并切片的 Outdoors spritesheet 创建一个瓷砖调色板。
选择 Outdoors spritesheet 并将其拖动到 Tile Palette 区域,在那里显示“将 Tile、Sprite 或 Sprite 纹理资源拖动到此处”
当系统提示“将图块生成到文件夹”时,导航到我们之前创建的精灵➤图块➤室外图块文件夹,然后按“选择”按钮。Unity 现在将从单独切片的精灵中生成 TilePalette。过一会儿,您应该会看到我们的户外 spritesheet 中的瓷砖出现在瓷砖面板中。
用瓷砖调色板绘画
现在有趣的部分来了:我们将使用我们的 Tile 调色板来绘制 Tilemap。
从拼贴调色板中选择画笔工具,然后从拼贴调色板中选择一个拼贴。使用画笔在场景视图中的 Tilemap 上绘制。如果写错了,可以按住 Shift 键,把瓷砖画笔当橡皮擦用。当画笔被选中时,您可以按住 Option (Mac)/Alt (PC) +鼠标左键来平移 Tilemap。
使用 Option (Mac)/Alt (PC) +鼠标左键在图块调色板周围平移,左键单击以选择图块,左键单击并拖动以选择一组图块。如果您的鼠标有滚轮,您可以使用它来放大和缩小图块调板,或者您可以按住 Option / Alt +在触摸板上上下滑动来放大和缩小。这些相同的键和手势也适用于平铺地图。
画你的磁贴地图,玩得开心!你可以让你的磁贴看起来像你喜欢的那样,但是这里有一个如何开始的建议(图 4-3 )。
图 4-3
Tilemap 的起源
现在我们已经完成了一点绘画,让我们仔细看看瓷砖调色板中的工具。
瓷砖调色板
-
选择——选择网格或特定图块的区域
-
移动选择——在选定区域内移动
-
画笔——从图块调色板中选择一个图块,然后使用画笔在图块地图上绘画
-
方框填充——使用当前选中的图块绘制一个填充区域
-
选择新笔刷——使用 Tilemap 中现有的图块作为新笔刷
-
擦除——从磁贴地图中移除一个已绘制的磁贴(快捷键:按住 Shift 键)
-
整体填充——用当前选中的图块填充一个区域
让我们回到建立我们的水平。
从你为这本书下载的资源中,将名为“OutdoorsObjects.png”的文件拖到精灵➤对象文件夹中。检查器中的导入设置应设定为以下内容:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:多重
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
确保选择底部的默认按钮,并将压缩设置为:无
按下应用按钮。
现在点击检查器中相应的按钮进入精灵编辑器。按左上角的切片按钮,然后从类型菜单中按单元格大小选择网格。X 和 Y 像素大小使用 32 × 32。我们正在重用我们在第三章学到的精灵切片技术。
按下切片按钮,并检查产生的白色切片线看起来像他们在正确的位置分割精灵表。按下精灵编辑器右上角的应用按钮。我们现在有了一组户外主题的物体精灵来放置在我们的场景中。
现在,我们将创建一个瓷砖调色板来绘制这些对象精灵。返回到我们的图块调色板,并从下拉列表中选择“创建新调色板”。将新的调色板命名为“室外对象”,然后按“创建”按钮。出现提示时,将此调色板保存到 TilePalettes 文件夹中,我们之前在该文件夹中保存了室外瓷砖调色板。
现在,我们将做与我们为室外瓷砖所做的相同的事情:选择室外对象 spritesheet,并将其拖动到瓷砖调色板区域,在那里它说,“拖动瓷砖,精灵或精灵纹理资产在这里。”
当提示“生成瓷砖到文件夹”,导航到我们创建的精灵➤瓷砖➤对象文件夹,并按下选择按钮。Unity 现在将从单独切片的精灵中生成瓷砖调色板瓷砖。过一会儿,您应该会看到我们的对象 spritesheet 中的图块出现在图块调色板中。
小费
有时精灵是由多个瓷砖组成的。要一次选择多个单幅图块,请确保选择了画笔工具,然后单击并拖动一个矩形围绕您要使用的单幅图块。然后你就可以正常的用画笔画画了。对象 spritesheet 中的大岩石由四个独立的 sprite tiles 组成。
通过单击并拖动四块瓷砖周围的矩形,从户外对象瓷砖调色板中选择一块岩石。用画笔在你的地图上放置一块石头。您会立即注意到有些地方看起来不对:您实际上可以在岩石精灵的轮廓周围看到 Unity 场景视图的背景(图 4-4 )。
图 4-4
放置的岩石对象精灵周围的透明边框
当我们在地板砖上绘制岩石瓷砖时,我们实际上并没有在现有的瓷砖上绘制。相反,我们用新的瓷砖替换了现有的瓷砖。因为我们绘制的岩石精灵包含一些透明像素,所以我们可以看到场景视图的背景。为了避免这种情况,我们将使用多个 Tilemaps 和排序层。
使用多个切片地图
让我们把地图整理好。单击层次视图中的 Tilemap 对象,并将其重命名为:“Layer_Ground”
我们将创建多个 Tilemaps,并将它们堆叠在图层中。右键单击层次视图中的网格对象,然后转到:2D 对象➤平铺地图创建一个新的平铺地图。选择这个新的 Tilemap,并将其重命名为:“层 _ 树 _ 和 _ 岩石。”正如你可能已经从名字中猜到的,我们将在这个 Tilemap 上绘制树木、灌木丛、灌木和岩石。
在这一点上,如果你开始绘画,你会注意到又碰到了同样的透明度问题。要解决这个问题,我们必须做两件事。
要在特定的 Tilemap 上绘画,必须在 Tile Palette 视图中选择它作为活动 Tilemap 。在 Tile Palette 窗口中,你会注意到活动 Tilemap 的下拉菜单(图 4-5 )。使用它来选择我们的新层,层 _ 树 _ 和 _ 岩石。
图 4-5
选择 Layer_Trees_and Rocks 使其成为活动的 Tilemap
如果您还记得我们之前的讨论,精灵渲染器使用排序层来确定渲染精灵的顺序。在我们在 Layer_Trees_and_Rocks Tilemap 上绘制之前,我们需要为 Tilemap 设置排序层。这将确保当我们绘制树木和岩石时,它们会出现在地面瓷砖的顶部。
选择 Layer_Ground 并在检查器中找到 Tilemap 渲染器组件。
按下 Tilemap 渲染器中的添加排序层按钮,创建两个层:将第一个层称为“地面”,将第二个层称为“对象”。通过点击和拖动来重新排列这些排序层,使地面位于列表中的对象之上,如图 4-6 所示。
图 4-6
确保地面层在对象层之上
再次在“层次”视图中选择 Layer_Ground Tilemap,以在检查器中查看其属性。在 Tilemap 渲染器组件中,将排序图层更改为“Ground”选择图层 _Trees_and_Rocks Tilemap,将其排序图层更改为“Objects”
通过将活动层设置为 Layer_Ground 来删除我们之前绘制的岩石瓷砖,然后从瓷砖调色板工具集中选择擦除工具。您也可以通过按住 shift 键并绘画来使用画笔删除项目。用一些草或者任何你喜欢的室外物品面板上的地板砖填充被擦掉的地方。
现在我们准备开始绘画了。当您要绘制地面瓷砖时,请确保活动瓷砖贴图设置为 Layer_Ground,当您要绘制树木、岩石和灌木时,请确保活动瓷砖贴图为 Layer_Trees_and_Rocks。
小费
使用方括号键“[”和“]”旋转选定的拼贴,然后再使用它进行绘制。您也可以通过这种方式直接在调色板上旋转图块。
然后将激活的 Tilemap 设置为 Layer_Trees_and_Rocks,并使用 Outdoor Objects Tile 调色板绘制一些岩石和灌木丛(图 4-7 )。
图 4-7
在层 _ 树 _ 岩石磁贴上画一些岩石和灌木
现在我们的地图开始看起来像地图了。在我们的玩家开始探索之前,我们还需要做一些事情。
我们要确保玩家呈现在地面和岩石前。我们将通过设置玩家的排序层来实现这一点。选择 PlayerObject,然后在 Sprite 渲染器组件中查找排序层属性,并按“添加排序层”按钮。添加一个名为“字符”的排序层,并将其移动到底部,在地面和物体层之后。现在我们已经告诉 Sprite 渲染器按照从第一个排序层“地面”到最后一个排序层“角色”的顺序渲染对象
你的排序层应该如图 4-8 所示。
图 4-8
添加字符排序层
选择 PlayerObject 并将其排序层设置为我们刚刚创建的 Characters 层。这将使玩家站在地面和地面上的任何物体上,并使角色看起来像是在地面上行走。
我们将在本章后面解释相机是如何工作的,但是现在,选择相机对象并将大小属性更改为 3.75。
按下播放按钮,带我们的玩家在小岛上散步。
您会立即注意到一些事情:
-
镜头不跟着球员。事实上,如果你想的话,你可以直接走出屏幕,一直走下去。
-
玩家可以穿过地图上的物体。
-
您可能会在 Tilemap 上看到一些奇怪的线条或“眼泪”。如果它们出现,它们将位于两块瓷砖之间。
我们将在本章中讨论所有这些问题。
我们将学习使用碰撞器来防止玩家走过所有东西,我们将使用一个叫做 Cinemachine 的工具来让摄像机跟随玩家行走。我们还要确保摄像机配置正确。我们将配置图形设置,以确保我们得到一个清晰的边缘,这对像素艺术很重要,我们将使用一种材料来消除眼泪。
小费
如果您有多个 Tilemap 层,但希望只关注一个层,请使用场景视图右下角的 Tilemap 聚焦模式。这将允许您灰化其他 Tilemap 图层,并专注于特定图层的工作。
图形设置
让我们调整 Unity 引擎的图形设置,使我们的像素艺术看起来尽可能好。当当前设备的图形输出不足以将对象的边缘渲染成完美平滑的线条时,Unity 使用一种称为抗锯齿的算法。对象的边缘不是呈现平滑的线条,而是呈现锯齿状或锯齿。抗锯齿算法在对象的边缘上运行,并使其具有平滑的外观,以补偿锯齿状的图形输出。
默认情况下,在 Unity 编辑器中抗锯齿是打开的,与您使用的设备的电源无关。要关闭抗锯齿,请转到编辑菜单➤项目设置➤质量,并将抗锯齿设置为禁用。正如我们所知,Unity 引擎可以用于 3D 和 2D 游戏,但我们不需要为我们的像素艺术风格的 2D 游戏抗锯齿。
从同一个菜单中,编辑➤项目设置➤质量,也禁用各向异性纹理。当使用特定类型的相机透视时,各向异性过滤是增强图像质量的一种方式。它与我们正在做的项目无关,所以我们应该关闭它。
照相机
Unity 中的所有 2D 项目都使用一种叫做正交摄像机的东西。正交相机渲染大小相同的远近物体。通过将所有对象渲染成相同的大小,在旁观者看来,好像所有东西离摄像机的距离都是相同的。这与 3D 项目渲染对象的方式不同。在 3D 项目中,对象以不同的大小渲染,以产生距离和透视的错觉。当我们建立一个 2D 项目时,我们在一开始就配置了我们的 Unity 项目使用正交摄像机。
为了在渲染 2D 图形时获得最佳效果,理解相机在 2D 游戏中的工作方式很重要。正交摄像机有一个称为大小的属性,它决定了屏幕高度的一半可以容纳多少个垂直的世界单位。世界单位通过在 Unity 中设置 PPU 或每单位像素来确定。正如你可能从名字中怀疑的那样,每单位像素设置描述了 Unity 引擎应该在一个世界单位中渲染多少像素,即每单位像素。PPU 可以在导入资产过程中设置。PPU 是很重要的,因为当你为你的游戏创作时,你会希望确保所有的东西在同一个 PPU 下看起来都很好。
摄像机尺寸的计算公式是:
(垂直分辨率/ PPU) * 0.5 =摄像头尺寸
让我们用几个简单的例子来阐明这个概念。
给定屏幕分辨率为 960 × 640,垂直屏幕高度为 640 像素。让我们使用 64 的 PPU,使我们的计算简单:640 除以 64 等于 10。这意味着 10 个世界单位相互堆叠将占据整个垂直屏幕高度,5 个世界单位将占据垂直屏幕高度的一半。因此,摄像机尺寸为 5,如图 4-9 所示。
图 4-9
分辨率为 960 × 640,PPU 为 64,因此相机大小为 5
再来做一个例子。如果你的游戏使用 1280 × 1024 的屏幕分辨率,那么垂直屏幕高度就是 1024。使用 32 的 PPU,我们将 1024 除以 32 得到 32。这意味着 32 个世界单位相互堆叠将占据整个垂直屏幕高度,16 个世界单位将占据垂直屏幕高度的一半。因此,正交相机大小为 16。
这里还有最后一个例子来加强这个等式。使用 1280 × 720 的屏幕分辨率,垂直屏幕高度将为 720。使用 32 的 PPU,我们将 720 除以 32 得到 22.5。这意味着 22.5 个世界单位堆叠在一起将适合垂直屏幕高度:22.5 除以 2 等于 11.25,这是屏幕高度和我们的正交相机尺寸的一半。
开始掌握诀窍了吗?正投影尺寸乍一看似乎很奇怪,但实际上,这是一个非常简单的等式。
这又是一个等式:
(垂直分辨率/ PPU) * 0.5 =摄像头尺寸
获得好看的像素艺术游戏的诀窍是注意与分辨率相关的正交相机尺寸,并确保艺术作品在某个 PPU 下看起来不错。
在我们的游戏中,我们将使用 1280 × 720 的分辨率,但我们将使用一个技巧来放大图片。我们将 PPU 乘以比例因子 3。
我们修改后的等式将如下所示:
(垂直分辨率/ (PPU 缩放因子)) 0.5 =摄像机尺寸
使用 1280 × 720 的分辨率和 32 的 PPU:
(720 / (32 PPU * 3)) * 0.5 = 3.75 摄像头尺寸
这就是为什么我们把相机尺寸提前到 3.75 的原因。
现在我们已经更好地了解了相机在正交游戏中的工作原理,让我们来设置屏幕分辨率。Unity 自带多种屏幕分辨率选择,但有时设置自己的分辨率也是有益的。我们将设置 1280 × 720 的分辨率,这被认为是“标准高清”,应该足以满足我们正在制作的游戏风格。
点击游戏窗口,寻找屏幕分辨率下拉菜单。默认情况下,可能会设置为自由方面,如图 4-10 所示。
图 4-10
下拉菜单
在下拉菜单的底部,按加号打开一个窗口,您可以在其中输入新的分辨率。创建一个 1280 × 720 的自定义分辨率,如图 4-11 所示。
图 4-11
创建新的自定义分辨率
按下播放按钮,让角色在地图上走来走去,看看我们新的分辨率和相机的效果。
令人兴奋的东西!我们的游戏开始变得像...嗯,一个游戏!
我们已经为玩家创建了一个地图,但是你可能已经注意到了,摄像机停留在一个地方。这对于某些类型的游戏来说很好,比如益智游戏,但是对于 RPG 来说,我们需要摄像机跟随玩家。可以编写一个 C# 脚本来引导相机跟随玩家,但我们将使用一个名为 Cinemachine 的 Unity 工具来代替。
注意
Cinemachine 最初由亚当·迈希尔创作,在 Unity 资产商店出售。Unity 最终收购了 Cinemachine,并将其作为免费服务的一部分。如第二章所述,您可以创建自己的工具、作品和内容,并在 Unity Asset Store 中出售。
使用电影胶片
Cinemachine 是一套功能强大的 Unity 工具,用于程序游戏中的摄像机、电影和过场动画。Cinemachine 可以自动执行所有类型的摄像机移动,自动在摄像机之间混合和剪切,并自动执行所有类型的复杂行为,其中许多行为超出了本书的范围。我们将使用 Cinemachine 自动跟踪玩家在地图上的行走。
Cinemachine 通过 Unity 2017.1 的资产商店提供,但从 Unity 2018.1 开始,Cinemachine 通过新的 Unity Package Manager 提供。Unity 的早期版本仍然可以使用资产商店中的 Cinemachine,但该版本不再更新,也不会包含新功能。
稍后我们将讨论如何在 Unity 2017 和 Unity 2018 中安装 Cinemachine。请参阅您正在运行的 Unity 版本的说明。
在 Unity 2017 中安装 Cinemachine
转到“窗口”菜单,选择“资产存储”以打开“资产存储”选项卡。在屏幕顶部的搜索栏中,键入“Cinemachine”,然后按 enter 键。您应该会得到类似图 4-12 的结果。
图 4-12
资产商店中的 Cinemachine Unity 套装
点击 Cinemachine 图标,进入资产页面。在“资产”页面上,按“导入”按钮将 Cinemachine Unity 软件包导入到您当前的项目中。Unity 将向您呈现一个弹出屏幕,如图 4-13 所示,显示包内的所有资产。按导入按钮。
图 4-13
导入 Cinemachine Unity 软件包
导入 Cinemachine 包应该已经创建了一个名为“Cinemachine”的新文件夹。
在 Unity 2018 中安装 Cinemachine
从菜单中,选择窗口➤软件包管理器。您应该会看到 Unity 软件包管理器窗口出现。选择 All 选项卡,如图 4-14 所示,然后选择 Cinemachine。
图 4-14
选择全部选项卡
点击右上角的安装按钮安装 Cinemachine。Cinemachine 安装完成后,关闭软件包管理器窗口。您应该在项目视图中看到一个新的 Packages 文件夹。
安装 Cinemachine 后
不管你运行的是哪个版本的 Unity,当 Cinemachine 安装完成后,你应该会在屏幕顶部看到一个 Cinemachine 菜单,在组件和窗口之间。
注意
Unity 包是可以放入项目中的文件集合,开箱即用。软件包以模块化、版本化的形式出现,并自动解析依赖关系。2018 年 5 月,Unity 宣布包是未来,他们打算通过包分发他们的许多新功能。
虚拟摄像机
进入 Cinemachine 菜单,选择创建 2D 相机。这应该创建两个对象:一个 Cinemachine Brain ,连接到主摄像头,和一个 Cinemachine 虚拟摄像头游戏对象,名为“CM vcam1”。
什么是虚拟摄像头?Cinemachine 文档使用了一个很好的类比——虚拟摄像机可以被认为是摄影师。该摄影师控制主摄像机的位置和镜头设置,但实际上不是摄像机。虚拟相机可以被认为是一个轻量级的控制器,它指导主相机并告诉它如何移动。我们可以为虚拟相机设置一个跟随的目标,沿着一条路径移动虚拟相机,从一条路径混合到另一条路径,并围绕这些行为调整所有类型的参数。虚拟摄像机是 Unity 游戏开发工具箱中一个非常强大的工具。
Cinemachine Brain 是场景中主摄像机和虚拟摄像机之间的实际链接。Cinemachine 大脑监控当前活动的虚拟摄像机,然后将其状态应用到主摄像机。在运行时打开和关闭虚拟相机,Cinemachine 大脑可以将相机混合在一起,获得一些非常惊人的结果。
选择虚拟摄像机并将 PlayerObject 拖动到名为“Follow”的属性中,如图 4-15 所示。
图 4-15
将虚拟摄影机跟随目标设置为 PlayerObject
这告诉 Cinemachine 虚拟摄像机在玩家在地图上移动时跟随并跟踪玩家游戏对象的变换组件。
按下播放键,看着摄像机跟着玩家转。相当整洁!有了 Cinemachine,我们只需点击几下鼠标,就可以获得一些非常复杂的相机行为。为了更好地理解控制相机运动的隐藏参数,让我们隐藏地面层。从项目视图中选择 Layer_Ground Tilemap 对象。取消选中 Tilemap 的 Tilemap 渲染器组件旁边的复选框以将其禁用。现在 Unity 不会渲染 Layer_Ground Tilemap 了。你的场景应该类似于图 4-16 ,隐藏所有的地面瓷砖。
图 4-16
在取消选中“Tilemap Renderer”左侧的复选框以禁用它之后
现在点击层次视图中的主相机对象,并按下标有背景的彩色框。将背景颜色改为白色(图 4-17 )。这将使得在下一步中更容易看到 Cinemachine follow 帧。
图 4-17
将相机背景颜色改为白色
最后,选择虚拟相机,并确保“游戏窗口指南”复选框被选中。下一步你会看到什么是游戏窗口指南。
再次按下播放按钮。请注意,中间有一个白色的盒子围绕着玩家,周围是浅蓝色的区域,还有一个红色的区域包围着它(图 4-18 )。这个白色的盒子被称为“死区”死区内有一个黄色点,称为跟踪点,它将直接随着玩家移动。
图 4-18
玩家周围的死区包含一个黄色跟踪点
玩家周围的死区是跟踪点可以移动的区域,摄像头不会移动跟随。当跟踪点移出死区并进入蓝色区域时,摄像机将移动并开始跟踪。Cinemachine 也会给机芯增加一点阻尼。如果你能以某种方式足够快地移动,让球员进入红色区域,摄像机将 1:1 地跟踪球员,没有延迟地跟踪每个动作。
确保游戏视图是可见的,并点击白色框的边缘。将白色框向外拖一点,以调整跟踪区域的大小,使其变大一点。现在,玩家可以在不移动相机的情况下走得更远一点。您可以调整这些引导线的大小,以在游戏中获得自然的相机行为。
在层次视图中 Cinemachine 对象仍处于选中状态的情况下,查看 Cinemachine 虚拟摄影机组件。您将看到一个展开“Body”部分的箭头。在机身部分内,(图 4-19 )有调整虚拟相机机身 X 和 Y 阻尼的选项。阻尼是虚拟摄像机死区移动以赶上跟踪点的速度。
图 4-19
虚拟相机机身属性部分的阻尼属性
理解阻尼的最好方法是当你带玩家在地图上走动时调整 X 和 Y 阻尼。按 Play 并试验阻尼值。
如果你把玩家带到地图的边缘,你会看到相机随着目标移动,事情看起来不会太糟。但是我们可以做得更好。
停止播放并在层次视图中选择 Layer_Ground 对象。选中“Tilemap Renderer”左侧的框,使图层再次可见。
电影展
现在我们知道了如何让摄像机跟踪玩家走动,我们将学习当玩家接近屏幕边缘时如何防止摄像机移动。我们将使用一个叫做 Cinemachine Confiner 的组件来将摄像机限制在某个区域。Cinemachine Confiner 将使用一个碰撞器 2D 对象,我们已经预先配置了它来包围我们想要约束摄像机的区域。
在我们进入实现细节之前,让我们想象一下 Confiner 将如何影响摄像机的移动。请记住,虚拟摄影机实际上是在指挥活动场景摄影机,告诉它向哪里移动以及以什么速度移动。
在图 4-20 中,我们有一个场景中的玩家,他正准备向东走。
图 4-20。玩家正准备向东走
白色区域是当前活动摄影机的可见视口。灰色区域是贴图的其余部分,在摄影机的视口之外,当前不可见。灰色区域的外围被一个 2D 对撞机所包围。
当玩家向东行走时,虚拟相机会引导相机向东移动,并在玩家走过场景时跟踪玩家,如图 4-21 所示。
图 4-21。玩家正在向东走
虚拟相机的移动将考虑玩家的移动速度、死区的大小以及应用于相机机身的阻尼量。
要记住的关键是,我们已经用多边形碰撞器 2D 用灰色圈住了这个区域的周界,并设置了包围器的边界形状指向这个碰撞器。当约束器边缘碰到边界形状的边缘时,它会相互作用并告诉虚拟摄像机引导活动摄像机停止移动,如图 4-22 所示。
图 4-22。碰撞器撞到了 2D 多边形碰撞器的边缘,相机停止了移动
正如你在前面的图中看到的,Confiner 边碰到了包围着关卡的边界图形的边,这个边界图形就是碰撞器 2D。虚拟相机已经停止移动,玩家继续走到地图边缘。
让我们建造一个电影院。
从层级视图中选择我们的虚拟摄像机。在检查器中,在添加扩展旁边,从下拉菜单中选择 CinemachineConfiner。这将为我们的 Cinemachine 2D 摄像机增加一个 Cinemachine Confiner 组件。
CinemachineConfiner 需要一个复合对撞机 2D,或者一个多边形对撞机 2D 来确定限制的边缘从哪里开始。选择 Layer_Ground 对象,并通过“添加组件”按钮添加多边形碰撞器 2D。点击碰撞器组件上的编辑碰撞器按钮,编辑碰撞器,使其包围我们的 Layer_Ground 层的边缘,如图 4-23 所示。
图 4-23
拖动多边形碰撞器 2D 的角以匹配 Layer_Ground 的轮廓
图 4-23 中的箭头提醒你在碰撞器和地图边缘之间留一点空间。这是为了让相机显示一点水,而不是严格限制在陆地的边缘。当你完成编辑碰撞器时,不要忘记再次按下编辑碰撞器按钮。检查碰撞器组件的“Is Trigger”属性,然后再次选择我们的 Cinemachine 摄像机。我们想使用这个碰撞器作为触发器,因为如果我们不这样做,当玩家的碰撞器和 Tilemap 碰撞器交互时,玩家将被强行推出碰撞器。这是因为两个带有碰撞器的物体不能占据同一个地方,除非其中一个被用作触发器。
选择并拖动 Layer_Ground 对象到 Cinemachine Confiner 的边界形状 2D 区域,如图 4-24 所示。
图 4-24
来自 Layer_Ground 的多边形碰撞器 2D 将用于 2D 的边界形状
Confiner 将从 Layer_Ground 对象中获取碰撞器 2D,并将其用作 Confiner 的边界形状。确保选中“限制屏幕边缘”框,告诉限制程序停止在多边形 2D 边缘。
按下播放按钮,走到屏幕边缘。如果一切都设置正确,你会看到虚拟相机的死区停止移动,只要相机到达我们之前放置多边形碰撞器 2D 的边缘。图 4-25 中的箭头指向多边形碰撞器 2D 的边缘。正如你所看到的,玩家已经走出死区很远,当跟踪点继续随着玩家移动时,摄像机已经停止。
图 4-25
死区已经停止随着玩家移动
回顾一下,设置 Cinemachine Confiner 的三个步骤:
-
向虚拟摄像机添加 CinemachineConfiner 扩展。
-
在 Tilemap 上创建一个多边形碰撞器 2D,编辑其形状以确定限制边,并设置“是触发器”属性。
-
使用这个多边形碰撞器 2D 作为 Cinemachine Confiner 的边界形状 2D 场。
迫使相机在屏幕边缘停止移动,同时允许玩家继续行走,这是你可能在几十个 2D 游戏中见过的常见效果。
请注意,使用 Confiner 不会阻止玩家离开地图——只是相机会跟踪他们。我们将很快设置一些逻辑来防止玩家离开地图。
稳定化
当你带玩家在地图上走动时,你可能会注意到轻微的抖动效果。当你停止行走时,抖动尤其明显,虚拟相机阻尼慢慢使跟踪停止。这种抖动效果是由于过于精确的相机坐标。摄像机正在跟踪玩家,但是它移动到子像素位置,而玩家只是在像素间移动。我们之前在计算正交相机尺寸时已经确定了这一点。
为了解决这种抖动,我们希望强制 Cinemachine 虚拟相机的最终位置保持在像素边界内。我们将编写一个简单的“扩展”组件,并将其添加到 Cinemachine 虚拟摄像机中。我们的扩展组件将抓取 Cinemachine 虚拟相机的最后一个坐标,并将它们四舍五入到与我们的 PPU 一致的值。
创建一个名为 RoundCameraPos 的新 C# 脚本,并在 Visual Studio 中打开它。键入以下脚本,并参考以下注释以更好地理解它。这当然是你将要编写的更高级的脚本之一,但是如果让你的游戏看起来漂亮对你来说很重要,理解它是值得的。
using UnityEngine;
// 1
using Cinemachine;
// 2
public class RoundCameraPos : CinemachineExtension
{
// 3
public float PixelsPerUnit = 32;
// 4
protected override void PostPipelineStageCallback(
CinemachineVirtualCameraBase vcam,
CinemachineCore.Stage stage, ref CameraState state, float deltaTime)
{
// 5
if (stage == CinemachineCore.Stage.Body)
{
// 6
Vector3 pos = state.FinalPosition;
// 7
Vector3 pos2 = new Vector3(Round(pos.x), Round(pos.y), pos.z);
// 8
state.PositionCorrection += pos2 - pos;
}
}
// 9
float Round(float x)
{
return Mathf.Round(x * PixelsPerUnit) / PixelsPerUnit;
}
}
对之前代码的解释是:
// 1
using Cinemachine;
导入 Cinemachine 框架来编写一个扩展组件,我们将把它附加到 Cinemachine 虚拟摄像机上。
// 2
public class RoundCameraPos : CinemachineExtension
挂钩到 Cinemachine 的处理管道的组件必须从 CinemachineExtension 继承
// 3
public float PixelsPerUnit = 32;
每单位像素,或 PPU。正如我们之前讨论相机时所讨论的,我们在一个世界单位中显示 32 个像素。
// 4
protected override void PostPipelineStageCallback(CinemachineVirtualCameraBase vcam, CinemachineCore.Stage stage, ref CameraState state, float deltaTime)
从 CinemachineExtension 继承的所有类都需要此方法。在 Confiner 完成处理后,Cinemachine 会调用它。
// 5
if (stage == CinemachineCore.Stage.Body)
Cinemachine 虚拟摄像机有一个由几个阶段组成的后处理管道。我们执行此检查,以查看我们处于相机后处理的哪个阶段。如果我们在“身体”阶段,那么我们被允许设置虚拟相机在空间中的位置。
// 6
Vector3 finalPos = state.FinalPosition;
检索虚拟摄像机的最终位置
// 7
Vector3 newPos = new Vector3(Round(finalPos.x), Round(finalPos.y), finalPos.z);
调用我们写的 Rounding 方法(如下)对位置进行舍入,然后用结果创建一个新的 Vector。这将是我们新的、像素限定的位置。
// 8
state.PositionCorrection += newPos - finalPos;
将 VC 的新位置设置为旧位置和我们刚刚计算的新舍入位置之间的差值。
// 9
对输入值进行舍入的方法。我们使用这种方法来确保相机总是停留在一个像素位置。
材料
当你带着玩家在地图上走的时候,你可能会注意到方块之间的一些线或“裂缝”。这是因为它们没有精确地捕捉到像素级的位置。为了解决这个问题,我们将使用一种叫做材质的东西来告诉 Unity 我们想要如何渲染我们的精灵。
创建一个名为“材料”的新文件夹,然后右键单击并创建➤材料。称这种材料为“Sprite2D”
按如下方式设置材料的属性:
-
着色器:sprite/default
-
确保选中像素捕捉。
新的材料属性应该如图 4-26 所示。
图 4-26
配置新材料
我们希望游戏对象中的渲染器组件使用这种材质,而不是默认材质。
选择我们的 Layer_Ground Tilemap,并通过单击 material 属性旁边的点来更改 Tilemap 渲染器中的材质。当你选择了 Sprite2D 材质后,渲染器组件看起来应该如图 4-27 所示。
图 4-27
在我们的 Tilemap 渲染器组件中使用 Sprite2D 材质
对我们所有的 Tilemap 图层都这样做,然后按下播放按钮,眼泪应该已经消失了。
碰撞器和 Tilemaps
Tilemap 对撞机
现在我们要解决的问题是,玩家可以在 Tilemap 上浏览任何东西。还记得我们在第三章中如何给我们的 player 对象添加一个 2D 碰撞器吗?有一个专门为 Tilemap 定制的组件,叫做 Tilemap Collider 2D。当一个 Tilemap 碰撞器 2D 被添加到一个 Tilemap 时,Unity 将自动检测并添加一个碰撞器 2D 到它在那个 Tilemap 上检测到的每个 sprite tile。我们将使用这些 Tilemap 碰撞器来确定 PlayerObject 碰撞器何时与 tile 碰撞器接触,并防止玩家走过它。
从层次视图中选择 Layer_Trees_and_Rocks,然后按检查器中的“添加组件”按钮。搜索并添加一个名为“Tilemap Collider 2D”的组件。
你会注意到 Layers_Objects Tilemap 上的所有精灵现在都有一条绿色细线围绕着它们,表示一个碰撞器组件,类似于图 4-28 。
图 4-28
如箭头所示,2D 在岩石上添加了对撞机
注意
如果您在 Tilemap 上看到每个图块周围都有一个框,则您选择了错误的 Tilemap (Layer_Ground)。这是一个常见的错误。点击检查器中组件右上角的齿轮图标,移除 Tilemap Collider 2D 组件,然后从菜单中选择移除组件,如图 4-29 所示。
图 4-29
移除放错位置的 Tilemap 碰撞器 2D 组件
现在在层次视图中选择想要的 Tilemap: Layer_Trees_and_Rocks,并添加一个 Tilemap 碰撞器 2D 组件到其中。
我们刚刚给 Layer_Objects 上的每个贴图精灵添加了一个 2D 碰撞器。看一下图 4-30 ,注意花园周围的灌木丛是如何拥有七个独立的碰撞器的。问题是 Unity 跟踪所有这些碰撞器的效率很低。
图 4-30
层 _ 树 _ 岩石中的每个精灵现在都有自己的碰撞器
复合对撞机
幸运的是,Unity 附带了一个叫做复合对撞机的工具,它将所有这些独立的对撞机组合成一个大型对撞机,效率更高。在层次视图中保持 Layer_Trees_and_Rocks Tilemap 层处于选中状态,选择“添加组件”并将复合碰撞器 2D 组件添加到 Layer_Trees_and_Rocks。您可以保留所有默认设置。现在检查 2D tile map 碰撞器上的方框,上面写着“由复合材料使用”,看看所有用于布什的独立碰撞器是如何像变魔术一样合并在一起的。
当我们在 Tilemap 层添加一个复合碰撞器 2D 时,Unity 自动添加了一个刚体 2D 组件。设置这个刚体 2D 组件身体类型为静态,因为它不会移动。
在我们按下 Play 之前,我们先确定一下当玩家撞到什么东西的时候,她没有转过来,如图 4-31 所示。因为 PlayerObject 有一个动态刚体 2D 组件,所以当它与其他碰撞器交互时,会受到物理引擎施加的力的影响。
图 4-31
这个看起来荒谬的旋转是由于刚体 2D 碰撞造成的
选择 PlayerObject,在附加的刚体 2D 组件中,勾选“冻结旋转 Z”复选框,如图 4-32 所示。
图 4-32
冻结 Z 轴旋转以防止玩家旋转
按下播放按钮,让玩家在地图上走一圈。你会注意到她再也不能穿过灌木丛,岩石,或者任何你放在树木和岩石层的东西。这是因为我们在第三章中添加到 PlayerObject 的碰撞器与我们刚才添加的 Tilemap 碰撞器发生了碰撞。
您还会注意到,对于某些对象,播放器停止的位置和 Tilemap 上的对象之间存在明显的差距。为了更好地查看每个碰撞器的边界,保持游戏运行并通过选择场景选项卡切换到场景视图。
使用鼠标或触摸板上的滚轮放大播放器。如果需要,可以通过按 Alt (PC)或 Option (Mac)键,然后单击并拖动 Tilemap 来平移场景。从层次视图中选择 PlayerObject 以查看其长方体碰撞器。然后按住 Control (PC)或 Cmd / ⌘ (Mac)并选择 Layer_Trees_and_Rocks TileMap,而不取消选择 PlayerObject。
现在两个游戏对象都应该被选中了,你应该在玩家周围看到一个碰撞器,在 Tilemap 中的瓷砖周围看到另一个碰撞器。取决于你如何绘制你的 Tilemap,确切的瓷砖会有所不同,但是正如你在图 4-33 中看到的,碰撞器框为每个物体显示为细绿线。
图 4-33
玩家和她周围的物体之间的距离是由碰撞盒造成的——薄薄的绿色盒子
石头和玩家的碰撞器发生碰撞,阻止玩家靠近。因为碰撞器没有紧紧地拥抱岩石,在玩家停止的地方和岩石之间有一个明显的间隙。我们可以通过编辑每种精灵的物理形状来解决这个问题。
编辑物理形状
要编辑 spritesheet 中的 Sprite 的物理形状,请在项目视图中选择 Outdoor Objects spritesheet,并在检查器中打开 Sprite 编辑器。进入左上角的精灵编辑器下拉菜单,选择编辑物理形状,如图 4-34 所示。
图 4-34
在精灵编辑器中选择“编辑物理”形状
选择要编辑的精灵,并按下“轮廓公差”旁边的更新按钮,以查看精灵周围的物理形状轮廓。
拖动方框以匹配您想要的对象轮廓(图 4-35 )。除非你的游戏机制真的依赖它,否则没有必要对物理形态要求超精确。您可以通过单击线本身来创建其他点,通过选择一个点并按 Control (PC)或 Cmd / ⌘ (Mac) + delete 来删除点。
图 4-35
将物理形状与精灵相匹配
当你对物理图形感到满意时,按下应用按钮并关闭精灵编辑器。要在场景中使用这个新的物理轮廓,请确保选择了相关的 Tilemap,并从 Tilemap 碰撞器 2D 组件上的齿轮图标下拉菜单中按下重置按钮,如图 4-36 所示。这将迫使 Unity 编辑器读取更新的物理形状信息。
图 4-36
重置 Tilemap 碰撞器 2D 组件以使用新的物理图形
现在按下播放按钮,看看你的新的和改进的碰撞器是如何工作的。
小费
Unity 在制作复合碰撞器时,在合并碰撞器方面做出了最好的猜测,因此如果您在 Tile Editor 中调整精灵的物理轮廓时在精灵周围留下了间隙,您可能不会看到所有的 Tile 合并到一个巨大的碰撞器中。您可以再次在平铺编辑器中调整物理轮廓,或者如果没有太多间隙,则保留它。记住:如果你调整物体的物理轮廓,你需要每次重置组件来获得更新的物理轮廓。
因为你现在是碰撞器方面的专家,你可能还想把我们播放器上的箱式碰撞器 2D 调小一点,如图 4-37 所示。
图 4-37
调整我们播放器上的碰撞器大小,使之更适合
现在我们已经熟悉了 Tilemap 碰撞器,让我们用它们在地图上创建一个围绕陆地的边界,这样玩家就不能走进水里了。你的游戏可能有不同的要求——你可能出于某种原因希望玩家走进水里。但接下来是几种不同的技术之一,以防止球员走进你不想让他们进入的区域。
选择您的 Layer_Ground,并从您不想让玩家进入的区域移除任何牌。在我们创建的示例地图中,我们将移除水瓷砖,因为我们不希望玩家走进水中。我们要移除这些瓷砖,因为我们要将它们绘制到不同的图层上。现在创建一个新的 Tilemap 层,名为“层 _ 水”。确保将新图层上的排序图层设置为地面,以保持一致。
确保选择新创建的层作为平铺调色板屏幕中的活动平铺地图。画出你想让玩家远离的区域,比如水,如图 4-38 所示。请注意,在图 4-38 中,我们将焦点设置为 Tilemap,因此我们只能看到当前所选 Tilemap 层的图块。
图 4-38
打开焦点可以更清楚地看到新的 Tilemap 图层
我们想添加一个 Tilemap 碰撞器 2D 和一个复合碰撞器 2D 到层 _ 水 Tilemap。添加复合碰撞器 2D 将自动添加一个刚体 2D 组件。将刚体 2D 身体类型设置为静态,因为我们不希望海洋瓷砖在与玩家碰撞时移动。最后,在图块碰撞器 2D 中选中“用于合成”框,将所有单独的图块碰撞器合并成一个高效的碰撞器。
按下播放按钮,注意玩家是如何无法再走进水中的。我们在这里用对撞机所做的并不是什么新鲜事。在本章的前面,当我们使用 Tilemap 碰撞器时,你已经做了这种事情。
摘要
在这一章中,我们已经介绍了用 Unity 制作 2D 游戏的一些核心概念。我们学习了如何将精灵变成瓷砖调色板,并用它们来绘制瓷砖地图。我们使用碰撞器来防止玩家穿过物体,以及如何调整它们来获得更好的玩家体验。我们学习了如何配置相机,以实现缩放比例、艺术尺寸和分辨率之间的平衡,这在 2D 像素艺术风格的游戏中非常重要。我们在这一章中提到的最有价值的工具之一是 cinema Chine——一个自动化摄像机运动的强大工具。如果你有兴趣了解更多关于 Cinemachine 的信息, https://forum.unity.com
是一个提问和向它的创造者学习的好地方!在第五章中,你将会看到我们到目前为止所学的东西汇集在一起,你将会开始觉得你真的在做一个游戏。
五、组装螺母和螺栓
到目前为止,我们已经了解了很多关于 Unity 提供的构建游戏的工具,现在我们将开始把它们放在一起。在这一章中,我们将为玩家、敌人和游戏中可能出现的任何其他角色构建 C# 类结构。我们还将创建一些玩家可以捡起的预设,包括硬币和电源,并学习如何指定我们的游戏逻辑关心和不关心哪些对象碰撞。我们将回顾一个重要的 Unity 专用工具,称为脚本化对象,以及利用它们来构建一个干净的、可扩展的游戏架构的技巧。
字符类
在这一部分,我们将为游戏中每个角色、敌人或玩家的职业结构打下基础。在我们的游戏中,每个“活着的”角色都会有一些特征,比如健康的概念。
生命值或“生命值”被用来衡量一个角色在死亡前所能承受的伤害。生命值是过去桌面战争游戏的延续术语,但是现在各种类型的游戏都有生命值或生命值的概念。
在图 5-1 中,一张由这个庞然大物开发的游戏城堡毁灭者的截图,展示了有多少游戏选择在视觉上表现一个角色剩余的生命值。这个截屏展示了一个常见的技术:在屏幕顶部的每个角色名字下面有一个红色的生命值或生命值条。
图 5-1
生命值在屏幕顶部显示为不同长度的红色条
现在,我们只是记录生命值,但最终我们会建立自己的生命值栏来直观地显示玩家的剩余生命值。
在名为 MonoBehaviours 的脚本下创建一个新文件夹。因为我们将创建更多的 MonoBehaviours,所以为他们提供自己的文件夹是有意义的。将 MovementController 脚本移动到此文件夹中,因为它继承自 MonoBehaviour。
在 MonoBehaviours 文件夹中,创建一个名为 Character 的新 C# 脚本。双击角色脚本在我们的编辑器中打开它。
我们将建立一个普通的角色类,我们的玩家和敌人都将继承它。这个角色类将包含我们游戏中所有角色类型共有的功能和属性。
输入以下代码,完成后不要忘记保存。像往常一样,不要输入行注释。
using UnityEngine;
// 1
public abstract class Character : MonoBehaviour {
// 2
public int hitPoints;
public int maxHitPoints;
}
// 1
我们将在 C# 中使用 Abstract 修饰符来表示这个类不能被实例化,必须由子类继承。
// 2
跟踪字符当前的hitPoints
以及最大数量的hit-points
。一个角色的“健康”程度是有限的。
完成后,请务必保存该脚本。
玩家等级
接下来我们将创建基本的玩家类。在我们的 MonoBehaviours 文件夹中,创建一个名为 Player 的新 C# 脚本。这个玩家类一开始会非常简单,但是我们会随着时间的推移添加一些功能。
输入以下代码。我们已经删除了Start()
和Update()
函数。
using UnityEngine;
// 1
public class Player : Character
{
// Empty, for now.
}
// 1
我们现在想做的就是从Character
类继承来获得类似hitPoints
的属性。
保存脚本,然后切换回 Unity 编辑器。
选择玩家预设。将玩家脚本拖放到玩家对象中,并设置其属性,如图 5-2 所示。给玩家 5 点生命值和 10 点最大生命值。
图 5-2
配置我们的玩家脚本
我们开始时玩家的生命值低于他们的最大生命值,因为在本章的后面,我们将建立一个功能,玩家可以获得心脏能量来增加他们的生命值。
专注于预设
对于我们的冒险家来说,生活并不全是娱乐和游戏,即使是无畏的英雄也需要以某种方式谋生。让我们在场景中创建一些硬币让她捡起来。
从本书的下载游戏资产文件夹中,选择标题为“hearts-and-coins s32x 32 . png”的 spritesheet,它听起来完全像 20 世纪 80 年代的魅力摇滚金属乐队,并将其拖到资产➤ Sprites ➤对象文件夹中。
检查器中的导入设置应设定为以下内容:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:多重
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
确保选择底部的默认按钮,并将压缩设置为:无
按下应用按钮,然后打开精灵编辑器。
从“切片”菜单中,选择“按单元格大小划分网格”,并将像素大小设置为宽度:32,高度:32。按下应用并关闭精灵编辑器。
创建一个硬币预置
在这一部分,我们将创建硬币预置本身。
在项目视图中创建一个新的游戏对象,并将其重命名为 CoinObject。从切片的心形硬币火焰精灵中选择四个单独的硬币精灵,并将其拖动到硬币对象上,以创建一个新的动画。按照第三章中我们创建玩家和敌人动画的相同步骤。重命名动画剪辑为“硬币旋转”,并将其保存到动画➤动画文件夹。将生成的控制器重命名为“CoinController ”,并将其移动到 Controllers 文件夹中。
在精灵渲染器组件中,单击“精灵”表单旁边的小圆点,并选择在场景视图中预览该组件时使用的精灵。
通过选择 Sprite 渲染器组件中的排序层下拉菜单创建一个新的排序层,单击“添加排序层”,然后在地面和字符层之间添加一个名为“对象”的新层。
再次选择 CoinObject,将其排序图层设置为:Objects。
为了允许玩家捡起硬币,我们需要配置 CoinObject 的两个方面:
-
检测玩家与硬币碰撞的方法
-
硬币上的定制标签,表示可以被取走
建立环形对撞机 2D
再次选择 CoinObject 并添加一个圆形碰撞器 2D 组件。圆形碰撞器 2D 是一种原始的碰撞器,我们可以用它来检测玩家何时撞上硬币。将圆形碰撞器 2D 的半径设置为:0.17,这样它和精灵的大小差不多。
我们要写的脚本逻辑要求玩家穿过硬币捡起它。为了做到这一点,我们将使用圆形对撞机 2D 有点不同于我们使用其他对撞机。如果我们简单地给硬币对象添加一个圆形碰撞器 2D,玩家将无法穿过它。我们希望 CoinObject 上的圆形碰撞器 2D 充当某种“触发器”,并在另一个碰撞器与之交互时进行检测。我们不希望环形对撞机 2D 阻止其他对撞机穿过它。
要使用圆形对撞机 2D 作为触发器,我们需要确保“是触发器”属性被选中,如图 5-3 所示。
图 5-3
选中圆形碰撞器上的触发框
设置自定义标签
我们还想向 CoinObject 添加一个标记,脚本可以用它来检测是否可以拾取另一个对象。
让我们从标签和层菜单中创建一个新标签,名为“CanBePickedUp”:
-
从项目视图中选择 CoinObject
-
在检查器的左上方,从“标签”菜单中选择“添加标签”。
-
创建 CanBePickedUp 标记
-
再次选择 CoinObject 并将其标签设置为:CanBePickedUp
我们准备好创建预制品了。
通过将 CoinObject 拖动到 prefabs 文件夹中来创建一个预置。创建预设后,可以从项目视图中删除 CoinObject。
总之,创建一个可交互的预置的步骤是:
-
创建一个游戏对象并重新命名。
-
为预设动画添加精灵。这将把一个精灵渲染器组件附加到游戏对象上。
-
设置预设的精灵属性。这个精灵将被用来代表场景中的预设。
-
设置排序层,使预设可见并以正确的顺序渲染。
-
添加一个适合精灵形状的碰撞器 2D 组件。
-
根据你创建的预设类型,set:是碰撞器的触发器。
-
创建名为 CanBePickedUp 的标记,并将对象的标记设置为 CanBePickedUp。
-
如果需要,请更改图层。
-
将游戏对象拖到预设文件夹中作为预设使用。
-
从层级视图中删除原始游戏对象。
小费
拖放一个硬币预置到场景中,然后选择它。取消硬币预设上的触发框。请注意文本“Is Trigger”是如何变成粗体蓝色的。这是 Unity 提醒我们的方式,这个值只在预置的这个实例上被改变。如果我们想为预设的所有实例保存此设置,请按检查器右上角的“应用”按钮。确保完成后检查是否触发,这样硬币预设才能正常工作。
基于层的碰撞检测
我们想让我们的角色扮演游戏中的玩家能够通过走进硬币来捡起硬币。我们的游戏也会有敌人在地图上走来走去,但是我们希望敌人直接走过硬币而不要捡起来。
正如我们在第三章中讨论的,层被用来定义游戏对象的集合。连接到同一层上的游戏对象的碰撞器组件将会知道彼此并且可以交互。我们可以根据这些交互创建逻辑来做一些事情,比如拿起物体。
还有一种技术可以让不同层上的碰撞器组件相互感知。这种方法使用一种称为的统一特性,基于层的碰撞检测。
我们将使用这个特性,这样玩家和硬币碰撞器,尽管在不同的层上,也能互相感知。我们还会配置一些东西,让敌人的对撞机不知道硬币,因为他们不能捡起它们。如果两个对撞机彼此不了解,它们就不会相互作用。敌人会直接穿过硬币而不会捡起来。
要看到这个功能的运行,首先我们需要创建层并分配给相关的游戏对象。
我们在第三章中学习了如何创建新层,但是如果你需要复习:
-
在层次中选择 CoinObject
-
在检查器中,选择“层”下拉菜单
-
选择:“添加层”
-
创建一个新的层称为:“消耗品”
-
创建另一层称为:“敌人”
消耗品层将用于诸如硬币、心脏和其他我们希望玩家消费的物品。敌人层将用于:你猜对了-敌人。
创建两个新层后,检查员应该如图 5-4 所示。
图 5-4
添加敌人层
去编辑菜单➤项目设置➤物理二维。查看 Physics2DSettings 视图底部的层碰撞矩阵。在这里,我们将配置图层,让敌人可以直接穿过硬币、电源和我们选择的任何东西。
通过选中和取消选中一列和一行交叉处的框,我们可以配置哪些层相互了解并将进行交互。如果选中两个层相交处的框,不同层的对象上的碰撞器可以交互。
我们想配置玩家和硬币对象,这样他们的碰撞器就能互相感知。我们希望敌人的对撞机不知道硬币对撞机。
取消勾选消耗品和敌人交叉点的方框,使其看起来像图 5-5 。敌人层中的物体将不再与消耗品层中的物体发生碰撞而触发互动。这两个不同的层现在彼此不知道对方。我们还没有给敌人设定绕关卡行走的脚本——这是以后的事。但当我们这样做时,敌人不会意识到硬币,因为这两层没有配置为相互作用。
图 5-5
层碰撞矩阵允许我们配置层相互作用
选择 CoinObject 预设,并更改其层为:消耗品。当我们在这的时候,在预置文件夹中选择敌人对象预置,并改变它的层为:敌人。
现在拖动一个 CoinObject 预置到场景中的某个地方。
按下播放键,让角色走到硬币前。你会注意到玩家可以走过硬币。CoinObject 在消耗品层,Player 在阻挡层。因为我们在碰撞矩阵中为这些层选中了框,所以当它们各自的对象发生碰撞时,这些层会相互感知。我们将利用这种意识编写逻辑脚本,允许玩家捡起硬币。
触发器和脚本
正如我们前面提到的,对撞机不仅仅用于检测两个物体的碰撞。碰撞器也可以用来定义一个对象周围的范围,并检测另一个游戏对象已经进入该范围。当另一个游戏对象在范围内时,脚本行为可以相应地被触发。
“Is Trigger”属性用于检测另一个对象何时进入碰撞器定义的范围。当玩家的碰撞器接触到硬币的圆形碰撞器时,方法:void OnTriggerEnter2D(Collider2D collision)
在两个附加到碰撞器的物体上自动调用。我们可以使用这个方法自定义两个对象碰撞时应该发生的行为。因为我们设置的是触发器,碰撞器不再阻止玩家穿过硬币。
打开 Player.cs 脚本,在底部添加以下方法。
// 1
void OnTriggerEnter2D(Collider2D collision)
{
// 2
if (collision.gameObject.CompareTag("CanBePickedUp"))
{
// 3
collision.gameObject.SetActive(false);
}
}
让我们来看看这个方法的实现。
// 1
每当该对象与触发碰撞器重叠时,就会调用OnTriggerEnter2D()
。
// 2
使用碰撞来取回玩家碰撞过的gameObject
。检查被碰撞的gameObject
的标签。如果标签是“CanBePickedUp
”,那么继续在 If 语句中执行。
// 3
我们知道另一个游戏对象可以被拿起,所以我们将创建一个对象已经被拿起的印象,并将它隐藏在场景中。我们实际上还没有编写拾取对象的功能脚本——这是以后的事情。
在 Visual Studio 中按 Save,然后返回 Unity 编辑器并按 play。带玩家走到场景中的硬币前,看着玩家触摸硬币时硬币消失。
总结一下,当玩家与硬币发生碰撞时,碰撞器会检测到交互,脚本逻辑会确定这个对象是否可以被拾取,如果可以,我们会将硬币设置为非活动状态。相当整洁!
小费
确保在你修改脚本时按下保存键,否则这些修改不会在 Unity 编辑器中编译,也不会反映到你的游戏中。很常见的情况是做一个快速的改变,然后又回到 Unity,奇怪为什么你看不到任何不同的事情发生。
可编写脚本的对象
可脚本化的对象是一个重要的概念,对于任何想要建立一个干净的游戏架构的 Unity 游戏开发者来说都需要学习。可脚本化的对象可以被认为是可重用的数据容器,通过 C# 脚本定义,通过“资源”菜单生成,并作为资源保存在 Unity 项目中。
可编写脚本的对象有两种主要的使用情形:
-
通过存储对可脚本化对象资产的单个实例的引用来减少内存使用。这样做是为了避免每次使用对象时都复制每个对象的所有值,从而增加内存使用量。
-
预定义的可插入数据集。
为了解释第一个用例,让我们考虑一个虚构的例子:
想象一下,我们创建了一个包含这本书全部文本的 string 属性的预置。每次我们创建该预置的另一个实例时,我们也会创建这本书的整个文本的新副本。你可以想象,这种方法会很快耗尽游戏的内存。
如果我们在预设中使用一个可脚本化的对象来保存这本书的全部文本,那么每次我们创建预设的一个新实例时,它都会引用这本书文本的相同副本。我们可以生成尽可能多的预置副本,而书中文本使用的内存将保持不变。
关于第一个用例,使用可脚本化对象时要记住的一个重要事项是,每次我们引用可脚本化对象资产时,我们都是在引用内存中的同一个可脚本化对象。这种方法的结果是,如果我们改变这个可脚本化的对象引用中的任何数据,我们将改变可脚本化的对象资产本身中的数据,并且当我们停止运行我们的游戏时,这些改变将保持不变。如果我们想在运行时更改可脚本化对象资产的任何值,而不永久更改原始数据,那么我们应该首先在内存中创建一个副本。
Unity 开发者也经常在他们的游戏架构中使用可脚本化的对象来定义可插入的数据集。可以定义数据集来描述玩家可能在商店或库存系统中找到的物品。可脚本化的对象也可以用于定义属性,例如数字版本的纸牌游戏中的攻击和防御级别。
可脚本化的对象继承自ScriptableObject
类,(它又继承自Object
),而不是MonoBehaviour
,所以我们不能访问Start()
和Update()
方法。无论如何,使用这些方法实际上没有意义,因为可脚本化的对象是用来存储数据的。因为可脚本化的对象不是从MonoBehaviour
继承的,所以不能附加到游戏对象上。除了附加到游戏对象,使用可脚本化对象的一种常见方式是从继承自MonoBehaviour
的 Unity 脚本内部创建对它们的引用。
创建可编写脚本的对象
我们将创建一个名为“Item”的可脚本化对象来保存玩家可以消费或拾取的对象的数据。我们将在一个从MonoBehaviour
派生的脚本中引用这个可脚本化的对象,并将该脚本附加到物品的预设上。当一个玩家与预设发生碰撞时,我们将抓取一个可脚本化的对象的引用,并给人一种该物品已经被取消激活的印象。最终,我们会将这些对象添加到我们将要构建的清单中。
在脚本目录中创建一个名为“可编写脚本的对象”的文件夹。然后右键单击并创建名为 Item 的新脚本。
将以下内容键入 Item.cs,完成后不要忘记保存。像往常一样,我们将详细解释代码做什么。
using UnityEngine;
// 1
[CreateAssetMenu(menuName = "Item")]
// 2
public class Item : ScriptableObject {
// 3
public string objectName;
// 4
public Sprite sprite;
// 5
public int quantity;
// 6
public bool stackable;
// 7
public enum ItemType
{
COIN,
HEALTH
}
// 8
public ItemType itemType;
}
让我们看一下项目脚本:
// 1
CreateAssetMenu 在创建子菜单中创建一个条目,如图 5-6 所示。这使我们能够轻松地创建 Item 可脚本化对象的实例。
图 5-6
从“创建”子菜单中实例化项目实例
这些可编写脚本的对象实例实际上作为单独的资源文件存储在项目中,并且可以通过检查器在对象本身上修改它们的属性。
// 2
继承自ScriptableObject
,而不是Monobehaviour
。
// 3
The
字段:objectName
,可以有几种不同的用途。它肯定会在调试时派上用场,也许你的游戏会显示店面中某个物品的名称,或者另一个游戏角色会提到它。
// 4
存储一个对物品精灵的引用,这样我们就可以在游戏中显示它。
// 5
跟踪这个特定项目的数量。
// 6
可堆叠是一个术语,用来描述相同项目的多个副本如何存储在同一个地方,并可以由玩家同时进行交互。硬币是可堆叠物品的一个例子。我们设置布尔属性Stackable
来表示一个项目是否是可堆叠的。如果一个项目不可堆叠,则该项目的多个副本不能同时交互。
// 7
定义用于指示项目类型的枚举。虽然objectName
可以在游戏中的某些点上显示给玩家,但是ItemType
的属性永远不会显示给玩家,只会被游戏逻辑用来在内部识别对象。继续我们的硬币项目的例子,你的游戏可能有不同类型的硬币,但他们都将被归类为ItemType:
硬币。
// 8
使用ItemType
枚举创建一个名为itemType
的属性。
构建可消费的脚本
可脚本化的对象不从MonoBehaviour
继承,所以它们不能被附加到游戏对象。我们将编写一个继承自MonoBehaviour
的小脚本,它有一个保存项目引用的属性。因为这个脚本将继承自MonoBehaviour
,所以它可以附加到一个游戏对象上。在 MonoBehaviours 文件夹中,右键单击并创建一个名为“Consumable”的新 C# 脚本。
using UnityEngine;
// 1
public class Consumable : MonoBehaviour {
//2
public Item item;
}
// 1
从 MonoBehaviour 继承,这样我们可以将这个脚本附加到一个游戏对象。
// 2
当Consumable
脚本被添加到游戏对象中时,我们将为item
属性分配一个项目。这将在可消费脚本中存储对可脚本化对象资产的引用。因为我们已经声明了它public
,它仍然可以从其他脚本中访问。
如前所述,如果我们改变这个可脚本化对象引用中的任何数据,我们将改变可脚本化对象资产本身中的数据,并且当我们停止运行我们的游戏时,这些改变将保持不变。如果我们想在运行时改变可脚本化对象的任何值,而不改变原始数据,那么我们应该首先复制它。
保存耗材脚本并切换回 Unity 编辑器。
组装我们的产品
选择 CoinObject 预置并将可消费脚本拖到它上面。我们需要将图 5-7 中的消耗品属性设置为一个可脚本化的项目对象。我们将创建一个可脚本化的项目对象来附加。
图 5-7
可消耗项目属于项目类型,这是一个可脚本化的对象
在“可编写脚本的对象”文件夹中,右键单击并选择“资源”菜单顶部的“创建➤项目”,以创建项目可编写脚本的对象。如果您更喜欢使用 Unity 编辑器顶部的菜单栏,您可以选择资产➤创建➤项目。
将可编写脚本的对象重命名为“Item”。确保选择项目可编写脚本的对象,然后检查 Unity 检查器。将该项目的设置更改为图 5-8 。将对象命名为“coin”,勾选“Stackable ”,然后从项目类型下拉列表中选择 COIN。
图 5-8
设置硬币项目的属性
将 sprite 属性设置为 sprite,名称为:“hearts-and-coins s32x 32 _ 4”,如图 5-8 和 5-9 所示。这个 sprite 是项目的清晰表示,当我们想要在静态上下文中显示项目时,例如在库存工具栏中,就会用到它。这不同于我们在动画精灵出现在场景中时显示它们的方式。
图 5-9
选择一个精灵来代表硬币项目
回到硬币预置中的消耗脚本,将消耗物品设置为我们的硬币物品,如图 5-10 所示。
图 5-10
将可消耗物品设置为我们的新硬币物品
玩家冲突
我们的 Player 类已经有了检测与硬币预置碰撞的逻辑,但是现在我们想获取一个对可脚本化对象的引用,这样我们就可以在玩家遇到它时隐藏它。这将起到把硬币添加到玩家物品栏的作用。
在 Player 类中,在OnTriggerEnter2D method
中,修改我们之前编写的现有 if 语句,如下所示:
if (collision.gameObject.CompareTag("CanBePickedUp"))
{
// 1
// Note: This should all be on a single line
Item hitObject = collision.gameObject.GetComponent<Consumable>().item;
// 2
if (hitObject != null)
{
// 3
print("it: " + hitObject.objectName);
collision.gameObject.SetActive(false);
}
}
这里发生了很多事情,所以我们将一点一点地讲述。总的来说,我们的目标是在Consumable
类中检索对Item
(一个可脚本化的对象)的引用,并将其分配给hitObject
。
// 1
首先,我们获取对附加到collision
的gameObject
的引用。记住每一个collision
都会有一个与它相撞的游戏物体附在collision
上。在我们游戏的这一点上,gameObject
将会是一枚硬币,但稍后它可能会是任何带有标签“CanBePickedUp
”的游戏对象。
我们在脚本名称中的gameObject and
通道上调用GetComponent()
,以检索附加的Consumable
脚本组件。我们之前附上了Consumable
脚本。最后,我们从Consumable
组件中检索名为item
的属性,并将其分配给hitObject
。
// 2
检查hitObject
是否为空。如果hitObject
不是null
,那么我们已经成功地取回了hitObject
。如果hitObject
是null
,则什么都不做。像这样的安全检查有助于避免路上的错误。
// 3
为了确保我们已经检索到了item
,打印出objectName
属性,这是我们之前在检查器中设置的。
保存脚本并切换回 Unity 编辑器。按下播放按钮,将玩家带入硬币中。您应该会在控制台上看到图 5-11 中打印出来的文本。
图 5-11
与硬币的碰撞已被正确检测到
创造心脏能量
现在我们知道了如何创建脚本化的对象,让我们创建另一个玩家可以选择的对象:心脏能量。使用我们之前从“hearts-and-coins 32 x 32 . png”sprite-sheet 中截取的 sprite。
让我们回顾一下创建预置的步骤。
-
创建一个 GameObject,重命名为“HeartObject”。
-
为预设动画添加精灵。使用标题为“hearts-and-coins s32x 32”的精灵,以 0、1、2 和 3 结尾。命名新创建的动画,“心脏旋转”,并将其保存到动画➤动画文件夹。
-
从心脏对象中创建一个预设,方法是将它拖到预设文件夹中,然后从层次中删除原始对象。
-
选择文件夹中的心脏预设,并设置预设的精灵属性。在场景中预览时使用该属性。
-
在精灵渲染器组件上,将排序层设置为对象,以便预设可见。
-
添加一个碰撞器 2D 组件。我们可以使用圆形碰撞器,盒子,或者多边形 2D,但是对于心形的精灵,多边形 2D 会更好。如果需要,编辑碰撞器形状。
-
根据你创建的预设类型,set:是碰撞器的触发器。
-
在游戏对象上设置标签。我们将使用:CanBePickedUp,对于这个预置。
-
将图层更改为“耗材”
-
将游戏对象拖到预设文件夹中作为预设使用。
-
从层级视图中删除原始游戏对象。
小费
如果您同时为一个动画选择多个精灵,您可以在检查器中预览它们。在图 5-12 中,我们同时选择了四个心脏精灵。
图 5-12
在检查器中一次预览多个精灵
点击并拖动一个心形预置到场景中的某个地方(图 5-13 )。
图 5-13
一颗心的预制,等待被拾起
我们将设置心脏预设,这样它就像硬币预设一样包含一个可脚本化对象的引用。通过选择预设,然后按“添加组件”按钮并键入“消耗品”,将可消耗脚本添加到心脏预设。
现在我们需要创建 Item 可脚本化对象的新实例。这个新实例将是它自己的资产,与我们项目中的所有其他资产一起存储在项目视图中。
在项目视图中打开“可编写脚本的对象”文件夹。右键单击,然后选择创建➤项目,然后将创建的项目重命名为“心脏”。选择心脏项目,并将设置更改为图 5-14 中的设置。
图 5-14
心脏可脚本化对象的设置
我们将新的 heart 项目命名为“Heart”,给它一个 sprite,我们稍后将在库存中显示它,并将其数量设置为 1。当玩家捡起红心时,这个值将被用来增加玩家的生命值。我们还将项目类型设置为健康。不要点击可堆叠,因为红心不会被储存在玩家的库存中,而是会被立即消耗。
因为我们在心脏预设上有可消耗的脚本,我们可以按下可消耗物品属性旁边的圆圈并添加我们的新心脏物品,如图 5-15 所示。
图 5-15
将心脏项目分配给消耗品项目属性
就是这样!如果你按下 play,让玩家走进屏幕上的心形预设,你应该会在控制台上看到图 5-16 中的文本。
图 5-16
记录确认玩家跑进了心脏预设
我们希望玩家每捡起一颗红心就增加一次hitPoints
。切换回 Visual Studio 并打开 Player 类。
将OnTriggerEnter2D()
方法更改如下。本章前面已经讨论了一些代码,所以我们不再赘述。
void OnTriggerEnter2D(Collider2D collision)
{
if (collision.gameObject.CompareTag("CanBePickedUp"))
{
Item hitObject = collision.gameObject.GetComponent<Consumable>().item;
if (hitObject != null)
{
print("Hit: " + hitObject.objectName);
// 1
switch (hitObject.itemType)
{
// 2
case Item.ItemType.COIN:
break;
// 3
case Item.ItemType.HEALTH:
AdjustHitPoints(hitObject.quantity);
break;
default:
break;
}
collision.gameObject.SetActive(false);
}
}
}
// 4
public void AdjustHitPoints(int amount)
{
// 5
hitPoints = hitPoints + amount;
print("Adjusted hitpoints by: " + amount + ". New value: " + hitPoints);
}
让我们看一下这段代码。
// 1
使用一个switch
语句来匹配hitObject
属性:itemType
,并在项目类中定义ItemType
枚举。这允许我们编写与每个项目类型冲突时的特定行为。
// 2
在hitObject
是硬币类型的情况下,暂时不要做任何事情。我们将学习如何在创建库存时收集硬币。
// 3
在玩家遇到健康类型的物品的情况下,调用我们将要编写的方法AdjustHitPoints(int amount)
。该方法接受一个类型为int
的参数,我们将从hitObject
属性quantity
中获取该参数。
// 4
这个方法会根据参数中的数值来调整玩家的生命值。将命中点调整逻辑放在单独的函数中,而不是将逻辑放在 switch 语句中,有两个主要优点。
第一个优点是清晰。清晰的代码更容易阅读和理解,因此错误也更少。我们希望始终保持代码的意图和组织尽可能清晰。
第二个优点是,通过将逻辑放入函数中,我们可以很容易地从其他地方调用它。理论上来说,可能会有玩家的生命值被其他东西调整的情况,而不是碰到健康物品。
// 5
将 amount 参数添加到现有的生命点数中,然后将结果赋给hitPoints
。这个方法也可以通过为amount
参数传入一个负数来减少hitPoints
。当玩家受到伤害时我们会使用这个。
保存玩家脚本并切换回 Unity 编辑器。
按下播放,让玩家跑进心脏预设。您应该在控制台中看到图 5-17 输出中的消息。
图 5-17
调整玩家的生命值
摘要
在这一章中,我们已经开始将不同的 Unity 元素整合到游戏机制中。我们已经建立了基本的 C# 脚本,将用于我们游戏中的所有角色类型,并创建了玩家可以与之互动的几种类型的预设。碰撞检测是游戏开发的一个基本方面,我们已经了解了 Unity 引擎提供的检测和定制碰撞检测的工具。我们还学习了可脚本化的对象,它们是可重用的数据容器,使我们的游戏架构更加简洁。
六、健康和库存
这一章很大。我们将把目前为止学到的所有东西结合起来,建立一个健康栏来跟踪玩家的生命值。除了利用游戏对象、可脚本化的对象和预设,我们将了解一些新的 Unity 组件类型,如画布和 UI 元素。
没有一个物品清单系统的 RPG 是不完整的,所以我们将建立一个,以及一个屏幕上的物品清单栏,显示玩家持有的所有物品。这将是一个紧张的章节,有很多脚本和预设,但在它结束时,你会对构建自己的游戏组件更有信心。
创建健康栏
正如我们在第五章的角色职业部分所讨论的,许多电子游戏都有角色生命值的概念和追踪生命值的生命值条。我们将建立一个健康栏来跟踪我们无畏的玩家的健康水平。
画布对象
我们的健康栏将使用一个叫做画布的东西作为主要的游戏对象。什么是画布?画布是一种特定类型的 Unity 对象,负责渲染用户界面,或 Unity 场景中的“UI”元素。Unity 场景中的每个 UI 元素都需要是 Canvas 对象的子对象。一个场景可能有多个画布对象,如果在创建新的 UI 元素时画布不存在,那么将创建一个画布,并且新的 UI 元素将被添加为该画布的子元素。
用户界面元素
UI 元素是封装特定的、通常需要的用户界面功能的游戏对象,例如按钮、滑块、标签、滚动条或输入字段。Unity 允许开发人员通过提供预制的 UI 元素快速构建定制的用户界面,而不是要求开发人员从头开始创建。
关于 UI 元素需要注意的一点是,它们使用 Rect 转换,而不是常规的转换组件。除了位置、旋转和缩放之外,矩形变换与常规变换相同,它们还具有宽度和高度。宽度和高度用于指定矩形的尺寸。
建造健康酒吧
右键单击层次视图中的任意位置,然后选择 UI ➤画布。这将自动创建两个对象:Canvas 和 EventSystem。将画布对象重命名为“HealthBarObject”。
EventSystem 是用户使用鼠标或其他输入设备直接与对象交互的一种方式。我们暂时不需要,你可以删除。
选择 HealthBarObject 并查找画布组件。确保渲染模式被设置为屏幕空间覆盖,并勾选“像素完美”复选框。
将渲染模式设置为屏幕空间覆盖可确保 Unity 在场景顶部渲染 UI 元素。如果调整屏幕大小,包含 UI 元素的画布将自动调整自身大小。画布组件设置自己的矩形变换设置,并且不能更改。如果你需要一个更小的 UI 元素,你可以调整元素本身的大小,而不是画布。
现在我们已经创建了一个 Canvas 对象,让我们确保所有的 UI 元素,比如我们正在构建的健康栏,在屏幕上总是具有相同的相对大小。
选择 HealthBarObject 并查找画布缩放组件。将 UI 缩放模式设置为:随屏幕尺寸缩放,如图 6-1 所示,并将单位参考像素设置为 32。
图 6-1
设置用户界面缩放模式
这可以确保画布大小随屏幕大小适当缩放。
是时候导入我们将用于健康栏的精灵了。在精灵文件夹中新建一个名为“健康栏”的子文件夹。我们会把所有和生命值相关的精灵放在这个文件夹里。现在将名为“HealthBar.png”的 spritesheet 拖动到我们刚刚创建的文件夹中。
选择健康栏 spritesheet,并在检查器中使用以下导入设置:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:多重
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
确保选择底部的默认按钮,并将压缩设置为:无
按下应用按钮,然后打开精灵编辑器。
在切片菜单中,确保“类型”设置为:自动。我们将让 Unity 编辑器检测这些精灵的边界。
按“应用”切割精灵,然后关闭精灵编辑器。
接下来,我们将向 HealthBarObject 添加一个 Image 对象,它是一个 UI 元素。选择 HealthBarObject,右键单击,然后转到 UI ➤图像对象以创建图像。
这个图像对象将作为 HealthBar 的背景图像。将对象重命名为“背景”。单击源图像旁边的点,选择标题为“HealthBar_4”的切片图像。如图 6-2 所示,图像最初看起来是方形的。
图 6-2
调整尺寸前的背景图像
选择背景对象,将矩形变换宽度更改为:250,高度更改为:50。
按“W”使用移动工具的工具栏快捷方式。使用手柄,将背景对象移动到画布的右上角,如图 6-3 所示。
图 6-3
调整健康栏的大小和移动健康栏后
锚
您可能已经注意到图 6-2 和图 6-4 中间的星形符号。该符号由四个小三角形手柄组成,代表特定于 UI 元素的称为锚点的属性。
图 6-4
所选 UI 元素的锚点
如图 6-5 中的蓝线所示,锚点中的每个菱形对应于 UI 元素的 Rect 变换的一个角。左上角的锚点菱形对应于 UI 元素的左上角,依此类推。
图 6-5
四个锚点对应于 UI 元素的四个角
UI 元素的每个角总是以相对于其各自锚点的相同距离呈现。这确保了 UI 元素总是在相同的位置,一个场景接一个场景。当画布的大小随着屏幕的大小一起缩放时,在锚点和 UI 元素之间设置一致的距离的能力变得特别有用。
通过调整锚点的位置,我们可以确保健康栏总是出现在屏幕的右上角。我们将定位锚点,以显示屏幕边缘和健康栏之间的小边界,而不管屏幕有多大。
调整锚点
选择背景对象。在矩形变换组件中,点击图 6-6 中高亮显示的锚点预设图标。
图 6-6
“锚点预置”按钮
按下图标会给你一个锚预置菜单,如图 6-7 所示。默认情况下,选择中间-中心。这解释了为什么背景对象的锚点出现在画布的中间。
图 6-7
默认锚点预设为:中间-中心
我们希望始终将健康栏固定在相对于屏幕右上角的位置。在标题为“右”的列和标题为“上”的行中选择锚点预置设置。你会看到一个白色的方框围绕着选定的锚点预置,如图 6-8 所示。
图 6-8
选择右上锚点预设
按下锚点预设图标将其关闭,并注意锚点现在如何移动到画布的右上角(图 6-9 )。
图 6-9
锚点预置现在位于画布的右上角
我们在健康栏和画布的角落之间留了一点空间,锚点都集中在右上角。不管我们将屏幕尺寸缩放到什么程度,健康栏总是位于那个确切的位置。
小费
如果“矩形变换”组件在检查器中折叠,锚点将不会出现。如果您在选择 UI 元素时没有看到锚点,请确保单击“Rect Transform”左侧的小箭头,以便在组件折叠时将其展开。
用户界面图像遮罩
右键单击背景对象并创建另一个图像对象。因为我们在选择背景对象的同时创建了这个图像对象,所以它将被创建为一个“子”对象。它是与背景图像对象相同的类型的对象,但是我们使用它的方式有点不同。子图像对象将充当遮罩。这个面具和你在万圣节可能戴的面具有点不同。事实上,它的作用与万圣节面具正好相反。这个遮罩不会隐藏它下面的内容,只会显示任何符合遮罩形状的底层子图像的一部分。在这种情况下,底层图像将是 health meter,并将作为子对象添加。
选择图像对象,并将其重命名为“BarMask”。将源图像设置为:HealthBar_3。它看起来应该如图 6-10 所示。
图 6-10
为健康栏遮罩设置源图像后
如图 6-10 所示,作为 UI 元素的子对象也有锚点,但是这些锚点是相对于它们的父对象的。默认情况下,BarMask 的锚点相对于背景对象居中。
选择 BarMask 对象,将矩形变换的大小调整为宽度:240 和高度:30。我们想让 BarMask 比 health bar 的尺寸小一点,以显示实际 health meter 周围的边距。
按“W”使用移动工具的工具栏快捷方式。如图 6-11 所示,将酒吧老板移动到位。如果您喜欢在矩形转换中手动输入位置,可以设置位置 X: 0,位置 Y: 6。
图 6-11
将巴尔马克移动到位
在 BarMask 对象仍然被选中的情况下,点击检查器中的添加组件按钮,添加一个“Mask”组件,如图 6-12 所示。
图 6-12
向 BarMask 对象添加一个遮罩组件
这是将进行实际屏蔽的组件。包含遮罩的父对象的任何子对象都将被自动遮罩。
右键单击 BarMask 并添加一个类型为 Image 的子 UI 元素。这与我们之前创建 BarMask 时遵循的过程相同。称这个子图像对象为:“米”。将其源图像设置为:HealthBar_0,如图 6-13 所示,并将宽度设置为:240,高度设置为:30。
图 6-13
设定仪表图像对象的尺寸
因为米与 BarMask 的大小相同,并且是作为子对象创建的,所以您不必重新定位它。
本书的资源中包含的 spritesheet 图像包括几个替代的仪表图像。在这个例子中,我们使用的是实心绿色指示器,但是您可以随意选择您最喜欢的指示器。
选择仪表对象,并在图像组件上,将图像类型更改为:填充。然后将填充方法更改为:水平,并将填充原点更改为:左侧。这些设置将确保健康条从左到右水平填充。
选择仪表对象后,慢慢向左滑动填充量滑块。如图 6-14 所示,你会看到计量器慢慢缩小,表示玩家正在失去生命值。
图 6-14
向左移动填充量以模拟玩家正在失去生命值
我们将编写代码,以编程方式更新计量器的填充量,以指示剩余的点击次数。
小费
理解 UI 元素是如何呈现的很重要。对象在层次视图中出现的顺序就是它们将被渲染的顺序。将首先渲染层次中最顶层的对象,最后渲染底层的对象,导致最顶层的对象出现在背景中。
导入自定义字体
您很可能希望在项目中使用自定义字体。幸运的是,在 Unity 中导入和使用自定义字体非常简单。这个项目包括一个免费的定制字体,具有复古风格,称为丝网印刷。Silkscreen 是 Jason Kottke 创造的字体。
右键单击项目视图中的 Assets 文件夹,并创建一个名为“Fonts”的新文件夹。
打开本地计算机上保存本章资源文件的目录,并查看 Fonts 文件夹。找到。标题为“silkscreen.zip”的 zip 文件,双击它将其解压缩。解压它会创建另一个名为“silkscreen”的文件夹,在这个文件夹中,你会看到一个名为“slkscr.ttf”的文件。
将字体文件“slkscr.ttf”拖放到 Unity 项目的 Fonts 文件夹中以导入它。Unity 将检测文件类型,并使字体在任何相关的 Unity 组件中可用。
添加命中点文本
右键单击背景对象并从菜单中选择:UI ➤文本,添加一个文本 UI 元素作为背景的子元素。将对象重命名为“HPText”。这个文本对象将显示剩余的生命值。
在 HPText 的 Rect Transform 组件上,将宽度设置为:70,高度设置为:16。在 HPText 的文本组件上,将字体大小更改为 16,并将颜色更改为白色。将字体改为“slkscr”,这是我们刚刚导入的自定义丝印字体。如图 6-15 所示,将段落水平对齐和垂直对齐分别设置为左对齐和中对齐。
图 6-15
配置文本组件
健康栏图像底部有一个小托盘,提供背景并提高文本的可视性。将 HPText 对象移动到托盘上,使其类似于图 6-16 。
图 6-16
将 HPText 对象移动到托盘中
将 HPText 锚点改为左下方,如图 6-17 所示。
图 6-17
将 HPText 锚点设置为左下方
我们希望确保 HPText 与其父对象的左侧和底部保持相同的距离。
将 HealthBarObject 拖动到 prefabs 文件夹中,创建一个预置,并将预置重命名为:HealthBarObject。不要从层次视图中删除 HealthBarObject 我们稍后将使用它。
最终,我们将在 Player 对象中创建一个对 HealthBarObject 预置的引用,这样玩家脚本就可以很容易地找到它。但是首先我们必须构建健康栏脚本。
编写健康栏脚本
玩家类从角色类继承属性:生命值。现在,生命值只是一个常规类型:整数。我们将利用脚本化对象的能力在健康栏和玩家类之间共享生命值数据。
计划是创建此 HitPoints 可脚本化对象的实例,并将资产保存到 ScriptableObjects 文件夹。我们将向 Player 类添加一个 HitPoints 属性,并创建一个单独的包含 HitPoints 属性的 HealthBar 脚本。因为两个脚本都包含对相同的可脚本化对象资源:生命点的引用,所以生命点数据将在这两个脚本之间自动共享。
当我们构建这个功能时,请记住,我们正在对代码的某些部分进行更改,这将暂时破坏游戏,并导致游戏无法编译。这很正常——把它想象成拆开一个汽车引擎来升级一个零件,然后再把引擎装回去。发动机拆开后就不运转了,但一旦重新组装起来,就会比以前运转得更好。
在 Scriptable Objects 文件夹中,右键单击并创建一个名为 HitPoints 的新脚本,并将其更新为使用以下代码。
可编写脚本的对象:生命值
using UnityEngine;
// 1
[CreateAssetMenu(menuName = "HitPoints")]
public class HitPoints : ScriptableObject
{
// 2
public float value;
}
// 1
我们在第五章中使用了同样的技术。CreateAssetMenu
在“创建”子菜单中创建一个条目,这允许我们轻松地创建 HitPoints 可脚本化对象的实例。这些实例作为资产保存在 Unity 项目中。
// 2
使用一个浮子来保持生命值。我们需要在健康栏的计量器对象中为 Image 对象属性:Fill Amount 分配一个 float,这样我们的生活从一个 float 开始会更容易一些。
更新角色脚本
我们需要对角色脚本做一个小小的改动,以利用我们刚刚创建的生命值脚本。在角色脚本中,更改该行:
public int hitPoints;
致:
public HitPoints hitPoints;
我们已经将类型从:int
更改为新创建的可脚本化对象:HitPoints
。
并将maxHitPoints
的类型从int
更改为float
:
public float maxHitPoints;
因为我们在 HitPoints 对象中使用了一个float
来存储当前值,所以我们也将角色脚本中的maxHitPoints
更改为float
。
添加以下附加属性:
public float startingHitPoints;
我们将使用这个属性来设置一个角色开始时的生命值。
更新玩家脚本
在Start()
方法上方的任意位置添加以下两个属性。
// 1
public HealthBar healthBarPrefab;
// 2
HealthBar healthBar;
// 1
用于存储健康栏预置的引用。我们将使用这个引用作为参数来实例化()我们实例化了一个 HealthBar 预置的副本。
// 2
用于存储对实例化 HealthBar 的引用。
在现有的Start()
方法中,添加以下几行:
// 1
hitPoints.value = startingHitPoints;
// 2
healthBar = Instantiate(healthBarPrefab);
// 1
当脚本被启用时,Start()
方法将只被调用一次。我们想让玩家从startingHitPoints
开始,所以我们把它分配给当前的hitPoints.value
。
// 2
实例化一个健康栏预置的副本,并在内存中存储对它的引用。
有一件重要的事情我们没有做,当我们编写的逻辑拾起心脏和增加玩家的生命值。玩家当前的生命值不能超过他们允许的最大生命值。我们现在将添加该逻辑。
将OnTriggerEnter2D()
方法改为:
void OnTriggerEnter2D(Collider2D collision)
{
if (collision.gameObject.CompareTag("CanBePickedUp"))
{
Item hitObject = collision.gameObject.GetComponent<Consumable>().item;
if (hitObject != null)
{
// 1
bool shouldDisappear = false;
switch (hitObject.itemType)
{
case Item.ItemType.COIN:
// 2
shouldDisappear = true;
break;
case Item.ItemType.HEALTH:
// 3
shouldDisappear = AdjustHitPoints(hitObject.quantity);
break;
default:
break;
}
// 4
if (shouldDisappear)
{
collision.gameObject.SetActive(false);
}
}
}
}
// 5
public bool AdjustHitPoints(int amount)
{
// 6
if (hitPoints.value < maxHitPoints)
{
// 7
hitPoints.value = hitPoints.value + amount;
// 8
print("Adjusted HP by: " + amount + ". New value: " + hitPoints.value);
// 9
return true;
}
// 10
return false;
}
// 1
该值将被设置为指示碰撞中的对象应该消失。
// 2
默认情况下,玩家碰撞到的任何硬币都应该消失,给人一种它们已经被捡起并添加到玩家库存中的错觉。我们将在下一节创建一个玩家清单,所以现在这一行就足够了。
// 3
我们将添加额外的逻辑来“限制”生命值:maximumHitPoints
——玩家类从角色类继承的属性。下面提到的AdjustHitPoints()
方法,如果生命值被调整,将返回true
,如果没有被调整,将返回false
。
尽管玩家的生命值已满,AdjustHitPoints()
将返回 false,他们碰到的任何红心都不会被“捡起”并在场景中保持活跃。
// 4
如果AdjustHitPoints()
返回 true,那么预设对象应该消失。按照我们设计这种逻辑的方式,我们将来添加到 switch 语句中的任何新项目也可以设置shouldDisappear
值来使对象消失。
// 5
AdjustHitPoints()
方法将返回类型:bool
,表示hitPoints
是否调整成功。
// 6
检查当前生命值是否低于最大允许生命值。
// 7
按金额调整玩家当前hitPoints
。这种方法也允许负调整。
// 8
打印出一个帮助调试的方法。这是可选的。
// 9
返回true
表示生命值被调整。
// 10
Return false
表示玩家的生命值没有被调整。
创建健康栏脚本
右键单击 MonoBehaviours 文件夹 HealthBar:script,creation,并创建一个名为 HealthBar 的新 C#。使用以下代码创建健康栏脚本。
using UnityEngine;
// 1
using UnityEngine.UI;
public class HealthBar : MonoBehaviour
{
// 2
public HitPoints hitPoints;
// 3
[HideInInspector]
public Player character;
// 4
public Image meterImage;
// 5
public Text hpText;
// 6
float maxHitPoints;
void Start()
{
// 7
maxHitPoints = character.maxHitPoints;
}
void Update()
{
// 8
if (character != null)
{
// 9
meterImage.fillAmount = hitPoints.value / maxHitPoints;
// 10
hpText.text = "HP:" + (meterImage.fillAmount * 100);
}
}
}
// 1
使用 UI 元素需要导入名称空间UnityEngine.UI
。
// 2
玩家预设引用的同一生命值资产(可脚本化的对象)。这个数据容器允许我们在两个对象之间自动共享数据。
// 3
我们需要一个对当前玩家对象的引用来检索maxHitPoints
。此引用将通过编程方式设置,而不是通过 Unity 编辑器设置,因此在检查器中隐藏它以消除混淆是有意义的。
我们使用[HideInInspector]
在 Inspector 中隐藏这个公共属性。[HideInInspector]
的括号语法表明它是一个属性。属性允许方法和变量的附加行为。
// 4
为了方便和简单起见,我们创建了这个属性,这样我们就不必搜索各种子对象来查找仪表图像对象。一旦附加了 HealthBar 脚本,我们将在 Unity 编辑器中通过将计量器对象拖放到该属性中来进行设置。
// 5
这是为了方便和简单而创建的另一个属性。我们将在 Unity 编辑器中通过将 HPText 对象拖放到该字段中来进行设置。
// 6
因为在我们当前的游戏设计中,生命值的最大值不会改变,我们将把它缓存在一个局部变量中。
// 7
检索并存储角色的最大生命值。
// 8
在我们试图对它做任何事情之前,检查以确保对character
的引用不为空。
// 9
图像的填充量属性要求该值介于 0 和 1 之间。我们通过将当前点击量除以最大点击量将当前点击量转换成百分比,然后将结果分配给计量器的填充量属性。
// 10
修改 HPText 文本属性,以整数形式显示剩余的生命值。将fillAmount
乘以 100(如. 40 =气血:40,或. 80 =气血:80)。
小费
当你为你的游戏构建架构时,考虑一下一个公共变量是否需要在 Unity 编辑器中可见,或者它是否可以通过编程来设置。如果它是以编程方式设置的,那么当你检查一个预设并且不记得某个属性是否需要被设置时,使用[HideInInspector]
属性可以让你避免一些困惑。
我们还需要补充最后一点。回到播放器脚本,在现有的Start()
方法中,添加下面一行:
healthBar.character = this;
这一行将healthBar
中的Player character
属性设置为实例化的播放器。我们把这个保存到最后,这样你就可以看到我们刚刚添加到 HealthBar 的代码和播放器脚本之间的联系。HealthBar 脚本使用这个 player 对象来检索maxHitPoints
属性。
配置健康栏组件
切换回 Unity 编辑器,并从项目视图的 Prefabs 文件夹中选择 HealthBarObject。向 HealthBar 对象添加 Health Bar 脚本。
我们刚刚创建的属性应该是空白的,如图 6-18 所示。
图 6-18
设置属性前的健康栏脚本
在“可编写脚本的对象”文件夹中,右键单击并使用我们创建的菜单选项:“创建➤生命点”来创建生命点对象的新实例。重命名为:“生命值”,如图 6-19 所示。这个 HitPoints 对象是一个实际的资源,保存在项目文件夹中。
图 6-19
从可编写脚本的对象创建生命点资源
选中 HealthBarObject,将 HitPoints 对象拖动到 Hit Points 属性上,如图 6-20 所示。
图 6-20
将点击对象拖至属性
如你所见,生命值属性现在是粗体。正如我们之前所讨论的,这是 Unity 编辑器提醒我们的方式,我们只改变了预置的这个特定实例。如果我们想将更改应用到预设的所有实例,我们必须按下检查器右上角的应用按钮。请记住,在未来的某些情况下,你可能不希望对每个现有的预设进行修改。
我们将设置我们在 Health Bar 脚本中创建的属性,该脚本被添加到 HealthBarObject 中。脚本中的HitPoints hitPoints
和Text hpText
等属性实际上会被设置为引用 HealthBarObject 的一些子对象。
选择 Health Bar 对象,并单击 Health Bar 脚本中每个属性旁边的小圆点。为每个属性选择合适的值,如图 6-21 所示。完成后,请按下检查器中的“应用”按钮。
图 6-21
使用健康栏上的相应对象设置血糖仪图像和 Hp 文本
在预设文件夹中选择玩家对象预设。将我们创建的 HitPoints 脚本化对象拖到播放器脚本的 Hit Points 属性中。请记住,我们在健康栏对象中使用了相同的生命值对象。生命值数据像魔法一样在两个独立的物体之间共享。
在玩家脚本中设置属性如下:起始生命值为 6,最大生命值为 10,拖动 HealthBarObject 设置生命条预置属性如图 6-22 所示。
图 6-22
将健康栏预设属性设置为 HealthBarObject 预设
让我们总结一下我们刚刚构建的内容。
-
当玩家撞上一颗心时,
AdjustHitPoints()
增加生命值。 -
HealthBar 脚本还有一个名为
hitPoints
的属性,它引用了与播放器相同的生命值对象。HealthBar 继承自MonoBehaviour
,这意味着它在每一帧都调用Update()
方法。 -
在 HealthBar 脚本的
Update()
方法中,我们检查当前的value
内部生命值,并在仪表图像上设置填充量。这将调整健康计的视觉外观。
是时候测试一下健康棒了。确保您已经保存了所有的 Unity 脚本,并在 HealthBarObject 上按 apply 以应用更改。删除 HealthBarObject 以将其从层次结构中删除。
按 Play,让玩家走来走去捡红心。玩家每捡起一颗红心,生命值就会增加 10 点,如图 6-23 所示。
图 6-23
每当玩家收集到一颗心,生命值条就会增加点数
恭喜你!你建了一个健康吧!
小费
如果您需要处理层次结构或项目视图中的对象,但想要在检查器中保持不同的对象可见,请单击如图 6-24 所示的锁图标,以保持原始对象可见。当您需要拖动其他对象并将其设置为属性时,锁定对象会使工作变得更加容易。要解锁对象,只需再次按下锁图标。
图 6-24
使用锁定按钮使对象在检查器中保持打开状态
库存
许多电子游戏都有库存的概念——一个存放玩家所拿物品的地方。在本节中,我们将创建一个库存栏,其中包含几个存放物品的物品槽。一个脚本将被附加到库存栏,它将管理玩家的库存以及库存栏本身的外观。我们将把库存栏变成一个预置,并在玩家对象中存储对它的引用,就像我们对健康栏所做的那样。
右键单击层次视图中的任意位置,然后选择 UI ➤画布;这将创建两个对象:Canvas 和 EventSystem。将画布对象重命名为“InventoryObject”并删除 EventSystem。
选中 InventoryObject,在 Canvas 组件中选中:Pixel Perfect,并将 UI Scale Mode 属性设置为:Scale with Screen Size,就像我们之前对 Health Bar 所做的那样。
再次右键单击 InventoryObject 并选择 Create Empty。这将创建一个空的 UI 元素。将空元素重命名为:“InventoryBackground”。
小费
如果看不到正在处理的对象,请在层次视图中双击该对象,使其在场景中居中。双击 InventoryBackground 对象使其居中。
确保选择“库存背景”,然后按“添加组件”按钮。搜索并添加水平布局组,如图 6-25 所示。
图 6-25
添加水平布局组
水平布局组组件将自动安排其所有子视图水平并排放置。
选中 InventoryObject,创建一个空的 GameObject 子对象,并将其重命名为:“Slot”。
插槽对象将显示单个项目或大量“可堆叠”的项目。当我们的游戏运行时,我们将编程实例化插槽预置的五个副本。
每个 Slot 父对象将包含四个子对象:背景图像、托盘图像、项目图像和文本对象。
如图 6-26 所示,选择槽对象并在矩形变换组件中将其宽度和高度设置为 80 和 80。
图 6-26
将槽元素尺寸设置为 80 × 80
你的 Slot 元素的位置 X 和位置 Y 可能与图 6-26 不同,这没关系,因为我们无论如何都要以编程方式实例化它们。
右键单击插槽对象并选择 UI ➤图像以创建图像子对象。将子对象重命名为:“背景”。右键单击 Slot 对象并创建另一个名为“ItemImage”的图像。Background 和 ItemImage 都应该是 Slot 的子元素。
现在,我们将添加一个小“托盘”,在其中放置可堆叠物品数量文本。选择背景对象并创建一个图像子对象。将图像对象重命名为:“托盘”。右键单击托盘并选择 UI ➤文本创建一个文本子对象,重命名该对象:“QtyText”。
完成后,插槽结构应该如图 6-27 所示。
图 6-27
设置托盘和 QtyText 子项
所有这些对象在层次结构中的顺序都是正确的,这一点很重要。如图 6-27 所示对它们进行排序将确保背景先渲染,然后 ItemImage、Tray 和 QtyText 在其上渲染。如果您不小心用错误的父对象创建了一个对象,只需单击并将其拖到正确的父对象上。
导入库存插槽图像
在精灵下创建一个名为“库存”的新文件夹。在下载本章资源的本地目录中,从 Spritesheets 文件夹中选择名为“InventorySlot.png”的 spritesheet。将其拖动到项目视图的 Sprites/Inventory 文件夹中。
选择清单 Slot spritesheet,并在检查器中使用以下导入设置:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:多重
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
确保选择底部的默认按钮,并将压缩设置为:无
按下应用按钮,然后打开精灵编辑器。
在切片菜单中,确保“类型”设置为:自动。我们将让 Unity 编辑器检测这些精灵的边界。
按“应用”切割精灵并关闭精灵编辑器。
配置库存插槽
库存插槽由几个不同的项目组成,每个项目都有自己的配置。配置完成后,我们将把库存槽变成它自己的预置,并把它从主库存对象中分离出来。
配置项目图像
选择槽中的 ItemImage 对象。在矩形转换组件中,将宽度和高度更改为 80。
通过选中检查器中组件左上角的框来禁用图像。我们将在将图像放入插槽后启用它。ItemImage 的图像组件应类似于图 6-28 。
图 6-28
禁用 ItemImage 的图像组件
我们禁用图像,因为如果没有源图像提供给图像组件,图像组件将默认为默认颜色。我们不想显示一个巨大的空白盒,所以我们禁用图像组件,直到我们有一个源图像显示。
配置背景
选择背景对象,并确保图像组件的设置如图 6-29 所示。使用“InventorySlot_0”作为源映像,并确保映像类型设置为简单。
图 6-29
配置插槽的背景
将背景的矩形变换组件的宽度和高度设置为 80 和 80,如图 6-30 所示。
图 6-30
设置背景的宽度和高度
配置托盘
选择托盘对象,将其宽度和高度更改为 48 × 32。将图像组件的源图像设置为:“InventorySlot_1”,如图 6-31 所示。
图 6-31
设置托盘图像
因为托盘是作为背景的子对象添加的,所以它被自动设置为 0 和 0 的位置 X 和位置 Y,如图 6-32 所示。
图 6-32
托盘的默认位置
将托盘的锚点设置为右下角,然后再次将位置 X 和位置 Y 更改为 0 和 0。这将导致托盘的中心移动到其父对象的右下角,如图 6-33 所示。
图 6-33
锚点设置为右下角,位置 X,Y 设置为:0,0
配置数量文本-数量文本
文本对象用于向用户显示不可交互的文本。它们有助于在游戏中显示文本、调试和设计自定义 GUI 控件。库存中的文本对象将用于显示投币口中可堆叠物品的数量,如硬币。
选择文本组件,将其宽度更改为 25,高度更改为 20。在文本(脚本)组件中,将文本更改为“00”。我们将文本更改为 00,以帮助我们看到文本的位置。将字体设置为“slkscr”(我们定制的丝印字体),保持正常的字体样式。将字体大小改为 16,颜色改为白色,对齐方式如图 6-34 所示。
图 6-34
在文本对象中配置文本组件
因为 QtyText 对象是 Tray 的一个子对象,所以我们将保留锚点的默认值:middle-center。没必要移动它们。
对文本的位置满意后,通过取消选中文本对象上文本组件左上角的框来禁用文本组件。我们禁用文本,因为我们不想显示数量,直到我们有多个可堆叠的项目占据同一个插槽。我们将以编程方式启用该组件。
创建预设
现在所有的子元素都就位了,我们要用插槽制作一个预置。我们将以编程方式实例化这个预置的副本,并使用它们来填充库存栏。
选择高亮显示的项目:Slot,如图 6-35 所示,并将其拖入 prefabs 文件夹来创建一个 Slot 预置。确保你没有选择整个 inventory object——我们只是想从插槽中创建一个预置。我们一会儿会回来使用这个预制屋。
图 6-35
选择并拖动插槽到预设文件夹来创建一个预设
一旦你从插槽中创建了一个预置,从层次视图中删除插槽,这样只剩下清单对象和清单背景。它应该类似于图 6-36 。
图 6-36
在创建一个槽预置并从它的父槽中移除该槽后
最后但并非最不重要的是,点击并拖动库存对象到预设文件夹来创建一个预设,然后从层次中删除它。
构建插槽脚本
我们将构建一个简单的脚本来保存对插槽中文本对象的引用。该脚本将被附加到每个插槽对象。
在项目视图中选择插槽预置,并添加一个名为“插槽”的新脚本。在脚本中使用以下代码:
using UnityEngine;
using UnityEngine.UI;
// 1
public class Slot : MonoBehaviour {
// 2
public Text qtyText;
}
// 1
从 MonoBehaviour 继承,以便我们可以将此脚本附加到插槽对象。
// 2
对槽内文本对象的引用。我们将在 Unity 编辑器中进行设置。
保存这个脚本并切换回 Unity 编辑器。我们希望设置刚才在 Slot 脚本中创建的 Qty 文本属性。问题是,如果我们在项目视图中选择插槽预置,我们只能看到背景和 ItemImage 子项目,如图 6-37 所示。
图 6-37。在项目视图中选择托盘或 QtyText 子对象时,我们看不到它们
这个限制是 Unity 设计者故意设置的,目的是阻止开发人员引用嵌套父子层次结构内部的对象。
要在 Unity 编辑器中看到一个预设的所有子对象,我们需要临时实例化一个副本。将插槽预设拖到层次视图或场景中,以临时创建插槽的实例。
如果我们在项目视图中选择新实例化的副本,我们可以再次看到插槽的所有子对象,如图 6-38 所示。
图 6-38
查看所有插槽预置子对象
您将无法在场景中实际查看插槽预置,因为它目前不是画布对象的子对象。没关系——我们现在需要的就是能够访问 QtyText 对象
点击 Slot 脚本旁边的小点,设置 Slot 脚本上的 Qty Text 属性,如图 6-39 所示。
图 6-39
设置插槽脚本的数量文本属性
在脚本中包含对 QtyText 对象的引用使得以后的查找更加容易,而不必跟踪索引。通过特定索引引用对象也是一种有点脆弱的方式。如果顺序发生变化,或者添加了额外的组件,索引就会发生变化,脚本将不再正常工作。
按下检查器右上角的应用按钮,将更改应用到插槽预设,然后从层次视图中删除预设。
创建清单脚本
下一步是写一个脚本来管理玩家的库存,以及库存栏的外观。此脚本将附加到 InventoryObject。库存脚本将会比我们到目前为止所学的任何课程都要复杂,但是请将此视为一个学习和练习脚本技能的机会。
我们还将创建一个脚本来保存对 QtyText 的引用,并将该脚本附加到插槽预置中。
在项目视图的 MonoBehaviours 文件夹中,创建一个名为“Inventory”的新子文件夹。在 Inventory 文件夹中,右键单击并创建一个名为“Inventory”的新 C# 脚本。双击在 Visual Studio 中打开。
用下面的代码替换库存中的默认代码。
设置属性
首先,我们要为 Inventory 类设置属性。
using UnityEngine;
using UnityEngine.UI;
public class Inventory : MonoBehaviour
{
// 1
public GameObject slotPrefab;
// 2
public const int numSlots = 5;
// 3
Image[] itemImages = new Image[numSlots];
// 4
Item[] items = new Item[numSlots];
// 5
GameObject[] slots = new GameObject[numSlots];
public void Start()
{
// Empty for now
}
}
// 1
存储一个插槽预置的引用,我们将在 Unity 编辑器中附加它。我们的库存脚本将实例化这个预置的多个副本,用作库存槽。
// 2
清单栏将包含五个位置。我们使用const
关键字,因为我们不应该在运行时动态修改这个数字,因为脚本中的几个实例变量依赖于它。
// 3
实例化一个名为itemImages
的数组,大小为numSlots
(5)。该数组将保存图像组件。每个图像组件都有一个 Sprite 属性。当玩家将一个物品添加到他们的库存中时,我们将这个 Sprite 属性设置为该物品中引用的 Sprite。精灵将显示在清单栏的槽中。请记住,我们游戏中的物品实际上只是可脚本化的对象,或数据容器,将信息捆绑在一起。
// 4
items
数组将保存玩家拾取的可脚本化对象类型的实际项目的引用。
//5
数组中的每个索引将引用一个槽预置。这些槽预置是在运行时动态实例化的。我们将使用这些引用来查找槽内的文本对象。
实例化插槽预置
将以下方法添加到 Inventory 类中。这个方法负责从预设中动态创建插槽对象。
public void CreateSlots()
{
// 1
if (slotPrefab != null)
{
// 2
for (int i = 0; i < numSlots; i++)
{
// 3
GameObject newSlot = Instantiate(slotPrefab);
newSlot.name = "ItemSlot_" + i;
// 4
newSlot.transform.SetParent(gameObject.transform.GetChild(0).transform);
// 5
slots[i] = newSlot;
// 6
itemImages[i] = newSlot.transform.GetChild(1).GetComponent<Image>();
}
}
}
// 1
在我们尝试编程使用之前,检查以确保我们已经通过 Unity 编辑器设置了插槽预置。
// 2
循环遍历槽数。
// 3
实例化插槽预置的副本,并将其分配给newSlot
。将实例化的 GameObject 的name
改为“ItemSlot_
”,并将索引号追加到末尾。Name
是每个游戏对象的固有属性。
// 4
此脚本将附加到 InventoryObject。InventoryObject 预设只有一个子对象:Inventory。
将实例化槽的父对象设置为 InventoryObject 索引 0 处的子对象。索引为 0 的子对象是:存货,如图 6-40 所示。
图 6-40
Inventory 是 InventoryObject 在索引 0 处的子对象
// 5
将这个新的 Slot 对象分配给当前索引处的slots
数组。
// 6
槽的索引 1 处的子对象是 ItemImage。我们从 ItemImage 子元素中检索图像组件,并将其分配给itemImages
数组。当玩家拿起物品时,这个图像组件的源图像将出现在物品栏中。图 6-41 说明了 ItemImage 如何位于索引:1。
图 6-41
ItemImage 是索引为 1 的 Slot 的子对象
填充 Start()方法
让我们填写Start()
方法。这是一个短的。
public void Start()
{
// 1
CreateSlots();
}
// 1
调用我们之前写的方法来实例化插槽预置并设置库存栏。
AddItem 方法
接下来,我们将构建一个方法,将一个项目实际添加到库存中。
// 1
public bool AddItem(Item itemToAdd)
{
// 2
for (int i = 0; i < items.Length; i++)
{
// 3
if (items[i] != null && items[i].itemType == itemToAdd.itemType && itemToAdd.stackable == true)
{
// Adding to existing slot
// 4
items[i].quantity = items[i].quantity + 1;
// 5
Slot slotScript = slots[i].gameObject.GetComponent<Slot>();
// 6
Text quantityText = slotScript.qtyText;
// 7
quantityText.enabled = true;
// 8
quantityText.text = items[i].quantity.ToString();
// 9
return true;
}
// 10
if (items[i] == null)
{
// Adding to empty slot
// Copy item & add to inventory. copying so we don’t change original Scriptable Object
// 11
items[i] = Instantiate(itemToAdd);
// 12
items[i].quantity = 1;
// 13
itemImages[i].sprite = itemToAdd.sprite;
// 14
itemImages[i].enabled = true;
return true;
}
}
// 15
return false;
}
因为这是一个较长的方法,每一行代码都包含在每一个解释的上方,所以您不必不停地来回翻页。
// 1
public bool AddItem(Item itemToAdd)
方法AddItem
将接受类型为Item
的单个参数。这是要添加到清单中的项目。该方法还返回一个bool
,指示商品是否被成功添加到库存中。
// 2
for (int i = 0; i < items.Length; i++)
遍历items
数组中的所有索引。
// 3
这三个条件与可堆叠物品相关。让我们来看一下这个 if 语句:
items[i] != null
检查当前索引是否不为空。
items[i].itemType == itemToAdd.itemType
检查商品的itemType
是否等于我们要添加到库存中的商品的itemType
。
itemToAdd.stackable == true
检查要添加的项目是否可堆叠。
这三个条件的组合将具有检查索引中的当前项目(如果存在)是否与玩家想要添加的类型相同的效果。如果它是相同的类型,并且是一个可堆叠的项目,那么我们希望将新项目添加到现有项目的堆叠中。
// 4
items[i].quantity = items[i].quantity + 1;
因为我们正在堆叠这个项目,所以在项目数组中的当前索引处增加数量。
// 5
Slot slotScript = slots[i].GetComponent<Slot>();
当我们实例化一个插槽预置时,我们真正做的是创建一个附有插槽脚本的游戏对象。这一行将获取对插槽脚本的引用。Slot 脚本包含对 QtyText 子文本对象的引用。
// 6
Text quantityText = slotScript.qtyText;
获取对文本对象的引用。
// 7
quantityText.enabled = true;
因为我们将可堆叠对象添加到已经包含可堆叠对象的插槽中,所以现在一个插槽中有多个对象。启用我们将用来显示数量的文本对象。
// 8
quantityText.text = items[i].quantity.ToString();
每个项目对象都有一个类型为int
的数量属性。ToString()会将类型:int
转换成类型:String
,这样就可以用来设置文本对象的text
属性。
// 9
return true;
因为我们能够向清单中添加一个对象,所以返回true
表示成功。
// 10
if (items[i] == null)
检查项目数组的当前索引是否包含项目。如果它为空,那么我们将把newItem
添加到这个槽中。
因为我们每次都是线性地遍历 items 数组,所以一旦我们找到一个包含 null 项的索引,就意味着我们遍历了所有已经保存的项。所以我们要么添加特定itemType
的第一个项目,要么我们试图添加的项目不可堆叠。
注意,如果我们想在将来添加删除对象的功能,我们必须稍微修改这个逻辑。我们将添加这样的逻辑:当从一个槽中移除对象时,向左移动所有剩余的对象,并且不留下空槽。
// 11
items[i] = Instantiate(itemToAdd);
实例化一个itemToAdd
的副本,并将其分配给items
数组。
// 12
items[i].quantity = 1;
将项目对象上的数量设置为 1。
// 13
itemImages[i].sprite = itemToAdd.sprite;
将itemToAdd
中的精灵分配给itemImages
数组中的图像对象。请注意,这是我们之前在CreateSlots()
: itemImages[i] = newSlot.transform.GetChild(1).GetComponent<Image>();
中设置插槽时,用下面一行代码分配的精灵
// 14
itemImages[i].enabled = true;
return true;
使能itemImage
并返回true
表示itemToAdd
添加成功。回想一下,我们最初禁用了图像,因为如果没有向图像组件提供源图像,图像组件将默认为默认颜色。因为我们已经分配了一个精灵,所以我们启用了图像组件。
// 15
return false;
如果两个 If 语句都没有将itemToAdd
添加到库存中,那么库存一定是满的。返回false
表示itemToAdd
没有被添加。
保存清单脚本并返回 Unity 编辑器。
选择清单对象,并通过检查器将清单脚本附加到该对象。拖动插槽预置到库存脚本中的插槽预置属性。没有必要按下应用按钮,因为我们正在直接修改库存对象预置,而不是预置的一个实例。
更新玩家脚本
我们已经建立了这个伟大的库存系统,但玩家对象完全不知道它的存在。打开播放器脚本并添加以下属性:inventoryPrefab
和inventory
,然后在现有的Start()
方法中的任意位置添加Instantiate(inventoryPrefab)
行:
// 1
public Inventory inventoryPrefab;
// 2
Inventory inventory;
public void Start()
{
// 3
inventory = Instantiate(inventoryPrefab);
hitPoints.value = startingHitPoints;
healthBar = Instantiate(healthBarPrefab);
healthBar.character = this;
}
// 1
存储对库存预置的引用。稍后我们将在 Unity 编辑器中使用它。
// 2
用于在库存实例化后存储对库存的引用。
// 3
实例化库存预置。这一行将在inventory
变量中存储一个预置的引用。我们存储这个引用,这样我们就不必在每次想要使用它的时候搜索库存。
最后一件事…
在现有的OnTriggerEnter2D(Collider2D collision)
方法中,修改switch
语句,如下所示:
switch (hitObject.itemType)
{
case Item.ItemType.COIN:
// 1
shouldDisappear = inventory.AddItem(hitObject);
// 2
shouldDisappear = true;
break;
case Item.ItemType.HEALTH:
shouldDisappear = AdjustHitPoints(hitObject.quantity);
break;
default:
break;
}
// 1
在本地库存实例上调用AddItem()
方法,并将其作为参数传递hitObject
。将结果分配给shouldDisappear
。如果你回想起当我们在建立健康栏的时候更新玩家脚本,如果shouldDisappear
是true
,那么玩家碰撞的游戏对象将被设置为无效。因此,如果对象被添加到清单中,那么原始对象将从场景中消失。
// 2
去掉这条线,因为我们不再需要它了。
保存玩家脚本并切换回 Unity 编辑器。
选择玩家预设,并将新创建的库存对象预设拖到玩家脚本的库存预设属性中。它看起来应该如图 6-42 所示。
图 6-42
将库存对象分配给库存预设属性
添加更多的硬币让玩家通过拖放硬币到场景中来拾取。
现在按播放键。让玩家在地图上走来走去,捡起硬币。注意当你持有多枚硬币时,数量计数器的文字是如何显示的,如图 6-43 所示。
图 6-43
玩家是正式的富有…非常富有
摘要
咻!嗯,这是相当多的,但想想我们已经完成了多少。我们已经使用了可脚本化的对象和预设,甚至学习了画布和 UI 元素。这一章让我们写了比以往更多的 C#,我们学到了一些保持游戏架构整洁的技巧。我们有一个正常运行的库存和健康栏,我们的游戏开始看起来像一个正常的 RPG。
七、角色、协程和出生点
这一章将看到我们构建一些对任何视频游戏都很重要的核心组件。我们将构建一个游戏管理器,负责协调和运行游戏逻辑,比如在玩家死亡时让她出生。我们还将构建一个摄像机管理器,以确保摄像机总是设置正确。我们将更深入地了解 Unity,并学习如何通过编程来做事情,而不是依赖 Unity 编辑器。从长远来看,以编程方式做事可以让你的游戏架构更加灵活,并节省你的时间。在本章中,你还会学到 C# 和 Unity 编辑器的一些有用的特性,它们会让你的生活更简单,代码更整洁。
创建游戏管理器
到目前为止,我们一直在创建游戏的一些片段,这些片段之间没有任何协调逻辑。我们将创建一个游戏管理器脚本或“类”,它将负责运行游戏逻辑,例如,如果玩家被敌人杀死,它将生成玩家。
一个
在我们开始编写 RPGGameManager 脚本之前,让我们先了解一种叫做 Singleton 的软件设计模式。在应用的生命周期内,应用需要创建一个且只有一个特定类的实例时,可以使用单例。当你有一个类提供游戏中其他几个类使用的功能时,比如在游戏管理器类中协调游戏逻辑,单例是很有用的。单例可以提供对这个类及其功能的公共统一访问点。它们还提供惰性实例化,这意味着它们是在第一次被访问时创建的。
在我们开始把单例看作游戏开发架构的救星之前,让我们先来看看单例的一些缺点。
尽管单例可以为功能提供统一的访问点,但这也意味着单例持有状态不确定的全局可访问值。整个游戏中的任何一段代码都可以访问和设置 Singleton 中的数据。虽然这看起来是件好事,但是想象一下,试图找出访问单例的 20 个不同类中的哪一个将特定的属性设置为不正确的值。那是噩梦的内容。
使用 Singleton 的另一个缺点是,我们很难控制 Singleton 实例化的精确时间。例如,假设我们的游戏正处于一段非常图形化的代码中,突然一个我们希望在游戏早期创建的单例被实例化了。游戏断断续续,影响最终用户的体验。
对于单身族,还有其他几个有争议的优点和缺点,你应该仔细阅读它们,并自己决定何时使用它们。如果谨慎使用,独生子女肯定会让你的生活更轻松。
将我们的 RPGGameManager 类实现为单例类是有意义的,因为在任何时候,我们只需要一个类来协调游戏逻辑。我们不会有任何性能问题,因为当场景加载时,我们正在访问和初始化 RPGGameManager。
每个单例都包含防止创建该单例的其他实例的逻辑,从而保持其作为单个唯一实例的状态。我们将在稍后创建 RPGGameManager 类时回顾其中的一些逻辑。
创建单例
在层级中创建一个新的游戏对象,重命名为:“RPGGameManager”。然后在脚本下创建一个名为“经理”的新文件夹。
创建一个名为“RPGGameManager”的新 C# 脚本,并将其移动到 Manager 文件夹中。将脚本添加到 RPGGameManager 对象中。
在 Visual Studio 中打开 RPGGameManager 脚本,并使用以下代码构建 RPGGameManager 类:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RPGGameManager : MonoBehaviour
{
// 1
public static RPGGameManager sharedInstance = null;
void Awake()
{
// 2
if (sharedInstance != null && sharedInstance != this)
{
// 3
Destroy(gameObject);
}
else
{
// 4
sharedInstance = this;
}
}
void Start()
{
// 5
SetupScene();
}
// 6
public void SetupScene()
{
// empty, for now
}
}
// 1
一个static
变量:sharedInstance
用于访问 Singleton 对象。通过这个属性应该只能访问单例。
重要的是要理解static
变量属于类本身 (RPGGameManager),而不是该类的一个特定实例。属于类本身的一个结果是内存中只存在一个RPGGameManager.sharedInstance
的副本。
如果我们在 Hierarchy 视图中创建两个 RPGGameManager 对象,第二个要初始化的对象将与第一个 RPGGameManager 共享同一个 sharedInstance。这种情况本来就令人困惑,所以我们将采取措施防止它发生。
检索对sharedInstance
的引用的语法:
RPGGameManager gameManager = RPGGameManager.sharedInstance;
// 2
我们只希望一次存在一个RPGGameManager
实例。检查sharedInstance
是否已经初始化并且不等于当前实例。如果您以某种方式在层次中创建 RPGGameManager 的多个副本,或者如果您以编程方式实例化 RPGGameManager 预置的副本,这种情况是可能发生的。
// 3
如果sharedInstance
已经初始化,不等于当前实例,那么销毁它。应该只有一个 RPGGameManager 实例。
// 4
如果这是唯一的实例,则将sharedInstance
变量赋给当前对象。
// 5
将所有逻辑整合到一个方法中来设置场景。这使得将来从Start()
方法之外的地方再次调用变得更加容易。
// 6
SetupScene()
方法暂时是空的,但是很快就会改变。
构建一个游戏管理器预置
让我们创建一个 RPGGameManager 预置。遵循同样的过程,我们总是用游戏对象来创建预置:
-
将 RPGGameManager 游戏对象从层次视图拖动到项目视图的预设文件夹中,创建一个预设。
-
通常我们会从层次视图中删除原始的 RPGGameManager 对象。这一次,将它保留在层次视图中,因为我们还没有完成对它的处理。
我们创建了一个负责运行游戏的集中管理类。因为它是单例的,所以一次只存在 RPGGameManager 类的一个实例。
产生点数
我们希望能够在场景中的特定位置创建或“繁殖”角色——一个玩家或一个敌人。如果我们在繁殖敌人,那么我们可能也想定期繁殖它们。为了完成这一点,我们将创建一个产卵点预置,并附上一个脚本与产卵逻辑。
在层级视图中右键单击,创建一个空的游戏对象,并将其重命名为:“SpawnPoint”。
向我们刚刚创建的名为“SpawnPoint”的 SpawnPoint 对象添加一个新的 C# 脚本。将脚本移动到 MonoBehaviours 文件夹。
在 Visual Studio 中打开 SpawnPoint 脚本,并使用以下代码:
using UnityEngine;
public class SpawnPoint : MonoBehaviour
{
// 1
public GameObject prefabToSpawn;
// 2
public float repeatInterval;
public void Start()
{
// 3
if (repeatInterval > 0)
{
// 4
InvokeRepeating("SpawnObject", 0.0f, repeatInterval);
}
}
// 5
public GameObject SpawnObject()
{
// 6
if (prefabToSpawn != null)
{
// 7
return Instantiate(prefabToSpawn, transform.position, Quaternion.identity);
}
// 8
return null;
}
}
// 1
这可能是任何我们想要一次或在一个一致的时间间隔产卵的预置。我们将在 Unity 编辑器中将它设置为玩家或敌人的预设。
// 2
如果我们想定期生成预设,我们将在 Unity 编辑器中设置这个属性。
// 3
如果repeatInterval
大于 0,那么我们表示对象应该在某个预设的时间间隔重复产生。
// 4
因为repeatInterval
大于 0,我们使用InvokeRepeating()
以规则的、重复的间隔产生对象。InvokeRepeating()
的方法签名有三个参数:要调用的方法、第一次调用前等待的时间以及两次调用之间等待的时间间隔。
// 5
SpawnObject()
负责实例化预置和“生成”对象。方法签名表明它将返回类型为:GameObject
的结果,这将是衍生对象的一个实例。我们将这个方法的访问修饰符设置为:public
,这样就可以从外部调用了。
// 6
为了避免错误,在实例化副本之前,检查以确保我们已经在 Unity 编辑器中设置了预置。
// 7
在当前 SpawnPoint 对象的位置实例化预设。有几种不同类型的Instantiate
方法用于实例化预设。我们使用的具体方法是一个预置,一个指示位置的Vector3
,和一个称为四元数的特殊类型的数据结构。四元数用来表示旋转,Quaternion.identity
表示“不旋转”所以我们在没有旋转的情况下,在种子点的位置实例化预设。我们不会讨论四元数,因为它们可能非常复杂,超出了本书的范围。
返回预置的新实例的引用。
// 8
如果prefabToSpawn
为空,那么这个种子点可能没有在编辑器中正确配置。返回null
;
建立一个产卵点预置
计划是这样的:我们将首先为玩家设置一个产卵点,看看所有的碎片是如何组合在一起的,然后我们将为敌人设置一个产卵点。要构建一个通用的 SpawnPoint,将我们刚刚编写的脚本添加到 SpawnPoint 游戏对象中,然后创建一个预置。
按照下面的过程来创建一个预置的游戏对象:
-
从层级视图中拖动游戏对象到项目视图中的预设文件夹,创建一个预设。
-
从层次视图中删除原始 SpawnPoint 对象。
将 SpawnPoint 预设拖到你希望玩家出现的场景中。将 Spawn Point 的新实例重命名为“PlayerSpawnPoint”,如图 7-1 所示。不要按“应用”按钮,因为我们不想把这个改变应用到预置本身——只应用到这个实例。
图 7-1
重命名产卵点
正如你在图 7-2 中看到的,在场景中几乎看不到产卵点的位置。因为 GameObject 实例没有附加精灵,所以很难看到。
图 7-2
没有精灵的游戏对象有时很难在场景视图中看到
小费
当游戏没有运行时,为了使产卵点更容易在场景中定位,选择产卵点,然后按下检查器左上角的图标,如图 7-3 所示。
图 7-3
在检查器中选择图标
选择一个图标来直观地表示场景中的选定对象。你应该看到选中的图标出现在场景中的物体上,如图 7-4 所示。
图 7-4
使用图标使对象更容易在场景中找到
这些图标也可以在运行时通过选择游戏窗口右上角的 Gizmos 按钮来显示,如图 7-5 所示。
图 7-5
使用 Gizmos 按钮设置图标在运行时可见
配置玩家产卵点
我们仍然需要配置产卵点,以便它知道要产卵的预设。如图 7-6 所示,通过将 PlayerObject 预设拖到相应的属性,将附加的 Spawn Point 脚本中的“预设生成”属性设置为 PlayerObject 预设。让重复间隔设置为 0,因为我们只想繁殖玩家一次。
图 7-6
配置种子点脚本
因为计划是使用 PlayerSpawnPoint 来生成播放器,所以从 Hierarchy 视图中删除播放器实例。
按下播放,你会立即注意到没有任何变化。那个运动员不见了。这是因为我们实际上还没有在任何地方调用 SpawnPoint 类的SpawnObject()
方法。让我们修改 RPGGameManager 来调用SpawnObject()
。
切换回 Unity 编辑器并打开 RPGGameManager 类。
产生玩家
将以下属性添加到类的顶部:
public class RPGGameManager : MonoBehaviour
{
// 1
public SpawnPoint playerSpawnPoint;
// ...Existing code from the RPGGameManager class...
}
// 1
属性将保存一个特别为玩家指定的种子点的引用。我们保留了这个特定的重生点的参考,因为我们希望当玩家过早死亡时能够重生
添加以下方法:
public void SpawnPlayer()
{
// 1
if (playerSpawnPoint != null)
{
// 2
GameObject player = playerSpawnPoint.SpawnObject();
}
}
// 1
在我们尝试使用它之前,检查一下playerSpawnPoint
属性是否不为空。
// 2
调用playerSpawnPoint.SpawnObject
上的SpawnObject()
方法来生成播放器。存储对实例化播放器的本地引用,我们很快就会用到它。
在 RPGGameManager 的SetupScene()
方法中,添加一行:
public void SetupScene()
{
// 1
SpawnPlayer();
}
// 1
这将调用我们刚刚编写的SpawnPlayer()
方法。
最后,我们需要在 Hierarchy 视图中配置 RPGGameManager 实例,引用玩家种子点。将 PlayerSpawnPoint 从 Hierarchy 视图中拖放到 RPGGameManager 实例中的 Player Spawn Point 属性中,如图 7-7 所示。
图 7-7
将 Player Spawn Point 属性设置为 PlayerSpawnPoint 实例
按 Play,你应该会看到玩家对象出现在场景中玩家产卵点的位置。
概括起来
-
产卵点用于确定产卵的对象类型和产卵的位置。我们已经配置了玩家种子点实例来引用玩家对象预置。
-
在 RPGGameManager 实例中配置对玩家种子点的引用。
-
在 RPGGameManager 的
SetupScene()
方法中,调用 Player Spawn Point 类的SpawnObject()
方法。
敌人的滋生地
让我们建造一个出生点来繁殖敌人。因为我们已经建立了一个产卵点预置,这将是快速的。
-
拖放一个预置到场景中。
-
将其重命名为 EnemySpawnPoint。
- (可选)将图标更改为红色,以便我们可以在场景视图中轻松查看它
-
将“预设为产卵”属性设置为敌人预设。
-
将重复间隔设置为 10 秒,每 10 秒产生一个敌人。
配置完敌人的产卵点后,场景应该类似于图 7-8 。
图 7-8
SpawnPoint 的一个实例,配置为使用自定义的红色图标来繁殖敌人,使其容易被看到
按下播放键,每 10 秒钟就会有敌人出现。我们还没有编写任何人工智能来使敌人移动或攻击,所以玩家暂时是安全的。
当你带着玩家在地图上走来走去的时候,你可能已经注意到有什么不对劲了。镜头不再跟随玩家!大灾难!这是因为我们现在正在动态生成玩家,而不是在 Cinemachine 虚拟相机中设置玩家预置实例——follow 属性。虚拟摄像机没有跟随目标,因此保持在同一位置。
摄像机管理器
为了恢复相机跟随玩家在地图上走动的行为,我们将创建一个相机管理器类,并让游戏管理器使用它来确保虚拟相机被正确设置。这个摄像头管理器在未来会很有用,它是一个集中配置摄像头行为的地方,而不是将摄像头代码嵌入到我们应用的各个地方。
在层次中创建一个新的游戏对象,并将其重命名为:RPGCameraManager。创建一个名为 RPGCameraManager 的新脚本,并将其添加到 RPGCameraManager 对象中。在 Visual Studio 中打开该脚本。
我们将再次使用单例模式,就像我们在本章前面对 RPGGameManager 所做的那样。
对 RPGCameraManager 类使用以下代码:
using UnityEngine;
// 1
using Cinemachine;
public class RPGCameraManager : MonoBehaviour {
public static RPGCameraManager sharedInstance = null;
// 2
[HideInInspector]
public CinemachineVirtualCamera virtualCamera;
// 3
void Awake()
{
if (sharedInstance != null && sharedInstance != this)
{
Destroy(gameObject);
}
else
{
sharedInstance = this;
}
// 4
GameObject vCamGameObject = GameObject.FindWithTag("VirtualCamera");
//5
virtualCamera = vCamGameObject.GetComponent<CinemachineVirtualCamera>();
}
}
// 1
导入Cinemachine
名称空间,以便 RPGCameraManager 能够访问 Cinemachine 类和数据类型。
// 2
存储对 Cinemachine 虚拟摄像机的引用。使其成为public
以便其他类可以访问它。因为我们将以编程方式设置它,所以使用[HideInInspector]
属性,这样它就不会出现在 Unity 编辑器中。
// 3
实现单例模式。
// 4
在当前场景中找到虚拟摄像机游戏对象。在下面一行中,我们将获得对其虚拟相机组件的引用。我们还需要在 Unity 编辑器中创建这个标签,并配置虚拟摄像机来使用它。
记住游戏对象可以有多个组件,每个组件提供不同的功能。这就是所谓的“组合”设计模式。
// 5
虚拟摄像机的所有属性,如跟随目标和正交尺寸,都可以通过脚本和 Unity 编辑器进行配置。保存对虚拟摄像机组件的引用,这样我们就可以通过编程来控制这些虚拟摄像机属性。
从 RPGCameraManager 创建一个预置,但在层次视图中保留一个实例。
使用相机管理器
在 RPGGameManager 类中,将以下属性添加到该类的顶部:
public RPGCameraManager cameraManager;
我们制作这个属性public
是因为我们要通过 Unity 编辑器来设置它。RPGGameManager 将在生成播放器时使用对 RPGCameraManager 的引用,正如您将在下面的代码中看到的那样。
仍然在 RPGGameManager 类中,将SpawnPlayer()
方法更改为以下内容:
public void SpawnPlayer()
{
if (playerSpawnPoint != null)
{
GameObject player = playerSpawnPoint.SpawnObject();
// 1
cameraManager.virtualCamera.Follow = player.transform;
}
}
// 1
我们已经将这一行添加到了SpawnPlayer()
。将virtualCamera
的Follow
属性设置为player
对象的transform
。这将指示 Cinemachine 虚拟摄像机在玩家在地图上走动时再次跟随她。
切换回 Unity 编辑器,并在层次结构中选择 RPGGameManager 实例。我们将配置游戏管理器来使用相机管理器。
将 RPGCameraManager 实例拖动到层次结构中 RPGGameManager 的 CameraManager 属性中,如图 7-9 所示。
图 7-9
设置摄像机管理器属性
在我们的虚拟摄像机再次跟随玩家之前,还有最后一件事要做:在虚拟摄像机上设置标签,以便 RPGCameraManager 脚本可以找到它。
在层次视图中选择虚拟摄影机对象。默认情况下,虚拟摄像机将被命名为:CM vcam1。点按检查器中的“标签”下拉菜单。如果您需要复习标签下拉菜单的位置,请看图 7-10 。
图 7-10
标签下拉菜单
将名为“VirtualCamera”的标签添加到标签列表中。然后在层次中再次选择虚拟相机对象,并将标签设置为您刚刚创建的 Virtual Camera 标签(图 7-11 )。
图 7-11
将标记设置为 VirtualCamera,以便 RPGCameraManager 脚本可以找到它
再次按下播放键,让玩家在地图上走一圈。当玩家在地图上走来走去时,摄像机应该再次跟随她。
角色类设计
如果你还记得在第六章中,我们设计了一个名为:Character 的类。目前,只有玩家职业从角色继承,但在未来,每个从角色继承的职业都需要对其他角色造成伤害,对其造成伤害,甚至死亡的能力。这一章的剩余部分将涉及到设计和扩充角色、玩家和敌人的职业。
虚拟关键字
C# 中的“virtual
”关键字用于声明类、方法或变量将在当前类中实现,但是如果当前实现不充分,也可以在继承类中覆盖。
**在下面的代码中,我们构建了杀死一个角色的基本功能,但是继承类可能需要额外的功能。
因为我们游戏中的所有角色都是凡人,我们将在父类中提供一个杀死他们的方法。将以下内容添加到角色类的底部:
// 1
public virtual void KillCharacter()
{
// 2
Destroy(gameObject);
}
// 1
当人物生命值为零时,将调用此方法。
// 2
当角色被杀死时,调用Destroy(gameObject)
将破坏当前游戏对象并将其从场景中移除。
敌人阶级
成为英雄的一部分是面对逆境和可能的危险。在这一部分,我们将建造一个敌人职业,并赋予它伤害玩家的能力。
在第六章中,我们用一个巧妙的技巧用可脚本化的对象构建了一个名为HitPoints
的可脚本化的对象,它可以立即与玩家的生命值栏共享数据。Character 类包含一个由继承自 Character 的 Player 类使用的类型为HitPoints
的属性。
因为我们游戏中的敌人不会有屏幕上的生命值条,所以他们不需要一个HitPoints
ScriptableObject。只有拥有健康栏的玩家需要访问一个HitPoints
ScriptableObject。因此,我们可以通过简单地使用一个常规的float variable to track hit-points instead.
来简化我们追踪敌方职业生命值的方法
重构
为了简化我们的类架构,我们将重构一些代码。重构代码是一个简单的术语,用来在不改变现有代码行为的情况下对其进行重构。
在 Visual Studio 中打开角色类和播放器类。将hitPoints
变量从角色类移至玩家类,移至我们已有属性的顶部:
public HitPoints hitPoints;
选择敌人对象预设,并添加一个名为:敌人的脚本。在 Visual Studio 中打开敌方脚本。删除敌人类中的默认代码,并替换为以下代码。
using UnityEngine;
// 1
public class Enemy : Character
{
// 2
float hitPoints;
}
// 1
我们的敌人类继承自 Character,这意味着它可以访问 Character 类中的公共属性和方法。
// 2
类型为float
的简化的hitPoints
变量。
在这些代码更改之后,我们的玩家职业将继续使用我们在第六章中创建的 HitPoints ScriptableObject。我们还创建了一个敌人类,它包含了一个追踪生命值的简单方法。敌人职业也获得了角色职业中与生命值相关的现有属性:startingHitPoints
和maxHitPoints
。
小费
当重构代码时,最好保持较小的变化,然后进行测试以确保正确的行为,从而最小化引入新错误的机会。进行小的改变,然后测试的迭代循环是保持你理智的好方法。
内部访问修饰符
注意,我们在敌人类的hitPoints
变量前面省略了任何访问修饰符关键字(public
,private
)。在 C# 中,缺少访问修饰符意味着默认情况下将使用internal
访问修饰符。internal
访问修饰符将对变量或方法的访问限制在同一个“程序集”内汇编是 C# 中使用的一个术语,可以认为包含了 C# 项目。
协同程序
我们将暂停一下构建角色和敌人的职业,来讨论一下合一的一个重要而有用的特性。当在 Unity 中调用一个方法时,该方法一直运行到完成,然后返回到最初的调用点。常规方法中发生的一切都必须发生在 Unity 引擎的单个框架中。如果你的游戏调用了一个运行时间超过一帧的方法,Unity 实际上会强制整个方法在该帧内被调用。当这种情况发生时,你不会得到你想要的结果。甚至有可能用户看不到应该运行几秒钟的方法的结果,因为它将在单个框架内运行和完成。
为了解决这个难题,Unity 提供了一个叫做协程的东西。协程可以被认为是可以在执行过程中暂停,然后在下一帧继续执行的函数。打算在多个帧的过程中执行的长时间运行的方法通常被实现为协程。
声明协程和使用返回类型一样简单:IEnumerator
并在方法体中的某个地方包含一行指令 Unity 引擎暂停或“让步”。正是这条yield
线告诉引擎暂停执行并返回到后续帧中的相同点。
调用协程
一个名为RunEveryFrame()
的假想协程可以通过将其包含在方法StartCoroutine()
中来启动,如下所示:
StartCoroutine(RunEveryFrame());
暂停或“放弃”执行
RunEveryFrame()
将一直运行,直到到达一个yield
语句,此时它将暂停,直到下一帧,然后继续执行。一个yield
声明可能看起来像:
yield return null;
完整的协程
下面的RunEveryFrame()
方法只是协程的一个例子。不要把它添加到你的代码中,但是要确保你理解它是如何工作的:
public IEnumerator RunEveryFrame()
{
// 1
while(true)
{
print("I will print every Frame.");
yield return null;
}
}
// 1
我们将print
和yield
语句包含在一个while()
循环中,以保持该方法无限期运行,也就是说,使其长期运行并跨越多个帧。
具有时间间隔的协同程序
协程也可以用于以固定的时间间隔调用代码,比如每 3 秒,而不是每一帧。在下一个例子中,我们没有使用yield return null
来暂停,而是使用yield return new WaitForSeconds()
并传递一个时间间隔参数:
public IEnumerator RunEveryThreeSeconds()
{
while (true)
{
print("I will print every three seconds.");
yield return new WaitForSeconds(3.0f);
}
}
当这个示例协程到达yield
语句时,执行将暂停 3 秒钟,然后恢复。由于while()
循环,每三秒钟就会调用并打印一次print
语句。
我们将编写一些协程来构建角色、玩家和敌人类的功能。
抽象关键字
C# 中的“abstract
”关键字用于声明类、方法或变量不能在当前类中实现,而必须由继承类实现。
敌人和玩家职业都继承自角色职业。通过将以下方法的定义放在角色类中,我们要求敌人和玩家类在游戏编译和运行之前实现它们。
将下面的"using"
语句添加到角色类的顶部。我们需要导入System.Collections
来使用协程。
using System.Collections;
然后在KillCharacter()
方法下添加以下内容:
// 1
public abstract void ResetCharacter();
// 2
public abstract IEnumerator DamageCharacter(int damage, float interval);
// 1
将角色设置回其原始开始状态,以便可以再次使用。
// 2
被其他角色调用来伤害当前角色。对角色造成的伤害量和时间间隔。该时间间隔可用于反复出现损坏的情况。
如前所述,返回类型:IEnumerator
在协程中是必需的。IEnumerator
是System.Collections
名称空间的一部分,这就是为什么我们必须在前面添加导入行:using System.Collections
。
请记住,所有抽象方法都必须在代码编译和运行之前实现。因为这个方法在玩家和敌人的父类中,所以我们必须在两个类中都实现这两个方法。
实现敌人类
既然我们是协程专家,并且已经构建了角色类,我们将从DamageCharacter()
协程开始实现抽象方法。
想象一下我们游戏中的一个场景,一个敌人撞上了玩家,而玩家没有让开。我们的游戏逻辑说,只要敌人和玩家保持联系,敌人就会持续伤害她。另一个定期造成伤害的场景是玩家走过熔岩。那只是科学。
为了实现这个场景,我们已经将DamageCharacter()
方法声明为一个协程,以允许该方法定期应用损害。在DamageCharacter()
的实现中,我们将利用:yield return new WaitForSeconds()
将执行暂停一段指定的时间。
DamageCharacter()方法
将以下导入添加到类的顶部:
using System.Collections;
我们需要导入System.Collections
来使用协程。
在敌人类内部实现DamageCharacter()
方法:
// 1
public override IEnumerator DamageCharacter(int damage, float interval)
{
// 2
while (true)
{
// 3
hitPoints = hitPoints - damage;
// 4
if (hitPoints <= float.Epsilon)
{
// 5
KillCharacter();
break;
}
// 6
if (interval > float.Epsilon)
{
yield return new WaitForSeconds(interval);
}
else
{
// 7
break;
}
}
}
// 1
当在一个派生(继承)类中实现一个abstract
方法时,使用override
关键字来指示该方法正在从基类(父类)中覆盖KillCharacter()
方法。
这个方法需要两个参数:damage
和interval
。Damage
是对角色造成的伤害量,interval
是施加damage
之间等待的时间。传递一个interval
= 0,正如我们将看到的,将造成damage
一次,然后返回。
// 2
这个while()
循环将继续施加damage
直到角色死亡,或者如果interval
= 0,它将break
并返回。
// 3
从电流hitPoints
中减去damage
的量,并将结果设置为hitPoints
。
// 4
调整敌人的hitPoints
后,我们想检查一下hitPoints
是否小于 0。然而,hitPoints
的类型是:float
,由于floats
的实现方式,浮点运算容易出现舍入误差。因此,在某些情况下,最好将float
值与float.Epsilon
值进行比较,后者定义为当前系统中“大于零的最小正值”。为了敌人的生死,如果hitPoints
小于float.Epsilon
,那么这个角色的生命值为零。
// 5
如果hitPoints
小于float.Epsilon
(实际上为 0),那么敌人已经被击败。呼叫KillCharacter()
然后脱离while()
循环。
// 6
如果interval
大于float.Epsilon
,那么我们要执行yield
,等待interval
秒,然后继续执行while()
循环。在这种情况下,循环只有在角色死亡时才会退出。
// 7
如果interval
不大于float.Epsilon
(实际上等于 0),那么这个break
语句将被命中,while()
循环将被中断,方法将返回。参数interval
在伤害不连续的情况下将为零,比如单次命中。
让我们实现 Character 类中声明的其余抽象方法。
在敌人阶层:
ResetCharacter()
Lets build out the method to set the Character variables back to their original state. It's important to do this if we want to use the Character object again after it dies. This method can also be used to set up the variables when the Character is first created.
// 1
public override void ResetCharacter()
{
// 2
hitPoints = startingHitPoints;
}
// 1
因为敌人类继承自角色类,所以我们在父类中override
声明ResetCharacter()
。
// 2
重置角色时,将当前生命值设置为startingHitPoints
。我们在 Unity 编辑器中将startingHitPoints
设置在预置本身上。
在 OnEnable()中调用 ResetCharacter()
敌方职业继承自角色,角色继承自MonoBehaviour
。OnEnable()
方法是MonoBehaviour
类的一部分。如果OnEnable()
是在一个类中实现的,它将在每次一个对象被激活时被调用。我们将使用OnEnable()
来确保每次敌方目标被激活时都会发生一些事情。
private void OnEnable()
{
// 1
ResetCharacter();
}
// 1
调用我们刚刚写的方法重置敌人。目前,“重置”敌人仅仅意味着将hitPoints
设置为startingHitPoints
,但是我们也可以在ResetCharacter()
中包含其他东西。
KillCharacter()
因为我们已经在角色类中将KillCharacter()
实现为一个virtual
方法,而敌人继承自角色,所以不需要在敌人类中实现它。除了角色实现提供的功能之外,敌人不需要任何额外的功能。
更新播放器类
接下来,我们将在 Player 类中实现抽象方法。在 Visual Studio 中打开 Player 类,并使用下面的代码实现 Character 父类的抽象方法。
将以下导入添加到类的顶部:
using System.Collections;
然后将下面的方法添加到 Player 类中:
// 1
public override IEnumerator DamageCharacter(int damage, float interval)
{
while (true)
{
hitPoints.value = hitPoints.value - damage;
if (hitPoints.value <= float.Epsilon)
{
KillCharacter();
break;
}
if (interval > float.Epsilon)
{
yield return new WaitForSeconds(interval);
}
else
{
break;
}
}
}
// 1
实现 DamageCharacter()方法,就像我们在敌人类中做的那样。
public override void KillCharacter()
{
// 1
base.KillCharacter();
// 2
Destroy(healthBar.gameObject);
Destroy(inventory.gameObject);
}
// 1
使用base
关键字来引用当前类继承的父类或“基类”。调用base.KillCharacter()
会调用父类中的KillCharacter()
方法。父KillCharacter()
方法销毁当前与玩家关联的gameObject
。
// 2
摧毁玩家的生命值和物品。
重构预设实例化
在第六章中,我们在Start()
方法中初始化了生命条和库存预置的实例。这是在我们有方法之前:ResetCharacter()
。从Start()
上取下以下三条线,放入ResetCharacter()
内,如下图所示:
从Start()
中删除这三行:
inventory = Instantiate(inventoryPrefab);
healthBar = Instantiate(healthBarPrefab);
healthBar.character = this;
然后创建方法ResetCharacter()
,如下所示,在角色父类中覆盖方法abstract
:
public override void ResetCharacter()
{
// 1
inventory = Instantiate(inventoryPrefab);
healthBar = Instantiate(healthBarPrefab);
healthBar.character = this;
// 2
hitPoints.value = startingHitPoints;
}
// 1
我们从 Start()方法中删除的三行代码。这三行代码初始化并设置健康栏和库存。
// 2
将玩家的生命值设定为起始生命值。记住——因为起始生命值是公开的,我们可以在 Unity 编辑器中设置它。
回顾
让我们回顾一下我们刚刚构建的内容:
-
角色类为我们游戏中所有不同的角色类型提供了基本的功能,包括玩家和他的敌人。
-
角色类功能包括:
-
杀死一个角色的基本功能
-
重置角色的抽象方法定义
-
损坏角色的抽象方法定义
-
利用我们已经建立的
我们已经构建了一些非常好的核心功能,但是我们实际上还没有使用它。敌人有可以伤害玩家的方法,但是他们现在没有被调用。为了查看DamageCharacter()
和KillCharacter()
方法的运行情况,我们将向敌人类添加功能,当玩家遇到敌人类时,敌人类将调用 DamageCharacter()方法。
在敌人类中,将这两个变量添加到类的顶部:
// 1
public int damageStrength;
// 2
Coroutine damageCoroutine;
// 1
在 Unity 编辑器中设置,这个变量将决定敌人碰到玩家时会造成多大的伤害。
// 2
对正在运行的协程的引用可以保存到一个变量中,并在以后停止。我们将使用damageCoroutine
来存储对DamageCharacter()
协程的引用,这样我们可以在以后停止它。
二维 oncollisionenter
OnCollisionEnter2D()
是一个包含在所有 MonoBehaviours 中的方法,每当当前对象Collider2D
与另一个Collider2D
接触时,Unity 引擎就会调用它。
// 1
void OnCollisionEnter2D(Collision2D collision)
{
// 2
if(collision.gameObject.CompareTag("Player"))
{
// 3
Player player = collision.gameObject.GetComponent<Player>();
// 4
if (damageCoroutine == null)
{
damageCoroutine = StartCoroutine(player.DamageCharacter(damageStrength, 1.0f));
}
}
}
// 1
碰撞细节作为参数:collision
,传入OnCollisionEnter2D()
。
// 2
我们想写游戏逻辑,让敌人只能伤害玩家。对比敌人碰撞过的物体上的标签,看看是不是玩家物体。
// 3
此时,我们已经确定另一个对象是播放器,因此检索对播放器组件的引用。
// 4
查看这个敌人是否已经在运行DamageCharacter()
协程。如果不是,那么在播放器对象上启动协程。传入DamageCharacter()``damageStrength
和interval
,因为只要它们接触,敌人就会持续伤害玩家。
我们正在做一件前所未见的事情。我们在变量damageCoroutine
中存储了对正在运行的协程的引用。我们可以调用StopCoroutine()
并给它传递参数:damageCoroutine
,以便随时停止协程。
oncollonixis 2d
当另一个对象的Collider2D
停止接触当前 MonoBehaviour 对象的Collider2D
时,调用OnCollisionExit2D()
。
// 1
void OnCollisionExit2D(Collision2D collision)
{
// 2
if (collision.gameObject.CompareTag("Player"))
{
// 3
if (damageCoroutine != null)
{
// 4
StopCoroutine(damageCoroutine);
damageCoroutine = null;
}
}
}
// 1
碰撞细节作为参数:collision
,传入OnCollisionEnter2D()
。
// 2
检查敌人停止碰撞的物体上的标签,看看它是否是玩家物体。
// 3
如果damageCoroutine
不为空,这意味着协程正在运行,应该停止,然后设置为null
。
// 4
停止实际上是DamageCharacter()
的damageCoroutine
,并将其设置为null
。这将立即停止协程。
配置敌人脚本
翻回到 Unity 编辑器,配置敌人脚本,如图 7-12 所示。记住伤害强度就是敌人碰到她会对玩家造成多大的伤害。
图 7-12
配置敌人脚本
按下播放,并步行到一个敌人产卵点的球员。让玩家撞上一个敌人,你会注意到玩家受到一些伤害,但也会把敌人推开。这是因为玩家和敌人都有 RigidBody2D 组件附着在他们身上,并且受 Unity 的物理引擎控制。
最终敌人会追着玩家跑,但是现在,把敌人逼到墙角,保持和它的联系。观察生命值下降到 0,直到物品、生命值和玩家从屏幕上消失。
摘要
我们的样本游戏真的开始走到一起了。我们已经为整个游戏中的各种类型的角色创建了一个架构,并在这个过程中获得了一些关于使用 C# 的指导。我们的游戏现在有一个中央游戏管理器,负责设置场景,生成玩家,并确保相机设置正确。我们已经学习了如何编写代码来编程控制摄像机,而以前我们必须通过 Unity 编辑器来设置摄像机。我们构建了一个 Spawn Point 来生成不同的角色类型,并学习了协程,这是 Unity 开发人员工具箱中的一个重要工具。**
八、人工智能和弹弓
这一章涵盖了很多,但是到最后,你会有一个游戏的功能原型。我们将构建一些有趣的功能,如具有追逐行为的可重用人工智能组件。我们勇敢的玩家也将最终得到她选择的武器:一把弹弓,用来保护自己。您将学习一种在游戏编程中广泛使用的优化技术,称为对象池,并运用一些您从未想过会用到的高中数学知识。本章还演示了混合树的使用,这是一种更有效的制作动画的方式,从长远来看,对你的游戏架构更好。最后,我们将向您展示如何在 Unity 之外编译您的游戏,并谈一谈您的游戏编程冒险的下一步。
游走算法
在这一节中,我们将利用我们所学的协程编写一个脚本,让敌人在棋盘上随机游走。如果敌人察觉到玩家就在附近,敌人就会追击她,直到她逃跑,杀死敌人,或者玩家死亡。
Wander 算法听起来可能很复杂,但是当我们一步一步地分解它时,你会发现它是完全可以实现的。
图 8-1 是游走算法的示意图。我们将分阶段实现每个部分,并在过程中进行解释,这样您就不会感到不知所措。
图 8-1
游走算法
入门指南
选择敌人的预设,并将其拖入场景中,使我们的生活更容易。选择 EnemyObject 并向其添加 CircleCollider2D 组件。选中圆形碰撞器上的 Is 触发框,将碰撞器的半径设置为:1。圆形碰撞器应该看起来像图 8-2 。
图 8-2
设置是触发器和半径
这个圆形对撞机代表了敌人能“看”多远。换句话说,当玩家的对撞机穿越圆形对撞机时,敌人可以看到玩家。记住触发碰撞器是如何工作的:因为我们已经检查了圆形碰撞器上的是触发框,它可以穿过其他物体。敌人会“看到”玩家穿越对撞机,然后改变航向,追击她。
创建漫游脚本
我们将创建一个单行为的漫游脚本,这样它就可以被重复使用,并在将来附加到敌人以外的其他游戏对象上。
添加一个新的脚本,名为:“Wander”。在 Visual Studio 中打开该脚本,并添加以下内容:
// 1
using System.Collections;
using UnityEngine;
// 2
[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(CircleCollider2D))]
[RequireComponent(typeof(Animator))]
public class Wander : MonoBehaviour
{
}
// 1
我们将在 Wander 算法中使用协程和IEnumerator
。正如在第七章中提到的,IEnumerator
是System.Collections
名称空间的一部分,所以我们在这里导入它。
// 2
确保我们将来附加漫游脚本的任何游戏对象都有一个Rigidbody2D
、一个CircleCollider2D
和一个Animator
。这三个组件都是 Wander 脚本所必需的。
通过使用RequireComponent
,这个脚本附加到的任何脚本将自动添加所需的组件(如果它还不存在的话)。
漂移变量
接下来我们将勾画出游走算法所需的变量。将以下变量添加到 Wander 类中:
// 1
public float pursuitSpeed;
public float wanderSpeed;
float currentSpeed;
// 2
public float directionChangeInterval;
// 3
public bool followPlayer;
// 4
Coroutine moveCoroutine;
// 5
Rigidbody2D rb2d;
Animator animator;
// 6
Transform targetTransform = null;
// 7
Vector3 endPosition;
// 8
float currentAngle = 0;
// 1
这三个变量将用于设置敌人追击玩家的速度,不追击时的一般游荡速度,以及将是前两个速度之一的当前速度。
// 2
The directionChangeInterval
通过 Unity 编辑器设置,将用于确定敌人应该多久改变一次游荡方向。
// 3
这个脚本可以附加到游戏中的任何角色上,添加流浪行为。你可能希望最终创造一个不追逐玩家而只是四处游荡的角色。可以设置followPlayer
标志来开启和关闭玩家追逐行为。
// 4
变量 moveCoroutine 是我们保存对当前运行的移动协程的引用的地方。这个协程将负责在每一帧中向目的地移动敌人一点点。我们需要保存对协程的引用,因为在某个时候我们需要停止它,为此我们需要一个引用。
// 5
附加在游戏对象上的刚体 2D 和动画。
// 6
We use targetTransform
敌人追击玩家时。该脚本将从 PlayerObject 中检索转换,并将其分配给targetTransform
。
// 7
敌人游荡的目的地。
// 8
当选择一个新的方向漫游时,一个新的角度将添加到现有的角度。该角度用于生成一个矢量,该矢量成为目的地。
构建开始()
现在我们已经有了目前需要的所有变量,让我们构建 Start()方法。
void Start()
{
// 1
animator = GetComponent<Animator>();
// 2
currentSpeed = wanderSpeed;
// 3
rb2d = GetComponent<Rigidbody2D>();
// 4
StartCoroutine(WanderRoutine());
}
// 1
抓取并缓存当前游戏对象的动画组件。
// 2
将当前速度设置为wanderSpeed
。敌人开始悠闲地游荡。
// 3
我们需要参考Rigidbody2D
来实际移动敌人。存储一个引用,而不是每次需要时都检索它。
// 4
启动WanderRoutine()
协程,这是 Wander 算法的入口点。接下来我们写WanderRoutine()
。
流浪的协程
除了追踪逻辑外,WanderRoutine()
协程包含本章前面图 8-1 中描述的 Wander 算法的所有高级逻辑。我们仍然需要编写一些从WanderRoutine()
内部调用的方法,但是这个协程是 Wander 算法的大脑。
// 1
public IEnumerator WanderRoutine()
{
// 2
while (true)
{
// 3
ChooseNewEndpoint();
//4
if (moveCoroutine != null)
{
// 5
StopCoroutine(moveCoroutine);
}
// 6
moveCoroutine = StartCoroutine(Move(rb2d, currentSpeed));
// 7
yield return new WaitForSeconds(directionChangeInterval);
}
}
// 1
这个方法是一个协程,因为它无疑会在多个框架上运行。
// 2
我们希望敌人无限期地游荡,所以我们将使用 while(true)来无限期地循环这些步骤。
// 3
ChooseNewEndpoint()
方法确实如其名。它会选择一个新的终点,但不会让敌人朝它移动。接下来我们将编写这个方法。
// 4
通过检查moveCoroutine
是否为null
或是否有值来检查敌人是否已经在移动。如果它有一个值,那么敌人可能正在移动,所以我们需要在移动到新的方向之前先阻止它。
// 5
停止当前运行的运动协程。
// 6
启动Move()
协程,并在moveCoroutine
中保存对它的引用。Move()
协程负责实际移动敌人。我们很快就会写出来。
// 7
让协程执行directionChangeInterval
秒,然后重新开始循环并选择一个新的端点。
选择新端点
我们已经写出了起点和 Wander 协程,所以是时候开始填充由WanderCoroutine()
调用的方法了。ChooseNewEndpoint()
方法负责随机选择一个新的终点供敌人行进。
// 1
void ChooseNewEndpoint()
{
// 2
currentAngle += Random.Range(0, 360);
// 3
currentAngle = Mathf.Repeat(currentAngle, 360);
// 4
endPosition += Vector3FromAngle(currentAngle);
}
// 1
通过省略访问修饰符使这个方法私有,因为它只在 Wander 类中需要。
// 2
选择一个 0 到 360 之间的随机值来表示新的行进方向。该方向以角度表示,单位为度。我们把它加到当前角度。
// 3
方法Mathf.Repeat(currentAngle, 360)
将循环值:currentAngle
,使其永远不会小于 0,也不会大于 360。我们有效地将新角度保持在 0 到 360 度的范围内,然后用结果替换currentAngle
。
// 4
调用一个方法将角度转换成一个Vector3
,并将结果添加到endPosition
。变量endPosition
将被Move()
协程使用,我们很快就会看到。
角度到弧度到矢量!
该方法以度为单位获取一个角度参数,将其转换为弧度,并返回一个由ChooseNewEndpoint()
使用的方向向量 3。
Vector3 Vector3FromAngle(float inputAngleDegrees)
{
// 1
float inputAngleRadians = inputAngleDegrees * Mathf.Deg2Rad;
// 2
return new Vector3(Mathf.Cos(inputAngleRadians), Mathf.Sin(inputAngleRadians), 0);
}
// 1
通过乘以角度到弧度的转换常量,将输入角度从角度转换为弧度。Unity 提供了这个常量,所以我们可以快速转换。
// 2
使用输入角度(以弧度为单位)创建敌人方向的归一化方向向量。
敌人行走动画
到目前为止,敌人只有一个动画:闲置。是时候利用我们在第三章创建的敌人行走动画剪辑了。
选择敌人预设,然后打开动画窗口,如图 8-3 所示。
图 8-3
选择了敌人对象的动画窗口
如果空闲状态是默认状态,它将显示为橙色。如果不是默认状态,右击“敌人-闲置-1”状态,选择:设置为层默认状态。
正如你所看到的,敌人-步行-1 状态是存在的,有一个动画剪辑,但目前没有被使用。计划是创建一个动画参数,并使用该参数在空闲和行走状态之间切换。
点击动画师参数部分的加号,选择 Bool,如图 8-4 所示。
图 8-4
选择 Bool 以创建类型为 Bool 的动画参数
将该参数命名为“isWalking”,如图 8-5 所示。
图 8-5
创建 isWalking Bool 参数
我们的漫游脚本将使用这个参数在空闲和行走之间切换敌人的动画状态。为了保持简单,行走动画将作为追逐玩家时跑步以及悠闲行走的替身。
右键点击敌人-闲置-1 状态并选择:进行转换。创建空闲状态和行走状态之间的转换。然后在行走状态和空闲状态之间创建另一个转换。当你完成后,动画状态窗口应该如图 8-6 所示。
图 8-6
创建空闲和行走状态之间的转换
点击从敌人-闲置-1 到敌人-步行-1 的转换状态,并使用以下设置,如图 8-7 所示。
图 8-7
过渡设置
点击从敌人-步行-1 到敌人-空闲-1 的转换,并使用图 8-7 中的相同设置进行配置。
设置每个过渡以使用我们刚刚创建的动画参数:isWalking。设置条件:isWalking 为真,如图 8-8 所示,从敌方空闲-1 过渡到敌方步行-1。
图 8-8
如果 isWalking == true,则满足此条件
在敌人-步行-1 到敌人-空闲-1 的转换中,将 isWalking 设置为 false。
就这样!敌人行走动画设置完毕。要使用新的动画状态,我们只需要在我们的Move()
协程中将isWalking
改为true
,你很快就会看到。
在检查器中点击“应用”,将这些改变应用到所有敌人的预设上。
Move()协程
Move()
协程负责将给定speed
处的刚体 2D 从其当前位置移动到endPosition
变量。
将以下方法添加到 Wander 脚本中。
public IEnumerator Move(Rigidbody2D rigidBodyToMove, float speed)
{
// 1
float remainingDistance = (transform.position - endPosition).sqrMagnitude;
// 2
while (remainingDistance > float.Epsilon)
{
// 3
if (targetTransform != null)
{
endPosition = targetTransform.position;
}
// 4
if (rigidBodyToMove != null)
{
// 5
animator.SetBool("isWalking", true);
// 6
Vector3 newPosition = Vector3.MoveTowards(rigidBodyToMove.position, endPosition, speed * Time.deltaTime);
// 7
rb2d.MovePosition(newPosition);
// 8
remainingDistance = (transform.position - endPosition).sqrMagnitude;
}
// 9
yield return new WaitForFixedUpdate();
}
// 10
animator.SetBool("isWalking", false);
}
// 1
等式:(transform.position – endPosition)
产生一个向量 3。我们使用一个名为sqrMagnitude
的属性,它在 Vector3 类型上可用,来检索敌人当前位置和目的地之间的大致剩余距离。使用sqrMagnitude
属性是 Unity 提供的执行快速矢量幅度计算的方法。
// 2
检查当前位置和终点位置之间的剩余距离是否大于等于零的float.Epsilon,
。
// 3
当敌人正在追击玩家时,值targetTransform
将被设置为玩家变形而不是空值。然后我们覆盖了endPosition
的原始值,使用targetTransform
来代替。敌人移动的时候会朝着玩家移动,而不是朝着原来的endPosition
移动。因为targetTransform
实际上是玩家的变身,它会随着玩家新的位置不断更新。这使得敌人可以动态地跟随玩家。
// 4
Move()
方法需要一个RigidBody2D
,用它来移动敌人。在我们继续之前,确保我们确实有一个RigidBody2D
要移动。
// 5
将Bool
类型的动画参数isWalking
设置为true
。这将启动状态转换到行走状态,并播放敌人行走动画。
// 6
Vector3.MoveTowards
方法用于计算刚体 2D 的运动。它实际上并没有移动刚体 2D。该方法采用三个参数:当前位置、结束位置和在帧中移动的距离。记住变量:speed
会变,取决于敌人是在追击还是悠闲的在场景周围徘徊。这个值将在追踪代码中改变,我们还没有写出来。
// 7
使用MovePosition()
将刚体 2D 移动到新位置,在前一行中计算。
// 8
使用sqrMagnitude
属性更新剩余距离。
// 9
直到下一次固定帧更新。
// 10
敌人已经到达endPosition
等待选择新的方向,所以将动画状态改为空闲。
保存这个脚本并切换回 Unity 编辑器。
配置漫游脚本
选择敌人的预设,并配置漫游脚本,看起来像图 8-9 。将追踪速度设置为比漫游速度稍快的速度。方向改变间隔是漂移算法调用ChooseNewEndpoint()
选择新的漂移方向的频率。
图 8-9
在漫游脚本中使用这些设置
在检查器中按“应用”,然后从层次视图中删除 EnemyObject。
现在按播放键。注意敌人是如何在场景中游荡的。如果玩家走近敌人,他们还不会追击她。接下来我们要添加追踪逻辑。
二维标记()
因此,除了追踪逻辑,我们已经实现了几乎所有的 Wander 算法。在这一节中,我们将编写一些简单的逻辑来插入游走算法,让敌人追击玩家。
追踪逻辑依赖于OnTriggerEnter2D()
方法,每个单行为都提供了这个方法。正如我们在第五章中了解到的,触发碰撞器(设置了 Is Trigger 属性的碰撞器)可以用来检测另一个游戏对象进入碰撞器。当这种情况发生时,会对冲突中涉及的 MonoBehaviours 调用OnTriggerEnter2D()
方法。
当玩家进入附属于敌人的 CircleCollider2D 时,敌人可以“看见”玩家,应该会追击她。
让我们写下这个逻辑。
void OnTriggerEnter2D(Collider2D collision)
{
// 1
if (collision.gameObject.CompareTag("Player") && followPlayer)
{
// 2
currentSpeed = pursuitSpeed;
// 3
targetTransform = collision.gameObject.transform;
// 4
if (moveCoroutine != null)
{
StopCoroutine(moveCoroutine);
}
// 5
moveCoroutine = StartCoroutine(Move(rb2d, currentSpeed));
}
}
// 1
检查碰撞中对象上的标签,查看它是否是 PlayerObject。还要检查followPlayer
当前是否为真。该变量通过 Unity 编辑器设置,用于打开和关闭追踪行为。
// 2
在这一点上,我们已经确定collision
和玩家在一起,所以将currentSpeed
改为pursuitSpeed
。
// 3
设置targetTransform
等于玩家的变换。Move()
协程将检查targetTransform
是否不为空,然后将其作为 endPosition 的新值。敌人就是这样不断的追击玩家,而不是漫无目的的游荡。
// 4
如果敌人正在移动,moveCoroutine
将不会为空。需要在再次启动之前停止它。
// 5
因为endPosition
现在被设置为玩家对象的变换,调用Move()
会将敌人移向玩家。
2d control success()
如果敌人pursuitSpeed
比玩家movementSpeed
小,玩家可以跑得比任何敌人都快。随着玩家逃离敌人,她将退出敌人触发碰撞器,导致OnTriggerExit2D()
被调用。当这种情况发生时,敌人实际上失去了玩家的视线,并继续漫无目的地游荡。
这种方法几乎与OnTriggerEnter2D()
相同,只是做了一些调整。
void OnTriggerExit2D(Collider2D collision)
{
// 1
if (collision.gameObject.CompareTag("Player"))
{
// 2
animator.SetBool("isWalking", false);
// 3
currentSpeed = wanderSpeed;
// 4
if (moveCoroutine != null)
{
StopCoroutine(moveCoroutine);
}
// 5
targetTransform = null;
}
}
// 1
检查标签,看看玩家是否正在离开碰撞器。
// 2
敌人在失去玩家的视线后感到困惑,并停顿了一会儿。将isWalking
设置为 false,将动画更改为 idle。
// 3
将currentSpeed
设置为wanderSpeed
,下次敌人开始移动时使用。
// 4
因为我们希望敌人停止追击玩家,所以我们需要阻止moveCoroutine
。
// 5
敌人不再跟踪玩家,所以将targetTransform
设置为null
。
保存这个脚本并返回到 Unity 编辑器。按播放。
将玩家移动到敌人的视野中,注意敌人将如何追击她,直到她跑出视野。
小发明
Unity 支持可视化调试和设置工具 Gizmos 的创建。这些工具是通过一组方法创建的,并且只出现在 Unity 编辑器中。当你的游戏在用户的硬件上编译和运行时,它们不会出现在你的游戏中。
我们将创建两个小发明来帮助可视化调试 Wander 算法。我们将创建的第一个小发明将显示圆形对撞机 2D 的电线轮廓,用于检测玩家何时在敌人的视线范围内。这个小发明将使人们更容易看到追逐行为应该何时开始。
将以下变量添加到 Wander 类的顶部,这里有其他变量:
CircleCollider2D circleCollider;
然后给Start()
加上下面一行。它可以放在方法中的任何位置:
circleCollider = GetComponent<CircleCollider2D>();
这一行检索当前敌人对象的CircleCollider2D
组件。我们将使用它在屏幕上画一个圆,直观地表示当前的圆形碰撞器。
要实现 Gizmo,实现 MonoBehaviour 提供的名为OnDrawGizmos()
的方法:
void OnDrawGizmos()
{
// 1
if (circleCollider != null)
{
// 2
Gizmos.DrawWireSphere(transform.position, circleCollider.radius);
}
}
// 1
在我们尝试使用它之前,确保我们有一个圆形碰撞器的参考。
// 2
调用Gizmos.DrawWireSphere()
并为其提供位置和半径,绘制一个球体。
保存脚本并返回到 Unity 编辑器。确保 Gizmos 按钮已按下,然后按播放。当敌人四处游荡时,注意敌人周围的小玩意,如图 8-10 所示。这个小控件的周长和位置对应于CircleCollider2D
。
图 8-10
代表敌人周围的CircleCollider2D
的小发明
如果你没有看到圆形小控件出现,确保你在游戏窗口的右上角启用了小控件,如图 8-11 所示。
图 8-11
启用小控件
如果我们有一条显示敌人目的地的线,就更容易看到游走算法如何将敌人移向一个位置。让我们在屏幕上从当前敌人位置到终点位置画一条线。
我们将使用Update()
方法,这样每一帧都会画出一条线。
void Update()
{
// 1
Debug.DrawLine(rb2d.position, endPosition, Color.red);
}
// 1
当启用小控件时,方法Debug.DrawLine()
的结果是可见的。该方法获取当前位置、结束位置和线条颜色。
在图 8-12 中我们可以看到,从敌人的中心到目的地(endPosition)画了一条红线。
图 8-12
从敌人阵地到终点画了一条红线
自卫
我们勇敢的玩家除了用智慧引导她和用弹弓防御之外,将一无所有。每按一次鼠标按钮,我们的玩家就会向鼠标点击的位置发射一发弹弓子弹。我们将编写弹药的行为脚本,这样当它在空中飞行时,它会沿着一条弧线而不是直线飞向目标。
需要的类别
我们需要三个不同职业的组合来给玩家保护自己的能力。
武器类将封装弹弓的功能。这个职业将附属于玩家预设,并负责一些不同的事情:
-
确定何时按下鼠标按钮,并使用按钮按下的位置作为目标
-
从当前动画切换到射击动画
-
制造弹药并向目标移动
我们需要一个类来表示弹弓发射的弹药。这个弹药班将负责:
-
确定附加的弹药游戏对象何时与敌人发生碰撞
-
记录它与敌人碰撞时造成的伤害
我们还将构建一个 Arc 类,负责以夸张的弧线将弹药游戏对象从起始位置移动到结束位置。否则弹药会直线前进。
弹药等级
目前,我们希望游戏中的弹药只能伤害敌人,但是你也可以在将来很容易地扩展这个功能来伤害其他东西。每个 AmmoObject 将在 Unity 编辑器中显示一个属性,描述它造成的伤害。我们将把这个氨物体变成一个预制体。如果你想给玩家提供两种不同类型的弹药,创建第二个弹药预置,改变上面的精灵和造成的伤害是一个简单的任务。
在项目层次中创建一个新的游戏对象,并将其重命名为“AmmoObject”。我们将创建 AmmoObject,配置它,编写脚本,然后将它变成一个预置。
导入资产
从你下载的资源中,将标题为“Ammo.png”的 spritesheet 拖到资源➤ Sprites ➤对象文件夹中。
选择弹药 spritesheet,并在检查器中使用以下导入设置:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:单个
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
确保选择了底部的默认按钮,并将压缩设置为:无
按下应用按钮。
Unity 编辑器将自动检测精灵的边界,所以没有必要打开精灵编辑器或切片精灵。
添加组件,设置层
将 Sprite 渲染器组件添加到 AmmoObject。
在 Sprite 渲染器上,将排序层设置为:Characters,并将 Sprite 属性设置为:Ammo。弹药是我们刚刚进口的雪碧。
向 AmmoObject 添加 CircleCollider2D。确保选中“触发”设置,并将半径设置为 0.2。如果你需要调整碰撞器,点击编辑碰撞器按钮,移动手柄直到你满意碰撞器包围弹药精灵。
创建一个名为“弹药”的新层,并用它来设置 AmmoObject 上的层,如图 8-13 所示。
图 8-13
将层设置为:弹药
更新层碰撞矩阵
如果你还记得在第五章中,我们学习了基于层的碰撞检测。总而言之,只有当层碰撞矩阵被配置为相互感知时,不同层中的两个碰撞器才会相互作用。
进入编辑菜单➤项目设置➤物理 2D,配置图层碰撞矩阵,如图 8-14 。
图 8-14
配置弹药层
我们想让一个弹药碰撞机与一个敌人的碰撞机相互作用,但不与任何其他碰撞机相互作用。回到第五章,我们配置了敌人来使用敌人层,我们也配置了 AmmoObject 来使用弹药层。
构建弹药脚本
给 AmmoObject 添加一个名为“弹药”的新脚本。在 Visual Studio 中打开弹药脚本。
使用下面的代码来构建弹药类。
using UnityEngine;
public class Ammo : MonoBehaviour
{
// 1
public int damageInflicted;
// 2
void OnTriggerEnter2D(Collider2D collision)
{
// 3
if (collision is BoxCollider2D)
{
// 4
Enemy enemy = collision.gameObject.GetComponent<Enemy>();
// 5
StartCoroutine(enemy.DamageCharacter(damageInflicted, 0.0f));
// 6
gameObject.SetActive(false);
}
}
}
// 1
弹药对敌人造成的伤害。
// 2
当另一个物体进入弹药游戏物体的触发碰撞器时调用。触发器碰撞器只是一个设置了:Is Trigger 属性的碰撞器。在这种情况下,它是一个CircleCollider2D
。
// 3
重要的是检查我们是否击中了敌人内部的BoxCollider2D
。记住敌人也有一个CircleCollider2D
,它在漫游脚本中用来探测玩家是否在附近。BoxCollider2D
是我们用来探测与敌人实际碰撞的物体的对撞机。
// 4
从collision
中检索gameObject
的敌方脚本组件。
// 5
启动协程来伤害敌人。如果您还记得第七章中的,那么DamageCharacter()
的方法签名如下所示:
DamageCharacter(int damage, float interval)
第一个参数:damage
,是对敌人造成的伤害量。第二个参数:interval
,是施加damage
之间等待的时间。通过interval
= 0 将造成damage
一次。我们将变量damageInflicted
作为第一个参数传递,它是弹药类的一个实例变量,将通过 Unity 编辑器设置。
// 6
因为弹药已经击中了敌人,所以将 AmmoObject 的gameObject
设置为非活动状态。
为什么我们要将gameObject
设置为非活动状态,而不是调用Destroy(gameObject)
并完全删除它?
好问题——很高兴你问了。我们将 AmmoObject 设置为非活动状态,这样我们就可以使用一种叫做对象池的技术来保持游戏的良好性能。
在我们忘记之前...使氨对象成为预设的
在我们进入对象池之前,最后一件事——让我们把 AmmoObject 变成一个预置。遵循同样的过程,我们总是用游戏对象来创建预置:
-
从层次视图拖动一个对象到预设文件夹来创建一个预设。
-
从层次视图中删除原始 AmmoObject。
对象池
如果你的游戏有大量的对象在短时间内被实例化然后销毁,你可能会看到游戏暂停,速度变慢,整体性能下降。这是因为在 Unity 中实例化和销毁对象比简单地激活和停用对象更消耗性能。销毁一个对象将调用 Unity 的内部内存清理过程。短时间内重复调用这个过程,尤其是在内存受限的环境中,比如移动设备或 web,会影响性能。这些对性能的影响不会随着对象数量的减少而显现出来,但是如果你的游戏需要制造大量的敌人或子弹,你就需要考虑一个更优化的方法。
为了避免与对象创建和销毁相关的性能问题,我们将使用一种称为对象池的优化技术。要使用对象池,请提前为场景预实例化一个对象的多个副本,取消激活它们,然后将它们添加到对象池中。当场景需要一个对象时,遍历对象池并返回找到的第一个非活动对象。当场景使用完该对象后,将其置于非活动状态,并将其返回到对象池,以便场景将来重用。
简而言之,对象池重用对象,最大限度地减少由于运行时内存分配和清理导致的性能下降。对象最初将被设置为非活动状态,只有在使用时才被激活。当使用一个对象完成场景时,该对象再次被设置为非活动状态,表明它可以在需要时被重用。
通过反复点击鼠标按钮,弹弓武器将快速连续发射多发子弹。这是一个教科书式的场景,对象池可以提高运行时性能。
以下是在 Unity 中使用对象池的三个关键步骤:
-
在需要对象之前,预先实例化对象的集合(一个“池”),并将其设置为非活动状态
-
当游戏需要一个对象时,不要实例化一个新的对象,从池中抓取一个不活动的对象并激活它
-
使用完对象后,只需将其置于非活动状态,即可将其放回池中
建造武器类
我们将在武器类中创建并存储弹药对象池。如前所述,这个类将包含弹弓功能,并最终控制显示玩家发射弹弓的动画。
我们将通过创建用来存放弹药的对象池来开始构建基本的弹弓功能。
选择 PlayerObject 预设,并添加一个名为“武器”的新脚本。在 Visual Studio 中打开此脚本。使用下面的代码开始构建武器类。
// 1
using System.Collections.Generic;
using UnityEngine;
// 2
public class Weapon : MonoBehaviour
{
// 3
public GameObject ammoPrefab;
// 4
static List<GameObject> ammoPool;
// 5
public int poolSize;
// 6
void Awake()
{
// 7
if (ammoPool == null)
{
ammoPool = new List<GameObject>();
}
// 8
for (int i = 0; i < poolSize; i++)
{
GameObject ammoObject = Instantiate(ammoPrefab);
ammoObject.SetActive(false);
ammoPool.Add(ammoObject);
}
}
}
// 1
我们需要导入System.Collections.Generic
,这样我们就可以使用List
数据结构。List
类型的变量将用于表示对象池——预实例化对象的集合。
// 2
武器继承自MonoBehaviour
,因此可以附加到游戏对象上。
// 3
属性ammoPrefab
将通过 Unity 编辑器设置,并用于实例化 AmmoObject 的副本。这些副本将被添加到Awake()
方法中的对象池中。
// 4
类型为List
的属性ammoPool
用于表示对象池。
C# 中的List
是强类型对象的有序集合。因为它们是强类型的,所以您必须提前声明List
将保存什么类型的对象。试图插入任何其他类型的对象将导致编译时出错,您的游戏将无法运行。这个List
被宣布只持有GameObjects
。
变量ammoPool
是一个静态变量。如果您回忆一下第七章中的,static
变量属于类本身,并且只有一个副本存在于内存中。
// 5
属性允许我们设置对象池中预实例化的对象数量。因为这个属性是public
,所以可以通过 Unity 编辑器进行设置和调整。
// 6
创建对象池和预初始化 AmmoObjects 的代码将包含在Awake()
方法中。Awake()
在脚本的生命周期中被调用一次:当脚本被加载时。
// 7
检查ammoPool
对象池是否已经初始化。如果它还没有被初始化,创建一个新的类型为List
的ammoPool
来保存GameObjects
。
// 8
使用poolSize
作为上限创建一个循环。在循环的每次迭代中,实例化一个新的ammoPrefab
副本,将其设置为非活动的,并将其添加到ammoPool
。
对象池(ammoPool
)已经创建好,可以在场景中使用了。你很快就会看到,每当玩家用弹弓发射弹药时,我们会从ammoPool
中抓取一个不活动的 AmmoObject 并激活它。当场景使用 AmmoObject 完成后,它被停用并返回到ammoPool
。
根除方法
方法存根是尚未开发的代码的替代品。它们还有助于找出特定功能所需的方法。让我们为其余的基本武器功能列出我们需要的各种方法。
将以下代码添加到武器类中。
// 1
void Update()
{
// 2
if (Input.GetMouseButtonDown(0))
{
// 3
FireAmmo();
}
}
// 4
GameObject SpawnAmmo(Vector3 location)
{
// Blank, for now...
}
// 5
void FireAmmo()
{
// Blank, for now...
}
// 6
void OnDestroy()
{
ammoPool = null;
}
// 1
在Update()
方法中,检查每一帧,看看用户是否点击了鼠标来发射弹弓。
// 2
GetMouseButtonDown()
方法是输入类的一部分,接受单个参数。这个方法将检查鼠标左键是否被点击和释放。方法参数0
表示我们对第一个(左)鼠标按钮感兴趣。如果我们对鼠标右键感兴趣,我们将传递值:1
。
// 3
因为已经单击了鼠标左键,所以调用我们将要编写的FireAmmo()
方法。
// 4
SpawnAmmo()
方法将负责从对象池中检索并返回一个 AmmoObject。该方法采用一个参数:location
,指示实际放置检索到的 AmmoObject 的位置。SpawnAmmo()
返回一个GameObject
——从ammoPool
对象池中检索到的激活的 AmmoObject。
// 5
FireAmmo()
将负责将 AmmoObject 从在SpawnAmmo()
中产生的起始位置移动到鼠标按钮被点击的结束位置。
// 6
设置ammoPool = null
销毁对象池并释放内存。OnDestroy()
方法是MonoBehaviour
自带的,当附属的GameObject
被销毁时会被调用。
产卵弹药法
SpawnAmmo 方法将遍历预先实例化的 AmmoObjects 的集合或“池”,并找到第一个非活动对象。然后它将激活 AmmoObject,设置transform
。position
,然后返回 AmmoObject。如果不存在非活动的 AmmoObjects,则返回null
。因为弹药池是用设定数量的 AmmoObjects 初始化的,所以一次可以出现在屏幕上的 AmmoObjects 的数量有一个固有的限制。这个限制可以通过改变 Unity 编辑器中的poolSize
来调整。
小费
找出在对象池中预实例化的对象的理想数量的最好方法是经常玩这个游戏,然后相应地调整这个数量。
让我们在武器类中实现 SpawnAmmo()方法。
public GameObject SpawnAmmo(Vector3 location)
{
// 1
foreach (GameObject ammo in ammoPool)
{
// 2
if (ammo.activeSelf == false)
{
// 3
ammo.SetActive(true);
// 4
ammo.transform.position = location;
// 5
return ammo;
}
}
// 6
return null;
}
// 1
在预先实例化的对象池中循环。
// 2
检查当前对象是否处于非活动状态。
// 3
我们发现了一个不活动的对象,所以将其设置为活动的。
// 4
将对象上的transform.position
设置为参数:location
。当我们调用SpawnAmmo()
时,我们将传递一个location
,让它看起来像是从弹弓中射出的氨物体。
// 5
返回活动对象。
// 6
找不到非活动对象,因此当前正在使用池中的所有对象。返回null
。
弧类和线性插值
Arc 脚本将负责实际移动 AmmoObject。我们希望弹药沿着弧线飞向目标。我们将创建一个名为“Arc”的新 MonoBehaviour 来包含此功能。因为我们将 Arc 创建为一个独立的 MonoBehaviour,所以我们可以在将来将这个脚本附加到其他游戏对象上,使它们也能以弧形运行。
为了简单起见,我们将首先实现沿直线行进的 Arc 脚本。在我们做好工作后,我们将添加一个小的调整来使弹药以一个好看的弧线运行。
在项目视图中选择 AmmoObject 预设,并添加一个名为“Arc”的新脚本。在 Visual Studio 中打开 Arc 脚本,并编写以下代码:
using System.Collections;
using UnityEngine;
// 1
public class Arc : MonoBehaviour
{
// 2
public IEnumerator TravelArc(Vector3 destination, float duration)
{
// 3
var startPosition = transform.position;
// 4
var percentComplete = 0.0f;
// 5
while (percentComplete < 1.0f)
{
// 6
percentComplete += Time.deltaTime / duration;
// 7
transform.position = Vector3.Lerp(startPosition, destination, percentComplete);
// 8
yield return null;
}
// 9
gameObject.SetActive(false);
}
}
// 1
因为 Arc 是一个单体行为,所以它可以附加到游戏对象上。
// 2
是沿着弧线移动游戏对象的方法。将TravelArc()
设计成协程是有意义的,因为它将在几个帧的过程中执行。TravelArc()
带两个参数:destination
和duration
。定义如下:destination
是结束位置,duration
是将附属gameObject
从起始位置移动到destination
所需的时间。
// 3
抓取当前游戏对象的transform.position
并将其分配给startPosition
。我们将在位置计算中使用startPosition
。
// 4
percentComplete
用于本方法后面使用的Lerp
或线性插值计算。我们将解释它的用法。
// 5
检查percentComplete
是否小于 1.0。把 1.0 想象成 100%的十进制形式。我们只希望这个循环运行到percentComplete
是 100%。当我们在下一行解释线性插值时,这将是有意义的。
// 6
我们希望将 AmmoObject 平稳地移向它的目的地。每一帧弹药移动的距离取决于我们希望移动持续的时间,以及已经过去的时间。
自上一帧以来经过的时间量除以运动的总期望持续时间,等于总持续时间的百分比。
Take a look at this line again: percentComplete += Time.deltaTime / duration;
Time.deltaTime
是自绘制最后一帧以来经过的时间。这一行中的结果:percentageComplete
,是我们将总持续时间的百分比与之前完成的百分比相加得到的结果,从而得到到目前为止已经完成的持续时间的总百分比。
我们将在下一行中使用这个总完成百分比来平滑地移动 AmmoObject。
// 7
为了实现一个效果,即一个物体以恒定的速度在两点之间平滑移动,我们使用了一种在游戏编程中广泛使用的技术,叫做线性插值。线性插值需要起始位置、结束位置和百分比。当我们使用线性插值来确定每帧要行进的距离时,线性插值方法的百分比参数:Lerp()
,是完成时长的百分比(percentComplete)
)。
在Lerp()
方法中使用持续时间percentComplete
意味着无论我们在哪里发射 AmmoObject,都需要相同的时间到达那里。这对于现实世界的模拟来说显然是不现实的,但是对于电子游戏来说,我们可以暂停现实世界的规则。
基于这个百分比,Lerp()
方法将返回起点和终点之间的一个点。我们将结果赋给 AmmoObject 的transform.position
。
// 8
暂停协程的执行,直到下一帧。
// 9
如果电弧已经到达其目的地,关闭附加的gameObject
。
别忘了保存这个脚本!
屏幕点数和世界点数
在我们写下一个方法之前,我们应该谈谈屏幕点和世界点。
屏幕空间是屏幕上实际可见的空间,以像素为单位定义。例如,我们的屏幕空间目前是 1280 × 720 或水平 1280 像素垂直 720 像素。
世界空间是真实的游戏世界,没有大小限制。它的大小理论上是无限的,用单位来定义。当我们在第四章中设置 PPU 时,我们配置了摄像机来将世界单位映射到屏幕单位。
当我们在游戏中移动物体时,因为它们可以移动到任何地方,而不仅限于在屏幕上移动,所以我们相对于世界空间移动它们。Unity 提供了一些从屏幕转换到世界空间的简便方法。
火弹药法
现在我们已经构建了移动 AmmoObject 的 Arc 组件,切换回武器类,让我们使用下面的代码实现FireAmmo()
方法。
首先,将下面的变量添加到武器类的顶部,在poolSize
变量之后。这个变量将用于设置弹弓发射弹药的速度:
public float weaponVelocity;
然后使用下面的代码实现FireAmmo()
方法:
void FireAmmo()
{
// 1
Vector3 mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
// 2
GameObject ammo = SpawnAmmo(transform.position);
// 3
if (ammo != null)
{
// 4
Arc arcScript = ammo.GetComponent<Arc>();
// 5
float travelDuration = 1.0f / weaponVelocity;
// 6
StartCoroutine(arcScript.TravelArc(mousePosition, travelDuration));
}
}
// 1
因为鼠标使用屏幕空间,我们将鼠标位置从屏幕空间转换到世界空间。
// 2
通过SpawnAmmo()
方法从弹药对象池中获取一个激活的 AmmoObject。传递当前武器的transform.position
作为取回 AmmoObject 的起始位置。
// 3
检查以确保SpawnAmmo()
返回了一个 AmmoObject。记住,如果所有预实例化的对象都已经被使用了,那么SpawnAmmo()
可能会返回null
。
// 4
检索对 AmmoObject 的Arc
组件的引用,并将其保存到变量arcScript
。
// 5
数值weaponVelocity
将在 Unity 编辑器中设置。用 1.0 除以weaponVelocity
得到一个分数,我们将把这个分数用作一个氨物体的移动持续时间。例如,1.0 / 2.0 = 0.5,所以弹药将需要半秒钟穿过屏幕到达目的地。
这个公式的结果是,当目的地较远时,弹药的速度会加快。想象一个玩家向附近的东西开火的场景。如果我们不能保证无论距离远近,飞行时间总是 0.5 秒,那么子弹很可能会从弹弓中快速射向敌人,以至于你真的看不到它。如果我们制作一个第一人称射击游戏,这可能是好的。但是在我们的 RPG 中,我们希望随时都能看到弹弓发射的弹药。这样看起来更“有趣”。
// 6
调用我们之前在arcScript
写的 TravelArc 方法。召回方法签名:TravelArc(Vector3 destination, float duration)
。对于destination
参数,传递鼠标点击的位置。对于duration
参数,传递我们在前一行中计算的travelDuration that
:
float travelDuration = 1.0f / weaponVelocity;
回想一下,TravelArc()
中的duration
参数用于确定 AmmoObject 从起始位置移动到destination
需要多长时间。我们将在下一步配置武器脚本时设置weaponVelocity
的值。
配置武器脚本
我们快完成了!在玩家使用弹弓之前,还需要整理一些东西。保存武器脚本,切换到 Unity 编辑器,并选择 PlayerObject。因为我们已经将武器脚本添加到 PlayerObject 中,所以将 AmmoObject 预设拖到武器脚本的弹药预设属性中。如图 8-15 所示,设置池大小为 7,武器速度为 2。
图 8-15
配置武器脚本
我们选择用0.5
来表示武器的速度,因为这感觉像是弹弓子弹飞行的自然时间。你可以随意调整这个值,让它看起来自然有趣。
我们准备好出发了。按下 Play 并点击一个敌人发射弹弓和像素化的死亡雨。
太棒了!弹弓发射弹药,但它不是以弧线运行。让我们解决这个问题。
形成电弧
切换回 Visual Studio 中的 Arc 脚本。我们将稍微调整一下脚本,使弧线脚本名副其实,实际上是沿着弧线轨迹行进。
修改 Arc 脚本中的while()
循环,如下所示:
while (percentComplete < 1.0f)
{
// Leave this existing line alone.
percentComplete += Time.deltaTime / duration;
// 1
var currentHeight = Mathf.Sin(Mathf.PI * percentComplete);
// 2
transform.position = Vector3.Lerp(startPosition, destination, percentComplete) + Vector3.up * currentHeight;
// Leave these existing lines alone.
percentComplete += Time.deltaTime / duration;
yield return null;
}
// 1
为了理解这里发生的事情,我们需要一点高中三角学的知识。波的“周期”是完成一个完整周期所需的时间。根据图 8-16 ,正弦波的周期为(2 * π),正弦波的一半周期正好为(π)。
图 8-16
正弦曲线
通过将(percentComplete
× Mathf.PI)
的结果传递给正弦函数,我们有效地每隔duration
秒沿着正弦曲线移动 PI 距离。结果被分配给currentHeight
。
// 2
Vector3.up
是单位提供的变量,表示 Vector3(0,1,0)。将Vector3.up * currentHeight
与Vector3.Lerp()
的结果相加,调整位置,使 AmmoObject 不再沿直线移动,而是沿 Y 轴向上然后向下朝着endPosition
移动。
保存脚本,返回 Unity 编辑器,然后按 Play。发射弹弓,注意它是如何以弧线飞行的。
你会注意到,当玩家发射弹弓时,我们实际上并没有播放任何类型的射击动画。我们将在下一节中解决这个问题。
制作弹弓动画
我们已经创建了一个武器,并编写了开火的代码,但玩家看起来有点奇怪,因为她只是站在那里,看着弹药神秘地出现并飞向目标。在这一节中,我们将构建播放玩家发射弹弓的动画的功能。您还将学习一种简化动画状态管理的新方法。
为了简单起见,我们首先将这种新的状态管理方法应用于行走动画,因为我们已经熟悉了状态机的工作方式,以及动画应该是什么样子。一旦我们适应了新的方法,我们将把它应用于发射弹弓。
动画和混合树
回到第三章,我们为玩家设置了一个动画状态机,由包含动画剪辑的动画状态组成。这些状态通过转换连接起来,我们通过在 Animator 组件上设置动画参数来控制转换。
玩家的状态机目前类似于图 8-17 。
图 8-17
播放器动画状态机
因为玩家可以向四个不同的方向行走,所以她也可以向四个不同的方向发射弹弓。如果我们为四个发射方向添加另外四个动画状态,这个状态机将开始看起来相当拥挤。如果我们最终想要向状态机添加更多的状态,事情将很快变得难以管理,视觉上令人困惑,并降低整体开发速度。
幸运的是,Unity 为我们提供了一个解决方案—输入:混合树。
混合树木
游戏编程经常需要在两个动画之间混合,例如当一个角色在行走时,然后逐渐开始奔跑。混合树可用于将多个动画平滑地混合成一个平滑的动画。虽然我们不会在游戏中混合多个动画,但是混合树还有一个我们将要用到的次要用途。
当用作动画状态机的一部分时,混合树可用于平滑地从一个动画状态过渡到另一个动画状态。混合树可以将各种动画捆绑到一个节点中,使您的游戏架构更加清晰和易于管理。混合树由在 Unity 编辑器中配置并在代码中设置的变量控制。
我们将创建两个混合树。由于我们已经熟悉了行走动画状态机,我们创建的第一个混合树将用于重新创建行走状态。我们还将更新玩家的 MovementController 代码来使用这个混合树。重建熟悉的东西将是一个很好的方式来适应混合树。
一旦我们有了行走混合树,我们将添加四个射击状态作为他们自己的射击混合树,并更新武器类来使用射击混合树。
清理动画师
是时候告别陈旧的动画状态管理方式了。选择 PlayerObject,打开 Animator 视图。从动画状态机中删除四个原始玩家行走状态。移除任何状态和空闲状态之间的转换,因为我们也不再需要它了。
当你完成后,动画视图应该如图 8-18 所示。
图 8-18
移除了旧玩家行走状态的动画视图
我们将创建一个混合树节点,作为其中各种行走动画状态的容器。包含所有四个行走动画的混合树节点将在 Animator 视图中显示为单个节点。可以想象,随着状态数量的增加,这种方法使得开发人员更容易可视化和管理状态。
构建行走混合树
-
在 Animator 窗口中右键单击并选择:从新混合树创建状态➤。
-
选择创建的混合节点,并在检查器中将其名称更改为:“行走树”。
-
双击“行走树”节点以查看混合树图形。
混合树应该如图 8-19 所示。
图 8-19
空混合树形图
图 8-22
混合树中包含四个动画剪辑的四个运动
- 再添加三个动作,并添加以下动画剪辑:玩家走南、玩家走西、玩家走北,如图 8-22 所示。
图 8-21
在运动中使用 player-walk-east 动画剪辑
- 在“选择运动选择器”打开的情况下,选择 player-walk-east 动画剪辑。运动现在应该如图 8-21 所示。
图 8-20
单击点以打开“选择运动选择器”
-
选择混合树节点,并将检查器中的混合类型更改为:2D 简单方向。完成混合树的配置后,我们将讨论更多的混合类型。
-
选择混合树节点,右键单击,然后选择:添加运动。一个动作保存一个动画剪辑的引用和相应的输入参数。当我们为过渡使用混合树时,输入参数用于确定应该播放什么运动。
-
在检查器中,单击我们刚刚添加的运动旁边的点(图 8-20 )以打开选择运动选择器。
添加完所有四个动作后,动画窗口应该如图 8-23 所示。每个运动都显示为混合树节点的子节点。
图 8-23
具有四个运动节点的混合树,包含动画剪辑
一层层,一直往下
我们在这里所做的是将所有四个动画状态包装到一个容器中——一个混合树节点。该混合树节点位于基础层的子层内。如果你点击 Animator 视图左上角的基础层按钮,如图 8-24 所示,Animator 视图将返回到“基础层”并显示一个混合树节点。当使用动画师时,如果适合你的架构,你可以在层内嵌套层。
图 8-24
单击“基础层”按钮返回到基础 Animator 视图
正如我们在图 8-25 中看到的,这种简化的管理状态的方法将使你的游戏架构在未来保持整洁和易于管理。行走混合树是动画器中的一个节点。
图 8-25
Animator 中带有单个混合树(行走树)节点的基础层
关于混合类型的注记
混合类型用于描述混合树应该如何混合运动。如你所知,我们实际上并没有混合运动,所以术语混合类型有点误导。我们在它们之间转换,所以我们配置了混合树来使用 2D 简单方向混合。这种混合类型有两个参数,最适合表示不同方向的动画,例如向北走、向南走等等。因为我们使用混合树在向北、向南、向东和向西行走之间进行过渡,所以 2D 简单方向混合非常适合我们的用例。
动画参数
我们过去曾经使用过动画参数,当我们第一次为播放器配置动画状态机并创建“Animation State”参数时。
删除 Animator 窗口左侧的 AnimationState 参数。我们已经删除了依赖它的动画过渡。我们将用混合树和它自己的参数替换这个参数和相关的状态。这些参数将用于我们将在武器类中编写的代码中。
创建这三个动画参数。大写很重要,因为我们将在代码中引用它们:
-
类型的 is walking:Bool
-
xDir 类型:Float
-
yDir 类型:Float
参数:Blend 是在创建动画师时创建的。请随意删除该参数,因为我们不需要它。
动画师的动画参数部分应该如图 8-26 所示。
图 8-26
行走混合树的新动画参数
小费
创建动画参数时,一个常见的错误来源是使用错误的数据类型创建它们。
使用参数
选中混合树,从检查器的下拉菜单中选择 xDir 和 yDir 参数,如图 8-27 所示。我们将在下一步中使用这两个参数。
图 8-27
从下拉菜单中选择参数:xDir 和 yDir
选择“混合树”节点后,查看检查器中参数下方的可视化窗口。将多个运动添加到混合树后,可视化窗口将自动出现。
想象一个(0,0)穿过窗口中心的笛卡尔坐标平面(图 8-28 )。四个坐标(1,0)、(0,-1)、(-1,0)和(0,1)可以相应地映射到下面的虚线的末端。可视化窗口的目的是帮助开发人员可视化配置。
图 8-28
想象一个笛卡尔坐标平面
在图 8-28 中,有四个蓝点聚集在 0,0 处,你看不到它们,因为它们被红色的中心点遮住了。这些点中的每一个都代表了我们之前添加的四个动作中的一个。
设置第一个动作的位置 X 和位置 Y,使代表玩家向东走动作的蓝点位于位置:(1,0),如图 8-29 所示。
图 8-29
为所有四个动作设置位置 X 和 Y
我们还想相应地设置其他三个运动的 X 和 Y 位置。例如,玩家向南行走的运动位置应该设置为(0,-1)。如图 8-29 所示设置所有四个动作的位置。
好吧,但是为什么是?
因此,我们已经设置了混合树来使用我们的动画参数,并注意为每个动作设置位置 X 和位置 Y,但是对来说是什么呢?
正如我们在本节开始时提到的,我们可以通过在 animator 组件上设置变量来管理混合树中的 2D 状态转换。这就好比我们在第三章动画状态机上设置变量一样。
换句话说,要使用混合树,我们将编写类似下面的代码。目前不要在任何类中编写这段代码——这只是出于说明的目的。
// 1
movement.x = Input.GetAxisRaw("Horizontal");
movement.y = Input.GetAxisRaw("Vertical");
// 2
animator.SetBool("isWalking", true);
// 3
animator.SetFloat("xDir", movement.x);
animator.SetFloat("yDir", movement.y);
// 1
从用户那里获取输入值。变量:movement
的类型为:Vector2
。
// 2
设置动画参数:isWalking
,表示玩家正在行走。这将过渡到行走混合树。
// 3
设置混合树用于过渡到特定运动的动画参数。这些是类型:Float
,因为机芯Vector2
包含了Floats
。
当用户向右按压时,输入值将是(0,1)。我们在 Animator 上设置这个,混合树播放玩家向右走的动画剪辑。
循环时间
选择混合树的四个子节点中的每一个,如果默认情况下没有选中,检查循环时间属性,如图 8-30 所示。该属性告诉动画制作者在这种状态下连续循环播放动画剪辑。
图 8-30
检查循环时间属性
如果我们不选中这个框,动画将播放一次,然后停止。
创建过渡
最后但同样重要的是,我们需要创建空闲状态和新的行走混合树之间的过渡。
右键单击动画器中的空闲状态节点,并选择:进行过渡。将过渡连接到行走混合树。选择过渡并使用以下设置:
-
具有退出时间:未选中
-
固定持续时间:未选中
-
过渡持续时间:0
-
过渡偏移:0
-
中断源:无
使用我们创建的isWalking
变量创建一个条件。设置为:true
。
在行走混合树和空闲状态之间创建另一个过渡。选择过渡并使用与前面相同的设置,除了当您创建isWalking
条件时,将其设置为:false
。
更新运动控制器
是时候使用行走混合树了。打开 MovementController 类。
从 MovementController 中删除以下所有代码,因为我们不再需要它:
string animationState = "AnimationState";
并删除整个CharStates
枚举:
enum CharStates
{
walkEast = 1,
walkSouth = 2,
// etc
}
将现有的UpdateState()
方法替换为:
void UpdateState()
{
// 1
if (Mathf.Approximately(movement.x, 0) && Mathf.Approximately(movement.y, 0))
{
// 2
animator.SetBool("isWalking", false);
}
else
{
// 3
animator.SetBool("isWalking", true);
}
// 4
animator.SetFloat("xDir", movement.x);
animator.SetFloat("yDir", movement.y);
}
// 1
检查运动向量是否大约等于 0,表明玩家是静止不动的。
// 2
因为玩家是站着不动的,所以把isWalking
设为false
。
// 3
否则movement.x
、movement.y
,或者两者都是非零数字,说明玩家在移动。
// 4
用新的移动值更新animator
。
保存这个脚本并切换回 Unity 编辑器。按下播放键,让玩家在场景中走动。您已经摆脱了旧的动画状态,并使用混合树重建了行走动画。
导入战斗精灵
第一步是导入用于玩家战斗动画的精灵。将名为“PlayerFight32x32.png”的 spritesheet 拖动到 sprites 播放器文件夹中。
选择玩家战斗画面,并在检查器中使用以下导入设置:
-
纹理类型:精灵(2D 和用户界面)
-
精灵模式:多重
-
每单位像素:32
-
过滤器模式:点(无过滤器)
-
确保选择了底部的默认按钮,并将压缩设置为:无
按下应用按钮,然后打开精灵编辑器。
从“切片”菜单中,选择“按单元大小划分网格”,并将像素大小设置为 32。按下应用并关闭精灵编辑器。
创建动画剪辑
下一步是创建动画剪辑。在前面的章节中,我们通过为动画的每一帧选择精灵来创建动画剪辑,然后将它们拖动到游戏对象上。Unity 会自动创建一个动画剪辑并添加一个动画控制器(如果还没有的话)。
这次我们将创建一个稍微不同的动画剪辑,因为我们将创建一个混合树来管理动画。
转到精灵➤播放器文件夹,展开我们刚刚切片的精灵工作表。选择前四帧,如图 8-31 所示。这些精灵对应于玩家拉回弹弓并发射它。
图 8-31
在项目视图中选择前四个玩家战斗精灵
右键选择创建➤动画,如图 8-32 所示。
图 8-32
手动创建动画
将创建的动画重命名为:“玩家-火-东”。选择接下来的四个精灵,并遵循相同的步骤。将生成的动画命名为:“玩家-火-西”。
开火北动画只有两帧:“PlayerFight32x32_8”和“PlayerFight32x32_9”。使用这些帧来创建“玩家-火-北”。
击南动画有三帧:“PlayerFight32x32_10”、“PlayerFight32x32_11”、“PlayerFight32x32_12”。使用那些帧来创建“玩家-火-南方”。
将我们刚刚创建的所有动画剪辑移动到动画➤动画文件夹。
建立战斗混合树
-
不要选中混合树子节点中的循环时间框。我们只想播放一次射击动画。
-
创建空闲状态和新的火焰混合树之间的过渡。选择过渡并使用以下设置:
-
具有退出时间:未选中
-
固定持续时间:未选中
-
过渡持续时间:0
-
过渡偏移:0
-
中断源:无
-
图 8-34
为每个动作设置位置 X 和位置 Y
- 为每个动作设置位置 X 和位置 Y,如图 8-34 所示。
图 8-33
配置动画参数
-
在 Animator 窗口中右键单击并选择:从新混合树创建状态➤。
-
选择创建的混合节点,并在检查器中将其名称更改为:“火树”。
-
双击“火焰树”,在它自己的层上查看混合树图形。
-
选择混合树节点,并将检查器中的混合类型更改为:2D 简单方向。
-
选择混合树节点,右键单击,然后选择:添加运动。
-
在检查器中,单击我们刚刚添加的运动旁边的点,以打开“选择运动选择器”。
-
选择 player-fire-east 动画剪辑。
-
再添加 3 个动作,并添加 player-fire-south、player-fire-west 和 player-fire-north 的动画剪辑。
-
创建以下动画参数:
isFiring
(类型:Bool
)、fireXDir
(类型:Float
)、fireYDir
(类型:Float
),删除混合参数。 -
配置混合树使用下拉框中的动画参数,如图 8-33 所示。
使用我们创建的isFiring
变量在转换中创建一个条件。设置为:true
。
- 在火焰混合树和空闲状态之间创建另一个过渡。选择过渡并使用与前面相同的设置,但有两处不同:
-
创建
isFiring
条件时,将其设置为:false
。 -
检查退出时间属性,并将退出时间的值设置为:1。
-
退出时间
过渡的“退出时间”属性用于告诉动画制作人,在动画播放了多少百分比之后,过渡才会生效。通过将“开火➤”空闲过渡的“退出时间”属性设置为:1,我们说我们希望在过渡前播放 100%的开火动画。
更新武器等级
下一步是更新武器类,以利用我们刚刚建立的火焰混合树。
将RequireComponent
属性添加到武器类的顶部:
[RequireComponent(typeof(Animator))]
public class Weapon : MonoBehaviour
我们将要添加的代码需要一个 Animator 组件,所以要确保总有一个可用的组件。
添加变量
我们需要一些额外的变量来激活玩家。将以下变量添加到武器类的顶部。
// 1
bool isFiring;
// 2
[HideInInspector]
public Animator animator;
// 3
Camera localCamera;
// 4
float positiveSlope;
float negativeSlope;
// 5
enum Quadrant
{
East,
South,
West,
North
}
// 1
描述玩家是否正在发射弹弓。
// 2
将[HideInInspector]
属性与public
访问器一起使用,这样就可以从这个类的外部访问 animator,但它不会显示在检查器中。没有理由在检查器中显示animator
,因为我们计划以编程方式检索对 Animator 组件的引用。
// 3
使用localCamera
保存一个对摄像机的引用,这样我们就不必每次需要时都检索它。
// 4
存储我们将在本章后面进行的象限计算中使用的两条线的斜率。
// 5
用于描述玩家射击方向的枚举。
开始()
添加Start()
方法,我们将使用它来初始化和设置在整个武器类中需要的变量。
void Start()
{
// 1
animator = GetComponent<Animator>();
// 2
isFiring = false;
// 3
localCamera = Camera.main;
}
// 1
通过获取对 Animator 组件的引用进行优化,这样我们就不必在每次需要时都检索它。
// 2
首先将isFiring
变量设置为false
。
// 3
获取并保存对本地摄像机的引用,这样我们就不必在每次需要时都检索它。
更新更新()
对Update()
方法做两个小的修改,如下所示:
void Update()
{
if (Input.GetMouseButtonDown(0))
{
// 1
isFiring = true;
FireAmmo();
}
// 2
UpdateState();
}
// 1
当鼠标左键被按下并抬起时,将isFiring
变量设置为true
。这个变量将在UpdateState()
方法中被检查。
// 2
UpdateState()
方法将更新每一帧的动画状态,不管用户是否按下了鼠标按钮。我们将很快编写这个方法。
确定方向
为了确定播放哪个动画剪辑,我们需要确定用户相对于播放器单击的方向。如果用户点击播放器的西边,只是为了播放向东发射弹弓的动画,这看起来不会很好。
为了确定用户点击的方向,我们将屏幕分成四个象限:北、南、东和西。我们应该认为所有的用户点击都是相对于玩家的,所以这四个象限都以玩家为中心,如图 8-35 所示。
图 8-35
基于当前玩家位置的四个象限
我们可以检查用户点击了哪个象限,以确定玩家发射弹弓的方向,以及要播放的正确动画剪辑。
根据玩家的位置将屏幕划分为象限是有意义的,但是我们实际上如何通过编程来确定用户点击了哪个象限呢?
回想一下你高中数学时代的斜率截距形式:
- y = mx + b,
其中:
m =斜率(可以是正斜率,也可以是负斜率)
x 和 y 是一个点的坐标
b =是y-截距,或直线与y-轴相交的点。
这种形式允许我们沿着一条线找到任何一点。正如我们在图 8-35 中看到的,我们通过将屏幕分成象限创建了两条线。如果我们想象用户在屏幕上的任何一点点击鼠标,我们可以想象另一组两条线从点击的点出现。
诀窍是:我们可以根据鼠标点击的正斜线是在玩家的正斜线之上还是之下来确定用户点击了哪个象限。同样,我们检查鼠标点击的负斜线是高于还是低于玩家的负斜线。
看一下图 8-36 以获得可视化的帮助。记住向上倾斜的线有一个正斜率,向下倾斜的线有一个负斜率。
图 8-36
点击西象限
两条斜率相等的直线意味着它们彼此平行。
为了检查一条线是否在另一条线之上,斜率相等,我们简单地比较它们的y-截距。如图 8-36 所示,如果鼠标点击线的y-截距在负玩家线之下,但在正玩家线之上,则用户点击了西象限。
关于这种方法,有一些事情你应该内化。如果玩家站在屏幕的正中央,每条线都会从一个角走到另一个角。当玩家在场景中移动时,线条也跟着移动。象限的可见大小发生了变化,但是划分屏幕的两条线的斜率保持不变。每条线的斜率保持不变,因为屏幕尺寸永远不会改变——只有她的位置会改变。
当我们编写代码时,我们将重新排列斜率截距形式 y = mx + b ,以便更容易比较 y 截距。因为我们在比较 y 截距,我们需要求解 b 。所以重排后的形式是:b = y–MX。
让我们继续写代码。
斜率法
给定一条直线上的两点,计算直线斜率的标准方程为:(y2–y1)/(x2–x1)= m,其中 m =斜率。
写出来,那就是:第二个y-坐标减去第一个y-坐标,除以第二个x-坐标减去第一个x-坐标。
将以下方法添加到武器类以计算直线的斜率:
float GetSlope(Vector2 pointOne, Vector2 pointTwo)
{
return (pointTwo.y - pointOne.y) / (pointTwo.x - pointOne.x);
}
计算斜率
让我们使用GetSlope()
方法。将以下内容添加到Start()
方法中。
// 1
Vector2 lowerLeft = localCamera.ScreenToWorldPoint(new Vector2(0, 0));
Vector2 upperRight = localCamera.ScreenToWorldPoint(new Vector2(Screen.width, Screen.height));
Vector2 upperLeft = localCamera.ScreenToWorldPoint(new Vector2(0, Screen.height));
Vector2 lowerRight = localCamera.ScreenToWorldPoint(new Vector2(Screen.width, 0));
// 2
positiveSlope = GetSlope(lowerLeft, upperRight);
negativeSlope = GetSlope(upperLeft, lowerRight);
// 1
创建四个向量来代表屏幕的四个角。Unity 屏幕坐标(不同于我们用来创建清单和健康栏的 GUI 坐标)从左下角的(0,0)开始。
我们也将每个点在分配之前从屏幕坐标转换到世界坐标。我们这样做是因为我们将要计算的斜率将与玩家相关。玩家在世界空间中移动,世界空间使用世界坐标。正如我们在本章前面所描述的,世界空间是真实的游戏世界,在大小方面没有限制。
// 2
使用GetSlope()
方法得到每条线的斜率。一条线从左下角到右上角,另一条线从左上角到右下角。因为屏幕尺寸将保持不变,所以斜率也将保持不变。我们计算斜率并将结果保存到一个变量中,这样我们就不必在每次需要时重新计算。
比较y-截距
在HigherThanPositiveSlopeLine()
方法中,我们计算鼠标点击是否高于穿过玩家的正斜线。将以下内容添加到武器类。
bool HigherThanPositiveSlopeLine(Vector2 inputPosition)
{
// 1
Vector2 playerPosition = gameObject.transform.position;
// 2
Vector2 mousePosition = localCamera.ScreenToWorldPoint(inputPosition);
// 3
float yIntercept = playerPosition.y - (positiveSlope * playerPosition.x);
// 4
float inputIntercept = mousePosition.y - (positiveSlope * mousePosition.x);
// 5
return inputIntercept > yIntercept;
}
// 1
为清晰起见,保存对当前transform.position
的引用。这个脚本附加到玩家对象,所以这将是玩家的位置。
// 2
将鼠标位置inputPosition
转换到世界空间并保存一个参考。
// 3
稍微重排一下 y = mx + b 来求解 b 。这将很容易比较每条线的y-截距。这条线上的形式是:b = y–MX。
// 4
使用重新排列的形式:b = y–MX,找到inputPosition
(鼠标)创建的正斜线的y-截距。
// 5
比较鼠标点击的y-截距和穿过玩家的线的y-截距,如果鼠标点击更高则返回。
HigherThanNegativeSlopeLine()
除了我们将鼠标点击的 y 截距与穿过播放器的负斜线进行比较之外,HigherThanNegativeSlopeLine()
方法与HigherThanPositiveSlopeLine()
相同。将以下内容添加到武器类。
bool HigherThanNegativeSlopeLine(Vector2 inputPosition)
{
Vector2 playerPosition = gameObject.transform.position;
Vector2 mousePosition = localCamera.ScreenToWorldPoint(inputPosition);
float yIntercept = playerPosition.y - (negativeSlope * playerPosition.x);
float inputIntercept = mousePosition.y - (negativeSlope * mousePosition.x);
return inputIntercept > yIntercept;
}
我们将放弃对HigherThanNegativeSlopeLine()
方法的解释,因为它与前面的方法几乎相同。
GetQuadrant()方法
GetQuadrant()
方法负责确定用户点击了四个象限中的哪一个,并返回一个Quadrant
。它利用了我们之前编写的HigherThanPositiveSlopeLine()
和 HigherThanNegativeSlopeLine()方法。
// 1
Quadrant GetQuadrant()
{
// 2
Vector2 mousePosition = Input.mousePosition;
Vector2 playerPosition = transform.position;
// 3
bool higherThanPositiveSlopeLine = HigherThanPositiveSlopeLine(Input.mousePosition);
bool higherThanNegativeSlopeLine = HigherThanNegativeSlopeLine(Input.mousePosition);
// 4
if (!higherThanPositiveSlopeLine && higherThanNegativeSlopeLine)
{
// 5
return Quadrant.East;
}
else if (!higherThanPositiveSlopeLine && !higherThanNegativeSlopeLine)
{
return Quadrant.South;
}
else if (higherThanPositiveSlopeLine && !higherThanNegativeSlopeLine)
{
return Quadrant.West;
}
else
{
return Quadrant.North;
}
}
// 1
返回描述用户点击位置的象限。
// 2
抓取用户点击位置和当前玩家位置的引用。
// 3
检查用户是否单击了正斜线和负斜线的上方(高于)。
// 4
如果用户的点击不高于正斜线,但高于负斜线,则用户点击了东象限。如果这还不太有意义,请回头参考图 8-36 。
// 5
返回Quadrant.East
枚举。
其余的 if 语句检查剩余的三个象限并返回它们各自的Quadrant
值。
UpdateState()方法
UpdateState()
方法检查玩家是否开火,检查用户点击了哪个象限,并更新 Animator 以便混合树可以显示正确的动画剪辑。
void UpdateState()
{
// 1
if (isFiring)
{
// 2
Vector2 quadrantVector;
// 3
Quadrant quadEnum = GetQuadrant();
// 4
switch (quadEnum)
{
// 5
case Quadrant.East:
quadrantVector = new Vector2(1.0f, 0.0f);
break;
case Quadrant.South:
quadrantVector = new Vector2(0.0f, -1.0f);
break;
case Quadrant.West:
quadrantVector = new Vector2(-1.0f, 1.0f);
break;
case Quadrant.North:
quadrantVector = new Vector2(0.0f, 1.0f);
break;
default:
quadrantVector = new Vector2(0.0f, 0.0f);
break;
}
// 6
animator.SetBool("isFiring", true);
// 7
animator.SetFloat("fireXDir", quadrantVector.x);
animator.SetFloat("fireYDir", quadrantVector.y);
// 8
isFiring = false;
}
else
{
// 9
animator.SetBool("isFiring", false);
}
}
// 1
在Update()
方法中,我们检查用户是否点击了鼠标按钮。如果是,变量isFiring
被设置为等于true
。
// 2
创建一个Vector2
来保存我们将传递给混合树的值。
// 3
调用GetQuadrant()
来确定用户点击了哪个象限,并将结果分配给quadEnum
。
// 4
打开象限(quadEnum
)。
// 5
如果quadEnum
是东,在新的Vector2
中给quadrantVector
赋值(1,0)。
// 6
将动画师内部的isFiring
参数设置为true
,这样它会过渡到火焰混合树。
// 7
将 animator 中的fireXDir
和 fireYDir 变量设置为用户点击的象限的相应值。这些变量将被火焰混合树拾取。
// 8
将isFiring
设置回 false。动画将在停止之前一直播放,因为我们将过渡中的退出时间设置为 1。
// 9
如果isFiring
为假,将动画器中的isFiring
参数也设置为false
。
保存武器脚本并返回到 Unity 编辑器。
按下播放键,在场景周围的各个地方点击鼠标,发射弹弓。请注意玩家动画如何显示她向特定方向发射弹弓,然后返回空闲状态。
受损时的 flickr
当一个角色在电子游戏中被损坏时,有一个视觉效果来表示他们已经被损坏是很有帮助的。为了给我们的游戏增加一点光泽,让我们创建一个效果,将任何角色染成红色一会儿,也许是十分之一秒,以显示他们受伤了。这种闪烁效果会在几帧内发生,因此作为协程来实现是有意义的。
打开字符类,并在底部添加以下代码:
public virtual IEnumerator FlickerCharacter()
{
// 1
GetComponent<SpriteRenderer>().color = Color.red;
// 2
yield return new WaitForSeconds(0.1f);
// 3
GetComponent<SpriteRenderer>().color = Color.white;
}
// 1
将Color.red
分配给 SpriteRenderer 组件会将 sprite 染成红色。
// 2
产出执行 0.1 秒。
// 3
默认情况下,SpriteRenderer 使用白色的淡色。将 SpriteRenderer 色调更改回默认颜色。
更新玩家和敌人的职业
打开玩家和敌人类,更新每个类中的DamageCharacter()
方法如下。更新DamageCharacter()
时,务必将StartCoroutine
调用添加到while()
循环的顶部。
public override IEnumerator DamageCharacter(int damage, float interval)
{
while (true)
{
// 1
StartCoroutine(FlickerCharacter());
//... Pre-existing code
// 1
启动FlickerCharacter()
协程将字符暂时染成红色。
就这样!按下播放并向敌人发射弹弓。被击中时,它应该会短暂闪烁红色。如果一个敌人设法赶上玩家并伤害她,她也会闪烁红色。
为平台而建
在这一节,我们将学习如何编译你的游戏在 Unity 编辑器之外的几个平台上运行。
转到菜单栏中的文件➤构建设置。您应该会看到一个类似图 8-37 的屏幕。
图 8-37
构建设置屏幕
构建设置屏幕允许您选择目标平台,调整一些设置,选择要包含在构建中的场景,然后创建构建。如果您的游戏包含多个场景,请单击“添加开放场景”按钮来添加它们。
我们将选择 Mac OS X,但如果您在 PC 上工作,应该已经选择了它。
按下“构建”按钮。选择二进制文件的名称和保存位置,然后按 save 按钮。Unity 将创建构建,并在成功时通知您。
要玩游戏,请转到您保存游戏的位置,双击图标。当出现图 8-38 所示的屏幕时,确保为您使用的计算机选择正确的分辨率。如果你使用错误的分辨率,你的游戏可能会出现起伏。
图 8-38
为您的计算机选择分辨率
该屏幕还允许用户选择图形质量,如果他们有一台旧机器,这很重要。
按下播放!按钮来玩你的游戏!
退出游戏
天下没有不散的宴席,在某个时候,用户会想要退出你的游戏。在这一节中,我们将学习如何构建允许用户按 Escape 键退出游戏的功能。
当在 Unity 编辑器中玩游戏时,这种游戏结束功能将不起作用——它只适用于当你在编辑器外运行游戏时。
打开 RPGGameManager 类并添加以下内容:
void Update()
{
if (Input.GetKey("escape"))
{
Application.Quit();
}
}
Update()
方法将检查每一帧,看用户是否按了退出键。如果是这样,退出应用。
摘要
咻——这一章我们讲了很多。您已经使用协程构建了智能追逐行为,并且在这样做的过程中,为玩家构建了第一个真正的挑战。玩家现在可以死亡,需要能够保护自己,所以我们做了一个弹弓,可以向敌人发射弹药。slingshot 利用了一种广泛使用的优化技术,称为对象池。我们利用了一些高中水平的轨迹弧三角学。我们学习了混合树,以及如果我们想在未来添加额外的动画,它们如何帮助我们更好地组织我们的游戏架构和简化状态机。我们也知道了为 PC 或 Mac 开发游戏并在 Unity 之外运行它是多么简单。
你可能对如何改变和改进你的游戏有一些想法。伟大的事情是:你现在有这样做的技能!尝试、打破常规、修补脚本、阅读文档,并检查其他人的代码以从中学习。你能做什么的唯一限制是你愿意投入多少努力。
下一步是什么
你可能想知道接下来会发生什么——你如何提高你的游戏开发知识并开发出更好的游戏。一个很好的起点是参与游戏开发者社区。
团体
没有人天生是任何方面的专家。成为更好的开发人员的关键是向更有经验的开发人员学习。你永远不想成为房间里最好的开发者。如果你是,确保其他开发人员也很棒,这样你就可以向他们学习。
Meetup.com 是一个寻找每月游戏开发者聚会的好地方。Meetup 也有官方 Unity 用户组 meetup 的列表。可能你所在的城市有一个团结聚会,而你并不知道。世界各地都有官方的 Unity 用户组。如果你所在的城市或城镇没有当地的团结聚会,考虑开一个吧!
Discord 是一款专门为游戏玩家设计的语音和文本聊天应用。这也是一个虚拟会见开发者的好地方。不和谐社区可以回答问题,也可以与社区进行有益的互动。有时游戏开发者会创建他们自己的专用于他们游戏的 Discord 服务器,在那里他们收集反馈,收集 bug 报告,并分发早期版本。
如果不提及 Twitter,任何关于社区的讨论都是不负责任的。Twitter 有助于宣传和营销你的游戏,也有助于联系其他 Unity 开发者。
Reddit 维护了两个对游戏开发者有用的活动子 Reddit:/r/unity 2d和 /r/gamedev 。这些子 reddits 可以是一个很好的地方来发布你的工作演示和收集反馈,以及与其他热情的游戏开发者进行讨论。 /r/gamedev 子 reddit 也有自己的 Discord 服务器。
了解更多信息
Unity 在其网站 https://unity3d.com/learn/
上托管了大量频繁更新的教育内容。内容从绝对初学者到高级都有,一定要去看看。
这个网站: https://80.lv
,有游戏开发者感兴趣的各种主题的文章。一些文章是 Unity 特有的,而另一些是更通用的技术。
YouTube 也有助于学习新技术,尽管内容质量可能差异很大。在 YouTube 上可以很容易地找到过去 Unity 会议的许多演讲。
哪里可以找到帮助
每个人都会在某个时候遇到一个无论如何都无法解决的问题。对于这种情况,有几个重要的资源需要了解。
Unity Answers ( https://answers.unity.com
)是一个有用的资源,是为问答(Q & A)而不是扩展讨论而构建的。例如,一个问题的标题可能是:“调试这个运动脚本有问题。”
Unity 论坛( https://forum.unity.com
)是 Unity 员工和其他游戏开发者经常光顾的活跃留言板。论坛旨在围绕主题进行讨论,而不是简单的问答互动。你会发现很多有用的“有什么技术可以优化它”的讨论,比你在 Unity 的回答中发现的更多。
最后, https://gamedev.stackexchange.com
是 Q & A 网站的栈交换网络的一部分。它不像 Unity 网站那样繁忙,但如果你遇到问题,绝对值得你花时间。
游戏堵塞
游戏堵塞是构建视频游戏的黑客马拉松。他们通常有一个时间限制,比如 48 小时,这意味着给参与者施加压力,让他们只关注游戏中必要的东西,同时鼓励他们的创造力。游戏堵塞需要所有类型的参与者:艺术家、程序员、游戏设计师、声音设计师和作家。有时候游戏卡顿会有一个特定的主题,通常会提前保密。
游戏堵塞可以是一种奇妙的方式来满足本地(或远程)游戏开发商,推动自己,扩大你的知识,并带走(希望)一个完成的游戏。全球游戏大赛( https://globalgamejam.org
)是一年一度的全球游戏大赛,有世界各地的不同站点和数百名参与者。Ludum Dare ( https://ldjam.com
)是一个每四个月举办一次的周末游戏。如果你想看并制作一些令人惊奇的游戏,这两个游戏都是很好的参与方式。另一个找到在线游戏堵塞的好地方是 itch.io/jams.
新闻和文章
Gamasutra.com 是游戏新闻、工作和行业事件的旗手。另一个不错的网站是 indiegamesplus.com,有新闻、评论和对独立游戏开发者的采访。
游戏和资产
正如我们在第一章中提到的,Unity 资产商店包含数以千计的免费和付费游戏资产,以及脚本、纹理和着色器。关于资产商店,你应该注意的常见批评是,严格使用商店中的资产制作的游戏看起来“千篇一律”
Itch.io 是一个广为人知的发布独立游戏和资源的社区。你可以上传自己制作的游戏,免费玩其他独立游戏,或者通过购买其他开发者的游戏来支持他们。Itch.io 也是为你的游戏购买美术或声音资源的好地方。Gamejolt.com 类似于 itch.io,但完全专注于独立游戏,没有资产。
OpenGameArt.org 有大量用户上传的游戏作品,可以通过各种许可获得。
超越!
如果你已经和我在一起这么久了,那么你就有毅力通读一本几百页的编程书。这种坚韧将在游戏编程中很好地为你服务,因为尽管有大量的例子和书籍教授游戏编程的基础知识,但真正独特和有趣的游戏往往包含没有教程的元素。构建有趣的游戏可能非常困难,但很少有其他有创意的冒险是值得的。要想在游戏编程方面做得更好,最重要的是要记住继续做游戏!游戏开发就像任何其他学科一样——如果你坚持练习,总有一天你会回头看看你开始的地方,让自己大吃一惊。