Unity-安卓游戏开发示例初学者指南-全-
Unity 安卓游戏开发示例初学者指南(全)
原文:
zh.annas-archive.org/md5/d75c78708eb336bd69c1bca671f8f754译者:飞龙
前言
在这本书中,我们探索了不断扩大的移动游戏开发世界。使用 Unity 3D 和 Android SDK,我们将学习如何创建移动游戏的各个方面。每一章都探索了开发难题的另一部分。探索移动平台开发的特殊功能,书中的每个游戏都旨在提高你对这些功能的理解。我们还通过总共四个完整游戏和创建更多游戏所需的所有工具来结束这本书。
我们制作的第一款游戏是井字游戏。这款游戏的功能与经典的纸牌版相同。两名玩家轮流在一个网格中填充他们的符号;第一个连成三线的玩家获胜。这是我们探索 Unity 中图形界面选项的完美游戏。通过学习如何在这里向屏幕添加按钮、文本和图片,我们已经拥有了添加任何界面所需的所有理解和工具。
我们接下来要创建的游戏是坦克大战游戏。玩家控制坦克在城市中驾驶并射击目标和敌人。这款游戏分为三个章节,使我们能够探索为 Android 平台创建游戏的关键点。我们首先创建一个城市,并使用在制作井字游戏时学到的控制方法使玩家的坦克移动。我们还创建并动画化玩家将要射击的目标。在游戏的第二部分,我们添加了一些照明和特殊相机效果。到本章结束时,环境看起来很棒。在游戏的第三部分创建过程中,我们创建了一些敌人。利用 Unity 的力量,这些敌人会在整个城市中追逐玩家,并在靠近时攻击。
第三款即将完成的游戏是一个流行的移动游戏的简单克隆。利用 Unity 的物理系统,我们能够创建结构并向它们投掷小鸟。摧毁结构以获得分数,摧毁目标猪以赢得关卡。我们还花时间探索了一些 2D 游戏的具体功能,例如透视滚动背景,以及如何在 Unity 中实现它们。我们通过创建关卡选择菜单来完成这一章节和游戏。
最后,我们创建了太空战斗机游戏。这款游戏涉及使用移动设备的特殊输入来控制玩家的飞船。当玩家的设备倾斜时,他们可以操纵飞船。当他们触摸屏幕时,他们可以向敌舰和小行星射击。游戏的第二部分包括添加特殊效果,以完善每个游戏的外观。当飞船被摧毁时,我们创建爆炸效果,并为飞船添加引擎尾迹。我们还添加了射击和爆炸的声音效果。
本书以对优化的探讨作为结尾。我们探讨了 Unity 的所有优秀特性,甚至创建了一些我们自己的特性,以使我们的游戏运行得尽可能好。我们还花了一些时间来了解我们可以做些什么来最小化我们资产的大小,同时最大化它们在游戏中的外观和效果。在这个时候,我们的旅程结束了,但我们有四款即将上市的优秀游戏。
本书涵盖的内容
第一章, 向 Unity 和 Android 问好,探讨了 Android 平台和 Unity 3D 游戏引擎的功能列表,涵盖了为什么它们是开发的好选择。我们还将介绍设置开发环境,并为您的设备和模拟器创建一个简单的 Hello World 应用程序。
第二章, 看起来不错 – 图形用户界面,详细探讨了图形用户界面。通过创建一个井字棋游戏,我们在制作过程中学习了用户界面,并使其看起来令人愉悦。
第三章, 任何游戏的骨架 – 网格、材质和动画,探讨了网格、材质和动画。通过创建坦克大战游戏,我们涵盖了玩家在游戏中看到的核心内容。
第四章, 搭建舞台 – 摄像头效果和光照,解释了关于摄像头效果和光照的内容。通过添加阴影、光照贴图、距离雾和天空盒,我们的坦克大战环境变得更加动态。利用特殊的摄像头效果,我们为玩家创造了额外的反馈。
第五章, 四处走动 – 寻路和 AI,展示了在坦克大战游戏中创建移动敌人的过程。我们探讨了寻路和 AI,为玩家提供一个比静止的木偶更有意义的靶子。
第六章, 移动设备的特色 – 触摸和倾斜,涵盖了使现代移动设备特殊的功能。我们创建了一个 Space Fighter 游戏来理解触摸界面和倾斜控制。
第七章, 炫耀你的力量 – 物理和 2D 摄像头,展示了在短暂休息 Space Fighter 游戏后,创建了一个愤怒的小鸟克隆版。在这里,我们探讨了物理和 2D 摄像头效果。
第八章, 特效 – 声音和粒子,回到 Space Fighter 游戏,添加特效。声音效果和粒子的加入使我们能够创造一个更完整的游戏体验。
第九章,优化,涵盖了 Unity 3D 中的优化。我们讨论了将我们的坦克大战和太空战斗机游戏尽可能高效制作的利弊。
您需要这本书的内容
在这本书中,我们将同时使用 Unity 3D 游戏引擎和 Android。正如您在前一节中看到的,我们将在第一章,向 Unity 和 Android 问好中涵盖 Unity 和 Android SDK 的获取和安装。为了充分利用这本书,您需要访问一个 Android 设备;一部手机或平板电脑都可以很好地工作。为了简化,我们假设您正在使用 Windows 电脑。此外,本书中的代码是用 C#编写的,尽管每个章节的项目都有 JavaScript 版本可供参考。为了充分利用章节项目提供的模型,您需要 Blender,这是一个免费建模程序,可在www.blender.org找到。为了完成所有的挑战,您需要使用 Blender 或您熟悉的另一个建模程序,例如,一个照片编辑程序;Photoshop 是一个常见的选择,也是音频文件创建或获取的来源。本书提供的所有音频文件都来自www.freesound.org。
这本书面向的对象
这本书非常适合那些刚开始使用 Unity 进行游戏开发和移动开发的读者。对于那些更喜欢通过实际案例而不是枯燥的文档来学习的人,每一章都会很有用。即使您编程技能很少或没有,这本书也会是一个很好的起点,让您学习一些编程的概念和标准。
习惯用法
在这本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 标签以如下方式显示:“如果在构建过程中,Unity 抱怨 Android SDK 的位置,请选择安装位置内的android-sdk文件夹”。
代码块设置如下:
public void OnGUI() {
GUILayout.Label("Hello World");
}
任何命令行输入或输出都按如下方式编写:
adb kill-server
adb start-server
adb devices
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“通过点击下载 Windows SDK 工具按钮来跟进”。
注意
警告或重要提示以如下框中的形式出现。
小贴士
小贴士和技巧看起来像这样。
读者反馈
读者反馈始终欢迎。请告诉我们您对本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中受益的标题非常重要。
发送一般反馈,只需将电子邮件发送到<feedback@packtpub.com>,并在邮件主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support,并注册以将文件直接通过电子邮件发送给您。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从:www.packtpub.com/sites/default/files/downloads/2014OT_Images.pdf下载此文件。
勘误
尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。您可以通过从www.packtpub.com/support选择您的标题来查看任何现有勘误。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者方面的帮助,以及我们为您提供有价值内容的能力。
问题和答案
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章. 与 Unity 和 Android 问候
欢迎来到移动游戏开发的奇妙世界。无论你仍在寻找合适的发展套件,还是已经选择了其中一个,这一章都将非常重要。在本章中,我们将探讨选择 Unity 作为开发环境和 Android 作为目标平台所带来的各种功能。通过与主要竞争对手的比较,我们将发现为什么 Unity 和 Android 能站在最前沿。随后,我们将研究 Unity 和 Android 如何协同工作。最后,我们将设置开发环境,并创建一个简单的 Hello World 应用程序来测试一切是否设置正确。为了本书的目的,我们假设你在一个基于 Windows 的环境中工作。
在本章中,我们将涵盖以下主题:
-
主要的 Unity 功能
-
主要的 Android 功能
-
Unity 许可证选项
-
安装 JDK
-
安装 Android SDK
-
安装 Unity 3D
-
安装 Unity Remote
理解使 Unity 伟大的原因
也许 Unity 最伟大的功能就是它的开放性。目前市场上的几乎所有游戏引擎在可构建的内容上都有所限制。这虽然合乎逻辑,但可能会限制团队的能力。平均游戏引擎已经高度优化以创建特定类型的游戏。如果你计划反复制作相同的游戏,这当然很好。当一个人灵感迸发,想要制作下一个大热门游戏,却发现游戏引擎无法处理,每个人都必须重新学习一个新的引擎或加倍开发时间来使其具备这种能力时,这可能会非常令人沮丧。Unity 并没有这个问题。Unity 的开发者们非常努力地优化了引擎的每一个方面,而没有限制可以制作的游戏类型。从简单的 2D 平台游戏到庞大的在线角色扮演游戏,在 Unity 中都是可能的。一个刚刚完成超真实第一人称射击游戏的开发团队可以立即转向制作 2D 格斗游戏,而无需学习全新的系统。
然而,这种开放性也带来了一些缺点。没有默认的工具是针对构建完美游戏进行优化的。为了解决这个问题,Unity 允许创建任何可以想象到的工具,使用与创建游戏相同的脚本。除此之外,还有一个强大的用户社区,他们提供了大量工具和组件,既有免费的也有付费的,可以快速插入并使用。这导致了大量可用内容的出现,可以帮助你快速启动下一个伟大的游戏项目。
当许多潜在用户看到 Unity 时,他们可能会认为因为它非常便宜,所以它不如昂贵的 AAA 游戏引擎好。这根本不是真的。往游戏引擎上投入更多的钱并不能让游戏变得更好。Unity 支持所有你想要的复杂着色器、法线贴图和粒子效果。最好的部分是,几乎所有你想要的复杂功能都包含在 Unity 的免费版本中,而且 90%的时间,甚至不需要使用 Pro 版本特有的功能。
在选择游戏引擎时,尤其是针对移动市场,最大的担忧之一是它会给最终构建大小增加多少体积。大多数引擎都相当庞大。使用 Unity 的代码剥离功能,它变得相当小。代码剥离是 Unity 从编译库中移除所有额外代码的过程。一个为 Android 编译且利用了完整代码剥离的空白项目最终大约为 7 兆字节。
Unity 最酷的特性之一可能是其多平台兼容性。使用单一项目,人们可以为几个不同的平台构建应用。这包括同时针对移动设备、PC 和游戏机的功能。这允许人们专注于真正的问题,例如处理输入、分辨率和性能。
在过去,如果一家公司希望在其产品部署到多个平台,他们必须几乎将开发成本翻倍,以便重新编程游戏。每个平台都,并且仍然,按照自己的逻辑和语言运行。多亏了 Unity,游戏开发从未如此简单。我们可以使用简单快速的脚本开发游戏,让 Unity 处理每个平台的复杂转换。
当然,还有许多其他游戏引擎选项。两个主要的选择是cocos2d和Unreal Engine。虽然两者都是优秀的选择,但我们总能找到它们在某些方面的不足。
《愤怒的小鸟》所使用的 cocos2d 引擎可能是你下一个移动应用的绝佳选择。然而,正如其名所示,它基本上仅限于 2D 游戏。一个游戏可以在其中看起来很棒,但如果你想要第三维度,添加起来可能会很棘手。cocos2d 的第二个主要问题是它非常基础。任何用于构建或导入资源的工具都需要从头开始创建,或者需要找到它们。除非你有时间和经验,否则这可能会严重减缓开发进度。
然后是主要游戏开发的核心,虚幻引擎。这个游戏引擎多年来一直被开发者成功使用,为世界带来了许多优秀的游戏;其中,虚幻竞技场和战争机器无疑是佼佼者。然而,这两个都是家用游戏机和电脑游戏,这是引擎的根本问题。虚幻引擎是一个非常庞大且强大的引擎。在移动平台上,只能进行有限的优化。它一直存在同样的问题;它会给项目及其最终构建增加很多负担。虚幻引擎的另一个主要问题是其在第一人称射击引擎方面的僵化。虽然技术上可以在其中创建其他类型的游戏,但这样的任务既漫长又复杂。在实现这样的成就之前,必须对底层系统有深入的了解。
总的来说,Unity 在众多引擎中确实表现强劲。也许你已经发现了这一点,这就是你阅读这本书的原因。但选择 Unity 进行游戏开发仍然有很好的理由。项目可以看起来和 AAA 级游戏一样出色。在最终构建中的开销和负担很小,这在移动平台上非常重要。系统的潜力足够开放,允许你创建任何你想要的游戏类型,而其他引擎往往只限于创建单一类型的游戏。而且,如果在项目的生命周期中的任何时刻你的需求发生变化,很容易添加、删除或更改你的目标平台选择。
理解什么让 Android 如此出色
在用户手中拥有超过 3000 万部设备的 Android 平台,为什么你不会选择它作为你下一个移动成功的平台呢?苹果可能凭借其 iPhone 的轰动效应第一个进入市场,但 Android 在智能手机技术方面无疑走在了前面。其最佳特性之一是它明显的能力,可以打开它,让你查看手机的工作原理,无论是物理上还是技术上。如果需要,可以更换电池和升级 micro SD 卡。将手机连接到电脑不必成为一项巨大的任务;它可以简单地作为可移动存储介质使用。
从开发成本的角度来看,Android 市场也更为优越。其他移动应用商店要求支付大约 100 美元的年度注册费。一些还限制了同时可以注册用于开发的设备数量。Google Play 市场只需一次性注册费,而且无需担心你使用多少或什么类型的 Android 设备进行开发。
一些其他移动开发套件的缺点之一是,在获得 SDK 访问权限之前,你必须支付年度注册费。对于其中一些,在查看他们的文档之前,你需要注册和支付。Android 则更加开放和易于访问。任何人都可以免费下载 Android SDK。文档和论坛可以完全查看,无需支付任何费用。这意味着 Android 的开发可以更早开始,设备测试从一开始就是其中的一部分。
理解 Unity 和 Android 如何协同工作
由于 Unity 以通用方式处理项目和资产,因此无需为多个目标平台创建多个项目。这意味着你可以轻松地使用 Unity 的免费版本开始开发,并针对个人电脑。然后,在未来的某个时间点,你可以通过点击按钮切换到 Android 平台。也许,在你游戏发布后不久,它迅速占领市场,并有人强烈要求将其带到其他移动平台。只需再次点击按钮,你就可以轻松地将 iOS 作为目标,而无需对项目进行任何更改。
大多数系统需要经过一个漫长且复杂的步骤序列,才能在设备上运行你的项目。对于本书中的第一个应用,我们将要经历这个过程,因为它很重要。然而,一旦你的设备设置完成并被 Android SDK 识别,单次点击按钮就能让 Unity 构建你的应用,将其推送到设备并开始运行。没有任何事情比试图在设备上运行应用更让一些开发者头疼的了。Unity 让这一切变得简单。
通过添加免费的 Android 应用程序 Unity Remote,测试移动输入变得简单且容易,无需经历整个构建过程。在开发过程中,每次需要测试微调时,等待 5 分钟进行构建是最令人烦恼的事情,尤其是在控制和界面方面。经过第一次十几处小的调整后,构建时间开始累积。Unity Remote 让测试变得简单且容易,无需按下构建按钮。
这三大亮点:通用项目、一键构建过程和 Unity Remote。当然,我们可以想出更多 Unity 和 Android 可以协同工作的优秀方式。但这三个是节省时间和金钱的主要途径。你可能有世界上最好的游戏,但如果构建和测试需要 10 倍的时间,那还有什么意义呢?
Pro 版与基础版的区别
Unity 提供两种许可选项,Pro 和 Basic,可在 store.unity3d.com 找到。为了跟随本书的大部分内容,Unity Basic 就足够了。然而,第四章 中的实时阴影、“设置场景 – 摄像机效果和照明”,第五章 中的全部内容、“移动 – 寻路和 AI”,以及 第九章 中讨论的一些优化功能,“优化”,将需要 Unity Pro。如果您还没有准备好花费 3,000 美元购买带有 Android 扩展的完整 Unity Pro 许可证,还有其他选择。Unity Basic 是免费的,并附带 30 天的 Unity Pro 免费试用。这个试用是完整和完整的,就像购买了 Unity Pro 一样。您还可以在以后升级您的许可证。Unity Basic 提供免费移动选项,而 Unity Pro 需要为每个移动平台购买 Pro 扩展。
许可证比较概述
许可证比较 可以在此处找到。本节将涵盖 Unity Android Pro 和 Unity Android Basic 之间的具体差异。我们将探讨该功能是什么以及它有多有用。
-
NavMeshes, Pathfinding, and crowd Simulation: This feature is Unity's built-in pathfinding system. It allows characters to find their way from point to point around your game. Just bake your navigation data in the editor and let Unity take over at runtime. This feature is great if you don't have the ability or inclination to program a pathfinding system yourself. There is a whole slew of tutorials online about how to program pathfinding and do crowd simulation. It is completely possible to do all of this in Unity Basic; you just need to provide the tools yourself.
-
LOD 支持: LOD(细节级别)允许您根据网格与摄像机的距离来控制网格的复杂程度。当摄像机靠近一个对象时,渲染一个包含许多细节的复杂网格。当摄像机远离该对象时,渲染一个简单的网格,因为所有这些细节无论如何都看不到。Unity Pro 提供了一个内置的系统来管理这一点。然而,这也是在 Unity Basic 中可以创建的另一个系统。无论是否使用 Pro,这都是提高游戏效率的重要功能。通过在远处渲染更简单的网格,可以更快地渲染一切,为精彩的游戏留下更多空间。
-
音频过滤器:音频过滤器允许你在运行时对音频剪辑添加效果。也许你为你的角色创建了砾石脚步声。当你的角色在跑动时,我们可以清楚地听到脚步声,但突然他们进入了一个隧道,太阳耀斑击中,导致时间扭曲,一切变慢。音频过滤器将允许我们将砾石脚步声扭曲,使其听起来就像是从隧道内部传来,并且被时间扭曲所减慢。当然,你也可以让音频人员为时间扭曲声音创建一套新的隧道砾石脚步声。但这可能会使你的游戏中的音频数量翻倍,并限制我们在运行时对其动态性的使用。我们要么播放时间扭曲的脚步声,要么不播放。音频过滤器将允许我们控制时间扭曲对我们声音的影响程度。
-
视频播放和流媒体:当处理复杂或高清的剪辑场景时,能够播放视频变得非常重要。特别是在移动目标上,将视频包含在构建中可能需要大量的空间。这就是这个特性的流媒体部分发挥作用的地方。这个特性不仅让我们能够播放视频,还允许我们从互联网上流式传输视频。然而,这个特性也有一个缺点。在移动平台上,视频必须通过设备的内置视频播放系统来播放。这意味着视频只能全屏播放,不能用作纹理。理论上,你可以将视频分解成每一帧的单独图片,并在运行时翻页,但这不建议用于构建大小和视频质量的原因。
-
使用资源包的完整流媒体:资源包是 Unity Pro 提供的一个优秀特性。它允许你创建额外的内容并将其流式传输给用户,而无需更新游戏。你可以添加新角色、关卡或几乎所有你能想到的其他内容。它们的唯一缺点是不能添加更多代码。功能不能改变,但内容可以。这是 Unity Pro 最好的特性之一。
-
10 万美元的营业额:这与其说是一个特性,不如说是一个指导方针。根据 Unity 的最终用户许可协议,Unity 的基本版本不能被任何在前一年财政年度收入达到 10 万美元的团体或个人所许可。这基本上意味着,如果你赚了很多钱,你就必须购买 Unity Pro。当然,如果你赚了那么多钱,你可能根本不会遇到问题。至少这是 Unity 的看法,这也是为什么会有这个规定。
-
Mecanim: IK Rigs: Unity 的新动画系统 Mecanim 支持许多令人兴奋的新特性,其中之一就是逆运动学(IK)。如果你对这个术语不熟悉,IK 允许你定义动画的目标点,并让系统找出如何到达那里。想象一下,你有一个杯子放在桌子上,而一个角色想要拿起它。你可以让角色弯腰去拿起杯子,但如果角色稍微偏离一点呢?或者玩家可能造成的任何其他轻微偏移,这可能会完全破坏你的动画。为每一种可能性进行动画制作显然是不切实际的。有了 IK,角色稍微偏离一点几乎无关紧要。我们只需定义手的终点,然后让手臂由 IK 系统来处理。它为我们计算出手臂需要如何移动才能将手伸到杯子那里。另一个有趣的用途是让角色在房间里走动时看向有趣的事物。一个守卫可以追踪最近的人,玩家角色可以看向他们可以与之交互的事物,或者一个触手怪物可以在不进行所有复杂动画的情况下攻击玩家。这将是一个令人兴奋的可以玩弄的特性。
-
Mecanim: 同步层与额外曲线
-
在 Mecanim 中,同步层允许我们保持多组动画状态之间的同步。比如说,你有一个士兵,你想要根据他的健康状况不同来对他进行不同的动画处理。当健康状况良好时,他快步走动。受到一点伤害后,他的步伐变得沉重。当健康状况低于一半时,他的走路会变得蹒跚。而当几乎要死时,他会沿着地面爬行。通过同步层,我们可以创建一个动画状态机,并将其复制到多个层。通过更改动画并同步层,我们可以在保持状态机的同时轻松地在不同的动画之间进行转换。
-
额外的曲线仅仅是向你的动画中添加曲线的能力。这意味着我们可以通过动画来控制各种值。例如,在游戏世界中,当一个角色抬起脚进行跳跃时,重力会几乎立即将其拉下来。通过向那个动画添加额外的曲线,在 Unity 中,我们可以控制重力对角色的影响程度,使得角色在跳跃时实际上能够进入空中。这是一个在动画旁边控制此类值的有用特性,但也可以轻松创建一个脚本,该脚本可以保存和控制曲线。
-
-
自定义启动画面: 虽然这个功能相当直观,但如果之前没有使用过 Unity,可能并不立即明显为什么这个特性被指定。当一个在 Unity 中构建的应用程序在任何平台上初始化时,它会显示一个启动画面。在 Unity Basic 中,这始终是 Unity 的标志。通过购买 Unity Pro,你可以用任何你想要的图片替换 Unity 的标志。
-
构建大小剥离: 这是移动平台的一个重要特性。构建大小剥离会从最终构建中移除所有多余的内容。Unity 在仅包含用于最终构建的资产方面做得非常好。通过剥离,它还会仅包含游戏本身使用的引擎部分。这在您必须低于从蜂窝塔下载的限制时非常有用。另一方面,您也可以创建类似于资产包的东西。只需让用户购买框架,然后稍后下载资产即可。
-
实时方向阴影: 光线和阴影可以为场景增添许多氛围。此功能使我们能够超越模糊阴影,并使用看起来更真实的阴影。如果你有足够的处理空间,这当然很好。但大多数移动设备都没有。此功能也不应用于静态场景。相反,应使用静态光照贴图,这正是它们的作用。但如果能在简单需求和品质之间找到良好的平衡,这可能是区分一般游戏和优秀游戏的特性。
-
HDR,色调映射: HDR(高动态范围)和色调映射使我们能够创建更逼真的光照效果。标准渲染使用从零到一的值来表示像素中每种颜色的量。这不允许探索完整的照明选项范围。HDR 允许系统使用超出此范围的值,并通过色调映射处理它们以创建更好的效果,例如明亮的早晨房间或汽车窗户反射的阳光造成的泛光。此功能的缺点在于处理器。设备仍然只能处理零到一的值,因此转换它们需要时间。此外,效果越复杂,渲染它所需的时间就越长。在手持设备上,即使是简单的游戏,看到这种效果被很好地使用也会令人惊讶。也许现代平板电脑可以处理它。
-
光照探针: 光照探针是一个有趣的小特性。当放置在世界中时,光照探针会确定一个物体应该如何被照亮。然后,当角色四处走动时,它会告诉角色如何进行阴影处理。角色当然是由场景中的灯光照亮的,但一次只能有有限数量的灯光为物体提供阴影。光照探针在运行时进行所有复杂的计算,从而允许更好的阴影效果。然而,再次强调,处理能力仍然是一个问题。处理能力不足,您将无法获得良好的效果;处理能力过多,将没有剩余的处理能力来玩游戏。
-
使用全局光照和区域光照进行光照贴图: Unity 的所有版本都支持光照贴图,允许烘焙复杂的静态阴影和光照效果。随着全局光照和区域光照的加入,你可以为场景增添更多真实感。然而,Unity 的每个版本也允许你导入自己的光照贴图。这意味着,你可以使用其他程序渲染光照贴图,并单独导入。
-
静态批处理: 这个功能可以加快渲染过程。而不是在每一帧上花费时间对对象进行分组以实现更快的渲染,这个功能允许系统保存事先生成的组。减少绘制调用次数是使游戏运行更快的一个强大步骤。这正是这个功能所做的事情。
-
渲染到纹理效果: 这是一个有趣的功能,但用途有限。它仅仅允许你将摄像机的渲染从屏幕重定向到纹理。这个纹理在最简单的形式下,可以被放置在网格上,并像监控摄像头一样工作。你也可以进行一些自定义的后处理,例如当玩家失去健康时从世界中移除颜色。然而,这个选项可能会非常消耗处理器资源。
-
全屏后处理效果: 这是另一个处理器密集型功能,可能不会出现在你的移动游戏中。但你可以在场景中添加一些非常酷的效果。例如,当玩家移动非常快时添加运动模糊,或者当飞船穿过扭曲的空间区域时添加漩涡效果来扭曲场景。其中最好的是使用辉光效果给事物带来类似霓虹的光芒。
-
遮挡剔除: 这是另一个优秀的优化功能。标准的摄像机系统渲染摄像机视锥体内的所有内容,即视空间。遮挡剔除允许我们在摄像机可以进入的空间中设置体积。这些体积用于计算摄像机从这些位置实际可以看到的内容。如果前方有墙壁,渲染其后的所有内容有什么意义呢?遮挡剔除会计算这一点,并阻止摄像机渲染墙壁后面的任何内容。
-
导航网格:动态障碍和优先级: 这个功能与路径查找系统协同工作。在脚本中,我们可以动态设置障碍,角色将绕过它们找到路径。能够设置优先级意味着不同类型的角色在寻找路径时可以考虑到不同类型的对象。士兵必须绕过路障才能到达目标。然而,坦克如果愿意,可以直接冲过去。
-
.Net Socket 支持: 这个功能只有在您计划在用户的网络上进行一些复杂操作时才有用。多玩家网络在 Unity 的每个版本中都已经支持。不过,可用的多玩家功能确实需要一个主服务器。使用套接字,可以在本地创建与其他设备的连接。
-
性能分析器和 GPU 性能分析: 这是一个非常有用的功能。性能分析器提供了大量关于您的游戏对处理器造成多少负载的信息。有了这些信息,我们可以深入了解细节,并确定脚本处理的确切时间。然而,在本书的结尾,我们还将创建一个工具,用于确定代码特定部分的处理时间。
-
脚本访问资产管道: 这是一个不错的功能。有了对管道的完全访问权限,可以对资产和构建进行大量的自定义处理。所有可能性的范围超出了本书的范围。但可以想象成能够将所有导入的纹理稍微染成蓝色。
-
深色皮肤: 这是一个完全的视觉功能。它的目的和意义值得怀疑。但如果您想要一个平滑的深色皮肤外观,这就是您想要的功能。编辑器中有一个选项可以将其更改为 Unity Basic 中使用的颜色方案。对于这个功能,任何让您满意的颜色都可以。
设置开发环境
在我们能够为 Android 创建下一个伟大的游戏之前,我们需要安装一些程序。为了使 Android SDK 能够工作,我们首先安装 JDK。然后,我们将安装 Android SDK。接下来是 Unity 的安装。然后,我们必须安装一个可选的代码编辑器。为了确保一切设置正确,我们将连接到我们的设备,并查看一些特殊策略,如果设备比较棘手的话。最后,我们将安装 Unity Remote,这是一个在您的移动开发中将变得非常有价值的程序。
行动时间 - 安装 JDK
Android 的首选开发语言是 Java,因此为了开发它,我们需要在我们的计算机上安装一份 Java SE 开发工具包,即 JDK。安装 JDK 的过程在以下步骤中给出:
-
可以从
www.oracle.com/technetwork/java/javase/downloads/index.html下载 JDK 的最新版本。因此,在网页浏览器中打开该网站。![行动时间 - 安装 JDK]()
-
从可用版本中选择 Java 平台 (JDK),您将进入一个包含许可协议和类型选择页面的组合页面。
-
接受许可协议,并从列表底部选择您适当的 Windows 版本。如果您不确定选择哪个,那么 Windows x86 通常是一个安全的选择。
-
下载完成后,运行新的安装程序。
-
扫描、两次点击下一步按钮、初始化和再次点击一个下一步按钮,将 JDK 安装到默认位置。它在那里和任何其他地方一样好,所以安装完成后,点击关闭。
发生了什么?
我们安装了JDK(Java 开发工具包)。我们需要这个,以便我们的 Android 开发工具包能够工作。幸运的是,这个基石的安装过程既简短又甜蜜。
安装 Android SDK 的行动时间
为了真正开发和连接到我们的设备,我们需要安装 Android SDK。拥有它满足两个主要要求。首先,它确保我们有识别设备的大部分最新驱动程序。其次,我们能够使用ADB(Android 调试桥)。ADB 是用于实际连接和与设备交互的系统。安装 Android SDK 的过程如下所述:
-
最新版本的 Android SDK 可以在
developer.android.com/sdk/index.html找到,所以打开一个网页浏览器并访问该网站。 -
一旦到达那里,滚动到页面底部并选择使用现有 IDE。
-
然后点击下载 Windows SDK 工具按钮。
![安装 Android SDK 的行动时间 – 安装 Android SDK]()
-
然后,您将被发送到一项条款和条件协议。如果您愿意,可以阅读它,但为了继续并点击下载按钮开始下载安装程序,您需要同意它。
-
下载完成后,启动它。
-
点击第一个下一步按钮,安装程序将尝试找到合适的 JDK 版本。如果您没有安装它,您将看到一个抱怨的页面。
-
如果您跳过了前面的步骤并且没有安装 JDK,请点击页面中间的访问 java.oracle.com按钮,然后返回到前面的部分以获取安装它的指导。如果您已经有了它,请继续进行此过程。
-
再次点击下一步将带我们到一个关于选择为谁安装 SDK 的页面。
-
选择为使用此计算机的任何人安装,因为默认的安装位置更容易在以后使用。
-
点击下一步两次,然后点击安装以将安装程序安装到默认位置。
-
完成后,点击下一步和完成以完成 Android SDK 管理器的安装。
-
如果 Android SDK 管理器没有立即启动,请启动它。无论如何,给它一点时间来初始化。SDK 管理器确保我们有用于在 Android 平台上开发的最新驱动程序、系统和工具。但首先,我们必须实际安装它们。
![安装 Android SDK 的行动时间 – 安装 Android SDK]()
-
默认情况下,它应该选择安装一些选项。如果不是,请选择最新的 Android API,即写作此书时的 4.3(API 18),Android 支持库和位于附加组件中的Google USB 驱动器。务必确保Android SDK 平台工具被选中。这一点非常重要。它实际上包括了连接到我们的设备所需的工具。
-
一切都选择完毕后,点击右下角的安装包。
-
下一屏是另一组许可协议。每次通过 SDK 管理器安装或更新组件时,你都必须同意许可条款,然后才能安装。接受所有许可并点击安装以开始过程。
-
现在,你可以坐下来放松一下。组件下载和安装需要一段时间。一旦完成,你可以关闭它。我们已经完成了这个过程,但你应该偶尔回来检查一下。定期检查 SDK 管理器以更新将确保你使用的是最新的工具和 API。
发生了什么?
我们已安装了 Android SDK。没有它,我们将完全无法对 Android 平台进行任何操作。除了下载和安装组件的漫长等待外,这是一个相当简单的安装过程。
安装 Unity 3D 的行动时间
或许这本书最重要的部分,没有它,其余部分都没有意义,就是安装 Unity。
-
Unity 的最新版本可以在
www.unity3d.com/unity/download找到。截至写作此书时,当前版本是 4.2.2。 -
下载完成后,启动安装程序,点击下一步,直到到达选择组件页面。
![安装 Unity 3D 的行动时间 – 安装 Unity 3D]()
-
在这里,我们可以选择 Unity 安装的特征。这些选项中没有任何一个是继续阅读本书其余部分所必需的,但它们值得一看,因为每次更新或重新安装 Unity 时,它都会要求你查看。
-
示例项目是 Unity 当前构建的项目,用于展示其一些最新功能。如果你想早点跳进去看看一个完整的 Unity 游戏是什么样子,请保留这个选项。
-
如果你计划使用 Unity 开发浏览器应用程序,则需要Unity 开发网络播放器。由于本书专注于 Android 开发,这是可选的。然而,检查它是个好主意。你永远不知道何时可能需要网络演示,而且使用 Unity 进行网络开发完全是免费的,所以拥有它没有坏处。
-
最后一个选项是MonoDevelop。明智的选择是不要勾选这个选项。下一节将有更多细节,但现在只需说,它只是增加了一个额外的脚本编辑程序,而这个程序并不像它应该的那样有用。
-
-
一旦你选择了或取消选择了你想要的操作选项,点击下一步。如果你希望完全按照书籍进行,我们将取消选择MonoDevelop,其余选项保持选中。
-
接下来是安装位置。默认位置效果很好,所以点击安装并等待。这需要几分钟,所以请坐下来,放松,享受你最喜欢的饮料。
-
安装完成后,将显示运行 Unity 的选项。保持选中并点击完成。如果你之前从未安装过 Unity,你将看到一个许可证激活页面。
![安装 Unity 3D 的时间 - 行动时间]()
-
虽然 Unity 提供了一个功能丰富的免费版本,但要完整地跟随这本书的内容,需要使用一些 Unity Pro 的功能。在
store.unity3d.com你可以购买各种许可证。为了完整地阅读这本书,你至少需要购买 Unity Pro 和 Android Pro 许可证。购买后,你将收到一封包含你的新许可证密钥的电子邮件。在提供的文本字段中输入该密钥。 -
如果你还没有准备好购买,你有两种选择。我们将在本章后面的构建简单应用程序部分介绍如何重置你的许可证。
-
第一种选择是你可以勾选激活 Unity 免费版本的复选框。这将允许你使用 Unity 的免费版本。如前所述,有许多理由选择这个选项。目前最显著的理由是成本。
-
或者,你可以选择激活 Unity Pro 30 天免费试用版的选项。Unity 提供了一次性安装的完整功能版本和免费的 Unity Pro 30 天试用版。这个试用版也包括 Android Pro 附加组件。在这 30 天内产生的任何内容都是完全属于你的,就像你购买了一个完整的 Unity Pro 许可证一样。他们希望你能看到它有多好,这样你才会回来购买。缺点是试用版本的水印将始终显示在游戏的角落。30 天后,Unity 将恢复到免费版本。这是一个很好的选择,如果你选择在购买前等待的话。
-
-
无论你的选择是什么,一旦做出决定,就点击确定。
-
下一页只是要求你使用 Unity 账户登录。这将是你用来购买账户的同一账户。只需填写字段并点击确定。
-
如果你还没有购买,你可以点击创建账户,以便在购买时一切准备就绪。
-
下一页是对你的开发兴趣的简短调查。填写并点击确定或直接滚动到页面底部并点击现在不是时候。
-
最后有一个感谢页面。点击开始使用 Unity。
-
经过短暂的初始化后,项目向导将打开,我们可以开始创建下一个伟大的游戏。然而,还有很多工作要做:连接开发设备。所以,现在,点击右上角的X按钮关闭项目向导。我们将在后面的构建简单应用程序部分介绍如何创建新项目。
发生了什么?
我们刚刚安装了 Unity 3D。整本书都依赖于这一步。我们还必须做出关于许可证的选择。如果你选择购买专业版,你将能够无障碍地跟随本书中的所有内容。然而,其他选择将有一些不足之处。你可能无法完全访问所有功能,或者被限制在试用期的长度内。
可选的代码编辑器
现在必须做出关于代码编辑器的选择。Unity 附带一个名为MonoDevelop的系统。它在许多方面与 Visual Studio 相似。而且,就像 Visual Studio 一样,它为项目添加了许多额外的文件和体积,这些都是它运行所必需的。所有这些额外的体积使得它在启动时需要花费令人烦恼的时间,才能真正进入代码编写。
从技术上讲,你可以使用纯文本编辑器,因为 Unity 并不真正关心。本书建议使用 Notepad++,可以在notepad-plus-plus.org/download找到。它是免费的,本质上是一个带有代码高亮的记事本。Notepad++有几个花哨的小工具和插件,可以增加更多的功能,但它们对于跟随本书不是必需的。如果你选择这个替代方案,将 Notepad++安装到默认位置将工作得很好。
连接到设备
在与 Android 设备一起工作时,最令人烦恼的步骤可能是设置与电脑的连接。因为设备种类繁多,有时仅仅为了让电脑识别设备,就会变得有些棘手。
行动时间 - 简单设备连接
简单的设备连接方法涉及更改一些设置和在命令提示符中做一些工作。这可能会有些可怕,但如果一切顺利,你很快就会连接到你的设备。
-
我们需要做的第一件事是在手机上开启一些开发者设置。在设置页面的顶部,应该有一个开发者选项;选择它。如果你没有这个选项,请寻找应用程序选项。
-
找到未知来源复选框并勾选它。这让我们能够在设备上安装我们的开发应用程序。
-
下一个需要勾选的复选框是USB 调试。这允许我们从开发环境中实际检测到我们的设备。
-
如果你使用 Kindle,请确保进入安全设置并开启启用 ADB。
小贴士
与开启这些各种选项相关联有几个警告弹窗。它们本质上等同于与您的计算机相关的恶意软件警告。有不良意图的应用程序可能会干扰您的系统并获取您的私人信息。如果您的设备仅用于开发,所有这些设置都需要开启。但是,正如警告所建议的,如果担心恶意应用程序,在不开发时关闭它们。
-
接下来,启动一个命令提示符。这通常可以通过按 Windows 键,输入
cmd.exe,然后按Enter键来完成。 -
现在我们需要导航到 ADB 命令。如果您没有安装到默认位置,请在以下命令中将路径替换为您安装的路径。
-
如果您正在运行 32 位版本的 Windows 并且安装到默认位置,请在命令提示符中输入以下内容:
cd c:\program files\android\android-sdk\platform-tools -
如果您正在运行 64 位版本,请在命令提示符中输入以下内容:
cd c:\program files (x86)\android\android-sdk\platform-tools
-
-
现在将您的设备连接到计算机,最好使用随设备附带的 USB 线。
-
等待您的计算机完成设备识别。完成时,应该会有一个“设备驱动程序已安装”类型的消息弹窗。
-
以下命令让我们可以看到哪些设备目前被 ADB 系统连接和识别。仿真设备也会显示出来。在命令提示符中输入:
adb devices -
在短暂的等待处理后,命令提示符将显示一个列表,其中包含连接的设备及其所有连接设备的唯一 ID。如果这个列表现在包含您的设备,恭喜您,您有一个对开发者友好的设备。如果没有,事情会变得有点复杂。
发生了什么?
我们第一次尝试连接到我们的 Android 设备。对于大多数人来说,这应该就足够连接到您的设备了。对于一些人来说,这个过程还不够。接下来的小节将介绍解决这个问题的方法。
行动时间 - 连接更复杂的设备
对于更复杂的设备,我们可以尝试一些通用方法。如果这些步骤无法连接您的设备,您可能需要进行一些特殊研究。
-
首先输入以下命令。这些命令将重新启动连接系统并再次显示设备列表。
adb kill-server adb start-server adb devices -
如果您仍然没有成功,尝试以下命令。这些命令强制更新并重新启动连接系统。
cd ../tools android update adb cd ../platform-tools adb kill-server adb start-server adb devices -
如果您的设备仍然没有显示,您可能有一个最令人烦恼和复杂的设备。检查制造商的网站以获取数据同步和管理程序。如果您已经使用了一段时间,您可能已经多次被提示安装此程序。如果您尚未安装,请安装最新版本,即使您从未打算使用它。目的是获取您设备的最新驱动程序,这是最简单的方法。
-
再次使用第一组命令重新启动连接系统,并交叉手指。
-
如果你仍然无法连接,最好的、专业的建议就是去谷歌搜索。在设备品牌后面加上
adb进行搜索应该会在前几个结果中找到针对你设备的逐步教程。www.xda-developers.com/也是了解 Android 设备细节的一个极好资源。
发生了什么?
在开发过程中,你可能会遇到一些不易连接的设备。我们刚刚介绍了一些快速步骤并成功连接了这些设备。如果我们能涵盖每个设备的流程,我们会这样做。然而,设备的种类实在太多了,而且它们还在不断制造新的设备。
Unity Remote
Unity Remote 是由 Unity 团队创建的一个优秀应用程序。它允许开发者将他们的 Android 设备连接到 Unity 编辑器,并提供移动输入以进行测试。这对于任何有志于 Unity 和 Android 开发的开发者来说都是必不可少的。如果你使用的是非亚马逊设备,获取 Unity Remote 相当简单。在撰写本书时,它可以在 Google Play 上找到,地址为play.google.com/store/apps/details?id=com.unity3d.androidremote。它是免费的,只做连接到 Unity 编辑器的事情,所以权限可以忽略不计。
然而,如果你像不断增长的亚马逊市场一样,或者想要针对亚马逊的 Android 设备系列,添加 Unity Remote 会变得稍微复杂一些。首先,你需要下载 APK。它可以在以下位置找到:files.unity3d.com/ricardo/AndroidRemote.apk。请确保将文件下载到你可以轻松获取整个文件路径的位置。在下一节中,我们将构建一个简单的应用程序并将其放在我们的设备上。从开始控制台步骤开始,用下载的 APK 替换简单应用程序。
构建一个简单的应用程序
现在,我们将创建一个简单的 Hello World 应用程序。这将使你熟悉 Unity 界面以及如何实际上将应用程序放在你的设备上。
行动时间 – Hello World
为了确保一切设置正确,我们需要一个简单的应用程序来测试,还有什么比 Hello World 应用程序更好的呢?
-
第一步相当直接和简单;启动 Unity。
-
如果你一直跟随着,一旦完成,你应该会看到一个类似于下一张截图的屏幕。正如标签所暗示的,这是我们打开各种项目的屏幕。然而,目前我们的兴趣在于创建一个新项目,所以从顶部的第二个标签中选择创建新项目,我们将这样做。
![行动时间 – Hello World]()
-
使用浏览按钮选择一个空文件夹来保存您的项目。请确保文件夹为空,因为 Unity 在创建新项目之前将删除其中的所有内容。"Ch1_HelloWorld_CS"是一个很好的项目名称。
![行动时间 – Hello World]()
-
目前我们可以忽略这些包。这些是由 Unity 提供的资产和功能片段。它们对您在项目中的使用是免费的。
-
点击创建按钮,Unity 将为我们创建一个全新的项目。
![行动时间 – Hello World]()
-
Unity 的默认布局包含创建游戏所需的窗口。
-
从左侧开始,层次结构包含场景中当前存在的所有对象的列表。它们按字母顺序组织,如果有父对象,则分组。
-
那边是场景视图。这个窗口允许我们在 3D 空间中编辑和排列对象。在左上角,有两个按钮组。这些按钮影响您与场景视图的交互方式。
-
最左侧看起来像手的按钮,让您在点击并拖动鼠标左键时可以平移。
-
下一个按钮是带有交叉箭头的按钮,允许您移动对象。如果您使用过任何建模程序,它的行为和提供的 gizmo 将很熟悉。
-
第三个按钮将 gizmo 更改为旋转。它允许您旋转对象。
-
第四个按钮用于缩放。它也会改变 gizmo。
-
第二个到最后一个按钮在枢轴和中心之间切换。这将改变最后三个按钮使用的 gizmo 的位置,要么是所选对象的枢轴点,要么是所有选定对象的平均位置点。
-
最后一个按钮在本地和中心之间切换。这会改变是否将 gizmo 与世界原点平行或与选定的对象旋转。
-
在场景视图下方是游戏视图。这是场景中任何相机正在渲染的内容。玩家在玩游戏时会看到它,并用于测试您的游戏。在窗口的上中部分有三个按钮控制游戏视图的播放。
-
第一个是播放按钮。它切换游戏的播放。如果您想测试游戏,请按此按钮。
-
第二个是暂停按钮。在游戏进行时,按下此按钮将暂停整个游戏,让您可以查看游戏当前的状态。
-
第三个是步骤按钮。当游戏暂停时,此按钮将允许您逐帧前进游戏。
-
在右侧是检查器窗口。它显示当前选定的任何对象的信息。
-
在左下角是项目窗口。它显示项目中当前存储的所有资产。
-
在它后面是 控制台。它将显示调试信息、编译错误、警告和运行时错误。
-
-
在顶部,帮助 下方有一个名为 管理许可证... 的选项。通过选择此选项,我们将获得控制许可证的选项。按钮描述很好地说明了它们的功能,所以我们在此不会详细说明。
-
接下来我们需要做的是连接我们的可选代码编辑器。在顶部,转到 编辑,然后是 首选项...,这将打开以下窗口:
![Time for action – Hello World]()
-
通过在左侧选择 外部工具,我们可以选择用于管理资产编辑的其他软件。
-
如果你不想使用 MonoDevelop,请选择 外部脚本编辑器 右侧的下拉列表,导航到 Notepad++ 的可执行文件或其他你选择的代码编辑器。
-
你也可以在这里更改你的 图像应用程序 为 Adobe Photoshop CS3 或你喜欢的任何其他图像编辑程序,就像脚本编辑器一样。
-
如果你将 Android SDK 安装在默认位置,无需担心。否则,单击 浏览... 并找到
android-sdk文件夹。 -
现在,对于实际创建此应用程序,请在 项目 窗口内部右键单击。
-
从弹出的新窗口中,从菜单中选择 创建 和 C# 脚本。
-
输入新脚本的名称,
HelloWorld将是一个不错的选择,然后按两次 Enter 键:一次确认名称,一次打开它。小贴士
因为这是第一章,所以它将是一个简单的 Hello World 应用程序。Unity 支持使用 C#、JavaScript 和 Boo 作为脚本语言。为了保持一致性,本书将使用 C#。如果你希望使用 JavaScript 编写脚本,所有项目都可以在本书的其他资源中找到,并且带有
_JS后缀表示 JavaScript。 -
每个将要附加到对象的脚本都继承自 MonoBehaviour。JavaScript 会自动这样做,但 C# 脚本必须显式定义它。然而,正如你在脚本中的默认代码所看到的,我们不需要担心最初设置它;这是自动完成的。从 MonoBehaviour 继承让我们的脚本可以访问游戏对象的各个值,例如位置,并允许系统在游戏中的特定事件期间自动调用某些函数,例如更新周期和 GUI 渲染。
-
现在,我们将删除 Unity 强制包含在每一个新脚本中的
Start和Update函数。用一段简单的代码替换它们,在屏幕的左上角渲染文字 Hello World。你现在可以关闭脚本并返回 Unity。public void OnGUI() { GUILayout.Label("Hello World"); } -
将
HelloWorld脚本从 项目 窗口拖动到 层次结构 窗口中的 主摄像机 对象上。恭喜你,你刚刚在 Unity 中为一个对象添加了你的第一个功能。 -
如果您在层次结构中选择主相机,那么检查器将显示附加到其上的所有组件。列表底部是您全新的
HelloWorld脚本。 -
在我们可以测试它之前,我们需要保存场景。为此,转到顶部的文件,并选择保存场景。给它命名为
HelloWorld,然后点击保存。 -
现在,您可以在编辑器的上中部自由点击播放按钮,见证 Hello World 的魔法。
-
我们现在可以构建应用程序了。在顶部,选择文件,然后选择构建设置...。
-
默认目标平台是PC。在平台下,选择Android,然后在“构建设置”窗口的左下角点击切换平台。
-
在“构建场景”框下方,有一个标有“添加当前”的按钮。点击它,将我们当前打开的场景添加到构建中。只有列在此列表并勾选的场景将被添加到游戏的最终构建中。旁边带有数字零的场景将在游戏开始时首先加载。
-
在我们可以点击“构建”按钮之前,还有最后一件事需要更改。在“构建设置”窗口的底部选择玩家设置...。
-
检查器窗口将打开应用程序的玩家设置。从这里我们可以更改启动画面、图标、屏幕方向以及其他一些技术选项。
![行动时间 – Hello World]()
-
目前,我们只关心几个选项。在顶部,公司名称是将在应用程序信息下显示的名称。产品名称是将在您的 Android 设备上的图标下显示的名称。您可以将这些设置为您想要的任何内容,但它们需要立即设置。
![行动时间 – Hello World]()
-
在“其他设置”下,重要的设置是“包标识符”。这是区分设备上所有其他应用程序的唯一标识符。格式为
com.公司名.产品名,在所有产品中使用相同的公司名是一种良好的做法。对于这本书,我们将使用com.TomPacktAndBegin.Ch1.HelloWorld作为包标识符,选择使用额外的点进行组织。 -
上到文件并再次点击保存。
-
现在,您可以在“构建设置”窗口中点击“构建”按钮。
-
选择一个保存位置和文件名;
Ch1_HelloWorld.apk是一个不错的选择。务必记住它的位置,然后点击保存。 -
如果在构建过程中,Unity 抱怨 Android SDK 的位置,请选择安装位置中的
android-sdk文件夹。对于 32 位 Windows,默认位置为C:\Program Files\Android\android-sdk,对于 64 位 Windows,默认位置为C:\Program Files (x86)\Android\android-sdk。 -
一旦加载条完成,这应该不会花很长时间,您的
apk已经制作完成,我们准备继续。 -
我们已经完成了本章的 Unity。您可以关闭它并启动一个命令提示符。
-
就像我们连接设备时做的那样,我们需要导航到
platform tools文件夹,以便连接到我们的设备。如果您将其安装到默认位置,请使用:-
对于 32 位 Windows:
cd c:\program files\android\android-sdk\platform-tools -
对于 64 位 Windows:
cd c:\program files (x86)\android\android-sdk\platform-tools
-
-
使用以下方法检查设备是否已连接并被识别:
adb devices -
现在我们来安装应用程序。此命令告诉系统在连接的设备上安装一个应用程序。
-r表示如果发现与我们要安装的应用程序具有相同包标识符的应用程序,则应覆盖它。这样,您就可以在开发过程中直接更新游戏,而无需每次更新前都卸载旧版本。您要安装的.apk文件的路径如下所示,并用引号括起来:adb install -r "c:\users\tom\desktop\packt\book\ch1_helloworld.apk" -
用您的 apk 文件路径替换它;大写字母不重要,但请确保所有正确的间隔和标点符号都正确无误。
-
如果一切顺利,控制台将在将您的应用程序推送到设备后显示上传速度,并在安装完成后显示成功消息。在此阶段出现错误的最常见原因是在执行命令时不在
platform-tools文件夹中,以及没有正确路径的.apk文件,且文件名被引号包围。 -
一旦您收到成功消息,请在您的手机上找到该应用程序并启动它。
-
现在,带着惊奇的目光看看您使用 Unity 创建 Android 应用程序的能力。
发生了什么?
我们创建了我们的第一个 Unity 和 Android 应用程序。诚然,它只是一个简单的 Hello World 应用程序,但一切都是从这里开始的。此外,它对于检查设备连接以及学习构建过程(而不受游戏杂乱无章的影响)非常有用。
挑战英雄 - 提前工作
尝试更改应用程序的图标。这是一个相当简单的流程,您无疑会在游戏开发过程中想要执行。如何做到这一点在本节中已经提到。但作为提醒,请查看玩家设置。此外,您还需要导入一个图像。在菜单栏下查看资产,了解如何进行此操作。
摘要
本章有很多技术内容。首先,我们讨论了使用 Unity 和 Android 时的好处和可能性。然后是一系列的安装;JDK、Android SDK、Unity 3D 和 Unity Remote。然后我们找到了通过命令提示符连接到我们设备的方法。我们的第一个应用程序制作起来既快又简单。最后,我们构建了它并将其放置在设备上。
下一章我们将创建一个交互性显著更强的游戏——井字棋。我们将探索图形用户界面的奇妙世界。因此,我们不仅会制作游戏,还会让它看起来很棒。
第二章. 看起来不错 – 图形界面
在前一章中,我们介绍了 Unity 和 Android 的功能。我们还讨论了将它们结合使用的优点。在安装了大量软件并设置好我们的设备后,我们创建了一个简单的 Hello World 应用程序来确认一切连接正确。
本章全部关于图形用户界面(GUI)。我们将从创建一个简单的井字棋游戏开始,使用 Unity 提供的 GUI 基本组件。之后,我们将讨论 Unity 的 GUI 样式和 GUI 皮肤。利用我们所学,我们将改善游戏的外观。此外,我们将探索处理不同屏幕尺寸的 Android 设备的技巧和窍门。最后,我们将了解一种更快地将游戏上传到设备的方法,这在上一章中已经介绍过。话虽如此,让我们开始吧。
在本章中,我们将涵盖以下主题:
-
用户偏好
-
按钮和标签
-
GUI 皮肤和 GUI 样式
-
动态 GUI 定位
-
构建和运行
在本章中,我们将在 Unity 中创建一个新的项目。本节将指导你完成其创建和设置。
创建井字棋游戏
本章的项目是一个简单的井字棋风格游戏,类似于我们中任何一个人可能在纸上玩的游戏。与其他任何事物一样,你可以用几种不同的方式制作这个游戏。我们将使用 Unity 的 GUI 系统,以便更好地了解如何为我们的其他游戏创建 GUI。
行动时间 – 创建井字棋
基本的井字棋游戏涉及两名玩家和一个 3 x 3 的网格。玩家轮流填写 X 和 O。首先填满一行三个方格的玩家获胜。如果所有方格都填满,但没有玩家填满一行三个,则游戏平局。让我们按照以下步骤创建我们的游戏:
-
首先要做的事情是为本章创建一个项目。因此,启动 Unity,我们将这样做。
-
如果你一直跟随着,Unity 应该会启动到最后一个打开的项目。这不是一个坏特性,但它可能会变得非常令人烦恼。想想看:你已经在一项项目中工作了一段时间,它已经变得很大。现在你需要快速打开其他东西,但 Unity 默认打开你的大型项目。如果你在开始工作之前等待它打开,可能会浪费很多时间。要更改此功能,请转到 Unity 窗口的顶部,点击编辑然后点击首选项。这是我们更改脚本编辑器首选项的地方。不过,这次我们将更改常规选项卡中的设置。以下截图显示了常规选项卡下的选项:
![行动时间 – 创建井字棋]()
-
目前,主要关注的是始终显示项目向导选项;然而,我们仍将逐一介绍所有选项。以下是对常规选项卡下所有选项的详细解释:
-
自动刷新:这是 Unity 的最好特性之一。当资产在 Unity 外部被更改时,此选项允许 Unity 自动检测更改并刷新项目中的资产。
-
始终显示项目向导:这是安装 Unity 时每次都应检查的绝佳首选选项。Unity 不会打开最后一个项目,而是打开项目向导。从那里,你可以打开任何你选择的项目或创建一个新的项目。这是一个始终开启的好选项。
-
导入时压缩资产:这是一个复选框,用于在首次将游戏资产导入 Unity 时自动压缩它们。
-
编辑器分析:这是 Unity 匿名使用统计信息的复选框。保持勾选状态,Unity 编辑器会偶尔向 Unity 源发送信息。保持开启不会影响任何东西,并有助于 Unity 团队使 Unity 编辑器变得更好。但这最终还是取决于个人喜好。
-
显示资产商店搜索结果:此设置仅在计划使用资产商店时相关。资产商店可以为任何游戏提供资产和工具的绝佳来源;然而,由于我们不会使用它,此书的相关性相当有限。它做的是名字所暗示的事情。当你从 Unity 编辑器内部搜索资产商店中的内容时,结果的数量会根据此复选框显示。
-
验证保存资产:这是一个很好的选项,可以不开启。如果开启此选项,每次你在 Unity 中点击保存时,都会弹出一个对话框,以便你可以确保保存自上次保存以来更改的任何资产。这不仅仅关乎你的模型和纹理,还涉及到 Unity 的内部文件、材质和预制体。目前最好将其关闭。
-
皮肤(仅限专业版):此选项仅适用于 Unity 的专业用户。它提供了在 Unity 编辑器的浅色和深色版本之间切换的选项。这完全是外观上的,所以根据你的直觉来选择。
-
-
设置好你的偏好后,现在转到文件菜单,然后选择打开项目。
-
选择创建新项目选项卡,然后点击浏览...按钮来选择新项目的位置和名称。
-
我们将不会使用任何包含的包,因此点击创建,我们可以继续进行。
-
一旦 Unity 完成新项目的初始化,在项目面板中创建两个新的脚本,就像我们在上一章的Hello World项目中做的那样。将新脚本命名为
TicTacToeControl和SquareState。打开它们并清除默认函数;再次,就像我们在第一章中做的那样,向 Unity 和 Android 问好。 -
SquareState脚本将保存我们游戏板每个方块的可能的州。为此,清空脚本中的所有内容,并用一个简单的枚举来替换。枚举只是潜在值的列表。这个枚举关注的是控制该方块的玩家。是 X 控制它,O 控制它,还是因为它默认情况下是清晰的(游戏板传统上是默认清晰的)。Clear成为第一个,因此是默认状态。public enum SquareState { Clear, XControl, OControl } -
在我们的另一个脚本
TicTacToeControl中,我们开始于两个将很大程度上控制游戏流程的变量。第一个定义了我们的游戏板。传统上游戏是在一个 3x3 的网格上进行的,因此有九个方块。第二行指定了轮到谁。它将如何改变将在稍后变得清楚,但就现在而言,如果轮到 X,值将是 true。如果不是 X 的回合,值将是 false。public SquareState[] board = new SquareState[9]; public bool xTurn = true;小贴士
在 Unity 中,每个脚本默认继承自
MonoBehaviour类。这给我们的脚本带来了两个主要好处。首先,它允许我们将脚本作为组件添加到对象中。如果计划将脚本添加到对象中,脚本文件的名称也需要与脚本内部的类名完全相同。
MonoBehaviour类的第二个好处是它附带的各种变量和函数。变量让我们可以访问组成 Unity 中对象的各个部分。函数提供了一系列自动功能和访问游戏初始化和循环的能力。这正是我们在这个特定时刻最感兴趣的。 -
为了在每一帧的 GUI 中绘制任何内容,需要利用
MonoBehaviour类提供的OnGUI函数。这就是我们将绘制游戏板的地方。OnGUI函数让我们在每一帧绘制我们的界面。在它内部,我们首先定义我们板方块的宽度和高度。public void OnGUI() { float width = 75; float height = 75; -
接下来是一对 for 循环。因为我们的板是一个 3x3 的网格,我们需要循环来计数三行三列的方块。
for(int y=0;y<3;y++) { for(int x=0;x<3;x++) { -
在循环内部,我们首先必须弄清楚我们目前正在绘制哪个方块。如果你不知道哪个方块被触摸,玩游戏会变得很困难。
int boardIndex = (y * 3) + x; -
下一行代码定义了方块是否将以
Rect类的形式绘制。Rect类在 GUI 空间中定义为 x 位置、y 位置、宽度和高度。在 Unity 中,GUI 空间定义为左上角为 (0,0),右下角为Screen.width、Screen.height。屏幕的宽度和高度是像素数。Rect square = new Rect(x * width, y * height, width, height); -
然后我们确定谁控制着这个方块。以下代码行有点复杂,但它实际上只是一个压缩的
if语句。基本上,它的工作方式是这样的:首先检查一个条件,如果它是真的,返回第一个值,即问号和冒号之间的值。如果条件是假的,返回冒号后面的值。这里结合了两个这样的压缩if语句;如果方块被 X 拥有,将我们的所有者设置为 X。否则,如果它被 O 拥有,将所有者设置为 O。如果两个条件都不成立,则没有人拥有这个方块,我们将所有者设置为空字符串。string owner = board[boardIndex] == SquareState.XControl ? "X" : board[boardIndex] == SquareState.OControl ? "O" : ""; -
现在我们已经完成了确定我们位置的所有艰苦工作,我们实际上绘制我们的游戏棋盘方块。这是通过使用 Unity 提供的一个奇妙的小函数
GUI.Button来完成的。要使用这个函数的基本形式,我们必须告诉函数按钮应该绘制在哪里以及显示什么文本,因此是 rect 和 string。我们给它我们的方块和所有者变量,它完成所有在屏幕上绘制的实际工作,并返回一个布尔结果,表示按钮是否被按下。因此,我们用if语句检查它,如果为真,我们发送到一个新函数,告诉它哪个方块被按下,让它处理设置所有者的操作。另外,别忘了额外的花括号来关闭循环和函数。if(GUI.Button(square, owner)) SetControl(boardIndex); } } } -
SetControl函数相当简短;它只是为传递给它的任何方块设置所有者。它首先确保给定的索引确实在我们棋盘的范围内。如果不是,我们将提前退出函数。下一行代码根据轮到谁来设置棋盘方块的控件。如果是 X 的回合,将方块设置为XControl;否则将控件设置为OControl。最后我们改变轮到谁。这是通过简单地设置我们的xTurn布尔值为其相反的值来完成的,表示现在是另一人的回合。public void SetControl(int boardIndex) { if(boardIndex < 0 || boardIndex >= board.Length) return; board[boardIndex] = xTurn ? SquareState.XControl : SquareState.OControl; xTurn = !xTurn; } -
我们差不多准备好玩游戏了。我们只需要设置场景。为此,首先将我们的
TicTacToeControl脚本从 Unity 编辑器的项目面板拖动到场景面板中的主摄像机对象。 -
现在保存场景,就像我们在第一章中做的那样,向 Unity 和 Android 问好,命名为
TicTacToe。 -
在这个阶段可以玩游戏。也可以在设备上玩游戏;只需遵循第一章中的相同步骤,向 Unity 和 Android 问好,但现在只需在 Unity 编辑器中进行测试。在本章的后面,我们将介绍一种更简单的方法来构建到我们的设备上。
小贴士
下载示例代码
你可以从你购买的所有 Packt 书籍的账户中下载示例代码文件。
www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
刚才发生了什么?
我们创建了玩井字棋所需的基础。我们用两个简短的脚本完成了这个任务。然而,现在你在玩游戏时,可能注意到了一些关于它的细节。首先,它看起来并不特别出色。考虑到这是本章的重点,这非常奇怪,但我们很快就会解决这个问题。其次,没有检查来确定是否有人已经控制了一个方格。
此外,没有检查来确定是否有人赢得了游戏。最后,如果你决定构建到设备,你可能已经注意到了 Unity 的 GUI 函数的一个优点。不需要特殊的编程就可以使任何 GUI 函数与触摸输入而不是鼠标一起工作。当你不必担心特殊输入时,可以节省很多时间,尤其是如果你计划多平台定位。
完成游戏
如果你不想经历构建应用程序并将其放在设备上的过程,你仍然可以尝试与按钮进行交互,用手指触摸它们。在第一章“向 Unity 和 Android 问好”中,我们安装了应用程序,Unity Remote。将设备连接到您的计算机并启动它;当你在 Unity 编辑器中点击播放时,你应该能在你的设备上看到游戏正在运行。如果你在 Unity 编辑器中看到游戏正在播放,但在设备上没有看到,只需重新启动 Unity。确保保存它;丢失所有辛勤工作将是可怕的。
毫无疑问,当你使用 Unity Remote 时,你首先会注意到游戏看起来并不好。它几乎肯定会被拉伸和像素化。如果你现在并不在意,不用担心,当项目变得更加复杂时,情况会更糟。现在,在你开始恐慌,抱怨为什么你必须安装这样一个无用的程序之前,你必须理解 Unity Remote 的目的。我们之前已经讨论过这个问题,但我们将进一步深入探讨。Unity Remote 用于测试设备输入:触摸屏、倾斜等。它的外观是释放带宽的结果,以便帧率与 Unity 编辑器中的帧率相同。
关于拉伸问题,你可以做并且应该做的是。在 Unity 编辑器的游戏窗口的左上角有一个下拉列表。默认情况下,它设置为自由比例,这意味着窗口将填充所有可用空间。如果你点击它,会显示一系列的纵横比选项。点击这些选项,你会在游戏窗口中看到黑色条带。这是 Unity 正在调整游戏窗口并屏蔽未使用的空间。选项会根据构建目标而变化。在构建设置窗口中,将你的平台更改为 Android。在游戏窗口的下拉菜单中,找到一个与你的设备匹配的纵横比。选择该选项后,使用 Unity Remote 时你的游戏将不再出现拉伸。
行动时间 - 完成游戏的创建
让我们通过创建一个开场屏幕来完成我们游戏的创建。然后,添加一些检查以防止玩家多次选择方格。接着检查是否有人获胜,并最终显示游戏结束屏幕。这样,游戏就准备好让我们让它看起来很棒了。
让我们执行以下步骤来完成我们的游戏:
-
我们将通过首先创建另一个类似于我们的
SquareState脚本来完成所有这些。创建新的GameState脚本并清除默认内容。添加以下代码片段,我们将得到跟踪我们游戏当前状态的所需值:public enum GameState { Opening, MultiPlayer, GameOver } -
现在,我们需要更新我们的
TicTacToeControl脚本。首先,因为我们想能够玩多个游戏,所以将NewGame函数添加到脚本中。这个函数初始化我们的控制变量,以便我们可以从一张干净的棋盘开始新游戏。如果玩家开始新游戏时棋盘已经填满,这对玩家来说效果不会很好。这个函数将由我们即将编写的菜单主界面使用。public void NewGame() { xTurn = true; board = new SquareState[9]; } -
但首先,我们需要更新我们的
OnGUI函数。为此,首先将OnGUI中的所有当前内容移动到一个名为DrawGameBoard的新函数中。 -
现在,我们需要将我们的已清除的
OnGUI函数更改为以下代码片段,以便它能够根据当前游戏状态检查并绘制适当的屏幕。switch语句与一系列的if和else if语句的工作方式相同。在我们的情况下,我们检查游戏状态,并根据它是调用不同的函数。例如,如果游戏状态等于GameState.MultiPlayer,我们将调用DrawGameBoard函数,该函数现在应包含之前在OnGUI函数中的内容。public void OnGUI() { switch(gameState) { case GameState.Opening: DrawOpening(); break; case GameState.MultiPlayer: DrawGameBoard(); break; case GameState.GameOver: DrawGameOver(); break; } } -
到目前为止,你可能想知道那个游戏状态变量是从哪里来的。如果你猜它是 Unity 自动提供的,那你就错了。我们必须跟踪我们自己的游戏状态。这就是为什么我们之前创建了
GameState脚本。将以下代码行添加到我们的TicTacToeControl类顶部,就在我们定义游戏板的地方上方:public GameState gameState = GameState.Opening; -
接下来,我们需要创建另外两个游戏状态屏幕。让我们从开场屏幕开始。当我们绘制开场屏幕时,我们首先定义用于标题的
Rect类。然后,我们快速调用GUI.Label。通过传递一个Rect类来定位文本,以及一些文本,文本就被简单地绘制在屏幕上。这个函数是绘制屏幕上文本段落的最佳方式。public void DrawOpening() { Rect titleRect = new Rect(0, 0, 300, 75); GUI.Label(titleRect, "Tic-Tac-Toe"); -
以下代码行定义了用于我们的
New Game按钮的Rect类。我们想确保它位于标题下方,所以它从标题的 x 位置开始。然后,我们将标题的 y 位置与其高度相加,以找到位于其下方的位置。接下来,我们使用标题的宽度,以便我们的按钮覆盖其下的整个位置。最后,高度设置为75,因为这是一个适合手指的好尺寸,我们不想让它根据标题改变。我们同样可以使用标题的所有值,或者只输入数字,但我们的标题将在我们开始设计样式时改变。Rect multiRect = new Rect(titleRect.x, titleRect.y + titleRect.height, titleRect.width, 75); -
最后,我们调用一个将绘制我们的按钮的函数。你可能还记得我们绘制游戏板时使用的
GUI.Button函数。如果按钮被按下,游戏状态将被设置为MultiPlayer,这将开始我们的游戏。同时也会调用NewGame函数,这将重置我们的游戏板。当然,还有一个额外的花括号来结束函数。if(GUI.Button(multiRect, "New Game")) { NewGame(); gameState = GameState.MultiPlayer; } } -
我们还剩下最后一个屏幕需要绘制,那就是游戏结束屏幕。为了实现这个功能,我们将创建一个由我们的
OnGUI函数引用的函数。然而,为了让一个游戏结束,必须有胜者,所以在我们游戏状态变量下面添加以下代码行。我们正在扩展使用SquareState枚举。如果胜者变量等于Clear,则没有人赢得游戏。如果它等于XControl或OControl,则相关玩家获胜。不用担心,当我们创建下一个游戏结束屏幕和稍后赢家检查系统时,这会更有意义。public SquareState winner = SquareState.Clear; -
在
DrawGameOver函数中没有什么特别新的内容。首先,我们将定义我们将要写谁赢了游戏的位置。然后,我们将使用我们的胜者变量来确定谁赢了。在绘制胜者标题后,我们使用的Rect类将根据其高度向下移动,以便可以重复使用。最后,我们将绘制一个按钮,将我们的游戏状态改回Opening,这当然是我们的主菜单。public void DrawGameOver() { Rect winnerRect = new Rect(0, 0, 300, 75); string winnerTitle = winner == SquareState.XControl ? "X Wins!" : winner == SquareState.OControl ? "O Wins!" : "It's A Tie!"; GUI.Label(winnerRect, winnerTitle); winnerRect.y += winnerRect.height; if(GUI.Button(winnerRect, "Main Menu")) gameState = GameState.Opening; } -
为了确保我们不覆盖别人已经控制的方块,我们需要对我们的
DrawGameBoard函数做一些修改。首先,如果玩家可以轻松地知道谁的回合,那将很有帮助。为此,我们将在函数的末尾添加以下代码片段。这应该开始变得熟悉。我们首先定义我们想要绘制的位置。然后,我们将使用我们的xTurn布尔值来确定要写关于谁的回合。最后,我们使用GUI.Label函数在屏幕上绘制它。Rect turnRect = new Rect(300, 0, 100, 100); string turnTitle = xTurn ? "X's Turn!" : "O's Turn!"; GUI.Label(turnRect, turnTitle); -
我们现在需要更改绘制棋盘方格的部分,即
GUI.Button函数。我们需要只在方格为空时绘制该按钮。下面的代码片段将通过将按钮移动到新的if语句中来完成这一任务。它检查棋盘方格是否为空。如果是,我们绘制按钮。否则,我们使用标签将所有者信息写入按钮的位置。if(board[boardIndex] == SquareState.Clear) { if(GUI.Button(square, owner)) SetControl(boardIndex); } else GUI.Label(square, owner); -
我们最后需要做的是创建一个检查胜者的系统。我们将在
MonoBehaviour类提供的另一个函数中完成这项工作。LateUpdate在每个帧的末尾被调用,就在屏幕上绘制事物之前。你可能自己会想,为什么我们不创建一个在OnGUI末尾被调用的函数,因为OnGUI已经每帧被调用?原因是当绘制一些 GUI 元素时,OnGUI函数会变得有些奇怪。它有时会被调用多次,以便绘制所有内容。所以,大部分功能不应该由OnGUI控制。这就是Update和LateUpdate的作用。Update是正常的游戏循环,大多数游戏功能都是从这里调用的。LateUpdate用于需要在对象更新之后发生的事情,比如我们的游戏结束检查。 -
将以下
LateUpdate函数添加到我们的TicTacToeControl类中。我们将从一个检查开始,确保我们甚至应该检查胜者。如果游戏不在我们正在玩的状态,在这个例子中是MultiPlayer,在这里退出并不再继续。public void LateUpdate() { if(gameState != GameState.MultiPlayer) return; -
接着是一个简短的
for循环。在这个游戏中,胜利意味着连续三个匹配的方格。我们首先检查由循环标记的列。如果第一个方格不是Clear,则将其与下面的方格进行比较;如果它们匹配,则将其与下面的方格进行比较。我们的棋盘存储为一个列表,但以网格的形式绘制,因此我们需要加三才能向下移动一个方格。else if语句随后检查每一行。通过将循环值乘以三,我们将跳过每一循环的一行。我们再次将方格与SquareState.Clear进行比较,然后与它右侧的方格比较,最后与两个方格右侧的方格比较。如果任一条件组成立,我们将该组中的第一个方格发送到另一个函数以更改我们的游戏状态。for(int i=0;i<3;i++) { if(board[i] != SquareState.Clear && board[i] == board[i + 3] && board[i] == board[i + 6]) { SetWinner(board[i]); return; } else if(board[i * 3] != SquareState.Clear && board[i * 3] == board[(i * 3) + 1] && board[i * 3] == board[(i * 3) + 2]) { SetWinner(board[i * 3]); return; } } -
下面的代码片段与之前我们编写的
if语句大致相同。然而,这些代码行检查的是对角线。如果条件成立,再次调用其他函数以更改游戏状态。你可能也注意到了函数调用后的返回值。如果在任何时刻找到了胜者,就没有必要再检查棋盘上的其他位置。因此,我们将提前退出LateUpdate函数。if(board[0] != SquareState.Clear && board[0] == board[4] && board[0] == board[8]) { SetWinner(board[0]); return; } else if(board[2] != SquareState.Clear && board[2] == board[4] && board[2] == board[6]) { SetWinner(board[2]); return; } -
这是我们的
LateUpdate函数的最后一点。如果没有玩家获胜,如该函数的前一部分所确定的,我们必须检查是否有平局。这是通过检查游戏板上的所有方格来完成的。如果任何一个方格是Clear,则游戏尚未结束,我们退出函数。但是,如果我们整个循环中没有找到Clear方格,我们就设置赢家并宣布平局。for(int i=0;i<board.Length;i++) { if(board[i] == SquareState.Clear) return; } SetWinner(SquareState.Clear); }小贴士
请务必记得关闭最后一个花括号。这是关闭
LateUpdate函数所必需的。如果你忘记了,一些令人烦恼的错误将会出现。 -
最后,我们将创建一个
SetWinner函数,这个函数将在我们的LateUpdate函数中被反复调用。简短而直接,我们将传递给这个函数,它将决定赢家。它设置我们的赢家变量并将游戏状态改为GameOver。public void SetWinner(SquareState toWin) { winner = toWin; gameState = GameState.GameOver; }![行动时间 - 完成创建游戏]()
刚才发生了什么?
就这些了。恭喜!我们现在有一个完全功能正常的井字棋游戏,并且你成功完成了这个过程。在接下来的章节中,我们终于可以把它做得更漂亮了。这是一件好事,因为,如截图所示,游戏现在看起来并不好看。
GUI 皮肤和 GUI 风格
GUI 风格是我们改变 Unity 中 GUI 元素、按钮和标签的外观和感觉的方式。GUI 皮肤包含多个 GUI 风格,并允许我们改变整个 GUI 的外观,而无需为每个元素显式定义 GUI 风格。要创建一个 GUI 皮肤,在 Unity 编辑器的项目窗口中右键单击,就像创建一个新的脚本一样。转到创建,但不是选择脚本,而是到底部选择GUI 皮肤。选择此选项将创建新的 GUI 皮肤,并允许我们将其命名为GameSkin。通过在检查器窗口中查看我们的GameSkin,你可以看到我们可以使用什么。

-
在顶部是一个字体属性。通过将字体文件导入到你的项目中并放置在这里,你可以改变整个游戏中文本使用的默认字体。
-
在下面是一个长的 GUI 元素列表,包括我们的好朋友按钮和标签。这些都是 GUI 风格,与我们在屏幕上绘制东西所使用的 GUI 函数相对应。例如,除非有其他指定,当我们使用
Button函数时,它将使用按钮GUI 风格来绘制。 -
在 GUI 元素列表之后是一个自定义风格属性。这就是我们可以放置任何我们想要使用的额外风格的地方。在我们的十二个按钮中,也许我们想要一个按钮显示红色文字。这种 GUI 风格就会放在这里。
-
在底部是一个设置属性。通过展开它,我们可以看到它相当简短。它包括是否可以使用多击进行选择、光标的颜色以及它在文本字段中闪烁的速度,以及所选单词的高亮颜色。这里的默认值已经很好了。除非有非常具体的外观或需求,否则可以忽略这些值。
现在,让我们来看看成为 GUI 样式所需具备的条件。从我们的GameSkin示例中扩展按钮GUI 样式。无论 GUI 样式用于什么,它们都是由相同的元素组成的。它看起来像有很多属性组成 GUI 样式,但其中大多数几乎是相同的,这使得它变得简单得多。

-
第一个属性相当直接,但也许是最重要的。名称是 Unity 用来查找 GUI 样式并将其应用于 GUI 元素的方式。它让我们知道样式应该是什么;然而,如果名称和代码之间存在拼写错误,你将永远看不到你的样式在游戏中。
-
接下来的几组值描述了 GUI 元素在特定状态下应该看起来如何。这是你将放置大部分样式的位置。任何元素的初级状态主要是正常、悬停、激活和聚焦。次要状态是正常时、悬停时、激活时和聚焦时。这些次要状态仅在 GUI 元素转移到相应的初级状态时发生。并非每个 GUI 元素都使用每个状态,并且你可以控制元素可以进入哪些状态,但我们将稍后讨论这一点。让我们详细看看这些状态是如何工作的:
-
正常状态:这是任何 GUI 元素的默认状态。它始终被使用,并且当元素没有被交互时发生。
-
悬停状态:这个状态主要用于按钮和其他可点击元素。当你的鼠标位于 GUI 元素上方时,如果可能,它将进入这个状态。然而,由于本书的重点是触摸屏,我们没有鼠标真正需要关心。因此,我们不会使用这个状态。
-
激活状态:这可能是第二重要的状态。当元素被激活时,它会进入这个状态。例如,当按下按钮时,它就是激活状态。通过点击或触摸按钮,它进入激活状态。所有可以交互的 GUI 元素都使用这个状态。
-
聚焦状态:这是一个很少使用的状态。在 Unity 的 GUI 中,聚焦意味着拥有键盘控制。默认使用它的唯一元素是文本字段。
-
-
如果你展开任何状态,你会看到它有两个属性,背景 和 文字颜色。背景 属性是一个纹理。它可以是你游戏中任何纹理。文字颜色 属性是 GUI 元素中出现的任何文字的颜色。除了 正常 状态外,如果一个状态没有背景纹理,则不会使用它。这既有好的一面也有不好的一面。如果我们不希望我们的按钮显示它们已被悬停,只需从悬停状态的 背景 属性中移除纹理即可。当我们想要一个没有自己背景图像的 GUI 元素,但同时又想在不同状态下改变文字颜色时,这会变得很烦人。我们如何使用活动状态,但不使用背景纹理呢?答案是,我们创建一个空白图像,但这并不像保存一个 100%透明的 PNG 文件并使用它那样简单。GUI 样式太智能了。它会检测到图像是完全空白的,这使得它与没有图像没有区别。因此,该状态仍然没有被使用。为了解决这个问题,创建一个小的空白 PNG 图像,但取一个像素并使其 90%透明白色。这看起来可能像是一种黑客解决方案,但不幸的是,这是唯一的方法。在如此低的透明度下,我们无法检测到像素;尽管它实际上并不透明。然而,Unity 会看到有一个稍微白色的像素必须被绘制,并这样做。
-
现在,你可能会想,这很愚蠢。我只是要创建所有按钮的图像,而不必担心文字。这确实很愚蠢,但对此的回应是,如果你需要稍微改变按钮的文字怎么办?或者按钮上的文字可能是基于玩家名字的动态文本。在参与的几乎所有项目中,都有创建这种不完全空白图像的需求。
-
在 GUI 元素的各个状态下面是 边界, 边距, 填充, 和 溢出。这些属性控制元素如何与它的背景图像和包含的文本交互。在每个属性中,你将找到 左, 右, 上, 和 下 的值。由于每个元素都被绘制为一个矩形,这些对应于该矩形的每一侧。它们是以像素为单位的,就像我们的 GUI 空间一样。让我们详细看看所有这些属性,如下所示:
-
边界: 这允许我们定义每个边应该有多少像素不应被拉伸。在定义 GUI 元素时,背景通常均匀地拉伸到占据的空间。如果你要创建一个带有红色边缘和圆角蓝色的框,这些值将保持你的边缘和角落规则,同时仍然拉伸内部的蓝色。
-
边距: 这仅由 Unity 的自动 GUI 布局系统 GUILayout 使用。它表示元素外部应该有多少额外的空间。
-
填充是元素边框和包含的文本之间的空间。如果你想将按钮的文本左对齐但稍微向右偏移,你应该使用填充。
-
溢出定义了背景图像的额外空间。在创建我们的按钮时,我们定义了一个
Rect类来表示按钮占据的空间。如果我们使用Overflow,按钮本身将是Rect类所在的位置,但背景将根据值延伸到每个边缘。这对于周围有阴影或发光效果的按钮很有用。
-
-
接下来的几个值与元素中的文本有关。字体属性是专门由这种样式使用的字体。如果这个值留空,则使用 GUI 皮肤中的字体。字体大小决定了文本字母的大小。这就像你的最喜欢的文字处理器一样工作,除了零值表示使用字体对象中定义的默认字体大小。字体样式也像你的文字处理器一样工作。它让你可以选择正常、加粗和斜体文本。这只有在你的选择字体支持的情况下才有区别。
-
对齐定义了在 GUI 元素中放置文本的位置。想象一下将你的元素分成一个 3x3 的网格。对齐与网格的位置相同。
-
自动换行定义了文本是否应该分成多行,如果它太长。它再次遵循与你的文字处理器相同的原理。如果选中,并且文本行会超出 GUI 元素的边缘,文本将分成必要的行数以保持在其范围内。
-
富文本是 GUI 样式的相对较新且有趣的功能。它允许我们使用 HTML 样式标记来控制文本。你可以在标签的文本中围绕一个单词放置
<b>和</b>标签,而不是编写这些标签,Unity 将使标签之间的单词加粗。我们可以使用加粗、斜体、大小和颜色标签。这允许我们选择性地使文本的某些部分加粗或斜体。我们可以使某些单词更大或更小。此外,可以使用十六进制值更改文本任何部分的颜色。 -
文本裁剪在最近更新中变得有些奇怪。它曾经是一个很好的值下拉列表,但现在它是一个整数字段。无论如何,它仍然履行其功能。如果文本超出 GUI 元素的边缘,这个属性决定了要做什么。零值表示不裁剪文本,让它超出边缘。任何非零值都会导致文本被裁剪。任何超出边界的文本将简单地不会被绘制。
-
图像位置与 GUIContent 一起使用。GUIContent 是一种将文本、图标图像和工具提示传递给 GUI 元素的方式。图像位置描述了图像和文本的交互方式。图像可以位于文本的左侧或上方。或者,我们可以选择只使用图像或文本。由于在触摸环境中工具提示并不真正有用,所以 GUIContent 对我们来说用途有限。因此,我们将不会广泛使用它,如果使用的话。
-
内容偏移通过提供的值调整 GUI 元素内部包含的任何内容。如果你的所有文本通常在按钮中居中,这将允许你将其稍微向右和向上移动。这是一个美学问题,当你需要一个非常具体的视觉效果时。
-
固定宽度和固定高度提供大约相同的功能。如果为这些属性提供了除零以外的任何值,它们将覆盖用于 GUI 元素的
Rect类中对应的值。因此,如果你想让按钮始终宽一百像素,无论它们在游戏中的位置如何,你可以将固定宽度设置为 100,它们就会这样做。 -
拉伸宽度和拉伸高度也起到类似的作用。它们被 GUILayout 用于 GUI 元素的自动放置。它基本上给系统授权,使其元素可以更宽/更瘦和更高/更短,以满足其更好的布局条件。GUILayout 排列元素的方式并不总是最好的。如果你需要快速完成某事,它很好。但如果你想要更深入的控制,就会变得复杂。
更美观的井字棋
最后,我们可以将关于 GUI 皮肤和 GUI 样式的知识应用到实际中,使我们的游戏看起来更好。或者,至少让游戏看起来没有使用默认资源。无论你的艺术才能如何,你都需要找到或创建一些图像,以便继续学习。
是时候为游戏添加样式了
如果你不想找得太远,本章使用的资源可以在与书籍资源一起找到。所有需要的图像都可用,并且它们将工作得很好,直到你有机会创建一些自己的。
-
首先,我们需要五个小纹理:
ButtonActive、ButtonNormal、ONormal、XNormal和Title。为了创建这些,你将不得不使用一个单独的图片编辑程序或使用包含在项目中的那些。 -
将图像放入你的 Unity 项目中最简单的方法是将它们保存到创建新项目时创建的
Assets文件夹中。或者,你可以点击顶部并选择资产,然后选择导入新资产。这将打开一个文件浏览器,让你导航到想要导入的资源。当你找到你想要导入的资源并点击导入按钮时,该资源的副本将被放入你的项目中。Unity 不会移动或删除当前项目之外的文件。-
现在关于纹理的导入设置有一个说明。默认情况下,Unity 假设导入到项目中的任何图像文件都将用作游戏模型纹理。因此,Unity 会压缩它们,并将它们调整为适合 2 的幂。
小贴士
如果你不知道,在计算机图形学中,渲染可以均匀分成一半的图像要快得多,直到单个单位。有更深层的原因,但简单来说,这是因为构成计算机的实际上是二进制开关。
![行动时间 - 游戏样式]()
-
让我们的图像被识别为 GUI 的图像非常简单。在纹理类型的右侧,点击纹理并从下拉菜单中选择GUI。
-
你会注意到我们得到了一个新的过滤器模式下拉菜单。这基本上是 Unity 在调整图像大小以适应各种 GUI 元素时将投入多少努力。三线性是最好的,点是最快的,双线性位于中间。
-
一旦更改了导入设置,请务必点击应用,否则当你尝试做其他任何事情时,Unity 都会抱怨。如果你不想提交更改,点击还原将丢弃刚刚所做的任何更改,并将导入设置窗口恢复到最后一次使用的配置。
-
-
因此,将所有图像的纹理类型设置为 GUI,我们就可以继续了。
-
让我们从游戏开始的地方开始。打开你的
TicTacToeControl脚本,并在开头添加以下代码行。这些代码允许我们在 Unity 编辑器内部附加对其他资源的引用。第一个将保存我们的GameSkin,这样我们就可以为所有的 GUI 元素设置样式。第二个,正如你在下面的代码行中可以看到的,将保存我们的精美标题图像:public GUISkin guiSkin; public Texture2D titleImage; -
现在转到 Unity 编辑器,并从层次结构窗口中选择主摄像机。
-
你在层次结构窗口中看到的每一个列表对象都是一个
GameObject。通过附加到它上的各种组件,GameObject被赋予了目的和意义,例如我们的TicTacToeControl脚本。 -
一个空的
GameObject只是一个由Transform组件定义的空间点,该组件是任何GameObject上的第一个组件。 -
您可以在检查器窗口中看到,主摄像机对象有一个摄像机组件。它为
GameObject赋予目的,并控制摄像机组件的功能,就像我们底部的TicTacToeControl组件让我们控制我们的井字棋游戏一样。 -
检查器窗口还让我们能够看到在 Unity 编辑器中可以更改的所有公共变量。如果它们被更改,这些值将在游戏播放时保存并使用。因此,通过在我们的脚本中创建一个变量,我们可以将
GameSkin的引用添加到其中,它将在游戏中使用。要添加引用,只需将对象拖放到检查器窗口中所需的变量上即可。
-
-
将
GameSkin拖到GUI 皮肤槽位,并将我们的标题图像拖到标题图像槽位。小贴士
一定要保存。定期保存是您和过早脱发之间唯一的障碍,下次您的电脑决定死亡时。
-
在我们的
TicTacToeControl脚本中,在OnGUI函数的开始处添加以下代码行。它首先检查是否有可用的 GUI 皮肤。如果有,它将设置到GUI.skin变量中。这个变量控制游戏中使用的 GUI 皮肤。一旦设置,之后绘制的任何 GUI 元素都将使用新的 GUI 皮肤。这可以允许您设置一个 GUI 皮肤并绘制一半的 GUI,然后设置不同的 GUI 皮肤并在完全不同的风格中绘制另一半。if(guiSkin != null) GUI.skin = guiSkin; -
如果您现在玩游戏,它看起来不会太多。新的 GUI 皮肤默认值与 Unity 使用的默认 GUI 皮肤完全相同。让我们通过选择我们的
GameSkin并在检查器窗口中展开按钮和标签来改变它。 -
对于按钮,我们创建了按钮正常和按钮活动图像。通过将它们拖到相应状态的背景属性中,按钮的外观将发生变化。
-
供应的按钮图像有黄色背景,这将使白色文字难以阅读。因此,通过点击文本颜色属性旁边的颜色,将打开颜色选择器窗口,我们可以选择新的颜色。对于正常状态,海军蓝效果很好;对于活动状态,淡蓝色效果很好。
-
此外,为了防止在 Unity 编辑器中看起来奇怪,请移除悬停状态。在触摸界面上,没有光标可以悬停在按钮上;因此,不需要悬停状态。要移除它,首先点击背景图像右侧的小圆圈。
![操作时间 - 设置游戏样式]()
-
弹出的新窗口允许我们选择项目中当前任何图像。然而,由于我们希望那里没有任何内容,请选择列表中的第一个选项无。
-
按钮图像的边框比默认按钮的边框大得多。因此,我们需要调整边框属性来适应它们。每边 15 的值效果很好。
-
文本也太小,因此对于字体大小属性,选择一个值为 40 的值。这将给我们一个字体大且易于阅读的文本。
-
对于标签元素,我们只会进行两项更改。首先,文本太小。因此,它的字体大小也将设置为 40。其次,我们希望文本在 GUI 元素中居中。这需要将对齐设置为中间中心。
![操作时间 - 为游戏添加样式]()
-
现在就玩游戏吧。它看起来已经更好或者至少不同了。然而,一眼看去很难判断谁控制着哪个方块。为了解决这个问题,我们将创建两种自定义 GUI 样式。要做到这一点,请在我们检查器窗口中展开
GameSkin的自定义样式属性。默认情况下,已经有一个空白样式在那里。我们需要两个,但暂时不要改变数量。 -
通过默认名为
Element 0的扩展自定义 GUI 样式。 -
在名称属性右侧点击,大约在检查器窗口的中间位置,将允许我们重命名样式。名称非常重要。无论我们在这里叫什么,都需要在代码中完全相同地调用它,否则它将不会被使用。给它命名为
XSquare,因为它将被用来标记由 X 玩家控制的方块。 -
在正常状态下,将XNormal图像添加到背景属性。文本颜色属性可以保持为黑色。我们还需要调整字体大小和对齐属性,使其与标签元素相同。因此,将它们设置为40和MiddleCenter。
-
现在我们已经创建了第一个样式,创建第二个样式就变得快速且简单。折叠XSquare样式。
-
将自定义样式属性的大小设置为2。在 Unity 编辑器中增加数组大小时,Unity 会将数组中最后一个项目复制到每个新的槽位。因此,我们现在应该有两个
XSquareGUI 样式。 -
扩展第二个 GUI 样式,并将其名称更改为
Osquare。 -
还要将
XNormal的背景图像替换为ONormal图像。小贴士
如果你发现在检查器窗口中拖放有困难,可能是
GameSkin一直在失去焦点;在检查器窗口的顶部有一个锁定按钮。点击它将阻止窗口在选中新对象时切换到其他任何内容。再次点击它将关闭此功能。 -
虽然我们有了我们炫酷的新自定义 GUI 样式,但这并不意味着它们会自动工作。但是,只需一点点的编码就能让它们工作。在我们的
TicTacToeControl脚本的DrawGameBoard函数内部,我们需要更改绘制标签的行,并在其末尾添加一些内容。添加第二个字符串将告诉 GUI 系统查找特定的 GUI 样式。在函数中更早的地方,我们确定谁拥有这个方块,是 X 还是 O。通过添加到Square中,我们创建了我们的两个自定义 GUI 样式的名称,XSquare和OSquare。else GUI.Label(square, owner, owner + "Square"); -
如果你现在玩游戏,你会看到当玩家声称控制一个方块时,我们的自定义样式就会出现。
![行动时间 - 设置游戏样式]()
-
要改变井字棋游戏的外观,还有一件事要做。你还记得创建的标题图像以及我们为其添加的变量吗?现在就是放置它的时刻。在
TicTacToeControl中进入DrawOpening函数。为了绘制我们的图像,我们需要用GUI.DrawTexture的调用替换GUI.Label的调用。这个函数不是使用 GUI 样式,而是简单地将在屏幕上绘制一个图像。它使用Rect类,就像我们所有的按钮和标签一样,来定义大小和位置。默认情况下,图像会被拉伸以填充整个Rect类。目前,这非常适合我们。GUI.DrawTexture(titleRect, titleImage); -
我们可以通过更新定义标题
Rect类的上一行代码来修复拉伸。正如以下代码片段所示,我们使用titleImage的宽度和高度来确定titleRect的宽度和高度。现在Rect类会根据标题图像的大小自动确定它应该有多大。如果Rect类与图像具有相同的大小和形状,它就不会被拉伸。此外,由于我们为新游戏按钮定义了Rect类,它仍然直接位于标题图像下方,并且与标题图像一样宽。Rect titleRect = new Rect(0, 0, titleImage.width, titleImage.height); -
这就是为井字棋游戏设置样式的全部内容。点击播放按钮,看看你所有的辛勤工作。
![行动时间 - 设置游戏样式]()
发生了什么?
我们让我们的井字棋游戏看起来很棒,至少不像默认的。我们通过使用一些图像和一些自定义 GUI 皮肤和 GUI 样式实现了这一点。通过添加一个用于在屏幕上绘制纹理的特殊函数,我们还添加了一个独特的标题图像到我们的开场屏幕。
尝试一下英雄 - 背景
你的挑战是在游戏后面绘制一个背景图像,并且它必须覆盖整个屏幕。默认的蓝色很好,但我们可以做得更好。作为备注,无论哪个 GUI 元素是最后绘制的,它都会在最上面,所以仔细考虑在哪里调用函数以在背景中绘制图像。此外,由于拉伸只适用于锻炼和橡皮筋,请查看是否也传递一个ScaleMode函数,这是一个 Unity 使用的特殊值类型,用于确定图像应该如何拉伸。查看脚本参考或在网上搜索以获取更多信息。
动态定位
你可能认为游戏现在样式齐全,但所有内容仍然都位于屏幕的左上角。那么,你很幸运。这正是本节的主题。仅仅调整我们Rects中的数字,直到我们的 GUI 居中是不够的。在处理 Android 平台和其他移动设备时,我们必须准备好应对各种可能性。不是我们游戏将在其上运行的所有设备都具有相同的屏幕尺寸。因此,你可能将 GUI 定位在平板电脑上居中,但在手机上它将远离屏幕。
行动时间 - 动态 GUI
我们将介绍两种动态调整 GUI 以适应任何屏幕要求的方法。开场屏幕和游戏结束屏幕都将居中。我们将拉伸游戏板以填充可用空间。回合指示文本也将根据屏幕方向自动更改位置。
-
再次,让我们从主菜单开始。打开
TicTacToeControl脚本并转到DrawOpening函数。 -
为了使菜单居中,我们将在
DrawOpening函数的开始处添加以下代码行,将内容包装为 GUI 组。将 GUI 的分组想象成某些电视可以做的画中画(PIP)。在屏幕上选择一个矩形区域,并在其中绘制另一个频道。所以,首先我们决定在哪里绘制我们的组。我们通过找到屏幕的中心,将Screen.width和Screen.height除以二来实现这一点。但是,由于 GUI 内容定位在左上角,我们必须减去我们内容大小的一半以找到那个角落。对于宽度,那只是我们的标题图像的宽度。但高度是图像和下面的按钮的组合。Rect groupRect = new Rect((Screen.width / 2) - (titleImage.width / 2), (Screen.height / 2) - ((titleImage.height + 75) / 2), titleImage.width, titleImage.height + 75); -
GUI 的
BeginGroup函数为我们提供了画中画效果。在调用此函数之后绘制的任何 GUI 元素都将限制在传递给函数的Rect类中。与从屏幕左上角开始的位置不同,组内的元素将从组的左上角Rect开始。任何超出组边缘的内容也不会绘制,就像它超出了屏幕边缘一样。GUI.BeginGroup(groupRect); -
在我们能够看到组的作用之前,我们必须在
DrawOpening函数的末尾添加一行。EndGroup是BeginGroup的直接对应物。如果你使用了BeginGroup,那么必须有相应的EndGroup调用。如果你没有配对函数调用,Unity 会一直抱怨,直到问题得到解决。这真的很烦人。GUI.EndGroup(); -
添加了这一行后,开始玩游戏。主菜单现在将自动居中。无论屏幕大小如何,它都会这样做。此外,无论屏幕是横屏还是竖屏模式,它都会这样做。在这种情况下,保持所有内容都在屏幕上的技巧是计划最小的屏幕尺寸,并制作相应大小的图像和 GUI 元素。
-
快进一点,我们可以使用类似的方法来居中游戏屏幕。在
DrawGameOver函数的开头添加以下代码行。你可以看到我们正在做刚才做过的事情。确定屏幕的中心,并减去内容总大小的一半。在这种情况下,我们提供了具体的数字而不是根据图像的大小来设置。此外,因为数学很简单,我们已经在计算中完成了除法,得出了150和75。Rect groupRect = new Rect((Screen.width / 2) - 150, (Screen.height / 2) - 75, 300, 150); GUI.BeginGroup(groupRect); -
一定要在
DrawGameOver函数的末尾添加你的EndGroup函数调用。GUI.EndGroup(); -
之后,找到定义
winnerRect变量类的行。我们需要修改它,使其更容易调整大小并适应内容,如果我们想要的话。由于我们设置了 winner 标签和 主菜单 按钮,这会导致每个都占用整个组的宽度。它们还会平均分割可用的高度;因此,除以二。Rect winnerRect = new Rect(0, 0, groupRect.width, groupRect.height / 2); -
现在我们来到了一个棘手的部分。对于游戏板,我们希望它均匀扩展,以便填充最短的方向。回合指示器应该位于剩余空间的中心。因为游戏板需要动态扩展,而回合指示器需要根据方向在屏幕的右侧或底部,所以我们不能仅仅使用我们的 GUI 组函数。相反,我们首先需要确定屏幕的哪一侧更小,是宽度还是高度。这通过在
DrawGameBoard函数开头添加以下几行代码来实现。你能认出这个条件语句,我们那位老朋友吗?首先,我们创建一个变量来保存屏幕宽度和高度的比较结果;我们稍后会再次使用它。如果宽度更小,显然短边是宽度;否则就是高度。bool widthSmaller = Screen.width < Screen.height; float smallSide = widthSmaller ? Screen.width : Screen.height; -
接下来,我们更改宽度和高度的定义。因为游戏板是一个 3 x 3 的网格,一旦我们有了短边,就可以简单地计算出应该有多大才能填满空间。对高度的改变是为了保持游戏板的方块实际上是正方形。也许你还记得你的第一次几何课程?正方形的边长是相等的。
float width = smallSide / 3; float height = width; -
在这个阶段玩游戏,我们将能够体验到一个与游戏屏幕一起缩放的棋盘。试试看吧!!行动时间 – 动态 GUI
-
记得我们连接 Unity Remote 的时候吗?使用游戏窗口左上角的下拉菜单选择不同的屏幕尺寸和方向。然而,这却揭示了一个小错误。回合指示器的文本有时会出现在我们的游戏板上方。有时它可能超出了屏幕的边缘。或者,也许你已经注意到了这一点?无论如何,为了使其更好,我们需要找到将覆盖剩余负空间的
Rect类。 -
在我们定义
turnRect的初始定义之后,添加以下代码片段。使用我们的条件朋友,我们计算出所有需要将Rect类放置在负空间中的内容。如果在纵向模式下宽度较小,负空间从屏幕的左侧开始,即零。空间的位置从游戏板的结束处开始,相当于宽度;毕竟,它是一个正方形板。负空间的总宽度也相当于屏幕的宽度。高度成为高度和宽度之间差异的剩余部分。如果我们处于横向模式,高度小于宽度,定位方式大致相同。turnRect.x = widthSmaller ? 0 : smallSide; turnRect.y = widthSmaller ? smallSide : 0; turnRect.width = widthSmaller ? Screen.width : Screen.width - Screen.height; turnRect.height = widthSmaller ? Screen.height - Screen.width : Screen.height; -
这一切都很不错。当回合文本实际放置在易于看到和阅读的位置时,看起来相当不错。但是,在某些屏幕尺寸下,有大量的空白空间。如果有一种方法可以调整文本以更好地适应空间就好了。恰好有一种很好的方法可以做到这一点。在我们完成对回合指示器的
Rect的修改后,我们可以添加以下一行代码。这段代码从当前的 GUI 皮肤中获取标签 GUI 样式并创建一个副本。在代码中,如果我们创建一个新的 GUI 样式并将另一个样式作为参数传递,所有值都会复制到新的 GUI 样式中。这允许我们进行临时和动态的更改,而不会破坏正在使用的整个 GUI 皮肤。GUIStyle turnStyle = new GUIStyle(GUI.skin.GetStyle("label")); -
在下一行代码中,我们将调整字体大小。为了做到这一点,我们必须计算出在游戏板放大后屏幕长边剩余的空间量。将屏幕的宽度和高度相加得到可用的总屏幕距离。通过减去两个边中较小的一边,即游戏板覆盖的距离乘以二,我们得到多余的负空间。将所有这些除以一百,即我们之前用于我们的回合指示器的空间量,将字体大小按比例调整以适应空间的变化。最后,通过显式转换为整数类型,因为字体大小值必须定义为整数。
turnStyle.fontSize *= (int)((Screen.width + Screen.height - (smallSide * 2)) / 100); -
要真正看到这个动态字体大小在行动中的效果,我们需要更改绘制回合指示器的行。我们将对
Label函数的调用更改为使用临时样式。我们不再向 GUI 函数提供 GUI 样式的名称,而是可以提供一个特定的 GUI 样式。然后,该函数将使用此样式来绘制 GUI 元素。GUI.Label(turnRect, turnTitle, turnStyle); -
尝试一下。通过点击游戏窗口选项卡并将其拖入游戏窗口,你可以取消停靠窗口并使其自由浮动。将游戏窗口右上角的下拉菜单(纵横比)更改为自由纵横比,我们可以自由调整窗口大小并见证我们的伟大作品在行动中的效果。
![行动时间 – 动态 GUI]()
刚才发生了什么?
我们让我们的游戏根据我们的设备屏幕动态变化。两个菜单屏幕都会居中。我们还让我们的游戏板根据屏幕大小增长和缩小,直到尽可能填满屏幕。然后,我们使用一点精心应用的代码魔法,使回合指示器自动定位并更改字体大小以填充剩余的空间。
尝试一下英雄 – 缩放菜单
第二个挑战稍微有点困难。继续使用 GUI 组,但让开场屏幕和游戏结束屏幕根据屏幕大小缩放。如果你想对这个挑战进行子挑战,看看你能否同时缩放文本。此外,别忘了用于指示游戏板方格控制的文本。
如果你想为更多设备做准备,请更改本节中我们使用的矩形(Rects)。无论我们在屏幕上使用特定数字来表示位置或大小,都要将它们改为百分比。你将需要使用百分比和屏幕大小来计算像素大小。然后,可以将计算出的数值传递给我们的矩形并使用它。
更好地构建到设备的方法
现在是构建过程中每个人都很想学习的一部分。有一个更快更简单的方法可以让你的游戏在 Android 设备上构建并运行。虽然长而复杂的方法仍然很好,但如果你使用这个较短的路径失败,并且它最终会失败,了解长方法将有助于调试任何错误。此外,短路径仅适用于构建到单个设备。如果你有多个设备和大项目,使用短路径将需要更多的时间来加载它们。
行动时间 – 构建并运行
使用这种替代构建方法,我们可以快速轻松地在我们的设备上测试游戏,如下所示:
-
首先,打开构建设置窗口。记住,它可以在 Unity 编辑器的文件菜单中找到。
-
点击添加当前按钮,将我们的当前场景(也是唯一场景)添加到构建中的场景列表中。如果这个列表为空,则没有游戏。
-
确保将你的平台更改为Android,如果你还没有这样做的话。毕竟,这本书的重点就是这一点。
-
不要忘记设置玩家设置。点击玩家设置按钮,在检查器窗口中打开它们。你还记得第一章中的内容吗,向 Unity 和 Android 问好。
-
在顶部,设置公司名称和产品名称字段。
TomPacktAndBegin和Ch2 TicTacToe的值将匹配包含的完成项目。记住,这些值会被玩游戏的人看到。 -
在其他设置下的包标识符字段也需要设置。格式仍然是
com.CompanyName.ProductName,所以com.TomPactAndBegin.Ch2.TicTacToe将很好地工作。 -
为了让我们能在设备上看到我们酷炫的动态 GUI 的实际效果,还有一个其他设置需要更改。点击分辨率和演示以展开选项。
-
我们对默认方向感兴趣。默认是纵向,但这意味着游戏将固定在纵向显示模式。点击下拉菜单并选择自动旋转。此选项告诉 Unity 自动调整游戏,使其在设备被持握的任何方向上都是竖直的。
- 当选择自动旋转时出现的新的选项集允许限制支持的旋转方向。也许你正在制作一个需要更宽屏幕并横屏持握的游戏。通过取消勾选纵向和纵向颠倒,Unity 仍然会调整(但仅限于剩余的旋转方向)。在你的 Android 设备上,沿着较短的边,通常有一些控制按钮,通常是一个主页、菜单、返回和搜索按钮组。这一侧通常被认为是设备的底部,这些按钮的位置决定了每个方向。纵向是指这些按钮相对于屏幕向下。横向右是指它们在右侧。模式开始变得清晰,不是吗?
-
现在,保留所有方向选项的勾选,我们将返回到构建设置。
-
下一步(这一点非常重要)是将你的设备连接到你的电脑,并给它一点时间来被识别。如果你的设备不是首先连接到电脑,这个较短的构建路径将会失败。
-
在构建设置窗口的右下角,点击构建并运行按钮。你将被要求为应用程序文件、APK 文件提供一个相关名称并将其保存到适当的位置。名称
Ch2_TicTacToe.apk将很好,并且足够适合将其保存到桌面。 -
点击保存并坐下来观看提供的精彩加载进度条。如果你在第一章中构建Hello World项目时注意到了加载进度条,与 Unity 和 Android 打招呼,你会注意到这次多了一个步骤。在应用程序构建完成后,会有一个推送到设备步骤。这意味着构建成功,Unity 现在正在将应用程序放到你的设备上并安装它。一旦完成,游戏将在设备上启动,加载进度条将结束。
发生了什么?
我们刚刚学习了构建和运行按钮,它是由构建设置窗口提供的。快速、简单,且免受命令提示符的痛苦;短构建路径不是很好吗?但如果构建过程因任何原因失败,包括无法找到设备,应用程序文件将不会保存。如果你想再次尝试安装,你必须再次经历整个构建过程。这对我们简单的井字游戏来说不是什么大问题,但对于更大的项目可能会消耗很多时间。此外,在构建过程中,你只能将一台 Android 设备连接到你的电脑。如果连接更多,构建过程将肯定失败。Unity 在完成其余可能很长的构建过程之前,也不会检查多个设备。
除了这些警告之外,构建和运行选项实际上非常好。让 Unity 处理将游戏传送到设备上的困难部分。这让我们有更多时间专注于测试和制作一款优秀的游戏。
尝试英雄 – 单人模式
这是一个难题。创建一个单人模式。你将不得不先添加一个额外的游戏状态。对于游戏状态选择多人是否开始有意义?开屏将需要一个额外的按钮来选择第二种游戏模式。此外,任何关于电脑玩家的逻辑都应该放在由MonoBehaviour类提供的Update函数中。电脑需要在我们在LateUpdate中检查胜利之前走它的回合。Update函数正是做这件事的地方。此外,看看Random.Range用于随机选择一个方块来控制。或者,你可以做更多的工作,让电脑搜索一个可以获胜的方块或创建一条两连胜的线路。
摘要
到目前为止,你应该已经熟悉了 Unity 的 GUI 系统,包括 GUI 皮肤、GUI 样式以及各种 GUI 功能。
在本章中,我们通过创建井字棋游戏学习了所有关于 GUI(图形用户界面)的知识。我们首先熟悉了在 GUI 上绘制按钮等元素。在深入研究并理解了 GUI 样式和 GUI 皮肤之后,我们将这些知识应用到我们的游戏中,使其外观更加出色。当我们为 GUI 元素添加一些动态定位后,游戏继续改进。开场和结束屏幕居中,而游戏板动态缩放以填充屏幕。最后,我们探索了将我们的游戏上传到设备上的另一种构建方法。
在下一章中,我们将开始一个新的、更复杂的游戏。我们将创建的坦克大战游戏将用于理解任何游戏的基本构建块:网格、材质和动画。当一切完成时,我们将能够驾驶坦克在五彩斑斓的城市中穿梭,并射击动画目标。
第三章。任何游戏的骨架——网格、材质和动画
在前一章中,我们学习了关于 GUI 的内容。我们从一个简单的井字棋游戏开始,以了解基本元素。然后,我们对 GUI 进行样式化以改变游戏的外观。最后,我们调整了游戏,使其能够自动扩展以适应任何大小的屏幕。
本章是关于任何游戏的核心:网格、材质和动画。没有这些模块,通常没有什么可以展示给玩家。当然,你可以在 GUI 中使用平面图像。但那样有什么乐趣呢?如果你要选择一个 3D 游戏引擎,你最好充分利用其功能。
为了理解网格、材质和动画,我们将创建一个坦克战斗游戏。这个项目将在其他几个章节中使用。到本书结束时,它将是我们将创建的两个健壮游戏之一。对于本章,玩家将能够驾驶坦克在小型城市中行驶,他/她将能够射击动画目标,我们还将添加一个计数器来跟踪分数。
本章涵盖了以下主题:
-
导入网格
-
创建材料
-
动画
-
创建预制体
-
光线追踪
我们将为本章开始一个新的项目,所以请按照第一部分的内容进行操作。
设置
虽然这个项目最终会变得比之前的更大,但实际的设置是相似的,并不过于复杂。你还需要为这个项目准备一些起始资产;它们将在设置过程中进行描述。由于这些资产的复杂性和特定性质,建议现在使用提供的那些。
行动时间——设置
正如我们在前两章中所做的那样,我们需要创建一个新的项目,这样我们才能创建我们的下一个游戏。显然,首先要做的事情是启动一个新的 Unity 项目。为了组织目的,将其命名为Ch3_TankBattle_CS。
-
这个项目也将变得比我们之前的项目更大,所以我们应该创建一些文件夹以保持组织有序。首先,创建六个文件夹。顶级文件夹将是
Models、Scripts和Prefabs文件夹。在Models内部创建Environment、Tanks和Targets。拥有这些文件夹可以使项目显著更容易管理。任何完整的模型都可以由一个网格文件、一个或多个纹理、每个纹理的材质以及可能成百上千的动画文件组成。 -
在我们走得太远之前,如果你还没有这样做,将你的目标平台更改为 Android 是一个好主意。每次更改目标平台时,项目中的所有资产都需要重新导入。这是 Unity 自动执行的一个步骤,但随着项目的增长,所需的时间会越来越多。在我们项目没有任何内容之前设置目标平台,我们可以在以后节省大量时间。
-
我们还将利用 Unity 的一个非常强大的部分。预制件是特殊对象,可以显著简化创建游戏的过程。这个名字的意思是预制件——事先创建并复制的。对我们来说,这意味着我们可以完全设置一个坦克射击的目标,并将其转换为预制件。然后,我们可以在游戏世界中放置该预制件的实例。如果我们需要更改目标,我们只需修改原始预制件即可。对预制件所做的任何更改也会应用到该预制件的任何实例上。不用担心;使用时会更合理。
-
我们还需要为这个项目创建一些网格和纹理。首先,我们需要一个坦克。没有坦克的坦克战似乎有点困难。提供的坦克有一个炮塔和大炮,它们是独立的部件。我们还将使用一个技巧使坦克的履带看起来像在移动,这样每个履带都是独立的部件,并且使用单独的纹理。
-
对于我们的战场位置,创建了一个城市的一部分。我们不会为城市应用特定的纹理,而是使用可平铺的纹理。此外,城市周围有一堵墙,以防止玩家掉出世界。
-
最后,我们需要一个动画目标。提供的这个目标像人的手臂一样装上了,手部有一个靶心。它有四个动画。第一个从蜷缩位置开始,延伸到伸展位置。第二个是第一个的相反,从伸展位置到蜷缩位置。第三个从伸展位置开始,被猛地甩回,好像被正面击中,然后回到蜷缩位置。最后一个和第三个一样,但它向前移动,好像被从后面击中。这些动画相当简单,但它们将很好地帮助我们了解 Unity 的动画系统。
发生了什么?
这里发生的事情非常少,我们只是创建了项目并添加了一些文件夹。还讨论了我们将在本章的项目中使用的资产。
导入网格
将资产导入 Unity 有多种方法。我们将介绍可能最简单且肯定是最适合资产组的方法。
行动时间 – 导入坦克
让我们开始吧。
-
在 Unity 编辑器中,首先右键单击你的
Tanks文件夹,从菜单中选择在资源管理器中显示。 -
这将打开包含所选资产的文件夹。在这种情况下,Windows 文件夹浏览器中打开了
Models文件夹。我们只需将我们的坦克及其纹理放入Tanks文件夹中。小贴士
为本章提供的文件是
Tank.blend、Tanks_Type01.png和TankTread.png文件。此外,在 Unity 中使用.blend文件需要在你的系统上安装 Blender。Blender 是一个免费建模程序,可在www.blender.org找到。Unity 利用它将之前提到的文件转换为它能够完全利用的文件。 -
当我们返回 Unity 时,我们添加的文件将被检测到,并且它们将被自动导入。这是 Unity 的最好之处之一。无需明确告诉 Unity 导入。如果项目资产中有所更改,它就会自动这样做。
-
你可能还会注意到,当 Unity 导入我们的坦克时,创建了一个额外的文件夹和一些文件。每当导入一个新的网格时,默认情况下 Unity 都会尝试将其与材质配对。我们将在下一节中更详细地介绍 Unity 中的材质是什么。现在,它是一个跟踪如何在网格上显示纹理的对象。根据网格中的信息,Unity 会在项目中查找具有正确名称的材质。如果找不到,将在网格旁边创建一个
Materials文件夹,并在其中创建缺失的材质。在创建这些材质时,Unity 也会搜索正确的纹理。这就是为什么在将网格添加到文件夹的同时添加纹理很重要,这样它们就可以一起导入。如果你没有在添加坦克的同时添加纹理,关于创建材质的部分将描述如何将纹理添加到材质中。
刚才发生了什么?
我们刚刚将坦克导入 Unity。这实际上非常简单。对项目中的任何资产或文件夹所做的更改都会被 Unity 自动检测,并且需要导入的内容都会被适当地导入。
坦克导入设置
当将任何资产导入 Unity 时,都是通过使用一组默认设置来完成的。这些设置中的任何一个都可以从检查器窗口中更改。选择你的新坦克,我们将在这里介绍模型的导入设置。

-
Unity 编辑器的顶部有三个标签页:模型、绑定和动画。模型页面处理网格本身,而绑定和动画用于导入动画。目前,我们只关心模型页面,所以如果尚未选择,请选择它。
-
导入设置窗口的网格部分从缩放因子属性开始。这是一个告诉 Unity 默认网格大小的值。从你的建模程序中,一个通用单位或一米的长度在 Unity 中对应一个单位。这个坦克是用通用单位制作的,所以坦克的缩放因子是 1。如果你在制作坦克时使用的是厘米,缩放因子将是 0.01,因为一厘米是米的百分之一。
-
下一个选项,网格压缩,在最后章节中当我们讨论游戏优化时将变得重要。压缩设置得越高,游戏中的文件越小。然而,这可能会开始给您的网格引入一些奇怪的现象,因为 Unity 正在努力使其变小。目前,请将其保持关闭。
-
读写启用如果您想在游戏运行时对网格进行更改,则非常有用。这可能允许您做一些非常酷的事情,例如可破坏的环境,其中您的脚本根据被射击的位置将网格分解成碎片。然而,这也意味着 Unity 需要在内存中保留网格的副本,如果它很复杂,这可能会真的开始拖慢系统。这超出了本书的范围,所以取消勾选此选项是个好主意。
-
优化网格是一个很好的选项,除非您正在对网格进行某些特定且复杂的工作。开启此选项,Unity 会在幕后进行一些特殊的操作。在计算机图形学,尤其是 Unity 中,每个网格最终都是一系列在屏幕上绘制的三角形。此选项允许 Unity 重新排列文件中的三角形,以便整个网格可以更快、更轻松地绘制。
-
下一个选项,生成碰撞器,如果进行复杂的物理操作时非常有用。Unity 有一组简单的碰撞器形状,应尽可能使用,因为它们更容易处理。然而,在某些情况下,它们可能无法完成工作,例如,对于由一系列简单形状难以构成的复杂碰撞形状,如碎石或半管道。这就是为什么 Unity 有一个网格碰撞器组件。勾选此选项后,每个模型中的网格都会添加一个网格碰撞器组件。在本章中,我们将坚持使用简单的碰撞器,所以请保持生成碰撞器选项关闭。
-
交换 UV和生成光照贴图 UV主要用于处理光照,尤其是光照贴图。Unity 可以处理模型上的两组 UV 坐标。通常,第一组用于纹理,第二组用于光照贴图或阴影纹理。如果顺序错误,交换 UV将会调整它们,使得第二组现在排在第一位。如果您需要一个光照贴图的展开图,但尚未创建,生成光照贴图 UV将为您创建一个。在这个项目中,我们不使用光照贴图,所以这两个选项都可以保持关闭。
-
下一部分选项,法线与切线,从法线选项开始。这定义了 Unity 将如何获取网格的法线。默认情况下,它们是从文件中导入的;但也可以选择让 Unity 根据网格的定义方式计算它们。或者,如果我们将此选项设置为无,Unity 将不会导入法线。如果我们希望网格受到实时光照的影响或使用法线贴图,则需要法线。我们将在本项目中使用实时光照,所以请将其设置为导入。
-
切线、平滑角度和分割切线在您的网格具有法线贴图时使用。切线用于确定光照如何与法线贴图表面交互。默认情况下,Unity 将为您计算这些值。仅从少数文件类型中可以导入切线。平滑角度决定了两个面之间的角度,从而决定了边缘的着色是平滑还是尖锐。分割切线选项用于处理一些特定的光照问题。如果光照被接缝破坏,启用此选项将修复它。法线贴图非常适合使低分辨率游戏看起来像高分辨率游戏。然而,由于使用它们需要额外的文件和信息,它们并不适合移动游戏。因此,我们在这本书中不会使用它们,并且可以关闭所有这些选项以节省内存。
-
最后一个部分,材质,定义了 Unity 应该如何查找材质。第一个选项,导入材质,是决定是否导入材质。如果关闭,将应用默认的白色材质。这个材质不会出现在您的项目中;它是一个隐藏的默认材质。对于没有纹理的模型,例如碰撞网格,可以关闭此选项。对于我们的坦克和几乎所有其他情况,应该保持开启状态。
-
最后两个选项,材质命名和材质搜索,共同用于命名和查找网格的材质。直接位于它们下方有一个文本框,描述了 Unity 将如何搜索材质。正在搜索的材质名称可以是建模程序中使用的纹理名称,建模程序中创建的材质名称,或者模型名称加上材质名称。如果找不到纹理名称,将使用材质名称代替。默认情况下,Unity 执行递归向上搜索。这意味着我们首先在
Materials文件夹中查找,然后是同一文件夹中的任何材质。然后检查父文件夹中的匹配材质,接着是上一级文件夹。这个过程会一直持续,直到我们找到具有正确名称的材质或者达到根资产文件夹。或者,我们还可以选择只检查紧邻模型的Materials文件夹来检查整个项目。这些选项的默认设置就很好。一般来说,它们不需要更改。它们最容易通过 Unity 编辑器脚本进行更改,而这本书不会涉及。 -
接下来,我们有一对按钮:还原和应用。每次修改导入设置时,必须选择这两个按钮之一。还原按钮取消更改,并将导入设置切换回更改之前的状态。应用按钮确认更改,并使用新设置重新导入模型。如果不选择这些按钮,Unity 将通过弹出窗口抱怨,并强制你做出选择,然后才能进行其他操作。
![坦克导入设置]()
-
最后,我们有两种预览方式。导入对象部分是当对象被添加到场景视图并选中时,在检查器窗口中对象预览的样子。预览窗口显示了模型在场景视图中的样子。你可以在该窗口中点击并拖动来旋转对象,从不同的角度观察它。此外,在这个窗口中还有一个蓝色的按钮。点击这个按钮,你可以为对象添加标签。然后,这些标签也可以在项目窗口中搜索到。
设置坦克
现在我们已经导入了坦克,我们需要对其进行设置。我们将调整坦克的排列,并创建一些脚本。
行动时间 - 创建坦克
到目前为止,我们坦克的创建主要将包括创建和排列坦克的组件。
-
首先,将坦克从Project窗口拖动到Hierarchy窗口。你会在Hierarchy窗口中注意到坦克的名字以蓝色显示。这是因为它是一个预制实例。你项目中的任何模型在很大程度上都像是一个预制实例。但是,我们希望我们的坦克能做更多的事情,而不仅仅是坐在这里。因此,作为一个静态网格的预制实例是没有帮助的。因此,在Hierarchy窗口中选择你的坦克,我们将通过移除
Animator组件来开始使其变得有用。为此,在Inspector窗口中,选择Animator组件右侧的齿轮。从新的下拉列表中选择移除组件,它将被移除。 -
如果你正在使用默认提供的坦克,选择它的不同部分将会显示所有枢轴点都在底部。这对于正确地使我们的炮塔和炮管旋转来说将没有用。解决这个问题最简单的方法是添加新的空
GameObject作为枢轴点。小贴士
场景中的任何对象都是一个
GameObject。任何空GameObject都只包含一个Transform组件。 -
在 Unity 编辑器的顶部,创建空对象是位于GameObject按钮下的第一个选项。它创建我们需要的对象。创建两个空
GameObject,并将一个放置在炮塔的底部,另一个放置在炮管的底部。此外,分别将它们重命名为TurretPivot和CannonPivot。这可以通过Inspector窗口顶部的文本框来完成,如果对象已被选中。 -
在Hierarchy窗口中,将
TurretPivot拖动到Tank上。这将TurretPivot的父对象更改为Tank。然后,将对象,即炮塔网格,拖动到TurretPivot上。在代码中,我们将旋转枢轴点,而不是直接旋转网格。当一个父对象移动或旋转时,所有子对象都会随之移动。当你进行这个更改时,Unity 会抱怨对象原始层次结构的更改;只需确认这是一个你想要进行的更改,而不是一个意外。![行动时间 – 创建坦克]()
-
由于失去与预制实例的连接可能会破坏游戏,Unity 只是想确保我们确实想要这样做。因此,点击继续,我们可以完成与坦克的工作,而不会收到 Unity 的其他投诉。我们还需要将
CannonPivot设置为TurretPivot的子对象,并将炮管设置为CannonPivot的子对象。 -
为了完成我们的层次结构更改,我们需要放置摄像机。因为我们希望它看起来像玩家实际上在坦克里,所以摄像机应该放置在坦克后面和上方,略微向下倾斜以聚焦在坦克前方几长度的位置。一旦定位好,将其也设置为
TurretPivot的子对象。
刚才发生了什么?
我们设置了坦克将使用的基本结构。通过这种方式使用多个对象,我们可以独立控制它们的移动和动作。在这个阶段,我们不再有一个只能向前指的刚性坦克,我们可以独立倾斜、旋转和瞄准每个部件。
小贴士
此外,坦克应该位于你想要整个物体围绕旋转的点上方。如果你的不是,你可以在 Hierarchy 窗口中选择位于基础坦克对象下的所有内容并移动它。
行动时间 - 记录得分
一个用于跟踪玩家得分的简短脚本将是本节的重点。
-
为了使这个坦克工作,我们需要三个脚本。第一个相对简单。创建一个新的脚本并将其命名为
ScoreCounter。正如其名所示,它将跟踪得分。在Scripts文件夹中创建它,并清除默认函数,就像我们迄今为止创建的每个脚本一样。 -
将以下代码行添加到新脚本中:
public static int score = 0;- 对于大部分内容,这应该看起来与上一章相似。首先我们定义一个整数计数器。因为它被定义为静态的,所以其他脚本(例如我们将为靶子创建的脚本)将能够修改这个数字并给出得分。
-
接下来是一个
OnGUI函数,它定义了一个Rect类并使用GUI.Box函数显示得分。一个框就像一个标签,但默认情况下有一个黑色背景。这将使得在移动时更容易看到。public void OnGUI() { Rect scoreRect = new Rect(0, 0, 100, 30); GUI.Box(scoreRect, "" + score); }
刚才发生了什么?
我们刚刚创建了一个非常简单的脚本。它将在整个游戏过程中跟踪我们的得分。此外,我们不会直接进行得分增加,其他脚本将更新计数器以给玩家加分。
行动时间 - 控制底盘
一个普通的坦克会在原地旋转,并且可以轻松地前进和后退。我们将通过创建单个脚本来实现我们的坦克这样做。
-
第二个脚本被称为
ChassisControls。它将使我们的坦克移动。同样在Scripts文件夹中创建它。 -
脚本的前三行定义了坦克移动所需的变量。我们还可以在 Inspector 窗口中更改它们,以防我们的坦克速度过快或过慢。第一行定义了一个变量,它包含对
CharacterController组件的连接。这个组件可以轻松地移动坦克,但允许它被墙壁和其他碰撞体阻止。接下来的两行代码定义了移动和旋转的速度:public CharacterController characterControl; public float moveSpeed = 10f; public float rotateSpeed = 45f; -
现在让我们把我们的好朋友
OnGUI加入到混合中。这应该看起来很熟悉。我们正在创建四个按钮,它们将位于屏幕的左下角。当按下前两个按钮时,我们将调用一个函数来移动我们的坦克,并给它一个表示其移动速度的值。正值将使我们向前移动,而负值将使我们向后移动。最后两个按钮做的是同样的事情,只是用旋转代替了移动。正值将使坦克向右旋转,而负值将使坦克向左旋转。这些按钮也是RepeatButtons。一个普通的按钮在每次按下时只会激活一次。而重复按钮在按下时一直处于激活状态。这的好处是它将允许我们的坦克在按钮被按下的每一帧移动。坏处是RepeatButton和OnGUI函数工作方式中的一个怪癖。如果这些按钮中的任何一个处于激活状态,那么在OnGUI函数中,该按钮之后的任何内容都不会被绘制。这有点烦人,但就目前而言,它符合我们的需求。public void OnGUI() { Rect fore = new Rect(50, Screen.height – 150, 50, 50); if(GUI.RepeatButton(fore, "f")) { MoveTank(moveSpeed); } Rect back = new Rect(50, Screen.height – 50, 50, 50); if(GUI.RepeatButton(back, "b")) { MoveTank(-moveSpeed); } Rect left = new Rect(0, Screen.height – 100, 50, 50); if(GUI.RepeatButton(left, "l")) { RotateTank(-rotateSpeed); } Rect right = new Rect(100, Screen.height – 100, 50, 50); if(GUI.RepeatButton(right, "r")) { RotateTank(rotateSpeed); } } -
剩下只有两个函数需要完成。我们通过定义
MoveTank函数开始下一行代码。它需要一个速度值来指定移动的距离和方向。之前已经提到过;正值将前进,而负值将后退。public void MoveTank(float speed) { -
为了在三维空间中移动,我们需要一个向量——一个既有方向又有大小的值。因此,我们定义了一个移动向量,并将其设置为坦克的前进方向,乘以坦克的速度,然后再乘以自上一帧以来经过的时间。如果你还记得几何课上的内容,三维空间有三个方向:x、y 和 z。在 Unity 中,以下约定适用:x 是向右,y 是向上,z 是向前。Transform 组件包含一个对象的位置、旋转和缩放。我们可以通过调用 Unity 提供的
.transform值来访问任何对象在 Unity 中的 Transform 组件。Transform 组件还提供了一个前进值,它将给我们一个相对于对象的指向前进的向量。此外,我们希望以均匀的速度移动,例如,每秒移动一定数量的米,因此我们使用了Time.deltaTime。这是 Unity 提供的一个值,表示自游戏上一帧在屏幕上绘制以来经过的秒数。想象一下像翻书一样。为了使看起来像一个人在页面上走过,他需要在每一页上稍微移动一下。在游戏的情况下,页面不是定期翻动的。因此,我们必须根据翻到新页面所需的时间来修改我们的移动。这有助于我们保持均匀的速度。Vector3 move = characterControl.transform.forward * speed * Time.deltaTime; -
接下来,我们想要保持在地面。一般来说,任何你想要在游戏中控制的角色都不会自动接收到像石头一样所有的物理属性,例如重力。例如,当跳跃时,你暂时移除重力,以便角色可以向上移动。这就是为什么下一行代码通过减去重力的正常速度并再次保持与帧率同步来简单地实现重力:
move.y -= 9.8f * Time.deltaTime; -
最后,对于
MoveTank函数,我们实际上执行移动。CharacterController组件有一个特殊的Move函数,它将移动角色但通过碰撞来限制它。我们只需要告诉它我们想要在这一帧移动多远以及朝哪个方向移动,通过传递Move向量给它。当然,最后的那个花括号关闭了函数。characterControl.Move(move); } -
RotateTank函数是最后一个。这个函数也需要一个速度值来指定旋转的速度和方向。我们首先定义另一个向量;但是,与定义移动方向不同,这个向量将指定旋转的方向。在这种情况下,我们将围绕我们的向上方向旋转。然后我们将其乘以我们的速度和Time.deltaTime,以便移动得足够快,并保持与帧率的同步。public void RotateTank(float speed) {Vector3 rotate = Vector3.up * speed * Time.deltaTime; -
函数的最后部分实际上执行旋转。Transform组件提供了一个
Rotate函数。旋转,尤其是在 3D 空间中,可以变得非常奇怪和困难。Rotate函数为我们处理所有这些;我们只需要提供要应用的旋转值。另外,别忘了用花括号关闭函数。characterControl.transform.Rotate(rotate); }
发生了什么?
我们创建了一个脚本来控制坦克的移动。它将在屏幕上绘制一组按钮,以便我们的坦克可以向前和向后移动。这是通过使用CharacterController组件的特殊Move函数来完成的。我们还使用了Transform组件提供的特殊Rotate函数,通过另一组按钮来旋转我们的坦克。
行动时间 - 控制炮塔
这个脚本将允许玩家旋转他们的炮塔并瞄准大炮。
-
我们需要为我们的坦克创建的最后一段脚本是
TurretControls。这个脚本将允许玩家左右旋转炮塔并上下倾斜大炮。与其他所有脚本一样,在Scripts文件夹中创建它。 -
我们定义的前两个变量将持有炮塔和炮管枢轴的指针,这些是用于我们坦克的空
GameObjects。第二个集合是我们炮塔和炮管旋转的速度。最后,我们还有一些限制值。如果我们不对炮管的旋转范围进行限制,它就会不断地旋转,穿过我们的坦克。这对坦克来说并不是最逼真的行为,因此我们必须对它进行一些限制。这些限制值在 300 的范围内,因为正前方是零度,向下是 90 度。我们希望它是向上的角度,所以它在 300 度的范围内。我们还使用 359.9,因为 Unity 会将 360 度转换为零,以便它可以继续旋转。public Transform turretPivot; public Transform cannonPivot; public float turretSpeed = 45f; public float cannonSpeed = 20f; public float lowCannonLimit = 315f; public float highCannonLimit = 359.9f; -
下一步是创建
OnGUI函数来绘制按钮并让玩家控制炮塔。这个函数几乎与为ChassisControls脚本制作的OnGUI函数相同。不同之处在于Rects类将移动到屏幕的右下角,并且我们正在调用RotateCannon和RotateTurret函数。当我们向RotateCannon发送速度时,需要一个正值来上升,一个负值来下降。RotateTurret将主要像RotateTank函数一样工作;正值将向右旋转,负值将向左旋转。public void OnGUI() { Rect up = new Rect(Screen.width – 100, Screen.height – 150, 50, 50); if(GUI.RepeatButton(up, "u")) { RotateCannon(cannonSpeed); } Rect down = new Rect(Screen.width – 100, Screen.height – 50, 50, 50); if(GUI.RepeatButton(down, "d")) { RotateCannon(-cannonSpeed); } Rect left = new Rect(Screen.width – 150, Screen.height – 100, 50, 50); if(GUI.RepeatButton(left, "l")) { RotateTurret(-turretSpeed); } Rect right = new Rect(Screen.width – 50, Screen.height – 100, 50, 50); if(GUI.RepeatButton(right, "r")) { RotateTurret(turretSpeed); } } -
接下来是
RotateTurret函数。它的工作方式与RotateTank函数完全相同。然而,我们不是查看CharacterController组件的Transform变量,而是作用于函数开头定义的turretPivot变量。public void RotateTurret(float speed) { Vector3 rotate = Vector3.up * speed * Time.deltaTime; turretPivot.Rotate(rotate); } -
最后一个函数
RotateCannon在旋转方面变得更加复杂。错误完全在于需要对炮管的旋转进行限制的需要。在打开函数后,第一步是确定我们将在这个帧中旋转多少。我们使用浮点值而不是向量,因为我们必须自己设置旋转。public void RotateCannon(float speed) { float rotate = speed * Time.deltaTime; -
接下来,我们定义一个变量来保存我们的当前旋转。我们这样做是因为 Unity 不允许我们直接对旋转进行操作。实际上,Unity 将旋转作为四元数来跟踪。这是一种定义旋转的复杂方法,超出了本书的范围。幸运的是,Unity 为我们提供了一个名为
EulerAngles的 x、y、z 方法来定义旋转。它是在 3D 空间中围绕每个轴的旋转。Transform组件的localEulerAngles值是相对于父GameObject的旋转。Vector3 euler = cannonPivot.localEulerAngles;小贴士
它被称为
EulerAngles是因为莱昂哈德·欧拉,一位瑞士数学家,他定义了这种定义旋转的方法。 -
接下来,我们通过使用
Mathf.Clamp函数一次性调整旋转并应用限制。Mathf是一组有用的数学函数。Clamp函数接受一个值,并使其不比传递给函数的其他两个值低或高。因此,我们首先发送我们的 x 轴旋转,这是从euler的当前 x 旋转中减去rotate的结果。因为正旋转是围绕轴顺时针的,所以我们必须减去我们的旋转,以便用正值向上而不是向下移动。接下来,我们将我们的下限传递给Clamp函数,然后是我们的上限:我们在脚本顶部定义的lowCannonLimit和highCannonLimit变量。euler.x = Mathf.Clamp(euler.x – rotate, lowCannonLimit, highCannonLimit); -
最后,我们必须将新的旋转实际应用到炮管的旋转中心点上。这仅仅是设置变换组件的
localEulerAngles值到新值。同样,务必使用花括号来关闭函数。cannonPivot.localEulerAngles = euler; }
刚才发生了什么?
我们创建了一个脚本,用来控制坦克的炮塔。通过使用屏幕上的按钮,玩家能够倾斜炮管并旋转炮塔。这个脚本与之前创建的ChassisControls脚本非常相似。区别在于限制了炮管可以倾斜的程度。
行动时间 - 组装零件
那就是最后的脚本,目前是这样。我们有了我们的坦克和我们的脚本;下一步是将它们组合起来。
-
现在,我们需要将它们添加到我们的坦克上。记得我们在上一章中如何将我们的
Tic-tac-toe脚本添加到相机上吗?首先,在层次结构窗口中选择你的坦克。在它们工作之前,我们首先需要将CharacterController组件添加到我们的坦克上。因此,转到 Unity 编辑器的顶部,选择组件,然后选择物理,最后点击Character Controller选项。-
你会注意到在场景视图中坦克上还出现了一个绿色的胶囊;同时新组件也被添加了。这个胶囊代表将与其他碰撞体发生碰撞和交互的空间。CharacterController 组件上的值让我们可以控制它与其它碰撞体的交互方式。在大多数情况下,前四个属性的默认值就足够好了。
斜坡限制:这个属性显示了控制器可以移动的倾斜程度。
步高偏移量:这个属性显示了在开始阻止移动之前,台阶可以有多高。
皮肤宽度:这定义了另一个碰撞体在完全停止之前可以穿透这个控制器碰撞体的距离。这主要用于挤压在物体之间。
最小移动距离:这个属性用于限制抖动。这是在帧中实际移动之前必须应用的最小移动量。
中心点, 半径和高度:这些属性定义了你在场景视图中看到的胶囊的大小。它们用于碰撞检测。
![行动时间 – 组装部件]()
-
-
最后三个值是我们现在关心的。我们需要调整这些值,尽可能匹配坦克的值。诚然,胶囊是圆形的,而我们的坦克是方形的,但
CharacterController组件是移动带有碰撞的角色的最简单方式,并且将被最频繁使用。将半径属性的值设为2.3,以及中心属性的Y部分的值;其他所有内容都可以保留默认值。 -
现在是时候将脚本添加到我们的坦克上了。通过在层次结构窗口中选择坦克,并将
ChassisControls、TurretControls和ScoreCounter脚本拖动到检查器窗口中来实现。这就像我们在前面的章节中所做的那样。 -
在坦克开始工作之前,我们需要完成在脚本中开始创建的连接。首先点击
CharacterController组件的名称,并将其拖动到我们新的ChassisControls脚本组件上的角色控制值。Unity 允许我们在 Unity 编辑器中连接对象变量,这样它们就不需要硬编码。 -
我们还需要连接我们的炮塔和炮塔旋转点。因此,点击并拖动层次结构窗口中的点,到
TurretControls脚本组件上的相应变量。 -
将场景保存为
TankBattle并尝试运行。
发生了什么?
我们刚刚完成了坦克的组装。除非你在使用移动控制时查看场景视图,否则很难看出坦克在移动。然而,在游戏视图中可以看到炮塔控制。除了没有我们的坦克是否在移动的参考点之外,它运行得相当好。下一步和下一节将随着我们添加城市而提供这个参考点。
尝试一下英雄 – 炮塔对准
你可能会注意到当第一次尝试倾斜炮塔时会有一个快速的跳跃。这种行为很烦人,并且让游戏看起来像是出了问题。尝试调整炮塔来修复它。如果你遇到困难,可以查看炮塔的初始旋转。
创建材质
在 Unity 中,材质是决定模型如何在屏幕上绘制的关键因素。它们可以是简单的全部蓝色,也可以是复杂的带有波浪的反射水。在本节中,我们将介绍材质控制的细节。我们还将创建我们的城市和一些简单的材质来纹理化它。
行动时间 – 创建城市
创建一个城市为我们的坦克和玩家提供了一个良好的游戏场所。
-
在本节中,提供的城市没有分配特定的纹理。它只是被展开,并创建了一些可重复使用的纹理。因此,我们需要首先将城市和纹理导入到
Environment文件夹中。就像我们导入坦克一样做。小贴士
文件包括
TankBattleCity.blend,brick_001.png,brick_002.png,brick_003.png,dirt_001.png,dirt_003.png,pebbles_001.png,rocks_001.png,rubble_001.png, 和water_002.png. -
由于城市已经被展开,Unity 仍然为它创建了一个单独的材质。然而,在任何建模程序中从未应用过纹理。因此,材质是纯白色的。我们有一些额外的纹理,所以整个城市将需要不止一个材质。创建一个新的材质很简单;就像创建一个新的脚本一样进行。在
环境文件夹内的材质文件夹上右键点击,然后选择创建,接着选择材质,这在菜单中大约是中间位置。 -
这将在文件夹中创建一个新的材质,并立即允许我们为其命名。将材质命名为,
Pebbles。![创建城市的时间 - 创建材质]()
-
在选择新的材质后,查看检查器窗口。当我们选择一个材质时,我们会得到改变其外观所需的选项。
-
在检查器窗口的顶部,我们有材质的名称,后面跟着一个着色器下拉列表。着色器本质上是一个简短的脚本,告诉显卡如何在屏幕上绘制某个东西。你将最常使用漫反射着色器,所以它默认总是被选中。这里你可以从你的凹凸贴图着色器、高光、透明以及其他众多选项中进行选择。如果你要创建一些自定义着色器,它们也会在这里找到。
-
下一个小块只有在 Unity 有警告信息时才会可见。在这种情况下,它建议我们出于性能原因使用不同的着色器。然而,这是一个关于第九章,优化的讨论,所以我们现在忽略它。
-
然后,我们有带有旁边彩色方块的主颜色值。点击该方块,将打开颜色选择器窗口,允许我们选择任何我们想要的颜色。这个颜色值会改变材质绘制的纹理的色调。因为我们还没有纹理,所以你会注意到它只是改变了预览窗口中球的颜色。
-
基础 (RGB) 值位于主颜色值之下。这是纹理。右侧的带有无 (纹理)和选择按钮的框是一个用于预览当前材质使用的纹理的预览框。要向材质添加纹理,要么从项目窗口拖动一个到这个框中,要么点击选择按钮。按钮会打开一个新窗口,其中包含项目中所有纹理的缩略图。你可以滚动浏览或使用搜索栏找到所需的纹理,然后双击以选择它。
-
在框的左侧,我们有平铺和偏移控件。平铺值决定了纹理在 x 和 y 方向上在归一化 UV 空间中重复的次数。偏移是纹理在归一化 UV 空间中从零开始的距离。你可以选择数字字段并输入值来修改它们。这样做,并注意下方的预览窗口,你会看到它们如何改变纹理。平铺纹理最常用于大型表面,其中纹理在表面上的相似性足够高,以至于可以重复。
-
我们的预览窗口位于检查器窗口的底部。它的工作方式与我们之前看到的坦克网格的预览窗口相同。然而,这个窗口特别之处在于窗口右上角的两个按钮。左边的按钮可以滚动预览形状集。通过点击它,你可以看到纹理在球体、立方体、圆柱体或环面上的样子。另一个按钮在两种光照类型之间切换。
-
-
通过从项目窗口拖动并将其放置在基础(RGB)预览框上,将
pebbles_001纹理添加到这种材料中。 -
将材料的平铺值设为 30,将主颜色选项设为浅米色,以便纹理可以正确缩放,看起来更令人愉悦。
-
要查看我们的新材料效果,首先将你的城市拖动到层次结构窗口中,使其添加到场景视图中。通过右键单击并拖动,你可以在你的场景视图中四处查看。看看城市的街道。
-
现在,将你的新材料从项目窗口拖动到你的场景视图中。在拖动材料时,你应该看到网格发生变化,看起来就像它们正在使用这种材料。一旦你拖动到街道上,就松开你的左鼠标按钮。现在,材料已经应用到网格上了。
-
然而,我们目前有整个城市四分之一的大小需要纹理化。因此,创建更多材料,并使用剩余的纹理来纹理化城市的其余部分。为每个额外的纹理创建一个新的材料,再加上四个额外的
brick_002,这样我们就可以为每个建筑高度有不同的颜色。 -
通过比较以下图示或根据你自己的艺术喜好,将你的新材料应用到城市上:
![动手实践 – 创建城市]()
小贴士
当试图到达中心喷泉时,如果你的坦克挡路,请在层次结构窗口中选择你的坦克,并在场景视图中使用** Gizmo**选项将其拖出障碍。
- 如果你现在尝试玩游戏,可能会注意到我们有一些问题。首先,我们只有城市四分之一的大小;如果你自己制作了城市,可能更多。城市中仍然没有碰撞,所以我们在移动时会直接穿过它。此外,坦克对于这个城市来说有点大,而且太暗了,看不清我们想去哪里。打开灯光是解决这个问题最快的方法。
-
在 Unity 编辑器的顶部,选择游戏对象,然后选择创建其他,最后选择方向光。这将创建一个以单一方向发射光线的对象。下一章将解释光线及其控制方法,所以细节将留待以后介绍。
-
改变我们的坦克大小也很简单。在层次结构窗口中选择它,并在我们的变换组件中查找缩放标签。在缩放下更改X、Y和Z值将改变坦克的大小。确保均匀地更改它们,否则当我们开始旋转坦克时会出现奇怪的现象。0.5 的值可以使坦克足够小,以通过狭窄的街道。
-
接下来是城市的碰撞问题。大部分情况下,我们可以使用处理速度较快的简单碰撞形状。然而,城市圆形中心将需要特殊处理。首先在场景视图中双击其中一个方形建筑的墙壁。
小贴士
当处理预制体时,城市仍然是一个预制体,点击构成预制体的任何对象都会选择根预制体对象。一旦选择了预制体,点击其任何部分都会选择那个单独的部分。因为这种行为与非预制体对象不同,所以在场景视图中选择对象时需要留心这一点。
-
在选择了一组墙壁后,转到 Unity 编辑器的顶部,选择组件,然后选择物理,最后选择Box Collider。
-
由于我们正在将碰撞器添加到特定的网格,Unity 会尽力自动将碰撞器适配到形状。对我们来说,这意味着新的
BoxCollider组件已经调整大小以适应建筑。继续添加BoxColliders到其余的方形建筑和外墙。我们的街道基本上是平的,所以BoxCollider组件对它们来说也适用。尽管尖顶指向顶部,但喷泉中央的方尖碑基本上也是一个盒子;因此,另一个BoxCollider也适用。 -
我们最后要处理的是一座建筑和喷泉的环形区域。这些不是盒子、球体或胶囊。因此,我们的简单碰撞器将不起作用。选择最后一座建筑,即靠近中心喷泉的那座。在您选择Box Collider的选项下方几项,有一个Mesh Collider选项。这将为我们对象添加一个
MeshCollider组件。这个组件的功能正如其名;它将网格转换为碰撞器。通过将其添加到特定的网格,MeshCollider组件会自动选择该网格以使其可碰撞。您还应该将MeshColliders添加到中心建筑周围的短边缘和喷泉周围的环形墙上。 -
最后要解决的问题是我们城市四分之一的复制。首先,在您的层次结构中选择根
city对象TankBattleCity,并从中移除Animator组件。城市不会进行动画处理,因此不需要这个组件。 -
现在,在层次结构窗口中右键单击城市,并选择复制。这将创建所选对象的副本。
-
将城市四分之一再复制两次,我们就会有我们城市的四个部分。唯一的问题是它们都在完全相同的位置。
-
我们需要旋转三个部件来构建一个完整的城市。选择一个部件,在变换组件中设置Y 旋转值为
90。这将使其围绕垂直轴旋转 90 度,从而得到城市的一半。 -
我们将通过将一个剩余部件设置为
180,另一个设置为270来完成城市。 -
这样就剩下最后一件事要做。我们有四个中心喷泉。在四个城市部件中的三个中,选择组成中心喷泉的三个网格(
方尖碑、墙壁和水),然后按键盘上的删除键。每次确认都要断开预制件连接,我们的城市就会完成。![行动时间 – 创建城市]()
刚才发生了什么?
现在尝试游戏。我们可以驾驶汽车在城市中行驶并旋转我们的炮塔。这真是太有趣了。我们创建了材质并为城市着色。在使建筑和道路能够碰撞后,我们复制了该部分,以便我们有一个完整的城市。
英雄试炼 – 装饰城市
现在您已经拥有了导入网格和创建材质所需的所有技能,挑战就是装饰城市。创建一些废墟和坦克陷阱,并练习将它们导入 Unity 并在场景中设置。如果您真的想做得更好,尝试自己创建一个城市。选择世界上的某个东西,或者选择您想象中的某个东西。一旦创建,我们就可以在其中释放坦克。
行动时间 – 移动履带
-
只剩下最后一件事要做,然后我们就可以完成材质,并继续制作一个更有趣的游戏。还记得材质的偏移值吗?实际上,我们可以通过脚本来控制它。首先打开
ChassisControls脚本。 -
首先,我们需要在脚本的开头添加几个变量。前两个变量将保存对坦克履带渲染器的引用,这是网格对象中跟踪应用到的材质并实际进行绘制的那部分。这就像
characterControl变量保存了对CharacterController组件的引用一样。public Renderer rightTread; public Renderer leftTread; -
后两个变量将跟踪每个履带应用的偏移量。我们在这里存储它,因为它比在每一帧从线程的材质中查找要快。
private float rightOffset = 0; private float leftOffset = 0; -
为了使用新值,需要将这些行添加到
MoveTank函数的末尾。这里的第一行根据我们的速度调整右侧履带的偏移量,并保持与我们的帧率同步。第二行利用Renderer组件的材料值来找到我们的坦克履带材料。材料的主纹理偏移值是材料中主纹理的偏移量。在我们的漫反射材料中,它只有一个纹理。然后,我们必须将偏移量设置为一个新的Vector2值,该值将包含我们的新偏移量。Vector2就像我们用于移动的Vector3一样,但在 2D 空间中工作而不是 3D 空间。纹理是平面的,因此是 2D 空间。代码的最后两行与前面的两行做相同的事情,但针对左侧坦克履带。rightOffset += speed * Time.deltaTime; rightTread.material.mainTextureOffset = new Vector2(rightOffset, 0); leftOffset += speed * Time.deltaTime; leftTread.material.mainTextureOffset = new Vector2(leftOffset, 0); -
为了将连接到履带
Renderer组件,做与我们为枢轴点所做相同的事情:将履带网格从层次窗口拖动到检查器窗口中的相应值。完成后,务必保存并尝试一下。
刚才发生了什么?
我们更新了ChassisControls脚本以使坦克履带移动。当坦克被驾驶时,纹理在适当的方向上平移。这是用于制作水波和其他移动纹理的相同类型的功能。
尝试一下英雄般的旋转——用履带旋转
材料移动的速度并不完全匹配坦克的速度。找出如何为坦克的履带添加一个速度值。如果坦克旋转时它们向相反方向移动那就更酷了。真实坦克通过让一个履带向前移动,另一个向后移动来转弯。
动画
我们接下来要讨论的主题是动画。当我们探索 Unity 中的动画时,我们将为坦克创建一些射击目标。Unity 动画系统 Mecanim 的大部分功能都在于处理人类角色。但是,设置和动画人类类型角色本身就是一本专著,所以这里不会涵盖。然而,我们仍然可以从 Mecanim 中学到很多并做很多事情。
-
在我们继续解释动画导入设置之前,我们需要一个动画模型来工作。我们还需要导入最后一组资源到我们的项目中。将
Target.blend和Target.png文件导入到我们项目的Targets文件夹中。一旦导入,调整目标页面的导入设置窗口,就像我们为坦克所做的那样。现在切换到绑定选项卡。![动画]()
-
动画类型属性告诉 Unity 当前模型在动画时将使用哪种类型的骨架。
-
人类选项为处理人类类型角色添加了许多更多按钮和开关到这个页面。但同样,这里过于复杂,无法涵盖。
-
通用绑定仍然使用 Mecanim 及其许多功能。实际上,这只是任何不类似于人类结构的动画骨架。
-
第三种选项,Legacy,使用 Unity 的旧动画系统。但是,这个系统将在 Unity 的下一个几个版本中逐步淘汰,因此也不会被涵盖。
-
最后一个选项,None,表示对象将不会进行动画。您可以为坦克和城市选择此选项,因为它也会防止 Unity 添加那个
Animator组件,并在最终项目大小中节省空间。 -
根节点值是模型文件中每个对象的列表。其目的是选择动画绑定的基础对象。对于这个目标,选择Bone_Arm_Upper,它在第二个绑定选项下面。
![动画]()
-
-
导入设置的最后一页,动画,包含了我们将动画从文件导入 Unity 所需的所有内容。在目标导入设置窗口的顶部,我们有导入动画复选框。如果一个对象不会进行动画,关闭此选项是个好主意。这样做也会在项目中节省空间。
-
下方那个选项,烘焙动画,仅在您的动画包含运动学和来自 3Ds Max 或 Maya 时使用。这个目标是来自 Blender 的,所以选项被灰色显示。
-
接下来的四个选项,动画压缩、旋转错误、位置错误和缩放错误,主要用于平滑抖动的动画。几乎所有的时候,默认值都适用得很好。
-
剪辑部分是我们真正关心的。这将是一个当前从模型中导入的每个动画剪辑的列表。在列表的左侧,我们有剪辑的名称。在右侧,我们可以看到剪辑的开始和结束帧。
-
Unity 将为每个新模型添加默认取用动画剪辑。这是从建模程序的默认预览范围生成的剪辑,当文件被保存时。
-
在 Blender 中,也可以为每个绑定创建一系列动作。默认情况下,它们被 Unity 作为动画剪辑导入。在这种情况下,会创建ArmatureAction剪辑。
-
在剪辑下方和右侧,有一个带有+和-按钮的小标签。这两个按钮分别将剪辑添加到末尾和移除选定的剪辑。
![动画]()
-
-
当选择一个剪辑时,下一个部分就会出现。它从一个用于更改剪辑名称的文本字段开始。
-
在文本字段下方,有一个源取用下拉列表。这个列表与默认动画相同。大多数时候,您只会使用默认取用;但如果您的动画看起来不正确或丢失,首先尝试更改源取用下拉列表。
-
然后,我们有一个小的时间轴,后面跟着动画剪辑的开始和结束帧的输入字段。在时间轴上点击并拖动两个蓝色标志将改变输入字段中的数字。
-
接下来是循环姿态和循环偏移。如果我们想让动画重复,请勾选循环姿态旁边的框。当动画循环时,循环偏移将变为可用。这个值让我们可以调整循环动画开始时的帧。
-
接下来的三个小部分,根变换旋转、根变换位置(Y)和根变换位置(XZ),允许我们通过动画控制角色的运动。
-
这三个部分都包含一个将动画烘焙到姿态选项。如果这些选项未被勾选,动画中根节点(我们在绑定页面下选中的节点)的运动将转化为整个物体的运动。可以这样理解:假设你要动画化一个向右跑的人物,在动画程序中,你实际上移动他们,而不是像通常那样原地动画化。在 Unity 旧版动画系统中,为了让角色的物理部分移动碰撞体,必须通过代码移动
GameObject。因此,如果你使用那种动画,角色看起来像是移动了,但实际上没有碰撞。在新系统中,当播放该动画时,整个角色都会移动。然而,这需要一个不同且更复杂的设置才能完全工作。所以,我们没有选择在坦克上使用它,尽管我们可以使用它。 -
这三个部分各自还有一个基于的下拉选项。这个选项的选择决定了每个部分的物体中心。如果你正在处理类人角色,会有更多选择,但目前为止我们只有两个。
根节点选项意味着根节点对象的旋转中心是中心点。
原始选项意味着动画程序定义的原点是物体的中心。
-
对于这些部分的前两个,还有一个偏移选项,用于纠正运动中的错误。当为一个角色动画化行走循环时,如果角色稍微向一侧拉,调整根变换旋转下的偏移选项可以纠正它。
-
-
我们动画剪辑的最后一个选项是变换遮罩选项。通过点击左侧的箭头,你可以展开一个包含模型中所有对象的列表。每个对象旁边都有一个复选框。当播放此剪辑时,未勾选的对象将不会动画化。这在挥手动画的情况下很有用。这样的动画只需要移动手臂和手,因此我们会取消勾选可能构成角色身体的所有对象。然后我们可以分层动画,使我们的角色能够在站立、行走或跑步时挥手,而无需创建额外的三个动画。
-
最后,我们还有底部的Revert按钮、Apply按钮和Preview窗口。就像我们所有的其他导入设置一样,当进行更改时,我们必须点击其中一个按钮。这个Preview窗口通过右上角的速率滑块和左上角的大play按钮变得特别。通过点击这个按钮,我们可以预览选定的动画。这让我们能够检测到我们之前提到的运动错误,并确保动画是我们想要的。
目标动画
因此,现在描述都已经说完了,让我们实际做点什么。我们首先设置目标的动画。
行动时间 - 设置目标动画
利用我们刚刚获得的知识,我们现在可以设置目标的动画。
-
首先,如果你之前错过了或跳过了,请确保将
Target.blend和Target.png文件导入到Targets文件夹中。此外,在导入设置的Rig页面,将Animation Type属性设置为Generic,将Root Node属性设置为Bone_Arm_Upper。 -
我们总共需要六个动画。通过点击Clips部分中的+按钮,你可以添加另外四个动画。如果你添加了太多,点击-按钮来删除额外的剪辑。
-
所有这些剪辑都应该有一个Source Take下拉列表,选择Default Take,并且所有Bake into Pose选项都应该被勾选,因为目标不会从其起始位置移动。
-
首先,让我们创建我们的空闲动画。选择第一个剪辑并将其重命名为
Idle_Retract。由于它是一个机械物体,我们可以用一个非常短的动画来应付;短到我们只需要使用第一帧。将起始帧设置为0.9,结束帧设置为1。 -
我们还需要打开Loop Pose,因为空闲动画当然是循环的。
-
扩展空闲动画几乎是以相同的方式创建的。选择第二个剪辑并将其重命名为
Idle_Extend。这里的起始帧是14,结束帧是14.1。此外,这个动画需要循环。 -
接下来的两个动画是针对目标展开和收缩的情况。它们将被命名为
Extend和Retract,所以将接下来的两个剪辑重命名。Extend动画将从帧1开始,到帧13结束。Retract动画从帧28开始,到帧40结束。这两个动画都不会循环。 -
最后两个动画也不会循环。它们是在射击目标时使用的。有一个是正面被射击,另一个是从后面被射击。
Hit_Front动画将从帧57到帧87。Hit_Back动画将从帧98到帧128。 -
一旦所有更改都完成,请确保点击Apply,否则它们将不会被保存。
刚才发生了什么?
我们设置了目标将使用的动画。总共有六个。现在可能看起来不多,但下一节没有它们就无法进行。
状态机
为了我们在 Unity 中控制这些新的动画,我们需要设置一个状态机。状态机就是一个跟踪对象能做什么以及如何在这些事物之间转换的复杂对象。从实时策略游戏中的建筑者角度来想,建筑者有一个行走状态,用于移动到下一个建筑工地。到达那里后,它会切换到构建状态。如果有敌人出现,建筑者会进入逃跑状态,直到敌人消失。最后,当建筑者无所事事时,会有一个空闲状态。在 Unity 中,这些在处理动画和 Mecanim 时被称为 Animator Controllers。
执行时间 – 创建目标状态机
使用状态机可以让我们更多地关注目标正在做什么,同时让 Unity 处理如何去做的部分。
-
创建 Animator Controller 很简单,就像我们为脚本和材质所做的那样。选项位于创建菜单的倒数第二位。在
Targets文件夹中创建一个,命名为TargetController。 -
双击
TargetController以打开一个新窗口。![执行时间 – 创建目标状态机]()
-
Animator窗口是我们编辑状态机的地方。
-
在窗口的左上角,有一个类似于网站上的面包屑导航,让我们可以一眼看出我们在状态机中的位置。
-
右上角的自动实时链接按钮控制着我们是否能够实时看到状态机与游戏的更新。这对于调试角色过渡和控制非常有用。
-
在面包屑导航下方,有一列层和添加新层的按钮。每个状态机至少会有一个基础层。添加额外的层可以让我们混合状态机。比如说,一个角色在健康满值时正常行走。当他的健康值低于一半时,他开始跛行。如果角色的健康值只剩下百分之十,他开始爬行。这可以通过使用层来实现。
-
在窗口的左下角,有参数列表。点击+按钮将向列表中添加一个新参数。这些参数可以是布尔值、浮点数、向量或整数值。状态之间的转换通常是由参数的变化触发的。任何与状态机一起工作的脚本都可以修改这些值。
-
最后,中间那个带有任何状态的绿色框允许角色从任何动作转换到特定的状态。当角色的健康值降至零以下时,我们希望他们进入死亡状态。任何状态框将包含这个转换,并且它能够将角色从任何其他状态拉出来,并将他们放入死亡状态。
-
-
要创建一个新状态,右键单击动画器窗口内部的网格。将鼠标悬停在创建状态上,然后选择空。这为我们的状态机创建了一个新的空状态。通常新状态是灰色,但因为这个是我们机器中的第一个状态,所以它是橙色,这是默认状态的色彩。
-
每个状态机都将从其默认状态开始。点击状态以选择它,我们可以在检查器窗口中查看它。
![执行时间 – 创建目标状态机]()
-
在顶部,有一个文本框用于更改状态的名称。
-
在下面,你可以添加一个标签以用于组织目的。
-
接下来,有一个速度字段。这个字段控制动画的速度。
-
运动字段是我们将添加到之前创建的动画剪辑的连接的地方。
-
脚部逆运动学(Foot IK)选项让我们决定是否希望部分动画使用逆运动学(IK)来计算。我们没有为这些动画设置任何 IK,所以我们不需要担心这个选项。
-
最后一个选项,镜像,用于翻转动画的左右(或 x 轴)。如果你创建了一个右手挥动的动画,这个选项将允许你将其更改为左手挥动的动画。
-
在下面,是从这个状态到另一个状态的转换列表。这些是从状态中出来的转换,而不是进入状态。正如你很快就会看到的,列表中的转换会显示为当前状态的名称,右侧有一个箭头,后面跟着它连接到的状态的名称。
-
在右侧的独奏和静音标签下也有复选框。这些用于调试状态之间的转换。一次可以有任意数量的转换被静音,但一次只能有一个被独奏。当一个转换被静音时,这意味着状态机在决定要执行哪个转换时会忽略它。勾选独奏框等同于静音除了一个以外的所有转换。这只是一个快速将其设置为唯一活动转换的方法。
-
-
我们需要为每个目标动画创建一个状态。因此,创建五个更多状态,并将所有六个状态重命名为与我们之前创建的动画剪辑名称相匹配。默认状态,即橙色状态,应命名为
Idle_Retract。 -
在项目窗口中,点击目标模型左侧的小三角形。
![执行时间 – 创建目标状态机]()
- 这扩展了模型,因此我们可以在 Unity 中看到构成该模型的所有对象。第一个组,如每个对象旁边的小缩略图所示,是原始网格数据。接下来是一个 Avatar 对象;这是跟踪绑定设置的地方。在其下方,是动画剪辑对象;这是我们目前感兴趣的对象。构成模型的对象位于堆栈底部。
-
在Animator窗口中选择每个状态,并通过将动画剪辑从Project窗口拖动到Inspector窗口中的Motion字段来与之配对。
小贴士
动画剪辑的缩略图看起来像一个小播放按钮。
![执行时间 – 创建目标状态机]()
-
在我们可以创建过渡之前,我们需要一些参数。单击窗口左下角的Parameters旁边的+按钮,并从出现的菜单中选择Float。现在列表中应该出现一个新的参数。
-
左侧的新字段是参数的名称;将此重命名为
time。右侧字段是此参数的当前值。在调试状态机时,我们可以在此处修改这些值以触发状态机中的变化。游戏运行期间脚本所做的任何更改也将显示在此处。 -
我们还需要两个更多参数。创建两个布尔参数,并将它们重命名为
wasHit和inTheFront。这些将触发机器改变到被击中的状态,而time参数将触发机器利用extend和retract状态。 -
要创建新的过渡,右键单击一个状态,并从弹出的菜单中选择Make Transition。现在从状态到鼠标连接了一条过渡线。要完成过渡创建,请单击您希望连接到的状态。线上有一个箭头,指示过渡的方向。
-
我们需要一个从
Idle_Retract到Extend的过渡。 -
我们还需要从
Extend到Idle_Extend的过渡。 -
Idle_Extend需要三个过渡,每个分别过渡到Retract、Hit_Front和Hit_Back。 -
Retract、Hit_Front和Hit_Back需要过渡到Idle_Retract。小贴士
使用以下图表进行参考。如果您创建了一个不想要的过渡或状态,请选择它,然后按键盘上的Delete键将其删除。
![执行时间 – 创建目标状态机]()
-
-
如果您单击一条过渡线,那么我们可以查看其设置。
![执行时间 – 创建目标状态机]()
-
在Inspector窗口的顶部,我们有与状态中相同的指示器,显示我们正在过渡到哪个状态,过渡开始时的状态名称后面跟着一个箭头,最后是过渡结束时的状态名称。
-
在熟悉的转换列表下方,有一个文本字段,我们可以为我们的转换指定特定的名称。如果我们有几种不同类型的转换在相同两个状态之间,这将很有用。
-
在字段直接下方,有一个更精确的基于路径的指示,显示转换涉及哪些状态。
-
原子复选框允许我们决定一个转换是否可以被中断。如果勾选了原子,则不能被中断。
在关于任何状态选项和死亡状态的例子中,也许我们在拔剑时被击中,从空闲状态转换到攻击状态。如果我们想避免在倒下之前完成拔剑的奇怪情况,这个转换就不能是原子的。
-
接下来是一个时间线块,它让我们可以预览动画之间的转换。通过左右拖动小旗,我们可以在下面的预览窗口中查看转换。这个块的上半部分包含波形,表示动画中包含的运动。下半部分以框的形式显示状态,表示转换实际发生的位置。这两个框中的任何一个都可以拖动以改变转换的长度。
小贴士
由于我们的两个空闲动画长度可以忽略不计,这在我们正常的设置中是看不到的。如果你临时在
extend和retract状态之间创建一个转换,它就会变得可见。 -
最后,我们有一个条件列表。使用我们设置的参数,我们可以在其中创建任何数量的条件,这些条件必须满足才能进行此转换。
默认条件是退出时间。这意味着,当第一个状态达到其动画的某个百分比时(由右侧的浮点字段定义),它将开始转换到下一个状态。对于我们的转换的一半,这是我们想要的。另一半,即任何退出空闲状态的情况,需要基于我们的参数。
小贴士
在检查器面板的底部还有一个另一个预览窗口。它的工作方式与动画导入设置页面上的窗口相同,但这个窗口播放两个相关动画之间的转换。
-
-
选择
Idle_Retract状态和Extend状态之间的转换。我们希望目标随机弹出。这将由一个修改time参数的脚本控制。 -
点击条件列表下的
Exit Time,以显示参数列表并从列表中选择时间。 -
为了将浮点值转换为条件语句,我们需要将其与另一个值进行比较。这就是为什么当我们选择参数时,我们得到了一个新的比较选项下拉按钮。浮点值将大于或小于右侧的值。我们的时间将倒计时,因此从列表中选择小于,并将值保留为零。
-
将
Idle_Extend和Retract状态之间的转换条件改为相同。 -
在
Idle_Extend状态和Hit_Front状态之间的转换,我们将使用之前创建的两个布尔参数。选择转换,然后在条件下的+按钮上点击,添加第二个条件。 -
对于第一个条件,选择wasHit,对于第二个条件选择inTheFront。
-
布尔值要么是 true,要么是 false。在转换的情况下,它需要知道它正在等待哪个值。对于这个转换,两个都应该保持为 true。
-
接下来,设置
Idle_Extend和Hit_Back之间的转换条件,就像你为之前的转换所做的那样。区别在于,在inTheFront条件旁边的下拉列表中需要选择false。
刚才发生了什么?
我们创建了一个状态机,它将被我们的目标使用。通过将每个状态链接到动画并将它们通过转换连接起来,目标将能够在动画之间切换。这种转换是通过添加条件和参数来控制的。
行动时间 - 编写目标脚本
在我们完成组装目标之前,我们只需要再添加一个部件。
-
那个部件是一个脚本。在我们的
Scripts文件夹中创建一个新的脚本,并将其命名为Target。 -
首先,为了与我们的状态机交互,我们需要一个对
Animator组件的引用。这个组件是你从坦克和城市中移除的。Animator组件是将所有动画片段连接在一起的关键。public Animator animator; -
紧接着是两个浮点值,它们将决定我们的目标在空闲状态中停留的时间范围,单位是秒。
public float maxIdleTime = 10f; public float minIdleTime = 3f; -
接下来,我们有三个值将保存我们需要更改的参数的 ID 号。技术上讲,我们可以直接使用参数的名称来设置它们,但使用 ID 号要快得多。
private int timeId = -1; private int wasHitId = -1; private int inTheFrontId = -1; -
最后两个变量将保存两个空闲状态的 ID 号。我们需要这些来检查我们处于哪个状态。所有的 ID 最初都设置为
-1作为占位符,我们使用以下函数将它们设置为实际值:private int idleRetractId = -1; private int idleExtendId = -1; -
Awake函数是 Unity 中的一个特殊函数,它在游戏开始时对每个脚本进行调用。它的目的是在游戏开始之前进行初始化,非常适合最初设置我们的 ID 值。对于每个 ID,我们调用Animator.StringToHash函数。这个函数计算我们给出的参数或状态的 ID 号。状态名称也需要以Base Layer为前缀。这是因为 Unity 希望我们在可能存在具有相同名称的多个不同层的状态时,能够具体指定。此外,这里的名字必须与Animator窗口中的名字完全匹配。如果不匹配,ID 将不会匹配,将发生错误,脚本将无法正确运行。public void Awake() { timeId = Animator.StringToHash("time"); wasHitId = Animator.StringToHash("wasHit"); inTheFrontId = Animator.StringToHash("inTheFront"); idleRetractId = Animator.StringToHash("Base Layer.Idle_Retract"); idleExtendId = Animator.StringToHash("Base Layer.Idle_Extend"); } -
为了使用所有这些 ID,我们转向我们的好朋友——
Update函数。在函数的开始部分,我们使用GetCurrentAnimatorStateInfo函数来确定当前是哪个状态。我们传递一个零,因为它想知道我们正在查询的层的索引,而我们只有一个。该函数返回一个包含当前状态信息的对象,我们立即抓取这个状态的nameHash值(也称为 ID 值),并将我们的变量设置为它。public void Update() { int currentStateId = animator.GetCurrentAnimatorStateInfo(0).nameHash; -
下一行代码是与空闲状态 ID 的比较,以确定我们是否处于这些状态之一。如果我们处于这些状态,我们将调用
SubtractTime函数(我们将在稍后编写)来减少time参数。if(currentStateId == idleRetractId || currentStateId == idleExtendId) { SubtractTime(); } -
如果目标当前不在其空闲状态之一,我们首先检查是否被击中。如果是,我们使用
ClearHit函数清除击中,并使用ResetTime函数重置time参数。这两个函数也将很快被编写。最后,我们检查计时器是否已经低于零。如果是这种情况,我们再次重置计时器。else { if(animator.GetBool(wasHitId)) { ClearHit(); ResetTime(); } if(animator.GetFloat(timeId) < 0) { ResetTime(); } } } -
在
SubtractTime函数中,我们使用Animator组件的GetFloat函数来检索一个浮点参数的值。通过传递我们的timeId变量,我们可以接收到time参数的当前值。就像我们对坦克所做的那样,我们接着使用Time.deltaTime来与我们的帧率保持同步,并从计时器中减去时间。一旦完成,我们需要将新的值提供给状态机,这是通过SetFloat函数完成的。我们通过给它一个 ID 值来告诉它要更改哪个参数,并通过给它我们的新时间值来告诉它更改成什么。public void SubtractTime() { float curTime = animator.GetFloat(timeId); curTime -= Time.deltaTime; animator.SetFloat(timeId, curTime); } -
下一个要创建的函数是
ClearHit。这个函数使用Animator组件的SetBool来设置布尔参数。它的工作方式与SetFloat函数相同。我们只需给它一个 ID 和一个值。在这种情况下,我们将两个布尔参数都设置为 false,这样状态机就不再认为它被击中了。public void ClearHit() { animator.SetBool(wasHitId, false); animator.SetBool(inTheFrontId, false); } -
脚本中的最后一个函数是
ResetTime。这是一个快速函数。首先,我们使用Random.Range函数来获取一个随机值。通过传递一个最小值和一个最大值,我们的新随机数将在这两个值之间。最后,我们使用SetFloat函数给状态机提供新的值。public void ResetTime() { float newTime = Random.Range(minIdleTime, maxIdleTime); animator.SetFloat(timeId, newTime); }
刚才发生了什么?
我们创建了一个脚本来控制目标的状态机。为了比较状态和设置参数,我们收集并使用了 ID。现在,不要担心击中状态何时被激活。在接下来的章节中,当我们最终让坦克射击时,将会明确说明。
创建预制体
现在我们已经有了模型、动画、状态机和脚本,最后是时候创建目标并将其转换为预制体了。
行动时间 - 创建目标
我们已经拥有了所有部件;下一步是将它们组合起来。
-
首先,将目标模型从项目窗口拖动到层次结构窗口。这将在
target对象中创建一个新的实例。 -
通过选择新的
target对象,我们可以看到它已经附加了一个Animator组件;我们只需要添加一个对AnimatorController的引用。这样做是通过从项目窗口将TargetController拖动到 Animator 组件的控制器字段,就像我们迄今为止设置的所有其他对象引用一样。 -
此外,我们还需要将
Target脚本添加到对象中,并将其相关字段中的Animator组件的引用连接起来。 -
对目标对象进行的最后一项操作是添加一个碰撞器以实际接收我们的炮弹射击。不幸的是,因为
target对象使用骨骼和绑定进行动画,所以直接将碰撞器添加到我们将要射击的网格并不简单。相反,我们需要创建一个新的空GameObject。 -
将其重命名为
TargetCollider,并使其成为目标对象的Bone_Target骨骼的子对象。 -
向新的
GameObject添加一个MeshCollider组件。 -
现在,我们需要提供一些网格数据。在项目窗口中找到
Target网格数据,位于目标模型下方。将其拖动到MeshCollider组件的网格值。这将在场景视图中出现一个绿色的圆柱体。这是我们碰撞的,但实际上它不在目标上。小贴士
项目窗口中的许多对象可以通过每个旁边的符号轻松区分。网格的符号是一个灰色和蓝色的网格。
![行动时间 - 创建目标]()
-
使用变换组件将 GameObject 的位置设置为X值为
4,Y和Z值都为0。旋转需要更改为X值为0,Y值为-90,Z值为90。 -
在我们进行更改时,你可能已经注意到所有新或更改的内容的字体都变成了粗体。这是为了表明与原始预制件实例相比,这个预制件实例有所不同。记住,模型本质上就是预制件;它们的问题是我们不能直接进行更改,例如添加脚本。要将这个目标变成一个新的预制件,只需将其从层次结构窗口拖动,并将其放在项目窗口中的
Prefabs文件夹上即可。 -
使用这个新创建的预制件,用它在城市中填充。
-
在放置所有这些目标时,你可能注意到它们有点大。我们不需要单独编辑每个目标,甚至作为一组编辑所有目标,我们只需要对原始预制进行更改。在 Project 窗口中选择
Target预制。Inspector 窗口显示与场景中任何其他对象相同的信息。选择我们的预制后,其一半的缩放和场景中所有已存在的实例将自动更新以匹配。我们还可以更改最小和最大空闲时间,并使其影响整个场景。
发生了什么?
我们刚刚完成了坦克的目标创建。通过使用 Unity 的预制系统,我们也能够在整个游戏中复制目标,并轻松地对它们进行影响所有实例的更改。
如果你想让其中一个目标比其他所有目标都大,你可以在场景中更改它。对预制实例所做的任何更改都会被保存,并且它们会优先于对根预制对象所做的更改。此外,当在 Inspector 窗口中查看实例时,窗口顶部将出现三个新按钮。Select 按钮在 Project 窗口中选择根预制对象。Revert 将移除对此实例所做的任何独特更改,而 Apply 按钮将使用在此实例中做出的所有更改更新根对象。
英雄试炼 - 更多目标
利用你所学的关于动画和状态机的知识,你的挑战是创建第二种目标类型。尝试不同的运动和行为。也许,创建一个从摇摆不定过渡到静止不动的目标。
光线追踪射击
现在开始玩游戏;这相当酷。我们有可驾驶的坦克和纹理化的城市。我们甚至还有花哨的动画目标。我们只是缺少一件事:我们如何射击?我们需要再编写一个脚本,然后我们可以尽情射击目标。
行动时间 - 简单射击
通过添加一个对象和单个脚本,我们可以开始射击目标。
-
首先,我们需要在我们的坦克中添加一个空的
GameObject。将其重命名为MuzzlePoint并使其成为炮塔旋转点的子对象。完成后,将其放置在炮管末端,使蓝色箭头指向远离坦克的方向,与炮管方向一致。这将是我们子弹的发射点。 -
我们还需要一些东西来指示我们射击的位置。爆炸将在未来的章节中介绍,所以从 Create Other 菜单中选择 Sphere 并将其重命名为
TargetPoint。 -
将球体的缩放设置为每个轴的
0.2并赋予它红色材质。这样它更容易被看到,同时又不会过于显眼。 -
从
TargetPoint中移除SphereCollider组件。必须移除SphereCollider组件,因为我们不想射击自己的目标指示器。 -
现在,创建一个新的脚本并将其命名为
FireControls。 -
这应该开始让你感到熟悉。我们首先使用变量来保存对刚刚创建的枪口和目标对象的引用。随后是一个
OnGUI函数,它在屏幕的右下角绘制一个按钮,就在我们绘制炮塔控制按钮的上方。如果按钮被按下,我们将调用我们接下来要创建的Fire函数。public Transform muzzlePoint; public Transform targetPoint; public void OnGUI() { Rect fire = new Rect(Screen.width – 70, Screen.height – 220, 50, 50); if(GUI.Button(fire, "Fire")) { Fire(); } } -
Fire函数首先定义了一个变量,该变量将保存关于射击细节的详细信息。随后是一个if语句,用于检查Physics.Raycast函数。Raycast函数的工作原理就像开枪一样。我们从一个位置(枪口点的位置)开始,指向一个特定的方向(相对于枪口点的正向),然后获取被击中的物体。如果我们击中了某个物体,if语句的结果为真;否则为假,我们会跳过。当我们击中某个物体时,我们首先将我们的目标点移动到被击中的点。然后我们使用SendMessage函数通知被击中的物体它已被击中。SendMessage函数仅适用于GameObjects和MonoBehaviours,而我们的Target脚本位于目标根对象上,因此使用hit.transform.root.gameObject来获取被击中的GameObject。SendMessage函数接受一个函数的名称,并尝试在发送消息的GameObject上找到该函数。我们还提供了一个值,hit.point,以提供给应该找到的函数。SendMessageOptions.DontRequireReceiver这行代码的目的是防止函数在无法找到所需函数时抛出错误。我们Fire函数的最后一部分发生在我们没有击中任何物体的情况下。我们将目标点送回到世界原点,这样玩家就可以知道他们错过了所有物体。public void Fire() { RaycastHit hit; if(Physics.Raycast(muzzlePoint.position, muzzlePoint.forward, out hit)) { targetPoint.position = hit.point; hit.transform.root.gameObject.SendMessage("Hit", hit.point, SendMessageOptions.DontRequireReceiver); } else { targetPoint.position = Vector3.zero; } } -
最后要做的事情是将
Hit函数添加到我们的Target脚本末尾。我们像在脚本中之前做的那样,首先获取当前状态 ID。然而,这次我们只检查扩展的空闲 ID。如果它们不匹配,我们使用return退出函数。我们这样做是因为我们不希望让玩家射击任何倒下或处于过渡中的目标。如果我们的状态正确,我们继续通过使用SetBool函数告诉动画我们被击中。public void Hit(Vector3 point) { int currentStateId = animator.GetCurrentAnimatorStateInfo(0).nameHash; if(currentStateId != idleExtendId) return; animator.SetBool(wasHitId, true); -
Hit函数的其余部分负责确定目标被击中的哪一侧。为此,我们首先需要将我们从世界空间收到的点转换为局部空间。我们Transform组件中的InverseTransformPoint函数很好地完成了这项工作。然后我们检查射击的来源。由于目标的结构方式,如果射击在 x 轴上是正的,那么它来自后方。否则,它来自前方。无论哪种情况,我们都将状态机中的inTheFront参数设置为适当的值。然后我们通过增加我们在本章开头创建的ScoreCounter脚本中的静态变量来给玩家一些分数。Vector3 localPoint = transform.InverseTransformPoint(point); if(localPoint.x > 0) { animator.SetBool(inTheFrontId, false); ScoreCounter.score += 5; } else { animator.SetBool(inTheFrontId, true); ScoreCounter.score += 10; } } -
最后,务必将新的
FireControls脚本添加到坦克中。同时,你需要连接到MuzzelPoint和TargetPoint对象的引用。
刚才发生了什么?
我们创建了一个脚本,允许我们发射坦克的大炮。使用光线追踪的方法是最简单也是最广泛使用的。一般来说,子弹飞得太快,我们看不到它们。光线追踪就是这样,即;它是瞬间的。然而,这种方法并没有考虑到重力,或者任何可能改变子弹方向的其它因素。
尝试一下英雄 - 更好的 GUI
现在所有的按钮和组件都已经就位,让它们看起来更好。使用你在上一章中学到的技能来设计 GUI,使其变得出色。也许你甚至可以创建一个方向垫来控制移动。
摘要
就这样!这一章很长,我们学到了很多。我们导入了网格并设置了一个坦克。我们创建了材质,以便为城市添加颜色。我们还对一些目标进行了动画处理,并学习了如何击落它们。内容很多,现在是休息的时候了。玩游戏,射击目标,并收集那些分数。项目已经全部完成,准备好构建到你的设备上了。构建过程与之前的两个项目相同,所以享受乐趣吧!
下一章将介绍特殊相机效果和照明。我们将学习关于灯光及其类型的内容。我们的坦克战斗游戏将通过添加天空盒和几个灯光来扩展。我们还将查看距离雾。随着阴影和光照贴图的添加,我们将战斗的城市开始变得有趣和动态。
第四章。搭建舞台 – 摄像头效果和照明
在上一章中,你学习了任何游戏的基本构建块:网格、材质和动画。我们创建了一个利用所有这些块的坦克大战游戏。
在本章中,我们将扩展坦克大战游戏。我们将从添加天空盒和距离雾开始。通过使用第二个摄像头,继续探索摄像头效果,并添加一个坦克的涡轮加速效果,以完善我们对摄像头效果的观察。继续观察照明,我们将通过添加光照贴图和阴影来完成坦克环境的设置。
在本章中,我们将涵盖以下主题:
-
天空盒
-
距离雾
-
使用多个摄像头
-
调整视野
-
添加灯光
-
创建光照贴图
-
添加饼干
我们将直接利用第三章的项目,即《任何游戏的骨架 – 网格、材质和动画》。因此,在 Unity 中打开它,我们就可以开始了。
摄像头效果
有许多优秀的摄像头效果你应该添加,以给你的游戏带来最后的完美触感。在本章中,我们将介绍一些易于添加的选项。这些也将使我们的坦克游戏看起来非常完美。
天空盒和距离雾
当摄像头渲染游戏的帧时,它首先会清除屏幕。默认情况下,Unity 中的摄像头通过将一切染成纯蓝色来完成这一操作。然后,所有的游戏网格都会绘制在这个空白屏幕上。对于一场激动人心的坦克大战来说,蓝色相当无聊。所以,幸运的是,Unity 允许我们更改颜色。但是,粉红色并不比蓝色好,所以我们必须改变清除屏幕的方法。这就是天空盒的用武之地。天空盒只是指形成任何游戏背景天空的一系列图像。距离雾与天空盒协同工作,通过简化模型和背景之间的视觉过渡。
行动时间 – 添加天空盒和距离雾
我们首先需要的是一个天空盒,这是显而易见的。我们可以自己创建一个;然而,Unity 为我们提供了几个非常出色的天空盒,它们完全可以满足我们的需求。
-
在 Unity 编辑器的顶部,选择资产然后选择导入包。在这个列表大约一半的位置,选择天空盒。
-
经过一点处理,一个新窗口将弹出。在 Unity 中,一个包只是一个已经设置好的资产的压缩组。这个窗口显示了内容,并允许我们选择性地导入它们。我们想要所有这些,所以只需点击窗口右下角的导入按钮。
-
在 Project 窗口中将添加一个新的文件夹,
Standard Assets。这个文件夹包含一个名为Skyboxes的文件夹,其中包含各种天空盒材质。选择其中任何一个。你可以在 Inspector 窗口中看到它们是使用 Skybox 着色器的普通材质。每个都有六个图像,每个方向一个。 -
要将你选择的天空盒添加到游戏中,首先确保你已加载了正确的场景。如果没有,只需在 Inspector 窗口中双击它。这是必要的,因为我们即将更改的设置是针对每个场景特定的。
-
前往 Unity 编辑器的顶部,选择 Edit,然后选择 Render Settings。新的设置组将出现在 Inspector 窗口中。
-
目前我们关注的是从底部起的第五个值,Skybox Material。只需将天空盒材质拖放到 Skybox Material 槽中,它将自动更新。变化可以在 Game 窗口中查看。
-
要添加距离雾,我们也在 Render Settings 中进行调整。要启用它,只需点击 Fog 复选框。
-
下一个设置,Fog Color,允许我们为雾选择一个颜色。选择一个接近天空盒一般颜色的颜色是好的。
-
Fog Mode 是一个下拉列表,用于指定 Unity 将使用哪种方法来计算距离雾。对于几乎所有情况,默认的 Exp2 都很合适。
-
接下来的三个设置,Fog Density、Linear Fog Start 和 Linear Fog End,都决定了雾的密度和开始距离。Fog Density 用于 Exponential 和 Exp2 雾模式,而其他用于 Linear 雾模式。将雾置于视线边缘的设置通常很好。添加天空盒和距离雾的时间 - 添加天空盒和距离雾
刚才发生了什么?
我们导入了几个天空盒并将它们添加到场景中。距离雾设置也被打开并调整。现在,我们的场景开始看起来像一款真正的游戏。
目标指示器
另一个相当有趣的游戏摄像机效果是使用多个摄像机。第二个摄像机可以用来制作 3D GUI、小地图,或者可能是弹出式安全摄像头。在接下来的这一节中,我们将创建一个指向附近目标的系统。使用第二个摄像机,我们将使其出现在玩家的坦克上方。
添加指针的时间 - 创建指针
我们将首先创建一个指向目标的物体。我们将制作一个可以重复使用的预制件。然而,你需要一个玩家可以看到的模型。我们将使用一种饼状类型的网格。大小并不特别重要;我们稍后会调整其比例。让我们执行以下步骤来创建指针:
-
一旦你创建并导入你的网格,将其添加到场景中。
-
创建一个空的
GameObject并将其重命名为IndicatorSlice。 -
将你的网格设置为
IndicatorSlice的子对象,并定位它,使其沿着 GameObject 的 z 轴指向。IndicatorSlice将位于我们的指示器中心。每个创建的切片都将使其 z 轴指向目标的方向。![行动时间 – 创建指针]()
-
现在,我们需要创建一个新的脚本,用来控制我们的指示器。在项目窗口中创建一个名为TargetIndicator的新脚本。
-
我们从这个脚本开始,定义一对变量。第一个变量将保存对目标对象的引用,该指示器组件将指向这个目标。指示器的大小将根据目标距离的远近而增长或缩小。第二个变量将控制指示器开始增长的距离。
public Transform target; public float range = 25; -
下一个函数将用于在创建指示器组件时设置
target变量。public void SetTarget(Transform newTarget) { target = newTarget; } -
最后一段代码放在
LateUpdate函数中。LateUpdate函数用于确保在Update函数中坦克移动之后,指示器组件可以指向目标。函数开始时,我们检查目标变量是否有值。如果它是 null,则销毁指示器切片。Destroy函数可以用来从游戏中移除任何存在的对象。gameObject变量由MonoBehaviour类自动提供,并持有与脚本组件附加的GameObject的引用。销毁它也将销毁其所有子对象(或附加对象)。public void LateUpdate() { if(target == null) { Destroy(gameObject); return; } -
下面的代码块设置了指示器切片的缩放比例。正如你在下面的代码片段中可以看到,第一行代码使用
Vector3.Distance来确定两个位置之间的距离。下一行代码确定了切片的垂直缩放,即 y 轴。这是通过一些精心应用的数学和Mathf.Clamp01函数来实现的。这个函数将提供的值限制在零和一之间。最后一行代码设置了指示器切片的本地缩放。通过调整本地缩放,我们可以通过仅更改父对象的缩放来轻松控制整个指示器的大小。float distance = Vector3.Distance(transform.position, target.position); float yScale = Mathf.Clamp01((range - distance) / range); transform.localScale = new Vector3(1, yScale, 1); -
最后,这段代码用于此脚本。
transform.LookAt函数是一种自动旋转GameObject以使其 z 轴指向世界中的特定位置的高级方式。然而,我们希望所有指示器切片都平铺在地面上,而不是指向可能在我们上面的任何目标。因此,我们收集目标的位置。通过将变量的 Y 值设置为切片的位置,我们确保切片保持平铺。当然,最后一行关闭了LateUpdate函数。Vector3 lookAt = target.position; lookAt.y = transform.position.y; transform.LookAt(lookAt); } -
这就是此脚本的最后一部分代码。返回 Unity,并将
TargetIndicator脚本添加到场景中的IndicatorSlice对象。 -
为了完成指示器的创建,创建一个它的预制件。
-
最后,从场景中删除
IndicatorSlice对象。游戏开始时我们将动态创建切片。这需要预制件,但不是场景中的那个。
刚才发生了什么?
我们创建了一个我们将用来指示目标方向的物体的预制件。附加的脚本将旋转预制件的每个实例以指向目标。它还将调整比例以指示目标与玩家之间的距离。
行动时间 - 控制指示器
现在,我们需要创建一个控制指示切片的脚本。这包括在需要时创建新的切片。此外,它附加到的GameObject将作为我们刚刚创建的指示切片的中心点,围绕它旋转。
-
创建一个新的脚本并将其命名为
IndicatorControl。 -
我们从这个脚本开始,有一对变量。第一个将保存对刚刚创建的预制件的引用。这将允许我们随时生成其实例。第二个是静态变量,这意味着它可以很容易地被访问,而无需对场景中存在的组件的引用。游戏开始时,它将填充场景中此脚本实例的引用。
public GameObject indicatorPrefab; private static IndicatorControl control; -
下一个函数将由目标使用。很快,我们将更新目标的脚本,使其在游戏开始时调用此函数。该函数是静态的,就像变量一样,首先检查其中是否有对任何对象的引用。如果它是空的,等于 null,则使用
Object.FindObjectOfType尝试填充变量。通过告诉它我们想要找到的对象类型,它将在游戏中搜索并尝试找到。这是一个相对较慢的过程,不应经常使用,但我们使用这个过程和变量,以确保系统总能找到脚本。CreateSlice函数的第二部分检查确保我们的静态变量不为空。如果是这样,它就会告诉实例创建一个新的指示切片并将其传递给目标。public static void CreateSlice(Transform target) { if(control == null) { control = Object.FindObjectOfType(typeof(IndicatorControl)) as IndicatorControl; } if(control != null) { control.NewSlice(target); } } -
为此脚本添加一个额外的函数。
NewSlice函数正如其名所示,在调用时创建新的指示切片。它是通过首先使用Instantiate函数创建传递给它的GameObject的副本来完成的。函数的第二行使新的切片成为控制变换的子对象。下一行将新切片的局部位置归零。这样,在创建后它将正确居中。最后一行使用切片的SendMessage函数调用我们之前创建的SetTarget函数,并传递所需的靶对象。public void NewSlice(Transform target) { GameObject slice = Instantiate(indicatorPrefab) as GameObject; slice.transform.parent = transform; slice.transform.localPosition = Vector3.zero; slice.SendMessage("SetTarget", target); } -
脚本创建好了,我们需要使用它。创建一个空的
GameObject并将其命名为IndicatorControl。 -
新的
GameObject需要成为你的坦克的子对象,然后将其在每个轴上的位置设置为零。 -
将我们刚刚创建的脚本添加到
IndicatorControl中。 -
最后,选择
GameObject后,添加对IndicatorSlice预设件的引用。通过将预设件从项目窗口拖动到检查器窗口的正确槽位来完成此操作。
发生了什么?
我们创建了一个脚本,用于控制目标指示切片的生成。我们创建的GameObject也将使我们能够轻松地控制整个指示器的大小。我们几乎完成了目标指示器的制作。
是时候行动了——使用第二个相机
如果你现在开始玩游戏,它看起来仍然没有不同。这是因为目标还没有调用创建指示切片。我们将在完成目标指示器后,在本节中添加第二个相机。
-
首先,打开
Target脚本,并在Awake函数的末尾添加以下代码行。这一行告诉IndicatorControl脚本为这个目标创建一个新的指示切片。IndicatorControl.CreateSlice(transform);![是时候行动了——使用第二个相机]()
-
现在玩游戏时,你可以看到指示器正在起作用。然而,它可能太大,并且显然出现在坦克内部。一个不好的解决方案是将
IndicatorControl对象移动,直到整个指示器都出现在坦克上方。然而,当爆炸发生,东西开始在空中飞舞时,它们将再次遮挡目标指示器。一个更好的解决方案是添加一个第二个相机。现在通过从 Unity 编辑器的顶部选择GameObject,然后选择创建其他,最后选择相机来完成此操作。 -
此外,将相机设置为
Main Camera的子对象。确保将新相机的位置和旋转值设置为零。 -
默认情况下,Unity 中的每个相机都配备了各种组件:相机、光晕层、GUI 层和音频监听器。除了相机组件外,其他组件对每个相机通常都不重要,整个场景中应该只有一个音频监听器组件。从相机中移除多余的组件,只留下相机组件。
-
在我们对相机进行任何其他操作之前,我们需要更改
IndicatorSlice预设件所在的层。层用于在对象之间引起选择性的交互。它们主要用于物理和渲染。首先在项目窗口中选择预设件。 -
在检查器窗口的顶部是带有下拉列表的层标签,列表中显示为默认。点击下拉列表,从列表中选择添加层...。
-
现在将出现在检查器窗口中一个层的列表。这些都是游戏中使用的层。前几个是为 Unity 保留的;因此,它们被灰色显示。其余的是供我们使用的。点击用户层 8的右侧,将其命名为指示器。
-
再次选择
IndicatorSlice预设件。这次从层下拉列表中选择新的指示器层。 -
Unity 会询问我们是否想要更改所有子对象所在的层。我们希望整个对象都渲染在这个层上,因此选择是,更改子对象,我们就可以这样做。
-
现在,回到我们的第二个相机。选择它,并在检查器窗口中查看。
-
相机组件的第一个属性是清除标志。此选项列表决定了相机在绘制游戏中的所有模型之前将填充背景的内容。第二个相机不会阻挡第一个相机绘制的内容。我们从清除标志下拉列表中选择深度仅。这意味着,而不是在背景中放置天空盒,它将保留已经渲染的内容,并在其上绘制新内容。
-
下一个属性,裁剪遮罩,控制相机渲染哪些层。前两个选项,无和所有,用于取消选择和快速选择所有层。对于这个相机,取消选择所有其他层,以便只有指示器层旁边有勾选。
-
最后要做的事情是调整
IndicatorControl的缩放比例,以确保目标指示器的大小适中。![行动时间 - 使用第二个相机]()
刚才发生了什么?
我们创建了一个系统来指示潜在目标的方位。为此,我们使用了第二个相机。通过调整裁剪遮罩属性中的层,我们可以使相机只渲染场景的一部分。此外,通过将清除标志属性更改为深度仅,第二个相机可以在第一个相机绘制的内容之上绘制。
尝试一下英雄 - 调整位置
通过移动相机,可以改变指示器的绘制位置。如果你移动IndicatorControl对象,它将改变目标距离和方向的计算方式。移动并调整第二个相机,以便获得更令人愉悦的目标指示器视图。
当你移动第二个相机或使用下一节中的加速功能时,你可能注意到目标指示器仍然可以在坦克中看到。调整主相机,使其不渲染目标指示器。这和我们在第二个相机上只渲染目标指示器的方式非常相似。
涡轮加速
本章我们将探讨的最后一种相机效果是涡轮加速。它将在屏幕上显示一个按钮,玩家按下后会快速向前推进一段时间。相机效果之所以重要,是因为对视野属性的简单调整可以使我们看起来移动得更快。电影中通常使用类似的方法来使汽车追逐看起来更快。
行动时间 - 使用加速效果
在本节中,我们只将编写一个脚本。它将以与我们在上一章中创建的ChassisControls脚本类似的方式移动坦克。不同之处在于,我们不需要按住按钮来使加速功能生效。让我们开始吧。
-
首先,创建一个新的脚本,并将其命名为
TurboBoost。 -
要启动脚本,我们需要四个变量。第一个是
CharacterController的引用。我们需要这个来移动坦克。第二个是我们加速时的移动速度。第三个是我们将加速多长时间,以秒为单位。最后一个用于内部判断我们是否可以加速以及何时应该停止。public CharacterController controller; public float boostSpeed = 50; public float boostLength = 5; public float startTime = -1; -
接下来的代码返回到我们熟悉的朋友
OnGUI函数。在这里,我们只是在屏幕上绘制一个按钮,就像我们之前多次做的那样。如果按钮被按下,它将调用我们即将编写的StartBoost函数。public void OnGUI() { Rect turboRect = new Rect(10, Screen.height – 220, 75, 75); if(GUI.Button(turboRect, "Turbo")) StartBoost(); } -
StartBoost函数相当简单。它检查startTime变量是否小于零。如果是,变量被设置为Time.time提供的当前时间。小于零意味着我们目前没有在加速。public void StartBoost() { if(startTime < 0) startTime = Time.time; } -
我们将要使用的最后一个函数是
Update函数。它从检查startTime是否正在加速开始。如果我们没有在加速,函数会提前退出。下一行代码检查我们是否有了CharacterController引用。如果变量为空,那么我们无法使坦克移动。public void Update() { if(startTime < 0) return; if(controller == null) return; -
下一行代码看起来应该很熟悉。这是使坦克移动的行。
controller.Move(controller.transform.forward * boostSpeed * Time.deltaTime); -
以下几行代码实际上应用了相机效果。首先是一个检查,看看我们是否处于加速的前半秒。如果是,我们通过调整
fieldOfView值来过渡相机。Camera.main值是 Unity 提供的对场景中使用的主相机的引用。Mathf.Lerp函数根据介于零和一之间的第三个值将起始值移动到目标值。使用这个函数,相机的fieldOfView在半秒内移动到目标值。这组代码的第二部分检查我们加速的最后半秒,并使用相同的方法将fieldOfView值过渡回默认值。if(Time.time – startTime < 0.5f) Camera.main.fieldOfView = Mathf.Lerp(Camera.main.fieldOfView, 130, (Time.time - startTime) * 2); else if(Time.time – startTime > boostLength - 0.5f) Camera.main.fieldOfView = Mathf.Lerp(Camera.main.fieldOfView, 60, (Time.time – startTime – boostLength + 0.5f) * 2); -
最后一段代码检查我们是否已经完成加速。如果是,
startTime被设置为负一,以表示我们可以开始另一个加速。当然,最后一个花括号关闭了Update函数。if(Time.time > startTime + boostLength) startTime = -1; } -
我们几乎完成了。将脚本添加到坦克中,并连接
CharacterController引用。 -
尝试一下。
![行动时间 - 使用加速效果]()
发生了什么?
我们创建了一个涡轮增压。与上一章中使用的相同移动方法在这里移动坦克。通过调整相机的视野属性,我们让坦克看起来移动得更快。
尝试一下英雄级操作——风格和控制
这里的简单挑战是设计按钮样式。为了增加趣味性,尝试将其改为在加速时有标签,在不加速时有按钮。标签和按钮可以各自有不同的样式。
在玩游戏时,你可能还会注意到,你可以在加速的同时转向。尝试向ChassisControls脚本添加一个检查,以锁定控制,如果我们正在加速。你需要添加对TurboBoost脚本的引用。
为了增加额外的挑战,尝试为加速添加冷却时间。让玩家不能持续使用加速。此外,尝试在坦克撞到东西时取消加速。这是一个很大的问题,所以你将从一个提示开始:查看OnControllerColliderHit。
光源
Unity 提供了各种光源类型来照亮游戏世界。它们是方向光源、聚光灯、点光源和区域光源。每种光源都以不同的方式投射光线,以下将详细解释:
-
方向光源:这非常像太阳。它将所有光线投射到单一方向。光源的位置并不重要,只有旋转。光线以单一方向投射到场景的整个区域。这使得它非常适合最初向场景添加光线。
-
聚光灯:这就像舞台上的聚光灯一样工作。光线以锥形形状向特定方向投射。正因为如此,它也是系统计算中最复杂的光源类型。Unity 在计算光线方面做出了重大改进,但应避免过度使用这些光源。
-
点光源:这是你游戏中将使用的主要光源类型。它向所有方向发射光线。这就像一个灯泡一样工作。
-
区域光源:这是一种特殊用途的光源。它从平面以单一方向发射光线。想象一下,它就像用来宣传酒店或餐厅的大型霓虹灯招牌。由于它们的复杂性,这些光源只能在烘焙阴影时使用。当游戏运行时,由于计算量太大,它们无法使用。
当谈到光线时,下一个明显的问题就是阴影,尤其是实时阴影。虽然实时阴影可以为场景增添很多效果,并且在技术上任何平台都可行,但它们非常昂贵。此外,它们是 Unity Pro 的功能。总的来说,这使得它们对于普通移动游戏来说有点过于复杂。
另一方面,有一些完全可行的替代方案,成本远低于实时阴影,而且通常看起来比实时阴影更真实。第一个是针对你的环境。一般来说,游戏中的环境永远不会移动,也不会在特定场景中改变。为此,我们有光照贴图。这些是包含阴影数据的额外纹理。使用 Unity,你可以在制作游戏时创建这些纹理。然后,当游戏运行时,它们会自动应用,你的阴影就会出现。然而,这并不适用于动态对象(任何移动的物体)。
对于动态对象,我们有饼干。这些不是你祖母的饼干。在照明中,饼干是一种黑白图像,它被投射到游戏中的网格上。这类似于影子戏。影子戏使用剪影来遮挡光线的一部分,而饼干使用黑白图像来告诉光线它可以投射到哪些地方。
饼干也可以用来创建其他一些很棒的效果,无论是静态的还是动态的,比如云层在场景中移动的效果。也许是从笼子里投射出的光线。或者,你可以用它们来制作手电筒不均匀的焦点。
现在是时候添加更多灯光了
向场景添加额外的灯光相当简单。此外,只要坚持使用点光源,渲染它们的成本就会保持较低。
-
在 Unity 编辑器的顶部,选择GameObject,然后选择Create Other,最后选择Point Light。
-
在选择新的灯光后,在Inspector窗口中我们关注几个属性。
-
范围:这是光线从物体发出的距离。从这个点发出的光线在中心最亮,随着达到范围的极限而逐渐变暗。范围在场景视图中还以黄色线球的形式表示。
-
颜色:这仅仅是光线的颜色。默认情况下,它是白色的;然而,这里可以使用任何颜色。这个设置在所有灯光类型之间共享。
-
强度:这是光线的亮度。光线的强度越大,光线中心的亮度就越高。这个设置在所有灯光类型之间共享。
-
-
创建并放置更多灯光,沿着街道排列,以增加环境的趣味性。
-
使用Ctrl + D可以复制选定的对象。这可以大大加快创建过程。
![现在是时候添加更多灯光]()
-
在添加这些灯光时,你可能注意到了它们的一个主要缺点。实时影响表面的灯光数量是有限的。通过使用更复杂的网格,可以在一定程度上绕过这一点。更好的选择是使用光照贴图,我们将在下一节中看到。
-
再次在 Unity 编辑器的顶部,选择GameObject,然后选择Create Other,这次选择Spotlight。
-
再次选择新的灯光,并在Inspector窗口中查看它。
- 聚光角度:这是此类灯光的独特之处。它决定了光线发出的锥形范围有多宽。与范围一起,它在场景视图中以黄色线锥的形式表示。
-
在我们坦克战斗城市中心的喷泉周围添加几个聚光灯。
![行动时间——添加更多灯光]()
-
场景中有这么多对象开始使层次结构窗口变得杂乱,难以找到任何东西。为了组织它们,你可以使用空的
GameObject。创建一个并命名为PointLights。 -
通过使所有点光源成为这个空
GameObject的子对象,层次结构窗口变得明显更加整洁。
发生了什么?
我们向游戏中添加了几个光源。通过改变灯光的颜色,我们使场景看起来更加有趣,玩起来也更加吸引人。然而,照明系统的缺点也被揭露了。我们使用的城市非常简单,一次能影响平面的灯光数量有限。虽然我们的场景外观得到了改善,但大部分的震撼效果都被这个缺点所削弱。
光照贴图
光照贴图非常适合复杂的照明设置,这些设置在运行时可能过于昂贵或根本无法实现。它们还允许你在不使用实时阴影的情况下,为游戏世界添加详细的阴影。然而,它只适用于在整个游戏过程中不移动的对象。
行动时间——创建光照贴图
光照贴图是任何游戏环境的绝佳效果,但我们需要明确告诉 Unity 哪些对象不会移动,然后使用光照贴图。
-
首件事是使我们的环境网格静态。为此,首先选择你城市的一部分。
-
在对象名称字段右侧的检查器窗口的右上角有一个复选框和一个静态标签。勾选此框将使对象变为静态。
-
使整个城市的网格都变为静态。
小贴士
如果你有任何类型的分组(就像我们刚才对灯光所做的),这个步骤可以完成得更快。
-
选择你城市的根对象,即所有城市部分、建筑和街道的父对象。
-
现在去选择静态复选框。
-
在新的弹出窗口中,选择是,更改子对象以使所有子对象也变为静态。
-
-
任何未展开或 UV 位置超出归一化 UV 空间的网格在 Unity 生成光照贴图时将被跳过。在模型导入设置窗口中,有一个选项可以让 Unity 自动生成光照贴图坐标,生成光照贴图。如果你正在使用
TankBattleCity作为你的环境,现在应该打开此选项。 -
前往 Unity 编辑器的顶部,选择窗口然后选择光照贴图,位于底部附近。
-
你大部分时间都会花在烘焙页面上查看这个窗口。在窗口顶部选择烘焙以切换到该页面。
-
模式决定了系统将渲染哪些类型的光照贴图。为了节省处理速度和文件大小,从右侧的模式下拉列表中选择单光照贴图。这意味着只创建远光照贴图集,而不是近和远。使用双光照贴图还需要特殊的着色器,您在大多数情况下都不会使用。
-
品质是一组预设,决定了光照贴图看起来有多好。高显然是最好的,而低是处理速度最快的。对于我们的目的,低看起来已经足够好,应该被选中。
-
分辨率决定了对象在单个光照贴图上占据的空间大小。在输入字段右侧,它显示为每世界单位 texels。texel 是一种用于光照贴图的高级像素类型。它是世界空间中单个单位空间在光照贴图上占据的像素数。这里的 30 设置将保持所需的品质水平,同时使整个过程运行得更快。
-
在页面底部有一个烘焙场景按钮。点击此按钮将开始渲染过程。Unity 右下角将出现一个加载条,以便您可以监控进度。
-
如果您仍在调整灯光和设置,并希望看到游戏的一部分外观,请先选择您希望看到的网格。
-
接下来,点击烘焙场景按钮右侧的小箭头。
-
从新的下拉列表中选择烘焙所选。这将运行与烘焙场景相同的过程,但它只针对所选对象而不是整个场景。
小贴士
警告,这个过程可能需要一段时间。特别是随着环境和灯光数量的增加,运行时间会越来越长。而且,除非您有一台性能优越的电脑,否则在它运行时在 Unity 中您几乎无法做什么。
-
-
如果您点击了按钮并意识到自己犯了一个错误,不要担心。在烘焙场景被选中后,按钮将变为取消。此时您可以选中它并停止进程继续。然而,一旦纹理被创建并且 Unity 开始导入它们,就无法停止了。
-
在烘焙场景左侧是清除。这个按钮是删除和移除场景中当前使用的所有光照贴图最快、最简单的方法。这无法撤销。
-
为了给您的建筑添加阴影,在场景中选择方向光,并查看检查器窗口。
-
从阴影类型下拉列表中选择软阴影。这将为这个灯光打开阴影。如果您使用 Unity Pro,它还会打开这个灯光的实时阴影。打开阴影的灯光越多,渲染成本就越高。
-
当你的所有灯光和设置都符合你的期望时,选择烘焙场景,并惊奇地凝视现在在你面前的美丽场景。
![行动时间 – 创建光照贴图]()
发生了什么?
我们向游戏世界中添加了光照贴图。仅处理这一步骤所需的时间就使得进行细微调整变得困难。然而,通过几次点击,我们的照明得到了极大的改善。在此之前,灯光被网格破坏,我们现在有了平滑的颜色和灯光区域。
尝试一下英雄 – 理由和速度
在玩游戏时,人们不会质疑的唯一光源是太阳。如果看不到光源,其他任何灯光看起来都很奇怪。创建一个网格并将其添加到游戏中,为使用的灯光提供一个理由。这可能是火炬、路灯,甚至是发光的外星粘液球。无论它们最终变成什么,拥有它们都会增加那种完整性,使游戏从一般的外观变得出色。
作为第二个挑战,看看你的光照贴图的质量。玩一下我们讨论的各种质量设置,看看有什么区别。同时,找出在出现像素化之前分辨率可以降低多少。当在较小的移动设备屏幕上运行时,设置可以进一步降低吗?去找出答案。
Cookies
Cookies 是给你的游戏中的灯光增加兴趣的绝佳方式。它们使用纹理来调整光线的发射方式。这种效果可以覆盖从闪耀的晶体到笼子工业灯光的广泛用途,在我们的案例中,是车头灯。
行动时间 – 应用车头灯
通过给我们的坦克添加车头灯,我们给玩家提供了控制他们世界中灯光的能力。使用 cookie,我们可以使它们比光圈更有趣。
-
首先,创建一个聚光灯。
-
将灯光放置在坦克前方,并指向远离的方向。
-
在检查器窗口中,将强度属性值增加到三。这将使我们的车头灯像真实的车头灯一样明亮。
-
现在我们需要一些 cookie 纹理。在 Unity 编辑器的顶部,选择资产,然后选择导入包,最后选择Light Cookies。
-
在新窗口中,选择导入并等待加载条完成。
-
我们现在有几个选项可供选择。在
Standard Assets文件夹内,创建了一个新的文件夹,Light Cookies,其中包含新的纹理。从项目窗口中拖动Flashlight并将其放置在检查器窗口中Cookie字段上的聚光灯。添加 cookie 到灯光就这么简单。 -
为了完成它,复制第二个车头灯的光源,并使它们都成为坦克的孩子。如果没有和我们一起,车头灯有什么用呢?
![行动时间 – 应用车头灯]()
发生了什么?
我们使用饼干为我们的坦克创建了一对车头灯。这正是许多其他游戏,尤其是恐怖游戏,创建手电筒效果的方式。
尝试一下英雄 - 添加开关
尝试编写一个脚本,允许玩家打开和关闭车头灯。它应该是一个简单的按钮,用于切换灯光。查看作为灯光一部分提供的 enabled 变量。
作为一项更简单的挑战,创建一个位于坦克炮塔上的灯。给它也加上灯光。这样,玩家就可以将灯光指向他们射击的方向,而不仅仅是坦克指向的方向。
Blob 阴影
Blob 阴影是给角色添加阴影的一种更简单、更经济的办法。它们自时间之初就存在。正常阴影是一个物体在另一个表面上形成的固态、深色的投影。阴影的轮廓与物体的轮廓完全一致。当角色开始随机移动时,这会变得计算成本高昂。
Blob 阴影是位于角色或物体下方的黑色纹理块。它通常没有明确可定义的形状,并且永远不会与它打算成为阴影的物体的轮廓相匹配。Blob 阴影通常也不会改变大小。这使得它计算起来显著更容易,因此成为许多代视频游戏的首选阴影。这也意味着它对于我们的移动设备来说是一个更好的选择,因为在移动设备上处理速度可能会迅速成为一个问题。
行动时间 - 带阴影的坦克
我们将给我们的坦克添加一个 Blob 阴影。Unity 已经为我们做了大部分工作;我们只需要将其添加到坦克上。
-
我们首先通过导入 Unity 的 Blob 阴影开始。在 Unity 编辑器的顶部,选择资产,导入包,最后选择投影器。
-
在新窗口中选择导入,并在项目窗口中查看在
Standard Assets下创建的新文件夹Projectors。 -
将
Blob 阴影投影器预制体从项目窗口拖到场景中,并将其放置在坦克上方。![行动时间 - 带阴影的坦克]()
-
不幸的是,阴影出现在我们的坦克上方。为了解决这个问题,我们需要再次利用层。因此,选择坦克。
-
从层下拉列表中选择添加层...。
-
点击用户层 9的右侧,并将其命名为
PlayerTank。 -
再次选择你的坦克,但这次从层下拉列表中选择
PlayerTank。 -
当新窗口弹出时,务必选择是,更改子项以更改整个坦克的层。如果不这样做,Blob 阴影可能会出现在坦克的一些部分上,而不会出现在其他部分上。
-
现在,从层次结构窗口中选择
Blob 阴影投影器。小贴士
阴影是由投影器组件创建的。这个组件的功能与摄像头组件类似。然而,它将图像放在世界上,而不是将世界变成图像并显示在屏幕上。
-
看一下检查器窗口。我们现在关心的是忽略图层的值。目前它设置为无。
-
点击无,然后从图层下拉列表中选择
PlayerTank。这将使投影仪忽略坦克,并且只在它下面显示阴影。 -
下一步是将阴影的大小调整到与坦克大小大致匹配。调整视野属性的值,直到大小看起来大致合适。大约 70 的值似乎是一个不错的起点。
![行动时间 – 带阴影的坦克]()
-
最后一步是将Blob Shadow Projector设置为坦克的子对象。我们需要能够带着我们的阴影一起移动;我们不希望丢失它。
刚才发生了什么?
我们给我们的坦克添加了阴影。阴影非常适合让物体,尤其是角色,看起来像它们实际上是在接触地面。我们使用的阴影比实时阴影更好,因为它处理得更快。
尝试一下英雄 – 让它变得方形
随着阴影一起提供的纹理是圆形的,但我们的坦克主要是方形的。尝试为阴影创建自己的纹理并使用它。某种矩形形状应该会很好。
如果你成功地为阴影添加了自己的纹理,那么不妨看看那个大炮?大炮从我们的坦克中伸出,破坏了它原本的方形轮廓。使用一个附加在炮塔上的第二个阴影,为大炮投射阴影。它的纹理也将需要是矩形形状。
概述
到目前为止,你应该已经非常熟悉摄像头效果和灯光了。
在本章中,我们首先探讨了使用多个摄像头。然后我们尝试了涡轮增压摄像头效果。本章继续讲述了我们城市的照明。当我们使用光照贴图时,灯光得到了极大的改善。我们通过查看用于特殊照明效果的 cookie 和阴影来结束本章。
在下一章中,我们将看到为我们的游戏创建敌人。我们将使用 Unity 的路径查找系统让它们移动并追逐玩家。之后,如果玩家希望保持他们的分数,他们需要变得更加活跃。
第五章。四处走动 – 寻路和人工智能
在前一章中,我们学习了关于摄像机和照明效果的知识。我们在坦克大战游戏中添加了天空盒、灯光和阴影。我们创建了光照贴图来使场景动态。我们通过给坦克添加车头灯来查看饼干。我们还通过为坦克创建一个阴影来查看投影仪。还为坦克创建了一个涡轮增压。通过调整摄像机的观看角度,我们能够使坦克看起来比实际速度快得多。当我们完成时,我们有一个动态且看起来很刺激的场景。
本章全部关于敌人。玩家将不再能够只坐在一个地方来收集分数。我们将向游戏中添加一个敌方坦克。通过使用 Unity 的 NavMesh 系统,坦克将能够进行寻路并追逐玩家。一旦被发现,坦克就会开火并减少玩家的分数。
在本章中,我们将涵盖以下主题:
-
NavMesh
-
NaveMeshAgent
-
寻路
-
追击和攻击人工智能
-
生成点
我们将对第四章中的坦克大战游戏进行修改,第四章,搭建舞台 – 摄像机效果和照明,所以请打开它,我们可以开始。
理解人工智能和寻路
人工智能正如你可能猜到的,是人工智能。在广义上,这是任何非生命物体可能做的事情,以看起来像是在做决策。你可能最熟悉这个概念来自视频游戏。当一个角色,不是由玩家控制的,选择一个武器和一个目标来使用,这就是人工智能。
在其最复杂的形式中,人工智能试图模仿完整的人类智能。然而,仍有太多事情发生得非常快,以至于这很难真正成功。视频游戏不需要达到这种程度。我们主要关注的是让我们的角色看起来聪明,但仍然可以被我们的玩家征服。通常,这意味着不让角色根据比真实玩家可能拥有的更多信息来行动。调整角色拥有的信息和可以采取行动的信息量是调整游戏难度的好方法。
寻路是人工智能的一个子集。我们经常使用它,尽管你可能从未意识到这一点。正如其词义所示,寻路就是找到一条路径。每次你需要在你和任何两个点之间找到路径时,你就是在进行寻路。就我们的角色而言,最简单的寻路形式是沿着直线到目标点。显然,这种方法在开阔平原上效果最好,但如果有任何障碍物,这种方法就会失败。另一种方法是使用网格覆盖游戏。使用网格,我们可以找到一条绕过任何障碍物并达到目标的路径。
路径查找的另一种方法,也许是最常选择的方法,是使用一个特殊的导航网格,或称为 NavMesh。这是一个玩家永远不会看到的特殊模型,但它覆盖了计算机角色可以移动的所有区域。然后以类似网格的方式导航,区别在于使用网格的三角形而不是网格的方形。这是我们将在 Unity 中使用的方法。Unity 提供了一套很好的工具来创建 NavMesh 并利用它。
NavMesh
在 Unity 中创建导航网格非常简单。这个过程与我们制作光照贴图的过程类似。我们只需标记一些网格用于使用,在一个特殊窗口中调整一些设置,然后点击一个按钮。所以,如果你还没有这样做,请加载 Unity 中的坦克战斗游戏,我们就可以开始了。
时间行动 - 创建 NavMesh
Unity 可以自动从场景中存在的任何网格生成 NavMesh。为此,网格必须首先被标记为静态,就像我们对光照贴图所做的那样。然而,我们不想或不需要能够导航我们城市的屋顶,因此我们使用一组特殊的设置来指定每个对象将是什么类型的静态:
-
从 Hierarchy 窗口中选择城市,然后在 Inspector 窗口中点击 Static 右侧的向下箭头,我们可以查看静态对象可用的选项如下:
![Time for action – creating the NavMesh]()
-
Nothing: 此选项用于快速取消选择所有其他选项。如果所有其他选项都被取消勾选,则此选项将被勾选。
-
Everything: 使用此选项,您可以快速选择所有其他选项。当所有这些选项都被勾选时,此选项也会被勾选。Inspector 窗口中 Static 标签旁边的复选框执行与勾选和取消勾选 Everything 复选框相同的操作。
-
Lightmap Static: 在使用光照贴图时,需要勾选此选项,以便它们能够正常工作。任何未勾选此选项的网格将不会进行光照贴图处理。
-
Occluder Static: 这是一个用于处理遮挡的选项。Occlusion 是一种运行时优化方法,它只渲染实际上可以看到的对象,无论它们是否在摄像机的视图空间内。Occluder 是一个将阻止其他对象被看到的对象。它与 Occludee Static 选项协同工作。此选项的最佳对象选择是大型且坚固的。
-
Batching Static: 这是另一种运行时优化的选项。批处理是在渲染之前将对象分组的行为。它大大提高了游戏的整体渲染速度。
-
Navigation Static: 这是目前我们主要关注的选项。任何勾选了此选项的网格将在计算 NavMesh 时使用。
-
被遮挡物静态: 如前所述,此选项与遮挡物静态结合使用,以利于遮挡。被遮挡物是指将被其他物体遮挡的物体。当被遮挡物遮挡时,此物体将不会绘制。
-
离网链接生成: 此选项也与 NavMesh 计算一起工作。离网链接是 NavMesh 两个物理上未连接的部分之间的连接,例如屋顶和街道。通过在导航窗口中使用一些设置和此选项,链接将自动生成。
-
-
为了使 NavMesh 正常工作,我们需要更改设置,以便只有城市的街道可以导航。你上次看到坦克跳到或从建筑物的屋顶上下来是什么时候?因此,我们需要更改静态选项,以便只有街道有导航静态被选中。这可以通过以下两种方式之一完成:
-
第一种方法是逐一取消我们想要更改的每个对象的选项。
-
第二种方法是取消层次结构窗口中顶级对象的导航静态选项,当 Unity 询问我们是否要为所有子对象进行更改时,回答“是”。然后,仅针对我们想要导航的对象重新选中此选项。
-
-
现在,通过访问 Unity 的工具栏并点击窗口,然后点击菜单底部的导航来打开导航窗口。以下截图显示了制作 NavMesh 的所有工作的窗口:
![Time for action – creating the NavMesh]()
-
此窗口由三个页面和各种设置组成:
-
当选择一个对象时,设置将出现在对象页面上。两个复选框直接对应于我们刚才设置的相同名称的静态选项。导航层下拉列表让我们可以为 NavMesh 的不同部分使用不同的层。这些层可以用来影响路径查找计算。例如,可以将汽车设置为仅在道路层上行驶,而人类可以跟随人行道层。
-
烘焙页面是我们感兴趣的一个页面;它充满了更改 NavMesh 生成方式的选项。
半径: 这应该设置为平均角色的尺寸。它用于防止角色走得太靠近墙壁。
高度: 这是角色的身高。使用此设置,Unity 可以计算并移除角色无法通过的区域。任何低于此值的区域都被认为是太小了。
最大坡度: 在计算 NavMesh 时,任何比此值更陡峭的物体都将被忽略。
步高: 当使用楼梯时,必须使用此值。这是角色可以踩上的楼梯的最大高度。
下落高度: 这是指角色可以坠落的高度。有了这个设置,路径将包括从边缘跳下的情况,如果这样做更快的话。从截图可以看出,这是一个 Unity Pro 独有的功能。
跳跃距离: 使用这个值,角色可以在 NavMesh 中跳过间隙。这个值代表可以跳过的最长距离。从截图可以看出,这是一个 Unity Pro 独有的功能。
最小区域面积: 如果 NavMesh 的部分区域太小,小于这个值的任何区域将不会被用于最终的 NavMesh 中。
宽度误差百分比: 在进行 NavMesh 计算时,Unity 使用了许多近似值。这并不完全准确,但速度很快。这个值代表水平方向允许的误差量。
高度误差百分比: 这与之前的设置相同,区别在于它适用于垂直方向。
高度网格: 如果选中这个选项,原始的高度信息将保留在 NavMesh 中。除非你有特殊需求,否则这个选项应该保持关闭状态。它需要更长的时间来计算,并且需要更多的内存来存储。
-
第三页,层,允许我们调整每个层的移动成本。基本上,移动通过我们游戏世界的不同部分有多难。对于汽车,我们可以调整层,使它们在田野中移动的成本是沿路的两倍。
在窗口底部,我们有两个按钮:
清除: 这个按钮删除之前创建的 NavMesh。使用此按钮后,您需要重新烘焙 NavMesh,然后才能再次使用路径查找。
烘焙: 这个按钮开始工作并创建 NavMesh。
-
-
我们的城市非常简单,所以默认值将足够适合我们。点击烘焙并观察右下角的进度条。一旦完成,会出现一个蓝色网格。这就是 NavMesh,当然,代表了一个角色可以移动通过的所有区域。
-
我们还需要做最后一件事。我们的 NavMesh 恰到好处,但如果仔细观察,它会穿过城市中心的喷泉。如果敌方坦克开始开进喷泉,那就太不合适了。为了修复这个问题,首先选择形成喷泉周围墙壁的网格。
-
在 Unity 的工具栏中,点击组件,然后是导航,最后是NavMeshObstacle。这仅仅是一个组件,它告诉导航系统在寻找路径时绕行。因为我们已经选择了墙壁,所以新的组件已经调整大小以适应。你可以在场景视图中看到它表示为一个线框圆柱体。
![创建 NavMesh 的行动时间]()
发生了什么?
我们创建了 NavMesh。我们使用了导航窗口和静态选项来告诉 Unity 在计算 NavMesh 时使用哪些网格。Unity 团队为此过程投入了大量工作,使其变得快速且简单。
英雄试炼——创建额外障碍
记住,在第三章中,任何游戏的骨架——网格、材质和动画,当挑战是为玩家创建障碍时,你被鼓励创建额外的网格,例如坦克陷阱和碎石。让敌方坦克通过这些障碍是个坏主意。所以,尝试将这些转换为导航系统的障碍。这将与喷泉的做法一样。
NavMeshAgent 组件
你可能会想,我们有了 NavMesh,但没有角色可以导航它。在本节中,我们将开始创建我们的敌方坦克。
行动时间——创建敌方坦克
在进行任何 AI 类型编程之前,我们需要导入并设置第二个坦克:
-
从章节的起始资源中选择
Tanks_Type03.png和Tanks_Type03.blend,并将它们导入到Models文件夹下的Tanks文件夹中。 -
一旦 Unity 完成导入,请在项目窗口中选择新的坦克,并在检查器窗口中查看它。
-
这辆坦克没有动画,因此可以将动画类型设置为无,并分别从骨架和动画页面取消勾选导入动画。
-
将坦克从项目窗口拖到场景窗口;任何清晰的街道区域都行。
-
首先,将场景视图中的模型重命名为
EnemyTank。 -
现在,我们需要更改坦克的父级,以便炮塔可以旋转,炮管可以跟随,就像我们对玩家的坦克所做的那样。为此,创建一个空的GameObject并将其重命名为
TurretPivot。 -
将
TurretPivot定位在炮塔的底部。 -
在层次结构窗口中,将
TurretPivot拖放到EnemyTank上,使EnemyTank成为父对象。 -
仍然在层次结构窗口中,将炮管和炮塔网格对象设置为
TurretPivot的子对象。当 Unity 询问你是否确定要断开预制件连接时,请确保点击是。 -
坦克有点大,所以需要在检查器窗口中调整坦克的导入设置中的缩放因子为
0.6,以得到大约与玩家大小相当的坦克。 -
为了让坦克能够导航我们的新 NavMesh,我们需要添加一个NavMeshAgent组件。首先,在层次结构窗口中选择
EnemyTank,然后转到 Unity 的工具栏;点击组件,然后点击导航,接着点击NavMeshAgent。在检查器窗口中,我们可以看到新的组件及其相关设置,如下面的截图所示:![创建敌方坦克的行动时间]()
-
半径:这仅仅是代理的大小。与我们在导航窗口中设置的半径值一起工作,这可以防止对象部分地走进墙壁。
-
速度:当NavMeshAgent组件有路径时,它会自动移动连接的对象。此值决定了以每秒多少单位速度跟随路径。
-
加速度:这是代理将加速到的最大速度。
-
角速度:这是代理每秒可以转动的度数。人会有很高的角速度,而汽车的角速度会很低。
-
停止距离:这是代理开始减速并停止距离目标目的地有多远。
-
自动穿越离网链接:勾选此复选框后,代理在路径查找时将使用离网链接,例如跳跃缺口和从边缘掉落。
-
自动制动:勾选此复选框后,代理一到达目的地就会停止,而不是因为不规则的帧率而超过去。
-
自动重新规划路径:如果找到的路径因任何原因不完整,此复选框允许 Unity 自动尝试找到一个新的路径。
-
高度:此设置影响编辑器中出现的围绕代理的圆柱体。它只是设置了该圆柱体的高度。
-
基础偏移:这是附加到代理上的碰撞器的垂直偏移。
-
障碍物避免类型:这是代理在寻找绕过障碍物的平滑路径时将投入多少努力。质量越高,工作越多。
-
避免优先级:此值决定了谁有通行权。值高的代理会绕过值低的代理。
-
NavMesh 可通行:还记得之前在讨论导航窗口时提到的那些层吗?这就是我们可以设置代理能够穿越的层的地方。只有在这个列表中勾选的层才会用于路径查找。
-
-
现在我们已经了解了设置,让我们来使用它们。对于敌方坦克,半径设置为
2.4,高度设置为4将工作得很好。你应该能在场景窗口中看到另一个线形圆柱体,这次是在我们的敌方坦克周围。 -
最后要做的就是将
EnemyTank转换为预制体。就像我们处理目标一样做,通过从层次结构窗口拖动它,并将其放置在项目窗口中的Prefabs文件夹上。
刚才发生了什么?
我们创建了一个敌人坦克。我们还了解了NavMeshAgent组件的设置。然而,如果你现在尝试玩游戏,什么都不会发生。这是因为NavMeshAgent组件没有被赋予一个目的地。我们将在下一节解决这个问题。
追逐
我们接下来的任务是让我们的敌人坦克追逐玩家。我们需要两个脚本。第一个脚本将简单地广播玩家的当前位置。第二个脚本将使用那个位置和我们之前设置的NavMeshAgent组件来找到通往玩家的路径。
行动时间 – 玩家在这里
通过一个非常短的脚本,我们可以轻松地让所有敌人知道玩家的位置:
-
首先在项目窗口的
Scripts文件夹中创建一个新的脚本。命名为PlayerPosition。 -
这个脚本将从单个静态变量开始。这个变量将简单地保存玩家的当前位置。因为它是一个静态变量,所以我们可以轻松地从我们的其他脚本中访问它。
public static Vector3 position = Vector3.zero; -
对于接下来的代码行,我们使用
Start函数。当场景首次加载时,这个函数会自动被调用。我们使用它来确保position变量可以在游戏开始时立即填充并使用。public void Start() { position = transform.position; } -
代码的最后一段只是将
position变量在每一帧更新为玩家的当前位置。我们也在LateUpdate函数中这样做,以确保在玩家移动之后完成。LateUpdate函数在每个帧的末尾被调用。这样,玩家就可以在OnGUI和Update函数期间移动,并且他们的位置稍后会被更新。public void LateUpdate() { position = transform.position; } -
使用这个脚本的最后一步是将它添加到玩家的坦克上。所以,回到 Unity,从项目窗口拖放脚本到坦克上,就像我们之前对所有的其他脚本所做的那样,添加它作为一个组件。
刚才发生了什么?
我们为追逐 AI 创建了第一个必要的脚本。这个脚本只是更新一个变量,该变量包含玩家的当前位置。我们将在下一个脚本中使用它,我们将让敌人坦克四处移动。
行动时间 – 追逐玩家
我们接下来的脚本将控制我们的简单追逐 AI。因为我们正在使用NavMesh和NavMeshAgent组件,我们可以将路径查找的大部分困难部分留给 Unity:
-
再次创建一个新的脚本。这次给它命名为
ChasePlayer。 -
这个脚本的第一个代码行只是保存了我们之前设置的NavMeshAgent组件的引用。我们需要访问这个组件来移动敌人。
public NavMeshAgent agent; -
代码的最后部分首先确保我们有了NavMeshAgent引用,然后更新我们的目标目的地。它使用之前设置的
PlayerPosition脚本变量和NavMeshAgent的SetDestination函数。一旦我们告诉函数去哪里,NavMeshAgent组件就会做所有艰难的工作,带我们到那里。我们在FixedUpdate函数中更新目标目的地,因为我们不需要在每一帧都更新目的地。更新太频繁可能会导致严重的延迟,如果有很多敌人。FixedUpdate函数以固定间隔调用,比帧率慢,所以它很完美。public void FixedUpdate() { if(agent == null) return; agent.SetDestination(PlayerPosition.position); } -
我们现在需要将脚本添加到我们的敌人坦克中。在项目窗口中选择
prefab,并将脚本拖放到检查器面板中,位于NavMeshAgent组件下方。 -
一定要连接引用,就像我们之前做的那样。将NavMeshAgent组件拖到检查器窗口中的代理值。
-
现在玩游戏来尝试一下。无论敌人从哪里开始,它都会绕过所有建筑并到达玩家的位置。当你驾驶时,你可以观察敌人跟随。然而,敌人坦克最终穿过了我们的坦克。而且,我们也可以穿过它。
-
修复这个问题的第一步是添加一些碰撞器。从组件菜单下的物理中添加一个盒子碰撞器组件到炮塔、底盘以及每个履带箱对象。大炮和履带不需要碰撞器。履带箱已经覆盖了履带的区域,而大炮的目标太小,无法被正确射击。
![行动时间 – 追踪玩家]()
注意
如果你正在场景视图中进行任何这些更改,请确保在检查器窗口中点击应用按钮来更新根预制对象。
-
最后要更改的是NavMeshAgent组件上的停止距离属性。当坦克战斗时,它们进入射程并开始射击。它们不会试图占据与敌人相同的空间,除非那个敌人很小且柔软。通过将停止距离设置为
10,我们能够复制这种行为。![行动时间 – 追踪玩家]()
发生了什么?
在本节中,我们创建了一个脚本,使NavMeshAgent组件,在这种情况下是我们的敌人坦克,去追逐玩家。我们添加了碰撞器以阻止我们穿过敌人。我们还调整了停止距离以获得更好的坦克行为。
尝试英雄般的动作 – 添加阴影
尝试给敌人坦克添加一个阴影。这将给它一个更好的视觉上的接地感。你可以直接复制为玩家坦克制作的那个。
被攻击
没有冲突的游戏有什么乐趣;那令人烦恼的选择,那场殊死搏斗,那宇宙的末日?每个游戏都需要某种形式的冲突来驱使玩家寻求解决方案。我们的游戏将变成一场争夺分数的战斗。在此之前,它只是射击一些目标并获得一些分数。
现在,我们将让敌方坦克射击玩家。每次敌人击中,我们将减少玩家几分。
行动时间 - 准备射击
敌人将以与玩家射击相似的方式射击,但我们将使用一些基本的 AI 来控制方向和射击速度,以替换玩家的输入控制:
-
我们将从这个名为
ShootAtPlayer的新脚本开始。在Scripts文件夹中创建它。 -
就像我们所有的其他脚本一样,我们从这个脚本开始时有两个变量。第一个变量将保存敌方坦克的最后位置。如果坦克在移动,它将不会射击,因此我们需要存储那个最后位置以查看我们是否移动了。第二个变量将是我们可以移动和射击的最大速度。如果坦克移动得比这个速度快,它将不会开火。
private Vector3 lastPosition = Vector3.zero; public float maxSpeed = 1f; -
下两个变量决定了坦克准备射击所需的时间。在每一帧都射击玩家是不现实的。因此,我们使用第一个变量来调整准备射击所需的时间长度,第二个变量来存储射击何时准备就绪。
public float readyLength = 2f; private float readyTime = -1; -
下一个变量是炮塔旋转的速度。当坦克准备射击时,炮塔不会旋转指向玩家。这给了玩家一个移动避开的机会。然而,我们需要一个速度来防止炮塔在射击后立即转向面对玩家。
public float turretSpeed = 45f; -
这里最后两个变量持有坦克其他部分的引用。
turretPivot变量当然是我们要旋转的炮塔的旋转点。muzzlePoint变量将用作我们的炮火发射点。这些将按照与玩家坦克相同的模式使用。public Transform turretPivot; public Transform muzzlePoint; -
对于脚本的第一个功能,我们将使用
Update函数。它首先调用一个函数来检查是否可以发射炮火。如果我们能发射,我们会对我们的readyTime变量进行一些检查。如果它小于零,我们还没有开始准备射击,并调用一个函数来这样做。否则,如果它小于当前时间,我们已经完成了准备,并调用一个函数来发射炮火。如果我们不能发射,我们首先调用一个函数来清除任何准备,然后旋转炮塔面对玩家。public void Update() { if(CheckCanFire()) { if(readyTime < 0) { PrepareFire(); } else if(readyTime <= Time.time) { Fire(); } } else { ClearFire(); RotateTurret(); } } -
接下来,我们将创建我们的
CheckCanFire函数。代码的第一部分检查我们是否移动得太快。首先,我们使用Vector3.Distance来查看自上一帧以来我们移动了多远。通过将距离除以帧的长度,我们能够确定我们移动的速度。然后,我们使用当前的位置更新我们的lastPosition变量,以便它为下一帧做好准备。最后,我们将当前速度与maxSpeed进行比较。如果我们在这帧中移动得太快,我们就无法射击并返回一个false的结果。public bool CheckCanFire() { float move = Vector3.Distance(lastPosition, transform.position); float speed = move / Time.deltaTime; lastPosition = transform.position; if(speed > maxSpeed) return false; -
对于
CheckCanFire函数的第二部分,我们检查炮塔是否指向玩家。首先,我们找到指向玩家的方向。给定空间中的任何点,从它减去第二个点的位置,将给出从第二个点到第一个点的方向向量。然后,我们通过将y值设置为0来简化方向。这是因为在玩家上下看时我们不想这样做。然后,我们使用Vector3.Angle来找到指向玩家方向和我们的炮塔前进方向之间的角度。最后,我们将角度与一个低值进行比较,以确定我们是否在看着玩家,并返回结果。Vector3 targetDir = PlayerPosition.position – turretPivot.position; targetDir.y = 0; float angle = Vector3.Angle(targetDir, turretPivot.forward); return angle < 0.1f; } -
PrepareFire函数简单快捷。它只是将我们的readyTime变量设置为坦克完成射击准备的未来时间。public void PrepareFire() { readyTime = Time.time + readyLength; } -
Fire函数首先确保我们有一个可以从中射击的muzzlePoint引用。public void Fire() { if(muzzlePoint == null) return; -
函数继续创建一个
RaycastHit变量来存储射击结果。我们使用Physics.Raycast和SendMessage,就像我们在FireControls脚本中做的那样,射击任何东西并告诉它我们击中了它。RaycastHit hit; if(Physics.Raycast(muzzlePoint.position, muzzlePoint.forward, out hit)) { hit.transform.gameObject.SendMessage("RemovePoints", 3, SendMessageOptions.DontRequireReceiver); } -
Fire函数通过清除射击准备来完成。ClearFire(); } -
ClearFire函数是另一个快速函数。它将我们的readyTime变量设置为小于零,表示坦克没有准备射击。public void ClearFire() { readyTime = -1; } -
脚本中的最后一个函数是
RotateTurret。它首先检查turretPivot变量,如果引用缺失则取消函数。接着,它找到指向玩家的平面方向,就像我们之前做的那样。然后,我们创建step变量来保存这一帧我们可以移动的距离。我们使用Vector3.RotateTowards找到一个比当前前进方向更接近指向目标的向量。最后,我们使用Quaternion.LookRotation创建一个特殊的旋转,使炮塔指向新的方向。public void RotateTurret() { if(turretPivot == null) return; Vector3 targetDir = PlayerPosition.position – turretPivot.position; targetDir.y = 0; float step = turretSpeed * Time.deltaTime; Vector3 rotateDir = Vector3.RotateTowards(turretPivot.forward, targetDir, step, 0); turretPivot.rotation = Quaternion.LookRotation(rotateDir); } -
现在,回到 Unity 中,创建一个空的GameObject并将其重命名为
MuzzlePoint。 -
将
MuzzlePoint的位置设置为我们为玩家所做的,在炮管末端。 -
将
MuzzlePoint设置为炮管的子对象,并在Inspector窗口中将其任何Y旋转归零。 -
接下来,将我们的新
ShootAtPlayer脚本添加到敌方坦克上。此外,连接到TurretPivot和MuzzlePoint变量的引用。 -
最后,对于敌人坦克,在检查器窗口中点击应用按钮以更新预制体。
-
如果你现在玩游戏,你会看到敌人旋转指向你,但你的分数从未减少。这是因为两个问题。首先,坦克略微漂浮。无论你在世界的哪个地方放置它,在玩游戏时,坦克都会略微漂浮。这是因为 NavMeshAgent 组件的工作方式。修复方法是简单的,只需在检查器窗口中将基础偏移设置为
-0.3。这会让系统上当,并将坦克放在地面上。 -
第二个原因是分数没有变化是因为玩家缺少一个函数。打开
ScoreCounter脚本。 -
我们将添加
RemovePoints函数。给定一个数值,这个函数简单地从玩家的分数中减去这么多分数。public void RemovePoints(int amount) { score -= amount; }![行动起来——准备射击]()
刚才发生了什么?
我们赋予了敌人攻击玩家的能力。新的ShootAtPlayer脚本首先检查坦克是否减速以及大炮是否对准了玩家。如果是这样,它将对玩家进行常规射击以减少他们的分数。如果玩家希望在游戏结束时保留任何分数,他们需要不断移动并快速瞄准目标。
英雄尝试——玩家反馈
除非你密切关注你的分数,否则很难判断你是否正在被射击。我们将在未来的章节中处理爆炸,即使如此,玩家也需要一些反馈来了解发生了什么。大多数游戏会在玩家被击中时在屏幕上闪烁红色纹理,无论是否有爆炸。尝试创建一个简单的纹理,并在玩家被击中时在屏幕上绘制半秒钟。
攻击敌人
当面对无法与之战斗的敌人时,玩家往往会很快感到沮丧。因此,我们将赋予玩家伤害和摧毁敌人坦克的能力。这将以类似于射击目标的方式工作。
行动起来——赋予它弱点
让我们的敌人变弱的最简单方法是为它们提供一些生命值,当它们被射击时会减少,当生命值耗尽时摧毁它们:
-
我们首先创建一个新的脚本,并将其命名为
Health。 -
这个脚本相当简短,并且从一个单一变量开始。这个变量将跟踪坦克剩余的生命值。通过将默认值设置为
3,坦克在被摧毁之前能够承受三次攻击。public int health = 3; -
这个脚本也只包含一个函数,
Hit。就像目标的情况一样,当玩家射击它时,这个函数由BroadcastMessage函数调用。函数的第一行将health减少一点。下一行检查health是否低于零。如果是,通过调用Destroy函数并传递gameObject变量来销毁坦克。我们还给玩家一些分数。public void Hit() { health--; if(health <= 0) { Destroy(gameObject); ScoreCounter.score += 5; } } -
真的是这样简单。现在,将新脚本添加到项目窗口中的
EnemyTank预制体,它将更新场景中当前所有的敌方坦克。 -
尝试一下。在场景中添加几个额外的敌方坦克,并观察它们围绕你移动,在你射击它们时消失。
发生了什么?
我们给敌方坦克一个弱点,即生命值。通过创建一个简短的脚本,坦克能够跟踪其生命值并检测它是否被射击。一旦坦克的生命值耗尽,它将从游戏中移除。
尝试一下英雄般的操作——为敌人着色
我们现在有两个射击目标:动画目标和坦克。然而,它们都由红色切片指示。尝试使指向坦克的切片颜色不同。你将需要复制IndicatorSlice预制体并修改IndicatorControl脚本,以便在调用CreateSlice和NewSlice函数时能够指定使用哪种类型的切片。
作为进一步的挑战,当我们给一个生物一些生命值时,玩家希望能够看到他们对其造成的伤害量。你可以有两种方法来做这件事。首先,你可以在坦克上方放置一个立方体簇。然后,每次坦克失去生命值时,就删除一个立方体。第二种选项稍微复杂一些,在 GUI 中绘制条形并根据剩余的生命值改变其大小。为了使条形在摄像机移动时保持在坦克上方,请查看文档中的Camera.WorldToScreenPoint。
生成
在游戏开始时,游戏中的敌人数量有限并不适合我们的游戏以保持持久的乐趣。因此,我们需要创建一些生成点。随着坦克被摧毁,这些生成点将产生新的坦克,以保持玩家保持警惕。
行动时间——创建生成点
在本节中我们将创建的脚本将保持我们的游戏世界充满玩家可能想要摧毁的所有敌人:
-
我们需要为这部分创建另一个新的脚本。一旦创建,将其命名为
SpawnPoint。 -
这个脚本简单地开始,定义了几个变量。第一个变量将保存对
EnemyTank预制体的引用。我们需要它来生成副本。public GameObject tankPrefab; -
第二个变量跟踪生成的坦克。当它被销毁时,我们将创建一个新的坦克。使用这个变量,我们防止游戏被敌人淹没。坦克的数量将和生成点一样多。
private GameObject currentTank; -
第三个变量用于设置玩家距离,以防止在玩家上方生成坦克。如果玩家在这个距离之外,可以生成一个新的坦克。如果他们在里面,则不会生成新的坦克。
public float minPlayerDistance = 10; -
我们将使用第一个函数是
FixedUpdate。它将首先检查一个函数,看是否需要生成一个新的坦克。如果需要,它将调用SpawnTank函数来生成。public void FixedUpdate() { if(CanSpawn()) SpawnTank(); } -
接下来,我们创建
CanSpawn函数。函数的第一行检查我们是否已经有了坦克,如果有,则返回false。第二行使用Vector3.Distance来确定玩家当前的距离。最后一行将这个距离与玩家需要到达的最小距离进行比较,以确定我们是否可以生成任何东西,并返回结果。public bool CanSpawn() { if(current != null) return false; float currentDistance = Vector3.Distance(PlayerPosition.position, transform.position); return currentDistance > minPlayerDistance; } -
最后一个函数,
SpawnTank,首先检查确保tankPrefab引用已经被连接。如果没有东西可以生成,它将无法继续。第二行使用Instantiate函数创建预制体的副本。为了将其存储在我们的变量中,我们使用as GameObject使其成为正确的类型。最后一行将新坦克移动到生成点的位置。我们不希望坦克出现在随机的位置。public void SpawnTank() { if(tankPrefab == null) return; currentTank = Instantiate(tankPrefab) as GameObject; currentTank.transform.position = transform.position; } -
返回 Unity,创建一个空的GameObject,并将其重命名为
SpawnPoint。 -
将我们刚刚创建的
SpawnPoint脚本添加到其中。 -
接下来,选择生成点,通过从
Prefabs文件夹中将EnemyTank预制体拖动并放置在适当的值上,连接预制体引用。 -
现在,通过从Hierarchy窗口拖动并放置到
Prefabs文件夹中,将SpawnPoint对象转换为预制体。 -
最后,用新的点填充城市。在每个角落放置一个点将工作得很好。
![行动时间 - 创建生成点]()
发生了什么?
我们为游戏创建了生成点。每个点都会生成一个新的坦克。当一个坦克被摧毁时,在生成点处会创建一个新的坦克。你可以自由地构建游戏并在你的设备上尝试它。本节和本章现在已完成,准备总结。
英雄试炼 - 一对二
每个坦克有一个生成点很好,直到我们想要很多坦克。或者,我们希望它们都在同一个位置生成。这里的挑战是让一个生成点跟踪多个坦克。如果任何一个坦克被摧毁,应该创建一个新的。你肯定需要一个数组来跟踪所有的坦克。还要实现一个生成延迟。我们不希望它一次生成多个坦克。
现在你已经拥有了所有需要的知识和工具,作为一个进一步的挑战,尝试创建其他类型的敌方坦克。实验一下大小和速度。它们也可以有不同的强度,或者在摧毁时获得更多分数。也许有一种坦克在玩家射击它们时会给玩家加分。玩一玩,享受其中的乐趣。
快速问答 - 理解敌人
Q1. 在游戏中,最常见的行为可能是使用枪支射击。我们已经做过几次了。你还记得我们用来确定子弹可能击中哪里和什么的函数吗?
-
Input.mousePosition -
Transform.position -
Physics.Raycast
Q2. 我们需要许多敌人四处奔跑,以便我们可以射击它们。你还记得我们用来让它们在 NavMesh 上移动的函数吗?
-
CharacterController.Move -
NavMeshAgent.SetDestination -
NavMesh.CalculatePath
Q3. 最后,所有这些敌人将不得不一次又一次地被生成。你还记得我们用来生成这些敌人的函数吗?
-
Instantiate -
Destroy -
Start
摘要
在本章中,我们学习了 NavMesh 和路径查找。我们还做了一些与 AI 相关的工作。这可能是最简单的 AI 类型之一,但追逐行为对所有类型的游戏都至关重要。为了利用所有这些,我们创建了一个敌方坦克。它追逐玩家并向他们射击以减少他们的分数。为了给玩家一些优势,我们给敌方坦克增加了生命值。现在,玩家可以射击敌方坦克以及目标以获得分数。但是,我们也创建了一些生成点。每次坦克被摧毁时,都会创建一个新的坦克。从一般游戏玩法来看,我们的坦克大战游戏几乎已经完成。
在下一章中,我们将创建一个新的游戏。为了探索移动平台的一些特殊功能,我们将创建一个太空战斗机游戏。几乎所有的按钮都将从屏幕上移除,以支持新的控制方法。我们将把设备的倾斜传感器转换成我们的转向方法。而且,我们必须触摸敌人来射击它们。我们还将探讨不同的生成方法,为玩家提供无尽的飞行空间。
第六章. 移动设备的特性 - 触摸和倾斜
在前一章中,我们学习了路径查找和人工智能。我们将坦克战斗游戏扩展到包括敌方坦克。我们为它们创建了出生点,并让它们向玩家开火。利用 Unity 的路径查找系统,我们让坦克追逐玩家。此外,玩家还被赋予了摧毁坦克的能力。一旦摧毁,玩家将获得一些分数,并生成一个新的敌方坦克。
在本章中,我们开始着手制作一个新游戏,同时探索移动设备的特性。我们将创建一个太空战斗机游戏。玩家将控制一艘太空船,摧毁敌方飞船、地雷和小行星以获得分数。为了控制他们的飞船,玩家必须倾斜移动设备。为了射击,玩家需要在屏幕上触摸他们希望激光束击中的位置。
在本章中,我们将涵盖以下主题:
-
触摸控制
-
倾斜控制
我们将为本章创建一个新的项目,所以启动 Unity,我们将开始。
设置
与每个项目一样,我们需要做一些准备工作来准备我们的开发环境。别担心,本章的设置简单直接。
行动时间 - 创建项目
让我们开始吧。第一步当然是启动 Unity 并创建一个新的项目。将项目命名为Ch6_SpaceFighter将是一个不错的选择:
-
一旦 Unity 完成初始化,这就是设置我们的构建设置的绝佳机会。打开构建设置窗口,从平台列表中选择Android,然后点击切换平台以更改目标平台。
-
在构建设置窗口中,选择玩家设置以在检查器中打开玩家设置。调整公司名称、产品名称,尤其是包标识符。
-
我们需要创建几个文件夹以保持项目有序。在项目窗口中创建
Scripts、Models和Prefabs文件夹。 -
我们现在需要导入这个项目的资源。我们需要一个玩家飞船、一个敌方飞船、一个爆炸性地雷和一些小行星。幸运的是,所有这些都已经准备好,并且在本章的起始资源中可用。将
PlayerShip.blend、PlayerShip.png、EnemyShip.blend、EnemyShip.png、Asteroid.blend、Asteroid.png、SpaceMine.blend和SpaceMine.png导入你刚刚创建的Models文件夹中。
刚才发生了什么?
我们刚刚完成了本章项目的设置。再次强调,在项目开始时付出一点努力将节省时间和挫折。特别是,随着项目规模的扩大,最初的组织变得最为重要。
倾斜控制
现代移动设备拥有广泛的各种内部传感器,用于检测和提供有关周围世界的信息。尽管你可能没有这样想过,但你肯定最熟悉用于打电话的麦克风和扬声器。有一个 Wi-Fi 接收器用于连接互联网,还有一个相机用于拍照。你的设备几乎肯定有一个磁力计,与 GPS 一起工作以提供方向。
目前我们感兴趣的是陀螺仪这个传感器。这个传感器可以检测设备的局部旋转。通常,它被用来确定设备的方向。我们将用它来控制我们的飞船。当用户左右倾斜设备时,他们的飞船将向侧面转动。当设备上下倾斜时,飞船将上下移动。
行动时间 - 控制飞船
为了控制我们的飞船,我们需要创建一个单独的脚本并将其应用到我们的飞船上:
-
为了开始这个过程,创建一个新的脚本并将其命名为
TiltSteering。 -
就像我们所有的其他脚本一样,我们将从这个脚本开始,设置几个变量。前两个变量控制飞船旋转的速度,当设备倾斜时。接下来的两个变量将被用来限制飞船的旋转。这些将决定玩家飞船可以转动的圆圈有多紧。
public float horizRotateSpeed = 7f; public float vertRotateSpeed = 3f; public float horizMax = 60f; public float vertMax = 45f; -
接下来,我们将使用
Update函数。我们首先创建一个变量来存储飞船当前的旋转。然后,我们必须调整旋转。当使用欧拉旋转时,Unity 会将值调整为介于 0 到 360 之间。这样,值永远不会是负数。任何小于 0 的值都会简单地绕过并从 360 开始倒数;任何大于 360 的值都会回到起点,再次从 0 开始计数。我们需要负值。所以,如果旋转的部分超过 180 度,我们就减去 360 度来确定它们的负值。此外,我们不对 z 分量进行调整,因为飞船不会围绕其前进轴旋转。public void Update() { Vector3 rotation = transform.eulerAngles; if(rotation.y > 180f) rotation.y -= 360f; if(rotation.x > 180f) rotation.x -= 360f; -
接下来,我们应用加速度计的测量值。当设备水平持有时,主页按钮在右侧,屏幕朝向用户,x 分量保持设备面向用户时的旋转。z 分量保持屏幕上下倾斜时的旋转。这些分量乘以它们各自的速度并加到旋转上。y 旋转控制左右指向,而 x 分量控制上下指向。然而,z 加速度与飞船应该旋转的方向相反,所以我们取其负值。
rotation.y += Input.acceleration.x * horizRotateSpeed; rotation.x += -Input.acceleration.z * vertRotateSpeed; -
在应用加速度之后,我们需要限制旋转,以便飞船不会旋转得太远。我们使用
Mathf.Clamp函数来限制旋转分量在最大值的负数和最大相关值之间。rotation.y = Mathf.Clamp(rotation.y, -horizMax, horizMax); rotation.x = Mathf.Clamp(rotation.x, -vertMax, vertMax); -
最后,我们将旋转应用到飞船的变换上,并关闭脚本的函数。
transform.eulerAngles = rotation; } -
为了使用我们新的脚本,我们需要为玩家的飞船做一些设置。首先创建一个空的GameObject,并将其位置设置为零。将其重命名为
PlayerShipPivot。这将使我们能够独立控制玩家的飞船的运动和外观。 -
从项目窗口拖动你的
PlayerShip模型,并将其放在我们刚刚创建的支点上。确保你的船体在点上居中,并旋转它使其沿 z 轴朝前。 -
你现在可以将脚本添加到支点。
-
这时,拥有 Unity Remote 尤为重要。将你的设备连接并运行 Unity Remote 后,你可以举起它并控制船体。随意调整旋转速度和限制,直到找到感觉自然的控制设置。
-
我们需要能够看到船体之外,这样我们才能在飞向目标时射击。调整摄像机的位置,使船体在水平方向上居中,并且略低于中心。
-
场景还需要稍微照亮。在太空中,光线通常非常普遍,所以我们只需调整环境光即可。点击 Unity 菜单栏中的编辑,然后点击渲染设置。通过将环境光设置为白色,我们的场景将足够明亮,可以看清一切。
-
一切设置就绪后,务必保存场景。将其命名为
SpaceFighter。![行动时间 – 驾驶太空船]()
刚才发生了什么?
我们利用加速度计来提供太空船的转向控制。通过测量玩家如何倾斜他们的设备,我们能够相应地旋转船体。我们没有让船体实际移动,只是在原地旋转。我们很快就会明白为什么。
在太空中移动物体
你可能的第一反应是直接改变船体的位置。然而,当物体离世界原点非常远时,事情会变得奇怪。在编程中,实际上变量可以持有的数字大小是有限制的。这个限制导致当顶点位置变得过大时,渲染系统开始失败,导致模型扭曲到无法识别。诚然,我们谈论的是数以万计甚至更多的位置值。然而,假设玩家一直直线飞行很长时间,最终他们可能会达到这种扭曲的距离。
作为一种可能的解决方案,我们可以强制玩家转向,或停止他们的前进,或将他们的位置包裹起来,使他们从有限空间的另一侧继续飞行。然而,在无限空间中飞行要有趣得多。我们可以通过将玩家的飞船保持在原地,并移动它周围的一切来实现这一点。当进入一个新的空间时,可以生成新的敌人和物体来填充空间。远离的旧物体和敌人可以通过在玩家看不到的地方移除它们来销毁。这将给人一种无限空间的感觉。
行动时间 - 飞行小行星
在我们的无限空间中,我们将首先避免的是小行星:
-
为了让小行星工作,我们首先需要让玩家飞船的旋转和速度对小行星可用。为此,我们需要创建一个新的脚本,并将其命名为
PlayerShip。 -
同样,这个脚本从一组变量开始。第一个是玩家飞船的速度。空间中的小行星和其他物体将使用它来围绕玩家移动。第二个变量将保存对之前创建的
TiltSteering脚本的引用。这将使我们能够访问飞船的旋转速度。最后一个变量是一个静态变量,它将保存对场景中存在的此脚本实例的引用。这将允许其他脚本访问此脚本存储的信息。我们使用use变量来指示其他脚本,这是他们应该访问的实例。public float speed = 10f; public TiltSteering tilt; private static PlayerShip use; -
接下来,我们使用
Awake函数。这个函数在游戏开始时自动调用,非常适合初始化。我们用它来简单地设置我们之前创建的use变量为脚本的当前实例。public void Awake() { use = this; } -
下一个函数是为了提供给其他脚本玩家当前旋转信息。由于它是静态的,任何脚本都可以在任何时候调用它。该函数首先检查是否有对脚本当前实例的引用。如果找不到,则返回一个中性旋转,
Quaternion.identity。否则,返回脚本实例附加的变换的旋转。public static Quaternion GetRotation() { if(use == null) return Quaternion.identity; return use.transform.rotation; } -
这里的
Rotate函数是用来模拟玩家移动的。这个函数接受传递给它的变换,并移动和旋转它,使其看起来像是玩家已经穿越了空间。与之前的GetRotation函数一样,它首先检查玩家的引用,如果没有找到,则不执行任何操作。public static void Rotate(Transform other) { if(use == null) return; -
要围绕玩家的位置旋转小行星和其他任何东西,需要将当前的位置与玩家当前旋转的镜像相乘。为此,我们需要调整旋转以便正确地镜像。玩家的旋转的 Euler 角度被存储在变量中以供操作。然后我们将大于 180 的值进行移位,就像我们之前做的那样。然后旋转通过玩家飞船的旋转速度进行缩放。最后,它乘以帧速度以保持同步。
Vector3 euler = use.transform.eulerAngles; if(euler.x > 180f) euler.x -= 360f; if(euler.y > 180f) euler.y -= 360f; euler.Scale(new Vector3(use.tilt.vertRotateSpeed, use.tilt.horizRotateSpeed, 0)); euler *= Time.deltaTime; -
我们将负 Euler 旋转(即镜像旋转)转换回四元数,并将其存储在变量中以供使用。
Quaternion mirror = Quaternion.Euler(-euler); -
然后将镜像旋转与传入对象的位置相乘,更新位置以使它围绕玩家旋转,就像他们正在转弯一样。然后玩家的旋转与一个面向前方的向量、玩家的速度以及最终帧速度相乘。所有这些从对象当前的位置中减去,以模仿玩家的前进运动。最后,传入对象的旋转与镜像旋转相乘以改变其方向。总的来说,这模拟了玩家的移动。
other.position = mirror * other.position; other.position -= playerRotation * Vector3.forward * use.speed * Time.deltaTime; other.rotation *= mirror; -
将脚本添加到之前创建的
PlayerShipPivot对象上。确保连接TiltSteering引用。 -
接下来,我们需要通过创建另一个脚本来使用这个脚本。将其命名为
Asteroid。这个脚本将控制小行星在太空中飞行,并迫使玩家避开它。 -
该脚本的变量前两个用于确定小行星在太空中飞行的随机速度。第三个变量将保存这个随机速度。最后一个变量将保存小行星在太空中飞行的随机方向。
public float minSpeed = 5f; public float maxSpeed = 10f; private float speed = 1f; private Vector3 direction = Vector3.forward; -
接下来,我们再次使用
Awake函数进行初始化。任何位于半径为 1 的球面上的点本质上是一个指向随机方向的向量。因此,我们使用Random.onUnitSphere来为小行星找到随机方向。随后使用Random.Range和前两个变量来确定小行星飞行的随机速度。public void Awake() { direction = Random.onUnitSphere; speed = Random.Range(minSpeed, maxSpeed); } -
该脚本的最后一个函数是
LateUpdate。我们需要在小行星在玩家飞船更新其旋转之后移动;这就是为什么我们使用这个函数。函数的第一行使用我们为PlayerShip脚本创建的GetRotation函数,并将其存储在变量中以供使用。public void LateUpdate() { Quaternion playerRotation = PlayerShip.GetRotation(); -
接下来,我们调用
PlayerShip.Rotate函数,并传入小行星的变换,这样小行星就可以被移动以模拟玩家的移动。PlayerShip.Rotate(transform); -
下一行代码通过玩家的旋转来旋转小行星的运动方向,再次进行改变以模拟玩家的移动。位置再次通过调整方向后的小行星自身运动来更新。
direction = playerRotation * direction; transform.position += direction * speed * Time.deltaTime; -
函数和脚本通过检查小行星是否离玩家太远来完成。我们通过检查小行星位置的
sqrMagnitude来实现这一点。向量的长度是其大小。对于位置向量,这是从中心点的距离。sqrMagnitude是向量大小的平方。这比计算速度更快,并且比较起来也很容易。我们只需要将其与所需值的平方进行比较。在这种情况下,大约 300 的最大距离,其平方为 100,000,将很好地满足我们的需求。如果你还记得数学课上的内容,1e5 与一个后面有五个零的 1 相同,即 1 百万。最后,如果小行星离得太远,它将被销毁。if(transform.position.sqrMagnitude > 1e5) Destroy(gameObject); } -
为了测试这个脚本,我们需要一个小行星预制件。要创建它,首先将
Asteroid模型添加到场景中,并删除三个网格中的两个。 -
将脚本添加到模型中,并将其拖动到
Prefabs文件夹中,将其转换为预制件。![行动时间 – 飞行小行星]()
发生了什么?
我们创建了两个脚本和一个预制件。第一个脚本由玩家的飞船使用,用于将有关其旋转和速度的信息传递给其他脚本。第二个脚本控制游戏世界中小行星的运动。由于模型在极端距离下表现出的奇怪行为,玩家实际上从未移动。游戏世界及其中的所有对象都围绕着玩家移动。最后,我们创建了一个小行星预制件。尝试将几个预制件添加到场景中并尝试使用它们。尽管你的飞船实际上从未移动,但你仍然可以飞进、飞出并绕着它们飞行。
添加空间碰撞
在飞行过程中,你可能注意到你可以直接穿过小行星。为了让玩家能够击中它们,我们需要给玩家的飞船和小行星添加一些碰撞。这与坦克大战游戏中所做的是类似的。我们将在下一章中更详细地介绍碰撞的工作原理,但我们需要使用Rigidbody组件。它提供了对物理计算的访问权限,并允许我们将碰撞器分组以创建更复杂的碰撞形状。
行动时间 – 添加碰撞
我们需要给我们的太空对象添加一些碰撞能力,以便它们可以相互碰撞并被正确射击:
-
让我们从给小行星添加碰撞开始。首先选择小行星的网格,并添加一个MeshCollider组件。这将使我们能够向小行星射击。
-
接下来,选择包含我们的
Asteroid脚本组件的相同对象。向对象添加一个SphereCollider组件,并将半径调整为略大于小行星。这个碰撞器将检测小行星是否与玩家相撞。![行动时间 – 添加碰撞]()
-
SphereCollider需要勾选Is Trigger复选框。我们不是检查真正的碰撞,而是一个近似的碰撞。勾选此复选框后,对象将不再被碰撞体阻止,而是在对象进入碰撞体体积时在脚本中触发一个事件。当玩家进入碰撞体时,它将足够近,我们可以假设并作为它已经发生碰撞来行动。
-
当你对更改满意时,一定要将它们应用到预制件上。否则,其他小行星将不会更新并且不可碰撞。
-
为了检测玩家何时进入新的触发区域,我们需要在
Asteroid脚本中添加一个简短的功能。我们在脚本的末尾添加一个OnTriggerEnter函数。当其中一个碰撞体进入另一个碰撞体时,物理系统会自动调用此函数。传递给它的碰撞体是与之发生碰撞的那个。然而,此函数仅在至少有一个对象附加了Rigidbody组件时才有效。我们将在第七章中详细讲解,抛掷你的重量——物理和 2D 相机,但 Rigidbody 组件实际上是连接对象到 Unity 物理引擎的,使我们的脚本能够访问OnTrigger和OnCollision函数组。我们将将其添加到玩家的飞船上。当函数被调用时,它只是简单地销毁小行星。public void OnTriggerEnter(Collider other) { Destroy(gameObject); } -
接下来,我们需要为玩家的飞船添加碰撞。首先,将Rigidbody组件添加到我们之前创建的PlayerShipPivotGameObject 中。
-
一定要检查新组件的Is Kinematic复选框。这告诉物理系统我们希望通过脚本控制对象的运动。如果没有勾选,飞船会在游戏开始时开始下落。
-
Rigidbody组件的一个特性是它将Hierarchy中子对象的全部碰撞体视为单个碰撞形状的一部分。这样,我们能够使用几个简单且快速的碰撞体构建一个复杂的碰撞形状。通过创建一个空的GameObject并添加简单的碰撞体,我们将它们的大小和位置调整到覆盖玩家的飞船。务必确保将碰撞体对象设置为飞船的枢轴点的子对象。
![行动时间 - 添加碰撞]()
刚才发生了什么?
我们为小行星和玩家的飞船添加了碰撞。这给了玩家撞击并摧毁小行星的能力。我们利用触发碰撞体来近似小行星的碰撞。此外,我们还使用了 Rigidbody 组件,允许我们的飞船与其他场景中的物体相撞。此外,这还使我们能够利用 Unity 提供的简单碰撞体构建一个复杂的碰撞形状。虽然技术上可以使用 MeshCollider 来精确匹配飞船的形状,但这并不推荐。Rigidbody 和 MeshCollider 组件不太兼容。此外,几个简单的碰撞体比单个 MeshCollider 对计算机的计算要快得多。
尝试英雄 – 添加分数
能够让玩家飞船与小行星相撞是件好事,但从游戏的角度来看,这并没有什么意义。碰撞没有惩罚。这个挑战是让你实现一个类似于我们在上一章中使用的坦克大战游戏的计分系统。当玩家与小行星相撞时,从分数中减去一些分数。在 OnTriggerEnter 函数中使用其他碰撞体的 SendMessage 函数。不要仅仅在触发器进入时减分,因为(你稍后会发现)小行星不会只与玩家相撞。
创建敌船
在空间中飞来飞去,只有几颗小行星是很不错的,但这并不能构成一场战斗。这就是为什么我们要添加一个敌船,它会追逐并射击玩家。我们没有用于路径查找的网格,所以不能使用我们在上一章中学到的技术。然而,在太空中没有建筑物可以导航,所以追逐玩家将会简单得多。
行动时间 – 添加敌船
为了制作我们的敌船,我们需要一个脚本将敌船转换成预制体:
-
我们需要做的第一件事是创建一个新的脚本,并将其命名为
EnemyShip。这个脚本将控制敌船的运动和射击。 -
脚本开始时包含几个变量。前两个定义了飞船前进的速度和旋转的速度。
public float moveSpeed = 8f; public float turnSpeed = 0.5f; -
接下来的三个变量用于控制飞船的射击。首先是子弹发射的频率。其次是飞船必须处于的射程内才能射击玩家。第三个是一个用于存储自上次发射子弹以来经过时间的变量。这将与射击频率一起决定飞船何时可以再次射击。
public float fireRate = 1.5f; public float fireRange = 60f; private float fireTime = 0; -
最后两个变量将保存要发射的子弹预制体和子弹发射点的引用。这与我们在上一章中制作敌坦克射击的方式类似。区别在于太空游戏往往有很多激光束在飞行,迫使玩家躲避。
public GameObject bullet; public Transform muzzlePoint; -
在
Update函数中,我们将执行实际射击的工作。它首先跟踪自上次发射子弹以来经过的时间。然后检查是否已经足够长的时间以来可以再次射击,如果不是,则退出函数。函数中的第三行代码检查范围。这完全是以我们检查小行星是否远离玩家的方式进行的。接下来,我们检查确保飞船指向玩家。这是以我们使敌舰坦克射击玩家的相同方式进行。通过比较前进方向与指向玩家的方向来检查角度。如果它足够接近地指向玩家,飞船就可以开始射击。射击是通过调用Instantiate函数来完成的。通过传递muzzlePoint变量的位置和旋转,新的子弹会自动旋转。将有一个单独的脚本处理子弹的运动。最后,将飞船上次射击的时间重置为零。public void Update() { fireTime += Time.deltaTime; if(fireTime < fireRate) return; if(transform.position.sqrMagnitude > fireRange * fireRange) return; if(Vector3.Angle(transform.forward, -transform.position) > 10) return; Instantiate(bullet, muzzlePoint.position, muzzlePoint.rotation); fireTime = 0; } -
下一个函数是
LateUpdate函数。我们将像对小行星那样使用这个函数。代码的第一行只是调用PlayerShip脚本以旋转和移动飞船,以模拟玩家的移动。public void LateUpdate() { PlayerShip.Rotate(transform); -
下一行代码应用于飞船的运动。这个过程与我们如何在上一章中使敌舰炮塔旋转面对玩家相似。
step变量用于指定飞船转向玩家的速度。我们使用Slerp函数改变飞船当前旋转以指向目标旋转。在这种情况下,旋转是朝向玩家的旋转。最后,飞船向前移动。总的来说,这会产生类似于汽车转向的运动。float step = turnSpeed * Time.deltaTime; Quaternion toPlayer = Quaternion.LookRotation(-transform.position); transform.rotation = Quaternion.Slerp(transform.rotation, toPlayer, step); transform.position += transform.forward * moveSpeed * Time.deltaTime; -
函数和脚本的最后一个代码片段是检查飞船是否太远的检查。它与用于小行星的检查完全相同。
if(transform.position.sqrMagnitude > 1e5) Destroy(gameObject); } -
在我们可以组装我们的敌舰之前,我们需要编写一个更短的脚本。创建一个新的脚本并将其命名为
Bullet。正如你可能猜到的,这是控制敌舰子弹运动的脚本。 -
此脚本从单个变量开始,即子弹在空间中移动的速度。
public float speed = 20f; -
接下来,我们再次使用
LateUpdate函数。这个函数首先使用PlayerShip.Rotate重新定位子弹,就像在游戏世界中所有其他对象一样。然后它以速度前进。最后,它检查是否超出范围。public void LateUpdate() { PlayerShip.Rotate(transform); transform.position += transform.forward * speed * Time.deltaTime; if(transform.position.sqrMagnitude > 1e5) Destroy(gameObject); } -
脚本中的最后一个函数
OnTriggerEnter与用于小行星的函数一样。如果子弹与飞船接触,它将自我销毁。public void OnTriggerEnter(Collider other) { Destroy(gameObject); } -
现在我们有了脚本,下一步是创建敌舰和子弹预制体。要创建子弹,首先导航到GameObject | Create Other | Sphere。
-
将新球体重命名为
Bullet并将其缩放至原始大小的一半。 -
接下来,将
Bullet脚本添加到对象中,并确保在它的SphereCollider组件中勾选Is Trigger复选框。如果没有勾选该框,子弹将无法正常工作。 -
最后,将对象拖动到
Prefabs文件夹以将其转换为预制体。 -
接下来,我们需要创建敌对飞船。首先创建一个空的GameObject并将其重命名为
EnemyShipPivot。 -
将
EnemyShip模型的副本添加到场景中,并使其成为我们刚刚创建的枢轴点的子项。确保将其旋转以沿着 z 轴面向前方,并将其定位在枢轴点的中心。 -
接下来,使用简单的碰撞体和空的GameObject为飞船创建一个碰撞形状。这与我们为玩家飞船所做的是一样的。确保将所有碰撞体作为敌对飞船的枢轴点的子项。
-
我们需要创建一个炮口点并将其放置在敌对飞船的前面。就像我们为坦克所做的那样做。确保也将其作为船的枢轴点的子项。
-
现在,将
EnemyShip脚本和一个Rigidbody组件添加到枢轴点。 -
几乎完成了。从Hierarchy窗口拖动炮口点并将其放置在Inspector中脚本组件的适当槽位。同时,从Project窗口拖动Bullet预制体到等待槽位。
-
最后,将飞船拖动到Hierarchy窗口并将其放入
Prefabs文件夹,使其成为一个预制体。 -
在场景中添加几艘额外的飞船并尝试一下。
![行动时间 – 添加敌对飞船]()
发生了什么?
我们创建了一艘敌对飞船。飞船将在玩家之后飞行,并在进入射程时开始射击。就像小行星一样,它们围绕着玩家移动以模拟玩家的移动。然而,与随机方向移动不同,敌对飞船会转向玩家。通过减缓飞船转向的速度,它们以弧形移动而不是快速原地旋转。此外,由于敌对飞船附带的Rigidbody组件,它们可以与场景中的小行星相撞。
英雄试炼——天空盒和雾
现在我们有几个物体在太空中飞行,让场景看起来更好是个不错的主意。对于我们的坦克大战游戏,我们添加了一个天空盒,使场景看起来就像真的在一个星球上。然而,我们正在太空中飞行。挑战在于你找到或制作一个看起来像外太空的天空盒。此外,添加一些远处的雾气会在物体远离玩家时使其变得模糊。如果雾气是黑色的,它将看起来像物体被深空的黑暗吞噬。
触摸控制
现在我们已经在我们的空间场景中有几个物体在飞行,包括一些会射击玩家的物体,我们应该给玩家提供除了躲避之外的能力。现代移动设备最明显的特征之一就是触摸屏。这些设备使用用户的指尖的电导性和许多微小的接触点来确定被触摸的位置。Unity 为我们提供了访问触摸输入的便捷方式。通过将输入与射线投射相结合,就像我们为坦克射击所做的那样,我们可以确定用户在 3D 空间中触摸了哪个物体。对我们来说,这意味着我们可以给玩家提供射击并摧毁空间中物体的能力。
行动时间 - 触摸射击
为了利用触摸输入,我们需要在我们的玩家的飞船上添加一个单独的脚本:
-
为了给玩家提供射击的能力,我们首先需要创建一个新的脚本,并将其命名为
TouchShoot。 -
这个脚本从单个变量开始。一个
LayerMask用于选择性地用射线投射击中物体。实际上有很多层应该被击中。这个将用来确定玩家可以或不可以射击。public LayerMask touchMask = -1; -
Update函数是这个脚本中唯一的函数。它从一个循环开始。Input类为我们提供了touchCount值,它只是一个计数器,用于计算当前有多少手指正在触摸设备屏幕。public void Update() { for(int i=0;i<Input.touchCount;i++) { -
随着我们通过循环,我们使用
Input.GetTouch函数来访问有关每个触摸的信息。这一行代码检查触摸的阶段。每个触摸有五个潜在的阶段:开始、移动、静止、结束和取消:if(Input.GetTouch(i).phase == TouchPhase.Began) {-
开始:这个触摸阶段是当用户第一次触摸屏幕时。
-
移动:这个触摸阶段是当用户在屏幕上移动他的/她的手指时。
-
静止:这个触摸阶段是前一个阶段的相反;这是手指没有在屏幕上移动的时候。
-
结束:这个触摸阶段是指手指从屏幕上抬起。这是触摸完成的正常方式。
-
取消:这个触摸阶段是在跟踪触摸时发生错误时。这个阶段通常发生在手指触摸屏幕但长时间不移动的情况下。触摸系统并不完美,所以它假设它错过了手指从屏幕上抬起,并取消了这个触摸。
-
-
接下来,我们创建了一对变量。第一个是一个
Ray,它只是一个用于存储空间中的一个点和方向向量的容器。ScreenPointToRay函数是相机专门提供的,用于将触摸位置从屏幕的 2D 空间转换为游戏世界的 3D 空间。就像我们的坦克一样,第二个是一个用于存储我们的射线投射所击中的物体的容器。Ray ray = Camera.main.ScreenPointToRay(Input.GetTouch(i).position); RaycastHit hit; -
函数的最后一步是调用
Raycast函数。我们将射线和跟踪变量传递给函数。接下来,我们必须给它一个距离,最后是LayerMask。如果击中对象,它将被摧毁。此外,还需要几个花括号来关闭 if 语句、循环和函数。if(Physics.Raycast(ray, out hit, Mathf.Infinity, touchMask)) { Destroy(hit.transform.gameObject); } } } } -
要尝试脚本,只需将其添加到PlayerShipPivot游戏对象中。小心。在此阶段,如果你在测试时触摸玩家的船,它将被摧毁。
-
为了解决这个问题,我们需要创建一个新的层。首先,转到 Unity 的菜单栏,点击编辑 | 项目设置 | 标签。这是到达我们为坦克大战游戏创建层的同一位置的另一条路径。
-
点击用户层 8的右侧,并在字段中输入
Player。这将创建新的层。 -
在层次结构窗口中选择PlayerShipPivot对象。
-
在检查器窗口的右上角,从层下拉列表中选择我们刚刚创建的层。
-
当 Unity 询问你是否要更改子对象时,确认你要这样做。
-
对于
TouchShoot脚本,从触摸掩码列表中取消选择新的层。这将允许玩家射击除自己之外的一切。 -
我们还需要做最后一件事。转到 Unity 的菜单栏,点击编辑 | 项目设置 | 物理。这将在检查器窗口中打开一组新的控件,用于调整物理引擎的运行方式。
-
目前,我们只关心Raycasts Hit Triggers复选框。取消选中它。如果不这样做,当玩家射击时,他们会击中围绕小行星的触发体积,而不是小行星本身。这对小行星来说不是一个大问题。但是,如果我们要创建某种东西,比如一个爆炸性地雷,触发体积会大得多。这将使射击地雷看起来非常奇怪。
刚才发生了什么?
我们给了玩家在触摸屏幕时射击的能力。通过遍历触摸列表,玩家可以使用多个手指射击目标。相机提供的特殊ScreenPointToRay函数使我们能够将 2D 屏幕触摸转换为 3D 游戏世界交互。通过使用LayerMask,我们还防止玩家射击并摧毁自己。
尝试一下英雄 - 健康的船只
这里的挑战是给敌舰一些健康值。在我们上一章创建敌坦克时,我们让它们在被摧毁之前承受玩家的几发射击。在这里对敌舰做同样的事情。
空间生成
到目前为止,我们已经创建了一个允许玩家无限穿越空间的空间游戏。玩家实际上从未移动过;相反,场景中的对象围绕它移动,以模拟移动。我们有会随机在空间中飞行的陨石。我们还创建了追逐玩家并射击它们的敌舰。最后,我们有了射击并摧毁场景中对象的能力。然而,在这个阶段,我们很快就会用完可以射击的东西。要么它们离得太远,要么我们摧毁了它们。为了解决这个问题,我们现在将创建一个系统,它将在玩家周围随机生成所有这些对象。
行动时间 – 创建一个空间生成器
我们最后的脚本将填充我们的空间,并将附加到玩家的飞船上,因为它是游戏世界的中心:
-
为了用对象填充我们的空间,我们需要创建另一个脚本。将其命名为
SpaceSpawn。 -
我们开始时有两个变量。这两个变量定义了新对象将被生成的空间。它们将在最小范围之外但最大范围之内创建。
public float minRange = 200f; public float maxRange = 300f; -
接下来,我们有两个变量用于控制对象生成的频率。这些将与我们用于使敌舰向玩家射击的变量一样工作。
public float frequency = 0.3f; private float spawnTime = 0; -
此脚本的最后一个是数组。它只是一个可以生成的所有对象的列表。当我们返回 Unity 来设置它时,我们将稍后填充它。
public GameObject[] spawnList = new GameObject[0]; -
Update再次成为我们脚本的首选函数。我们首先确保列表中有要生成的东西。如果没有要生成的对象,就没有继续的必要。public void Update() { if(spawnList.Length <= 0) return; -
接下来,我们追踪自上次生成以来经过的时间,并检查是否已经足够长,可以再次生成。同样,这就像射击敌舰一样工作。
spawnTime += Time.deltaTime; if(spawnTime < frequency) return; -
现在,我们需要确定在空间中生成下一个对象的位置。为此,我们首先使用
Random.onUnitSphere来找到一个随机方向。然后,我们找到一个位于我们的最小和最大范围之间的随机距离。最后,它们被相乘,给我们一个位置。Vector3 direction = Random.onUnitSphere; float distance = Random.Range(minRange, maxRange); Vector3 position = direction * distance; -
为了选择一个随机对象,我们使用
Random.Range并将其传递给对象的列表长度。这将给我们列表中的一个槽位的索引。下一行代码确保槽位中有对象。如果没有,我们无法生成它。int index = Random.Range(0, spawnList.Length); if(spawnList[index] == null) return; -
接下来,我们实际上使用我们的好朋友
Instantiate函数来生成对象。我们将随机选择的对象、找到的位置以及最终的随机旋转传递给Instantiate函数。结果,对象在场景中被创建并放置到位。Instantiate(spawnList[index], position, Random.rotation); -
最后,我们从时间跟踪器中减去
frequency变量,以完成函数和脚本的编写。这将导致在每次频率滴答时发生生成,而不会丢失任何时间。spawnTime -= frequency; } -
我们现在回到 Unity 中设置脚本。将其添加到
PlayerShipPivot对象中。它在这里和其他任何地方一样都能正常工作。 -
要填充列表,只需从项目窗口拖动你的预制体,并将其拖放到检查器窗口中的Spawn List上。字段左侧的小三角形将允许你展开列表并查看其中当前的内容。如果你想调整各种对象出现的概率,只需更改列表中它们的出现次数。没有任何东西阻止你将九个关于小行星预制体的引用放入列表中,而只有一个关于敌方飞船预制体的引用,这样飞船就有 10%的概率被生成。无论你选择什么,都要使用列表来设置对象概率,并至少包含我们创建的每个障碍物。
-
最后,测试一下。按播放并飞来飞去,看看对象是如何生成并飞来飞去的。
![行动时间 - 创建空间生成]()
刚才发生了什么?
我们创建了一个系统,用于在太空中随机生成对象。首先,我们跟踪时间的方式与制作敌方飞船向玩家开火的方式相同。接下来,我们找到了一个随机方向和范围,以确定位置。之后,系统从列表中随机选择一个对象,并最终生成它。为了调整单个对象出现的概率,我们只需调整它在要生成的对象列表中出现的频率,相对于列表中的其他对象。
尝试英雄 - 更多生成和射击
本章的起始资产中还包括了两个更多的小行星网格和一个矿。使用这些,你可以创建更多要在游戏中生成的对象。矿也可以创建一个更大的触发体积。这将允许它在船只靠近时爆炸,而不仅仅是撞击它。如果你还有其他想法,至少可以让小行星在生成时随机选择一个比例。这将使小行星看起来更加多样化,尽管实际上只有几个。
此外,尝试创建另一艘或几艘船。也许其中一艘是运输船,它会逃离玩家。一个难度较高的例子是,当被摧毁时,船会分裂成两艘更小的船。或者,只需重新创建你最喜欢的科幻媒体中的宇宙飞船。在这个游戏中,宇宙是你的极限。
对于坦克战游戏,我们为玩家快速穿越城市创建了一个涡轮增压按钮。它对于逃离敌人也很有用。为太空战斗机实现它。这将有助于捕捉敌人和躲避子弹。
突击测验 - 理解 Android 组件
现代移动设备有许多部分,它们执行着各种各样的功能。了解它们是什么以及它们是如何协同工作的,是能够使用它们的第一步。以下陈述是真的还是假的?
Q1. 磁力计和加速度计协同工作,在地图上给出位置。
-
真的
-
假的
Q2. 陀螺仪检测设备的旋转和移动。
-
真的
-
假的
Q3. 触摸屏上的 2D 位置可以转换成游戏中的 3D 位置。
-
真的
-
假的
Q4. 哪一行代码能够将用户的触摸转换成游戏中的 3D 位置?
-
Camera.main.ScreenPointToRay(Input.GetTouch(0).position) -
Input.GetMouseButton(0) -
Camera.main.WorldToScreenPoint(Input.GetTouch(0).position)
Q5. 哪一行代码能够给出设备的加速度?
-
Input.gyro -
Input.compass -
Input.acceleration
摘要
在本章中,我们学习了现代移动设备的特性。我们创建了一个太空战斗机游戏来尝试这些特性。我们获得了访问设备的加速度计,以检测设备何时旋转。这使得我们的宇宙飞船能够被操控。我们还利用触摸屏,让玩家能够在游戏中射击敌人。因为我们想要无限的空间来飞行,所以我们不得不让玩家不动,而是让其他所有东西围绕玩家移动,以模拟玩家的移动。这也需要一个系统,能够持续在玩家周围生成新的敌人和障碍物,让我们能够继续飞行并找到新的东西来玩耍。
在下一章中,我们将暂时从太空战斗机游戏中休息一下。几乎可以肯定,市场上最受欢迎的移动游戏之一,愤怒的小鸟,是一种独特且不常见的游戏类型。为了学习 Unity 中的物理和 2D 风格游戏的可能性,我们将制作一个愤怒的小鸟克隆版。我们还将研究视差滚动来创建令人愉悦的背景。很快,我们将创建所有你一直希望能够玩到的愤怒的小鸟关卡。
第七章. 摆脱重量 - 物理和 2D 相机
在前一章中,我们学习了移动设备的特殊功能以及如何创建触摸和倾斜控制。我们还创建了一个太空战斗机游戏来使用这些新控制。飞船的操控是通过倾斜设备,而射击是通过触摸屏幕完成的。使用一些特殊的移动技巧,我们给了玩家无限的飞行空间和敌人来战斗。
在本章中,我们暂时从太空战斗机游戏中休息一下,来探索 Unity 的物理引擎。我们还将查看创建 2D 游戏体验的选项。为了完成所有这些,我们将重新创建市场上最受欢迎的移动游戏之一,愤怒的小鸟。我们将使用物理来投掷小鸟并摧毁结构。我们还将查看创建级别选择屏幕的过程。
在本章中,我们将涵盖以下主题:
-
Unity 物理
-
垂直滚动
-
等距相机
-
级别选择
我们将为本章创建一个新项目,所以启动 Unity,让我们开始吧!
3D 世界中的 2D 游戏
在开发游戏时,可能最未被充分认识到的一点是,在像 Unity 这样的 3D 游戏引擎中可以创建 2D 风格的游戏。与其他事物一样,它有其自身的优点和缺点,但为了生成令人愉悦的游戏体验,这种选择可能是值得的。最主要的优点是,可以为游戏使用 3D 资源。这允许动态光照和阴影轻松地被包含在内。然而,如果使用 2D 引擎,任何阴影都需要直接绘制到资源中,而且很难使其动态化。在缺点方面,2D 资源在 3D 世界中的使用。完全有可能使用它们,但为了达到所需的细节并避免出现像素化,需要大文件大小。然而,大多数 2D 引擎都使用矢量艺术,这将在图像缩放和缩小时会保持线条平滑。此外,可以使用正常动画为 3D 资源,但对于任何 2D 资源通常需要逐帧动画。总的来说,对于许多开发者来说,优点已经超过了缺点,创造出了大量外观出色的 2D 游戏,你可能从未意识到这些游戏实际上是在 3D 游戏引擎中制作的。现在,我们将通过重新创建广受欢迎的愤怒的小鸟游戏来设计另一款游戏。
行动时间 - 准备世界
让我们开始为愤怒的小鸟游戏准备世界:
-
要开始这一切,我们需要在 Unity 中创建一个新项目。将其命名为
Ch7_AngryBirds将是一个不错的选择。确保将目标平台更改为Android,并将包标识符设置为适当的值。 -
接下来,导入本章的起始资源并创建一些文件夹以保持一切井然有序。
-
在 Unity 中,将游戏从 3D 转换为 2D 非常简单。只需选择默认存在于每个新场景中的主摄像头对象,找到投影值,然后从下拉列表中选择正交。
小贴士
每个摄像头都有两种渲染游戏的方式。透视渲染利用摄像头与物体之间的距离,模仿现实世界;距离摄像头较远的物体比距离较近的物体绘制得小。正交渲染不考虑这一点;物体不会根据其与摄像头的距离进行缩放。
-
初始时,摄像头所看到的场景范围过大。要改变这一点,将大小值设置为
5。这将减少摄像头渲染的空间量。这个值将帮助我们专注于游戏中的动作。 -
要使摄像头能够正确使用,将其位置设置为X轴的
10,Y轴的3,Z轴的0。此外,将其旋转的Y轴设置为-90。所有东西都将沿着 z 轴定位,因此我们的摄像头需要设置为观察轴,并且足够远,以便它不在动作中。 -
接下来,我们需要一个地面。因此,前往 Unity 的菜单栏,点击GameObject,然后点击Create Other,最后点击Cube。这足以作为一个简单的地面。
-
为了让它看起来更像地面,创建一个绿色材质并将其应用到Cube上。
-
地面立方体需要足够大,以覆盖我们的整个游戏区域。为此,将其缩放设置为X轴的
5,Y轴的10,Z轴的100。此外,将其位置设置为X和Y轴的0,Z轴的30。由于 x 轴上没有物体移动,地面只需要足够大,以便其他将在场景中的物体可以着陆。然而,它确实需要足够宽和高,以防止摄像头看到边缘。 -
目前,由于缺乏光源,地面看起来相当暗。从 Unity 的菜单栏点击GameObject,然后点击Create Other,最后点击Directional Light来为场景增加一些亮度。它应该被放置在照亮面对摄像头的立方体侧面的位置。
-
接下来,我们需要防止场景中所有将要飞行的对象跑得太远并造成问题。为此,我们需要创建一些触发体积。最简单的方法是创建三个更多的立方体。将一个放置在地面对象的每个末端,最后一个立方体大约在 50 个单位的高度。然后,调整它们以形成一个与地面相同的盒子。每个立方体应该没有比单个单位更厚,并且它们需要深入五单位,与地面相同。接下来,移除它们的Mesh Renderer和Mesh Filter组件。这将移除可见的盒子,同时留下碰撞体积。要将它们更改为触发体积,请在每个Box Collider组件上勾选Is Trigger复选框。
-
要使体积实际上阻止对象跑得太远,我们需要创建一个新的脚本。创建它并将其命名为
GoneTooFar。 -
此脚本有一个单一、简短的功能,
OnTriggerEnter。我们使用它来销毁可能进入体积的任何对象。此函数由 Unity 的物理系统用于检测对象何时进入触发体积。我们将在稍后详细介绍,但在此阶段,要知道两个对象中的一个是体积或进入它的对象,需要有一个Rigidbody组件。在我们的情况下,当它们进入触发体积时,我们可能想要移除的所有内容都将有一个Rigidbody组件。public void OnTriggerEnter(Collider other) { Destroy(other.gameObject); } -
最后,返回 Unity 并将脚本添加到三个触发体积对象上。
![行动时间 - 准备世界]()
刚才发生了什么?
我们为我们的 2D 游戏完成了初始设置。通过将我们的摄像机视图更改为Orthographic,视图从 3D 游戏切换到 2D 游戏。我们还为场景创建了一个地面和一些触发体积。这些一起将防止我们的鸟和其他任何东西跑得太远。
物理
在 Unity 中,物理模拟主要关注使用Rigidbody组件。当Rigidbody组件附加到任何对象上时,它将被物理引擎接管。该对象将在重力作用下下落,并撞击任何具有碰撞器的对象。在我们的脚本中,要使用OnCollision函数和OnTrigger函数,至少需要将Rigidbody组件附加到两个交互对象中的至少一个上。然而,Rigidbody组件可能会干扰我们可能使对象采取的任何特定运动。不过,每个Rigidbody都可以标记为运动学,这意味着物理引擎不会移动它。我们用于坦克的CharacterController组件是一个特殊、修改过的Rigidbody。在本章中,我们将大量使用Rigidbody组件,将所有的鸟、方块和猪与物理引擎联系起来。
构成要素
对于我们的第一个物理对象,我们将创建猪城堡所用的块。我们将创建三种类型的块:木头、玻璃和橡胶。凭借这些简单的块,我们将能够轻松地创建大量不同级别和结构,供小鸟摧毁。
是时候行动了——创建木板
我们将要创建的每个块都将非常相似:
-
首先,我们将创建一块木板。为此,我们需要另一个立方体。将其重命名为
Plank_Wood。 -
将木板的Scale设置为X和Y轴为
2,Z轴为0.25。它在 y 和 z 轴上的缩放定义了玩家看到的尺寸。x 轴上的缩放确保它将被场景中的其他物理对象击中。 -
接下来,创建一个新的材质,使用
plank_wood纹理,并将其应用到立方体上。 -
要将这个新的木板转换为物理对象,请添加一个Rigidbody组件。确保选择木板,然后转到 Unity 的菜单栏,点击Component,然后点击Physics;最后,点击Rigidbody。
-
我们需要防止木板沿着 x 轴移动,并与我们的其他物理对象对齐,同时防止它旋转以向玩家展示其另一面。为此,我们利用Rigidbody组件上的Constraints组中的复选框。在Freeze Position旁边勾选X轴的复选框,并在Freeze Rotation旁边勾选Y和Z轴的复选框。这将防止对象以我们不希望的方式移动。
-
为了使木板在我们的游戏中正常工作,我们需要创建一个新的脚本,并将其命名为
Plank。 -
这个脚本从一系列变量开始。前两个变量用于跟踪长板的健康状况。我们需要将总健康量与当前健康量分开,这样我们就能检测到对象健康量减少到一半的情况。在那个时刻,我们将利用接下来的三个变量将对象的材料更改为显示损坏的材料。最后一个变量用于对象耗尽健康量并被摧毁时。我们将用它来增加玩家的分数。
public float totalHealth = 100f; private float health = 100f; public Material damageMaterial; public Renderer plankRenderer; private bool didSwap = false; public int scoreValue = 100; -
对于脚本的第一个函数,我们使用
Awake进行初始化。我们确保对象的当前健康量与其总健康量相同,并确保didSwap标志设置为false。public void Awake() { health = totalHealth; didSwap = false;} -
接下来,我们利用
OnCollisionEnter函数。这是一个特殊的函数,由 Rigidbody 组件触发,它提供了关于物体碰撞了什么以及如何碰撞的信息。我们使用这些信息来找到collision.relativeVelocity.magnitude。这是物体碰撞的速度,我们将其用作伤害来减少当前的健康值。接下来,函数检查健康值是否减少到一半,如果已经减少,则调用SwapToDamaged函数。通过使用didSwap标志,我们确保函数只会被调用一次。最后,对于该函数,它检查健康值是否下降到零以下。如果是,则销毁对象,并调用我们即将制作的LevelTracker脚本,以增加玩家的得分。public void OnCollisionEnter(Collision collision) { health -= collision.relativeVelocity.magnitude; if(!didSwap && health < totalHealth / 2f) { SwapToDamaged(); } if(health <= 0) { Destroy(gameObject); LevelTracker.AddScore(scoreValue); } } -
最后,对于脚本,我们有
SwapToDamaged函数。它首先将didSwap标志设置为true。接下来,它检查确保plankRenderer和damageMaterial变量有对其他对象的引用。最终,它使用plankRenderer.sharedMaterial值来切换到损坏的外观材质。public void SwapToDamaged() { didSwap = true; if(plankRenderer == null) return; if(damageMaterial == null) return; plankRenderer.sharedMaterial = damageMaterial; } -
在我们能够将
Plank脚本添加到我们的对象之前,我们需要创建之前提到的LevelTracker脚本。现在就创建它。 -
这个脚本相当简短,从单个变量开始。该变量将跟踪关卡中玩家的得分,并且是
static的,这样就可以在对象被销毁以获得分数时轻松更改。private static int score = 0; -
接下来,我们使用
Awake函数确保玩家在开始关卡时从零开始。public void Awake() { score = 0; } -
最后,对于脚本,我们添加了
AddScore函数。这个函数简单地接受传递给它的分数数量,并增加玩家的得分。它也是static的,这样就可以在场景中的任何对象上调用它,而无需引用脚本。public static void AddScore(int amount) { score += amount; } -
在 Unity 中,我们需要使用
plank_wood_damaged纹理创建一个新的材质。这将是在脚本中切换到的材质。 -
将
Plank脚本添加到我们的Plank_Wood对象中。将 Damaged Material 引用连接到新材料,将 Plank Renderer 引用连接到对象的 Mesh Renderer 组件。 -
当我们创建不同类型的木板时,我们可以调整 Total Health 值以赋予它们不同的强度。对于木制木板,
25的值效果相当不错。 -
接下来,创建一个空的 GameObject 并将其重命名为
LevelTracker。 -
将
LevelTracker脚本添加到对象中,它将开始跟踪玩家的得分。 -
如果你想看到木板的实际效果,将其放置在地面上方并点击播放按钮。游戏开始后,Unity 的物理引擎将接管并让木板在重力作用下落下。如果它开始时的高度足够高,你将能够看到它随着健康值的降低而切换纹理。
-
为了制作我们需要的另外两个木板,选择
Plank_Wood对象并按 Ctrl + D 两次来复制它。将一个重命名为Plank_Glass,另一个重命名为Plank_Rubber。 -
接下来,创建三种新材料。其中一种应该是紫色,用于橡胶板;另一种应使用
plank_glass纹理用于玻璃板;最后一种材料应使用plank_glass_damaged纹理,用于玻璃板损坏时。将新材料应用到新板的适当位置。 -
至于新板的健康值,玻璃板的值为
15,橡胶板的值为100将非常合适。 -
最后,将你的三块板转换为预制体,并使用它们为我们构建一个要摧毁的结构。你可以自由地调整它们的大小,但请保持 x 轴不变。此外,所有块应该在 x 轴上的值为零,你的结构应该在大约
30的 z 轴上居中。![行动时间 – 创建板]()
发生了什么事?
我们创建了游戏中将要被摧毁的结构所需的基本块。我们使用了一个刚体组件将它们与物理引擎连接起来。此外,我们还创建了一个脚本,用于跟踪它们的健康值,并在健康值降至一半以下时更换材料。
英雄尝试 – 创建石块
木材和玻璃作为基本块效果很好。但是,如果我们打算制作更难的关卡,我们需要一些更坚固的材料。尝试制作一个石块。为它创建两个纹理和材料,以展示其原始和损坏状态。
物理材料
物理材料是特殊类型的材料,它们会具体告诉物理引擎两个物体应该如何交互。这不会影响物体的外观。它定义了碰撞体的摩擦和弹性。我们将使用它们给我们的橡胶板一些弹性,给玻璃板一些滑动性。
行动时间 – 滑动和反弹
物理材料实现起来足够快,将使我们能够用四个简短的步骤完成这一部分:
-
物理材料就像其他所有内容一样,在项目面板中创建。在项目面板内右键单击,然后点击创建 | 物理材料。创建两个物理材料,一个命名为
Glass,另一个命名为Rubber。 -
选择其中一个,并在检查器窗口中查看它。目前,我们只关心前三个值。其他值用于更复杂的情况。
-
动态摩擦:这个属性是在物体移动时使用的摩擦量。零表示没有摩擦,例如冰,而一表示很多摩擦,例如橡胶。
-
静态摩擦:这个属性与动态摩擦功能相同,区别在于它是在物体不移动时使用的。
-
弹性:这个属性是当物体撞击某物或被某物撞击时,其能量反射的程度。零表示没有能量反射,而一表示全部反射。
-
-
对于
Glass材质,将两个摩擦值设置为0.1,弹性设置为0。对于Rubber材质,将两个摩擦值设置为1,弹性设置为0.8。 -
接下来,选择你的
Plank_Glass预制体,并查看其Box Collider组件。要应用你的新物理材质,只需从Project拖放到Material槽中。对Plank_Rubber预制体也做同样操作,任何时间一个对象撞击它们,就会使用这些材质来控制它们的交互。
发生了什么?
我们创建了一对物理材质。它们控制两个碰撞体相互碰撞时的交互方式。使用它们,我们可以控制任何碰撞体所具有的摩擦力和弹性。
角色
拥有一堆通用块只是这个游戏的开始。接下来,我们将创建一些角色,为游戏增添一些活力。我们需要一些邪恶的猪来摧毁,和一些善良的鸟来投掷它们。
敌人
我们的第一个角色将是敌人猪。单独来看,它们实际上并没有做什么。所以,它们实际上只是我们之前制作的看起来像猪的木块。然而,为了让它们的摧毁成为游戏的目标,我们将扩展我们的LevelTracker脚本以监视它们,如果它们全部被摧毁,则触发游戏结束事件。我们还将扩展它以在屏幕上绘制分数并保存分数以供以后使用。为了展示在 3D 环境中使用 2D 资产,猪也被创建为平面纹理。
行动时间 - 创建猪
让我们开始创建愤怒的小鸟游戏中的猪:
-
猪的创建方式与木板的创建方式类似。首先创建一个空的GameObject,并将其命名为
Pig。 -
接下来,创建一个平面,将其设置为
Pig对象的子对象,并移除其Mesh Collider组件。我们这样做是因为平面需要面向摄像机时的旋转。作为一个空GameObject的子对象,允许我们在处理猪时忽略那个旋转。 -
将平面的本地位置在每个轴上设置为
0,将其旋转设置为X轴上的90,Y轴上的270,以及Z轴上的0。这将使平面面向摄像机。 -
现在,创建两个材质。将一个命名为
Pig_Fresh,另一个命名为Pig_Damage。从它们的着色器下拉列表中,选择透明,然后是剪影,最后是软边缘不发光。这允许我们利用纹理的 alpha 通道并提供一些透明度。 -
通过向它们添加
pig_damage和pig_fresh纹理来完成材质。 -
向
Pig对象添加Sphere Collider组件、Rigidbody组件和Plank脚本。我们使用Sphere Collider组件,而不是平面自带Mesh Collider组件,因为平面没有厚度,因此将与其他对象发生许多碰撞问题。 -
为了完成猪的创建,将你的材质应用到平面上,并在
Plank脚本中连接引用。最后,就像我们对其他木板所做的那样,在刚体组件上设置约束参数。 -
现在,将猪转换成预制体,并将其添加到你的结构中。记住,在 x 轴上保持它们为零,但你可以自由调整它们的大小、生命值和分数值,以增加它们的多样性。
-
接下来,我们需要扩展
LevelTracker脚本。打开它,我们可以添加一些更多的代码。 -
首先,我们在脚本的开头添加一些变量。第一个,正如其名称所暗示的,将保存场景中所有猪的列表。接下来是一个标志,用于指示游戏是否结束。最后,一个字符串,用于告诉玩家游戏结束的原因。
public Transform[] pigs = new Transform[0]; private static gameOver = false; private static string message = ""; -
接下来,我们需要在
Awake函数中添加一行代码。这仅仅确保当关卡开始时gameOver标志为false。gameOver = false; -
我们使用
OnGUI函数在游戏结束时绘制游戏结束屏幕,或者如果游戏仍在继续,绘制当前的分数。public void OnGUI() { if(gameOver) DrawGameOver(); else DrawScore();} -
DrawScore函数接受当前的分数,并使用GUI.Label在屏幕的右上角绘制它。private void DrawScore() { Rect scoreRect = new Rect(Screen.width – 100, 0, 100, 30); GUI.Label(scoreRect, "Score: " + score); } -
DrawGameOver函数首先使用GUI.Box函数在屏幕上绘制一个覆盖整个屏幕的深色框,同时绘制“游戏结束”消息在屏幕上。接下来,它在屏幕中间绘制玩家的最终分数。下面,它绘制了一个按钮。这个按钮将保存玩家的当前分数并加载我们稍后创建的水平选择屏幕。使用Application.LoadLevel函数来加载游戏中的任何其他场景。你打算加载的所有场景都必须添加到文件菜单中找到的构建设置窗口,并且可以使用它们的名称或索引来加载,就像这里使用的那样:private void DrawGameOver() { Rect boxRect = new Rect(0, 0, Screen.width, Screen.height); GUI.Box(boxRect, "Game Over\n" + message); Rect scoreRect = new Rect(0, Screen.height / 2, Screen.width, 30); GUI.Label(scoreRect, "Score: " + score); Rect exitRect = new Rect(0, Screen.height / 2 + 50, Screen.width, 50); if(GUI.Button(exitRect, "Return to Level Select")) { Application.LoadLevel(0); SaveScore(); } } -
在
LateUpdate函数中,我们调用另一个函数来检查如果游戏尚未结束,是否所有猪都被摧毁了。public void LateUpdate() { if(!gameOver) CheckPigs(); } -
接下来,我们添加
CheckPigs函数。这个函数会遍历猪的列表,查看它们是否全部被摧毁。如果找到一只仍然存在的猪,它会退出函数。否则,游戏会被标记为结束,并且会设置一条消息通知玩家他们成功摧毁了所有的猪。private void CheckPigs() { for(int i=0;i<pigs.Length;i++) { if(pigs[i] != null) return; } gameOver = true; message = "You destroyed the pigs!"; } -
OutOfBirds函数将在我们稍后创建的弹弓中调用,当玩家用完可以发射到猪身上的鸟时。如果游戏尚未结束,该函数将结束游戏并为玩家设置一条适当的消息。public static void OutOfBirds() { if(gameOver) return; gameOver = true; message = "You ran out of birds!"; } -
最后,我们有
SaveScore函数。在这里,我们使用PlayerPrefs类。它让我们能够轻松地存储和检索少量数据,非常适合我们的当前需求。我们只需要提供一个唯一的键来保存数据。为此,我们使用一个简短的字符串与由Application.loadedLevel提供的关卡索引相结合。接下来,我们使用PlayerPrefs.GetInt检索最后保存的分数。如果没有,函数中传递的零将作为默认值返回。最后,我们比较新分数和旧分数,如果新分数更高,则使用PlayerPrefs.SetInt保存新分数。private void SaveScore() { string key = "LevelScore" + Application.loadedLevel; int previousScore = PlayerPrefs.GetInt(key, 0); if(previousScore < score) PlayerPrefs.SetInt(key, score); } -
在 Unity 中,需要将猪添加到
LevelTracker脚本的列表中。选择LevelTracker脚本后,将每个猪拖放到检查器窗口中的Pigs值以添加它们。![行动时间 – 创建猪]()
刚才发生了什么?
我们创建了猪并更新了我们的LevelTracker脚本以跟踪它们。猪实际上就像木板的形状,但它们是球体而不是盒子。更新的LevelTracker脚本会监视所有猪被摧毁的实例,并在它们被摧毁时触发游戏结束屏幕。它还处理游戏进行时的分数绘制以及在关卡结束时保存该分数。
同盟
接下来,我们需要一些东西去扔向猪和它们的防御工事。在这里,我们将创建最简单的鸟。红色的鸟本质上只是一个石头。他没有特殊的能力,代码中也没有什么特别之处,除了健康。你也会注意到这只鸟是一个 3D 模型,它有猪所缺少的阴影。
行动时间 – 创建红色鸟
让我们开始创建红色的鸟:
-
虽然红色鸟模型是 3D 的,但它的设置方式与猪类似。创建一个空的游戏对象,命名为
Bird_Red,并从birds模型中添加适当的模型作为子对象,将其位置归零。模型应该旋转以沿 z 轴对齐。如果稍微朝向摄像机转动,玩家就能看到鸟的脸,同时仍然给人一种向下看田野的印象。 -
接下来,给它添加一个球体碰撞器组件和一个刚体组件,并设置约束参数。
-
现在,我们需要创建一个新的脚本名为
Bird。这个脚本将成为我们所有鸟的基础,跟踪它们的生命值并在适当的时候触发它们的特殊能力。 -
它从两个变量开始。第一个将跟踪鸟当前的健康状况。第二个是一个标志,这样鸟就只会使用一次它的特殊能力。它被标记为
protected,这样扩展此脚本的类可以使用它,同时防止来自类外部的干扰。public float health = 50; protected bool didSpecial = false; -
Update函数在激活鸟的特殊能力之前进行三项检查。首先,检查它是否已经完成,然后检查屏幕是否被触摸,最后检查鸟是否有 Rigidbody 组件,并且它不是由另一个脚本控制的。public void Update() { if(didSpecial) return; if(!Input.GetMouseButtonDown(0)) return; if(rigidbody == null || rigidbody.isKinematic) return; DoSpecial(); } -
在红色鸟的情况下,
DoSpecial函数仅将其标志设置为true。它被标记为virtual,这样我们就可以为其他鸟重写该函数,让它们做一些更复杂的事情。protected virtual void DoSpecial() { didSpecial = true; } -
OnCollisionEnter函数与板子的函数类似,根据碰撞的强度减去健康值,如果健康值耗尽,则销毁鸟。public void OnCollisionEnter(Collision collision) { health -= collision.relativeVelocity.magnitude; if(health < 0) Destroy(gameObject); } -
返回 Unity 并将脚本添加到
Bird_Red对象。 -
通过将其转换为预制件并将其从场景中删除来完成鸟的创建。我们即将创建的弹弓将在游戏开始时处理鸟的创建。
刚才发生了什么?
我们创建了红色鸟。它的设置就像我们的其他物理对象一样。我们还创建了一个脚本来处理鸟的健康状况,这个脚本在稍后创建其他鸟时会被扩展。
控制
接下来,我们将给玩家提供与游戏交互的能力。首先,我们将创建一个弹弓来投掷鸟。随后将是创建相机控制。我们甚至将创建一个漂亮的背景效果,以完善我们游戏的外观。
攻击
要攻击猪堡垒,我们有基本的鸟弹。我们需要创建一个弹弓来将弹药投向猪。它还将处理游戏开始时鸟的生成,并在使用鸟时自动重新装填。当弹弓的鸟用完时,它将通知 LevelTracker 脚本,游戏将结束。最后,我们将创建一个脚本,以防止物理模拟进行得太久。我们不希望玩家被迫坐着看猪慢慢地滚过屏幕。因此,脚本会在一段时间后开始减慢 Rigidbody 组件的运动,使它们停止而不是继续滚动。
是时候行动起来——创建弹弓
弹弓的大部分外观实际上是一种视觉错觉:
-
要开始创建弹弓,将
slingshot模型添加到场景中,并将其定位在原点。将浅棕色材料应用到Fork模型上,深棕色材料应用到Pouch模型上。 -
接下来,我们需要四个空白的 GameObject。将它们都设置为
Slingshot对象的子对象。-
将第一个命名为
FocalPoint并将其中心对准弹弓的叉子之间。这将是我们发射所有鸟的点。 -
第二个是
Pouch。将pouch模型设置为该对象的子对象,将其位置设置为 X 轴上的0.5,Y 和 Z 轴上的0。这将使口袋出现在当前鸟的前面,而无需制作完整的口袋模型。 -
第三是
BirdPoint;这将定位被发射的鸟。将其设置为Pouch点的子项,并将其在 X 和 Y 轴上的位置设置为0,在 Z 轴上设置为0.3。 -
最后是
WaitPoint;等待发射的鸟将被定位在这个点后面。将 X 轴的位置设置为0,Y 轴的位置设置为0.5,Z 轴的位置设置为-4。
-
-
接下来,旋转
Fork模型,以便我们可以看到叉子的两个叉。对于 X 轴的270,Y 轴的25,Z 轴的0将工作得很好。 -
Slingshot脚本将为玩家提供大部分交互。现在创建它。 -
我们从一组变量开始。第一个将保留之前提到的阻尼器的引用。第二个组跟踪将在关卡中使用的鸟。接下来是一组变量,将跟踪当前准备好发射的鸟。第四,我们有几个变量来保存我们刚才创建的点的引用。
maxRange变量是从焦点到玩家可以拖动 pouch 的距离。最后两个变量定义了鸟将被发射的力度。public RigidbodyDamper rigidbodyDamper; public GameObject[] levelBirds = new GameObject[0]; private GameObject[] currentBirds; private int nextIndex = 0; public Transform waitPoint; public Transform toFireBird; public bool didFire = false; public bool isAiming = false; public Transform pouch; public Transform focalPoint; public Transform pouchBirdPoint; public float maxRange = 3; public float maxFireStrength = 25; public float minFireStrength = 5; -
与我们其他的脚本一样,我们使用
Awake函数进行初始化。levelBirds变量将保存将在关卡中使用的所有bird预制体的引用。我们首先创建每个实例并将它们存储在currentBirds变量中。将每个鸟的 Rigidbody 上的isKinematic变量设置为true,这样当它未被使用时就不会移动。接下来,它准备好第一只待发射的鸟,最后,将剩余的鸟定位在waitPoint后面。public void Awake() { currentBirds = new GameObject[levelBirds.Length]; for(int i=0;i<levelBirds.Length;i++) { GameObject nextBird = Instantiate(levelBirds[i]) as GameObject; nextBird.rigidbody.isKinematic = true; currentBirds[i] = nextBird; } ReadyNextBird(); SetWaitPositions(); } -
ReadyNextBird函数首先检查我们是否已经用完了鸟。如果是这样,它调用LevelTracker脚本来触发游戏结束事件。nextIndex变量跟踪列表中鸟的当前位置,以便向玩家发射。接下来,函数检查下一个槽位实际上是否有鸟,如果没有,则增加索引并尝试获取新的鸟。如果有可用的鸟,它将被存储在toFireBird变量中,并成为我们创建的BirdPoint对象的子项;其位置和旋转被设置为0。最后,重置发射和瞄准标志。public void ReadyNextBird() { if(currentBirds.Length <= nextIndex) { LevelTracker.OutOfBirds(); return; } if(currentBirds[nextIndex] == null) { nextIndex++; ReadyNextBird(); return; } toFireBird = currentBirds[nextIndex].transform; nextIndex++; toFireBird.parent = pouchBirdPoint; toFireBird.localPosition = Vector3.zero; toFireBird.localRotation = Quaternion.identity; didFire = false; isAiming = false; } -
SetWaitingPositions函数使用waitPoint的位置来定位所有剩余的鸟在弹弓后面。public void SetWaitingPositions() { for(int i=nextIndex;i<currentBirds.Length;i++) { if(currentBirds[i] == null) continue; Vector3 offset = Vector3.forward * (i – nextIndex) * 2; currentBirds[i].transform.position = waitPoint.position – offset; } } -
Update函数首先检查玩家是否发射了鸟,并观察rigidbodyDamper.allSleeping变量以查看是否所有物理对象都已停止移动。一旦它们停止移动,下一只鸟就准备好发射。如果我们还没有发射,则检查瞄准标志并调用DoAiming函数来处理瞄准。如果玩家既没有瞄准也没有刚刚发射了一只鸟,我们检查触摸输入,如果玩家触摸到足够接近焦点的地方,我们标记玩家已经开始瞄准。public void Update() { if(didFire) { if(rigidbodyDamper.allSleeping) { ReadyNextBird(); SetWaitingPositions(); } return; } else if(isAiming) { DoAiming();} else { if(Input.touchCount <= 0) return; Vector3 touchPoint = GetTouchPoint(); isAiming = Vector3.Distance(touchPoint, focalPoint.position) < maxRange / 2; } } -
DoAiming函数检查玩家是否停止触摸屏幕,如果他们停止触摸,则发射当前小鸟。如果他们没有停止,我们将袋子放置在当前触摸点上。最后,限制袋子的位置以保持其在最大范围内。private void DoAiming() { if(Input.touchCount <= 0) { FireBird(); return; } Vector3 touchPoint = GetTouchPoint(); pouch.position = touchPoint; pouch.LookAt(focalPoint); float distance = Vector3.Distance(focalPoint.position, pouch.position); if(distance > maxRange) { pouch.position = focalPoint.position – (pouch.forward * maxRange); } } -
GetTouchPoint函数使用ScreenPointToRay来确定玩家在 3D 空间中的触摸位置。这就像我们射击小行星时一样,但由于这个游戏是 2D 的,我们只需查看射线的起点并将其 x 轴值设置为 0 返回。private Vector3 GetTouchPoint() { Ray touchRay = Camera.main.ScreenPointToRay(Input.GetTouch(0).position); Vector3 touchPoint = touchRay.origin; touchPoint.x = 0; return touchPoint; } -
最后,对于此脚本,我们还有
FireBird函数。此函数首先将我们的didFire标志设置为true。接下来,它通过找到从袋子位置到focalPoint的方向来确定发射方向。它还使用它们之间的距离来确定用多大的力量发射小鸟,将其限制在我们的最小和最大强度之间。然后,通过清除其父对象并设置其isKinematic标志为false来释放小鸟。为了发射它,我们使用rigidbody.AddForce函数并将方向乘以力量传递给它。ForceMode.Impulse也被传递以使施加的力一次性并立即生效。接下来,袋子被放置在focalPoint处,就像它实际上处于紧张状态一样。最后,我们调用rigidbodyDamper.ReadyDamp以开始Rigidbody运动的阻尼。private void FireBird() { didFire = true; Vector3 direction = (focalPoint.position – pouch.position).normalized; float distance = Vector3.Distance(focalPoint.position, pouch.position); float power = distance <= 0 ? 0 : distance / maxRange; power *= maxFireStrength; power = Mathf.Clamp(power, minFireStrength, maxFireStrength); toFireBird.parent = null; toFireBird.rigidbody.isKinematic = false; toFireBird.rigidbody.AddForce(direction * power, ForceMode.Impulse); pouch.position = focalPoint.position; rigidbodyDamper.ReadyDamp(); } -
在我们可以使用
Slingshot脚本之前,我们需要创建RigidbodyDamper脚本。 -
此脚本以六个变量开始。前两个变量定义了在阻尼运动之前等待多长时间以及阻尼的程度。接下来的两个变量跟踪是否可以应用阻尼以及何时开始。接下来是一个变量,它将填充当前场景中所有刚体的列表。最后,它有一个
allSleeping标志,当运动停止时将设置为true。public float dampWaitLength = 10f; public float dampAmount = 0.9f; private float dampTime = -1; private bool canDamp = false; private Rigidbody[] rigidbodies = new Rigidbody[0]; public bool allSleeping = false; -
ReadyDamp函数首先使用FindObjectsOfType将所有刚体填充到列表中。它将开始阻尼的时间设置为当前时间加上等待长度。它标记脚本可以进行阻尼并重置allSleeping标志。最后,它使用StartCoroutine调用CheckSleepingRigidbodies函数。这是一种特殊的调用函数的方式,使它们在后台运行而不阻塞游戏的其余部分。public void ReadyDamp() { rigidbodies = FindObjectsOfType(typeof(Rigidbody)) as Rigidbody[]; dampTime = Time.time + dampWaitLength; canDamp = true; allSleeping = false; StartCoroutine(CheckSleepingRigidbodies()); } -
在
FixedUpdate函数中,我们首先检查是否可以阻尼运动以及是否是进行阻尼的时间。如果是,我们遍历所有刚体,对每个刚体的角速度和线速度应用我们的阻尼。那些是运动学、由脚本控制并且已经停止移动的(即它们停止了移动)的刚体将被跳过。public void FixedUpdate() { if(!canDamp || dampTime > Time.time) return; foreach(Rigidbody next in rigidbodies) { if(next != null && !next.isKinematic && !next.IsSleeping()) { next.angularVelocity *= dampAmount; next.velocity *= dampAmount; } } } -
CheckSleepingRigidbodies函数是特殊的,它将在后台运行。这是通过函数开头处的IEnumerator标志和中间的yield return null行实现的。这两个结合允许函数定期暂停,以免在等待函数完成时冻结游戏的其余部分。该函数首先创建一个检查标志并使用它来检查所有刚体是否已停止移动。如果找到一个仍在移动的刚体,则将标志设置为false并暂停函数,直到下一帧,届时它将再次尝试。当它到达末尾时,因为所有刚体都处于睡眠状态,它将allSleeping标志设置为true,以便弹弓可以准备下一只鸟。它还阻止自己在玩家准备发射下一只鸟时进行阻尼。private IEnumerator CheckSleepingRigidbodies() { bool sleepCheck = false; while(!sleepCheck) { sleepCheck = true; foreach(Rigidbody next in rigidbodies) { if(next != null && !next.isKinematic && !next.IsSleeping()) { sleepCheck = false; yield return null; break; } } } allSleeping = true; canDamp = false; } -
最后,我们有
AddBodiesToCheck函数。这个函数将在玩家发射鸟之后,由任何生成新物理对象的东西使用。它首先创建一个临时列表并扩展当前列表。接下来,它将临时列表中的所有值添加到扩展列表中。最后,在临时列表之后添加刚体列表。public void AddBodiesToCheck(Rigidbody[] toAdd) { Rigidbody[] temp = rigidbodies; rigidbodies = new Rigidbody[temp.Length + toAdd.Length]; for(int i=0;i<temp.Length;i++) { rigidbodies[i] = temp[i]; } for(int i=0;i<toAdd.Length;i++) { rigidbodies[i + temp.Length] = toAdd[i]; } } -
返回 Unity 并将两个脚本添加到
Slingshot对象中。在Slingshot脚本组件中,连接到 Rigidbody Damper 组件和每个点。同时,将 Level Birds 列表中添加尽可能多的红色鸟的引用,以适应该关卡。 -
为了防止物体在弹弓中滚动回滚,创建一个 Box Collider 组件并将其放置在
Fork模型的枪托上。 -
为了完成弹弓的外观,我们需要创建将袋子系在叉子上的弹性带。我们将通过首先创建
SlingshotBand脚本来完成这项工作。 -
脚本开始时有两个变量。一个用于弹性带结束的点,另一个用于引用将绘制它的
LineRenderer。public Transform endPoint; public LineRenderer lineRenderer; -
Awake函数确保lineRenderer变量只有两个点,并设置它们的初始位置。public void Awake() { if(lineRenderer == null) return; if(endPoint == null) return; lineRenderer.SetVertexCount(2); lineRenderer.SetPosition(0, transform.position); lineRenderer.SetPosition(1, endPoint.position); } -
在
LateUpdate函数中,我们将lineRenderer变量的结束位置设置为endPoint值。这个点将随着袋子移动,因此我们需要不断更新渲染器。public void LateUpdate() { if(endPoint == null) return; if(lineRenderer == null) return; lineRenderer.SetPosition(1, endPoint.position); } -
返回 Unity 并创建一个空的 GameObject。将其命名为
Band_Near并使其成为Slingshot对象的子对象。 -
作为新点的子对象,创建一个圆柱体和第二个空的 GameObject,命名为
Band。 -
给圆柱体一个棕色材质并将其放置在弹弓叉子的尖端附近。
-
将 Line Renderer 组件添加到
Band对象中,该组件位于 Component 菜单下的 Effects 中。在将其定位在圆柱体的中心后,将SlingshotBand脚本添加到对象中。 -
在材质下的Line Renderer中,你可以将你的棕色材质放入槽中,以着色带子。在参数中,将起始宽度设置为
0.5,将结束宽度设置为0.2,以设置线的尺寸。 -
接下来,创建一个额外的空GameObject,并将其命名为
BandEnd_Near。将其设置为Pouch对象的子对象,并将其放置在包内。 -
现在,将脚本的引用连接到其线渲染器和终点。
-
要创建第二个带子,复制我们刚刚创建的四个对象,并将它们放置在叉子尖端远处。这个带子的终点可以沿着 x 轴移动,以保持它不在鸟类的路上。
-
最后,将整个系统转换成一个预制件,以便可以在其他关卡中轻松重用。
![行动时间 – 创建弹弓]()
刚才发生了什么?
我们创建了将用于发射鸟类的弹弓。我们使用了在前一章中学到的技术来处理触摸输入,并在玩家瞄准和射击时跟踪玩家的手指。如果你保存场景并将相机定位到观察弹弓,你会注意到它已经完成,尽管可能不完全可玩。可以朝向猪堡垒发射鸟类,尽管我们只能从 Unity 的场景视图中看到破坏。
观察
在这个阶段,游戏在技术上是可以玩的,但看不清楚发生了什么。接下来,我们将创建一个控制系统来控制相机。这将允许玩家左右拖动相机,当鸟类被发射时跟随鸟类,并在一切停止移动时返回弹弓。同时,也会有一套限制,以防止相机移动得太远,看到我们不希望玩家看到的东西。
行动时间 - 控制相机
我们只需要一个相对简短的脚本就能控制和管理工作中的相机:
-
为了开始并保持一切井然有序,创建一个新的空GameObject,并将其命名为
CameraRig。为了简化,将其在每个轴上的位置设置为零。 -
接下来,创建三个更多的空GameObject,并将它们命名为
LeftPoint、RightPoint和TopPoint。将它们的X轴位置设置为5。将LeftPoint放置在弹弓前方,并在Y轴上设置为3。RightPoint需要放置在你创建的pig结构前方。TopPoint可以位于弹弓上方,但需要在Y轴上设置为8。这三个点将定义相机在被拖动和跟随鸟类时可以移动的范围。 -
将所有三个点和
Main Camera对象设置为CameraRig对象的子对象。 -
现在,我们创建
CameraControl脚本。这个脚本将控制所有与相机的移动和交互。 -
这个脚本的变量从对弹弓的引用开始;我们需要这个引用以便在发射当前鸟时跟随它。接下来是引用我们刚刚创建的点。下一组变量控制相机在没有输入的情况下会坐多久然后返回查看弹弓,以及返回的速度有多快。
dragScale变量控制玩家在屏幕上拖动手指时相机实际移动的速度,使我们能够保持场景随着手指移动。最后一组变量用于控制相机是否可以跟随当前鸟以及它跟随的速度有多快。public Slingshot slingshot; public Transform rightPoint; public Transform leftPoint; public Transform topPoint; public float waitTime = 3f; private float headBackTime = -1; private Vector3 waitPosition; public float headBackDuration = 3f; public float dragScale = 0.075f; private bool followBird = false; private Vector3 followVelocity = Vector3.zero; public float followSmoothTime = 0.1f; -
在
Awake函数中,我们首先确保相机没有跟随鸟,并在前往查看弹弓之前让它等待。这允许我们在关卡开始时将相机指向猪堡垒,并在给玩家机会看到他们面对什么之后移动到弹弓。public void Awake() { followBird = false; StartWait(); } -
StartWait函数设置开始返回弹弓的时间,并记录它返回的位置。这允许我们创建一个平滑的过渡。public void StartWait() { headBackTime = Time.time + waitTime; waitPosition = transform.position; } -
接下来,我们有
Update函数。这个函数首先检查弹弓是否已经发射。如果没有,它检查玩家是否开始瞄准,这表示应该跟随鸟,如果他们已经瞄准,则将速度归零。如果没有,则清除followBird标志。接下来,函数检查是否应该跟随,如果应该跟随,则执行跟随操作,并在鸟被销毁的帧中调用StartWait函数。如果不应该跟随鸟,它检查触摸输入,并在找到任何输入时拖动相机。如果玩家在这个帧中移除手指,则再次开始等待。最后,它检查弹弓是否已经完成当前鸟的发射,以及是否是返回的时间。如果两者都为真,则相机返回指向弹弓。public void Update() { if(!slingshot.didFire) { if(slingshot.isAiming) { followBird = true; followVelocity = Vector3.zero; } else { followBird = false; } } if(followBird) { FollowBird(); StartWait(); } else if(Input.touchCount > 0) { DragCamera(); StartWait(); } if(!slingshot.didFire && headBackTime < Time.time) { BackToLeft(); } } -
FollowBird函数首先确保有一个鸟需要跟随,通过检查Slingshot脚本中的toFireBird变量,如果找不到鸟则停止跟随。如果存在鸟,函数随后确定一个新位置移动到那里,以便直接看向鸟。然后它使用Vector3.SmoothDamp函数平滑地跟随鸟。这个函数的工作原理类似于弹簧——它离目标位置越远,移动物体的速度就越快。followVelocity变量用于保持其平滑移动。最后,它调用另一个函数来限制相机在之前设置的边界点内的位置。private void FollowBird() { if(slingshot.toFireBird == null) { followBird = false; return; } Vector3 targetPoint = slingshot.toFireBird.position; targetPoint.x = transform.position.x; transform.position = Vector3.SmoothDamp(transform.position, targetPoint, ref followVelocity, followSmoothTime); ClampPosition(); } -
在
DragCamera函数中,我们使用当前触摸的deltaPosition值来确定自上一帧以来它移动了多远。通过缩放这个值并从相机的位置减去向量,该函数在玩家在屏幕上拖动时移动相机。此函数还调用了限制相机位置的函数。private void DragCamera() { transform.position -= new Vector3(0, 0, Input.GetTouch(0).deltaPosition.x * dragScale); ClampPosition(); } -
ClampPosition函数首先获取摄像机的当前位置。然后,它将 z 位置限制在leftPoint和rightPoint变量的z位置之间。接下来,y位置被限制在leftPoint和topPoint变量的位置之间。最后,新的位置被重新应用到摄像机的变换中。private void ClampPosition() { Vector3 clamped = transform.position; clamped.z = Mathf.Clamp(clamped.z, leftPoint.position.z, rightPoint.position.z); clamped.y = Mathf.Clamp(clamped.y, leftPoint.position.y, topPoint.position.y); transform.position = clamped; } -
最后,我们有
BackToLeft函数。它首先使用时间和我们的持续时间变量来确定摄像机返回弹弓的进度。它记录摄像机的当前位置,并在 z 和 y 轴上使用Mathf.SmoothStep找到一个新的位置,这个位置是waitPosition变量和leftPoint变量之间适当距离的位置。最后,应用新的位置。private void BackToLeft() { float progress = (Time.time – headBackTime) / headBackDuration; Vector3 newPosition = transform.position; newPosition.z = Mathf.SmoothStep(waitPosition.z, leftPoint.position.z, progress); newPosition.y = Mathf.SmoothStep(waitPosition.y, leftPoint.position.y, progress); transform.position = newPosition; } -
接下来,回到 Unity 并将新脚本添加到
Main Camera对象中。连接到弹弓和每个点的引用以完成设置。 -
将摄像机定位指向你的猪堡垒,并将整个装置转换成预制件。
刚才发生了什么?
我们创建了一个摄像机装置,让玩家在玩游戏时观看所有的动作。现在,摄像机将跟随从弹弓发射出的鸟,并且现在可以被玩家拖动。通过几个物体的位置来锁定这个动作,以防止玩家看到我们不希望他们看到的东西。如果摄像机闲置时间足够长,它也会返回到观察弹弓的位置。
尝试一下英雄 - 更多关卡
现在我们有了制作完整关卡所需的所有部件,我们需要更多关卡。我们需要至少两个更多关卡。你可以使用方块和猪来创建你想要的任何关卡。保持猪结构在 Z 轴大约 30 处是不错的选择。同时,在制作关卡时要考虑难度,这样你就可以得到简单、中等和困难关卡。
更好的背景
许多 2D 游戏的一个出色功能是透视滚动背景。这仅仅意味着背景是在不同速度下滚动的层中创建的。想象一下,就像从你的汽车窗户向外看。远处的物体几乎不动,而近处的物体则快速移动。在 2D 游戏中,这会产生深度错觉,并为游戏的外观增添美感。对于这个背景,我们将在单个平面上叠加几个材质。
行动起来 - 创建透视背景
有一种创建和利用第二个摄像机的替代方法,但我们的方法将使用一个额外的脚本,该脚本还允许我们控制每个层的速度:
-
我们将从这个部分的创建
ParallaxScroll脚本开始。 -
这个脚本从三个变量开始。前两个跟踪每个材质及其滚动速度。第三个跟踪摄像机的最后位置,这样我们就可以跟踪它在每一帧中移动的距离。
public Material[] materials = new Material[0]; public float[] speeds = new float[0]; private Vector3 lastPosition = Vector3.zero; -
在
Start函数中,我们记录相机的初始位置。我们在这里使用Start而不是Awake,以防相机需要在游戏开始时进行任何特殊移动。public void Start() { lastPosition = Camera.main.transform.position; } -
接下来,我们使用
LateUpdate函数在相机移动后进行更改。它首先找到相机的新的位置,并比较 z 轴的值以确定它移动了多远。然后,它遍历材质列表。循环首先使用mainTextureOffset收集其纹理的当前偏移量。然后,从偏移量的 x 轴中减去相机移动乘以材质的速度,以找到新的水平位置。然后,将新的偏移量应用到材质上。最后,该函数记录相机在下一帧的最后位置。public void LateUpdate() { Vector3 newPosition = Camera.main.transform.position; float move = newPosition.z – lastPosition.z; for(int i=0;i<materials.Length;i++) { Vector2 offset = materials[i].mainTextureOffset; offset.x -= move * speeds[i]; materials[i].mainTextureOffset = offset; } lastPosition = newPosition; } -
返回 Unity 并创建六个新的材质。每个背景纹理一个:
sky、hills_tall、hills_short、grass_light、grass_dark和fronds。除了天空之外的所有材质,都需要在着色器下拉列表中的透明下的漫反射着色器中使用。如果不这样做,当它们分层时,我们将无法看到所有纹理。 -
我们还需要调整这些新材料的平铺。对于所有这些,将Y轴保留为
1。对于X轴,将5设置为sky,6设置为hills_tall,7设置为hills_short,8设置为grass_dark,9设置为fronds,10设置为grass_light。这将使纹理的所有特征偏移,这样在长距离平移时不会看到特征有规律地排列。 -
接下来,创建一个新的平面。将其命名为
Background并移除其网格碰撞器组件。 -
在X轴上将其定位为
-5,在Y轴上为7,在Z轴上为30。将X轴的旋转设置为90,将Y轴的旋转设置为90,将Z轴的旋转设置为0。此外,将X轴的缩放设置为10,将Y轴的缩放设置为1,将Z轴的缩放设置为1.5。总的来说,这些位置将平面定位为面向相机并填充背景。 -
在平面的网格渲染器组件中,展开材质列表并将大小设置为
6。按sky、hills_tall、hills_short、grass_dark、fronds、grass_light的顺序将我们的新材料添加到列表槽中。同样,在视差滚动脚本组件中的材质列表中也进行相同的操作。 -
最后,在视差滚动脚本组件中,将速度列表的大小设置为
6,并按以下顺序输入以下值:0.03、0.024、0.018、0.012、0.006、0。这些值将使材料平稳均匀地移动。 -
在这个阶段,将背景转换为预制件,将便于稍后重用。
![行动时间 - 创建视差背景]()
刚才发生了什么?
我们创建了一个视差滚动效果。此效果将平移一系列背景纹理,给我们的 2D 游戏带来深度感。为了更容易看到它的效果,按播放并抓取场景视图中的相机,沿着 z 轴移动以查看背景变化。
英雄尝试——夜晚的黑暗
我们还有两个其他关卡需要添加背景。你的挑战是创建自己的背景。使用你在本节中学到的技术创建一个夜幕风格的背景。它可以包括一个静止的月亮,而其他所有东西都在镜头中滚动。为了增加技巧,创建一个云层,它以缓慢的速度在屏幕上以及与相机和其他背景一起移动。
群体多样性
我们还需要为我们的关卡创建最后一组资产,即其他鸟。我们将创建另外三只鸟,每只鸟都有独特的特殊能力:一只加速的黄色鸟,一只分裂成多只鸟的蓝色鸟,以及一只爆炸的黑色鸟。有了这些,我们的鸟群就完整了。
为了使这些鸟的创建更容易,我们将利用一个称为继承的概念。继承允许脚本在不重写它们的情况下扩展它继承的功能。如果使用得当,这将非常强大,在我们的案例中,将有助于快速创建大量相似的角色。
黄色鸟
首先,我们将创建黄色鸟。基本上,这只鸟的功能与红色鸟完全相同。然而,当玩家触摸屏幕时,鸟的速度会增加。通过扩展我们之前创建的Bird脚本,这只鸟的创建变得相当简单。
行动时间——创建黄色鸟
由于继承的力量,我们在这里创建的脚本只有几行代码:
-
首先,以与红色鸟相同的方式创建黄色鸟,使用
YellowBird模型。 -
我们将不使用
Bird脚本,而是创建YellowBird脚本。 -
此脚本需要扩展
Bird脚本,因此需要在第四行将MonoBehaviour替换为Bird。它应该看起来类似于以下代码片段:public class YellowBird : Bird { -
此脚本添加了一个单变量,它将被用来乘以鸟的当前速度。
public float multiplier = 2f; -
接下来,我们重写
DoSpecial函数,并在调用时乘以鸟的rigidbody.velocity:protected override void DoSpecial() { didSpecial = true; rigidbody.velocity *= multiplier; } -
返回 Unity,将脚本添加到你的新鸟中,并将其转换为预制体。将其添加到弹弓上的列表中,以便在关卡中使用这只鸟。
刚才发生了什么?
我们创建了黄色鸟。这只鸟很简单。它直接修改其速度,在玩家触摸屏幕时突然获得速度提升。正如你很快就会看到的,我们使用这种相同的脚本创建风格来创建我们所有的鸟。
蓝色鸟
接下来,我们创建蓝色鸟。当玩家触摸屏幕时,这只鸟会分裂成三只鸟。它也将扩展Bird脚本。
行动时间——创建蓝色鸟
蓝色鸟将再次利用继承,减少创建鸟时需要编写的代码量:
-
再次,以与前面两个相同的方式开始构建你的蓝色鸟,替换适当的模型。你还应该调整Sphere Collider组件的Radius,以便与这只鸟较小的尺寸相匹配。
-
接下来,我们创建
BlueBird脚本。 -
再次调整第四行,使脚本扩展到
Bird而不是MonoBehaviour。public class BlueBird : Bird { -
这个脚本有三个变量。第一个是在鸟分裂时生成预制体的列表。接下来是每个新发射的鸟之间的角度差。最后是一个值,用于在当前位置前方生成鸟,以防止它们互相卡住。
public GameObject[] splitBirds = new GameObject[0]; public float launchAngle = 15f; public float spawnLead = 0.5f; -
接下来,我们重写
DoSpecial函数,并像其他人一样,标记我们已经做出了特殊动作。接下来,它计算要生成的鸟的数量的一半,并为存储新生成的鸟的刚体创建一个空列表。protected override void DoSpecial() { didSpecial = true; int halfLength = splitBirds.Length / 2; Rigidbody[] newBodies = new Rigidbody[splitBirds.Length]; -
函数继续通过鸟的列表进行循环,跳过空槽位。它在当前位置生成新鸟,如果缺少Rigidbody组件,则继续到下一个。然后,新的Rigidbody组件被存储在列表中。
for(int i=0;i<splitBirds.Length;i++) { if(splitBirds[i] == null) continue; GameObject next = Instantiate(splitBirds[i], transform.position, transform.rotation) as GameObject; if(next.rigidbody == null) continue; newBodies[i] = next.rigidbody; -
使用
Quaternion.Euler创建一个新的旋转,这将使新鸟沿着从主路径分叉的路径旋转。新鸟的速度被设置为当前鸟旋转后的速度。然后它沿着新路径向前移动,以避开正在生成的其他鸟。Quaternion rotate = Quaternion.Euler(launchAngle * (i – halfLength), 0, 0); next.rigidbody.velocity = rotate * rigidbody.velocity; next.transform.position += next.rigidbody.velocity.normalized * spawnLead; } -
循环结束后,函数使用
FindObjectOfType在场景中找到当前存在的弹弓。如果找到,它将改变以跟踪第一个新生成的鸟作为被发射的鸟。新的刚体列表也被设置为rigidbodyDamper变量,以便添加到其刚体列表中。最后,脚本销毁其附加的鸟,完成鸟分裂的幻觉。Slingshot slingshot = FindObjectOfType(typeof(Slingshot)) as Slingshot; if(slingshot != null) { slingshot.toFireBird = newBodies[0].transform; slingshot.rigidbodyDamper.AddBodiesToCheck(newBodies); } Destroy(gameObject); } -
在将脚本添加到你的新鸟之前,我们实际上需要两只蓝色鸟:一只负责分裂,另一只不分裂。复制你的鸟,将一个命名为
Bird_Blue_Split,另一个命名为Bird_Blue_Normal。将新脚本添加到分裂的鸟上,将Bird脚本添加到正常的鸟上。 -
将两只鸟都转换为预制体,并将正常的鸟添加到另一只鸟的分裂鸟列表中。
刚才发生了什么?
我们创建了蓝色鸟。当用户点击屏幕时,这只鸟会分裂成多只鸟。实际上,这个效果需要两只看起来完全相同的鸟。一只负责分裂,另一只被分裂但没有任何特殊动作。
尝试一下英雄——创建彩虹鸟
实际上,我们可以将任何我们想要生成的物品添加到蓝色鸟的分裂物品列表中。这里的挑战是创建一只彩虹鸟。这只鸟可以分裂成不同类型的鸟,而不仅仅是蓝色鸟。或者,也许它是一只石头鸟,分裂成石头块。作为一个更高级的挑战,创建一只神秘鸟,当它分裂时,会从其列表中随机选择一只鸟。
黑色鸟
最后,我们有黑色鸟。当玩家触摸屏幕时,这只鸟会爆炸。和之前讨论的所有鸟一样,它将扩展Bird脚本。
是时候行动了——创建黑色鸟
和之前讨论的两只鸟一样,从红色鸟继承使得黑色鸟的创建变得容易得多:
-
和其他鸟一样,这只鸟最初是以与红色鸟相同的方式创建的,重新调整半径以适应其增大的尺寸。
-
再次,我们创建一个新的脚本以扩展
Bird脚本。这次它被称为BlackBird。 -
不要忘记调整第四行,以扩展
Bird脚本而不是MonoBehaviour。public class BlackBird : Bird { -
这个脚本有两个变量。第一个是爆炸的大小,第二个是它的强度。
public float radius = 2.5f; public float power = 25f; -
再次,我们重写了
DoSpecial函数,首先标记我们已经做了。接下来,我们使用Physics.OverlapSphere获取所有在鸟的爆炸范围内的对象的列表。该函数然后遍历列表,跳过任何空槽位和没有刚体组件的对象。如果对象确实存在并且附加了Rigidbody组件,我们调用AddExplosionForce来模拟爆炸的强度随着距离的增加而减少。我们给函数提供爆炸的强度,然后是鸟的位置和半径。值3是一个垂直修正器。它不会干扰对象与爆炸的距离,而是调整爆炸击中对象的角度。这个3将力量移动到对象的下方,因为将碎片抛向天空的爆炸比将其推出去的爆炸更酷。再次使用ForceMode.Impulse来立即应用力量。最后,该函数销毁爆炸的鸟。protected override void DoSpecial() { didSpecial = true; Collider[] colliders = Physics.OverlapSphere(transform.position, radius); foreach(Collider hit in colliders) { if(hit == null) continue; if(hit.rigidbody != null) hit.rigidbody.AddExplosionForce(power, transform.position, radius, 3, ForceMode.Impulse); } Destroy(gameObject); } -
和前两只一样,将你的新脚本应用到你的新鸟上,并将其转换为预制体。你现在有四只鸟可供选择,当选择每个级别的弹弓武器库时。
刚才发生了什么?
我们创建了我们的第四只也是最后一只鸟,黑色鸟。当用户触摸屏幕时,这只鸟会爆炸,将附近的所有东西都抛向天空。这是一只很有趣的鸟,可以用来玩耍,并且对于摧毁你的猪堡非常有效。
尝试一下英雄——爆炸方块
现在你已经知道如何引发爆炸,我们又有了一个新的挑战。创建一个爆炸箱。扩展Plank脚本以制作它。当对箱子的伤害足够时,触发爆炸。作为一个额外的挑战,而不是让箱子爆炸,配置它抛出几颗炸弹,当它们撞击到某物时会爆炸。

关卡选择
最后,我们需要创建我们的关卡选择。从这个场景,我们将能够访问并开始玩我们之前创建的所有关卡。我们还将显示每个关卡当前的最高分。
行动时间 – 创建关卡选择
一个新的场景和单个脚本将很好地帮助我们管理关卡选择:
-
这最后一部分首先保存你的当前场景,然后按Ctrl + N创建一个新的场景,命名为
LevelSelect。 -
对于这个场景,我们需要创建一个单独的、简短的脚本,也命名为
LevelSelect。 -
第一个且唯一的变量定义了将出现在屏幕上的按钮大小。
public int buttonSize = 100; -
唯一的功能是
OnGUI函数。这个函数从一个循环开始。它将循环三次,对应于我们之前应该创建的三个关卡。创建了一个Rect变量并将其初始化为buttonSize。然后设置 x 和 y 值以将按钮排成一行,居中在屏幕上。接下来,使用PlayerPrefs.GetInt和我们在LevelTracker脚本中使用的相同键创建方法检索当前关卡的最高分。然后,函数创建一个字符串来保存将出现在按钮上的消息。最后,绘制按钮,当点击时,使用Application.LoadLevel加载场景并开始用户玩该关卡。public void OnGUI() { for(int i=0;i<3;i++) { Rect next = new Rect(0,0, buttonSize, buttonSize); next.x = (Screen.width / 2) – (buttonSize * 1.5f) + (buttonSize * i); next.y = (Screen.height / 2) – (buttonSize / 2f); int levelScore = PlayerPrefs.GetInt("LevelScore" + (i + 1), 0); string text = "Level " + (i + 1) + "\nScore: " + levelScore; if(GUI.Button(next, text)) { Application.LoadLevel(i + 1); } } } -
返回 Unity 并将脚本添加到
Main Camera对象。 -
最后,打开构建设置并将你的场景添加到构建中的场景列表中。在列表中点击并拖动场景可以重新排列它们。确保你的LevelSelect场景排在第一位,并且它的索引为零。其余的场景可以按照你希望的任何顺序出现。但请注意,它们将与按钮以相同的顺序关联。
刚才发生了什么?
我们创建了一个关卡选择屏幕。它使用循环创建与游戏中的关卡关联的按钮列表。当按下按钮时,Application.LoadLevel启动该关卡。我们还使用了PlayerPrefs.GetInt来检索每个关卡的最高分。
英雄试炼 – 添加一些风格
在这里,挑战是使用 GUI 样式使屏幕看起来很棒。一个标志和背景会有很大帮助。此外,如果你有超过三个关卡,请查看GUI.BeginScrollView。这个函数将使用户能够滚动查看一个关卡列表,其大小远大于屏幕上容易看到的。
摘要
在本章中,我们学习了在 Unity 中使用物理知识,并重新制作了广受欢迎的移动游戏《愤怒的小鸟》。通过使用 Unity 的物理系统,我们能够制作出我们想要玩的所有关卡。在这个游戏中,我们还探索了在 3D 环境中创建 2D 游戏的可能性。我们的鸟和弹弓是 3D 资产,这使得我们能够对它们进行光照和阴影处理。然而,猪和背景是 2D 图像,这减少了我们的光照选项,但可以在资产中提供一些更详细的细节。2D 图像在创建背景的视差滚动效果中也至关重要。最后,建筑块看起来是 2D 的,但实际上是 3D 块。我们还创建了一个关卡选择屏幕。从那里,玩家可以看到他们的高分,并选择我们创建的任何关卡来玩。
在下一章中,我们将回到上一章开始的空间战斗机游戏。我们将创建并添加所有完成游戏的特殊效果。我们将添加每个太空游戏都需要的开火和爆炸音效。我们还将添加各种粒子效果。当船只被射击时,它们实际上会爆炸,而不仅仅是消失。
第八章. 特效 – 声音和粒子
在前一章中,我们从我们的太空战斗机游戏中短暂休息,学习关于物理和 Unity 中的 2D 游戏。我们创建了一个愤怒的小鸟克隆版。小鸟利用物理在空中飞行并摧毁猪和它们的结构。我们使用视差滚动来制作令人愉悦的背景效果。我们还创建了一个关卡选择屏幕,从游戏中加载各种场景。
在本章中,我们回到太空战斗机游戏。我们将添加许多特殊效果,以完善游戏体验。我们首先学习 Unity 在处理音频时提供的控件。然后,我们添加一些背景音乐和警告声音,当任何东西太靠近时。接下来,我们学习粒子系统,为我们的飞船创建引擎尾迹。最后,我们将本章的效果结合起来,创建子弹爆炸和爆炸效果。
在本章中,我们将涵盖以下主题:
-
导入音频剪辑
-
播放 SFX
-
理解 2D 和 3D 特效
-
创建粒子系统
打开你的太空战斗机项目,我们开始吧。
理解音频
与其他资产一样,Unity 团队努力使音频的使用变得简单且无痛苦。Unity 能够导入和利用广泛的音频格式,允许你保持文件格式,以便你在其他程序中编辑。
导入设置
音频剪辑有一系列重要的设置。它们允许你轻松控制文件的类型和压缩。

在导入音频剪辑时,以下是我们必须处理的设置:
-
音频格式:这控制文件是否以原生格式包含或是在最终游戏中压缩。原生格式虽然文件大小更大,但对于短音效来说最佳,因为它们可以快速加载和播放。压缩格式更适合长音效和音乐。它们在最终构建中占用的空间更小。
-
3D 声音:此复选框控制文件是否以 2D 或 3D 播放。2D 声音无论玩家位于何处都会以恒定音量播放——非常适合背景音乐和旁白。3D 声音的音量会根据其与玩家的距离进行调整——非常适合爆炸和枪声。
-
强制单声道:此复选框将导致 Unity 将立体声文件更改为单声道文件。
-
加载类型:这控制游戏播放时文件的加载方式。
-
加载到内存中:这会将原生文件直接加载到内存中以便播放。
-
从磁盘流式传输:这将在播放时流式传输音频,例如从网络流式传输音乐或视频。
-
加载时解压缩:这会在文件首次需要时移除压缩。此选项的开销使其对于大文件来说是一个非常糟糕的选择。
-
内存中压缩: 这仅在播放时解压缩文件。当它只是保存在内存中时,文件保持压缩状态。
-
-
硬件解码: 这仅用于 iOS 设备以降低处理成本。
-
无缝循环: 这调整压缩方法以消除某些方法可能引入到文件中的小寂静声。
-
压缩: 这是每秒压缩文件的数据量,从而生成一个更小的文件。最好找到一个值,在最小化文件大小的同时,损失的质量最少。
音频监听器
为了在游戏中真正听到任何声音,每个场景都需要一个音频监听器组件。默认情况下,主相机对象(在任何新场景中首先包含)以及您可能创建的任何新相机都附加了一个音频监听器组件。您的场景中一次只能有一个音频监听器组件。如果有多个,或者在没有音频监听器的情况下尝试播放声音,Unity 将会在控制台日志中填充投诉。音频监听器组件还提供了任何 3D 声音效果的精确位置。
音频源
音频源组件就像一个扬声器,控制播放任何声音效果的设置。如果剪辑是 3D 的,则此对象到音频监听器组件的位置和选择的模式决定了剪辑的音量。

以下是一个音频源组件的各种设置:
-
音频剪辑: 这是此音频源组件默认播放的声音文件。
-
静音: 这是一种快速切换正在播放的声音音量开关的方法。
-
绕过效果: 这允许用户切换应用于此音频源组件的任何特殊过滤器。
-
唤醒时播放: 这将在场景加载或对象生成时立即开始播放音频剪辑。
-
循环: 这将导致播放的剪辑在播放时重复。
-
优先级: 这决定了正在播放的文件的相对重要性。0是最重要的,最适合音乐,而256是最不重要的。根据系统,一次只能播放这么多声音。要播放的文件列表从最重要的开始,直到达到这个限制,如果声音多于限制,则排除那些值最低的文件。
-
音量: 这决定了剪辑播放的响度。
-
音调: 这调整剪辑的播放速度。
![音频源]()
-
3D 声音设置: 这包含一组特定于播放 3D 音频剪辑的设置。可以使用组末尾的图表调整音量、平衡和扩散选项。这允许玩家在接近音频源组件时创建更动态的过渡。
-
多普勒水平: 这决定了应用于移动声音的多普勒效应的程度。
-
音量衰减:这控制了音量如何随距离衰减。
对数衰减:这是在源中心短距离处声音突然且迅速衰减。
线性衰减:这是一种距离上的均匀衰减,最响亮的是在最小距离值处,最安静的是在最大距离值处。
自定义衰减:这允许您通过调整组末尾的图表来创建自定义衰减。当调整图表时,它也会自动选择。
-
如果音频监听器组件比最小距离值更近,则音频将以当前音量级别播放。在此距离之外,声音将根据衰减模式衰减。
-
声像水平:这是应用于此音频源组件的 3D 效果的百分比。这影响诸如衰减和多普勒效应等因素。
-
扩散:这调整了声音在扬声器空间中覆盖的区域量。当与一个或两个以上的扬声器一起工作时,它变得更加重要。
-
超过最大距离值,声音将停止根据组下方图表进行过渡。
-
-
2D 音效设置:这组成了特定于 2D 音频剪辑的设置。
- 2D 声像:这调整了声音从每个扬声器中均匀输出的程度,偏向左或右扬声器。
添加背景音乐
既然我们已经了解了可用的音频设置,现在是时候将这项知识付诸实践了。我们将从添加一些背景音乐开始。这将必须是一个 2D 音效,这样无论音频源组件在哪里,我们都能舒适地听到它。我们还将创建一个简短的脚本,以渐入音乐来减少声音效果对玩家的突然冲击。
是时候添加背景音乐了
让我们从一个控制我们背景音乐的单一脚本开始。
-
我们将首先创建一个新的脚本,并将其命名为
FadeIn。 -
此脚本以三个变量开始。第一个是脚本必须达到的目标音量。第二个是过渡所需的时间(秒数)。最后一个是在过渡开始时的时间。
public float maxVolume = 1f; public float fadeLength = 1f; private float fadeStartTime = -1f; -
接下来,我们使用
Awake函数。它首先查看由 Unity 自动提供的audio变量,以检查是否有附加的音频源组件。如果找不到,则销毁gameObject并退出函数。public void Awake() { if(audio == null) { Destroy(gameObject); return; } -
Awake函数最后将音量设置为0,如果尚未播放,则播放它。audio.volume = 0; if(!audio.isPlaying) audio.Play(); } -
为了使过渡随时间进行,我们使用
Update函数。它首先检查fadeStartTime变量是否小于零,如果是,则将其设置为当前时间。这允许我们避免场景初始化可能引起的卡顿。public void Update() { if(fadeStartTime < 0) fadeStartTime = Time.time; -
接下来,该功能会检查过渡的时间是否结束。如果已经结束,则将音频源组件的音量设置为
maxVolume,并销毁脚本以释放资源。if(fadeStartTime + fadeLength < Time.time) { audio.volume = maxVolume; Destroy(this); return; } -
最后,通过计算从淡入开始经过的时间量并将其除以过渡的长度来计算当前进度。进度百分比乘以
maxVolume的值,并应用于音频源组件的音量。float progress = (Time.time – fadeStartTime) / fadeLength; audio.volume = maxVolume * progress; } -
在 Unity 中,我们需要创建一个新的空
GameObject,并将其命名为背景。 -
向此对象添加我们的
淡入脚本和音频源组件。 -
如果你还没有创建,请在你的项目面板中创建一个
音频文件夹,并导入章节中包含的四个声音文件。 -
选择
背景声音文件,并在导入设置中取消选择3D 声音复选框。 -
在层次窗口中选择你的
背景对象,并将背景声音拖到音频剪辑槽中。 -
确保在音频源组件上勾选唤醒时播放和循环复选框。音量选项也需要设置为0,这样文件就可以在整个游戏中播放,但在开始时没有声音。
刚才发生了什么?
我们在我们的游戏中添加了背景音乐。为了使声音保持恒定且不具有方向性,我们利用音乐作为 2D 声音。我们还创建了一个脚本,在游戏开始时淡入音乐。这使玩家更容易过渡到游戏,防止突然的声音冲击。
尝试一下英雄 - 设置一些氛围
背景音乐可以为游戏体验增色不少。没有一些恐怖音乐,恐怖场景就几乎不会那么吓人。没有他们令人敬畏的音乐,Boss 就会显得不那么令人畏惧。为你的其他游戏寻找一些好的背景音乐。轻快愉快的音乐非常适合愤怒的小鸟,而更工业化和节奏更快的音乐则会让坦克大战游戏中的心跳加速。
创建一个警报系统
为了理解 3D 音频效果,我们将创建一个警报系统。当物体接近飞船时,警报音量会增加。3D 效果将指示物体相对于飞船的方向。这为玩家提供了当他们无法看到周围所有事物时所需的反馈。实现此效果有几种方法,但这种方法将展示我们调整音频源组件随时间变化的能力。
行动时间 - 警告玩家
在空间中的物体上附加单个脚本,当物体接近时,会警告玩家。
-
我们首先创建了一个新的脚本,并将其命名为
Alarm。 -
此脚本从一个变量开始。它将保存声音开始淡入的距离值。
public float warningDist = 100f; -
接下来,我们创建
Update函数。它首先检查是否存在音频源组件,如果不存在,则提前退出函数。audio变量持有附加的音频源组件的引用。public void Update() { if(audio == null) return; -
函数继续通过计算玩家距离来执行。因为玩家从不移动,我们可以直接使用位置到原点的距离来简化它。我们还使用
sqrMagnitude,即向量的长度的平方,因为它计算起来更快。如果对象超出范围,音量设置为0,并且函数退出。float distance = transform.position.sqrMagnitude; if(distance > warningDist * warningDist) { audio.volume = 0; return; } -
最后,我们通过将距离除以
warningDist值的平方并从 1 中减去结果来计算新的音量。这将导致在接近最大音量时出现一个平滑的曲线。float volume = 1 – (distance / (warningDist * warningDist)); audio.volume = volume; } -
我们现在需要将脚本添加到相关对象中。将
Alarm脚本和音频源组件添加到敌舰和陨石预制体中。 -
对于音频剪辑值,选择
Alarm剪辑。同时,确保唤醒时播放和循环复选框都被勾选。 -
接下来,我们不想让警报声压倒游戏中的其他声音,所以将优先级选项设置为192。
-
为了防止在对象生成时产生任何噪音,将音量选项设置为0。
-
为了让脚本完全控制音频源组件的音量,展开3D 声音设置组件。将音量衰减设置为线性衰减,将最小距离选项设置为495。
刚才发生了什么?
我们创建了一个脚本,当对象过于接近玩家时警告玩家。当它们接近玩家时,它们音频源的音量会增加。当它们远离玩家时,音量会降低。通过使用 3D 音频剪辑,我们可以引导玩家确定接近的对象来自何方。
尝试一下英雄 - 微分
我们很高兴能够知道对象何时过于接近,但我们不能在看到它之前知道它是什么。找到一些替代警报声音。对于玩家必须应对的每种类型的对象,给它一个不同的声音。这样,玩家就会知道他们是否需要开始做一些复杂的机动来躲避子弹,或者他们是否进入了一个小行星带,需要小心飞行以避免碰撞。
理解粒子系统
粒子系统可以为游戏的最终外观增添很多。它们可以呈现为火焰、魔法波浪、雨,或者你可以想象出的无数其他效果。它们通常很难制作得很好,但都是值得努力的。记住,尤其是在与移动平台一起工作时,少即是多。较大的粒子比大量的粒子更有效。如果你的粒子系统在狭小的空间中包含数千个粒子,或者为了增加效果而自我复制,你需要重新思考设计并找到更有效的解决方案。
粒子系统设置
每个粒子系统都包含大量组件,每个组件都有自己的设置。大多数可用设置都有选项可以选择为常量、曲线、随机、两个常量之间的随机和两个曲线之间的随机。常量将是一个特定的值。曲线将是一个随时间沿曲线变化的固定值。两个随机设置在相应的值类型之间选择一个随机值。一开始可能会觉得有些混乱,但当我们逐一了解它们时,它们将变得更加容易理解。
正如您将在接下来的图像和描述中看到的那样,我们将逐一了解并理解粒子系统的每个部分。
正如您将在下面的屏幕截图中所看到的,我们将逐一了解并理解粒子系统的每个部分:

-
粒子系统的第一部分,即初始模块,包含了 Unity 中每个发射器使用的所有设置。
-
持续时间: 这是发射器持续的时间。循环系统将在这段时间后重复。非循环系统将在这段时间后停止发射。
-
循环: 此复选框决定了系统是否循环。
-
预加热: 如果勾选此复选框,则将启动一个循环系统,就像它已经有机会循环一段时间一样。这对于火炬已经点燃,玩家进入房间时不应该开始的情况很有用。
-
起始延迟: 当最初触发时,这将停止粒子系统在给定秒数内发射。
-
起始寿命: 这是单个粒子开始时的秒数。
-
起始速度: 这是粒子生成时的初始移动速度。
-
起始大小: 这决定了粒子生成时的大小。总是使用较大的粒子而不是更多的粒子更好。
-
起始旋转: 这将旋转发射的粒子。
-
起始颜色: 这是粒子生成时的颜色色调。
-
重力乘数: 这给粒子提供了更多或更少的重力效果。
-
继承速度: 如果它正在移动,这将使粒子获得其变换动量的一部分。
-
模拟空间: 这决定了粒子是随游戏对象移动(即本地)还是保持在它们在世界中的位置。
-
唤醒时播放: 如果勾选此复选框,则发射器将在生成或场景开始时立即开始发射。
-
最大粒子数: 这限制了系统在单个时间点支持的粒子总数。如果粒子发射速率或其寿命足够大,以至于超过了它们的破坏速率,则此值才会发挥作用。
![粒子系统设置]()
-
-
发射模块控制粒子发射的速度。
-
速率:如果设置为时间,这是每秒创建的粒子数。如果设置为距离,这是系统移动时每单位距离创建的粒子数。
-
爆发:仅在速率选项设置为时间时使用。它允许你设置系统时间轴上的点,在这一点上会发射特定数量的粒子。
![粒子系统设置]()
-
-
形状模块控制系统如何发射粒子。
-
形状:这决定了发射点将采取的形式。每个选项都附带一些额外的值字段,用于确定其大小。
球体:这是粒子从所有方向发射的点。半径决定了球体的大小。从壳体发射指定粒子是从球体的表面还是内部发射。
半球:正如其名所示,这是球体的一半。半径和从壳体发射在这里与球体相同。
圆锥:这沿着一个方向发射粒子。角度决定了形状更接近圆锥还是圆柱。半径决定了形状发射点的尺寸。发射位置将确定粒子从哪里发射。底部从形状的底部圆盘发射。底部壳体从圆锥的底部但围绕形状的表面发射。体积将在形状内部任何地方发射,而体积壳体从形状的表面发射。
-
盒子:这从立方体形状发射粒子。盒子 X、盒子 Y和盒子 Z决定了盒子的大小。
-
网格:这允许你选择用作发射点的模型。系统的所有粒子都将从网格的表面发射。
-
随机方向:这决定了粒子的方向是由所选形状的表面法线决定,还是随机选择。
![粒子系统设置]()
-
-
生命周期内速度模块允许你在粒子生成后控制粒子的动量。
-
X、Y和Z:这些定义了粒子动量沿每个轴每秒的单位数。
-
空间:这决定了速度是应用于系统的局部变换,还是相对于世界。
![粒子系统设置]()
-
-
限制生命周期内速度模块在粒子的移动超过指定值时阻尼粒子的运动。
-
分离轴:这允许你为每个轴定义一个独特的值,并确定该值是局部还是相对于世界的。
-
速度:这是在应用阻尼之前粒子必须移动的速度
-
阻尼:这是减少粒子速度的百分比。它是一个介于零和一之间的值。
![粒子系统设置]()
-
-
生命周期内的力模块在粒子的生命周期过程中为每个粒子添加一个恒定的移动量。
-
X、Y和Z:这些定义了沿每个轴应用多少力
-
空间:这决定了力是应用于系统的变换局部还是世界空间
-
如果X、Y和Z是随机值,随机化将导致每帧随机选择应用力的量,从而实现随机值的统计平均
![粒子系统设置]()
-
-
生命周期内的颜色模块允许您在粒子生成后定义粒子过渡的颜色序列。
-
颜色随速度变化模块使粒子在速度变化时过渡到定义的颜色范围。
-
颜色:这是过渡的颜色集
-
速度范围:这定义了粒子在颜色范围的最小和最大端必须达到的速度
![粒子系统设置]()
-
-
生命周期内的大小模块在粒子的生命周期过程中改变粒子的尺寸。
-
速度大小模块根据粒子移动的速度调整每个粒子的尺寸。
-
大小:这是粒子过渡的调整
-
速度范围:这定义了每个大小的最小和最大值
![粒子系统设置]()
-
-
生命周期内的旋转模块在粒子生成后随时间旋转粒子。
-
速度随速度旋转模块使粒子在速度更快时旋转更多。
-
角速度:这是要应用的旋转次数
-
速度范围:这是如果未设置为常数,则角速度值的最大和最小范围
![粒子系统设置]()
-
-
外部力模块乘以风区对象的效果。风区模拟风对粒子系统和 Unity 树的影响。
-
碰撞模块允许粒子碰撞并与其他物理游戏世界交互。
-
如果设置为平面,您可以定义粒子要与之碰撞的多个平坦表面。这比世界碰撞处理得更快。
平面:这是一个定义要与之碰撞的表面的变换列表。粒子只会与变换的局部、正 Y 侧碰撞。任何在点另一侧的粒子将被销毁。
可视化:这提供了将平面视为实体表面或网格表面的选项。
缩放平面:这调整了可视化选项的大小。它不会影响实际碰撞表面的实际大小。
粒子半径:这是用来定义计算粒子与平面碰撞所使用的球体的大小。
-
如果设置为世界,粒子将与场景中的每个碰撞器发生碰撞。这可能会对处理器造成很大负担。
碰撞对象:这定义了将与哪些图层发生碰撞的图层列表。只有在此列表中检查的图层上的碰撞体才会用于碰撞计算。
碰撞质量:这定义了此粒子系统的碰撞计算的精确度。高将精确计算每个粒子。中将使用近似值和每帧有限的新计算。低的碰撞计算频率低于中。
如果碰撞质量设置为中或低,则体素大小决定了系统估计碰撞点的精确度。
-
减慢速度:当粒子与表面碰撞时,此功能会从粒子中移除定义的分数速度。
-
弹跳:这允许粒子保持其定义的分数速度,特别是沿着被撞击表面的法线方向。
-
寿命损失:这是生命百分比。当粒子碰撞时,这部分生命百分比将从粒子中移除。当粒子的生命随时间降至零或通过碰撞移除时,它将被移除。
-
如果碰撞后粒子的速度低于最小杀伤速度值,则粒子将被销毁。
-
如果勾选了发送碰撞消息复选框,则每帧都会向附加到粒子系统和与之碰撞的物体的脚本发出碰撞发生的警报。每帧只发送一条消息,而不是每个粒子。
![粒子系统设置]()
-
-
子发射器模块允许在系统中每个粒子的生命周期中的特定点生成额外的粒子系统。
-
出生列表中的任何粒子系统都会在粒子首次创建时生成并跟随粒子。这可以用来创建火球或烟雾轨迹。
-
碰撞列表:当粒子撞击物体时,会生成粒子系统。这可以用来生成雨滴溅起效果。
-
死亡列表:当粒子被销毁时,会生成粒子。这可以用来生成烟花爆炸。
![粒子系统设置]()
-
-
纹理图动画模块使粒子在其生命周期内翻越多个粒子。使用的纹理在渲染器模块中定义。
-
瓦片:这定义了图中的行数和列数。这将确定可用的总帧数。
-
动画:这提供了整个图和单行的选项。如果设置为单行,则使用的行可以是随机选择的,也可以通过勾选随机行复选框和行值来指定。
-
帧随时间变化:这定义了粒子在帧之间的转换方式。如果设置为恒定,系统将只使用单个帧。
-
循环次数:这是粒子在其生命周期内循环动画的次数。
![粒子系统设置]()
-
-
渲染器模块决定了粒子如何在屏幕上绘制。
-
渲染模式: 这定义了粒子应该使用哪种方法在游戏世界中定位自己。
横幅: 这将始终朝向相机。
拉伸横幅: 这将朝向相机面对粒子,但根据相机的速度、粒子的速度或特定的值拉伸它们。
水平横幅: 这在游戏世界的 XZ 平面上是平的。
垂直横幅: 这将始终朝向玩家,但始终沿 y 轴保持直线。
如果设置为 网格, 你可以定义一个用作粒子而不是平面模型的模型。
-
正常方向: 这用于通过调整每个平面的法线来对粒子进行照明和着色。值为1时,法线直接指向相机,而值为0时,法线指向屏幕中心。
-
材料: 这定义了用于渲染粒子的材料。
-
排序模式: 这决定了粒子应该按照距离或年龄的顺序绘制。
-
排序微调: 这会导致粒子系统比正常情况下更早地绘制。值越高,它将在屏幕上越早绘制。这影响系统是否出现在其他粒子系统或半透明对象之前或之后。
-
投射阴影: 这确定粒子是否会阻挡光线。
-
接收阴影: 这确定粒子是否受到其他物体投射的阴影的影响。
-
最大粒子大小: 这是单个粒子允许填充的屏幕空间总量。无论粒子的实际大小如何,它永远不会填充超过这个屏幕空间。
-
创建引擎尾迹
为了加强玩家对他们的船正在移动的印象,我们需要为船的引擎创建一些尾迹。这种排气将像船在移动一样拖出,即使它没有移动。通过使粒子系统成为组成船的物体组的一部分,引擎尾迹将移动并留下预期的粒子。
行动时间 - 添加引擎尾迹
可以仅使用粒子系统轻松添加和控制引擎尾迹。
-
首先,我们需要创建一个新的粒子系统。通过前往 Unity 编辑器的顶部并导航到GameObject | Create Other | Particle System来实现这一点。
-
将新的粒子系统重命名为
EngineTrail。 -
首先,我们来看一下初始模块。我们需要勾选循环和预加热复选框。这将使船在整个游戏中看起来像是在移动,并消除系统在创建效果时通常需要的累积。
-
接下来,我们需要控制粒子移动的距离。通过将开始寿命选项设置为3和开始速度选项设置为1来实现这一点。
-
为了保持粒子在空间中的大小和位置适当,我们需要将开始大小选项设置为0.8,并将模拟空间选择为世界。
-
现在我们转向形状模块。我们希望粒子从引擎中直线飞出。因此,我们将角度选项的值设置为0,将半径选项的值设置为0.2。
-
排气通常随着时间的推移在颜色上逐渐变淡,在密度上逐渐消散。为了达到这种效果,激活生命周期内颜色和生命周期内大小模块。
-
对于颜色选项,在渐变开始时将Alpha选项设置为0,在短距离内设置为255。至于颜色,开始时选择鲜艳的蓝色,过渡到白色,然后到灰色。
![添加引擎尾迹的行动时间]()
-
对于大小选项,选择一个线性斜率,在开始时最大,在结束时最小。这最简单的方法是在大小标签右侧点击曲线,然后在检查器窗口底部的粒子系统曲线窗口中选择从底部起的第三个选项。
![添加引擎尾迹的行动时间]()
-
现在,在场景窗口中,将
EngineTrail对象复制三次,并将它们放置在飞船引擎后面。确保将它们旋转,以便粒子从飞船中发射出来。 -
最后,将它们全部设置为玩家的飞船的子对象。如果跳过这一步,它们将不会随着飞船的移动而移动。
![添加引擎尾迹的行动时间]()
发生了什么?
我们将关于粒子系统的知识付诸实践,为我们的宇宙飞船引擎创建尾迹。因为它们是船的子对象,并在世界空间中模拟,所以它们会随着船移动,并像预期的那样逐渐消失。
尝试英雄 - 更多尾迹
敌舰也有引擎。尝试为敌舰添加尾迹。注意选择本地空间或世界空间进行模拟。因为船只移动是为了让玩家看起来在移动,所以在世界空间中模拟可能会有一些不寻常的副作用。
如果你之前为玩家添加了涡轮增压效果,现在是时候为它添加一些额外的效果了。尝试在玩家加速时改变尾迹的长度。也许当飞船加速时,它会利用一种特殊的燃料。如果它燃烧的颜色不同,那么当玩家加速时,尾迹的颜色或颜色系列也必须不同。
将其组合
到目前为止,我们学习了关于音频效果和粒子系统的基础知识。它们各自可以为场景增添很多,设定氛围,并给游戏带来独特的光泽。但是,有许多效果不能单独作为一项或另一项存在。例如,爆炸如果没有视觉和听觉效果,就根本不会那么令人印象深刻。
爆炸
当敌人爆炸时摧毁他们要满足得多。要制造一个合适的爆炸,需要粒子效果和声音效果。我们将首先创建一个爆炸预制体。然后我们将更新玩家的射击,以便在摧毁小行星和敌舰时产生爆炸。
添加爆炸的时间
一个单独的粒子系统和脚本可以让我们创建一些可以在任何地方使用的漂亮的爆炸效果。
-
我们首先需要一些新的纹理来让爆炸看起来像火焰。幸运的是,Unity 提供了多种基本的粒子纹理。要将它们包含到你的项目中,请前往 Unity 编辑器的顶部,导航到Assets | Import Package | Particles。
-
在出现的窗口中,选择导入并等待 Unity 完成导入。这个包是一个很好的资源,包括纹理和完整的粒子系统。然而,包含的所有粒子系统都使用即将被 Unity 淘汰的旧系统。尽管如此,我们仍然可以充分利用包含的材料。
-
创建一个新的
particleSystem对象并将其命名为Explosion。 -
首先,在初始模块中,我们需要让效果持续一段时间。将持续时间设置为0.5,将开始生命周期选项设置为1。
-
为了让粒子更靠近,将开始速度选项的值设置为0.5。
小贴士
当我们完成这个系统后,它将不会循环。然而,如果我们现在让它循环,更容易看到我们在处理什么。
-
接下来,我们需要更多的粒子,所以前往发射模块。将速率选项设置为120以生成适当数量的粒子。
-
爆炸通常是圆形的,所以我们需要调整形状模块。将形状设置选择为球体,并将半径选项设置为0.5。
-
接下来是生命周期内颜色变化模块。爆炸开始时很亮,然后逐渐变成棕色,随后在燃烧过程中变为黑色。对于颜色选项,从军绿色开始,然后是淡黄色,接着是中棕色,最后在结束时变为黑色。同时,让Alpha在开始时逐渐出现,在结束时逐渐消失。这样可以防止粒子突然出现和消失。
![添加爆炸的时间 - 添加爆炸]()
-
接下来,我们需要为我们的粒子创建一个火焰材质。在渲染器模块的材质设置中,选择火焰烟雾材质。你也可以在项目窗口中找到它,通过导航到Standard Assets | Particles | Sources | Materials。
-
一旦我们对系统的外观感到满意,确保在初始模块中勾选在启动时播放复选框,并且不要勾选循环复选框。
-
与小行星和敌舰一样,爆炸需要随着玩家的移动而移动。它还需要在完成发射后自行销毁。因此,创建一个新的脚本并将其命名为
Explosion。 -
这个脚本很短,只包含两个函数。第一个函数
Update检查particleSystem对象是否存在或是否已经播放完毕。如果任一条件为真,则销毁gameObject。public void Update() { if(particleSystem == null || !particleSystem.isPlaying) Destroy(gameObject); } -
第二个函数
LateUpdate简单地使用我们创建在第六章中,即移动设备的特性 – 触摸和倾斜的PlayerShip.Rotate函数,来移动爆炸效果以匹配玩家的移动。这与玩家移动时移动小行星和敌舰的方式相同。public void LateUpdate() { PlayerShip.Rotate(transform); } -
返回 Unity,并将脚本添加到
Explosion对象中。 -
接下来,向对象添加一个音频源组件。
-
对于这个组件,我们需要勾选在激活时播放复选框。同时,在3D 声音设置下选择线性衰减作为音量衰减模式,并将最小距离设置为10。
-
当然,为源音频剪辑选择爆炸声音效果。这些设置将使声音在爆炸生成时立即播放。
-
要完成爆炸效果的创建,将对象转换为预制体,并从场景中删除实例。
-
接下来,我们需要更新
TouchShoot脚本以利用爆炸效果。现在打开它。 -
首先,我们在代码中添加一个变量来保存对爆炸的引用。
public GameObject explosion; -
在使用
Physics.Raycast函数的行之后,在我们销毁射击对象之前,添加以下行。如果存在对爆炸的引用,它将使用Instantiate函数生成一个新的爆炸实例,并将其位置和旋转设置为被射击对象的位置和旋转。if(explosion != null) Instantiate(explosion, hit.transform.position, hit.transform.rotation); -
回到 Unity 中,找到玩家飞船上的
TouchShoot脚本组件实例。在新的爆炸槽中添加对Explosion预制体的引用。![行动时间 – 添加爆炸]()
刚才发生了什么?
我们引发了一场爆炸。Unity 为我们提供了一系列的粒子纹理,我们可以用它们创建出多种效果。此外,还有一些已经创建好的粒子系统,包括爆炸效果。然而,那个爆炸效果使用的是旧系统,并且很快就不会再包含在 Unity 中了。我们还更新了我们的敌舰和小行星,以便当它们被玩家摧毁时能够引发爆炸。
大胆尝试吧 – 更多类型的爆炸
一个爆炸是可以的,但奇怪的是,小行星的爆炸方式与飞船相同。不同的气体、燃料和岩石成分以不同的颜色和不同程度的强度燃烧。为围绕你的太空中的不同物体创建更多的爆炸。更改颜色和大小以适应爆炸物。此外,探索其他声音效果,以提供有关爆炸的不同听觉线索。最后,尝试创建一个多爆炸系统。也许玩家的射击会导致第一个爆炸,而连锁反应会导致武器库和发动机舱中的爆炸。为了实现这一点,查看使用子发射器,或者当飞船被摧毁时在飞船周围生成几个不同的粒子系统。
创建激光束
能够摧毁物体并看到它们爆炸是很好的。这为玩家执行简单动作提供了奖励。然而,当开枪时,无论是否击中目标,你都会期望它有反应。为此,我们将为玩家的飞船创建一种类似于枪口闪光的效果。每次他们轻触屏幕开火时,一些粒子系统都会闪烁,并播放声音效果。
行动时间 - 添加激光束
脸部闪光通常由两部分组成。第一部分是沿着枪管向前直射的直线冲击波。第二部分是围绕第一部分底部的扇形。
-
首先,创建一个新的
particleSystem对象并将其重命名为LineBlast;我们现在将开始制作第一部分。 -
闪光不会持续很长时间,因此请在初始模块中找到持续时间选项并将其设置为0.1。
-
接下来,我们需要将起始寿命选项设置为0.1,这样粒子就不会在屏幕上停留很长时间。
-
闪光不会从枪口移开,因此将起始速度选项设置为1,以保持粒子靠近。
-
粒子的大小需要与我们的飞船枪口的大小相匹配。将起始大小选项设置为0.2以保持它们较小。
-
如果我们的激光束只是白色,那就没有乐趣了,所以将起始颜色值更改为适合你的激光的适当颜色。
-
最后一个要调整的是初始模块,取消选中播放唤醒复选框,以防止系统在加载时立即触发。
-
接下来,我们需要调整发射模块。闪光是突发性的,因此将速率选项设置为0。
-
要创建爆发,点击爆发列表右侧的+号。5的值将工作得很好。
-
接下来,我们调整形状模块以沿直线发射粒子。为此,将角度选项设置为0,将半径选项设置为0.01。
-
最后,我们需要调整渲染器模块。为了拉长粒子,将渲染模式选项更改为拉伸公告板,并将长度缩放选项设置为-4.5。
-
现在我们对系统的外观感到满意,请在初始模块中取消选中循环复选框。
-
现在创建第二个
particleSystem对象,并将其重命名为SpreadBlast。 -
这些粒子应该持续与第一个系统一样长的时间。因此,在Initial模块中,将Duration选项设置为0.1,Start Lifetime设置为0.1,Start Speed设置为1,Start Size设置为0.2,并取消选中Play On Awake。
-
为了使这些粒子与线条区分开来,将Start Color值设置为略暗的颜色。
-
接下来,在Emission模块中,将Rate选项设置为0,并添加Bursts选项,其Particles值为30。
-
对于Shape模块,将Angle选项设置为60,将Radius选项设置为0.01。这导致粒子在生成时向外扩散。
-
对于Renderer模块,将Render Mode选项设置为Stretched Billboard,将Length Scale选项设置为-3。
-
最后,一旦我们对系统的外观感到满意,再次在Initial模块中取消选中Looping复选框。
-
在放置粒子系统之前,我们需要创建一个脚本。创建一个新的脚本并将其命名为
LaserBlast。此脚本将触发粒子系统和音频剪辑的播放。 -
此脚本从单个变量开始。此变量保存当脚本被指示发射时将被触发的系统列表。
public ParticleSystem[] particles = new ParticleSystem[0]; -
接下来,脚本中只有一个函数,即
Fire。它首先确保在同一个GameObject上有一个Audio Source组件。如果存在,则使用源剪辑调用PlayOneShot。此函数播放传入的文件一次,而不会阻止其他剪辑的播放。public void Fire() { if(audio != null) audio.PlayOneShot(audio.clip); -
函数继续通过遍历粒子系统列表,并使用
Play来触发它们(如果它们存在)。for(int i=0;i<particles.Length;i++) { if(particles[i] != null) particles[i].Play(); } } -
接下来,我们需要更新
TouchShoot脚本。它需要在玩家触摸屏幕时调用LaserBlast脚本中的Fire函数。为此,我们首先添加lasers变量来保存需要触发的对象列表。public LaserBlast[] lasers = new LaserBlast[0]; -
在
Update函数的开始处,我们添加didFire boolean值。这将防止激光在每个帧中触发多次。bool didFire = false; -
我们接下来在检查触摸阶段值是否等于
TouchPhase.Began的if语句之后将布尔值设置为真。didFire = true; -
在
Update函数的末尾,如果didFire boolean为真,我们将调用稍后要编写的Fire函数。if(didFire) Fire(); -
最后,对于脚本,我们添加
Fire函数。此函数简单地遍历lasers数组,并在它们存在的情况下调用它们的Fire函数。private void Fire() { for(int i=0;i<lasers.Length;i++) { if(lasers[i] != null) lasers[i].Fire(); } } -
现在我们已经拥有了所有部件,我们需要将它们组合起来。首先创建一个新的空
GameObject,并将其命名为LaserBlast。 -
接下来,将
LineBlast和SpreadBlast粒子系统设置为这个新对象的子对象。确保将它们的定位和旋转设置为0。 -
将我们的
LaserBlast脚本添加到同名对象中,并将两个粒子系统添加到脚本组件的Particles列表中。 -
接下来,向对象添加一个音频源组件。将音频剪辑值选择为激光声音效果。
-
最后,对于这个对象,展开3D 声音设置组,并将音量衰减值设置为线性衰减;否则,我们将无法听到它。
-
将激光束放置在玩家飞船的炮口前方。根据需要复制,以覆盖所有点。同时,确保它们的局部 z 轴沿着炮口向前。
-
接下来,将所有的
LaserBlast对象设置为玩家飞船的子对象,这样它们就会随着飞船移动。 -
最后,将对象添加到
TouchShoot脚本组件上的激光列表中。![行动时间 – 添加激光束]()
刚才发生了什么?
我们为玩家创建了激光束。每次玩家触摸屏幕时,它们都会被触发。这样玩家就可以知道他们在射击,即使他们没有击中任何东西。这是一个快速而短暂的效果,但为最终体验增添了大量内容。
英雄试炼 – 更多类型的激光
玩家现在可以射击激光了,敌人也需要这样做。向敌舰添加一些激光束。同时,是时候用更好的东西替换他们的球形子弹了。创建一个等离子球来替换球形子弹。爆炸的小型循环版本可以用于球体。发动机尾迹的适当着色版本可以用作球体飞向玩家时的轨迹。也许给子弹添加警报也是个好主意。这样玩家就知道他们即将被从游戏中击飞出去。
摘要
在本章中,我们学习了 Unity 中的特效,特别是音频和粒子系统。我们从了解 Unity 如何处理音频文件开始。通过添加背景音乐和警报系统,我们将所学知识付诸实践。然后,我们转向了解粒子系统,并为玩家的飞船创建了引擎尾迹。最后,我们将两项技能结合在一起,创建了爆炸和激光束。粒子系统和音频效果为游戏的最终润色和外观增添了大量内容。
在下一章中,我们将通过查看 Unity 中的优化来共同完成我们的体验。我们将查看用于跟踪性能的工具。我们还将创建自己的工具来跟踪脚本性能的特定部分。我们还将创建自己的工具来跟踪脚本性能的特定部分。我们将探索资产压缩和其他我们可以更改以最小化应用程序大小的点。最后,我们将讨论减少延迟的关键点。
第九章:优化
在前一章中,我们学习了关于我们游戏的特效。我们在太空战斗机游戏中添加了背景音乐。我们还为我们的飞船创建了引擎尾迹。通过结合音频效果和粒子系统,我们创建了一些爆炸和枪炮声。这一切共同完善了游戏体验,使我们拥有了一个看起来非常完整的游戏。
在本章中,我们探讨了我们的优化选项。我们首先查看应用程序的占用空间,以及如何减少它。然后我们转向查看游戏性能。我们查看 Unity 提供的工具,并自己创建了一个。通过使用遮挡剔除,我们可以进一步提高游戏性能。最后,我们将探讨一些可能导致延迟的关键区域以及如何最小化它们的影响。
在本章中,我们将涵盖以下主题:
-
最小化应用程序占用空间
-
跟踪性能
-
最小化延迟
-
遮挡
对于本章,我们将同时处理我们的太空战斗机和坦克大战游戏。首先打开太空战斗机项目。
最小化应用程序占用空间
游戏成功的关键之一是游戏本身的大小。许多用户会迅速卸载任何看起来不必要的应用程序。此外,所有移动应用商店都对根据应用程序本身的大小向用户供应游戏有大小限制。熟悉您用于最小化游戏大小的选项是控制游戏分发方式的关键。
在尝试最小化占用空间时,首先要注意的是 Unity 在构建游戏时如何处理资产。只有用于构建中某个场景的资产才会实际包含在游戏中。如果它不在场景本身中,或者没有由场景中的资产引用,它将不会被包含。这意味着您可能有资产的测试版本或不完整的版本;只要它们没有被引用,它们就不会影响您游戏最终构建的大小。
Unity 还允许您以您需要用于工作的格式保存您的资产。当最终构建完成时,所有资产都会转换为适合其类型的适当版本。这意味着您可以保持模型为建模程序的原始格式。或者保持您的图像为 Photoshop 文件,或者您工作的任何其他格式,当游戏构建时,它们将被适当地转换为JPG或PNG。
编辑器日志
当您最终准备好处理游戏占用空间时,您可以找出确切是什么导致您的游戏比预期要大。在控制台窗口的右上角有一个下拉菜单按钮。在这个菜单内部是打开编辑器日志。

编辑器日志是 Unity 在运行时输出信息的位置。这包括有关当前版本、许可证检查以及任何资产导入的信息。在构建完成后,日志还将包含有关游戏文件大小和包含的资产的相关详细信息。

我们可以看到最终构建方面的分解。每个资产类别都有一个大小和总构建大小的百分比。我们还提供了一份实际包含在游戏中的每个资产的列表,按其文件大小排序,在添加到构建之前。当寻找可以缩小尺寸的资产时,这些信息变得非常有用。
资产压缩
在模型的导入设置中,包括纹理和音频,有影响导入资产大小和质量的选项。一般来说,受影响的变化是质量的降低。然而,尤其是在移动设备上工作时,资产质量可以降低到低于计算机所需的水平,而在设备上差异不明显。一旦你了解了每种类型资产可用的选项,你将能够就你游戏的质量做出最佳决策。在使用这些选项中的任何一项时,寻找一个在引入不希望出现的失真之前最小化大小的设置。
模型
无论你使用什么程序或方法来创建你的模型,最终它们始终是一系列顶点位置和三角形,以及一些纹理的引用。模型的大部分文件大小来自顶点位置的列表。为了确保你游戏中的模型具有最高质量,从你选择的建模程序开始。删除任何额外的顶点和面。这不仅会在构建最终游戏时导致文件更小,还会在编辑器中工作时减少导入时间。
模型的导入设置由三个页面组成,从而提供了更多调整质量的选项。每个页面选项卡对应于模型的相应部分,使我们能够精细调整每一个。
模型选项卡
在模型选项卡上,我们可以影响网格的导入方式。当涉及到优化模型的使用时,这里有许多关键成功的选择。一旦你的游戏看起来和玩起来是你想要的样子,我们应该仔细查看以下截图所示的设置:

在模型选项卡下可用的设置解释如下:
-
网格压缩:此选项让我们选择应该应用于模型的多大压缩量。效果相当于合并顶点以减少存储网格所需的总细节量。此设置可能会在网格中引入不希望出现的奇异性。因此,始终选择不会引入任何失真的最高设置。
-
读写启用:这个选项仅在您希望在游戏运行时通过脚本操作网格时才有用。如果您永远不会用任何脚本触摸网格,请取消选中此框。虽然不会影响最终构建的大小,但这将影响运行游戏所需的内存量。
-
优化网格:这个选项会导致 Unity 重新排序描述模型的三角形列表。这始终是一个值得勾选的好选项。您可能想要取消勾选的唯一原因是如果您正在根据三角形的特定顺序操作游戏或网格。
-
生成碰撞器:这个选项几乎总是可以省略的。这个选项会将网格碰撞器组件添加到模型中的每个网格上。当在游戏中处理物理时,这些计算相对昂贵。如果可能的话,你应该始终使用一组显著更简单的盒子碰撞器和球体碰撞器。
-
生成光照贴图 UV:这个选项仅在处理需要静态阴影的对象时使用。如果对象不需要,它将引入多余的顶点信息并使资产膨胀。
-
法线:这个选项用于材料确定顶点面向哪个方向以及光照应该如何影响它。如果网格从未使用需要法线信息的材料,请确保将其设置为无。
-
切线:这个选项用于材料使用凹凸贴图和类似特殊效果来模拟细节。就像法线设置一样,如果您不需要它们,请不要导入它们。如果法线设置为无,此设置将自动变为灰色,并且不再导入。
Rig 标签
以下是一个显示Rig标签的截图:

当优化动画骨架时,实际上只有两件事需要考虑。首先,如果资产没有动画,则不要导入它。通过将动画类型设置为无,Unity 将不会尝试导入骨架或任何无用的动画。其次,要考虑的是删除任何不必要的骨骼。一旦导入 Unity,删除任何对动画或角色没有实际影响的对象。
动画标签
就像在Rig标签中一样,如果模型没有动画,则不要导入动画。在首次导入资产时取消选中导入动画框,将避免向 Unity 中的 GameObject 添加任何额外的组件。此外,如果意外添加了任何额外的动画,它们会迅速使你的应用程序变得过大。

动画标签下的设置解释如下:
-
动画压缩: 此选项调整 Unity 处理动画中多余关键帧的方式。对于大多数情况,默认选项效果良好。
-
关闭: 此选项仅应在需要高精度动画时使用。这是选择中最大且成本最高的设置。
-
关键帧减少: 此选项将根据以下错误设置减少动画使用的关键帧数量。本质上,如果一个关键帧对动画没有明显的影响,它将被忽略。
-
关键帧减少和压缩: 此选项与上一个选项相同,但还会压缩动画的文件大小。然而,在运行时,动画仍然需要与上一个选项相同数量的处理器资源来计算。
-
-
旋转误差: 此选项是在执行关键帧减少时将被忽略的关键帧之间度数差异的数量。
-
位置误差: 此选项是在执行关键帧减少时将被忽略的移动距离。
-
缩放误差: 此选项是动画中在执行关键帧减少时将被忽略的大小调整量。
纹理
在计算机图形学中处理纹理时,始终在2 的幂下工作更好。2 的幂是指任何值,其中它及其后续的一半可以均匀地除以 2,直到达到 1。这是因为它们对计算机来说计算和处理速度更快。默认情况下,Unity 会将不符合此要求的任何纹理通过缩放调整到最接近的 2 的幂。

Unity 中可用的各种纹理设置如下所述:
-
纹理类型: 此选项影响此图像将被视为哪种类型的纹理。始终最好选择最适合图像预期用途的类型。
-
纹理: 此选项是最常见和默认的设置。应将其用于您的普通模型纹理。
-
法线贴图: 此选项用于特殊效果,如凹凸贴图。使用此类型纹理的材料还需要从模型导入设置中获取法线和切线信息。
-
GUI: 如果图像将出现在 GUI 中而不是任何模型上,应使用此选项。
-
反射: 这些纹理用于创建模仿真实物体反射特性的立方体贴图。
-
Cookie: 这些纹理用于灯光,改变从灯光对象发出的光线。
-
高级: 此选项提供了对所有与导入图像相关的设置的完全控制。您只有在纹理有特殊用途时才需要此设置。
当纹理类型选项设置为高级时,读写启用框变为可用。如果计划在游戏运行时从脚本中操作纹理,则应保留勾选。如果未勾选,Unity 不会在 CPU 中维护数据副本,从而为游戏的其他部分释放内存。
-
-
生成 Mip 贴图: 此选项是另一个高级设置,允许您控制纹理较小版本的创建。当纹理在屏幕上较小时,这些版本将被使用,从而减少绘制屏幕上纹理及其使用对象所需的处理量。
-
过滤模式: 此选项适用于所有纹理类型。它影响您非常接近图像时图像的外观。"点"会使图像看起来像块状,而"双线性"和"三线性"会模糊像素。一般来说,"点"是最快的;"三线性"是最慢的,但提供最佳的外观效果。
-
最大尺寸: 此选项调整图像在游戏中使用时可以有多大。这允许您使用非常大的图像,但以适当小的尺寸导入 Unity。一般来说,大于1024的值是较差的选择;不仅因为增加了内存需求,而且因为大多数移动设备的显卡无法处理任何更大的纹理。选择可能的最小尺寸将对最终构建中纹理的足迹大小产生重大影响。
-
格式: 此选项调整图像的导入方式以及每个像素可以保留的细节程度。"压缩"是最小的,而"真彩色"提供最详细的细节。
音频
总是为游戏提供高质量的声音总是会增加游戏最终的大小。这是那些游戏无法没有的资产之一,但可能很难在合适的水平上包含。当在音频程序中处理它们时,尽量使它们尽可能短,以最小化其大小。音频导入设置都会对它们的构建大小或运行游戏所需的内存产生影响。

Unity 中可用的各种音频设置如下所述:
-
音频格式: 此设置更改文件在游戏中的存储方式。"原生"提供更高的质量,而"压缩"则导致文件大小更小。作为移动平台的一个特殊功能,由于移动设备中的一些特殊硬件,压缩音频可以在其他平台上相对更快地检索。
-
3D Sound: 此设置决定文件是否受其在游戏中的位置相对于音频监听器的影响。如果未勾选,可以避免一些计算,从而减少每帧游戏中所需的处理量。
-
强制单声道:此设置将立体声音频转换为单声道。虽然大多数设备在技术上能够播放立体声音频,但它们并不总是拥有多个扬声器以产生差异。勾选此框可以显著减小音频文件的大小,通过移除额外的音频通道。
-
加载类型:此设置影响游戏运行时用于处理音频文件的系统内存量。加载时解压缩使用最多的内存,最适合小型短声音。内存中压缩仅在文件播放时解压缩文件,使用中等数量的内存,最适合中等大小的文件。从磁盘流式传输意味着只有当前正在播放的文件部分存储在运行时内存中。这就像从互联网上流式传输视频或音乐一样。此选项最适合大型文件,但一次只能由少数人使用。
-
压缩率(kbps):此设置调整音频文件中的细节量。较小的值会减小文件大小,但也会降低音质。较大的值会导致文件大小更大,音质更好。如果您的音频已经应用的压缩量小于此处设置的值,则此设置对声音没有影响。通常,在保持所需音质水平的同时,选择最小的尺寸为最佳。
玩家设置
通过访问 Unity 的工具栏并导航到编辑 | 项目设置 | 玩家来打开您的游戏玩家设置。在平台设置中,对于 Android,我们在其他设置下有其他一些选项,这些选项会影响我们游戏的最终大小和速度。
渲染
下面的截图显示了渲染设置:

Unity 中可用的各种渲染设置解释如下:
-
当我们制作光照贴图时,我们必须将一些对象设置为静态。这告诉 Unity 这些对象永远不会移动,并允许它们进行光照贴图。它还允许 Unity Pro 用户利用静态批处理,这可以让 Unity 通过将相同对象分组来显著加快渲染时间。然后它会在多个位置渲染一个对象,而不是单独渲染每个对象。潜在地,此设置可能会增加您最终构建的大小,因为 Unity 需要保存有关您静态对象的额外信息以使其工作。
-
动态批处理与静态批处理的工作方式相同,但有两大主要区别。首先,它对 Unity Pro 和基本用户都可用。其次,它将未标记为静态的对象分组。
优化
优化设置如下截图所示:

Unity 中可用的各种优化设置解释如下:
-
API 兼容级别:此设置确定最终构建中包含哪个 .Net 函数集。.Net 2.0 将包含所有可用函数,从而产生最大的足迹。.Net 2.0 子集是函数集的一个较小部分,仅包括你编程最可能使用的那些函数。除非你需要一些特殊功能,否则.Net 2.0 子集应该是你始终选择的选项。
-
剥离级别:此设置是 Unity Pro 独有的功能。它允许你在编译之前移除所有多余的代码,从而减小最终构建的大小。系统函数被分组到称为库的内容中,以便于参考。剥离程序集将从最终构建中移除未使用的库。使用微型 mscorlib选项与前面的选项执行相同的功能,但使用库的最小化形式。虽然库的大小显著减小,但可供代码使用的功能更少。然而,除非你的游戏非常复杂,否则这不应造成差异。
-
优化网格数据:此设置将从所有未使用任何材料应用到的网格中移除额外信息。这包括法线、切线和少量其他信息。除非你有非常特殊的情况,否则始终勾选此框是个好主意。
跟踪性能
Unity 为我们提供了许多工具,使我们能够确定游戏运行的好坏。我们将首先介绍的是 Unity Pro 和基本用户都 readily 可用的工具。然而,信息相当有限,尽管仍然有用。第二个工具仅适用于 Unity Pro 用户。它提供了关于性能的更多详细信息和数据。最后,我们将创建自己的工具,使我们能够详细查看脚本的性能。
编辑器统计信息
在 游戏 窗口的右上角,有一个标有 Stats 的按钮。点击此按钮将打开一个窗口,显示有关游戏运行情况的信息。其中有一点点关于游戏运行速度的信息。窗口中的大部分信息都关注游戏的渲染效果,主要涉及当前屏幕上的对象数量、动画对象数量以及它们占用的内存量。

Unity 编辑器中的各种统计数据解释如下:
-
在 Unity 编辑器 统计信息 窗口的右上角是当前的 FPS(每秒帧数)和最后一帧渲染所需的时间(以毫秒计)。这些值不受 Unity 编辑器其他渲染的影响,尽管在编辑器中运行游戏会有轻微的性能损失。一般来说,如果你能保持游戏运行在 60 FPS 以上,你的游戏将在目标平台上运行得相当好。
-
主线程统计提供了运行帧代码并渲染到屏幕所需的时间(以毫秒为单位)。这是处理你游戏单帧所需的总时间。
-
在主线程的右侧,我们有渲染器。这个统计数字表示仅渲染帧所花费的毫秒数。这个时间已经包含在主线程的统计中。
-
绘制调用统计是必须绘制到屏幕上的唯一对象的数量。这大约等于当前相机可见的对象数量。所以,相机后面的东西不会被绘制,也不会增加这个值。
-
批处理节省统计与绘制调用的数量密切相关。我们将在稍后了解更多关于批处理的内容。但就现在而言,批处理是一种特殊的分组过程,可以减少绘制调用的数量,使游戏渲染更快。
-
最终,3D 图形中的每个模型都是由一系列三角形组成的。三角形是相机看到的和正在渲染的总三角形数。
-
模型文件中的大部分信息都与每个顶点的位置有关。顶点是相机看到的和渲染的总顶点数。每个模型的顶点数越少,渲染到屏幕上的速度就越快。
-
使用纹理的第一个数字是当前帧中使用的唯一纹理的总数。第二个是它们占用的总内存量。通过降低纹理质量或合并纹理,这个统计数字可以减少,从而使游戏运行更快。
-
渲染纹理统计是一种用于特殊效果的特殊纹理,如安全摄像头和实时反射。这个统计数字显示了它们的总可见数和所需的内存量。
-
开关统计基本上等同于渲染纹理统计所做的工作量。更少的渲染纹理和更简单的材质将减少这个数字,从而减少渲染时间。
-
屏幕是游戏窗口当前宽度和高度的像素值。它还显示了渲染该尺寸所需的内存量。较小的尺寸会导致你的游戏细节更少,但同时也使得游戏更容易渲染。
-
VRAM 使用统计提供了当前使用的视频内存的大致最小值和最大值。它还提供了当前可用的总视频内存量(括号内)。有了这个统计数字和了解目标设备中可用的视频内存量,你可以确定你游戏的图形是否足够简单,可以在该设备上运行。
-
VBO 总数统计是当前由你的游戏渲染的唯一网格的总数。你可能会使用的每个不同模型都会增加这个统计数字。
-
阴影投射器统计用于使用实时阴影时。实时阴影成本很高。如果可能的话,它们不应该在移动设备上使用。但是,如果你必须使用它们,请尽量减少投射这些阴影的对象数量。限制到足够大以至于用户可以看到阴影的移动对象。特别是小型静态对象不需要投射阴影。
-
可见骨骼网格统计是当前在摄像机视图中可见的绑定对象总数。骨骼网格通常会是你的角色以及几乎所有其他需要动画的对象。
-
动画统计提供了场景中当前正在播放的动画总数。
-
网络统计组仅在多人游戏中连接到其他玩家时才会可见。信息通常包括游戏连接了多少人以及这些连接的速度有多快。
分析器
在 Unity 的窗口|分析器工具栏下找到的分析器窗口是一个分析游戏运行情况的优秀工具。它为我们提供了系统每个部分的彩色分解以及它们正在做多少工作。这个工具唯一真正不幸的部分是它仅适用于 Unity Pro 用户。

通过首先打开分析器窗口,我们可以在窗口中播放游戏并观察工具为我们提供一个相当详细的分解,说明正在发生什么。我们可以点击任何一点,在窗口底部查看关于该帧的详细信息。提供的信息是针对你点击的通道的,CPU 使用率、渲染、内存等等。
CPU 使用率信息在尝试找出游戏处理时间过长的部分时特别有用。处理成本的峰值很容易突出。通过点击峰值,我们可以看到导致该帧昂贵的每个游戏部分是如何分解的。对于这些部分中的大多数,我们可以深入到导致问题的确切对象或函数。然而,我们只能追溯到函数级别。仅仅因为我们知道代码中问题的一般位置,分析器也不会告诉我们该函数的哪个部分导致了问题。
为了真正工作,分析器需要钩入你游戏的每个部分。这会在你游戏的速度上引入一点额外的成本。因此,在分析提供的信息时,最好考虑相对成本,而不是将每个成本视为一个确切值。
跟踪脚本性能
Unity 提供的所有这些工具都很棒,但并不总是正确的解决方案。Unity 基础用户无法访问 Profiler。此外,Profiler 和编辑器统计信息相当通用。我们可以通过 Profiler 获取更多细节,但并不总是足够。在接下来的部分,我们将创建一个特殊的脚本,能够跟踪任何脚本特定部分的性能。这绝对应该成为你开发者工具包的常规部分。
行动时间 - 跟踪脚本
我们将在《太空战士》游戏中创建这个脚本:
-
首先,我们需要一个特殊的类来跟踪我们的性能统计信息。创建一个新的脚本并将其命名为
TrackerStat。 -
要开始这个脚本,我们首先需要更改类定义行。我们不想也不需要扩展
MonoBehaviour类。所以,找到以下代码行:public class TrackerStat : MonoBehaviour {然后,将其更改为以下内容:
public class TrackerStat { -
这个脚本开始时有四个变量。第一个将用作 ID,允许我们通过提供不同的键值来同时跟踪多个脚本。第二个将跟踪跟踪的代码片段所花费的平均时间。第三个是跟踪代码被调用的总次数。第四个是代码执行所花费的最长时间。
public string key = ""; public float averageTime = 0; public int totalCalls = 0; public float longestCall = 0; -
接下来,我们还有两个额外的变量。这些变量将负责跟踪脚本执行所需的时间。第一个是跟踪开始的时间。第二个是一个标记,表示跟踪已经开始。
public float openTime = 0; public bool isOpen = false; -
这个脚本的第一个函数是
Open。当我们想要开始跟踪一段代码时调用这个函数。它首先检查代码是否已经在跟踪中。如果代码正在跟踪,它使用Debug.LogWarning向 控制台 窗口发送警告。接下来,它设置标记表示代码正在被跟踪。最后,该函数使用Time.realtimeSinceStartup跟踪代码被调用的时间,这是游戏开始以来的实际秒数。public void Open() { if(isOpen) { Debug.LogWarning("Tracking is already open. Key: " + key); } isOpen = true; openTime = Time.realtimeSinceStartup; } -
下一个函数
Close是上一个函数的相反。当达到我们想要跟踪的代码的末尾时调用它。将跟踪应该停止的时间传递给它。这样做是为了最小化执行多余代码的数量。与上一个函数一样,它检查是否正在进行跟踪,如果正在跟踪,则使用Debug.LogWarning向 控制台 窗口发送警告,并提前退出。接下来,将isOpen标志清除,设置为false。最后,计算自跟踪开始以来经过的时间,并调用AddValue函数。public void Close(float closeTime) { if(!isOpen) { Debug.LogWarning("Tracking is already closed. Key: " + key); return; } isOpen = false; AddValue(closeTime - openTime); } -
这个脚本的最后一个函数是
AddValue。这个函数接收callLength参数,即跟踪的代码片段所花费的时间长度。然后它使用一些数学运算将值添加到averageTime。接下来,函数将当前的longestCall与新值进行比较,并选择最长的值。最后,函数增加totalCalls的值。public void AddValue(float callLength) { float totalTime = averageTime * totalCalls; averageTime = (totalTime + callLength) / (totalCalls + 1); longestCall = longestCall < callLength ? callLength : longestCall; totalCalls++; } -
接下来,我们需要创建另一个新的脚本,并将其命名为
ScriptTracker。此脚本将允许我们进行实际性能跟踪。 -
此脚本从单个变量开始。此变量维护所有当前正在跟踪的统计数据。注意这里使用
static的用法;它允许我们轻松地从游戏的任何位置更新列表。private static TrackerStat[] stats = new TrackerStat[0]; -
此脚本的第一函数
Open允许我们开始跟踪代码执行。它使用static标志,因此可以从任何脚本轻松调用此函数。函数接收一个key值,允许我们分组跟踪调用。函数首先创建一个变量来保存开始跟踪的统计数据的index。接下来,它遍历当前的一组stats以查找匹配的key值。如果找到,则更新index变量并退出循环。public static void Open(string key) { int index = -1; for(int i=0;i<stats.Length;i++) { if(stats[i].key == key) { index = i; break; } } -
Open函数继续检查是否找到了统计数据。如果我们在当前stats的整个循环中遍历并且无法找到匹配的key,则index变量将只会小于零。如果没有找到,我们调用AddNewStat(稍后创建),以创建用于跟踪的新统计数据。然后,将index设置为新统计数据的索引。最后,通过使用统计数据的Open函数来触发统计数据开始跟踪。if(index < 0) { AddNewStat(key); index = stats.Length – 1; } stats[index].Open(); } -
AddNewStat函数接收要创建的统计数据的key。它首先将stats列表存储在一个临时变量中,并将stats列表的尺寸增加一个。然后,将每个值从temp列表转移到更大的stats列表中。最后,创建一个新的统计数据,将其分配给stats列表的最后一个槽位,并设置key。private static void AddNewStat(string key) { TrackerStat[] temp = stats; stats = new TrackerStat[temp.Length + 1]; for(int i=0;i<temp.Length;i++) { stats[i] = temp[i]; } stats[stats.Length – 1] = new TrackerStat(); stats[stats.Length – 1].key = key; } -
接下来,我们有
Close函数。此函数接收要关闭的统计数据的key值。它首先找到函数被调用的时刻,以最小化跟踪的额外代码量。然后,它通过遍历stats列表来查找匹配的key。如果找到,调用统计数据的Close函数并退出函数。如果没有找到匹配项,调用Debug.LogError向 控制台 窗口发送错误消息。public static void Close(string key) { float closeTime = Time.realtimeSinceStartup; for(int i=0;i<stats.Length;i++) { if(stats[i].key == key) { stats[i].Close(closeTime); return; } } Debug.LogError("Tracking stat not found. Key: " + key); } -
此脚本的最后一个静态函数是
Clear。它仅清空统计数据列表,使其准备好进行新的跟踪。public static void Clear() { stats = new TrackerStat[0]; } -
脚本的最后一步是
OnGUI函数。这个函数将允许我们在游戏进行时查看我们的统计数据。在其中,我们大量使用GUILayout类及其函数。GUILayout自动排列各种 GUI 元素,使我们能够花费更少的时间排列,更多的时间分析。我们首先使用BeginVertical开始一个元素的垂直列表。使用BeginHorizontal开始一个元素的水平列表。然后使用Label函数为我们的统计数据每一行创建标题。我们使用GUILayout.Width函数为每个标签指定一个特定的宽度,使布局看起来更加美观。接下来,调用EndHorizontal来关闭水平列表。每个BeginHorizontal的调用都必须与一个EndHorizontal相匹配,否则 Unity 将会提出许多抱怨。public void OnGUI() { GUILayout.BeginVertical(); GUILayout.BeginHorizontal(); GUILayout.Label("Key", GUILayout.Width(150)); GUILayout.Label("Average", GUILayout.Width(100)); GUILayout.Label("Total", GUILayout.Width(50)); GUILayout.Label("Longest", GUILayout.Width(100)); GUILayout.EndHorizontal(); -
接下来,我们遍历我们的统计数据列表。对于每一个,我们创建一个水平列表,并使用
Label在屏幕上绘制每个统计数据。ToString函数用于将数字转换为字符串,这是标签所需的。for(int i=0;i<stats.Length;i++) { GUILayout.BeginHorizontal(); GUILayout.Label(stats[i].key.ToString(), GUILayout.Width(150)); GUILayout.Label(stats[i].averageTime.ToString(), GUILayout.Width(100)); GUILayout.Label(stats[i].totalCalls.ToString(), GUILayout.Width(50)); GUILayout.Label(stats[i].longestCall.ToString(), GUILayout.Width(100)); GUILayout.EndHorizontal(); } -
OnGUI函数通过创建一个按钮结束,该按钮在被点击时调用Clear函数。最后,调用EndVertical函数来结束元素的垂直列表。每个BeginVertical的调用都必须与一个EndVertical的调用相匹配,就像水平列表一样。if(GUILayout.Button("Clear")) Clear(); GUILayout.EndVertical(); } -
要测试这些脚本,打开你的
PlayerShip脚本。在Rotate函数的开始处添加以下行以开始跟踪运行所需的时间。ScriptTracker.Open("PlayerShip_Rotate"); -
在
Rotate函数的末尾,我们需要调用与相同键的Close函数。ScriptTracker.Close("PlayerShip_Rotate"); -
最后,创建一个空的游戏对象,并将你的
ScriptTracker脚本添加到其中。开始游戏并查看结果。![执行动作 – 跟踪脚本]()
刚才发生了什么?
我们创建了一个用于测试代码特定部分的工具。通过将任何代码块包裹在函数调用中并发送一个唯一的 ID,我们可以确定执行代码所需的时间。通过平均调用脚本,并包裹不同的代码部分,我们可以确定脚本中哪些部分完成得最慢。我们还可以找出代码部分是否被调用得太多。这两种情况都是开始寻找以减少处理和延迟的理想点。
在部署你的游戏之前,务必删除对这个工具的所有引用。如果留在最终关卡中,它可能会给 CPU 增加不必要的负担。这种对游戏的负面影响可能会使游戏无法玩。始终记得清除任何仅用于编辑器调试的工具的使用。
减少延迟
延迟是那些用来描述应用程序运行速度低于预期的不明确概念之一。作为开发者,我们不断努力提供尽可能高质量的用户体验,同时保持用户期望的速度和响应性。这本质上取决于用户设备上的处理器是否能够处理提供游戏体验的成本。你游戏中的一些简单对象会导致快速处理。几个复杂对象将消耗最多的处理资源。
遮挡
遮挡非常适合拥有大量对象的游戏。在其基本形式中,任何在相机两侧或后面的对象都不可见,因此不会被绘制。在 Unity Pro 中,我们能够设置遮挡剔除。这将计算相机实际上可以看到的内容,不会绘制任何被遮挡的对象。在使用这些工具时,需要达到一个平衡。计算不可见内容的成本需要低于仅绘制这些对象的成本。作为一个经验法则,如果你有很多较小的对象,它们经常被较大的对象遮挡,那么遮挡剔除是正确的选择。
行动时间 – 隐藏坦克
我们将向坦克大战游戏添加遮挡剔除,因为它是有足够大物体可以遮挡视线的唯一游戏:
-
因此,现在打开坦克大战游戏。如果你完成了挑战并添加了额外的碎片和障碍物,这一部分对你尤其有效。
-
通过访问 Unity 的工具栏并导航到窗口 | 遮挡剔除来打开遮挡窗口。此窗口是您修改与游戏中的遮挡相关的各种设置的主要访问点。
-
切换到烘焙页面,我们可以查看与遮挡剔除相关的选项。
![行动时间 – 隐藏坦克]()
-
技术: 此设置将确定在设置遮挡剔除时使用哪种方法。
仅 PVS: 此设置将仅计算场景中的静态对象以应用遮挡剔除。这个选项对处理器的压力最小,但只有在场景中移动对象非常少的情况下才适用。
PVS 和动态对象: 此设置将预先计算相机可以看到哪些对象。对于动态对象,系统将创建传送门。它们用于剔除位于传送门两侧且从相机视图中不可见的对象。
自动传送门生成: 此设置将基于传送门剔除静态和动态对象。虽然提供了最高的准确性,但此选项对处理器的成本也最高。
视图单元格大小: 此设置设置遮挡计算的详细程度。较小的值将导致更好的剔除,但会导致文件大小增加以存储额外的信息。
近裁剪面和远裁剪面:这些设置被系统用来估计相机在任何空间点可以看到的内容。它们应该设置为游戏中所有相机中最小的近裁剪面和最大的远裁剪面。
内存限制:当选择任一PVS 技术时,此设置会被使用。它有助于指导可以放入计算中的细节程度。
-
-
选择技术为PVS 和动态对象,视图单元格大小为
5。 -
为了使遮挡系统与动态对象一起工作,我们需要设置若干遮挡区域。要创建它们,创建一个空的GameObject,并在 Unity 的工具栏下组件 | 渲染 | 遮挡区域中添加一个遮挡区域组件。
-
它们需要覆盖任何动态对象将位于的区域。创建并定位足够多的区域来覆盖我们游戏中的街道。它们的大小可以像使用盒子碰撞体组件时一样编辑。务必使它们足够高,以覆盖所有目标。
![遮挡坦克的时间 - 遮挡坦克]()
-
在遮挡窗口的底部点击烘焙。Unity 编辑器的右下角将出现一个进度条,告诉你计算还需要多长时间。这个过程通常需要相当长的时间,尤其是当你的游戏变得越来越复杂时。
-
当烘焙过程完成后,遮挡窗口应该已经切换到可视化选项卡,并且你的场景窗口中应该已经选择了相机。如果没有,请现在选择它们。在场景视图中,Unity 将为我们预览遮挡剔除的工作情况。只有那些可以看到的对象才会可见,其余的将被关闭。
![遮挡坦克的时间 - 遮挡坦克]()
发生了什么事?
我们已经了解了设置遮挡剔除的基本过程。我们查看遮挡窗口,并了解了那里的设置。遮挡剔除对于减少场景中的绘制调用数量非常有用。然而,这种减少需要与存储和检索遮挡计算的成本相平衡。这种平衡是通过选择适当的技术和合适的视图单元格大小来实现的。现在尝试不同的值,找到一个既提供适当细节又不过度提供信息的单元格大小。
需要记住的要点
以下是一份处理和避免游戏中卡顿的技巧列表。并非所有这些技巧都适用于你制作的每个游戏,但它们对于每个项目都是值得记住的:
-
在创建材质时,如果可能的话,避免使用透明着色器。它们渲染起来稍微昂贵一些。而且,如果你避免使用它们,你可以在处理深度排序时节省很多麻烦。
-
每个对象使用一种材料。你的游戏中调用绘制的次数越多,每一帧渲染所需的时间就越长。每个网格都会根据其上的材料绘制一次,即使材料看起来没有做任何事情。通过每个对象使用一种材料,尤其是在移动平台上,你可以最小化调用绘制的次数并最大化渲染速度。
-
在可能的情况下合并纹理。你制作的每个纹理并不一定会利用整个图像。每当可能时,将同一场景中对象的纹理合并在一起。这最大化了你对图像的有效使用,同时减少了最终构建的大小和利用这些纹理所需的内存量。
-
使用空 GameObject 在层次结构中分组对象。虽然这并不是专门用于减少延迟,但它会使你的项目更容易操作。特别是对于大型和复杂的关卡,你将能够花费更少的时间在场景中的对象中搜索,更多的时间制作出优秀的游戏。
-
控制台窗口是你的朋友。在担心你的游戏无法工作之前,首先查看 Unity 中的控制台窗口或底部的栏。两者都会显示 Unity 可能对你当前游戏设置的任何不满。这里的信息对于指出解决问题的正确方向非常有用。如果你对消息的含义感到不确定,请在 Google 上搜索该消息,你应该能够轻松地从许多其他 Unity 用户那里找到解决方案。如果你的代码似乎不起作用,而 Unity 没有对此提出抱怨,请使用
Debug.Log函数将消息打印到控制台。这将让你找到代码可能意外退出的地方,或者不是应有的值。 -
设备测试很重要。在编辑器中工作很棒,但没有什么能比在目标设备上测试更接近真实情况了。你可以在设备上更好地感受到你的游戏性能。编辑器总是引入一定量的额外处理开销。此外,你正在工作的电脑总是比你可能打算部署的移动设备更强大。
摘要
在本章中,我们学习了在 Unity 中进行优化的选项。我们首先查看了一些设置,这些设置用于我们游戏中使用的资产,以在保持质量的同时降低文件大小。接下来,我们了解了一些影响整个游戏的设置。之后,我们探讨了跟踪游戏性能的选项。我们首先查看了一些 Unity 提供的用于跟踪性能的工具。然后,我们创建了一个用于详细跟踪脚本性能的工具。然后,我们查看了一些减少游戏延迟的选项,包括利用遮挡剔除。现在我们已经了解了所有这些工具和选项,请检查我们创建的游戏并进行优化。让它们变得尽可能好。
在这本书中,我们学到了很多。我们从学习 Unity、Android 以及如何使它们协同工作开始。我们的旅程继续通过探索 Unity 的 GUI 系统并创建一个井字棋游戏。然后,我们学习了任何游戏都需要的基本资源,同时开始创建一个坦克大战游戏。我们的坦克大战游戏通过添加一些特殊相机效果和一些光影效果而扩展。通过引入一些敌人并让他们追逐玩家,我们完成了坦克大战游戏的创建。我们的太空战斗机游戏让我们了解了可以在我游戏中利用的触摸和倾斜控制。短暂的休息后,我们创建了一个愤怒的小鸟克隆版,同时学习物理和 Unity 中 2D 游戏的可能。然后,我们回到太空战斗机游戏,通过添加声音和粒子效果来增加一些细节。最后,通过学习优化我们的游戏,我们的旅程画上了句号。感谢您阅读这本书。享受您与 Unity 一起的经历,并创建您一直梦想中的精彩游戏。
附录 A. 快速测验答案
第五章,四处走动 – 路径查找和人工智能
快速测验 – 理解敌人
| Q1 | 3 |
|---|---|
| Q2 | 2 |
| Q3 | 1 |
第六章,移动设备的特性 – 触摸和倾斜
快速测验 – 理解 Android 组件
| Q1 | 2 |
|---|---|
| Q2 | 1 |
| Q3 | 1 |
| Q4 | 1 |
| Q5 | 3 |

























































































浙公网安备 33010602011771号