Unity-安卓游戏开发学习手册-全-

Unity 安卓游戏开发学习手册(全)

原文:zh.annas-archive.org/md5/2F967148E2CB27E3CC5D9AF5E1B4F678

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在这本书中,我们探索了不断扩展的移动游戏开发世界。使用 Unity 3D 和 Android SDK,我们学习如何创建移动游戏的各个方面,同时利用 Unity 5.0 和 Android L 的新功能。每一章都探索了开发谜题的一个新部分。通过探索移动平台开发的特殊功能,书中每个游戏的设计都是为了提高您对这些功能的理解。我们将在书中完成总共四个完整的游戏,以及您需要创造更多游戏的全部工具。

我们将要制作的第一款游戏是井字游戏。这个游戏的功能与经典的纸质版本完全一样。两个玩家轮流在网格中填入他们的符号,第一个连成一线的三个相同符号的人获胜。这是探索我们在 Unity 中拥有的图形界面选项的完美游戏。通过学习如何在屏幕上添加按钮、文本和图片,您将拥有添加到任何游戏中可能需要的任何界面的理解和工具。

我们将要制作的下一个游戏是坦克大战。在这个游戏中,玩家控制一辆坦克在小城市中四处行驶,射击目标和敌人。这个游戏横跨三个章节,让我们能够探索为 Android 平台制作游戏的各种关键点。我们从创建一个城市和使玩家的坦克通过我们在制作井字游戏时学到的控制方式移动开始。我们还创建并动画化玩家将射击的目标。在游戏的第二部分,我们增加了一些照明和特殊的摄像机效果。到了章节末尾,环境看起来非常棒。在游戏制作的第三部分,我们创建了一些敌人。利用 Unity 的强大功能,这些敌人在城市中追逐玩家并在靠近时攻击他们。

第三个游戏是流行的移动游戏的简单克隆。利用 Unity 的物理系统,我们能够创建结构和向它们投掷鸟类。推倒结构以获得分数,摧毁目标猪以赢得关卡。我们还探索了一些 2D 游戏和 Unity 的 2D 管线特有的功能,例如视差滚动背景和使用精灵。我们通过创建关卡选择菜单和保存高分来完成章节和游戏。

最后,我们将创建一个类似猴子球风格的游戏。这个游戏涉及使用移动设备的特殊输入来控制球体的移动和玩家与世界的互动。当玩家的设备倾斜时,他们将能够引导猴子在关卡中移动。当他们触摸屏幕时,可以对游戏中的敌人造成伤害,并最终收集分散在各处的香蕉。这个游戏还展示了如何包含每个游戏完成外观所必需的特殊效果。当收集香蕉时,我们会制造爆炸效果,当猴子移动时,会产生尘土尾迹。同时,我们还为触摸和爆炸音效添加了声音效果。

我们通过探讨优化来结束这本书。我们探索了 Unity 的所有优秀特性,甚至创建了一些我们自己的功能,以使我们的游戏尽可能运行得更好。我们还花了一些时间了解如何尽量减小资源文件的大小,同时最大化它们在游戏中的外观和效果。在这一刻,我们的旅程结束了,但我们将拥有四个几乎准备投放市场的优秀游戏。

这本书涵盖的内容

第一章,向 Unity 和 Android 问好,探讨了 Android 平台和 Unity 3D 游戏引擎的功能列表,并解释了它们为何是开发的首选。我们还介绍了开发环境的设置,并为你的设备和模拟器创建了一个简单的 Hello World 应用程序。

第二章,看起来很棒 - 图形界面,详细介绍了图形用户界面。通过创建一个井字游戏,你可以在使界面看起来令人愉悦的同时学习用户界面知识。

第三章,任何游戏的核心 - 网格、材质和动画,探讨了如何在 Unity 中利用网格、材质和动画。通过创建一个坦克大战游戏,我们涵盖了玩家在玩游戏时所看到的核心内容。

第四章,布置舞台 - 摄像头效果和照明,解释了 Unity 中可用的摄像头效果和照明选项。通过添加阴影、光照图、距离雾和天空盒,我们的坦克大战环境变得更加动态。通过利用特殊的摄像头效果,我们为玩家创造了额外的反馈。

第五章,四处移动 - 路径查找和 AI,展示了在我们的坦克大战游戏中创建具有挑战性的敌人。我们探索了路径查找和人工智能,为玩家提供了比静止的假目标更有意义的攻击目标。

第六章, 移动设备的特色 - 触摸和倾斜, 涵盖了使现代移动设备特别的功能。我们创建了一个类似 Monkey Ball 风格的游戏,以理解触摸界面和倾斜控制。

第七章, 利用你的重量 - 物理和 2D 相机, 展示了如何创建一个类似 Angry Birds 游戏,同时短暂地休息一下 Monkey Ball 游戏。这里也探讨了物理和 Unity 的 2D 管线。

第八章, 特效 - 声音和粒子, 将我们带回到 Monkey Ball 游戏,添加特殊效果。声音效果和粒子的加入使我们能够创建更加完整的游戏体验。

第九章, 优化, 涵盖了 Unity 3D 中的优化。我们讨论了使我们的坦克大战和 Monkey Ball 游戏尽可能高效的好处和成本。

阅读本书所需的准备

在整本书中,我们将同时使用 Unity 3D 游戏引擎和 Android 平台。正如你在上一节所看到的,我们将在第一章介绍 Unity 和 Android SDK 的获取和安装。为了最大限度地利用本书,你需要有一个运行 Android 系统的设备;一个能良好工作的手机或平板电脑。本书的部分内容涵盖了只有在 Unity 专业版中才有的功能。为了简化起见,我们将假设你使用的是 Windows 电脑。此外,书中代码是用 C#编写的,尽管每章项目的 JavaScript 版本仅供参考。为了充分利用各章节项目提供的模型,你需要 Blender,这是一个免费的建模程序,可在www.blender.org获取。你还需要一个图片编辑程序;Photoshop 和 Gimp 都是不错的选择。你需要像 Blender 这样的建模程序和像 Photoshop 或 Gimp 这样的图像编辑程序来创建和处理你自己的内容。我们还建议你获取一个可以创建或获取音频文件的来源。本书提供的所有音频文件都可以在www.freesound.org找到。

本书的目标读者

对于那些刚接触使用 Unity 5.0 和 Android L 进行游戏开发和移动开发的新手来说,这本书将非常合适。那些通过实际例子而不是枯燥的文档最能学习好的读者会发现每个章节都很有用。即使你几乎没有编程技能,这本书也能让你入门,学习编程和游戏开发的一些概念和标准。

编写约定

在这本书中,你会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理程序如下所示:"CheckVictory函数遍历游戏中胜利的可能组合。"

一段代码如下设置:

public void NewGame() {
  xTurn = true;
  board = new SquareState[9];
  turnIndicatorLandscape.text = "X's Turn";
}

任何命令行输入或输出都如下编写:

adb kill-server
adb start-server
adb devices

新术语重要词汇会以粗体显示。你在屏幕上看到的词,例如菜单或对话框中的,会在文本中以这样的形式出现:"接着点击下载适用于 Windows 的 SDK 工具按钮。"

注意

警告或重要提示会以这样的方框显示。

小贴士

小技巧会以这样的形式出现。

读者反馈

我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。

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

如果你在一个主题上有专业知识,并且有兴趣撰写或参与书籍编写,请查看我们的作者指南:www.packtpub.com/authors

客户支持

既然你现在拥有了 Packt 的一本书,我们有一些事情可以帮助你最大限度地利用你的购买。

下载示例代码

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

下载本书的颜色图片

我们还为你提供了一个 PDF 文件,其中包含本书中使用的截图/图表的颜色图片。颜色图片将帮助你更好地理解输出的变化。你可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/LearningUnityAndroidGameDevelopment_Graphics.pdf

勘误

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

要查看之前提交的勘误信息,请访问 www.packtpub.com/books/content/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%的情况下,你甚至不需要使用仅限专业版的功能。

在选择游戏引擎时,尤其是在移动市场,最大的担忧之一是它将给最终构建的大小增加多少体积。大多数游戏引擎都相当庞大。利用 Unity 的代码剥离功能,项目的最终构建大小可以变得相当小。代码剥离是 Unity 从编译库中移除每一块多余代码的过程。一个为 Android 编译的空白项目,如果使用完全的代码剥离,最终的大小大约是 7 兆字节。

Unity 最酷的功能之一可能是它的多平台兼容性。通过单个项目,可以构建适用于多个不同的平台。这包括能够同时针对移动设备、PC 和游戏机进行开发。这使你能够专注于真正的问题,比如处理输入、分辨率和性能。

过去,如果一家公司希望将产品部署在多个平台上,他们几乎需要将开发成本翻倍,以便实质上重新编写游戏。每个平台都有自己独特的逻辑和语言,至今仍是如此。得益于 Unity,游戏开发变得前所未有的简单。我们可以使用简单快捷的脚本开发游戏,让 Unity 处理复杂的平台转换工作。

Unity – 群雄中的最佳选择

当然,还有其他几个游戏引擎可以选择。首先想到的两个主要选择是cocos2d虚幻引擎。虽然这两个都是极佳的选择,但在某些方面你可能觉得它们略有不足。

《愤怒的小鸟》所使用的引擎 cocos2d,可能是你下一个移动平台热门游戏的绝佳选择。然而,正如其名所示,它基本上仅限于 2D 游戏。游戏在 cocos2d 中可能看起来很棒,但如果你想要加入第三个维度,将三维效果加入 cocos2d 可能会有些棘手;这时,你可能需要选择一个新的游戏引擎。cocos2d 的第二个主要问题是它的基础性。任何用于构建或导入资源的工具都需要从头开始创建,或者需要找到现成的。除非你有足够的时间和经验,否则这可能会严重拖慢开发进度。

接下来是大型游戏开发的主流选择,虚幻引擎。这款游戏引擎已经被开发者们成功使用多年,为世界带来了众多伟大的游戏,其中不乏《虚幻竞技场》和《战争机器》等佳作。然而,这些基本上都是主机和电脑游戏,这正是该引擎的根本问题所在。虚幻引擎非常庞大且功能强大,对于移动平台而言,对其优化程度有限。它一直存在同样的问题;它会让项目及其最终构建变得庞大。虚幻引擎的另一个主要问题是它作为第一人称射击游戏引擎的僵化性。虽然技术上可以用它来创建其他类型的游戏,但这样的任务既漫长又复杂。在完成这样的壮举之前,必须对底层系统有一个深入的了解。

总的来说,Unity 在游戏引擎中绝对占有一席之地。或许,你已经发现了这一点,这也是你阅读这本书的原因。但是,选择 Unity 进行游戏开发仍有众多充分的理由。Unity 项目的最终效果可以与 AAA 级作品相媲美。最终构建的负担和体积都很小,这对于移动平台来说非常重要。系统的开放性足以让你创造任何你想要的游戏类型,而其他引擎往往局限于单一类型的游戏。此外,如果在项目生命周期的任何阶段,你的需求发生了变化,增加、移除或更改目标平台的选择也非常容易。

了解安卓的卓越之处。

用户手中拥有超过 3000 万台设备,为何不选择安卓平台作为你下一个移动热作的目标呢?苹果可能凭借 iPhone 的轰动效应走在了前面,但说到智能手机技术,安卓绝对领先一步。它最出色的特性之一就是其开放性,你可以深入了解手机是如何工作的,无论是从物理层面还是技术层面。如果需要,用户几乎可以在所有安卓设备上更换电池和升级 micro SD 卡。将手机连接到电脑也不必是一件麻烦事;它完全可以作为可移动存储媒体使用。

从开发成本的角度来看,安卓市场同样具有优势。其他移动应用商店需要大约 100 美元的年注册费。有些还对一次可以注册用于开发的设备数量有限制。而谷歌 Play 市场的注册费为一次性 25 美元,而且完全不必担心你用于开发的安卓设备数量或类型。

一些其他移动开发工具的缺点之一是,在获得 SDK 访问权限之前,你需要支付年度注册费用。有些工具在查看其文档之前,需要注册和付费。Android 则更加开放和易于接触。任何人都可以免费下载 Android SDK。文档和论坛完全开放,无需支付任何费用即可查看。这意味着 Android 的开发可以更早开始,从一开始就可以将设备测试作为其中的一部分。

了解 Unity 和 Android 如何协同工作。

由于 Unity 以通用方式处理项目和资源,因此无需为多个目标平台创建多个项目。这意味着你可以轻松使用免费的 Unity 版本开始开发,并针对个人电脑。然后,在稍后的时间,你可以通过点击一个按钮将目标切换到 Android 平台。也许,在你的游戏发布后不久,它就在市场上大受欢迎,有强烈的呼声要将它带到其他移动平台。只需再点击一个按钮,你就可以轻松地将目标定位为 iOS,而无需更改项目中的任何内容。

大多数系统需要经过一系列冗长且复杂的步骤才能在设备上运行你的项目。本书的第一个应用程序,我们将经历这个过程,因为了解它是很重要的。然而,一旦你的设备被设置并得到 Android SDK 的识别,只需点击一个按钮,Unity 就能构建你的应用程序,将其推送到设备上并开始运行。对于一些开发者来说,没有什么比尝试将应用程序安装到设备上更让人头疼的了。Unity 简化了这个过程。

通过添加免费的 Android 应用程序Unity Remote,无需经过整个构建过程,简单轻松地测试移动输入。在开发过程中,最让人烦恼的事情之一就是每次需要测试一个小调整时,都要等待 5 分钟的构建时间,尤其是在控制和界面方面。经过前十几次的小调整后,构建时间开始累积。Unity Remote 使得在不点击构建按钮的情况下,简单轻松地测试所有内容成为可能。

这里有三个主要的原因说明为什么 Unity 与 Android 配合得很好:

  • 通用项目

  • 一键构建过程

  • Unity 远程

当然,我们还可以提出更多关于 Unity 和 Android 如何协同工作的优点。然而,这三个原因是节省时间和金钱的主要因素。你可能拥有世界上最好的游戏,但如果构建和测试需要 10 倍的时间,那还有什么意义呢?

Unity 专业版与基础版之间的区别

Unity 提供两种授权选项:专业版(Pro)和基础版(Basic),可在store.unity3d.com找到。为了跟随本书的大部分内容,只需使用 Unity Basic 即可。然而,第四章《设置舞台 - 摄像头效果与光照》中的实时阴影以及第九章《优化》中讨论的一些优化功能将需要 Unity Pro。如果你还没有准备好花费 3000 美元购买带有 Android 扩展的完整 Unity Pro 授权,还有其他选择。Unity Basic 是免费的,并附有 Unity Pro 30 天的免费试用期。这个试用版是完整无缺的,就像你购买了 Unity Pro 一样,唯一的缺点是游戏右下角会有一个水印,标注着仅供演示使用。你也可以在以后升级你的授权。Unity Basic 免费提供移动平台选项,而 Unity Pro 需要为每个移动平台购买 Pro 扩展。

授权比较概览

授权比较可以在unity3d.com/unity/licenses找到。本节将介绍 Unity Android Pro 和 Unity Android Basic 之间的具体差异。我们将在以下各点探讨这些功能是什么以及它们各自有多有用:

这个功能是 Unity 内置的寻路系统。它允许角色在游戏中从一个点找到到另一个点的路径。只需在编辑器中烘焙你的导航数据,并在运行时让 Unity 接管即可。直到最近,这还是只有 Unity Pro 才有的功能。现在在 Unity Basic 中唯一受限的部分是使用非网格链接。唯一需要它们的时候,就是你希望你的 AI 角色能够跳跃穿过,或在缺口周围导航时。

LOD 支持

LOD(即细节层次)允许你根据物体与摄像头的距离来控制网格的复杂度。当摄像头靠近一个物体时,你可以渲染一个充满细节的复杂网格。当摄像头远离该物体时,你可以渲染一个简单的网格,因为所有的细节反正也看不到。Unity Pro 提供了一个内置的系统来管理这一点。然而,这也是可以在 Unity Basic 中创建的另一个系统。无论你是否使用 Pro 版本,这对于游戏效率来说都是一个重要的功能。通过在远处渲染较不复杂的网格,一切都可以渲染得更快,为精彩的游戏玩法留出更多空间。

音频过滤器

音频过滤器允许你在运行时为音频剪辑添加效果。例如,你可能为你的角色创造了沙砾脚步声。角色正在奔跑,我们可以清晰地听到脚步声,突然他们进入了一个隧道,一个太阳耀斑击中,造成了时间扭曲,放慢了一切。音频过滤器能够让我们扭曲沙砾脚步声,使其听起来像是从隧道内部传来的,并且被时间扭曲所放慢。当然,你也可以让音频师创造一组新的时间扭曲中的隧道沙砾脚步声,但这可能会使游戏中的音频数量翻倍,并限制我们在运行时能有多动态。我们要么播放时间扭曲的脚步声,要么不播放。音频过滤器可以让我们控制时间扭曲对我们的声音影响有多大。

视频播放与流媒体

在处理复杂或高清晰度的过场动画时,能够播放视频变得非常重要。特别是在移动设备目标中,将它们包含在构建中可能需要很多空间。这就是这个特性中流媒体部分的作用所在。这个特性不仅让我们播放视频,还允许我们从互联网上流式传输视频。然而,这个特性有一个缺点。在移动平台上,视频必须通过设备的内置视频播放系统。这意味着视频只能以全屏播放,不能用作如电视模型上移动图片等效果的材料。从理论上讲,你可以将视频分解为每一帧的单独图片,并在运行时翻阅它们,但由于构建大小和视频质量的原因,不推荐这样做。

使用资源包实现完整的流媒体功能

资源包是 Unity Pro 提供的一个非常好的特性。它们允许你创建额外的内容,并在不需要游戏更新的情况下将其流式传输给用户。你可以添加新角色、关卡,或者你能想到的几乎任何其他内容。它们唯一的缺点是你不能添加更多代码。功能无法改变,但内容可以。这是 Unity Pro 最好的特性之一。

10 万美元的营业额

这不是一个特性,而更像是一个指导原则。根据 Unity 的最终用户许可协议,任何在前一个财年中收入达到 10 万美元的团体或个人都不能许可 Unity 的基本版本。这意味着,如果你赚了很多钱,你就必须购买 Unity Pro。当然,如果你赚了这么多钱,你可能不会有问题地负担得起。至少这是 Unity 的看法,也是为什么会有一个 10 万美元营业额的原因。

Mecanim – IK 骨骼绑定

Unity 的新动画系统Mecanim支持许多激动人心的新功能,其中之一就是IK逆运动学的简称)。如果你对这个术语不熟悉,IK 允许你定义动画的目标点,让系统自行解决如何到达该点。想象一下,你有一个放在桌子上的杯子,角色想要拿起它。你可以让角色弯腰去拿;但是,如果角色稍微偏移一点呢?或者玩家可能造成的任何其他微小偏移,完全打乱了你的动画?为每一种可能性都制作动画是不切实际的。使用 IK,角色稍微偏移一点几乎无关紧要。

我们只需为手部定义目标点,而将手臂的动画交给逆运动学(IK)系统处理。它会计算出手臂需要怎样移动才能让手到达杯子。另一个有趣的用途是,当角色在房间内走动时,让他们观察有趣的事物:守卫可以追踪最近的人,玩家的角色可以查看可以互动的物体,或者触手怪物可以在没有复杂动画的情况下向玩家发起攻击。这将是一个令人兴奋的功能去尝试。

Mecanim – 同步图层和附加曲线

在 Mecanim 中的同步图层,能让我们保持多组动画状态彼此同步。假设你有一个士兵,你想根据他的生命值来不同地动画他。当他的生命值满时,他快速地走动。受到一点伤害后,他的行走变得更为沉重。如果他的生命值低于一半,他的行走中会引入跛行,而当他几乎要死亡时,他会沿着地面爬行。通过同步图层,我们可以创建一个动画状态机并复制到多个图层中。通过改变动画并同步图层,我们可以轻松地在不同的动画之间过渡,同时保持状态机。

附加曲线功能,简单来说就是在动画中添加曲线的能力。这意味着我们可以通过动画控制各种数值。例如,在游戏世界中,当角色抬起脚准备跳跃时,重力几乎会立即将他们拉下来。在 Unity 中为那个动画添加额外的曲线,我们可以控制重力对角色的影响程度,使他们跳跃时实际上能在空中。这是一个用于在动画的同时控制这类数值的有用功能,但你也可以轻松创建一个控制曲线的脚本。

自定义启动画面

尽管这个功能相当容易理解,但除非你之前使用过 Unity,否则可能不会立即明白为什么要特别指明这一功能。在任何平台上初始化的 Unity 构建的应用程序都会显示一个启动画面。在 Unity Basic 中,这总是 Unity 的标志。通过购买 Unity Pro,你可以用任何你想要的图像替换 Unity 标志。

实时点光源/软阴影

光照和阴影为场景的氛围增添了很多。这个特性让我们可以超越简单的 blob 阴影,使用看起来更逼真的阴影。如果你有足够的处理空间,这当然很好。然而,大多数移动设备并不具备这样的条件。这个特性也不应该用于静态场景;相反,应该使用静态光照贴图,这才是它们的作用所在。

然而,如果你能在简单需求和高质量之间找到一个好的平衡,这可能是区分一个还不错和一款优秀游戏的特点。如果你确实需要实时阴影,那么方向光支持它们,并且是计算速度最快的灯光。这也是 Unity Basic 中唯一支持实时阴影的灯光类型。

HDR 和色调映射

HDR(即高动态范围)和色调映射使我们能够创造出更逼真的光照效果。标准渲染使用从零到一之间的值来表示像素中每种颜色的显示程度。这并不允许探索完整的光照选项光谱。HDR 让系统能够使用超出这一范围的值,并使用色调映射处理它们,以创造更好的效果,如明亮的早晨房间或汽车窗户反射的阳光造成的泛光。这个特性的缺点在于处理器。设备仍然只能处理零到一之间的值,因此转换它们需要时间。此外,效果越复杂,渲染所需的时间越长。即使是在简单游戏中,看到手持设备很好地使用这项技术都会令人惊讶。也许现代平板电脑能够处理。

光探针

光探针是一个有趣的小功能。当它们被放置在世界上时,光探针会计算出物体应该如何被照亮。然后,当角色四处走动时,它们会告诉角色如何进行阴影处理。角色当然会被场景中的灯光照亮,但一次能对物体产生阴影的灯光数量是有限制的。光探针事先进行所有复杂的计算,允许在运行时进行更好的阴影处理。然而,同样存在关于处理能力的问题。如果处理能力不足,你不会得到好的效果;如果过多,将没有剩余的处理能力来玩游戏。

使用全局光照和区域光照的光照贴图

所有版本的 Unity 都支持光照贴图,允许烘焙复杂的静态阴影和光照效果。加上全局光照和区域光照,你可以为场景添加更多真实感。然而,Unity 的每个版本还允许你导入自己的光照贴图。这意味着你可以使用其他程序来渲染光照贴图,然后单独导入它们。

静态批处理

这个特性加快了渲染过程。它不是在每个帧上花费时间将对象分组以加快渲染速度,而是允许系统保存之前生成的组。减少绘制调用次数是使游戏运行更快的重要步骤。这正是这个特性的作用。

渲染到纹理效果

这是一个有趣的功能,但实用性有限。它允许你将摄像头的输出用作游戏中的纹理。这种纹理在最简单的形式下,可以被放置在网格上,充当监控摄像头。你也可以进行一些自定义的后处理,比如当玩家失去生命值时,从世界中移除颜色。然而,这个选项可能会变得非常消耗处理器资源。

全屏后处理效果

这也是一个非常消耗处理器资源的特性,可能不会用于你的移动游戏。然而,你可以为你的场景添加一些非常酷的效果,比如当玩家移动速度非常快时添加动态模糊效果,或者在飞船穿过扭曲的空间区域时添加漩涡效果。最佳效果之一是使用泛光效果,让事物呈现出类似霓虹灯的发光效果。

遮挡剔除

这又是另一个优秀的优化功能。标准的摄像头系统会渲染摄像头视野锥体内的所有内容,即视图空间。遮挡剔除允许我们在摄像头可以进入的空间内设置体积。这些体积用于计算摄像头从这些位置实际能看到的内容。如果有一堵墙挡在前面,那么渲染它背后的所有东西又有什么意义呢?遮挡剔除计算这一点,并阻止摄像头渲染墙后的任何内容。

延迟渲染

如果你希望制作出外观最佳的游戏,具有高度详细的光照和阴影,那么这个特性对你来说将非常有趣。延迟渲染是一个多通道过程,用于计算你的游戏中的光照和阴影细节。然而,这是一个代价高昂的过程,需要一张相当不错的图形卡来充分利用它。不幸的是,这使得它对于移动游戏来说有些难以承受。

模板缓冲区访问

自定义着色器可以使用模板缓冲区通过选择性地覆盖特定像素来创建特殊效果。这类似于使用 alpha 通道选择性地渲染纹理的部分区域。

GPU 蒙皮

这是一种处理和渲染方法,通过它,使用骨架绑定的人物或对象的外观计算被交给图形卡处理,而不是由中央处理器完成。这种方式渲染对象要快得多。然而,这仅支持 DirectX 11 和 OpenGL ES 3.0,这使得它对我们的移动游戏来说有些难以触及。

导航网格 - 动态障碍物和优先级

这个功能与寻路系统结合使用。在脚本中,我们可以动态设置障碍物,角色将找到绕过它们的方法。能够设置优先级意味着不同类型的角色在寻找路径时可以考虑不同类型的对象。例如,士兵必须绕过路障才能达到目标。然而,坦克可以撞过去,如果玩家希望这样做的话。

本地代码插件支持

如果你有一套以动态链接库DLL)形式的自定义代码,这就是你需要访问的 Unity Pro 功能。否则,Unity 无法访问本地插件,以便与你的游戏一起使用。

性能分析器和 GPU 性能分析

这是一个非常实用的功能。性能分析器提供了大量关于你的游戏对处理器产生多大负载的信息。有了这些信息,我们可以深入到细节中,准确地确定一个脚本处理需要多长时间。然而,在本书的后面,我们还将创建一个工具,以确定你代码中特定部分的处理需要多长时间。

脚本访问资源管道

这是一个还不错的功能。有了对管道的完全访问权限,可以对资源和构建进行大量自定义处理。完整可能性范围超出了本书的讨论范围。但是,你可以将它视为能够将所有导入的纹理稍微调整为蓝色的功能。

深色皮肤

这完全是一个外观功能。它的意义和目的值得商榷。然而,如果你想要一个光滑的深色皮肤外观,这就是你想要的功能。编辑器中有一个选项可以将其更改为 Unity Basic 中使用的颜色方案。对于这个功能,你喜欢什么就是什么。

设置开发环境

在我们能为安卓创建下一个伟大的游戏之前,我们需要安装一些程序。为了使 Android SDK 工作,我们首先安装Java 开发工具JDK)。然后安装 Android SDK。之后,我们将安装 Unity。接下来,我们还要安装一个可选的代码编辑器。为了确保一切设置正确,我们将连接到我们的设备,并查看如果设备比较棘手的一些特殊策略。最后,我们将安装 Unity Remote,这个程序在移动开发中将变得非常宝贵。

安装 JDK

安卓的开发首选语言是 Java;因此,为了开发它,我们需要在电脑上安装一份Java SE 开发工具包。安装 JDK 的过程在以下步骤中给出:

  1. 可以从www.oracle.com/technetwork/java/javase/downloads/index.html下载最新版本的 JDK。因此,在网页浏览器中打开该网站,你将能够看到以下截图显示的屏幕:安装 JDK

  2. 从可用版本中选择Java 平台(JDK),您将被引导至一个包含许可协议的页面,并允许您选择希望下载的文件类型。

  3. 接受许可协议,并从页面底部的列表中选择适合您的 Windows 版本。如果您不确定选择哪个版本,通常选择Windows x86是安全的。

  4. 下载完成后,运行新的安装程序。

  5. 系统扫描后,点击下一步两次,JDK 将初始化,然后再次点击下一步按钮以将 JDK 安装到默认位置。它在那里和其他任何地方一样好,因此安装完成后,点击关闭按钮。

我们刚刚完成了JDK的安装。我们需要它是因为我们的 Android 开发工具包才能工作。幸运的是,这个关键基石的安装过程既简短又顺利。

安装 Android SDK

为了实际开发和连接我们的设备,我们需要安装 Android SDK。安装 SDK 满足了两个主要需求。首先,它确保我们有最新的驱动程序以识别设备。其次,我们可以使用Android 调试桥ADB)。ADB 是用于实际连接和与设备交互的系统。以下是安装 Android SDK 的步骤:

  1. 最新版本的 Android SDK 可在 developer.android.com/sdk/index.html 找到,因此请打开网页浏览器并访问给定的网站。

  2. 到达页面后,滚动至底部,找到仅 SDK 工具部分。这是我们仅获取 SDK 的地方,我们需要它来使用 Unity 开发 Android 游戏,而不必处理 Android Studio 的花哨内容。

  3. 我们需要选择带有(推荐)标记的.exe安装包(如下面的截图所示):安装 Android SDK

  4. 您随后将被引导至一个条款与条件页面。如果您愿意,可以阅读它,但需要同意才能继续。然后点击下载按钮开始下载安装程序。

  5. 下载完成后,启动它。

  6. 点击第一个下一步按钮,安装程序将尝试找到合适的 JDK 版本。如果您没有安装 JDK,您将看到一个通知您找不到 JDK 的页面。

  7. 如果您跳过了步骤并且没有安装 JDK,请点击页面中间的访问 java.oracle.com按钮,并返回到前面的部分获取安装指导。如果您已经安装了它,请继续进行下一步。

  8. 再次点击下一步,会出现一个页面询问您安装 SDK 的对象。

  9. 选择为这台电脑上的任何人安装,因为默认安装位置便于以后访问。

  10. 点击下一步两次,然后点击安装,将 SDK 安装到默认位置。

  11. 完成后,点击下一步完成,以完成 Android SDK 管理器的安装。

  12. 如果 Android SDK 管理器没有立即启动,请启动它。无论如何,给它一点时间进行初始化。SDK 管理器确保我们有最新的驱动程序、系统和工具,以便与 Android 平台进行开发。但是,我们首先必须安装它们(这可以在以下屏幕完成):安装 Android SDK

  13. 默认情况下,SDK 管理器应该选择一些要安装的选项。如果没有,选择最新的 Android API(在撰写本书时为 Android L (API 20)),Android 支持库Extras中的Google USB 驱动程序。请务必确保选中了Android SDK Platform-tools。这将在后面非常重要。它实际上包含了我们需要连接设备所需的工具。

  14. 一旦选择好所有内容,点击右下角的安装包

  15. 下一个屏幕是另一组许可协议。每次通过 SDK 管理器安装或更新组件时,您都必须同意许可条款才能进行安装。接受所有许可协议,然后点击安装开始流程。

  16. 您现在可以坐下来放松一下。组件的下载和安装需要一些时间。一旦完成这些步骤,您可以关闭它。我们已经完成了这个过程,但您应该偶尔回来检查一下。定期检查 SDK 管理器是否有更新,以确保您正在使用最新的工具和 API。

Android SDK的安装现在已经完成。没有它,我们将完全无法在 Android 平台上进行任何操作。除了下载和安装组件的长时间等待,这是一个相当简单的安装过程。

安装 Unity 3D

这本书最重要的部分,没有它,其余的内容都没有意义,就是安装 Unity。执行以下步骤来安装 Unity:

  1. 最新版本的 Unity 可以在www.unity3d.com/unity/download找到。在撰写本书时,当前版本是 5.0。

  2. 下载完成后,启动安装程序,并点击下一步,直到您到达选择组件页面,如下截图所示:安装 Unity 3D

  3. 在这里,我们可以选择 Unity 安装的功能。实际上,对于跟随本书其余内容来说,这些选项都不是必需的,但它们值得一看,因为每次更新或重新安装 Unity 时,Unity 都会询问您希望安装哪些组件:

    • 示例项目:这是 Unity 为了展示其最新功能而构建的当前项目。如果您想提前看看一个完整的 Unity 游戏是什么样的,请保持选中此项。

    • Unity 开发网络播放器:如果你计划使用 Unity 开发浏览器应用程序,则需要此播放器。由于本书重点介绍 Android 开发,因此这是可选的。不过,勾选它是个不错的选择。你永远不知道何时可能需要一个网络演示,而且使用 Unity 开发网络应用程序完全免费,所以勾选它没有坏处。

    • MonoDevelop:选择不勾选这个选项是明智的。下一节会有更多详细信息,但现在只需知道它只是增加了一个用于脚本编辑的程序,而这个程序并没有它应有的那么有用。

  4. 选择或取消选择你所需的选项后,点击下一步。如果你希望严格按照书本操作,请注意我们将取消勾选MonoDevelop,其余的保持勾选。

  5. 下一步是安装位置。默认位置很好,所以点击安装并等待。这将需要几分钟,所以请坐下来,放松一下,享受你最喜欢的饮料。

  6. 安装完成后,将显示运行 Unity 的选项。保持勾选并点击完成。如果你以前从未安装过 Unity,将会出现一个许可证激活页面(如下图所示):安装 Unity 3D

  7. 虽然 Unity 确实提供了一个功能丰富的免费版本,但要完全遵循本书的内容,需要使用一些 Unity Pro 功能。在store.unity3d.com上,你可以购买各种许可证。要跟随整本书,你至少需要购买 Unity Pro 和 Android Pro 许可证。购买后,你将收到一封包含新许可证密钥的电子邮件。将密钥输入到提供的文本字段中。

  8. 如果你还没准备好购买,你还有两个选择。我们将在本章后面的构建一个简单的应用程序部分介绍如何重置你的许可证。以下是你可以选择的两种方式:

    • 第一个选择是勾选激活 Unity 的免费版本复选框。这将允许你使用 Unity 的免费版本。如前所述,有很多理由选择这个选项。目前最值得注意的是成本。

    • 你也可以选择激活 Unity Pro 的免费 30 天试用选项。Unity 提供一次性的完整功能安装,以及 Unity Pro 的免费 30 天试用。此试用版还包括 Android Pro 附加组件。在这 30 天内制作的所有内容完全属于你,就像你购买了完整的 Unity Pro 许可一样。他们希望你能体验到它有多棒,以便你回来进行购买。不过缺点是,游戏角落会不断显示试用版水印。30 天后,Unity 将恢复到免费版本。如果你打算在购买前先等待,这是一个很好的选择。

  9. 无论你的选择是什么,一旦做出决定,点击确定

  10. 下一个页面只是要求你使用 Unity 账户登录。这将是你用来进行购买的账户。只需填写字段并点击确定

  11. 如果你还没有进行购买,可以点击创建账户,这样当你购买时就可以准备好了。

  12. 下一个页面是对你开发兴趣的简短调查。填写完毕后点击确定,或者直接滚动到底部点击现在不要

  13. 最后会有一个感谢页面。点击开始使用 Unity

  14. 短暂初始化之后,项目向导会打开,我们可以开始创建下一个伟大的游戏。然而,为了连接开发设备,还有很多工作要做。所以现在,点击右上角的X按钮关闭项目向导。我们将在后面的构建一个简单的应用程序部分介绍如何创建新项目。

我们刚刚完成了 Unity 3D 的安装。整本书都依赖于这一步骤。我们还必须做出关于许可证的选择。如果你选择购买专业版,你将能够毫无问题地跟随本书中的所有内容。然而,其他选择会有一些不足之处。你将无法完全访问所有功能,或者在试用期限内向游戏添加水印的同时受限。

可选的代码编辑器

现在需要做一个关于代码编辑器的选择。Unity 自带一个名为MonoDevelop的系统。它在许多方面与Visual Studio相似。而且与 Visual Studio 一样,它会为项目添加许多额外文件和大量体积,这些都是其运行所需的。所有这些额外的体积使得启动时间变得令人讨厌,因为在真正接触到代码之前需要等待。

从技术上讲,你可以使用纯文本编辑器,因为 Unity 并不真正关心。本书推荐使用 Notepad++,可以在notepad-plus-plus.org/download找到。它是免费使用的,本质上是有代码高亮的 Notepad。Notepad++ 有许多花哨的小工具和插件,可以增加更多功能,但它们并不是跟随本书所必需的。如果你选择这个替代方案,将 Notepad++ 安装到默认位置就可以了。

连接到设备

在使用 Android 设备时,可能最麻烦的步骤就是将设备连接到电脑。由于有如此多不同类型的设备,有时仅仅让电脑识别设备都会有点棘手。

简单的设备连接

简单的设备连接方法涉及更改一些设置以及在命令提示符中做一些工作。这可能看起来有点可怕,但如果一切顺利,你很快就会连接到你的设备:

  1. 你需要做的第一件事是打开手机的开发者选项。在最新版本的 Android 中,这些选项已被隐藏。进入手机的设置页面,找到关于手机页面。

  2. 接下来,你需要找到构建号信息栏,并多次点击它。起初,它看起来似乎没有任何反应,但很快就会显示你需要再按几次按钮来激活开发者选项。Android 团队之所以这样做,是为了防止普通用户不小心进行更改。

  3. 现在回到你的设置页面,应该有一个新的开发者选项页面;现在选择它。这个页面控制了你在开发应用程序时可能需要更改的所有设置。

  4. 我们现在真正需要勾选的复选框是USB 调试。这允许我们从开发环境中实际检测到我们的设备。

  5. 如果你使用的是 Kindle,务必进入安全选项,并开启启用 ADB

    提示

    开启这些选项时会有几个警告弹窗,它们本质上与电脑上的恶意软件警告相同。意图不良的应用程序可能会干扰你的系统,获取你的私人信息。如果你的设备仅用于开发,所有这些设置都需要打开。然而,正如警告所提示的,如果担心恶意应用程序,不开发时请关闭它们。

  6. 接下来,在电脑上打开一个命令提示符。最简单的方法是按你的 Windows 键,输入cmd.exe,然后按回车

  7. 现在,我们需要导航到 ADB 命令。如果你没有将 SDK 安装到默认位置,请将以下命令中的路径替换为你安装 SDK 的路径。

    如果你运行的是 32 位 Windows 版本,并且将 SDK 安装到了默认位置,请在命令提示符中输入以下内容:

    cd c:\program files\android\android-sdk\platform-tools
    
    

    如果你运行的是 64 位版本,请在命令提示符中输入以下内容:

    cd c:\program files (x86)\android\android-sdk\platform-tools
    
    
  8. 现在,将你的设备连接到电脑上,最好使用随设备附带的 USB 线。

  9. 等待你的电脑完成识别设备。完成后应该会出现一个设备驱动程序已安装类型的消息弹窗。

  10. 以下命令让我们看到当前连接并被 ADB 系统识别的设备。模拟设备也会显示出来。在命令提示符中输入以下内容:

    adb devices
    
    
  11. 在短暂的处理后,命令提示符将显示已连接设备的列表以及所有连接设备的唯一 ID。如果现在这个列表中包含了你的设备,恭喜你!你有一个对开发者友好的设备。如果它不是完全的开发者友好型,在事情变得复杂之前,你还有一件事可以尝试。

  12. 去你的设备顶部打开系统通知。应该有一个看起来像 USB 符号的通知。选择它将打开连接设置。这里有几个选项,默认情况下 Android 会选择将 Android 设备作为媒体设备连接。

  13. 我们需要将设备连接为摄像头。这样做的原因是所使用的连接方式。通常,这将允许你的电脑进行连接。

我们已经完成了首次尝试连接到我们的安卓设备。对大多数人来说,这应该就是连接设备所需的一切。但对一些人来说,这个过程还不够。下一小节将介绍解决连接更难设备问题的方法。

对于更难连接的设备,我们可以尝试一些常规的方法;如果这些步骤无法连接你的设备,你可能需要进行一些特殊的研究。

  1. 从输入以下命令开始。这将重启连接系统并再次显示设备列表:

    adb kill-server
    adb start-server
    adb devices
    
    
  2. 如果你仍然没有成功,尝试以下命令。这些命令会强制更新并重启连接系统:

    cd ../tools
    android update adb
    cd ../platform-tools
    adb kill-server
    adb start-server
    adb devices
    
    
  3. 如果你的设备仍然没有显示出来,那么你有一个最令人讨厌且难以处理的设备。检查制造商的网站,看是否有数据同步和管理程序。如果你的设备已经使用了一段时间,你可能已经被提示不止一次安装这个程序。如果你还没有这样做,即使你从未打算使用它,也请安装最新版本。这样做是为了获取设备的最新驱动,这是最简单的方法。

  4. 再次使用第一组命令重启连接系统,然后交叉手指等待!

  5. 如果你仍然无法连接,最好的专业建议就是去谷歌搜索你的问题的解决方案。搜索你的设备品牌加上adb作为后缀,应该能在前几个结果中找到针对你的设备的分步教程。另一个了解关于安卓设备所有细节的优秀资源可以在www.xda-developers.com/找到。

在开发过程中,你会遇到一些不容易连接的设备。我们刚刚介绍了一些快速步骤,并成功连接了这些设备。如果我们可以涵盖每个设备的过程,我们就会这么做。然而,设备种类太多,制造商还在不断推出新产品。

Unity Remote

Unity Remote 是由 Unity 团队创建的一个很棒的应用程序。它允许开发者将他们的安卓设备连接到 Unity 编辑器,并提供移动输入以进行测试。这绝对是任何有志于成为 Unity 和安卓开发者的必备工具。如果你使用的是非亚马逊设备,获取 Unity Remote 非常简单。在撰写这本书的时候,它可以在 Google Play 上找到,地址是play.google.com/store/apps/details?id=com.unity3d.genericremote。它是免费的,除了将你的安卓设备连接到 Unity 编辑器之外,没有其他功能,所以应用程序权限可以忽略不计。实际上,目前有两个版本的 Unity Remote。要连接到 Unity 4.5 及更高版本,我们必须使用 Unity Remote 4。

然而,如果你喜欢不断增长的亚马逊市场,或者想要针对亚马逊的安卓设备,添加 Unity Remote 会变得有点复杂。首先,你需要从 Unity 资源商店下载一个特殊的 Unity 包。可以在www.assetstore.unity3d.com/en/#!/content/18106找到它。你需要将这个包导入一个新项目,并从那里构建。通过在 Unity 顶部导航到资产 | 导入包 | 自定义包,然后导航到你保存它的位置来导入包。在下一节中,我们将构建一个简单的应用程序并将其放在我们的设备上。导入包后,从我们打开构建设置窗口的步骤开始,用创建的 APK 替换简单应用程序。

构建一个简单的应用程序

我们现在将创建一个简单的“你好世界”应用程序。这将帮助你熟悉 Unity 界面以及如何将应用程序实际放到你的设备上。

你好世界

为了确保一切设置正确,我们需要一个简单的应用程序来进行测试,还有什么比使用“你好世界”应用程序更好的呢?要构建这个应用程序,请执行以下步骤:

  1. 第一步非常直接和简单:启动 Unity。

  2. 如果你到目前为止一直在跟进,完成这些步骤后,你应该会看到一个类似于下一张截图的屏幕。正如标签所示,这是我们打开各种项目的屏幕。但现在,我们感兴趣的是创建一个;因此,从右上角选择新项目,我们将这样做:你好世界

  3. 使用项目名称字段给你的项目命名;Ch1_HelloWorld作为一个项目名非常合适。然后使用位置字段右侧的三个点来选择电脑上的一个位置来放置新项目。Unity 将在这个位置创建一个基于项目名称的新文件夹,用于存储你的项目及其所有相关文件:你好世界

  4. 目前,我们可以忽略3D2D按钮。这些按钮让我们确定当创建新场景和导入新资源时 Unity 将使用的默认值。我们还可以忽略资产包按钮。这个按钮让你从 Unity 提供的各种资源和功能中选择。它们可以免费用于你的项目。

  5. 点击创建项目按钮,Unity 将为我们创建一个全新的项目。

    下面的截图展示了 Unity 编辑器的窗口:

    你好世界

  6. Unity 的默认布局包含了一系列创建游戏所需的窗口:

    • 从左侧开始,层次结构包含了当前场景中所有对象的列表。它们按字母顺序组织,并在父对象下分组。

    • 在旁边是场景视图。这个窗口让我们能够在 3D 空间中编辑和排列对象。在左上角,有两组按钮。这些按钮影响你与场景视图的交互方式。

    • 最左侧看起来像手的按钮,当你用鼠标点击并拖动时,可以让你平移视角。

    • 下一个按钮,交叉的箭头,让你移动对象。如果你使用过任何建模程序,它的行为和提供的工具将会很熟悉。

    • 第三个按钮将工具切换到旋转模式。它允许你旋转对象。

    • 第四个按钮用于缩放。它也会改变工具。

    • 第五个按钮允许你根据对象边界框及其相对于你视角的方向调整其位置和缩放。

    • 倒数第二个按钮在轴心中心之间切换。这将改变最后三个按钮使用的工具位置,要么在选中对象的轴心点,要么在所有选中对象平均位置点。

    • 最后一个按钮在局部全局之间切换。这会改变工具是否与世界原点平行或随选中对象旋转。

    • 场景视图下方是游戏视图。这是场景中任何相机当前渲染的内容。这是玩家在玩游戏时所看到的,用于测试你的游戏。在窗口中上部中央有三个控制游戏视图播放的按钮。

    • 第一个是播放按钮。它切换游戏的运行。如果你想测试游戏,按下这个按钮。

    • 第二个是暂停按钮。在播放时,按下这个按钮会暂停整个游戏,让你查看游戏的当前状态。

    • 第三个是单步按钮。在暂停时,这个按钮可以让你一次一帧地推进游戏。

    • 在右侧是检查器窗口。这会显示当前选中任何对象的信息。

    • 在左下角是项目窗口。这里显示的是当前项目中存储的所有资源。

    • 在其后是控制台。它会显示调试信息、编译错误、警告和运行时错误。

  7. 在顶部,紧挨着帮助的是名为管理许可...的选项。选择这个,我们会得到控制许可的选项。按钮描述已经很好地覆盖了它们的功能,所以在这个阶段我们不再详细讲解。

  8. 接下来我们需要做的是连接可选的代码编辑器。在顶部,转到编辑,然后点击偏好设置...,这将打开以下窗口:Hello World

  9. 通过选择左侧的外部工具,我们可以选择其他软件来管理资源编辑。

  10. 如果你不想使用 MonoDevelop,请选择外部脚本编辑器旁边的下拉列表,并找到Notepad++的可执行文件,或者你选择的任何其他代码编辑器。

  11. 你的图像应用程序选项也可以在这里改为Adobe Photoshop或你喜欢的任何其他图像编辑程序,与脚本编辑器的方式相同。

  12. 如果你将 Android SDK 安装到了默认位置,那么不必担心。否则,点击浏览...并找到android-sdk文件夹。

  13. 现在,我们要实际创建这个应用程序,请在你的项目窗口内右键点击。

  14. 在弹出的新窗口中,从菜单中选择创建C#脚本

  15. 为新脚本输入一个名字(HelloWorld就很好),然后按Enter键两次:一次确认名字,一次打开它。

    提示

    由于这是第一章,这将是一个简单的 Hello World 应用程序。Unity 支持 C#、JavaScript 和 Boo 作为脚本语言。为了保持一致性,本书将使用 C#。如果你希望使用 JavaScript 编写脚本,可以找到带有本书其他资源的所有项目副本,它们带有_JS后缀,表示 JavaScript。

  16. 每个将要附加到对象的脚本都扩展了MonoBehaviour类的功能。JavaScript 会自动这样做,但 C#脚本必须显式定义。但是,正如你在脚本中的默认代码所看到的,我们最初不必担心设置这一点;它会自动完成。扩展MonoBehaviour类让我们的脚本可以访问游戏对象的各个值,比如位置,并让系统在游戏中的特定事件期间自动调用某些函数,比如更新周期和 GUI 渲染。

  17. 目前,我们将删除 Unity 在每个新脚本中坚持要包含的StartUpdate函数。用一段简单的代码替换它们,在屏幕左上角显示Hello World;现在你可以关闭脚本,回到 Unity 界面:

    public void OnGUI() {
      GUILayout.Label("Hello World");
    }
    
  18. HelloWorld脚本从项目窗口拖拽到层级窗口中的主相机对象上。恭喜你!你刚刚向 Unity 中的一个对象添加了第一个功能。

  19. 如果你选择层级中的主相机,那么检查器会显示附加到它的所有组件。列表底部是你的全新HelloWorld脚本。

  20. 在我们测试之前,我们需要保存这个场景。为此,请到顶部选择文件,然后选择保存场景。给它起名为HelloWorld并点击保存。你的项目窗口将出现一个新图标,表示你已经保存了场景。

  21. 现在,你可以自由地按下编辑器中间上方的播放按钮,见证 Hello World 的魔力。

  22. 我们现在来构建这个应用程序。首先,在顶部选择文件,然后点击构建设置...

  23. 默认情况下,目标平台是PC。在平台下,选择Android,并在构建设置窗口左下角点击切换平台

  24. 构建中的场景框下方,有一个标有添加当前的按钮。点击它,将我们当前打开的场景添加到构建中。只有在这个列表中并已选中的场景才会被添加到游戏的最终构建中。旁边带有数字零的场景将是游戏启动时加载的第一个场景。

  25. 在我们点击构建按钮之前,还有一组东西需要更改。在构建设置窗口底部选择播放器设置...

  26. 检查器窗口将打开应用程序的播放器设置(如下截图所示)。从这里,我们可以更改启动画面、图标、屏幕方向以及其他一些技术选项:Hello World

  27. 目前,我们只关心几个选项。最顶部,公司名称是将在应用程序信息下方显示的名字。产品名称是在你的安卓设备上图标下方显示的名字。你可以将这些选项设置为任何你想要的,但它们需要立即设置。

  28. 重要的设置是捆绑标识符,位于其他设置识别下方。这是唯一标识你的应用程序与设备上所有其他应用程序不同的标识符。格式是com.CompanyName.ProductName,最好在所有产品中使用相同的公司名称。对于这本书,我们将使用com.TomPacktAndBegin.Ch1.HelloWorld作为捆绑标识符,并选择在组织中使用额外的点(句点)。Hello World

  29. 转到文件,然后再次点击保存

  30. 现在,你可以在构建设置窗口中点击构建按钮。

  31. 选择一个位置保存文件,以及一个文件名(Ch1_HelloWorld.apk是个不错的选择)。确保记住它的位置,然后点击保存

  32. 如果在构建过程中 Unity 报错关于 Android SDK 的位置,选择它安装位置内的android-sdk文件夹。对于 32 位 Windows 系统,默认位置是C:\Program Files\Android\android-sdk,而对于 64 位 Windows 系统是C:\Program Files (x86)\Android\android-sdk

  33. 当加载完成,应该不会太长时间,你的 APK 就已经制作好了,我们可以继续下一步。

  34. 本章节我们完成了 Unity 的操作。你可以关闭它并打开一个命令提示符。

  35. 就像我们连接设备时一样,我们需要导航到platform-tools文件夹以连接到我们的设备。如果你将 SDK 安装到默认位置,使用:

    • 对于 32 位 Windows 系统:

      cd c:\program files\android\android-sdk\platform-tools
      
      
    • 对于 64 位 Windows 系统:

      cd c:\program files (x86)\android\android-sdk\platform-tools
      
      
  36. 使用以下命令再次检查确保设备已连接并被识别:

    adb devices
    
    
  37. 现在我们将安装应用程序。这个命令告诉系统在连接的设备上安装应用程序。-r表示如果发现与我们要安装的应用程序具有相同捆绑标识符的应用程序,它应该覆盖。这样,你就可以在开发过程中直接更新游戏,而不是每次需要更新时先卸载再安装新版本。你希望安装的.apk文件的路径如下所示,用引号括起来:

    adb install -r "c:\users\tom\desktop\packt\book\ch1_helloworld.apk"

  38. 用你的 APK 文件路径替换它;字母大小写不重要,但一定要确保所有的空格和标点符号都是正确的。

  39. 如果一切顺利,控制台将在将应用程序推送到设备后显示上传速度,并在安装完成后显示成功消息。在这个阶段,最常见的错误原因是在发出命令时不在platform-tools文件夹中,以及没有正确引用.apk文件的路径。

  40. 一旦你收到成功消息,在手机上找到应用程序并启动它。

  41. 现在,以你用 Unity 的强大功能创建 Android 应用程序的能力为荣吧。

我们已经创建了第一个 Unity 和 Android 应用程序。诚然,这只是个简单的“Hello World”应用程序,但事情总是这样开始的。这对于双重检查设备连接以及在没有游戏干扰的情况下了解构建过程非常有帮助。

如果你想要一个更大的挑战,尝试为应用更改图标。这是一个相当简单的操作,随着游戏的开发,你无疑会想要执行。如何进行这一操作在本节前面已经提到过,但作为提醒,请查看玩家设置。此外,你还需要导入一个图像。查看资产菜单下的内容,了解如何操作。

总结

本章中有许多技术内容。首先,我们讨论了使用 Unity 和 Android 时的好处和可能性。然后是一大堆安装工作:JDK、Android SDK、Unity 3D 和 Unity Remote。之后,我们弄清楚了如何通过命令提示符连接到我们的设备。我们的第一个应用程序制作得既快又简单。我们构建了它,并将其放在设备上。

在下一章中,我们将创建一个更具互动性的游戏——井字棋。我们将探索图形用户界面的奇妙世界。因此,我们不仅会制作游戏,还会让它看起来美观。

第二章:外观美观——图形界面

在上一章中,我们介绍了 Unity 和 Android 的特性,并讨论了将它们一起使用的益处。在我们安装了大量软件并设置好设备之后,我们创建了一个简单的 Hello World 应用程序,以确认一切连接正确。

本章完全关于图形用户界面GUI)。我们将从使用 Unity 提供的基本 GUI 组件创建一个简单的井字游戏开始。接下来,我们将讨论如何改变我们的 GUI 控件的样式,以改善游戏的外观。我们还将探索一些技巧,以处理 Android 设备的不同屏幕尺寸。最后,我们将学习一种比上一章介绍的方法更快的方式,将我们的游戏放在设备上。说到这里,让我们开始吧。

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

  • 用户偏好设置

  • 按钮文字和图片

  • 动态 GUI 定位

  • 构建和运行

在本章中,我们将在 Unity 中创建一个新项目。这里的第一个部分将指导你完成创建和设置。

创建一个井字游戏

本章的项目是一个简单的类似井字风格的游戏,就像我们可能在纸上玩的那样。与其他任何事情一样,有多种方法可以制作这个游戏。我们将使用 Unity 的 uGUI 系统,以便更好地了解如何为我们的其他游戏创建一个图形用户界面。

游戏板

基本的井字游戏涉及两名玩家和一个 3x3 的网格。玩家轮流用 X 和 O 填充方格。第一个用字母填满一行三个方格的玩家赢得游戏。如果所有方格被填满,但没有玩家达到三个连成一行的方格,则游戏平局。让我们从以下步骤开始创建我们的游戏板:

  1. 首先,我们需要为本章创建一个项目。因此,启动 Unity,我们将执行这一操作。

    如果你一直按照至今的步骤操作,Unity 应该会启动到最后打开的项目。这并不是一个糟糕的特性,但它可能变得非常烦人。想象一下:你一直在一个项目上工作了一段时间,它已经变得很大。现在你需要快速打开别的东西,但 Unity 默认会打开你的大型项目。如果你在它能打开之前等待,那么可能会消耗很多时间。

    要更改此功能,请转到 Unity 窗口顶部,点击编辑,然后点击偏好设置。这是我们更改脚本编辑器偏好的同一个地方。不过,这次我们将更改常规标签下的设置。以下屏幕截图显示了常规标签下存在的选项:

    游戏板

  2. 在这个时候,我们主要关注的是启动时加载上一个项目的选项;不过,我们仍将按顺序介绍所有选项。以下是常规标签下的所有选项的详细解释:

    • 自动刷新:这是 Unity 最好的功能之一。因为资产是在 Unity 外部更改的,这个选项允许 Unity 自动检测更改并刷新你项目中的资产。

    • 启动时加载上一个项目:这是一个很棒的功能,你应该确保在安装 Unity 时始终不勾选这个选项。勾选后,Unity 将直接打开你最后工作的项目,而不是项目向导

    • 导入时压缩资源:这是用于在资源首次导入 Unity 时自动压缩你的游戏资源的复选框。

    • 编辑器分析:这个复选框是用于 Unity 的匿名使用统计。保持勾选状态,Unity 编辑器将偶尔向 Unity 源发送信息。让它开启不会造成任何伤害,并且有助于 Unity 团队改进 Unity 编辑器;然而,这也取决于个人偏好。

    • 显示资源商店搜索结果:只有当你计划使用资源商店时,这个设置才是相关的。资源商店可以是任何游戏资产和工具的绝佳来源;然而,由于我们不打算使用它,它与本书的相关性相当有限。它正如其名所示,当你在 Unity 编辑器中搜索资源商店中的内容时,根据这个复选框的设置,会显示搜索结果的数量。

    • 验证保存资源:这是一个好选项,可以保持不勾选。如果勾选了这个选项,每次你在 Unity 中点击保存时,都会弹出一个对话框,以便你可以确保保存自上次保存以来所有已更改的资产。这个选项与你模型和纹理无关,而是关注 Unity 的内部文件、材质和预制体。现在最好是关闭它。

    • 皮肤(仅限专业版):这个选项仅适用于 Unity Pro 用户。它提供了在 Unity 编辑器的浅色和深色版本之间切换的选项。这纯粹是外观上的,所以你可以根据自己的感觉选择。

  3. 设置好你的偏好设置后,现在转到文件,然后选择新建项目

  4. 点击浏览...按钮来选择新项目的位置和名称。

  5. 我们不会使用任何包含的包,所以点击创建,我们可以继续进行。

通过更改一些简单的设置,我们可以避免以后很多麻烦。对于本书中的简单项目来说,这看起来可能不是什么大问题,但对于大型复杂项目,如果你没有选择正确的设置,即使你只是想在项目之间快速切换,也可能会给你带来很多麻烦。

创建棋盘

新项目创建后,我们就有了一个干净的起点来创建我们的游戏。在我们能够创建核心功能之前,我们需要在场景中设置一些结构,以便游戏能够运行,玩家能够与之互动:

  1. 当 Unity 初始化新项目完成后,我们需要创建一个新的画布。我们可以通过导航到GameObject | UI | Canvas来实现这一点。整个 Unity 的 uGUI 系统需要画布才能在屏幕上绘制任何内容。它有几个关键组件,如接下来的检查器窗口所示,这些组件使得它和界面中的其他所有内容都能正常工作:创建面板

    • 矩形变换: 这是你几乎在你将要在游戏中使用的每个其他对象上找到的普通变换组件的特殊类型。它跟踪对象在屏幕上的位置、大小、旋转、围绕其旋转的轴心点以及屏幕大小变化时的行为方式。默认情况下,画布的矩形变换被锁定以包括整个屏幕的大小。

    • 画布: 这个组件控制它及其所控制的界面元素与相机和场景的交互方式。你可以通过调整渲染模式来改变这一点。默认模式是屏幕空间 - 覆盖,这意味着所有内容都将在屏幕上绘制,并覆盖场景中的其他所有内容。屏幕空间 - 相机模式将在特定距离处从相机绘制所有内容。这使得你的界面受到相机透视性质的影响,但任何可能更靠近相机的模型将出现在它的前面。世界空间模式确保画布及其控制的元素像场景中的任何模型一样在世界中绘制。

    • 图形光线投射器: 这是让你实际上可以与各种界面元素交互和点击的组件。

  2. 当你添加画布时,还会创建一个名为EventSystem的额外对象。这就是允许我们的按钮和其他界面元素与脚本交互的东西。如果你不小心删除了它,可以通过转到 Unity 顶部并导航到GameObject | UI | EventSystem来重新创建它。

  3. 接下来,我们需要调整 Unity 编辑器显示我们游戏的方式,这样我们就可以轻松制作游戏面板。为此,点击场景视图顶部的游戏视图标签,切换到游戏视图。

  4. 然后,点击写着自由宽高比的按钮,并选择靠近底部的选项:3:2 横向 (3:2)。你游戏将在大多数使用近似此比例屏幕的移动设备上播放。其余的设备在游戏中不会看到任何失真。

  5. 为了让我们的游戏能够适应各种分辨率,我们需要为画布对象添加一个新组件。在层次结构面板中选择它,然后在检查器面板中点击添加组件,并导航到布局 | 画布缩放器。所选组件允许我们从基本屏幕分辨率开始工作,使其在设备更改时自动缩放我们的 GUI。

  6. 要选择基本分辨率,请从UI 缩放模式下拉列表中选择随屏幕大小缩放

  7. 接下来,我们将X设置为960Y设置为640。从较大分辨率开始工作比从较小分辨率要好。如果你的分辨率太小,当它们在高分辨率设备上放大时,所有的 GUI 元素都会显得模糊。

  8. 为了保持组织性,我们需要创建三个空的 GameObject。回到 Unity 顶部,在GameObject下选择创建空对象三次。

  9. 层次结构标签中,点击并拖动它们到我们的画布上,使它们成为画布的子对象。

  10. 为了使它们每个都能用于组织我们的 GUI 元素,我们需要添加 Rect Transform 组件。在检查器中,通过导航到添加组件 | 布局 | Rect Transform来找到它。

  11. 要重命名它们,请点击检查器顶部它们的名称,并输入新名称。将一个命名为Board,另一个Buttons,最后一个Squares

  12. 接下来,使ButtonsSquares成为Board的子对象。Buttons元素将持有我们游戏板上所有可点击的片段,而Squares将持有已经被选中的格子。

  13. 为了保持Board元素在设备更改时位置不变,我们需要改变它相对于父元素的锚定方式。点击位于Rect Transform右上角的带红色交叉和黄色圆点的方框,展开锚点预设菜单:创建板块

  14. 这些选项中的每一个都会影响元素在屏幕尺寸变化时粘附到父元素的哪个角落。我们选择带有四个箭头、每个方向一个的右下角选项。这将使它与父元素一起拉伸。

  15. ButtonsSquares也进行同样的更改。

  16. 将这些对象的都设置为0。同时确保旋转设置为0缩放设置为1。否则,在我们工作或玩游戏时,界面可能会被奇怪地缩放。

  17. 接下来,我们需要改变板块的锚点。如果锚点没有展开,点击左侧的小三角形来展开它。无论如何,需要将Max X值设置为0.667,这样我们的板块将是一个正方形,覆盖屏幕左边的三分之二。

这个游戏板块是我们项目其余部分的基础。没有它,游戏就无法玩。游戏格子使用它来在屏幕上绘制自己,并锚定到相关位置。稍后,当我们创建菜单时,需要确保玩家只看到我们需要他们与之互动的内容。

游戏格子

既然我们已经有了基础的游戏板块,接下来就需要实际的游戏格子了。没有它们,游戏玩起来就会有些困难。我们需要为玩家创建九个可点击的按钮,九个被选中格子的背景图片,以及九个显示控制格子人员的文本。为了创建并设置它们,请执行以下步骤:

  1. 就像我们对画布所做的那样,导航到游戏对象 | UI,但这次选择按钮图像文本来创建我们需要的一切。

  2. 每个图像对象都需要一个文本对象作为子对象。然后,所有的图像必须是Squares对象的子对象,而按钮必须是Buttons对象的子对象。

  3. 所有的按钮和图片都需要在它们的名字中加入数字,以便我们可以将它们组织起来。将按钮命名为Button0Button8,图片命名为Square0Square8

  4. 下一步是布置我们的游戏板,这样我们就可以将事情组织起来并与编程保持同步。我们需要具体设置每个编号的集合。但首先,从锚点预设的右下角选择交叉箭头,确保它们的的值设置为0

  5. 为了将我们的按钮和方块放置在正确的位置,只需将数字与以下表格相匹配。这样做的结果就是所有的方块都会有序排列,从左上角开始,到右下角结束:

    方块 最小 X 最小 Y 最大 X 最大 Y
    0 0 0.67 0.33 1
    1 0.33 0.67 0.67 1
    2 0.67 0.67 1 1
    3 0 0.33 0.33 0.67
    4 0.33 0.33 0.67 0.67
    5 0.67 0.33 1 0.67
    6 0 0 0.33 0.33
    7 0.33 0 0.67 0.33
    8 0.67 0 1 0.33
  6. 我们需要添加的最后一样东西是一个指示器,用来显示轮到谁了。像之前一样创建另一个文本对象,并将其重命名为Turn Indicator

  7. 确保再次将的值设置为0之后,再次将锚点预设设置为蓝色箭头。

  8. 最后,将锚点下的最小 X 值设置为0.67

  9. 现在我们拥有玩基本井字游戏所需的一切。要查看它,选择Squares对象并取消右上角的勾选框以关闭它。现在当你点击播放,你应该能够看到整个游戏板并点击按钮。你甚至可以使用 Unity Remote 来测试触摸设置。如果你还没有这样做,保存场景然后继续会是一个好主意。

游戏方块是我们设置初始游戏的最后一步。现在它看起来几乎像一个可玩的游戏了。我们只需要添加一些脚本,就能够玩到我们梦寐以求的所有井字游戏。

控制游戏

拥有一个游戏板是创建任何游戏最重要的部分之一。然而,如果我们无法控制当其各个按钮被按下时发生的情况,那它对我们来说就没有任何好处。现在,让我们创建一些脚本并编写一些代码来解决这个问题:

  1. 项目面板中创建两个新的脚本,就像我们在上一章的Hello World项目中做的那样。将新脚本命名为TicTacToeControlSquareState。打开它们并清除默认函数,就像我们在第一章,Saying Hello to Unity and Android中所做的那样。

  2. SquareState脚本将保存我们游戏板每个方块的可能状态。为此,请清除脚本中的所有内容,包括using UnityEngine行和public class SquareState行,这样我们可以用一个简单的枚举来替换它们。枚举只是一个潜在值的列表。这个枚举关注的是控制方块的是哪个玩家。它将允许我们跟踪是 X 控制它,O 控制它,还是它是空的。Clear语句成为第一个,因此也就是默认状态:

    public enum SquareState {
      Clear,
      Xcontrol,
      Ocontrol
    }
    
  3. 在我们的另一个脚本TicTacToeControl中,首先需要在最开始的using UnityEngine下面添加一行,这行代码让我们的代码能够与各种 GUI 元素交互,最重要的是,它能与这个游戏交互,允许我们更改控制方块的人和轮到谁的信息:

    using UnityEngine.UI;
    
  4. 接下来,我们需要两个变量,它们将主要控制游戏的流程。它们需要替代两个默认函数。第一个定义了我们的游戏板,这是一个由九个方块组成的数组,用于跟踪谁拥有哪个方块。第二个变量用于记录轮到谁了。当布尔值为true时,X 玩家将进行操作。当布尔值为false时,O 玩家将进行操作:

    public SquareState[] board = new SquareState[9];
    public bool xTurn = true;
    
  5. 下一个变量将让我们更改屏幕上显示的轮到谁的信息:

    public Text turnIndicatorLandscape;
    
  6. 这三个变量将让我们访问到最后一个部分设置的所有 GUI 对象,允许我们根据谁拥有方块来更改图片和文本。我们还可以在点击时打开或关闭按钮和方块。它们都被标记为Landscape,这样当我们在设备Portrait方向上有第二个板块时,我们能够区分它们:

    public GameObject[] buttonsLandscape;
    public Image[] squaresLandscape;
    public Text[] squareTextsPortrait;
    
  7. 最后两个变量,目前将让我们访问到需要更改背景的图片:

    public Sprite oImage;
    public Sprite xImage;
    
  8. 我们为此脚本编写的第一个函数将在每次点击按钮时被调用。它接收被点击按钮的数量,并且首先关闭按钮并激活方块:

    public void ButtonClick(int squareIndex) {
      buttonsLandscape[squareIndex].SetActive(false);
      squaresLandscape[squareIndex].gameObject.SetActive(true);
    
  9. 接下来,函数会检查我们之前创建的布尔值,以确定轮到谁了。如果是 X 玩家的回合,方块将设置为使用适当的图片和文本,表明他们的控制权已设定。然后它在脚本内部的游戏板上标记控制方块,最后切换到 O 玩家的回合:

    if(xTurn) {
      squaresLandscape[squareIndex].sprite = xImage;
      squareTextsLandscape[squareIndex].text = "X";
    
      board[squareIndex] = SquareState.XControl;
      xTurn = false;
      turnIndicatorLandscape.text = "O's Turn";
    }
    
  10. 下一个代码块与上一个相同,不过它标记了 O 玩家的控制权,并将轮次改为 X 玩家:

    else {
      squaresLandscape[squareIndex].sprite = oImage;
      squareTextsLandscape[squareIndex].text = "O";
    
      board[squareIndex] = SquareState.OControl;
      xTurn = true;
      turnIndicatorLandscape.text = "X's Turn";
    }
    }
    
  11. 目前代码就这些。接下来,我们需要返回 Unity 编辑器,在场景中设置我们的新脚本。你可以通过创建另一个空的游戏对象并重命名为GameControl来实现这一点。

  12. 通过从项目面板中拖动TicTacToeControl脚本,并在选择对象时将其拖放到检查器面板中,将脚本添加到对象上。

  13. 现在,我们需要附上脚本实际工作所需的所有对象引用。我们不需要在检查器面板中触碰棋盘XTurn槽,但需要将Turn Indicator对象从层次结构标签拖到检查器面板中的Turn Indicator Landscape槽。

  14. 接下来,展开Buttons LandscapeSquares LandscapeSquare Texts Landscape设置,并将每个大小槽设置为9

  15. 对于每个新槽,我们需要从层次结构标签中拖动相关的对象。Buttons Landscape下的Element 0对象获得Button0Element 1获得Button1,依此类推。对所有按钮、图像和文本执行此操作。确保你将它们按正确的顺序排列,否则当玩家进行游戏时,我们的脚本会看起来很混乱,因为它会改变事物。

  16. 接下来,我们需要一些图像。如果你还没有这样做,通过导航到 Unity 顶部,选择资产 | 导入新资产,并选择要导入的文件,导入本章的起始资产。你需要逐个导航到并选择它们。我们有OnormalXnormal用来指示方块的控制器。当按钮只是闲置在那里时,使用ButtonNormal图像,当玩家触摸按钮时,使用ButtonActive。稍后,标题字段将用于我们的主菜单。

  17. 为了在我们的游戏中使用这些图像,我们需要更改它们的导入设置。逐一选择它们,并在检查器面板中找到纹理类型下拉菜单。我们需要将它们从纹理更改为精灵(2D \ uGUI)。其余设置可以保持默认。如果我们的精灵表包含单个图像中的多个元素,可以使用精灵模式选项。打包标签选项用于在表中分组和查找精灵。像素到单位选项影响精灵在世界空间中渲染时的大小。轴心点选项简单更改图像将旋转的点。

  18. 对于四个方形图像,我们可以点击精灵编辑器来更改它们渲染时边框的外观。点击后,会打开一个新窗口,显示我们的图像边缘有一些绿线及其在右下角的一些信息。我们可以拖动这些绿线来更改边框属性。绿线外的任何内容都不会随着图像填充比它大的空间而拉伸。每边大约13的设置将防止我们的整个边框拉伸。

  19. 一旦你做了任何更改,请确保点击应用按钮来提交它们。

  20. 接下来,再次选择游戏控制对象,并将ONormal图像拖到OImage槽,将XNormal图像拖到XImage槽。

  21. 每个按钮都需要连接到脚本。为此,依次从层次结构中选择它们,并点击它们检查器右下角的加号:控制游戏

  22. 然后,我们需要点击无功能左侧的小圆圈,并在新窗口中的列表中选择游戏控制

  23. 现在,导航到无功能 | 井字游戏控制 | 按钮点击(int),将我们的代码中的函数连接到按钮。

  24. 最后,对于每个按钮,将按钮的编号放入函数列表右侧的编号槽中。

  25. 为了保持组织性,将你的画布对象重命名为GameBoard_Landscape

  26. 在我们测试之前,请确保通过勾选检查器左上角的框来打开方块对象。同时,取消选中其每个图像子对象的框。控制游戏

这可能看起来不是世界上最好的游戏,但它是可玩的。我们有一些按钮可以调用脚本中的函数。随着游戏的进行,转向指示会发生变化。此外,每个方块在被选中后会显示谁控制它。再做一些工作,这个游戏就能看起来很棒,也能玩得很好。

调整字体

现在我们已经有了一个基本可玩的游戏,我们需要让它看起来更好一些。我们将添加按钮图片,并选择一些新的字体大小和颜色,使所有内容更具可读性:

  1. 让我们从按钮开始。选择一个按钮元素,你会在检查器中看到它由一个图像(脚本)组件和一个按钮(脚本)组件组成。第一个组件控制当 GUI 元素静止时它的外观。第二个组件控制当玩家与之互动时它的变化以及这会触发哪些功能:

    • 源图像:这是当元素静止未被玩家触碰时显示的基础图像。

    • 颜色:这控制着正在使用的图像的着色和淡化。

    • 材质:这允许你使用可能在 3D 模型上使用的纹理或着色器。

    • 图像类型:这决定了图像如何被拉伸以填充可用空间。通常,它会设置为切片,这是用于使用边框的图像,可以选择根据填充中心复选框用颜色填充。否则,它通常会设置为简单,例如,当你使用普通图像时,可以防止保持宽高比的框被奇数大小的矩形变换拉伸。

    • 可交互:这简单地切换玩家是否能够点击按钮并触发功能。

    • 过渡:这改变了当玩家与按钮交互时按钮的反应方式。颜色色调会使按钮在交互时改变颜色。SpriteSwap会在交互时改变图像。动画将允许你为状态之间的过渡定义更复杂的动画序列。

    • 目标图形是用于在屏幕上绘制按钮的基础图像的引用。

    • 正常槽、高亮槽、按下槽和禁用槽定义了当按钮未被交互或被鼠标悬停,或者玩家点击按钮并且按钮已被关闭时使用的效果或图像。

  2. 对于我们的每个按钮,我们需要从项目面板将ButtonNormal图像拖到源图像槽中。

  3. 接下来,点击颜色槽右侧的白框以打开颜色选择器。为了防止我们的按钮变暗,我们需要将A滑块移到最右边或把盒子设置为255

  4. 我们希望当按钮被按下时改变图像,因此将过渡改为SpriteSwap

  5. 移动设备几乎无法悬停在 GUI 元素上,因此我们不需要担心高亮状态。然而,我们确实想要将我们的ButtonActive图像添加到Pressed Sprite槽中,这样当玩家触摸按钮时,它就会切换。

  6. 按钮方块在有人点击之前应该是空的,因此我们需要删除文本元素。最简单的方法是选择每个按钮下的元素并删除它。

  7. 接下来,我们需要改变每个图像元素的文本子项。是文本(脚本)组件允许我们控制文本如何在屏幕上绘制。

    • 文本:这是我们能够更改将在屏幕上绘制的文本的区域。

    • 字体:这允许我们选择项目中任何字体文件用于文本。

    • 字体样式:这将允许你调整文本的粗体和斜体特性。

    • 字体大小:这是文本的大小。这就像在你喜欢的文字处理软件中选择字体大小一样。

    • 行间距:这是每行文本之间的距离。

    • 富文本:这将允许你使用一些特殊的 HTML 样式标签,仅对部分文本应用颜色、斜体等效果。

    • 对齐方式:这会改变文本在框中居中的位置。前三个框调整水平位置。后三个框改变垂直位置。

    • 水平溢出/垂直溢出:这些调整文本是否可以绘制在框外,换行还是裁剪。

    • 最佳适应:这将自动调整文本的大小,以适应动态大小变化的元素,在最小最大值之间。

    • 颜色/材质:这些会改变文本在绘制时的颜色和纹理。

    • 阴影(脚本):这个组件为文本添加了一个阴影效果,就像你在 Photoshop 中添加的那样。

  8. 对于我们的每个文本元素,我们需要使用120Font Size,并且Alignment应该居中。

  9. 对于Turn Indicator文本元素,我们还需要使用120Font Size,并且需要将其居中。

  10. 需要做的最后一件事是更改文本元素的颜色为深灰色,这样我们就可以轻松地将其与我们按钮的颜色区分开来:调整字体

现在,我们的游戏板运作良好,看起来也很棒。尝试为按钮添加自己的图片。你需要两张图片,一张是按钮静止时的,另一张是按钮被按下时的。此外,默认的 Arial 字体很乏味。为你的游戏找一个新字体;你可以像导入其他游戏资源一样导入它。

旋转设备

如果你到目前为止一直在测试你的游戏,你可能已经注意到,当我们横持设备时,游戏看起来才好看。当设备竖持时,由于正方形和回合指示器试图共享可用的少量水平空间,所有内容都会变得拥挤。由于我们已经为一种布局模式设置好了游戏板,因此为另一种模式复制它就变得相当简单了。然而,这确实需要复制我们的大部分代码,才能使其正常工作:

  1. 要复制我们的游戏板,右键点击它并从新菜单中选择Duplicate(复制)。将复制的游戏板重命名为GameBoard_Portrait。这将是在玩家设备处于竖屏模式时使用的游戏板。为了在制作更改时查看更改,请关闭横屏游戏板,并从Game窗口左上角的下拉列表中选择3:2 Portrait (2:3)

  2. 选择GameBoard_Portrait下的Board对象。在其Inspector面板中,我们需要将锚点改为使用屏幕的上三分之二,而不是左三分之二。将Min X设为0Min Y设为0.33Max XMax Y都设为1即可实现这一点。

  3. 接下来,需要选择Turn Indicator并将其移到屏幕底部三分之一的位置。将Min XMin Y设为0Max X设为1Max Y设为0.33,在这里效果会很好。

  4. 现在我们已经设置好了第二个游戏板,我们需要在代码中为它腾出空间。因此,打开TicTacToeControl脚本,并滚动到顶部,这样我们就可以从一些新变量开始。

  5. 我们将要添加的第一个变量将让我们能够访问屏幕竖屏模式下的回合指示器:

    public Text turnIndicatorPortrait;
    
  6. 接下来的三个变量将跟踪按钮、正方形图片和所有者文本信息。这些就像我们之前创建的三个列表,用于在横屏模式下跟踪游戏板:

    public GameObject[] buttonsPortrait;
    public Image[] squaresPortrait;
    public Text[] squareTextsPortrait;
    
  7. 在我们脚本顶部要添加的最后两个变量是为了跟踪实际绘制游戏面板的两个画布对象。我们需要这些以便在用户翻转设备时切换它们:

    public GameObject gameBoardGroupLandscape;
    public GameObject gameBoardGroupPortrait;
    
  8. 然后,我们需要更新一些函数,使它们对两个面板进行更改,而不仅仅是横屏面板。这两行代码用于在玩家点击时关闭竖屏面板的按钮并激活方块。它们需要放在我们使用SetActive对横屏的按钮和方块进行操作的代码后的ButtonClick函数的开始部分:

    buttonsPortrait[squareIndex].SetActive(false);
    squaresPortrait[squareIndex].gameObject.SetActive(true);
    
  9. 这两行代码更改了Portrait集中控制方块的图片和文本,以支持 X 玩家。它们放在ButtonClick函数的if语句内,紧接在为横屏集做相同操作的两行代码之后:

    squaresPortrait[squareIndex].sprite = xImage;
    squareTextsPortrait[squareIndex].text = "X";
    
  10. 这行代码放在同一if语句的末尾,更改Portrait集的轮次指示文本:

    turnIndicatorPortrait.text = "O's Turn";
    
  11. 接下来的两行代码更改图片和文本,以支持 O 玩家。它们放在对Landscape集进行相同操作的代码之后,位于ButtonClick函数的else语句内:

    squaresPortrait[squareIndex].sprite = oImage;
    squareTextsPortrait[squareIndex].text = "O";
    
  12. 这是我们需要添加到ButtonClick函数的最后一条代码;它需要放在else语句的末尾。它只是更改表示轮到谁的文本:

    turnIndicatorPortrait.text = "X's Turn";
    
  13. 接下来,我们需要创建一个新的函数,用于控制在玩家改变设备方向时游戏面板的更改。我们将从定义Update函数开始。这是一个由 Unity 每帧调用的特殊函数。它将允许我们检查每一帧的方向是否发生了变化:

    public void Update() {
    
  14. 函数以一个if语句开始,该语句使用Input.deviceOrientation来找出玩家当前的持握方式。它与LandscapeLeft方向进行比较,以查看设备是否被横向持握,主页按钮在左侧。如果结果为真,则关闭Portrait集的 GUI 元素,同时打开Landscape集:

    if(Input.deviceOrientation == DeviceOrientation.LandscapeLeft) {
      gameBoardGroupPortrait.SetActive(false);
      gameBoardGroupLandscape.SetActive(true);
    }
    
  15. 下一个else if语句检查如果主页按钮向下,是否为Portrait方向。如果为true,则打开Portrait并关闭Landscape设置:

    else if(Input.deviceOrientation == DeviceOrientation.Portrait) {
      gameBoardGroupPortrait.SetActive(true);
      gameBoardGroupLandscape.SetActive(false);
    }
    
  16. 这个else if语句用于检查当主页按钮在右侧时是否为LanscapeRight方向:

    else if(Input.deviceOrientation == DeviceOrientation.LandscapeRight) {
      gameBoardGroupPortrait.SetActive(false);
      gameBoardGroupLandscape.SetActive(true);
    }
    
  17. 最后,我们检查PortraitUpsideDown方向,即主页按钮在设备顶部时。别忘了额外的括号来结束并关闭函数:

    else if(Input.deviceOrientation == DeviceOrientation.PortraitUpsideDown) {
      gameBoardGroupPortrait.SetActive(true);
      gameBoardGroupLandscape.SetActive(false);
    }
    }
    
  18. 现在我们需要回到 Unity,选择我们的GameControl对象,以便我们可以设置新的Inspector属性。

  19. 将来自肖像游戏面板的各种部件从层级拖放到检查器中的相关槽位,将转向指示器拖到转向指示器肖像槽位,按钮按顺序拖到按钮肖像列表,方块到方块肖像,以及它们的文本子对象到方块文本肖像

  20. 最后,将GameBoard_Portrait对象拖放到游戏面板组肖像槽位中。旋转设备

现在我们应该能够玩我们的游戏,并在改变设备方向时看到面板切换。由于编辑器和电脑本身没有像移动设备那样的设备方向,你将需要在你的设备上构建项目或使用 Unity 远程连接。确保将你的游戏窗口的显示模式设置为左上角的远程,以便在使用 Unity 远程时与你的设备一起更新。

菜单和胜利

我们的游戏几乎完成了。我们还需要以下内容:

  • 一个允许玩家开始新游戏的开始菜单

  • 一段用于检查是否有人赢得游戏的代码

  • 一个用于显示谁赢得了游戏的游戏结束菜单

设置元素

与游戏面板相比,我们的两个新菜单将相当简单。开始菜单将包括我们游戏的标题图像和一个按钮,而游戏结束菜单将有一个显示胜利消息的文本元素和一个返回主菜单的按钮。下面是设置元素的操作步骤:

  1. 让我们从开始菜单开始,创建一个新的画布,就像我们之前做的那样,并将其重命名为OpeningMenu。这将使我们能够将其与其他创建的屏幕区分开来。

  2. 接下来,菜单需要一个图像元素和一个按钮元素作为子对象。

  3. 为了使一切更容易操作,通过它们检查器窗口顶部的复选框关闭游戏面板。

  4. 对于我们的图像对象,我们可以将标题图像拖到源图像槽位。

  5. 对于图像的矩形变换,我们需要将Pos XPos Y的值设置为0

  6. 我们还需要调整宽度高度。我们将匹配原始图像的尺寸,这样它就不会被拉伸。为宽度设置一个值320,为高度设置一个值160

  7. 要将图像移动到屏幕上半部分,在Pivot Y槽位中放入一个0。这将改变图像的定位基准。

  8. 对于按钮的矩形变换,我们同样需要在Pos XPos Y中输入值0

  9. 我们需要为宽度再次输入一个值320,但这次我们希望高度的值为100

  10. 要将其移动到屏幕下半部分,我们需要在Pivot Y槽位中输入一个值1

  11. 接下来,我们需要为按钮设置图像,就像之前为游戏板所做的那样。将ButtonNormal图像放入源图像槽中。将过渡更改为精灵交换,并将ButtonActive图像放入按下精灵槽中。别忘了将颜色更改为颜色选择器中的A值为255,这样我们的按钮就不会部分褪色。

  12. 最后,为了使此菜单更改按钮文本,请在层次结构中展开按钮并选择文本子对象。

  13. 在此对象的检查器面板中,文本下方是一个文本字段,我们可以在其中更改按钮上显示的文本。这里的值设置为新游戏会很合适。同时,将字体大小更改为45,这样我们才能实际阅读它。设置元素

  14. 接下来,我们需要创建游戏结束菜单。因此,关闭我们的开场菜单并为游戏结束菜单创建一个新的画布。将其重命名为GameOverMenu,以便我们可以继续保持组织性。

  15. 对于此菜单,我们需要一个文本元素和一个按钮元素作为其子项。

  16. 我们将几乎与上一个完全相同的方式设置这个。文本和按钮都需要在Pos XPos Y槽中具有0的值,以及320宽度值。

  17. 文本将使用160高度0Pivot Y。我们还需要将字体大小设置为80。你可以更改默认文本,但无论如何它都会被我们的代码覆盖。

  18. 要使菜单中的文本居中,请从对齐属性旁边的两组按钮中选择中间的按钮。

  19. 按钮将使用100高度1Pivot Y

  20. 同时,请确保将源图像颜色过渡按下精灵设置为适当的图像和设置。

  21. 需要设置的最后一项是按钮的文本子项。将默认文本设置为主菜单,并将字体大小设置为45设置元素

这样就完成了我们的菜单设置。我们有所有让玩家与游戏互动所需的屏幕。唯一的问题是,我们没有实现任何功能让它们实际执行操作。

添加代码

为了使我们的游戏板按钮起作用,我们不得不在脚本中创建一个函数,它们可以引用并在被触摸时调用。主菜单的按钮将开始新游戏,而游戏结束菜单的按钮将切换屏幕至主菜单。我们还需要创建一小段代码,以便在开始新游戏时清除并重置游戏板。如果我们不这样做,玩家将无法在需要重新启动整个应用程序之前玩超过一轮的游戏。

  1. 打开TicTacToeControl脚本,这样我们可以对其进行更多修改。

  2. 我们将在脚本顶部添加三个变量。前两个将跟踪两个新菜单,使我们能够根据需要打开或关闭它们。第三个是用于游戏结束屏幕中的文本对象,它将根据游戏结果给我们提供显示消息的能力。

  3. 接下来,我们需要创建一个新函数。NewGame函数将被主菜单中的按钮调用。其目的是重置棋盘,这样我们就可以继续玩,而无需重置整个应用程序:

    public void NewGame() {
    
  4. 该函数首先将游戏设置为从 X 玩家的回合开始。然后创建一个SquareStates的新数组,这实际上会清除旧的棋盘。然后设置横屏竖屏两组控制的回合指示:

    xTurn = true;
    board = new SquareState[9];
    turnIndicatorLandscape.text = "X's Turn";
    turnIndicatorPortratit.text = "X's Turn";
    
  5. 然后,我们遍历竖屏横屏控制的九个按钮和方块。所有按钮都通过SetActive打开,方块关闭,这就像点击检查器面板左上角的小复选框一样:

    for(int i=0;i<9;i++) {
      buttonsPortrait[i].SetActive(true);
      squaresPortrait[i].gameObject.SetActive(false);
    
      buttonsLandscape[i].SetActive(true);
      squaresLandscape[i].gameObject.SetActive(false);
    }
    
  6. 代码的最后三行控制当我们切换到游戏板时哪些屏幕可见。默认情况下,它选择打开横屏板并确保竖屏板关闭。然后关闭主菜单。别忘了最后的括号来结束函数:

      gameBoardGroupPortrait.SetActive(false);
      gameBoardGroupLandscape.SetActive(true);
      mainMenuGroup.SetActive(false);
    }
    
  7. 接下来,我们需要在ButtonClick函数的末尾添加一行代码。这是一个简单的调用,用于检查在处理完按钮和方块后是否有人赢得了游戏:

    CheckVictory();
    
  8. CheckVictory函数遍历游戏中可能获胜的组合。如果它找到连续三个匹配的方块,将调用SetWinner函数,当前游戏将结束:

    public void CheckVictory() {
    
  9. 在这个游戏中,连续三个匹配的方块组成一次胜利。我们从被循环标记的列开始检查。如果第一个方块不是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;
      }
    }
    
  10. 下面的代码片段与刚才看到的if语句基本相同。然而,这些代码检查对角线。如果条件为true,再次发送到另一个函数以更改游戏屏幕。你可能也注意到了函数调用后的返回。如果在任何一点找到胜者,就没有必要检查棋盘的其余部分。因此,我们将提前退出CheckVictory函数:

    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;
    }
    
  11. 这是我们的CheckVictory函数的最后一点。如果没有人赢得游戏,由函数的先前部分判断,我们必须检查平局。这是通过检查游戏板的所有格子来完成的。如果其中任何一个格子是Clear,游戏尚未结束,我们退出函数。但是,如果我们遍历整个循环而没有找到一个Clear的格子,我们通过宣布平局来设定胜者:

    for(int i=0;i<board.Length;i++) {
      if(board[i] == SquareState.Clear)
        return;
    }
    SetWinner(SquareState.Clear);
    }
    
  12. 接下来,我们创建一个SetWinner函数,该函数在CheckVictory函数中被反复调用。这个函数传递了谁赢得了游戏的信息,它最初会开启游戏结束屏幕并关闭游戏板:

    public void SetWinner(SquareState toWin) {
      gameOverGroup.SetActive(true);
      gameBoardGroupPortrait.SetActive(false);
      gameBoardGroupLandscape.SetActive(false);
    
  13. 然后,函数检查谁赢得了比赛,并为victorText对象选择一个适当的信息:

      if(toWin == SquareState.Clear) {
        victorText.text = "Tie!";
      }
      else if(toWin == SquareState.XControl) {
        victorText.text = "X Wins!";
      }
      else {
        victorText.text = "O Wins!";
      }
    }
    
  14. 最后,我们有BackToMainMenu函数。这个函数简短而精炼;它只是被游戏结束屏幕上的按钮调用,以切换回主菜单:

    public void BackToMainMenu() {
      gameOverGroup.SetActive(false);
      mainMenuGroup.SetActive(true);
    }
    

这就是我们在游戏中拥有的所有代码。我们拥有了构成游戏的所有视觉部分,现在我们也拥有了所有功能部分。最后一步是将它们组合起来,完成游戏。

将它们组合起来

我们已经有了代码和菜单。一旦将它们连接起来,我们的游戏就完成了。为了完成这一切,请执行以下步骤:

  1. 回到 Unity 编辑器,从Hierarchy面板中选择GameControl对象。

  2. 它的Inspector窗口中的三个新属性需要填写。将OpeningMenu画布拖到Main Menu Group槽中,将GameOverMenu拖到Game Over Group槽中。

  3. 同时,找到GameOverMenu的文本对象子级,并将其拖到Victor Text槽中。

  4. 接下来,我们需要为每个菜单连接按钮功能。首先选择OpeningMenu画布的按钮对象子级。

  5. 点击其Button (Script)组件右下角的小加号,以添加新的功能槽。

  6. 点击新槽中心的圆圈,并从新弹出的窗口中选择GameControl,就像我们对每个游戏板按钮所做的那样。

  7. 当前显示No Function的下拉列表是我们的下一个目标。点击它,然后导航到TicTacToeControl | NewGame ()

  8. 重复这几个步骤,为GameOverMenu的子按钮添加功能。不过,从列表中选择BackToMainMenu()

  9. 最后要做的就是使用Inspector左上角的复选框关闭游戏板和游戏结束菜单。只留下开场菜单,这样当我们玩游戏时,游戏将从那里开始。

恭喜!这就是我们的游戏。我们的所有按钮都已设置,我们拥有多个菜单,甚至还创建了一个根据玩家设备方向改变的游戏板。最后要做的就是为我们的设备构建它,并展示出来。

为设备构建的更好方法。

现在,是每个人渴望了解的构建过程部分。有一个更快更简单的方法来构建你的游戏并在你的 Android 设备上玩。长而复杂的方法仍然非常值得一知。如果这个简短的方法失败了,而且在某个时候它会失败,了解长方法有助于你调试任何错误。另外,简短路径只适合为单个设备构建。如果你有多个设备和一个大项目,使用简短的构建过程加载它们将需要更多的时间。按照以下步骤操作:

  1. 首先,打开构建设置窗口。记住,它可以在 Unity 编辑器顶部的文件下找到。

    如果你还没有这样做,保存你的场景。保存场景的选项也可以在 Unity 编辑器顶部的文件下找到。

  2. 点击添加当前按钮,将我们当前的场景(也是唯一一个场景)添加到构建中的场景列表中。如果这个列表是空的,就没有游戏。

  3. 如果您还没有这样做,请确保将您的平台更改为Android。毕竟,这是这本书的重点。

  4. 不要忘记设置玩家设置。点击玩家设置按钮,在检查器窗口中打开它们。你可能还记得我们在第一章中提到过,Saying Hello to Unity and Android

  5. 在顶部,设置公司名称产品名称字段。这些字段分别设置为TomPacktAndroidCh2 TicTacToe,将匹配包含的已完成项目。记住,这些字段会被玩你游戏的人看到。

  6. 其他设置下的捆绑标识符字段也需要设置。格式仍然是com.CompanyName.ProductName,所以com.TomPacktAndroid.Ch2.TicTacToe会很好用。为了在设备上看到我们酷炫的动态 GUI,还有一个设置应该更改。点击分辨率展示以展开选项。

  7. 我们关注的是默认方向。默认是纵向,但这个选项意味着游戏将被固定在纵向显示模式。点击下拉菜单,选择自动旋转。这个选项告诉 Unity 无论游戏是被持在哪个方向,都会自动调整游戏使其直立。

    当选择自动旋转时弹出的新选项集允许限制支持的方向。也许你正在制作一个需要更宽并且横屏持握的游戏。通过取消勾选纵向纵向倒置,Unity 仍然会进行调整(但只针对剩余的方向)。

    注意

    在你的 Android 设备上,控制按钮位于较短的边之一;这些通常是主页、菜单和返回或最近应用按钮。这一侧通常被认为是设备的底部,这些按钮的位置决定了每个方向。纵向模式是指这些按钮相对于屏幕向下。横向右模式是指它们位于右侧。这种模式开始变得清晰,不是吗?

  8. 现在,保留所有方向选项的勾选状态,我们将返回到构建设置

  9. 下一步(这是非常重要的)是将你的设备连接到电脑上,并给它一点时间以被识别。如果你的设备不是第一个连接到电脑的设备,这条简短的构建路径将会失败。

  10. 构建设置窗口的右下角,点击构建并运行按钮。系统会要求你给应用程序文件,即 APK,一个合适的名字,并将其保存到适当的位置。一个像Ch2_TicTacToe.apk这样的名字就很好,并且可以将其保存在桌面上。

  11. 点击保存,然后坐下来欣赏所提供的精彩加载条。如果你注意到了我们在第一章中的Hello World项目中构建的加载条,你会发现这次我们多了一个步骤。应用程序构建完成后,会有一个推送至设备的步骤。这意味着构建成功,Unity 现在正在将应用程序安装到你的设备上。完成这一步后,游戏将在设备上启动,加载完成。

我们刚刚了解了构建并运行按钮,这是由构建设置窗口提供的。这种方法快速、简单,且无需使用命令提示行的痛苦;这样简短的构建路径不是很棒吗?然而,如果构建过程由于任何原因失败,包括无法找到设备,应用程序文件将不会被保存。如果你想再次尝试安装,就必须重新进行整个构建过程。这对于我们简单的井字游戏来说并不算太糟糕,但对于较大的项目可能会消耗很多时间。此外,在构建时你只能将一个 Android 设备连接到电脑上。如果连接更多设备,构建过程肯定会失败。而且 Unity 在完成可能很长的构建过程之后才会检查多个设备。

除了这些注意事项之外,构建并运行选项真的相当不错。让 Unity 处理将游戏传送到设备上的复杂部分。这为我们提供了更多的时间来专注于测试和制作一款伟大的游戏。

如果你想要一个挑战,这是一个艰难的任务:创建单人模式。你将需要从添加一个额外的按钮开始,这个按钮位于开场屏幕上,用于选择第二种游戏模式。任何计算机玩家的逻辑都应该放在Update函数中。同时,查看Random.Range以随机选择一个方块进行控制。否则,你可以多做一点工作,让计算机寻找可以获胜或创建两个匹配行的方块。

总结

在这一点上,你应该已经熟悉了 Unity 的新 uGUI 系统,包括如何定位 GUI 元素,根据需要设置它们的样式,以及向它们添加功能。

在本章中,我们通过创建一个井字游戏,学习了关于 GUI 的所有内容。我们首先熟悉了创建按钮和其他要在游戏的 GUI 画布上绘制的对象。在深入改善游戏的外观之后,我们继续通过为游戏板添加动态方向来改进它。我们创建了一个开场和结束屏幕,以完善游戏体验。最后,我们探索了将游戏部署到设备上的另一种构建方法。

在下一章中,我们将开始创建一个全新且更复杂游戏。我们将要制作的坦克大战游戏,将用于了解任何游戏的基本构建块:网格、材质和动画。当一切完成时,我们将能够在多彩的城市中驾驶坦克并射击动画目标。

第三章:任何游戏的核心——网格、材质和动画

在上一章中,我们了解了 GUI。我们从创建一个简单的井字游戏开始,学习游戏的基本组成部分。然后通过改变游戏的外观并使游戏板支持多种屏幕方向来继续。最后,我们完成了一些菜单的制作。

本章将介绍任何游戏的核心:网格、材质和动画。没有这些基础,通常没有东西可以展示给玩家。当然,你也可以只使用 GUI 中的平面图像。但这样有什么乐趣呢?既然你选择了 3D 游戏引擎,不妨充分利用它的功能。

为了理解网格、材质和动画,我们将创建一个坦克大战游戏。这个项目将在其他章节中使用。到本书结束时,这将是我们创建的两个完整游戏之一。在本章中,玩家将驾驶坦克在一个小城市中四处移动,他们能够射击动画目标,我们还将添加一个计数器来跟踪分数。

本章包括以下主题:

  • 导入网格

  • 创建材质

  • 动画

  • 创建预制体

  • 光线追踪

在本章中,我们将开始一个新项目,请按照第一部分来启动。

设置准备

尽管这个项目最终会比之前的更大,但实际设置与前一个项目类似,并不复杂。这个项目你需要一些起始资源,这些将在设置过程中进行描述。由于这些资源的复杂性和特定性,建议现在使用本书代码包中提供的资源。

与前两章一样,我们需要创建一个新项目,以便开发下一款游戏。显然,首先要做的就是启动一个新的 Unity 项目。为了便于组织,将其命名为Ch3_TankBattle。以下是启动本项目所需的前提条件:

  1. 这个项目也会比我们之前的项目变得更大,因此我们应该创建一些文件夹来保持组织性。首先,创建六个文件夹。顶级文件夹将是ModelsScriptsPrefabs文件夹。在Models内创建EnvironmentTanksTargets。拥有这些文件夹使得项目管理起来更加容易。任何完整的模型可以包含一个网格文件,一个或多个纹理,每个纹理对应一个材质,以及可能包含数十个动画文件。

  2. 在继续之前,如果你还没有这样做,最好是将你的目标平台改为 Android。每次更改目标平台,项目中的所有资源都需要重新导入。这是 Unity 自动执行的一步,但随着项目的增长,这将花费越来越多的时间。在项目中有任何内容之前设置目标平台,我们可以节省很多时间。

  3. 我们还将利用 Unity 一个非常强大的部分:预制体。这些特殊对象使创建游戏的过程大大简化。这个名字意味着预先制造的——事先创建并复制的。对我们来说,这意味着我们可以完全设置一个坦克射击的目标,并将其转换成预制体。然后,我们可以在游戏世界中放置预制体的实例。如果我们需要更改目标,只需修改原始预制体即可。对预制体所做的任何更改也会应用于该预制体的任何实例。别担心,使用时它会更有意义。

  4. 我们需要为这个项目创建一些网格和纹理。首先,我们需要一辆坦克(如果没有坦克,进行坦克大战是有点困难的)。这个代码包中提供的坦克有一个炮塔和一门大炮,这些都是独立的部件。我们还将使用一个技巧,让坦克的履带看起来像是在移动,所以它们每个都是独立的部件,并使用单独的纹理。

  5. 最后,我们需要一个动画目标。本书代码包中提供的那个像人的手臂一样装有牛眼的手。它有四个动画。第一个从卷曲的位置开始,移动到伸展的位置。第二个与第一个相反,从伸展的位置回到卷曲的位置。第三个从伸展的位置开始,向后弹起,就像从前面被打到,然后回到卷曲的位置。最后一个与第三个类似,但是它是向前移动,就像是从后面被打到一样。这些动画相当简单,但它们将帮助我们很好地了解 Unity 的动画系统。

这里发生的事情很少;我们只是创建了一个项目并添加了一些文件夹。我们还简要讨论了将为本章项目使用哪些资源。

导入网格

有几种方法可以将资源导入 Unity。我们将介绍最简单(也可能是最好)的方法来导入资源组。让我们开始吧:

  1. 在 Unity 编辑器中,首先在你的Tanks文件夹上右键点击,然后从菜单中选择在资源管理器中显示

  2. 这会打开包含所选择资源的文件夹。在本例中,Models文件夹在 Windows 文件夹浏览器中打开。我们只需将坦克及其纹理放入Tanks文件夹中。

    注意事项

    本章提供的文件有 Tank.blendTanks_Type01.pngTankTread.png。此外,在 Unity 中使用 .blend 文件需要在你的系统中安装 Blender。Blender 是一个免费的建模程序,可在 www.blender.org 获取。Unity 利用它将前述文件转换成可以完全利用的文件。

  3. 当我们回到 Unity,它会检测到我们添加的文件,并自动导入。这是 Unity 最好的特点之一。无需明确告诉 Unity 导入。如果项目资产内部发生变化,它会自动更新资产。

  4. 你可能还会注意到,当 Unity 导入我们的坦克时,会创建一个额外的文件夹和一些文件。每当导入新网格时,默认情况下 Unity 会尝试将其与材质配对。下一节将详细介绍 Unity 中的材质是什么。现在,它是一个跟踪如何在网格上显示纹理的对象。根据网格中的信息,Unity 在项目中查找具有正确名称的材质。如果找不到,将在网格旁边创建一个 Materials 文件夹,并在其中创建缺失的材质。创建这些材质时,Unity 也会查找正确的纹理。这就是为什么将纹理与网格同时添加到文件夹中很重要,以便它们可以一起导入。如果你没有在导入坦克的同时添加纹理,关于创建材质的部分将介绍如何将纹理添加到材质中。

我们已经将坦克导入 Unity。这真的很简单。对项目中的任何资产或文件夹所做的更改都会被 Unity 自动检测到,并根据需要相应地导入。

坦克导入设置

将任何资源导入 Unity 是通过使用一组默认设置完成的。这些设置都可以从检查器窗口进行更改。选中你的新坦克后,我们将在这里介绍模型导入设置:

坦克导入设置

如前一张截图所示,在检查器窗口顶部有三个标签页:模型绑定动画模型页面处理网格本身,而绑定动画用于导入动画。目前我们只关心模型页面,如果尚未选择,请选择它。下面将详细介绍模型页面的每个部分。

网格

前一张截图中的网格部分有以下选项:

  • 导入设置窗口中的网格部分以缩放因子属性开始。这是一个告诉 Unity 网格默认大小的值。你的建模程序中的一个通用单位或一米转换为 Unity 中的一个单位。这个坦克是以通用单位制作的,所以坦克的缩放因子是 1。如果你在制作坦克时使用的是厘米,那么缩放因子将是 0.01,因为厘米是米的一百分之一。

  • 文件缩放选项是原始创建模型时建模程序中使用的缩放。它主要是信息性的。如果你需要调整导入模型的大小,请调整缩放因子

  • 下一个选项,网格压缩,在我们讨论游戏优化时将在最后一章变得非常重要。压缩设置得越高,游戏中文件的大小就会越小。然而,这也会开始让你的网格出现一些奇怪的现象,因为 Unity 会尝试使其更小。现在,将其设置为关闭

  • 如果你想在游戏运行时对网格进行修改,读/写启用选项将非常有用。这使得你可以实现一些非常酷的功能,比如可破坏的环境,你的脚本可以根据被射击的位置将网格分割成碎片。然而,这也意味着 Unity 需要在内存中保留网格的一个副本,如果它很复杂,这可能会让系统开始变得卡顿。这超出了本书的范围,因此取消选中此选项是个好主意。

  • 优化网格选项是一个好的选择,除非你对网格有特定的高级操作。开启这个选项,Unity 会进行一些特殊的“幕后”处理。在计算机图形学中,尤其是在 Unity 中,每个网格最终都是由一系列在屏幕上绘制的三角形组成。此选项允许 Unity 重新排列文件中的三角形,以便更快、更容易地绘制整个网格。

  • 导入混合形状选项允许 Unity 理解模型中可能包含的任何混合形状。这些是模型顶点的动画位置。通常,它们用于面部动画。下一个选项,生成碰撞器,在进行物理方面的复杂操作时非常有用。Unity 有一组简单的碰撞器形状,应该尽可能使用,因为它们更容易处理。然而,在某些情况下,它们可能无法完全完成任务;例如,瓦砾或半管,其中碰撞形状太复杂,无法用一系列简单的形状制作。这就是为什么 Unity 有一个网格碰撞器组件。选中此选项后,将为模型中的每个网格添加一个网格碰撞器组件。本章我们将坚持使用简单的碰撞器,所以将生成碰撞器选项关闭。

  • 交换 UV生成光照贴图 UV选项主要用于处理光照,尤其是光照贴图时。Unity 可以处理模型上的两套 UV 坐标。通常,第一套用于纹理,第二套用于光照贴图或阴影纹理。如果它们的顺序错误,交换 UV会将它们更改,使得第二套先出现。如果你需要一个光照贴图的展开,但并未创建一个,生成光照贴图 UV将为你创建一个。在这个项目中我们不使用光照贴图,所以这两个选项可以保持关闭。

法线与切线

早期截图中的法线与切线部分有以下选项:

  • 下一个选项部分,法线与切线,从法线选项开始。这定义了 Unity 如何保存你的网格的法线。默认情况下,它们是从文件中导入的;然而,也有一个选项让 Unity 根据网格的定义方式计算它们。否则,如果我们将此选项设置为,Unity 将不会导入法线。如果我们希望网格受到实时光照的影响或使用法线贴图,就需要法线。在这个项目中我们将使用实时光照,所以将其设置为导入

  • 如果你的网格具有法线贴图,那么切线平滑角度分割切线选项将派上用场。切线用于确定光照如何与法线贴图表面交互。默认情况下,Unity 会为你计算这些。导入切线仅限于几种文件类型。基于两个面之间角度的平滑角度,决定了边缘的着色是平滑还是锐利。分割切线选项用于处理一些特定的光照问题。如果光照被接缝破坏,启用此选项将修复它。法线贴图非常适合让低分辨率游戏看起来像高分辨率游戏。然而,由于使用它们需要额外的文件和信息,它们并不适合移动游戏。因此,在本书中我们不使用它们,这些选项都可以关闭以节省内存。

  • 保持四边形选项将允许你的模型利用 DirectX 11 的新镶嵌技术,从低细节模型和特殊的位移贴图创建高细节模型。不幸的是,移动设备支持这种细节还需要一段时间,而要成为普遍现象则需要更长时间。

材质

前一个截图中的材质部分有以下选项:

  • 最后一个部分,材质,定义了 Unity 应该如何查找材质。第一个选项,导入材质,允许你决定是否导入材质。如果关闭,将应用默认的白色材质。这种材质在项目中任何地方都不会显示;它是一个隐藏的默认值。对于不会有任何纹理的模型,比如碰撞网格,可以关闭这个选项。对于我们坦克模型以及几乎其他所有情况,应该保持开启状态。

  • 最后两个选项,材质命名材质搜索,共同作用于为网格命名和查找材质。在它们下面直接是一个文本框,描述了 Unity 将如何搜索材质。

    • 要搜索的材质名称可以是建模程序中使用的纹理名称、建模程序中创建的材质名称,或者是模型和材质的名称。如果找不到纹理名称,将使用材质名称。

    • 默认情况下,Unity 会进行递归向上搜索。这意味着我们从Materials文件夹开始搜索,然后查找同一文件夹中的任何材质。接着检查父文件夹是否有匹配的材质,然后是上一级文件夹。如此继续,直到找到具有正确名称的材质,或者到达根资产文件夹。

    • 另外,我们还可以选择检查整个项目,或者只在我们模型旁边的Materials文件夹中查找。这些选项的默认设置已经很好了。通常,它们不需要更改。特别是对于大型项目,可以使用 Unity 编辑器脚本轻松处理,本书将不涉及这部分内容。

恢复和应用按钮

接下来,截图中有恢复应用按钮,下面将对此进行解释:

  • 每当对导入设置进行更改时,必须选择两个按钮中的一个,恢复应用恢复按钮取消更改,并将导入设置恢复到更改之前的状态。应用按钮确认更改,并使用新设置重新导入模型。如果没有选择这些按钮,Unity 会弹出一个对话框并强制你做出选择,然后才能进行其他操作。恢复和应用按钮

  • 最后,我们可以看到如前截图所示有两种预览类型。Imported Object部分是如果我们将对象添加到Scene视图并选择它,在Inspector窗口中对象外观的预览。Preview窗口,我们可以在其中看到坦克模型的区域,是模型在Scene视图中的样子。你可以在该窗口中点击并拖动对象来旋转它,并从不同的角度观察它。此外,在这个窗口中有一个小蓝按钮。点击这个按钮,你将能够给对象添加标签。然后,这些标签也可以在Project窗口中进行搜索。

设置坦克

既然我们已经导入了坦克,我们需要对其进行设置。我们将调整坦克的布局,并创建一些脚本。

坦克

在这一点上,我们创建坦克的主要工作将包括创建和排列坦克的组件。使用以下步骤,我们可以设置我们的坦克:

  1. 首先,从Project窗口将坦克拖到Hierarchy窗口。你会注意到坦克的名字在Hierarchy窗口中以蓝色显示。这是因为它是一个预制体实例。你的项目中的任何模型在很大程度上都像预制体。然而,我们希望我们的坦克不仅仅是放在那里;所以,作为一个静态网格的预制体是没有帮助的。因此,在Hierarchy窗口中选择你的坦克,我们将开始通过移除Animator组件使其变得有用。为此,在Inspector窗口中选择 Animator 组件右侧的齿轮。从新的下拉列表中选择Remove Component,如下截图所示,它将被移除:The tank

  2. 如果你正在使用默认提供的坦克,选择它的不同部分,你会发现所有的轴心点都在底部。这对于使我们的炮塔和炮管正确旋转并不有用。解决这个问题的最简单方法就是添加新的空GameObject作为轴心点。

    注意事项

    场景中的任何物体都是一个GameObject。任何空的GameObject只包含一个Transform组件。

  3. 在 Unity 编辑器的顶部,Create EmptyGameObject按钮下的第一个选项。它创建了我们所需要的物体。创建两个空的 GameObject,并将一个定位在炮塔底部,另一个定位在炮管底部。此外,分别将它们重命名为TurretPivotCannonPivot。如果选择了物体,这可以通过Inspector窗口顶部的文本框来完成。

  4. 层次结构窗口中,将 TurretPivot 拖到 Tank 上。这将改变 TurretPivot 的父对象为 Tank。然后,将对象(即炮塔网格)拖到 TurretPivot 上。在代码中,我们将旋转枢轴点而不是直接旋转网格。当一个父对象移动或旋转时,所有子对象都会随之移动。当你进行这个更改时,Unity 会抱怨关于对象原始层次结构的更改;它这样做只是为了确保这是一个你想要做的更改,而不是一个意外:坦克

  5. 由于失去与预制件的连接可能会破坏游戏,Unity 想要确保我们确实希望这样做。因此,点击继续按钮,我们就可以在没有 Unity 其他抱怨的情况下完成坦克的工作。我们还需要将 CannonPivot 设置为 TurretPivot 的子对象,并将炮管设置为 CannonPivot 的子对象。

  6. 为了完成我们的层次结构更改,我们需要放置摄像机。由于我们希望玩家看起来就像是在坦克里一样,摄像机应该放在坦克后面和上方,稍微向下倾斜,以聚焦在几辆坦克长度前的一个点。一旦定位好,也将其设置为 TurretPivot 的子对象。

我们已经建立了一个基础结构,我们的坦克将会使用这个结构。通过这种方式使用多个对象,我们可以独立地控制它们的移动和动作。在这一点上,我们不再拥有一个只能向前指的僵硬坦克,我们可以独立地倾斜、旋转和瞄准每个部分。

提示

同时,坦克应该位于你希望整个物体围绕其旋转的中心点上方。如果它不是,你可以在层次结构窗口中选择基础坦克对象下的所有内容,并移动它们。

保持计分

本节将重点关注一个简短的脚本,用于跟踪玩家的分数和文本元素的添加。以下是创建我们脚本的步骤:

  1. 让我们的坦克工作的第一个脚本非常简单。创建一个新的脚本,并将其命名为 ScoreCounter。顾名思义,它将跟踪分数。在 Scripts 文件夹中创建它,并清除到目前为止我们制作的其他脚本中的默认函数。

  2. 正如上一章所做的那样,由于任何需要访问我们的 GUI 元素的脚本都需要在脚本最顶部添加一行代码,在 using UnityEngine; 这行代码之后添加以下代码行。这允许我们使用并更改需要显示分数的文本元素:

    using UnityEngine.UI;
    
  3. 下一行代码应该从上一章看起来很熟悉。首先,我们定义了一个整数计数器。由于它是静态的,其他脚本(例如我们为靶子创建的脚本)将能够修改这个数字,并给我们得分:

    public static int score = 0;
    
  4. 然后,我们将添加一个变量来存储界面的文本元素。它将像上一章中的转向指示器一样工作,为我们提供一个位置来更新和显示玩家的分数:

    public Text display;
    
  5. 这个脚本的最后一段代码是一个Update函数。这个函数由 Unity 自动为每一帧调用。这是放置任何需要定期更改而无需玩家直接输入的代码和逻辑的完美位置。对于我们的目的,我们将更新文本元素,并确保它总是显示最新的分数。通过将分数添加到双引号中,我们将数字转换为单词,以便文本元素可以正确使用它:

    public void Update() {
      display.text = "" + score;
    }
    

这就是这个非常简单的脚本的全部内容。它将跟踪整个游戏过程中的分数。此外,它本身不会执行任何分数增加的操作,而是由其他脚本更新计数器来给玩家加分。

重复按钮

到目前为止我们使用的按钮只在按下并释放时执行操作。我们的玩家需要按住按钮来控制他们的坦克。因此,我们需要创建一个重复按钮;一个只要按住就会执行操作的按钮。按照以下步骤来创建一个重复按钮:

  1. 创建一个名为RepeatButton的新脚本。

  2. 为了让这个脚本能够访问到它需要工作的 Unity 部分,和之前的脚本一样,我们需要在写着using UnityEngine;的那一行下面添加以下两行。第一行将让我们访问到Selectable类:所有交互式界面元素都从中派生的那个类。第二行将使我们能够处理玩家与我们新按钮交互时发生的事件:

    using UnityEngine.UI;
    using UnityEngine.EventSystems;
    
  3. 接下来,我们需要更新代码中的public class行。任何为游戏中的对象提供功能的普通脚本都是对MonoBehaviour类的扩展。我们需要将行更改为以下内容,以便我们的脚本可以存在于界面中并扩展其功能:

    public class RepeatButton : Selectable {
    
  4. 我们的脚本总共有四个变量。第一个允许它跟踪是否被按下:

    private bool isPressed = false;
    
  5. 接下来的三个变量将提供与上一章中按钮相同的功能。对于按钮,我们必须选择一个对象,然后选择特定脚本中的一个函数,最后发送一些值。这里,我们将做同样的事情。这里的第一变量跟踪我们要在场景中与之交互的对象。第二个将是附加到对象上某个脚本中的函数名称。最后一个将是一起发送给函数的数字,它将提供更具体的输入:

    public GameObject target;
    public string function = "";
    public float value = 0f;
    
  6. 本脚本的第一函数将覆盖Selectable类提供的函数。当玩家点击按钮时立即调用它。它接收到一些关于点击方式和位置的信息,这些信息存储在eventData中。第二行只是调用了父类中同名的函数。该函数最后做的是设置我们的布尔标志,以标记按钮当前正被玩家按下:

    public override void OnPointerDown(PointerEventData eventData) {
      base.OnPointerDown(eventData);
      isPressed = true;
    }
    
  7. 下一个函数与上一个函数完全相同。主要区别在于,当玩家的鼠标或触摸不再位于界面中的按钮上时调用它。第二个区别是它将布尔值设置为false,因为当玩家将手指从按钮上移开时,他们不再按下按钮,在这种情况下我们希望停止执行我们的动作:

    public override void OnPointerExit(PointerEventData eventData) {
      base.OnPointerExit(eventData);
      isPressed = false;
    }
    
  8. 以下函数与前两个类似。但是,当按钮释放时调用它:

    public override void OnPointerUp(PointerEventData eventData) {
      base.OnPointerUp(eventData);
      isPressed = false;
    }
    
  9. 该脚本的最后一个函数是我们的Update函数。它首先检查玩家当前是否按下了按钮。然后它在我们目标对象上调用SendMessage函数,告诉它要执行哪个函数以及使用哪个数字。SendMessage函数仅对GameObjectMonoBehviour组件可用。它接收一个函数名,并尝试在接收消息的 GameObject 上找到它:

    public void Update() {
      if(isPressed) {
        target.SendMessage(function, value);
      }
    }
    

另一个脚本完成了!这个脚本允许我们按住按钮,而不是被迫反复按下按钮来在游戏中移动。

控制底盘

常规坦克可以进行原地旋转,并且可以轻松地前进和后退。我们将通过创建一个脚本来使我们的坦克实现这一点。按照以下步骤为坦克创建我们的第二个脚本:

  1. 第二个脚本称为ChassisControls。它将使我们的坦克四处移动。我们将在Scripts文件夹中创建它。

  2. 脚本的前三行定义了坦克移动所需的变量。我们还可以在检查器窗口中更改它们,以防我们的坦克太快或太慢。第一行定义了一个变量,该变量保存了对CharacterController组件的连接。这个组件不仅容易移动坦克,而且还能让它碰到墙壁和其他碰撞体时停止。接下来的两行代码定义了我们移动和旋转的速度:

    public CharacterController characterControl;
    public float moveSpeed = 10f;
    public float rotateSpeed = 45f;
    
  3. 我们首先定义MoveTank函数,它需要传递一个speed值来决定坦克应该向哪个方向以及多远前进。正值将使坦克向前移动,负值将使其向后移动:

    public void MoveTank(float speed) {
    
  4. 为了在三维空间中移动,我们需要一个向量——一个既有方向又有大小的值。因此,我们定义了一个移动向量,并将其设置为坦克的前进方向,乘以坦克的速度,再乘以自上一帧以来经过的时间量。

    • 如果你记得几何课上的内容,3D 空间有三条轴:x、y 和 z。在 Unity 中,以下约定适用:x 是向右,y 是向上,z 是向前。transform 组件保存了一个对象的位置、旋转和缩放的这些值。我们可以通过调用 Unity 提供的transform变量来访问 Unity 中任何对象的 transform 组件。transform组件还提供了一个forward变量,它会给出一个指向对象面向方向的向量。

    • 此外,我们希望以恒定的速度移动,例如,每秒移动一定的距离;因此,我们使用了Time.deltaTime。这是 Unity 提供的一个值,它表示自上次在屏幕上绘制游戏的帧以来已经过去了多少秒。你可以把它想象成翻书。为了使一个人看起来像是在页面上走动,他在每一页上都需要稍微移动一点。在游戏的情况下,页面不是定期翻动的。因此,我们必须根据翻到新页面所花费的时间来调整我们的移动。这有助于我们保持恒定的速度。

      Vector3 move = characterControl.transform.forward * speed * Time.deltaTime;
      
  5. 接下来,我们希望角色保持在地面上。通常,在游戏中,任何你想控制的字符不会自动获得像石头那样的所有物理特性,比如重力。例如,当跳跃时,你暂时移除了重力,使角色能够向上移动。这就是为什么下一行代码简单地实现了重力,通过减去正常的重力速度,然后使其与我们的帧率保持同步:

    move.y -= 9.8f * Time.deltaTime;
    
  6. 最后,对于MoveTank函数,我们实际上执行了移动操作。CharacterController组件有一个特殊的Move函数,它能够移动角色并受到碰撞的限制。我们只需通过传递move向量来告诉它本帧我们想要移动多远以及移动的方向。当然,最后的这个花括号结束了这个函数的定义:

    characterControl.Move(move);
    }
    
  7. RotateTank函数也需要一个速度值来指定旋转的速度和方向。我们从定义另一个向量开始;然而,这个向量不是定义移动的方向,而是定义旋转的方向。在这种情况下,我们将围绕向上的方向旋转。然后我们将这个向量乘以我们的speedTime.deltaTime参数,以足够快的速度移动并保持与我们的帧率同步。

    public void RotateTank(float speed) {
      Vector3 rotate = Vector3.up * speed * Time.deltaTime;
    
  8. 函数的最后部分实际上执行了旋转操作。Transform组件提供了一个Rotate函数。在 3D 空间中,尤其是进行旋转操作时,可能会变得复杂和困难。Rotate函数为我们处理了所有这些操作;我们只需要为它提供要应用的旋转值。此外,别忘了用花括号结束这个函数的定义:

    characterControl.transform.Rotate(rotate);
    }
    

我们创建了一个控制坦克移动的脚本。它将使用CharacterController组件的专用Move函数,使我们的坦克能够前进和后退。我们还使用了Transform组件提供的专用Rotate函数来旋转坦克。

控制炮塔

下一个脚本将允许玩家旋转他们的炮塔并瞄准炮管:

  1. 我们需要为坦克创建的最后一个脚本为TurretControls。这个脚本将允许玩家左右旋转炮塔,以及上下倾斜炮管。与所有其他脚本一样,在Scripts文件夹中创建它。

  2. 我们定义的前两个变量将保存指向炮塔和炮管旋转点的指针——我们为坦克创建的空GameObjects。第二组是炮塔和炮管的旋转速度。最后,我们设置了一些限制值。如果我们不对炮管的旋转角度进行限制,它只会不停地旋转,穿过坦克。这对于坦克来说并不是最真实的行为,因此我们必须设置一些限制。限制范围是 300 度,因为正前方是 0 度,向下是 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;
    
  3. 接下来是RotateTurret函数。它的工作原理与RotateTank函数完全相同。但是,我们不是查看CharacterController组件的transform变量,而是对turretPivot变量进行操作:

    public void RotateTurret(float speed) {
      Vector3 rotate = Vector3.up * speed * Time.deltaTime;
      turretPivot.Rotate(rotate);
    }
    
  4. 第二个也是最后一个函数RotateCannon,在处理旋转时会更深入一些。这完全是因为我们需要对炮管的旋转设置限制。打开函数后,第一步是确定我们这一帧将旋转多少。我们使用浮点值而不是向量,因为我们必须自己设置旋转:

    public void RotateCannon(float speed) {
      float rotate = speed * Time.deltaTime;
    
  5. 接下来,我们定义一个变量来保存当前的旋转值。这样做是因为 Unity 不允许我们直接对旋转值进行操作。实际上,Unity 以四元数的形式跟踪旋转,这种方法超出了本书的讨论范围。幸运的是,Unity 为我们提供了名为EulerAngles的方法,通过 x、y 和 z 定义旋转。这是围绕三维空间中的三个轴的旋转。Transform组件的localEulerAngles值是相对于父GameObject的旋转。

    Vector3 euler = cannonPivot.localEulerAngles;
    

    注意

    它被称为EulerAngles,因为这是由瑞士数学家莱昂哈德·欧拉提出的一种定义旋转的方法。

  6. 接下来,我们通过使用Mathf.Clamp函数一次性调整旋转并应用限制。Mathf是一组有用的数学函数。clamp函数接收一个值,并使其不低于也不高于传递给函数的其他两个值。因此,我们首先发送我们的x轴旋转,这是从euler的当前 x 旋转中减去 rotate 的结果。由于沿轴正向旋转是顺时针的,因此我们需要减去我们的旋转,以便向上而不是向下使用正值。接下来,我们将下限传递给Clamp函数,然后是我们的上限:这是我们顶部脚本中定义的lowCannonLimithighCannonLimit变量:

    euler.x = Mathf.Clamp(euler.x – rotate, lowCannonLimit, highCannonLimit);
    
  7. 最后,我们需要将新的旋转实际应用到炮塔的支点。这只需将变换组件的localEulerAngles值设置为新的值。同样,请确保使用花括号关闭函数:

    cannonPivot.localEulerAngles = euler;
    }
    

我们现在已经创建了一个可以控制坦克炮塔的脚本。玩家将能够控制炮管的倾斜和炮塔的旋转。这个脚本与我们之前创建的ChassisControls脚本功能非常相似——区别在于限制炮管可以倾斜的程度。

组装部件

这暂时是最后一个脚本了。我们有我们的坦克和脚本;下一步是将它们组合起来:

  1. 现在,我们需要向坦克中添加脚本。还记得我们在上一章如何将井字游戏脚本添加到摄像机上的吗?首先在层次结构窗口中选择你的坦克。在这些脚本工作之前,我们首先需要在坦克上添加CharacterController组件。因此,在 Unity 编辑器顶部选择组件,然后选择物理,最后点击角色控制器选项。

    你会注意到,当你添加新组件后,在场景视图中坦克上会出现一个绿色的胶囊。这个胶囊表示将与其他碰撞体发生碰撞和交互的空间。角色控制器组件上的值允许我们控制它与其他碰撞体的交互方式。在大多数情况下,前四个参数的默认值都是可以的。

    组装部件

    角色控制器中的参数如下:

    • 斜率限制:这个属性显示控制器可以爬升的斜坡的最大倾斜度。

    • 步进偏移:这个属性显示了一个台阶在开始阻挡移动之前可以有多高。

    • 皮肤宽度:这定义了另一个碰撞体在完全停止之前可以穿透此控制器碰撞体的距离。这主要用于在物体间挤压。

    • 最小移动距离:这个属性用于限制抖动。这是在一帧中实际移动之前必须应用的最小移动量。

    • Center/Radius/Height:这些属性定义了你在 Scene 视图中看到的胶囊的大小。它们用于碰撞。

  2. 目前最后三个值最为重要。我们需要尽可能调整这些值以匹配我们坦克的大小。诚然,胶囊是圆形的,我们的坦克是方形的,但 CharacterController 组件是移动带碰撞的角色最简单的方式,它将最常被使用。将 Radius 属性和 Center 属性的 Y 部分设置为 2.3;其他部分可以保持默认值。

  3. 现在是向坦克添加脚本的时候了。通过在 Hierarchy 窗口中选择坦克,并将 ChassisControlsTurretControlsScoreCounter 脚本拖到 Inspector 窗口。这与我们在前几章中所做的一样。

  4. 接下来,我们需要完成在脚本中开始的连接。首先点击 CharacterController 组件的名称,并将其拖到我们新的 ChassisControls 脚本组件上的 Character Control 插槽。Unity 允许我们在 Unity 编辑器中连接对象变量,这样它们就不必硬编码。

  5. 我们还需要连接我们的炮塔和炮管旋转点。因此,从 Hierarchy 窗口点击并拖动点,到 TurretControls 脚本组件上的相应变量。

  6. 在测试我们的游戏之前,我们需要创建一堆 GUI 按钮来实际控制我们的坦克。首先创建一个画布,就像我们在上一章中所做的那样,并创建一个空的 GameObject。

  7. 空的 GameObject 需要一个 Rect Transform 组件,并且需要将其设置为 Canvas 的子对象。

  8. 将其重命名为 LeftControls 并将其锚点设置为左下角。此外,将 Pos X 设置为 75Pos Y 设置为 75Pos Z 设置为 0Width 设置为 150Height 设置为 150,如下截图所示:拼凑在一起

  9. 接下来,我们需要四个按钮作为 LeftControls 的子对象。与上一章一样,通过导航到 GameObject | UI | Button,可以在编辑器顶部找到它们。

  10. 将四个按钮重命名为 ForwardBackLeftRight。同时,你也可以将它们的文本子对象更改为相关的文本,比如 FBLR

  11. 按钮仅在玩家点击并释放时激活。仅仅为了使坦克移动而重复点击效果不太好。因此,点击它们每个 Button 组件右侧的齿轮,并选择 Remove Component

  12. 现在,将我们的 RepeatButton 脚本添加到每一个按钮上。由于我们扩展了 Selectable 类,你可以看到我们对按钮拥有与其他按钮相同的控制权。

  13. 将四个按钮的 WidthHeight 值设置为 50。它们的位置如下所示:

    按钮 Pos X Pos Y
    Forward 0 50
    -50 0
    Back 0 -50
    Right 50 0

    拼凑碎片

  14. 现在我们已经有了四个移动按钮,需要将它们连接到我们的坦克上。对于每个按钮,将层次结构面板中的Tank拖动到检查器面板中的目标槽里。

  15. 当我们下次设置函数槽时,拼写非常重要。如果有一点偏差,你的函数将找不到,会出现很多错误,坦克也将无法工作。对于Forward按钮,将函数槽设置为MoveTank槽设置为1Back按钮在函数槽中也需要MoveTank的值,但在槽中需要-1Left按钮在函数槽中需要RotateTank的值,槽中需要-1Right按钮在函数槽中需要RotateTank的值,槽中需要1

  16. 接下来,我们需要设置我们的炮塔控制。在层次结构窗口中右键点击LeftControls,并从新菜单中选择复制。将新副本重命名为RightControls

  17. 这个新的控制集需要一个右下角的锚点,PosX-75PosY75(如下面的截图所示):拼凑碎片

  18. 这组下面的按钮需要被重命名为UpDownLeftRight。它们的文本可以分别更改为UDLR

  19. Up按钮的函数槽应该设置为RotateCannon,其槽的值为1Down按钮的函数槽值为RotateCannon槽的值为-1Left按钮需要RotateTurret作为函数槽的值,槽的值为-1。最后,Right按钮需要函数槽的值为RotateTurret槽的值为1

  20. 最后一件事是创建一个新的文本元素,可以通过导航到游戏对象 | UI | 文本来找到,并将其重命名为Score

  21. 最后,选择你的Tank,并将层次结构窗口中的Score拖动到分数计数器(脚本)组件的显示槽中。

  22. 将场景保存为TankBattle并试玩一下。

我们刚刚完成了坦克的组装。除非在使用移动控制时查看场景视图,否则很难判断坦克是否在移动。炮塔控制可以在游戏视图中看到。除了没有判断坦克是否在移动的参照点外,它运行得相当好。下一步和下一节将为我们添加城市,提供那个参照点。

你可能会注意到,当你第一次尝试倾斜炮管时,会有一个快速的跳跃。这种行为很烦人,会让游戏看起来不完整。尝试调整炮管以修复它。如果你在这方面遇到麻烦,请查看炮管的起始旋转角度。这与我们每次尝试移动它时旋转被限制的方式有关。

创建材质

在 Unity 中,材质是决定模型在屏幕上如何绘制的关键因素。它们可以是简单的全蓝色,也可以是复杂的有波浪反射的水面。在本节中,我们将介绍控制材质的详细内容。我们还将创建我们的城市以及一些简单的材质来为其贴图。

城市

创建一个城市可以为我们的坦克和玩家提供一个良好的游戏场所。按照以下步骤来创建我们的城市:

  1. 为了本节的目的,本书代码包中提供的城市部分没有被赋予特定的纹理。它只是被展开,并创建了一些可平铺的纹理。因此,我们需要从导入城市和纹理到Environment文件夹开始。以我们导入坦克的相同方式来完成这个操作。

    注意

    相关文件包括TankBattleCity.blendbrick_001.pngbrick_002.pngbrick_003.pngdirt_001.pngdirt_003.pngpebbles_001.pngrocks_001.pngrubble_001.png以及water_002.png

  2. 当城市被展开时,Unity 仍然会为其创建一个单一材质。然而,在任何建模程序中都没有应用纹理。因此,材质是纯白色的。我们有多个额外的纹理,所以我们需要的不只是一个材质来覆盖整个城市。创建新材质很简单;就像创建新脚本一样。在Environment文件夹内的Materials文件夹上右键点击,选择创建,然后点击菜单中间的材质

  3. 这将在文件夹中创建一个新的材质,并立即允许我们为其命名。将材质命名为Pebbles

  4. 选择新的材质后,查看一下检查器窗口。当我们选择了一个材质,我们就可以获得改变其外观所需的选项:The city

  5. 从前面的截图我们可以看到以下内容:

    • 检查器窗口的最顶部,我们有材质的名称,其后是一个着色器下拉列表。着色器本质上是一个简短的脚本,它告诉显卡如何在屏幕上绘制某物。你通常会使用标准着色器;它本质上是一个全能型着色器,因此默认情况下总是被选中。在这里,你可以选择任何特殊效果或自定义着色器。

    • 渲染模式下拉菜单让你选择此材料是否使用任何程度的透明度。不透明表示它将是实心的。剪切选项将基于你的纹理透明区域的Alpha 截止值以锐利的边缘渲染。透明选项将基于你的纹理的 alpha 通道提供平滑的边缘。

主贴图

主贴图部分包含以下选项:

  • 主贴图部分从漫反射开始,这里放置你的主颜色纹理。可以通过纹理槽右侧的颜色选择器进行着色。

  • 高光选项定义了材料的光泽度;你可以想象成设备屏幕上的反光。你可以使用图像来控制它,或者使用颜色选择器来确定反射的颜色以及通过平滑度来控制反光的锐利程度。

  • 法线贴图选项允许你添加一个控制材料表面阴影的纹理。这些纹理需要特别导入。如果你选择的纹理没有正确设置,会出现一个警告框,你可以选择立即修复来更改它。还会出现一个滑块,让你控制纹理的效果程度。

  • 高度贴图选项的工作方式与法线贴图类似。它调整材料的凹凸程度,并提供一个滑块来调整它。

  • 遮挡选项允许你向材料添加环境遮挡纹理,根据模型中物体之间的接近程度来控制材料的暗度或亮度。

  • 发射选项让你控制材料发出的投影光和颜色。这只会影响光照图和此材料的外观。要实际动态地发出光,必须通过添加实时光源来模拟。

  • 细节遮罩选项允许你控制次要贴图中的纹理在材料上的出现位置。

  • 平铺偏移的值控制纹理的大小和位置。平铺的值决定了纹理在规范化的 UV 空间内沿 x 和 y 方向重复的次数。偏移参数是纹理在规范化的 UV 空间中从零点开始的距离。你可以选择数字字段并输入值来修改它们。这样做,并注意底部的预览窗口,你将看到它们如何改变纹理。平铺纹理通常用于大面积表面,这些表面的纹理相似且特定纹理只是重复出现。

次要贴图

次要贴图部分包含以下选项:

  1. 次要贴图细节漫反射 x2开始,这是一个额外的漫反射纹理,用于与你的主漫反射纹理混合。它可能用于在巨石表面添加凹凸不平的变化。

  2. 法线贴图与主要的法线贴图槽类似,控制细节纹理的阴影。

  3. 第二组贴图平铺偏移值与第一组类似,只是控制细节纹理。通常这些值设置得比第一组高,以在材质表面添加额外的兴趣点。

  4. UV 集只是让您选择细节纹理将要使用的模型展开集,这些纹理将应用于所添加材质的模型。

  5. 通过从项目窗口拖拽pebbles_001纹理,并将其放置在漫反射槽右侧的方框中,来为这个材质添加纹理。

  6. 为了使纹理的颜色更好,使用漫反射槽右侧的颜色选择器,选择一种浅褐色。

  7. 主要贴图平铺XY值设为30,将使得当贴图平铺应用到我们城市的街道时更容易观察。

  8. 为了查看我们新材质的效果,首先将你的城市拖到层次结构窗口,使其添加到场景视图中。通过右键拖动,你可以在场景视图中查看四周,使用WASD可以四处移动。看看城市的街道。

  9. 现在,将您的新材质从项目窗口拖到场景视图中。拖动材质时,您应该看到网格发生变化,好像它们正在使用该材质。当您拖过街道时,松开左键鼠标。现在材质已经应用到网格上了。

  10. 然而,我们目前有一个城市的四分之一需要贴图。因此,创建更多材质,并将剩余的纹理应用到城市的其他部分。为每个额外的纹理创建一个新材质,四个额外的brick_002纹理,这样我们可以为每栋建筑物的高度设置不同的颜色。

  11. 根据以下截图或您自己的艺术感,将新的材质应用到城市中:次要贴图

    提示

    当你试图到达中心喷泉时,如果坦克挡道了,在层次结构窗口中选择你的坦克,并在场景视图中使用小工具将其拖开。

    如果你现在尝试玩游戏,你可能会注意到我们有一些问题。首先,我们只有一个城市的四分之一;如果你制作了自己的城市,可能会有更多。此外,城市上仍然没有碰撞,所以当我们移动时,会直接穿过它。

  12. 改变坦克的大小非常简单。在层次结构窗口中选择它,并在变换组件中找到缩放标签。更改缩放下的XYZ值将改变坦克的大小。确保均匀地改变这些值,否则当我们开始旋转坦克时,会出现一些奇怪的现象。0.5的值使得坦克足够小,可以通过小街道。

  13. 接下来是城市部分的碰撞处理。在大多数情况下,我们可以使用简单的碰撞形状以加快处理速度。然而,城市的圆形中心需要特别处理。首先在场景视图中双击其中一个方形建筑的墙壁。

    提示

    在处理预制体时(城市仍然是预制体),点击构成预制体的任何对象都会选择根预制体对象。一旦选择了预制体,点击它的任何部分都会选择那个单独的部分。由于这种行为与非线性预制体对象不同,当你选择场景视图中的对象时,需要注意这一点。

  14. 选择一组墙壁后,在 Unity 编辑器顶部选择组件,然后选择物理,最后选择盒状碰撞器

  15. 由于我们将碰撞器添加到了一个特定的网格上,Unity 会尽可能地自动调整碰撞器以适应形状。对于我们来说,这意味着新的BoxCollider组件已经调整到适合建筑的尺寸。继续为其余的方形建筑和外围墙添加BoxCollider组件。我们的街道本质上只是一个平面,因此BoxCollider组件对它们来说也足够使用。尽管它是指向顶部的,但喷泉中心的方尖碑本质上只是一个盒子;因此另一个BoxCollider对它也非常合适。

  16. 我们还剩一栋建筑和喷泉环需要处理。这些不是盒子、球体或胶囊形状,因此我们简单的碰撞器将无法工作。选择靠近中心喷泉的最后一栋建筑的墙壁。在您选择盒状碰撞器的位置往下几行,有一个网格碰撞器选项。这将给我们的对象添加一个MeshCollider组件。这个组件如其名所示,它获取一个网格并将其转变为碰撞器。将MeshCollider组件添加到特定网格上,它会自动选择该网格作为可碰撞对象。你还需要将MeshCollider组件添加到中心建筑周围的小型边缘和喷泉周围的环墙上。

  17. 要解决的最后一个问题是城市区域的复制。首先在层级窗口中选择根城市对象,选择TankBattleCity,并从它上面移除Animator组件。城市不需要动画,因此不需要这个组件。

  18. 现在,在层级窗口中对城市右键点击,然后点击复制。这将创建一个被选中对象的副本。

  19. 再复制两次城市区域,我们就会有城市的四个部分了。唯一的问题是,它们将全部处于完全相同的位置。

  20. 我们需要旋转三个部分以形成一个完整的城市。选择一个部分,并将变换组件中的Y 轴旋转值设置为90。这将围绕垂直轴旋转 90 度,给我们半个城市。

  21. 我们将通过将其中一个剩余的部分设置为180度,另一个设置为270度来完成城市的构建。

  22. 还剩下最后一件事情要做。我们有四个中心喷泉。在四个城市片段中的三个里,选择构成中心喷泉的三个网格(ObeliskWallWater),然后在键盘上按下 Delete 键。每次确认你想打破预制件连接,我们的城市就会像下图一样完整:次要地图

现在就来尝试这个游戏吧。我们可以驾驶坦克在城市中穿梭并旋转炮塔。这真是太有趣了。我们创建了材质并对城市进行了纹理化处理,在让玩家能够与建筑物和道路发生碰撞后,我们复制了这一部分,以便拥有整个城市。

既然你已经具备了导入网格和创建材质所需的所有技能,挑战就是装饰城市。创建一些瓦砾和坦克陷阱,并练习将它们导入 Unity 并在场景中设置。如果你真的想做得更好,尝试自己创建一个城市;从世界上选择一些东西,或者用你的想象力做一些事情。一旦创建完成,我们就可以在其中释放坦克。

移动的履带

还剩下最后一件事情要做,然后我们就可以完成材质部分并且继续让游戏变得更加有趣。还记得材质的 Offset 值吗?事实证明,我们实际上可以用脚本控制它。执行以下步骤,让履带随着我们的坦克移动:

  1. 首先,打开 ChassisControls 脚本。

  2. 首先,我们需要在脚本开始处添加几个变量。前两个将保存对我们坦克履带渲染器的引用,这是网格对象的一部分,负责跟踪应用到网格上的材质并实际进行绘制。这类似于 characterControl 变量保存对我们 CharacterController 组件的引用:

    public Renderer rightTread;
    public Renderer leftTread;
    
  3. 接下来的两个变量将跟踪每个履带应用的偏移量。我们在这里存储它,因为这样比每帧尝试从履带的材质中查找要快。

    private float rightOffset = 0;
    private float leftOffset = 0;
    
  4. 为了利用这些新值,需要在 MoveTank 函数的末尾添加以下代码行。这里的第一行根据我们的速度调整右侧履带的偏移量,并与我们的帧率保持同步。第二行利用 Renderer 组件的材质值找到坦克履带的材质。材质的 mainTextureOffset 值是材质中主纹理的偏移量。在我们的漫反射材质中,这是唯一的纹理。然后,我们必须将偏移量设置为一个包含我们新偏移值的新 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);
    
  5. 为了将我们的履带与Renderer组件连接起来,我们需要对准点做同样的事情:将履带网格从层次结构窗口拖到检查器窗口中相应的值。完成这一步后,请确保保存并尝试一下。

我们更新了ChassisControls脚本来使坦克的履带移动。当坦克四处行驶时,纹理会向适当的方向移动。这是用于制作水中波浪和其他移动纹理的相同类型的功能。

材料移动的速度与坦克的速度不完全匹配。找出如何为坦克的履带添加额外的速度值。此外,如果坦克在旋转时履带能向相反方向移动将会很酷。实际上,坦克是通过让一个履带向前另一个向后来实现转向的。

Unity 中的动画

我们将要介绍下一个主题是动画。在探索 Unity 中的动画时,我们将为我们的坦克创建一些射击目标。Unity 的动画系统Mecanim的强大功能很大程度上在于处理人形角色。但是,设置和动画人形角色本身就可以填满一本书,所以这里不会介绍。然而,我们仍然可以学习和使用 Mecanim 做很多事情。

下面的要点将解释导入动画时所有可用的设置:

  • 在继续介绍动画导入设置之前,我们需要一个动画模型来操作。我们还有最后一组资源要导入到项目中。将Target.blendTarget.png文件导入到我们项目的Targets文件夹中。导入后,调整目标模型的导入设置窗口中的模型页面,就像我们对坦克所做的那样。现在,切换到骨骼标签(如下图所示):Unity 中的动画

  • 动画类型属性告诉 Unity 在执行动画时当前模型将使用哪种类型的骨骼。不同类型的模型无法共享动画。动画类型下的不同选项如下:

    • 人形选项为处理人形角色的页面添加了许多按钮和开关。但同样,这里过于复杂,不进行介绍。

    • 一个通用的骨骼仍然使用 Mecanim 及其许多功能。实际上,这仅仅是任何不类似人类结构的动画骨骼。

    • 第三个选项,Legacy,使用了 Unity 旧版的动画系统。然而,这个系统将在未来几个 Unity 版本中被淘汰,因此这里也不会进行介绍。

    • 最后一个选项,None,表示对象将不会有动画。你可以为坦克和城市选择这个选项,因为它也会阻止 Unity 添加 Animator 组件,并节省最终项目的大小。

  • 根节点的值是模型文件中每个对象的列表。其目的是选择你的动画绑定的基础对象。对于这个目标,选择位于第二个骨架选项下的Bone_Arm_Upper

  • 当勾选优化游戏对象选项时,它将隐藏你的模型整个骨骼。点击新出现的框上的加号,将允许你选择特定的骨骼,这些骨骼在你通过层级窗口查看模型时仍然需要访问。在处理具有大量骨骼的复杂绑定时,这个选项特别有用。Unity 中的动画

  • 导入设置的最后一个标签页是动画,它包含了我们将文件中的动画导入 Unity 所需的一切。在目标导入设置窗口的顶部,我们有导入动画的复选框。如果一个对象不会进行动画,那么关闭这个选项是个好主意。这样做还可以节省项目空间。

  • 下面的选项,烘焙动画,仅当你的动画包含运动学且来自 3ds Max 或 Maya 时使用。这个目标是来自 Blender 的,所以该选项是灰显的。

  • 接下来的四个选项,动画压缩旋转误差位置误差缩放误差,主要用于平滑抖动的动画。几乎在所有情况下,默认设置对于使用来说都很好。

  • 我们在这里真正关心的是剪辑部分。这将列出当前从模型中导入的每个动画剪辑。在列表的左侧,我们有剪辑的名称。在右侧,我们可以看到剪辑的开始和结束帧。剪辑部分下的各种参数如下:

    • Unity 将为每个新模型添加一个默认动画。这是在保存文件时从你的建模程序的默认预览范围生成的剪辑。在我们的目标案例中,这是默认采集

    • 在 Blender 中,还可以为每个绑定创建一系列动作。默认情况下,Unity 会将它们作为动画剪辑导入。在这种情况下,创建了ArmatureAction剪辑。

    • 在剪辑下方和右侧,有一个带有+按钮的小标签页。这两个按钮分别将剪辑添加到末尾和移除选定的剪辑。

    Unity 中的动画

  • 当选择一个剪辑时,下一个部分会出现。它以一个文本字段开始,用于更改剪辑的名称。

  • 在使用 Blender 时,文本字段下方有一个源采集下拉列表。这个列表与默认动画相同。大多数时候,你只需使用默认采集;但是,如果你的动画总是出现错误或缺失,首先尝试更改源采集下拉列表。

  • 然后,我们有一个小的时间线,以及动画剪辑的开始结束帧的输入字段。点击两个蓝色旗帜并在时间线上拖动它们将改变输入字段中的数字。

  • 接下来,我们有循环时间循环姿势循环偏移。如果我们希望动画重复,请勾选循环时间旁边的框。循环姿势将使得动画的第一帧和最后一帧中的骨骼位置相匹配。当动画循环时,循环偏移将变得可用。这个值让我们调整循环动画开始的帧。

  • 接下来的三个小节,根变换旋转根变换位置(Y)根变换位置(XZ),允许我们通过动画控制角色的移动。这些部分下的控制如下:

    • 这三个部分都有一个烘焙到姿势的选项。如果这些选项未被选中,根节点(我们在绑定页面选择了它)在动画中的移动将被转换为整个对象的移动。这样想:假设你要在动画程序中动画一个向右跑的角色,实际上你会移动他们,而不是像平常一样在原地动画。

    • 使用 Unity 的旧动画系统,要让角色的物理部分移动碰撞器,必须用代码移动 GameObject。因此,如果你使用这个动画,角色看起来像是移动了,但实际上没有碰撞。使用这个新系统,当播放动画时整个角色都会移动。然而,这需要不同的更复杂的设置才能完全工作。所以我们没有在坦克上使用这个,尽管我们可以使用。

    • 三个部分中的每一个都包含一个基于的下拉选项。这个选项的选择决定了每个部分的物体的中心。如果你在使用人形角色,会有更多的选择,但目前我们只有两个选项。如果选择根节点,意味着根节点对象的轴心点是中心。如果选择原始,则由动画程序定义的原点是物体的中心。

    • 前两个部分还有一个偏移选项,用于纠正动作中的错误。当为角色动画行走周期时,如果角色稍微向侧面拉,调整根变换旋转下的偏移选项将纠正它。

  • 我们的动画剪辑下一个选项是遮罩。通过点击左侧的箭头,你可以展开模型中所有对象的列表。每个对象旁边都有一个复选框。当播放这个剪辑时,未被勾选的对象将不会动画化。这对于挥手动画非常有用。这种动画只需要移动手臂和手,因此我们会取消勾选构成角色身体的所有对象。然后我们可以叠加动画,让角色在站立、行走或奔跑时挥手,而无需创建三个额外的动画。

  • 曲线选项将允许你向动画中添加一个浮点值,这个值将在动画过程中改变。当动画播放时,你的代码可以检查这个值。这可以用来调整角色跳跃时受重力的影响,当他们蹲下成球体时改变碰撞器的大小,或者做许多其他的事情。

  • 事件的工作原理类似于我们在RepeatButton脚本中使用SendMessage函数的方式。在你的动画中的特定时刻,可以调用一个函数来执行某些操作。

  • 运动选项允许你定义动画中的哪个骨骼控制模型的运动。这可以覆盖在绑定标签上选择的骨骼。我们的目标物不会移动,所以这对于我们的情况来说并不是特别相关。

  • 最后,我们在底部有恢复按钮、应用按钮和预览窗口。就像我们所有其他导入设置一样,当我们进行更改时,必须点击这些按钮之一。这个预览窗口的特殊之处在于右上角的速度滑块和左上角的大播放按钮。点击这个按钮,我们可以预览选定的动画。这让我们可以检测到我们之前讨论的动作中的错误,并确保动画就是我们想要的效果。

当我们在 Unity 中处理动画时,有许多设置可供我们使用。它们允许我们控制想要导入的原始动画程序中的帧数。此外,它们还可以用来控制动画如何与你的脚本交互。无论你选择什么设置,最重要的是动画剪辑的名称。如果没有设置这个,当你需要处理几个名称相同的动画时,可能会变得极其困难。

目标物的动画

那么,现在我们已经了解了所有的描述,让我们实际用它来制作一些东西。我们将从为目标物设置动画开始。利用我们刚刚获得的知识,我们现在可以如下设置我们的目标物的动画:

  1. 首先,如果你之前错过了或跳过了,请确保将Target.blendTarget.png文件导入到Targets文件夹中。此外,在导入设置的Rig页面,确保将Animation Type属性设置为Generic,并将Root Node属性设置为Bone_Arm_Upper

  2. 我们总共需要六个动画。在Clips部分点击+按钮,你可以添加四个更多动画。如果你添加得过多,点击-按钮来移除多余的剪辑。

  3. 所有这些剪辑都应该有一个Source Take下拉列表,选择Default Take,所有的Bake into Pose选项都应该勾选,因为目标不会从起始位置移动。

  4. 首先,让我们创建我们的空闲动画。选择第一个剪辑,并将其重命名为Idle_Retract。由于这是一个机械物体,我们可以使用一个非常短的动画;它如此之短,以至于我们只需要使用第一帧。将开始帧设置为0.9,结束帧设置为1

  5. 我们还需要开启Loop Pose,因为空闲动画当然是循环的。

  6. 延展空闲动画的创建几乎与上一个完全相同。选择第二个剪辑,并将其重命名为Idle_Extend。这里的开始帧是14,结束帧是14.1。此外,这个动画需要循环。

  7. 接下来的两个动画是针对目标展开和缩回的情况。它们将被命名为ExtendRetract,因此请重命名接下来的两个剪辑。Extend动画将从第1帧开始,到第13帧结束。Retract动画将从第28帧开始,到第40帧结束。这两个动画都不会循环。

  8. 最后两个动画也不会循环。它们是当我们射击目标时使用的。一个是从前面被击中,另一个是从后面被击中。Hit_Front动画将从第57帧到第87帧。Hit_Back动画将从第98帧到第128帧。

  9. 一旦完成所有更改,请务必点击Apply,否则更改将不会被保存。

现在我们已经设置好了目标将使用的动画。总共有六个。现在可能看起来不多,但如果没有它们,下一节将无法实现。

使用状态机在 Unity 中控制动画

为了让我们在 Unity 中控制这些新动画,我们需要设置一个状态机。状态机只是一个花哨的对象,用于跟踪一个对象能做什么,以及如何在不同的事物之间进行转换。你可以把它想象成实时策略游戏中的建造者。建造者有一个行走状态,用于移动到下一个建筑工地。当建造者到达那里时,它会切换到建造状态。如果有敌人出现,建造者将进入逃跑状态,直到敌人消失。最后,当建造者什么都不做时,有一个空闲状态。在 Unity 中,当你处理动画和 Mecanim 时,这些被称为 Animator 控制器。

目标状态机

状态机的使用让我们可以更多地关注目标正在做什么,而让 Unity 处理如何做到这一点的部分。执行以下步骤来创建状态机并控制目标:

  1. 创建一个动画师控制器很简单,这就像我们为脚本和材质所做的那样。该选项位于创建菜单的中间部分。在Targets文件夹中创建一个动画控制器,并将其命名为TargetController

  2. 双击TargetController打开一个新窗口(如下图所示):目标状态机

    动画师窗口是我们编辑状态机的地方。动画师窗口的各个部分如下:

    • 在左上角是一个图层按钮。点击它会显示构成你的动画系统的所有可混合图层列表。每个状态机至少会有一个基础图层。添加更多图层可以让我们混合状态机。比如说,如果一个角色在满血时正常行走,当他的血量降到一半以下时,他开始跛行。如果角色只剩下 10%的血量,他开始爬行。这可以通过使用图层来实现,从而避免为每种移动类型创建额外的动画。

    • 在它的右边是一个参数按钮,它会显示参数列表。点击+按钮将在列表中添加一个新参数。这些参数可以是浮点数整数布尔值触发器。状态之间的转换通常是由这些参数的变化触发的。任何与状态机一起工作的脚本都可以修改这些值。

    • 下一个部分像面包屑路径,就像你在网站上可能找到的那样。它让我们一眼就能看到我们在状态机中的位置。

    • 右上角的自动实时链接按钮控制我们能够在游戏中实时查看状态机的更新。这对于调试角色转换和控制非常有用。

    • 动画师窗口的中央,有三个框:任何状态入口出口。(如果你看不到它们,点击鼠标中键并在网格上拖动以平移视图。)这些框是您的动画状态机的基本控制。任何状态框允许你的对象进入特定的动画,无论它们在状态机的哪个位置,例如,无论玩家正在进行什么操作,都可以移动到死亡动画。入口框在你第一次启动状态机时使用。所有的转换都会被分析,第一个合适以及后续的动画将成为起始位置。出口框主要用于子状态机,并允许你从组中转换出去,而不需要很多额外的复杂连接。

  3. 要创建一个新状态,请在我们的动画师窗口内的网格上点击鼠标右键。将鼠标悬停在创建状态上,然后选择。这为我们的状态机创建了一个新的空状态。通常,新状态是灰色的,但由于这是我们机器中的第一个状态,所以它是橙色的,这是默认状态的颜色。

  4. 每个状态机都将从其默认状态开始。点击状态以选择它,我们可以在检查器窗口中查看它(如下截图所示)。目标状态机

    你可以在前面的截图中看到以下字段:

    • 在顶部,有一个用于更改状态名称的文本字段。

    • 在此之下,你可以添加一个标签以用于组织目的。

    • 接下来,有一个速度字段。该字段控制动画的播放速度。

    • 动作字段是我们添加到之前创建的动画剪辑连接的地方。

    • 足部 IK选项让我们决定是否希望部分动画通过逆运动学IK)来计算,这是根据末端目标骨骼的位置来计算一系列骨骼如何布局的过程。我们没有为这些动画设置任何 IK,所以不需要担心这个选项。

    • 使用写入默认值选项,我们可以控制动画属性在动画结束后是否保持改变。

    • 最后一个选项,镜像,用于翻转动画的左右轴(或x轴)。如果你创建了一个右手挥动的动画,这个选项将允许你将其更改为左手挥动的动画。

    • 在此之下,是当前状态转变到另一个状态的转换列表。这些都是离开状态而非进入状态的转换。正如你很快将要看到的,此列表中的转换以当前状态的名称开始,向右有一个箭头,后面跟着它所连接的状态名称。

    • 右侧的独奏静音标签下也会出现复选框。这些用于调试状态之间的转换。一次可以静音任意数量的转换,但一次只能独奏一个。当一个转换被静音,意味着状态机在决定要进行的转换时会忽略它。选中独奏框与静音除一个转换之外的所有转换相同;这只是快速使其成为唯一活动转换的方法。

  5. 我们的目标动画将需要各自对应一个状态。因此,再创建五个状态,并将这六个状态重命名为我们之前创建的动画剪辑的名称。默认状态,也就是你创建的第一个状态,在你的屏幕上显示为橙色,应命名为Idle_Retract

  6. 项目窗口中,点击目标模型右侧的小三角形(如下截图所示的高亮部分):目标状态机

    这扩展了模型,以便我们可以看到组成该模型的所有对象在 Unity 中的情况。第一组是由实际对象组成的模型。接下来是模型中使用的原始网格。然后是动画剪辑(它们将显示为带有大播放按钮的蓝色框);这些是我们现在感兴趣的内容。最后是一个 Avatar 对象;这是跟踪Rig设置的内容。

  7. 在你的Animator窗口中选择每个状态,并通过将动画剪辑从Project窗口拖动到Inspector窗口中的Motion字段,将其与正确的剪辑配对。

  8. 在创建我们的状态转换之前,我们需要几个参数。通过点击左上角的Parameters按钮打开参数列表。然后,点击出现的菜单中的+按钮,并从菜单中选择Float。现在应该在列表中显示一个新参数。

  9. 左侧的新字段是参数名称;你可以通过双击它随时重命名。将这个重命名为time。右侧的字段是此参数的当前值。在调试我们的状态机时,我们可以在这里修改这些值以触发状态机的更改。游戏运行时脚本所做的任何更改也会在这里显示。

  10. 我们还需要两个参数。创建两个Bool参数并将它们重命名为wasHitinTheFront。这些将触发机器进入被击中的状态,而时间参数将触发机器使用extendretract状态。

  11. 要创建新的转换,请右键点击一个状态,并从弹出的菜单中选择Make Transition。现在将有一条转换线从状态连接到你的鼠标。要完成转换创建,请点击你希望连接到的状态。线条上会有一个箭头,指示转换的方向。我们需要以下转换:

    • 我们需要从Idle_Retract状态转换到Extend状态。

    • 我们还需要从ExtendIdle_Extend的转换。

    • Idle_Extend需要三个转换,一个指向Retract,另一个指向Hit_Front,最后一个指向Hit_Back

    • RetractHit_FrontHit_Back动画需要一个指向Idle_Retract的转换。

      提示

      使用以下截图作为参考。如果你创建了一个不想要的转换或状态,选择它并按键盘上的Delete键来删除它。

      目标状态机

  12. 如果你点击其中一个转换线,我们可以查看其设置(如下面的截图所示):目标状态机

    你可以在截图中看到以下内容:

    • 检查器窗口的顶部,我们有与我们在状态中相同的指示器,显示我们正在过渡的状态——过渡开始的状态名称,然后是箭头,最后是过渡结束的状态名称。

    • 在熟悉的过渡列表下方,有一个文本字段,我们可以为我们的过渡指定特定的名称。如果我们有几种不同类型的过渡在两个相同的状态之间,这很有用。

    • 具有退出时间的复选框决定了过渡是否会在接近动画末尾时等待,然后才切换到下一个动画。这对于像平滑过渡走路和空闲动画这样的情况很有用。

    • 设置下的退出时间中的第一个值设定了过渡开始的时间。这只有在勾选它上面的复选框时才相关。它的值应该从零开始动画,到一结束动画。

    • 过渡持续时间设置定义了过渡需要多长时间。它的值也是介于零和一之间。

    • 过渡偏移设置定义了过渡将在目标动画的哪个位置开始。

    • 中断源有序中断选项决定了在执行当前过渡的过程中是否可以发生另一个过渡。它们还设置了哪个过渡集具有优先权以及它们将被处理的顺序。

    • 接下来是一个时间线块,让我们预览动画之间的过渡。通过拖动小旗子左右移动,我们可以在预览窗口中观看过渡。这个块的顶部显示了表示动画中包含的运动波纹图。下半部分显示了状态作为盒子,在过渡实际发生的地方重叠。这些盒子中的任何一个都可以被拖动以改变过渡的长度。

      提示

      由于我们两个空闲动画的长度可以忽略不计,这在我们的设置中通常不容易被看到。如果你在extendretract状态之间创建一个临时过渡,那么它将是可见的。

    • 最后,我们有一个条件列表。使用我们设置的参数,我们可以在这里创建任意数量的条件,这些条件必须在过渡发生之前得到满足。

    注意

    检查器面板的底部,有另一个预览窗口。它与动画导入设置页面的功能一样,但这个窗口会播放两个相关动画之间的过渡。

  13. 选择Idle_Retract状态和Extend状态之间的过渡。我们希望目标随机弹出。这将由一个脚本来控制,该脚本将改变时间参数。

  14. 点击条件列表下的+添加新条件。然后,点击条件中间的箭头来从参数列表中选择时间

  15. 为了将Float值转换为条件语句,我们需要将其与另一个值进行比较。这就是为什么当我们选择参数时,我们会得到一个新的下拉按钮,其中包含比较选项。Float值将大于或小于右侧的值。我们的时间将倒计时,因此从列表中选择Less,并将值保留为零。

  16. 添加一个条件,以便Idle_ExtendRetract状态之间的转换将是相同的。

  17. Idle_Extend状态和Hit_Front状态之间的转换,我们将使用创建的两个Bool参数。选择转换,并在条件下点击+按钮,直到你有两个条件。

  18. 对于第一个条件,选择wasHit,第二个条件选择inTheFrontBool参数要么是true,要么是false。在转换的情况下,它需要知道它在等待哪个值。对于这个转换,两者都应该保持为true

  19. 接下来,设置Idle_ExtendHit_Back之间的转换条件,就像你为前一个转换所做的那样。唯一的区别是,在inTheFront条件旁边的下拉列表中选择false

这里,我们创建了一个将被我们的目标使用状态机。通过将每个状态链接到一个动画,并将它们全部用转换连接起来,目标将能够切换动画。通过添加条件和参数来控制这种转换。

编写目标脚本

在我们完成目标组合之前,我们只需要一个额外的部分——一个脚本:

  1. 在我们的Scripts文件夹中创建一个新脚本,并将其命名为Target

  2. 首先,为了与我们的状态机交互,我们需要引用Animator组件。这是你从坦克和城市中移除的组件。Animator组件是将所有动画部分联系在一起的部分:

    public Animator animator;
    
  3. 这后面跟着两个浮点值,它们将决定我们的目标在空闲状态下将停留的时间范围,以秒为单位:

    public float maxIdleTime = 10f;
    public float minIdleTime = 3f;
    
  4. 接下来,我们有三个值,将保存我们需要更改的参数的 ID 号码。技术上可以使用参数名称来设置它们,但使用 ID 号码要快得多:

    private int timeId = -1;
    private int wasHitId = -1;
    private int inTheFrontId = -1;
    
  5. 最后两个变量将保存两个空闲状态的 ID 号码。我们需要这些来检查我们处于哪个状态。所有 ID 最初都设置为-1作为占位值;在下一步中,我们将使用函数将它们设置为实际值:

    private int idleRetractId = -1;
    private int idleExtendId = -1;
    
  6. Awake函数是 Unity 中一个特殊的函数,在游戏开始时对每个脚本进行调用。其目的是在游戏开始之前进行初始化,它非常适合用于最初设置我们的 ID 值。

    public void Awake() {
    
  7. 对于每个 ID,我们调用一次Animator.StringToHash函数。这个函数计算我们提供给它的参数或状态的名称的 ID 号码。状态名称还需要加上Base Layer的前缀。这是因为当可能存在多个不同层,且这些层中有名称相同的状态时,Unity 希望我们能够明确指出。这里的一个非常重要的点是,名称必须与Animator窗口中的名称完全匹配。如果不匹配,ID 将不匹配,会出现错误,并且脚本将无法正确运行。

      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");
    }
    
  8. 为了利用所有这些 ID,我们求助于我们的好朋友——Update函数。在函数的开始,我们使用GetCurrentAnimatorStateInfo函数来确定当前处于哪个状态。我们向这个函数发送零,因为它想知道我们要查询层的索引,而我们只有一个层。该函数返回一个包含当前状态信息的对象,我们立即获取该状态的nameHash值(也称为 ID 值),并将我们的变量设置为该值。

    public void Update() {
      int currentStateId = animator.GetCurrentAnimatorStateInfo(0).nameHash;
    
  9. 下一行代码是将我们的空闲状态 ID 进行比较,以确定我们是否处于这些状态之一。如果是这样,我们调用SubtractTime函数(我们稍后会编写)来减少时间参数。

    if(currentStateId == idleRetractId || currentStateId == idleExtendId) {
      SubtractTime();
    }
    
  10. 如果目标当前不在其空闲状态之一,我们首先检查是否被击中。如果是这样,使用ClearHit函数清除击中效果,并使用ResetTime函数重置时间参数。我们稍后会编写这两个函数。最后,我们检查计时器是否已经降到零以下。如果是这样,我们再次重置计时器。

    else {
      if(animator.GetBool(wasHitId)) {
        ClearHit();
        ResetTime();
      }
    
      if(animator.GetFloat(timeId) < 0) {
        ResetTime();
      }
    }
    }
    
  11. SubtractTime函数中,我们使用Animator组件的GetFloat函数来获取一个浮点参数的值。通过发送我们的timeId变量,我们可以接收时间参数的当前值。就像我们对坦克所做的那样,然后我们使用Time.deltaTime来跟上我们的帧率,并从计时器中减去时间。完成此操作后,我们需要将新值传递给状态机,这是通过SetFloat函数完成的。我们通过给它一个 ID 值来告诉它要更改哪个参数,并通过给我们新的时间值来告诉它要更改什么。

    public void SubtractTime() {
      float curTime = animator.GetFloat(timeId);
      curTime -= Time.deltaTime;
      animator.SetFloat(timeId, curTime);
    }
    
  12. 接下来要创建的函数是ClearHit。这个函数使用Animator组件的SetBool来设置布尔参数。它的作用与SetFloat函数完全一样。我们只需给它一个 ID 和一个值。在这种情况下,我们将两个布尔参数都设置为false,这样状态机就不再认为它已经被击中。

    public void ClearHit() {
      animator.SetBool(wasHitId, false);
      animator.SetBool(inTheFrontId, false);
    }
    
  13. 脚本要实现的最后一个函数是ResetTime。这也是一个简单的函数。首先,我们使用Random.Range函数来获取一个随机值。通过传递给它一个最小值和最大值,我们新的随机数将位于它们之间。最后,我们使用SetFloat函数将新值传递给状态机。

    public void ResetTime() {
      float newTime = Random.Range(minIdleTime, maxIdleTime);
      animator.SetFloat(timeId, newTime);
    }
    

我们创建了一个脚本来控制我们目标的状体机。为了比较状态和设置参数,我们收集并使用了 ID。现在,不必担心击中状态何时激活。当我们最终让坦克开火时,下一节将对此进行详细说明。

创建预制体

既然我们已经有了模型、动画、状态机和脚本,是时候创建目标并将其转换为预制体了。我们已经拥有所有部件,让我们将它们组合在一起:

  1. 首先,从Project窗口将Target模型拖动到Hierarchy窗口。这将创建目标对象的新实例。

  2. 通过选择新的目标对象,我们可以看到它已经附有一个Animator组件;我们只需添加对我们创建的AnimatorController的引用。通过将Project窗口中的TargetController拖动到 Animator 组件的Controller字段中,就像我们迄今为止设置的所有其他对象引用一样。

  3. 我们还需要将Target脚本添加到对象中,并在相关字段中连接到Animator组件的引用。

  4. 对目标对象的最后一步是添加一个碰撞器,以便实际接收我们的炮弹射击。不幸的是,由于Target对象使用骨骼和绑定进行动画,这不像直接在我们射击的网格上添加碰撞器那么简单。相反,我们需要创建一个新的空GameObject

  5. 将其重命名为TargetCollider,并将其设置为目标的Bone_Target骨骼的子对象。

  6. 在新的 GameObject 上添加一个MeshCollider组件。

  7. 现在,我们需要为此组件提供一些网格数据。在Project窗口中找到Target网格数据,位于Target模型下方。将其拖动到MeshCollider组件的Mesh值中。这会在Scene视图中显示一个绿色圆柱体。这是我们的碰撞体,但它尚未与目标对齐。

  8. 使用Transform组件将GameObjectX值设为4YZ值设为0。旋转需要改为X0Y-90Z90

  9. 当我们进行更改时,你可能已经注意到所有新内容或更改的内容都变成了粗体。这是为了表示与原始预制体实例相比,这个预制体实例有所不同。请记住,模型本质上是预制体;它们的问题是,我们无法直接进行更改,比如添加脚本。要将此目标变成新的预制体,只需从Hierarchy窗口中将其拖动并放到Project窗口中的Prefabs文件夹中。

  10. 在这个时髦的新预制体创建之后,用它来填充城市。

  11. 当你放置了所有这些目标时,你可能注意到它们有点大。我们不需要单独编辑每个目标,甚至也不需要将它们作为一个组来编辑,只需对原始预制件进行更改。在项目窗口中选择Target预制件。检查器窗口会显示与场景中任何其他对象相同的根预制对象信息。选中我们的预制件后,场景中已经存在的所有实例将自动更新以匹配缩放一半的预制件。我们还可以更改最小和最大空闲时间,并使其影响整个场景。

我们刚刚完成了坦克目标的创建。利用 Unity 的预制系统,我们可以在整个游戏中复制目标,并且轻松地进行影响所有目标的更改。

如果你希望其中一个目标比其他所有目标都大,你可以在场景中更改它。对预制实例所做的任何更改都会被保存,并且优先于对根预制对象所做的更改。此外,当你在检查器窗口中查看实例时,窗口顶部将出现三个新按钮。选择按钮在项目窗口中选择根预制对象。恢复按钮将移除对此实例所做的所有独特更改,而应用按钮则会将此实例中所有更改更新到根对象。

使用你所学的关于动画和状态机的所有知识,这里的挑战是创建第二种类型的目标。尝试不同的移动和行为。你可以创建一个从四处挥动过渡到静止不动的目标。

从光线追踪到射击

现在玩这个游戏,它非常酷。我们有可驾驶的坦克和带有纹理的城市。我们甚至有花哨的动画目标。我们只缺少一样东西:我们如何射击?我们还需要制作一个脚本,这样我们就可以尽情地射击目标了。按照以下步骤创建脚本并设置它:

  1. 首先,我们需要在坦克中添加一个空的GameObject。将其重命名为MuzzlePoint,并将其设置为炮塔枢轴点对象的子对象。完成此操作后,将其定位在炮管末端,使蓝色箭头指向远离坦克的方向,与炮管同一方向。这将是我们子弹发射的点。

  2. 我们还需要一些东西来指示我们的射击位置。爆炸效果将在后续章节中介绍,所以从GameObject下的3D Object菜单中选择Sphere,并将其重命名为TargetPoint

  3. 将球体的每个轴的比例设置为0.2,并给它一个红色的材质。这样,它就可以更容易地被看到,而不会完全突兀。它在我们场景中的起始位置并不重要,我们下一个脚本会在我们射击时移动它。

  4. TargetPoint中移除SphereCollider组件。必须移除SphereCollider,因为我们不希望射击我们自己的目标指示器。

  5. 现在,创建一个新脚本,并将其命名为FireControls

  6. 这应该开始让你感到熟悉了。我们从变量开始,这些变量用于保存对我们刚刚创建的枪口和瞄准物体的引用。

    public Transform muzzlePoint;
    public Transform targetPoint;
    
  7. Fire函数首先定义一个变量,用于保存被射击物体的详细信息:

    public void Fire() {
      RaycastHit hit;
    
  8. 这后面跟着一个检查Physics.Raycast函数的if语句。Raycast函数的工作原理就像射击枪一样。我们从一个位置(枪口点的位置)指向一个特定的方向(沿着蓝色轴相对于枪口点向前)并获取击中的物体。如果我们击中某物,if语句计算结果为true;否则,它是false,我们会跳过。

    if(Physics.Raycast(muzzlePoint.position, muzzlePoint.forward, out hit)) {
    
  9. 当我们击中某物时,首先将我们的目标点移动到被击中的点。然后我们使用SendMessage函数告诉被击中的物体它已经被击中,这与之前在RepeatButton脚本中使用的方式相同。我们使用hit.transform.root.gameObject来获取被击中的 GameObject。同时我们还提供一个值hit.point,告诉物体被击中的位置。代码行中的SendMessageOptions.DontRequireReceiver部分使得如果找不到预期的函数,函数不会抛出错误。我们的目标拥有这个函数,但城墙没有,如果不用这个参数,城墙会抛出错误。

    targetPoint.position = hit.point;
    hit.transform.root.gameObject.SendMessage("Hit", hit.point, SendMessageOptions.DontRequireReceiver);
    }
    
  10. 如果我们的Fire函数没有击中任何物体,最后一部分就会发生。我们将目标点发送回世界原点,这样玩家就知道他们什么都没有击中:

      else {
        targetPoint.position = Vector3.zero;
      }
    }
    
  11. 需要添加的最后一样东西是Target脚本末尾的Hit函数。我们首先获取当前状态 ID,就像之前在脚本中所做的那样。但这次我们只检查是否与扩展的空闲 ID 匹配。如果不匹配,我们使用return提前退出函数。这样做是因为我们不想让玩家射击那些已经倒下或处于过渡中的目标。如果我们的状态正确,我们继续通过使用SetBool函数告诉动画我们被击中了。

    public void Hit(Vector3 point) {
      int currentStateId = animator.GetCurrentAnimatorStateInfo(0).nameHash;
      if(currentStateId != idleExtendId) return;
      animator.SetBool(wasHitId, true);
    
  12. 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;
    }
    }
    
  13. 接下来,我们需要将新的FireControls脚本添加到坦克上。你还需要连接到MuzzlePointTargetPoint对象的引用。

  14. 最后,我们需要创建一个新的按钮来控制和触发这个脚本。所以,导航到GameObject | UI | Button,并将按钮重命名为Fire

  15. 接下来,我们需要点击按钮检查器窗口右下角的小加号,并为对象槽选择Tank,就像我们为井字游戏所做的那样。然后,从函数下拉菜单中导航到FireControls | Fire ()

我们创建了一个脚本,允许我们发射坦克的炮弹。使用射线追踪的方法是最简单且应用最广泛的。通常,子弹飞行速度太快,我们无法看到它们。射线追踪就是这样,即瞬间完成。然而,这种方法没有考虑重力,或者任何可能改变子弹方向的其他因素。

现在所有的按钮和组件都就位了,让它们看起来更好一些。使用你在上一章学到的技能来设计 GUI,让它看起来很棒。也许你甚至可以设法创建一个方向控制板来控制移动。

总结

就这样!这一章节内容很多,我们学到了不少东西。我们导入了网格并设置了一辆坦克。我们创建了材质,为城市添加了颜色。我们还制作了一些目标的动画,并学会了如何将它们击落。内容很多,现在是休息的时候了。玩玩游戏,射击一些目标,收集那些分数。项目已经全部完成,可以在你选择的设备上构建了。构建过程与前两个项目相同,所以尽情享受吧!

下一章将介绍特殊的相机效果和光照。我们将学习关于灯光及其类型。我们的坦克大战游戏将通过添加天空盒和几种灯光来进行扩展。我们还将看看距离雾效。随着阴影和光照图的加入,我们战斗的城市变得真正有趣和生动起来。

第四章:设置舞台 - 相机效果与照明

在上一章中,你已经学习了构成任何游戏的基础模块:网格、材质和动画。我们创建了一个名为Tank Battle的游戏,该游戏利用了所有这些模块。

在本章中,我们将扩展 Tank Battle 游戏。我们从添加天空盒和距离雾开始。通过使用第二个相机的目标指示器叠加,继续探索相机效果。为坦克创建涡轮增压效果,将完成我们对相机效果的探讨。继续关注照明,我们将通过添加光照图和阴影来完成坦克环境的制作。

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

  • 天空盒

  • 距离雾

  • 使用多个相机

  • 调整视野

  • 添加灯光

  • 创建光照图

  • 添加饼干(cookies)

我们将直接沿用第三章的项目,任何游戏的核心 - 网格、材质和动画。所以,在 Unity 中打开项目,我们将开始操作。

相机效果

你应该添加许多出色的相机效果,以使你的游戏达到最后的完美。在本章中,我们将介绍一些容易添加的选项。这些也将给我们的坦克游戏一个完成的外观。

天空盒和距离雾

当相机渲染游戏的帧时,它会首先清除屏幕。Unity 中的默认相机通过用渐变色给一切上色来实现这一点,模拟天空盒的外观。然后,所有的游戏网格都会绘制在这块空白的屏幕上。尽管渐变色比单一颜色看起来更好,但对于坦克的激烈战斗来说,它仍然相当乏味。幸运的是,Unity 允许我们更改天空盒。天空盒只是指形成任何游戏背景天空的一系列图像的华丽说法。距离雾与天空盒配合使用,通过在模型和背景之间平滑视觉过渡。

我们首先需要的是一个新天空盒。我们可以创建自己的,但是 Unity 为我们提供了几个非常适合我们需求的优秀天空盒。以下是获取天空盒的步骤:

  1. 在 Unity 编辑器的顶部,选择资源,然后点击导入包。在这个列表大约一半的位置,选择天空盒

  2. 经过一点处理,一个新的窗口会弹出。在 Unity 中,包只是一个已经设置好的压缩资产组。这个窗口显示内容并允许你选择性地导入它们。我们想要全部,所以只需点击此窗口右下角的导入

  3. 项目窗口中,将添加一个名为Standard Assets的新文件夹。其中包含一个名为Skyboxes的文件夹,里面包含各种天空盒材质。选择其中任何一个。你可以在检查器窗口看到,它们是使用天空盒着色器的普通材质。它们每个都有六张图片,代表一个立方体的每个方向。

  4. 你还会注意到,每张图片下方都有带有立即修复按钮的警告信息。这是因为所有图片都被压缩以节省导入时间和空间,但天空盒着色器需要它们以不同的格式。只需每次点击立即修复按钮,Unity 就会自动为你修复。它还将消除材质预览中的所有奇怪的黑色。

  5. 要将你选择的天空盒添加到游戏中,首先确保你已经加载了正确的场景。如果没有,只需在项目窗口中双击场景。这是必要的,因为我们即将更改的设置是针对每个场景的。

  6. 在 Unity 编辑器顶部,选择编辑,然后点击场景渲染设置。新的设置组将出现在检查器窗口中。

  7. 目前,我们关注的是顶部的值,即天空盒材质。只需将新的天空盒材质拖放到天空盒材质槽中,它就会自动更新。这个更改可以在游戏场景窗口立即查看。

  8. 要添加距离雾,我们还需在场景渲染设置中调整这个设置。要开启它,只需勾选使用雾复选框。

  9. 下一个设置,雾颜色,允许你为雾选择一个颜色。选择一个接近天空盒总体颜色的颜色是好的。

  10. 雾模式设置是一个下拉列表,其中包含决定 Unity 计算距离雾的方法的选项。在几乎所有情况下,默认设置指数平方是适用的。

  11. 接下来的三个设置,密度开始结束,决定了有多少雾以及它从多近的距离开始。它们只会在使用这些设置的雾模式下出现。密度用于指数指数平方雾模式,而其他设置用于线性雾模式。将雾设置在视线边缘通常会产生最佳视觉效果。将这些设置保持在指数平方,并将密度选择为0.03以获得良好的视觉效果。天空盒和距离雾

我们已经导入了几种天空盒,并将它们添加到了场景中。距离雾设置也已开启并调整。现在,我们的场景开始看起来像一个真正的游戏。

目标指示器

另一个相当有趣的摄像头效果是使用多个摄像头。第二个摄像头可以用来制作 3D GUI、小地图,或者可能是安全摄像头弹出窗口。在下一节中,我们将创建一个系统,该系统将指向附近的的目标。使用第二个摄像头,我们将使指示器出现在玩家坦克的上方。

创建指针

我们将从创建一个指向目标的物体开始。我们将制作一个可以重复使用的预制体。但是,为此章节,你需要导入IndicatorSliceMesh.blend起始资源,这样我们才有东西供玩家查看。它是一个饼状切片形状的网格。下面我们执行以下步骤来创建指针:

  1. 导入网格后,将其添加到场景中。

  2. 创建一个空的GameObject组件,并将其重命名为IndicatorSlice

  3. 将网格设置为IndicatorSlice的子对象,并将其定位,使其沿着GameObjectz轴指向,饼状切片的小端位于IndicatorSlice的位置。IndicatorSlice GameObject 将位于我们指示器的中心。创建的每个切片都将使其z轴指向目标的方向,如下图所示:Creating the pointer

  4. 现在,我们需要创建一个新的脚本来控制我们的指示器。在项目窗口中创建一个名为TargetIndicator的新脚本。

  5. 我们从这个脚本开始使用一对变量。第一个变量将保存对此指示器片段将指向的目标的引用。指示器也会根据目标距离的远近来增长和缩小。第二个变量将控制指示器开始增长的距离:

    public Transform target;
    public float range = 25;
    
  6. 下一个函数将在创建指示器片段时用来设置target变量:

    public void SetTarget(Transform newTarget) {
      target = newTarget;
    }
    
  7. 最后一组代码放入LateUpdate函数中。使用LateUpdate函数,指示器片段可以在我们的坦克在Update函数中移动后指向目标:

    public void LateUpdate() {
    
  8. 我们首先检查target变量是否有值。如果是 null,则销毁指示器片段。Destroy函数可以用来移除游戏中的任何对象。gameObject变量由MonoBehaviour类自动提供,并保存了对脚本组件所附加的GameObject组件的引用。销毁这个组件也会销毁所有其子对象(或附加的对象):

    if(target == null) {
      Destroy(gameObject);
      return;
    }
    
  9. 接下来,我们确定这个指示器片段距离其目标有多远。通过使用Vector3.Distance,我们可以轻松地计算出距离,而不需要我们自己进行数学计算:

    float distance = Vector3.Distance(transform.position, target.position);
    
  10. 这行代码通过使用一些精心应用的数学和Mathf.Clamp01函数,确定了片段的垂直缩放,即y轴。这个函数将提供的值限制在零和一之间:

    float yScale = Mathf.Clamp01((range – distance) / range);
    
  11. 我们使用计算出的比例来设置指示器切片的局部比例。通过调整局部比例,我们可以通过改变父对象的比例轻松控制整个指示器的大小:

    transform.localScale = new Vector3(1, yScale, 1);
    
  12. transform.LookAt函数只是一种花哨、自动的方式,用于旋转 GameObject,使其z轴指向世界中的特定位置。但是,我们希望所有的指示器切片都平铺在地面上,而不是指向可能在上方我们任何目标。因此,我们首先收集目标的位置。通过将变量的y值设置为切片的位置,我们确保切片保持平坦。当然,最后一行结束了LateUpdate函数:

      Vector3 lookAt = target.position;
      lookAt.y = transform.position.y;
      transform.LookAt(lookAt);
    }
    
  13. 上述代码是此脚本的最后一段代码。回到 Unity,并将TargetIndicator脚本添加到场景中的IndicatorSlice对象。

  14. 为了完成指示器,请创建它的预制件。就像我们对目标对象所做的那样。

  15. 最后,从场景中删除IndicatorSlice对象。游戏开始时,我们将动态创建切片。这需要预制件,但不需要场景中的那个。

我们创建了一个预制件,该预制件将用于指示目标的方向。创建并附加的脚本将旋转每个预制件实例,使其指向场景中的目标。它还将调整比例,以显示目标与玩家之间的距离。

控制指示器

现在我们需要创建一个控制指示器切片的脚本。这将包括在需要时创建新的切片。此外,它所附加的GameObject组件将作为我们刚刚创建的指示器切片围绕旋转的中心点。让我们执行以下步骤来完成这些操作:

  1. 创建一个新脚本,并将其命名为IndicatorControl

  2. 我们从这个脚本开始使用一对变量。第一个变量将保存对刚刚创建的预制件的引用。这样,我们就可以在需要时生成预制件的实例。第二个是一个静态变量,这意味着它可以轻松访问,无需引用场景中存在的组件。游戏开始时,它将被填充为场景中此脚本实例的引用:

    public GameObject indicatorPrefab;
    private static IndicatorControl control;
    
  3. 下一个函数将由目标使用。很快,我们将更新目标的脚本,以便在游戏开始时调用这个函数。该函数是静态的,就像前面的变量一样:

    public static void CreateSlice(Transform target) {
    
  4. 这个函数首先检查静态变量中是否有对任何对象的引用。如果它是空的,等于null,则使用Object.FindObjectOfType填充变量。通过告诉它我们想要查找什么类型的对象,它将在游戏中搜索并尝试找到。这是一个相对较慢的过程,不应该经常使用,但我们使用这个过程和变量,以便始终确保系统能找到脚本:

    if(control == null) {
      control = Object.FindObjectOfType(typeof(IndicatorControl)) as IndicatorControl;
    }
    
  5. CreateSlice函数的第二部分检查以确保我们的静态变量不为空。如果为空,它告诉实例创建一个新的指示器切片,并将目标传递给切片:

    if(control != null) {
      control.NewSlice(target);
    }
    }
    
  6. 这个脚本还有一个函数:NewSliceNewSlice函数如其名所示,当被调用时会创建新的指示器切片:

    public void NewSlice(Transform target) {
    
  7. 函数首先使用Instantiate函数创建indicatorPrefab的副本:

    GameObject slice = Instantiate(indicatorPrefab) as GameObject;
    
  8. 接下来,函数将新的切片设置为控制转换的子对象,这样在我们移动时它会跟随着我们。通过将新切片的本地位置归零,我们还确保它会与我们的控制处于同一位置:

    slice.transform.parent = transform;
    slice.transform.localPosition = Vector3.zero;
    
  9. 函数的最后一条线使用切片的SendMessage函数调用我们之前创建的SetTarget函数,并传递所需的目标对象:

      slice.SendMessage("SetTarget", target);
    }
    
  10. 现在脚本已经创建好了,我们需要使用它。创建一个空的GameObject组件,并将其命名为IndicatorControl

  11. 新的GameObject组件需要设置为坦克的子对象,然后将其在每个轴上的位置设置为 0。

  12. 将我们刚刚创建的脚本添加到IndicatorControl对象上。

  13. 最后,选择 GameObject,将IndicatorSlice预制体的引用添加到Inspector窗口中适当的槽位,通过从Project窗口拖动预制体来实现。

我们创建了一个脚本来控制目标指示器切片的生成。我们最后创建的GameObject组件还能轻松地让我们控制整个指示器的大小。我们几乎完成了目标指示器的工作。

使用第二个摄像头

如果你现在运行游戏,看起来不会有任何不同。这是因为目标还没有调用创建指示器切片的命令。我们还将在这个部分添加第二个摄像头,完成目标指示器的工作。以下步骤将帮助我们做好这件事:

  1. 首先打开Target脚本,在Awake函数的末尾添加以下代码行。这行代码告诉IndicatorControl脚本为这个目标创建一个新的指示器切片:

    IndicatorControl.CreateSlice(transform);
    

    使用第二个摄像头

  2. 如果你现在运行游戏,可以看到指示器的运行效果。但是它可能太大,而且肯定显示在坦克内部。一个糟糕的解决方案是将IndicatorControl对象移动,直到整个指示器显示在坦克上方。然而,当发生爆炸,物体开始空中飞散时,它们会再次遮挡目标指示器。一个更好的解决方案是添加第二个摄像头。现在你可以通过从 Unity 编辑器顶部选择GameObject,然后点击Camera来添加。

  3. 此外,将摄像头设置为Main Camera的子对象。确保将新摄像头的位置和旋转值设置为0

  4. 默认情况下,Unity 中的每个摄像机都会有一系列组件:摄像机光晕层GUI 层音频监听器。除了摄像机组件之外,其他组件通常对其他每个摄像机来说都不重要,整个场景中应该只有一个音频监听器组件。从摄像机中移除多余的组件,只留下摄像机组件。

  5. 在我们对摄像机进行任何其他操作之前,需要更改IndicatorSlice预制体的层。层用于对象之间的选择性交互。它们主要用于物理和渲染。首先在项目窗口中选择预制体。

  6. 检查器窗口的顶部是标签为的下拉列表,默认显示默认。点击下拉列表,并从列表中选择添加层...

  7. 检查器窗口中将会出现一个层列表。这些是游戏中使用的所有层。前几个被 Unity 保留使用,因此它们被灰显。其余的供我们使用。点击用户层 8右侧的输入框,并将其命名为Indicator

  8. 再次选择IndicatorSlice预制体。这次,从下拉列表中选择新的指示器层。

  9. Unity 会询问你是否也想改变所有子对象的层。我们希望整个对象在此层上渲染,因此我们需要选择是的,改变子对象,这样我们就能做到。

  10. 现在,让我们回到第二个摄像机。选择摄像机并查看检查器窗口。

  11. 摄像机组件的第一个属性是清除标志。这个选项列表决定了摄像机在绘制游戏中的所有模型之前,将用什么来填充背景。第二个摄像机不应该挡住第一个摄像机绘制的一切。我们从清除标志下拉列表中选择仅深度。这意味着它不会在背景中放置天空盒,而是保留已经渲染的内容,只在新内容上绘制。

  12. 下一个属性,剔除遮罩,控制摄像机渲染哪些层。前两个选项,全部,用于快速取消选择和选择所有层。对于这个摄像机,取消选择其他所有层,使得只有指示器层旁边有勾选标记。

  13. 要做的最后一件事是调整IndicatorControl的缩放,使得目标指示器不会太大或太小。使用第二个摄像机

我们创建了一个系统来指示潜在目标的方向。为此,我们使用了第二个摄像机。通过调整剔除遮罩属性中的层,我们可以使摄像机只渲染场景的一部分。同时,将清除标志属性更改为仅深度,第二个摄像机可以在第一个摄像机绘制的内容上绘制。

通过移动摄像头,可以改变指示器显示的位置。如果你移动IndicatorControl对象,它将改变目标距离和目标方向的计算方式。移动并调整第二个摄像头的角度,以便更美观地显示目标指示器。

当你移动第二个摄像头或使用加速功能(下一节内容)时,你可能会注意到目标指示器仍然可以在坦克内看到。调整主摄像头,使其不渲染目标指示器。这类似于我们如何让第二个摄像头只渲染目标指示器对象。

涡轮加速

在本章中,我们要看的最后一个摄像头效果是涡轮增压。它将在屏幕上是一个按钮,可以迅速推动玩家向前移动一段短暂的时间。摄像头效果之所以出现,是因为简单调整视野属性,就能让我们看起来移动得更快。电影中在汽车追逐场景中使用了类似的方法,让追逐看起来比实际更快。

在这一节中,我们只会制作一个脚本。该脚本将使坦克以类似于上一章中创建的ChassisControls脚本的方式移动。不同之处在于,我们不需要按下一个按钮就能使加速生效。下面是操作步骤:

  1. 首先,创建一个新脚本,并将其命名为TurboBoost

  2. 要开始脚本,我们需要四个变量。第一个变量是对坦克上CharacterController组件的引用。我们需要它来进行移动。第二个变量是我们在加速时移动的速度。第三个变量是加速持续的时间,以秒为单位。最后一个变量用于内部判断我们是否可以加速以及何时应该停止:

    public CharacterController controller;
    public float boostSpeed = 50;
    public float boostLength = 5;
    public float startTime = -1;
    
  3. StartBoost函数非常简单。它检查startTime变量是否小于零。如果是,将该变量设置为由Time.time提供的当前时间。该变量小于零意味着我们当前没有在加速:

    public void StartBoost() {
      if(startTime < 0)
        startTime = Time.time;
    }
    
  4. 我们将要使用的最后一个函数是Update函数。它首先检查startTime以确定我们当前是否正在加速。如果我们没有在加速,函数会提前退出。下一行代码检查以确保我们拥有CharacterController的引用。如果该变量为空,那么我们就无法让坦克移动:

    public void Update() {
      if(startTime < 0) return;
      if(controller == null) return;
    
  5. 下一行代码应该看起来很熟悉。这是让坦克移动的那一行:

    controller.Move(controller.transform.forward * boostSpeed * Time.deltaTime);
    
  6. 接下来,检查我们是否处于加速的前半秒。通过将当前时间与开始加速时记录的时间进行比较,我们可以轻松地计算出我们已经加速了多长时间:

    if(Time.time – startTime < 0.5f)
    
  7. 如果时间合适,我们会通过调整fieldOfView值来转换相机。Camera.main值是 Unity 提供的一个引用,指向场景中使用的主相机。Mathf.Lerp函数根据零到一之间的第三个值,从起始值向目标值移动。使用这个,相机的fieldOfView值会在半秒内向我们的目标值移动。

    Camera.main.fieldOfView = Mathf.Lerp(Camera.main.fieldOfView, 130, (Time.time – startTime) * 2);
    
  8. 下一段代码与前面两段相同,除了在加速的最后半秒,并使用相同的方法将fieldOfView值恢复到默认:

    else if(Time.time – startTime > boostLength – 0.5f)
    Camera.main.fieldOfView = Mathf.Lerp(Camera.main.fieldOfView, 60, (Time.time – startTime – boostLength + 0.5f) * 2);
    
  9. 最后一段代码检查我们是否完成了加速。如果是这样,将startTime设置为-1以表示我们可以开始另一次加速。最后的那个大括号,当然,关闭了Update函数:

    if(Time.time > startTime + boostLength)
      startTime = -1;
    }
    
  10. 接下来,将脚本添加到你的坦克上,并连接CharacterController引用。

  11. 我们快完成了。我们需要创建一个新的按钮。我们可以像以前一样做。将按钮锚定到画布的右下角,并将其定位在底盘移动控制之上。

  12. 最后,确保为OnClick对象选择Tank,并在功能中导航至涡轮增压 | StartBoost()

  13. 尝试这个吧。涡轮增压

我们在这里创建了一个涡轮增压。我们在前一章中使用的方法同样用于移动这里的坦克。通过调整相机的视野属性,我们使坦克看起来移动得更快。

在玩游戏时,你可能会注意到即使在加速时也可以转向。尝试在ChassisControls脚本中添加一个检查,以在加速时锁定控制。为此,你需要添加对TurboBoost脚本的引用。

为了增加额外的挑战,尝试给加速添加一个冷却时间。让玩家不能持续使用加速。另外,如果坦克撞到某物,尝试取消加速。这是一个难题,所以这里有一个提示:先查看 Unity 文档中的OnControllerColliderHit

Unity 提供了多种光类型来照亮游戏世界。它们分别是方向光聚光灯点光源区域光。这些光以不同的方式投射光线;以下是它们的详细解释:

  • 方向光:这类似于太阳。它将所有光线沿单一方向投射。光的位置无关紧要,重要的是旋转。光线以一个方向投射到整个场景。这使得它非常适合初步为场景添加光线。

  • 聚光灯:这就像舞台上的灯光一样工作。光线以类似圆锥的形状沿特定方向投射。因此,这也是系统计算中最复杂的光类型。Unity 对其计算光线的方式进行了重大改进,但应避免过度使用这些灯光。

  • 点光源:这是在游戏中主要使用的光源类型。它向四面八方发射光线。这就像一个灯泡。

  • 区域光源:这是一种特殊用途的光源。它从平面单一方向发射光线。可以把它想象成用来为酒店或餐厅做广告的大型霓虹灯。由于它们的复杂性,这些光源只能在烘焙阴影时使用。当游戏运行时,它们的计算量太大,无法使用。

在讨论灯光时,下一个明显的问题涉及到阴影,尤其是实时阴影。虽然实时阴影为场景增加了许多效果,并且在任何平台上技术上都是可能的,但它们的成本非常高。除此之外,对于所有光源类型,除了方向光,它们都是 Unity Pro 功能。总的来说,这对于一般的移动游戏来说有点过于昂贵了。

另一方面,有一些成本几乎不高的完美替代方案,它们通常比实时阴影看起来更真实。第一种替代方案是针对你的游戏环境。通常情况下,游戏中的环境在特定场景内不会移动也不会改变。为此,我们有光照图。它们是包含阴影数据的额外纹理。使用 Unity,你可以在制作游戏时创建这些纹理。然后,当游戏运行时,它们会自动应用,阴影就会出现。然而,这对于动态物体(任何会移动的东西)并不适用。

对于动态物体,我们有“cookies”。这可不是你奶奶做的饼干。在照明中,cookie 是一个黑白图像,它被投影到游戏中的网格上。这类似于皮影戏。皮影戏使用剪片来阻挡部分光线,而 cookies 则使用黑白图像来告诉光线可以投射光亮的位置。

Cookies 还可以用来创建其他静态和动态效果,比如在场景中移动的云层覆盖,或者从笼子中投射出的光线。或者,你可以使用它们来制作手电筒不均匀的焦点。

添加更多灯光

向场景中添加额外的灯光相当简单。而且,只要坚持使用点光源,渲染它们的成本就会保持较低。让我们使用以下步骤来照亮我们的游戏:

  1. 在 Unity 编辑器顶部,导航到 GameObject | Light | Point Light

  2. 选择新的光源后,在检查器窗口中我们应该关注以下几个属性:

    • 范围:这是光线从物体发射的距离。从这个点发出的光在中心位置最亮,在达到范围极限时逐渐消失。范围在场景视图中还以黄色线框球体表示。

    • 颜色:这仅仅是光线的颜色。默认情况下,它是白色;然而,这里可以使用任何颜色。这个设置在所有光源类型之间共享。

    • 强度:这表示光的亮度。光的强度越大,其中心的亮度也就越亮。这个设置对所有类型的灯光都是共享的。

  3. 创建并定位更多的灯光,沿着街道排列,为环境添加一些更有趣的元素。

  4. 按下 Ctrl + D 来复制选定的对象。这可以大大加快创建过程(如下面的截图所示):添加更多灯光

  5. 在添加这些灯光时,你可能注意到了它们的一个主要缺点。实时影响一个表面的灯光数量是有限制的。通过使用更复杂的网格,可以在一定程度上解决这个问题。更好的选择是使用光照图,我们将在下一节中看到。

  6. 再次在 Unity 编辑器顶部,导航到游戏对象 | 灯光 | 聚光灯

  7. 选择一盏新灯,并在检查器窗口中查看它。

    聚光角度:这是这种类型灯光独有的。它决定了发射光的锥形有多宽。与范围一起,在场景视图中由一个黄色线框锥形表示。

  8. 在坦克大战城市中心的喷泉周围添加几个聚光灯,如下面的截图所示:添加更多灯光

  9. 场景中拥有如此多的对象使得层次结构窗口显得杂乱无章,难以找到任何东西。为了组织它们,你可以使用空的游戏对象。创建一个游戏对象,并将其命名为 PointLights

  10. 通过将所有点光源设置为这个空游戏对象的子对象,层次结构窗口将变得不再那么杂乱。

我们为游戏添加了几盏灯。通过改变灯光的颜色,我们使场景看起来更有趣,玩起来也更吸引人。然而,这也揭示了照明系统的一个缺点。我们使用的城市非常简单,同时影响一个平面的灯光数量是有限制的。尽管如此,我们的场景外观得到了改善,但许多令人印象深刻的元素还是因为这一缺点而大打折扣。

光照图

光照图对于复杂的照明设置非常有效,这些设置在运行时可能过于昂贵或根本无法工作。它们还允许你在不消耗实时阴影的情况下为游戏世界添加详细的阴影。然而,这种方法只适用于在整个游戏过程中不会移动的对象。

光照图对于任何游戏环境都是一个很好的效果,但我们需要明确告诉 Unity 哪些对象不会移动,然后创建光照图。以下步骤将帮助我们完成此操作:

  1. 要做的第一件事是使你的环境网格静态化。为此,首先选择城市的一部分。

  2. 检查器窗口的右上角,对象名称字段右侧有一个复选框和静态标签。勾选此复选框将使对象变为静态。

  3. 按照以下步骤将城市的所有网格设为静态:

    • 如果你进行了任何形式的分组(正如我们对灯光所做的那样),那么这一步可以更快地完成,而不是逐个选择每个复选框。选择你城市的根对象,即所有城市部件、建筑和街道的父对象。

    • 现在,勾选静态复选框。

    • 在新的弹出窗口中,选择是,更改子对象,使所有子对象也变为静态。

  4. 当 Unity 生成光照图时,任何未展开或具有规范化 UV 空间外 UV 位置的网格都将被跳过。在模型导入设置窗口中,有一个选项可以让 Unity 自动生成光照图坐标,即生成光照图 UV。如果你正在使用TankBattleCity作为你的环境,现在应该开启这个选项。

  5. 转到 Unity 编辑器顶部,选择窗口,然后点击底部的光照

  6. 当你查看这个窗口时,大部分时间将花在场景页面上。选择窗口顶部的场景以切换到该页面。

  7. 你会首先注意到这个页面的一个特点是,它具有我们在场景渲染设置中看到的相同的天光部分,我们在那里更改了天空盒。在窗口底部,我们还有所有的设置。

  8. 我们感兴趣的部分是通用 GI 设置,如下面的截图所示:Lightmaps

    前面的截图有以下设置:

    • 工作流程:此设置决定了你将使用哪种方法来处理光照图。默认选择的是旧版方法。我们希望将其更改为按需。(迭代按需相同,但在你调整设置时尝试更新光照图。只有当你的计算机足够强大以处理此操作时,才推荐使用此选项。)

    • 全局参数:此设置允许你创建你可能想要快速选择的设置。如果你有许多需要更改的场景,这将特别有用。然而,我们只有一个场景,所以现在可以忽略它。

    • 天光:此设置影响场景中的环境光量。较低的值会使整个场景变暗,可能给你一个夜晚的场景。较高的值会使一切变得更亮,可能是白天的场景。此设置下面的实时天光复选框决定了此计算是在游戏运行时进行,还是在烘焙光照图时进行。取消勾选此框将节省处理资源,但勾选它将允许你在游戏运行时更改场景的亮度。因此,如果你想在游戏中看到你的灯光,将天光设置为0.2,并取消勾选实时天光

    • 反照率缩放:这个设置影响光线从表面反射的多少。间接光照缩放选项影响场景中来自非直接照射物体的光源的整体光照。出于我们的目的,这两个选项可以保持默认值。

    • 实时 GI 设置:这一部分仅在 Unity 的新光照贴图系统中可用。它包含在游戏运行时计算的光照贴图的控件。实时分辨率实时图集大小选项调整这些光照贴图中的细节量。CPU 使用率选项控制在游戏运行时系统将投入多少努力来计算你所看到的值。由于我们是在移动平台上工作,我们需要保持处理成本降低,所以将这些选项保持在其低默认值对我们来说很好。

    • 烘焙 GI 设置:这些设置包含调整预计算光照贴图的控件。这里是你大部分调整发生的地方。首先,我们有一个方向模式复选框,它决定了当我们未选中时是否使用单一组光照贴图。或者,如果我们使用两组,其中一组用于颜色和直接光照,第二组用于间接光照。使用两组光照贴图可以提供更高的细节,尤其是在暗区,但计算和使用成本更高。所以,我们打算保持它未选中。

    • 烘焙分辨率:此设置根据物体的大小控制其包含的细节量。在数字字段后面,你可以看到每单位纹理像素设置。纹理像素只是一个花哨的光照贴图像素。所以,它实际上是场景中每个单位在光照贴图中的像素细节量。出于我们的目的,30的值可以为我们提供足够的细节,而不会让计算机过载。

      提示

      烘焙分辨率设置将最直接影响实际烘焙光照贴图所需的时间。最好先使用低值开始工作,并且只有当你的光照设置接近你想要的最终产品的样子时,才增加这些值。

    • 烘焙图集大小:此设置控制最终光照贴图图像的分辨率。较小的分辨率更容易处理,但你需要限制场景中最大物体的总体细节。无论你选择哪种分辨率,你的模型的单个平面都不能比单个光照贴图图集拥有更多的细节。默认的1024在细节和处理成本之间取得了很好的平衡。

    • 填充:此设置调整光照贴图中对象之间的空间。过低的值将导致阴影渗透到共享光照贴图的其他对象的边缘。过高的值将导致光照贴图中浪费大量空间。同样,默认值对我们来说就很好。

    • 直接缩放: 这个设置将在烘焙到光照贴图中的灯光强度进行缩放。它允许你改变场景的整体亮度。默认设置在这里同样可以正常工作。

    • 环境光指数: 这个设置调整环境光照的对比度。这将使你场景中的暗区看起来更暗,亮区看起来更亮。将其保留为默认的1对我们来说就很好。

  9. 在页面底部有一个烘焙按钮。点击这个按钮将开始渲染过程。在 Unity 的右下角会出现一个加载条,这样你可以监控进度。

    注意

    提醒一下,这个过程可能需要一些时间。特别是随着环境复杂度和灯光数量的增加以及细节设置的提升,这个过程运行的时间会越来越长。另外,除非你有一台高性能的计算机,否则在它运行的时候在 Unity 里你几乎什么也做不了。

  10. 如果你点击了按钮并意识到你犯了一个错误,不要慌张。选择烘焙后,该按钮会变为取消。此时,可以选择它并停止进程继续进行。然而,一旦纹理被创建并且 Unity 开始导入它们,就无法停止这一过程。

  11. 烘焙按钮的左侧是清除。这个按钮是删除和移除场景中当前使用的所有光照贴图的最快和最简单的方法。这个操作无法撤销。

  12. 为了给你的建筑物添加阴影,在层级中选择你场景中的方向光,并查看检查器窗口。

  13. 阴影类型下拉列表中选择软阴影。这简单地为这个灯光开启了阴影。它为光照贴图和实时光照都开启阴影。开启阴影的灯光数量越多,渲染成本就越高。为你的光照贴图开启阴影是个好主意,但之后一定要关闭它们。这将为你最终的游戏节省处理资源,同时还能让你的静态场景看起来很好。

  14. 当你的所有灯光和设置都符合你的预期时,选择烘焙,一旦处理完成,就可以惊奇地欣赏你面前现在这个美丽的场景,如下图所示:光照贴图

我们为游戏世界添加了光照贴图。仅处理这一步所需的时间就使得进行微小调整变得困难。然而,通过几次点击,我们的光照效果得到了极大的改善。之前灯光被网格破坏,现在我们有了平滑的颜色和光照区域。

当玩游戏时,人们唯一不会质疑来源的光线类型是阳光。如果看不到来源,其他任何光线看起来都会很奇怪。创建一个网格并将其添加到游戏中,以便为你要使用的灯光提供一个理由。这可以是类似于火把、路灯,甚至是发光的外星粘液球的东西。无论它们最终是什么,拥有它们都能增加完整性,这是让游戏看起来不错与看起来很棒之间的区别。

作为第二个挑战,看看你的光照贴图的质量。玩弄我们讨论的各种质量设置,看看有什么不同。还要找出分辨率可以低到什么程度,你才会注意到像素化。在运行小型移动设备屏幕时,设置是否可以更低?去发现吧。

饼干效果

饼干是增强游戏中灯光兴趣的好方法。它们使用纹理来调整光线如何发射。这种效果可以涵盖广泛的使用范围,从闪烁的晶体到笼式工业灯光,在我们的案例中,是车头灯。

通过给我们的坦克添加车头灯,我们让玩家能够控制他们世界中的灯光。使用饼干效果,我们可以让它们看起来比简单的光圈更有趣。按照以下步骤添加这些灯光:

  1. 从创建一个聚光灯开始。

  2. 将灯光放置在坦克前方并指向外侧。

  3. Inspector窗口中,将强度属性的值增加到3。这将使我们的车头灯更亮,就像真正的车头灯一样。

  4. 现在,我们需要一些饼干纹理。在 Unity 编辑器顶部,导航到Assets | Import Package | Light Cookies

  5. 在新窗口中,点击Import并等待加载条完成。

  6. 现在我们有几个选项可以选择。在Standard Assets文件夹内,创建了一个名为Light Cookies的新文件夹,其中包含了新的纹理。将Project窗口中的Flashlight拖放到Inspector窗口中SpotlightCookie字段上。这样就可以简单地为灯光添加一个饼干效果。

  7. 你可能仍然无法看到你的饼干效果。这是之前我们遇到的问题的同样结果;太多的灯光不能对同一个物体进行阴影处理。不幸的是,意味着要移动的灯光不能烘焙到光照贴图中。为了解决这个问题,在Inspector面板中将灯光的渲染模式属性更改为重要。这将给灯光优先级,使其在场景中的其他物体之前照亮一个物体。

  8. 如果你现在再次烘焙你的灯光,你最终会得到一个贴在建筑物墙上的饼干形状。我们需要将GI 模式更改为实时,这样光线就能被光照贴图过程忽略,但仍然能够影响场景。

  9. 最后,复制第二个车头灯的灯光,并使它们都成为坦克的子物体。如果车头灯不跟着我们,那它们还有什么用?Cookies

我们通过几个简短的步骤,使用 cookies 为我们的坦克创建了一对大灯。这正是许多其他游戏,尤其是恐怖游戏,创建手电筒效果的方式。

尝试编写一个脚本,允许玩家打开和关闭大灯。它应该是一个简单的按钮,用来切换灯光。查看作为灯光一部分提供的enabled变量。

作为一项简单的挑战,创建一个位于坦克炮塔上的灯。给它一个光源。这样,玩家可以指向他们射击的地方,而不仅仅是坦克所指的方向。

阴影

阴影是一种简单且成本较低的方法,通过它你可以为角色添加阴影。自从视频游戏诞生以来,它们就一直存在。普通阴影是将物体的固体、暗色投影到另一个表面上。阴影的轮廓与物体的形状完全匹配。当角色开始随机移动时,这变得计算起来很昂贵。

阴影是一个位于角色或物体下方的黑色纹理块。它通常没有明确的形状,并且不会与它所要表示的物体的形状完全匹配。阴影通常也不会改变大小。这使得它计算起来明显更容易,成为许多代视频游戏的阴影选择。这也意味着它更适合我们的移动设备,因为在这些设备上处理速度可能很快就会成为一个问题。

我们将为我们的坦克添加一个阴影。Unity 已经为我们完成了大部分工作;我们只需将其添加到坦克上。通过以下步骤,我们可以添加阴影:

  1. 我们从导入 Unity 的阴影开始。回到 Unity 编辑器顶部,导航到资源 | 导入包 | 投影仪

  2. 在新窗口中点击导入,并在项目窗口中查看名为Projectors的新文件夹,该文件夹位于Standard Assets下创建。

  3. 项目窗口将Blob Shadow Projector预制体拖到场景中,并将其放置在坦克上方,如下图所示:阴影

  4. 不幸的是,阴影出现在我们坦克的顶部。为了解决这个问题,我们再次需要利用图层。所以,选择坦克。

  5. 图层下拉列表中选择添加图层…

  6. 点击用户图层 9右侧的文本框,为其命名PlayerTank

  7. 再次选择你的坦克,但这次从图层下拉列表中选择PlayerTank

  8. 当新窗口弹出时,请确保选择是,更改子对象以改变整个坦克的图层。如果你不选择这个,阴影可能会出现在坦克的某些部分,而其他部分可能不会出现。

  9. 现在,从层次结构窗口中选择Blob Shadow Projector

    注意

    blob 阴影是由Projector组件创建的。这个组件的工作方式与Camera组件类似。然而,它将图像投影到世界上,而不是将世界转换成图像并显示在你的屏幕上。

  10. 看一下Inspector窗口。我们现在关心的是Ignore Layers的值。目前,它被设置为Nothing

  11. 点击Nothing,并从Layers下拉列表中选择PlayerTank。这将使投影仪忽略坦克,只在其下方产生 blob 阴影。

  12. 下一步是调整阴影的大小,使其大致与坦克的大小相匹配。调整Field of View属性的值,直到大小差不多合适。从70开始似乎是一个不错的选择。Blob shadows

  13. 最后一步是使Blob Shadow Projector成为坦克的子对象。我们需要能够带着我们的阴影移动;我们可不想失去它。

我们为坦克添加了阴影。阴影对于使物体,尤其是角色看起来实际接触地面非常有用。我们使用的 blob 阴影优于实时阴影,因为它的处理速度更快。

blob 阴影自带的纹理是圆形的,但我们的坦克大多是方形的。尝试为 blob 阴影创建自己的纹理并使用它。某种矩形纹理应该会很合适。如果最终场景中出现了长长的黑色条纹,请确保你的纹理在图像边缘周围有完全白色的边框。

如果你成功地为 blob 阴影添加了自己的纹理,那么不妨看看那门炮?炮管伸出我们的坦克,破坏了其原本的方形轮廓。使用第二个 blob 阴影,附着在炮塔上,为炮管投射阴影。这个纹理也将必须是矩形形状的。

总结

在这一点上,你应该已经非常熟悉摄像机效果和灯光。

在本章中,我们首先查看了使用多个摄像头的用法。然后,我们玩弄了涡轮增压摄像机效果。接着,我们继续对城市进行照明。当我们使用光照图时,灯光效果得到了极大的提升。最后,我们通过一些特殊的照明效果来查看饼干和 blob 阴影。

在下一章中,我们将看到为我们的游戏创建敌人。我们将使用 Unity 的寻路系统使它们四处移动并追逐玩家。在此之后,如果玩家希望保持积分,他们需要变得更加积极。

第五章:穿梭自如 - 路径寻找与人工智能

在上一章中,我们了解了相机和光照效果。我们在坦克大战游戏中添加了天空盒、灯光和阴影。我们创建了光照图来使我们的场景动态化。我们通过给坦克车头灯添加“饼干”效果来了解了投影仪。我们还通过为坦克创建了一个斑点阴影来了解了投影仪。我们还为坦克创建了一个涡轮增压功能。通过调整相机的视角,我们能够让坦克看起来比实际速度快得多。当我们完成这一章时,我们已经拥有了一个动态且令人兴奋的场景。

本章将全面介绍敌人。玩家将不能仅仅待在一个地方来积累分数。我们将向游戏中添加一个敌方坦克。通过使用 Unity 的 NavMesh 系统,坦克将能够进行路径寻找并追逐玩家。一旦发现玩家,坦克就会射击并减少玩家的得分。

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

  • NavMesh

  • NavMeshAgent

  • 路径寻找

  • 追逐和攻击 AI

  • 出生点

我们将对第 四章《设置舞台 - 相机效果与光照》中的坦克大战游戏进行修改,所以加载它,我们可以开始。

理解人工智能与路径寻找

如你所猜测的,AI人工智能。在最广泛的意义上,这是任何非生命体可能做的,使其看起来像是在做决定。你对此概念最熟悉的可能来自视频游戏。当一个不由玩家控制的角色选择一个武器和一个使用它的目标时,这就是 AI。

在其最复杂的形式中,人工智能试图模仿完整的人类智能和学习。然而,对于这一切真正成功来说,发生的事情仍然太多太快。视频游戏无需达到这一步。我们主要关注的是让我们的角色看起来智能,但仍然能被玩家征服。通常,这意味着不允许角色根据比真实玩家更多的信息采取行动。调整角色拥有和可以采取行动的信息量是调整游戏难度的一个好方法。

路径寻找 是 AI 的一个子集。我们一直在使用它,尽管你可能从未意识到。路径寻找正如其名,是寻找路径的行为。每次你需要找到两点之间的路时,你都在进行路径寻找。就我们的角色而言,最简单的路径寻找形式是直接向目标点直线前进。显然,这种方法在开阔平原上最有效,但当遇到任何障碍物时往往会失败。另一种方法是给游戏覆盖一个网格。使用网格,我们可以找到一个绕过任何障碍物并到达我们目标的路径。

作为路径查找的替代方法,或许最常被选择的一种是使用特殊的导航网格,即 NavMesh。这只是一个玩家永远看不到的特殊模型,但它覆盖了计算机角色可以移动的所有区域。然后以类似于网格的方式导航玩家;不同之处在于,这里使用的是网格的三角形,而不是网格的方形。这就是我们在 Unity 中将使用的方法。Unity 提供了一套很好的工具用于创建和利用 NavMesh。

NavMesh

在 Unity 中创建导航网格非常简单。这个过程与我们用于制作光照图的过程类似。我们只需标记一些要使用的网格,调整特殊窗口中的某些设置,然后点击一个按钮。所以,如果你还没有加载 Unity 中的坦克大战游戏,现在就加载它,我们可以开始操作了。

Unity 可以自动从场景中存在的任何网格生成 NavMesh。为此,首先需要将网格标记为静态,就像我们对光照图所做的那样。然而,我们并不希望或需要能够导航城市的屋顶,因此我们使用一组特殊的设置列表来指定每个对象将是什么类型的静态。让我们从以下步骤开始:

  1. 层次结构窗口中选择城市,并在检查器窗口中点击Static右侧的向下箭头:The NavMesh

    我们可以查看以下静态对象的可用选项:

    • :此选项用于快速取消选中所有其他选项。如果所有其他选项都未被选中,此选项将被选中。

    • 一切:使用此选项,你可以快速选择所有其他选项。当所有选项都被选中时,此选项也将被选中。检查器窗口中Static标签旁边的复选框与选中或取消选中一切复选框执行相同的功能。

    • 光照图静态:在处理光照图时,需要选中此选项才能使它们正常工作。任何未勾选此选项的网格将不会被光照图处理。

    • 遮挡静态:这是用于处理遮挡的选项。遮挡是一种运行时优化方法,只渲染实际上可以看到的对象,无论它们是否在摄像机的视图空间内。遮挡物是会阻止其他对象被看到的对象。它与被遮挡静态选项一起工作。此选项的最佳对象选择是大型且实心的。

    • 批量静态:这是另一个运行时优化的选项。批量渲染是将对象组合在一起然后再渲染它们的操作。它大大提高了游戏的整体渲染速度。

    • 导航静态:这是我们目前主要关心的选项。任何勾选此选项的网格在计算 NavMesh 时将被使用。

    • 遮挡对象静态:正如刚才提到的,这个选项与遮挡器静态配合使用,以实现遮挡的好处。遮挡对象是会被其他对象遮蔽的物体。当被遮挡器覆盖时,这个物体将不会被绘制。

    • 离网格链接生成:这个选项同样与 NavMesh 计算有关。离网格链接是 NavMesh 两个不物理连接部分之间的连接,例如屋顶和街道。使用导航窗口中的几个设置和此选项,链接会自动生成。

    • 反射探针静态:最后一个选项允许物体被反射探针记录。这些探针记录它们周围的一切,并生成一个可以用作反射着色器的立方体贴图。

  2. 为了使 NavMesh 正常工作,我们需要更改设置,以便只能导航城市的街道。你上次看到坦克从建筑物屋顶跳下或掉下来是什么时候?因此,我们需要更改静态选项,使得只有街道勾选了导航静态。这可以通过以下两种方法之一完成:

    • 第一种方法是我们逐一取消要更改的每个对象的选项。

    • 第二种方法是,在层级窗口中取消勾选顶级对象的导航静态选项,当 Unity 询问是否要对所有子对象进行更改时,回答“是”。然后,只需对我们希望导航的对象重新勾选该选项。

  3. 现在,通过转到 Unity 的工具栏,点击窗口,然后点击菜单底部的导航来打开导航窗口。以下屏幕截图显示了制作 NavMesh 的所有工作发生的地方:NavMesh

  4. 这个窗口由三个页面和众多设置组成:

    当选择一个对象时,设置将出现在对象页面上。这两个复选框直接对应于我们刚才设置的同名的静态选项。在导航区域的下拉列表中,我们可以将 NavMesh 的不同部分分组。这些组可以用来影响路径查找计算。例如,可以设置汽车只在地面上行驶,而人类可以沿着人行道区域行走。

    烘焙页面是我们感兴趣的页面;它充满了改变 NavMesh 生成方式的选项。它甚至包括了一个很好的可视化表示,展示了各种设置在顶部:

    • 角色半径:这应该设置为最瘦的角色的大小。它用于防止角色走得太靠近墙壁。

    • 角色高度:这是你的角色的高度。利用这一点,Unity 可以计算出并移除那些对他们来说太低而无法通过的区域。任何低于这个值的区域都被认为是太小,因此应该将其设置为你的最矮角色的高度。

    • 最大坡度:在计算 NavMesh 时,任何比这个值更陡的斜坡都会被忽略。

    • 步高:在使用楼梯时,必须使用这个值。这是角色可以踏上的楼梯的最大高度。

    • 掉落高度:这是角色能够掉落的高度。有了这个设置,路径将包括从边缘跳下,如果这样做更快的话。

    • 跳跃距离:使用这个值,角色可以在.NavMesh 的缺口处跳跃。这个值表示可以跳跃的最远距离。

    • 手动体素大小/体素大小:勾选手动体素大小复选框,你可以调整体素大小的值。这是.NavMesh 的细节级别。值越低,与可见网格的匹配度越高,但计算时间会更长,存储所需的内存也更多。

    • 最小区域面积:如果.NavMesh 的部分小于这个值,那么在最终的.NavMesh 中将不会使用这些部分。

    • 高度网格:勾选此选项后,原始高度信息将在.NavMesh 中保持不变。除非你有特殊需要,否则这个选项应该保持关闭。系统计算需要更长的时间,存储也需要更多的内存。

    第三页区域允许我们调整我们定义的每个区域的移动成本。本质上,我们的游戏世界中不同部分的移动难度如何?对于汽车,我们可以调整层次,使其在田野中移动的成本是道路上的两倍。

    在窗口底部,我们有以下两个按钮:

    • 清除:这个按钮移除之前创建的.NavMesh。使用这个按钮之后,你需要在再次使用路径查找之前重新烘焙.NavMesh。

    • 烘焙:这个按钮开始工作并创建.NavMesh。

  5. 我们的城市非常简单,所以默认值对我们来说已经足够适用。点击烘焙,并观察右下角的进度条。完成之后,会出现一个蓝色网格。这就是.NavMesh,它表示角色可以移动的所有区域。

    提示

    可能你的坦克在移动时会稍微穿过后建筑物墙壁。如果发生这种情况,请在导航窗口中增加代理半径,直到它们不再这样做。

  6. 我们还需要做最后一件事。我们的导航网格.NavMesh 很完美,但如果你仔细观察,会发现它穿过了城市中心的喷泉。如果敌方坦克开始从喷泉中驶过,那就太不对了。要修复这个问题,首先选择围绕喷泉形成的网格。

  7. 在 Unity 的工具栏中,点击组件,然后是导航,最后是导航网格障碍。这仅仅添加了一个告诉导航系统在寻找路径时绕道的组件。由于我们已经选择了墙壁,新组件的大小将自动适应;我们只需要从形状下拉列表中选择胶囊。你可以在场景视图中看到一个线框圆柱体表示它。导航网格

我们创建了 NavMesh。我们利用了导航窗口和静态选项,告诉 Unity 在计算 NavMesh 时要使用哪些网格。Unity 团队投入了大量工作,使得这个过程快速而简单。

记住,在第三章 任何游戏的支柱 - 网格、材质和动画 中,当挑战是为玩家创建障碍时,我们鼓励你创建额外的网格,比如坦克陷阱和瓦砾。让敌方坦克也驶过这些障碍是个糟糕的想法。因此,尝试将这些设置为导航系统的障碍,就像对喷泉所做的那样。

NavMeshAgent 组件

你可能会想,我们有了 NavMesh 是很好,但是没有角色来导航它。在本节中,我们将开始创建我们的敌方坦克。在我们可以进行任何 AI 编程之前,我们需要导入并进行一些设置。使用这些步骤,我们可以创建它:

  1. 从本章的起始资源中选择 Tanks_Type03.pngTanks_Type03.blend,并将它们导入到 Models 文件夹下的 Tanks 文件夹中。

  2. Unity 导入完成后,在项目窗口中选择新的坦克,并在检查器窗口中查看它。

  3. 这个坦克没有动画,所以可以将动画类型设置为,并分别从骨骼动画页面取消选中导入动画

  4. 将坦克从项目窗口拖到场景窗口;任何街道上的清晰区域都可以。

  5. 首先,在场景视图中将模型重命名为 EnemyTank

  6. 现在,我们需要改变坦克的父子关系,以便炮塔可以转动,炮管跟随,就像我们对玩家坦克所做的那样。为此,创建一个空的游戏对象,并将其重命名为 TurretPivot

  7. TurretPivot 定位到炮塔底部。

  8. 层次结构窗口中,将 TurretPivot 拖放到 EnemyTank 上,使 EnemyTank 成为它的父对象。

  9. 接下来,再创建一个空的游戏对象,并将其重命名为 CannonPivot

  10. CannonPivot 游戏对象必须设置为 TurretPivot 的子对象。

  11. 层次结构窗口中,将炮塔网格设置为 TurretPivot 的子对象,将炮管网格设置为 CannonPivot 的子对象。当 Unity 询问你是否确定要断开预制件连接时,一定要点击

  12. 这个坦克模型有点大,因此需要在检查器窗口中调整坦克的导入设置中的缩放因子0.6,以便得到一个与玩家坦克大小相似的坦克。

  13. 为了让坦克在我们的新 NavMesh 上导航,我们需要添加一个NavMeshAgent组件。首先,在层次结构窗口中选择EnemyTank,然后导航到 Unity 的工具栏,选择组件 | 导航 | Nav Mesh Agent。在检查器窗口中,我们可以看到新组件及其相关设置,如下面的截图所示:NavMeshAgent 组件

    所有这些设置让我们可以控制 NavMeshAgent 与游戏世界的交互方式。让我们看看每个设置的作用:

    • 半径:这仅表示智能体的大小。结合我们在导航窗口中设置的半径值,可以防止对象部分进入墙壁和其他智能体中。

    • 高度:此设置影响编辑器中围绕智能体的圆柱体。它仅设置角色的高度,并影响他们可能能够走下的悬垂部分。

    • 基座偏移:这是附加到智能体的碰撞体的垂直偏移量。它允许你调整NavMeshAgent组件认为的角色底部位置。

    • 速度NavMeshAgent组件在拥有路径时自动移动连接的对象。此值决定了对象每秒沿路径移动的单位距离。

    • 角速度:这是智能体每秒可以转动的度数。人的角速度会非常高,而汽车的角速度会较低。

    • 加速度:这是智能体每秒增加的速度单位数,直到达到其最大容量。

    • 停止距离:这是从目标目的地开始,智能体将开始减速并停止的距离。

    • 自动刹车:勾选此选项后,由于大多数游戏的帧率通常平均在 60 到 90 FPS 之间,导致不规则帧率,智能体到达目的地时会立即停止,而不会超出目标。

    • 避障质量/优先级:质量表示智能体在寻找绕过障碍物的平滑路径时付出的努力程度。质量越高,寻找路径的努力越大。优先级选项决定了谁有先行权。值高的智能体将绕过值低的智能体。

    • 自动穿越非网格链接:勾选此选项后,智能体在进行路径寻找时会使用非网格链接,例如跳跃间隙和从边缘跌落。

    • 自动重新寻路:如果找到的路径由于任何原因不完整,此复选框允许 Unity 自动尝试寻找新路径。

    • 区域遮罩:还记得之前在讨论导航窗口时提到的区域吗?这里我们可以设置智能体能穿越哪些区域。只有在此列表中勾选的区域才会被智能体用于路径寻找。

  14. 现在我们理解了这些设置,让我们来使用它们。对于敌人坦克,Radius设置为2.4Height设置为4将会很好。你应该能够在场景窗口中看到另一个线框圆柱体,那是我们的敌人坦克。

  15. 需要做的最后一件事是将EnemyTank转变为预制体。就像我们对目标所做的那样,通过从层次结构窗口中拖拽它,并将其放置在项目窗口中的Prefabs文件夹里。

在这里,我们创建了一个敌人坦克。我们还了解了NavMeshAgent组件的设置。但是,如果你现在尝试运行游戏,似乎什么也不会发生。这是因为NavMeshAgent组件没有被指定一个目的地。我们将在下一节解决这个问题。

让敌人追踪玩家

我们下一个任务是让我们的敌人坦克追踪玩家。为此我们需要两个脚本。第一个脚本将简单地广播玩家的当前位置。第二个脚本将使用这个位置和我们之前设置的NavMeshAgent组件找到通往玩家的路径。

揭示玩家的位置

使用一个非常简短的脚本,我们可以轻松地让所有敌人知道玩家的位置。创建它的几个简短步骤如下:

  1. 首先,在项目窗口的Scripts文件夹中创建一个新的脚本。将其命名为PlayerPosition

  2. 这个脚本将从单一的静态变量开始。这个变量将简单地保存玩家的当前位置。由于它是静态的,我们可以很容易地从其他脚本访问它。

    public static Vector3 position = Vector3.zero;
    

    注意

    我们选择在这里使用静态变量,因为它的简单性和速度。另外,我们也可以为敌人坦克增加几个额外步骤;它可以在游戏开始时使用FindWithTag函数实际找到玩家坦克并将其存储在一个变量中。然后,在寻找玩家位置时查询该变量。这是我们可以采取的多种方法中的另一种。

  3. 在接下来的几行代码中,我们将使用Start函数。这个函数在场景首次加载时自动调用。我们使用它是为了让position变量在游戏开始时就能填充并使用。

    public void Start() {
      position = transform.position;
    }
    
  4. 代码的最后一段只是简单地更新了每一帧中的position变量,使其等于玩家的当前位置。我们还选择在LateUpdate函数中这样做,以便在玩家移动后再更新。LateUpdate函数在每一帧的末尾被调用。这样,玩家可以在Update函数中移动,而他们的位置会在稍后更新。

    public void LateUpdate() {
      position = transform.position;
    }
    
  5. 对于这个脚本需要做的最后一件事是将其添加到玩家的坦克中。因此,回到 Unity,将脚本从项目窗口拖放到坦克上,以添加它作为一个组件,就像我们对所有其他脚本所做的那样。

在这里,我们创建了追逐 AI 所需的首个脚本。这个脚本只是用一个变量更新玩家的当前位置。我们将在下一个脚本中使用它,让敌方坦克四处移动。

追逐玩家

我们下一个脚本将控制我们简单的追逐 AI。由于我们使用了NavMeshNavMeshAgent组件,我们可以将路径查找的大部分困难部分留给 Unity。通过执行以下步骤来创建脚本:

  1. 再次,创建一个新的脚本。这次,将其命名为ChasePlayer

  2. 这个脚本的第一行保存了之前设置的NavMeshAgent组件的引用。我们需要访问这个组件以便移动敌方坦克。

    public NavMeshAgent agent;
    
  3. 代码的最后一段首先确保我们有NavMeshAgent的引用,然后更新我们的目标目的地。它使用了之前设置的PlayerPosition脚本的变量和NavMeshAgentSetDestination函数。当我们告诉函数去哪里时,NavMeshAgent组件就会完成所有到达那里的艰苦工作。我们在FixedUpdate函数中更新我们的目标目的地,因为我们不需要在每一帧都更新目的地。如果有很多敌人,过于频繁地更新这可能会导致严重的滞后问题。FixedUpdate函数以固定的时间间隔被调用,并且比帧率慢,所以它非常合适。

    public void FixedUpdate() {
      if(agent == null) return;
    
      agent.SetDestination(PlayerPosition.position);
    }
    
  4. 现在我们需要将脚本添加到我们的敌方坦克中。在项目窗口中选择预制体,并将脚本拖放到检查器面板中,位于NavMeshAgent组件下方。

  5. 确保像之前一样连接引用。将NavMeshAgent组件拖到检查器窗口中的Agent值。

  6. 现在运行游戏来尝试一下。无论敌人从哪里开始,它都会绕过所有建筑物,到达玩家的位置。当你在周围驾驶时,你可以看到敌人跟随。然而,敌方坦克可能会穿过我们的坦克,我们也可能会驾驶穿过它。

  7. 修复这个问题的第一步是添加一些碰撞器。使用组件菜单中的物理选项,为炮塔、底盘和每个TreadCase对象添加盒状碰撞器。炮管和履带不需要碰撞器。履带箱体已经覆盖了履带的区域,而炮管作为目标太小,无法被准确射击。追逐玩家

    注意

    如果你是在场景视图中进行这些更改,请确保点击检查器窗口中的应用按钮,以更新根预制对象。

  8. 需要更改的最后一点是NavMeshAgent组件上的停止距离属性。当坦克交战时,它们会移动到射程内并开始开火。除非敌人小而脆弱,否则它们不会试图占据与敌人相同的空间。将停止距离设置为10,我们将能够复制这种行为。追逐玩家

在本节中,我们创建了一个脚本,使NavMeshAgent组件(在本例中是我们的敌人坦克)追逐玩家。我们添加了碰撞器以防止我们驶过敌人。此外,我们调整了停止距离的值,以获得更好的坦克行为。

尝试为敌人坦克添加一个斑点阴影。这将使它有更好的视觉接地感。你可以直接复制为玩家坦克制作的那个。

被敌人攻击

如果没有一点冲突,游戏还有什么乐趣;是选择战斗至死还是宇宙毁灭的烦恼?每个游戏都需要某种形式的冲突来推动玩家寻求解决方案。我们的游戏将变成一场分数争夺战。之前,这只是涉及射击一些目标并获得一些分数。

现在,我们将使敌人坦克向玩家开火。每次敌人得分,我们都会减少玩家的分数。敌人将以与玩家开火类似的方式射击,但我们将使用一些基本的 AI 来控制方向和射击速度,并替换玩家的输入控制。这些步骤将帮助我们实现它:

  1. 我们将从名为ShootAtPlayer的新脚本开始。在Scripts文件夹中创建它。

  2. 与我们所有的其他脚本一样,我们从两个变量开始。第一个变量将保存敌人坦克的最后位置。如果坦克在移动,它不会射击,因此我们需要存储其最后位置以查看它是否移动。第二个变量将是我们可以移动和射击的最大速度。如果坦克移动速度超过这个速度,它将不会开火。

    private Vector3 lastPosition = Vector3.zero;
    public float maxSpeed = 1f;
    
  3. 接下来的两个变量决定了坦克准备射击所需的时间。在每一帧都对玩家射击是不现实的。因此,我们使用第一个变量来调整准备射击所需的时间长度,第二个变量来存储射击将准备好的时间:

    public float readyLength = 2f;
    private float readyTime = -1;
    
  4. 下一个变量包含了炮塔旋转的速度值。当坦克准备射击时,炮塔不会旋转指向玩家。这给了玩家一个移开的机会。然而,我们需要一个速度变量,以防止炮塔在射击完毕后立即转向面对玩家。

    public float turretSpeed = 45f;
    
  5. 这里的最后三个变量引用了坦克其他部分的引用。turretPivot变量当然是我们要旋转的炮塔的支点。muzzlePoint变量将被用作我们开火的大炮的起点。这些将和玩家坦克的使用方式相同。

    public Transform turretPivot;
    public Transform muzzlePoint
    
  6. 对于脚本的第一个函数,我们将使用Update函数。它首先调用一个函数来检查是否可以开火。如果我们能开火,将对readyTime变量进行一些检查。如果它小于零,说明我们还没有开始准备射击,并调用一个函数来进行准备。然而,如果它小于当前时间,说明我们已经完成了准备,并调用开火的函数。如果我们无法开火,我们首先调用一个函数来清除任何准备,然后将炮塔转向玩家。

    public void Update() {
      if(CheckCanFire()) {
        if(readyTime < 0) {
          PrepareFire();
        }
        else if(readyTime <= Time.time) {
          Fire();
        }
      }
      else {
        ClearFire();
        RotateTurret();
      }
    }
    
  7. 接下来,我们将创建我们的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;
    
  8. 对于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;
    }
    
  9. PrepareFire函数简单快捷。它仅仅将我们的readyTime变量设置为一个未来的时间点,那时坦克将准备好射击:

    public void PrepareFire() {
      readyTime = Time.time + readyLength;
    }
    
  10. Fire函数首先确保我们有一个从muzzlePoint射击的引用:

    public void Fire() {
      if(muzzlePoint == null) return;
    
  11. 函数继续创建一个RaycastHit变量来存储我们射击的结果。我们使用Physics.RaycastSendMessage,就像在FireControls脚本中所做的那样,射击任何东西并告诉它我们击中了它:

    RaycastHit hit;
    if(Physics.Raycast(muzzlePoint.position, muzzlePoint.forward, out hit)) {
      hit.transform.gameObject.SendMessage("RemovePoints", 3, SendMessageOptions.DontRequireReceiver);
    }
    
  12. Fire函数最后通过清除射击准备来完成:

    ClearFire();
    }
    
  13. ClearFire函数是另一个简单的函数。它将我们的readyTime变量设置为小于零,表示坦克没有准备开火:

    public void ClearFire() {
      readyTime = -1;
    }
    
  14. 最后一个函数是RotateTurret。它首先检查turretPivot变量,如果引用缺失则取消函数。这之后是寻找指向玩家的方向,正如我们之前所做的。通过将y轴设置为0来扁平化这个方向。接下来,我们将创建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);
    }
    
  15. 现在,回到 Unity,创建一个空的GameObject并将其重命名为MuzzlePoint。将MuzzlePoint放置在炮管末端,就像我们对玩家所做的那样。

  16. MuzzlePoint设置为炮管的子对象,并在Inspector窗口中将可能存在的任何Y轴旋转归零。

  17. 接下来,将我们新的ShootAtPlayer脚本添加到敌方坦克中。此外,连接到TurretPivotMuzzlePoint变量的引用。

  18. 最后,对于敌方坦克,在Inspector窗口中点击Apply按钮以更新预制体。

  19. 如果你现在玩这个游戏,你会看到敌人旋转以指向你,但我们的分数不会减少。这是由于两个原因。首先,坦克略微浮空。无论你将其放置在世界上的哪个位置;当你玩游戏时,坦克会略微浮空。这是由于NavMeshAgent组件的工作方式。修复方法很简单;只需在Inspector窗口中将BaseOffset设置为-0.3。这调整了系统并将坦克放置在地面上。

  20. 分数没有变化的第二个原因是玩家缺少一个函数。为了解决这个问题,打开ScoreCounter脚本。

  21. 我们将添加RemovePoints函数。给定一个数值,这个函数简单地将玩家分数中那么多点数减掉:

    public void RemovePoints(int amount) {
      score -= amount;
    }
    

    提示

    如果你的敌方坦克仍然无法击中玩家,可能是因为它太大,射击时越过了玩家。只需将坦克的炮管向下倾斜,这样当它向玩家射击时,也会指向玩家坦克的中心。

    如果你看看右上角的分数计数器,当敌人靠近时分数会下降。记住,分数不会立即开始下降,因为敌人需要停止移动,准备好炮管,然后才能射击。

    遭受敌人攻击

我们赋予了敌人攻击玩家的能力。新的ShootAtPlayer脚本首先检查坦克是否已经减速并且炮管是否对准了玩家。如果是这样,它将定期向玩家开火以减少他们的分数。如果玩家希望在游戏结束时还能留下一些分数,他们就需要不停地移动并快速瞄准目标。

如果你不密切关注你的得分,就很难判断你是否正在被攻击。我们将在未来的章节中处理爆炸效果,但即便如此,玩家需要一些反馈来了解发生了什么。大多数游戏会在玩家被击中时在屏幕上闪烁红色纹理,不管是否有爆炸效果。尝试创建一个简单的纹理,并在玩家被击中时在屏幕上绘制半秒钟。

攻击敌人

当玩家面对一个无法对抗的敌人时,他们往往会很快感到沮丧。因此,我们将赋予玩家伤害和摧毁敌人坦克的能力。这将以与射击目标类似的方式运作。

削弱敌人的最简单方法就是给它们一些生命值,当它们被击中时生命值会减少。然后当它们的生命值耗尽时,我们可以摧毁它们。让我们按照以下步骤创建一个脚本来实现这一点:

  1. 我们将从创建一个名为Health的新脚本开始。

  2. 这个脚本相当简短,从一个变量开始。这个变量将跟踪坦克剩余的生命值。通过将默认值设置为3,坦克在遭到摧毁前能够承受三次打击。

    public int health = 3;
    
  3. 这个脚本也只包含一个函数,Hit。与目标的情况一样,当玩家向它射击时,这个函数是由BroadcastMessage函数调用的。函数的第一行将health减少一个点数。下一行检查health是否小于零。如果是,通过调用Destroy函数并传递gameObject变量来摧毁坦克。同时我们也给玩家一些分数。

    public void Hit() {
      health--;
      if(health <= 0) {
        Destroy(gameObject);
        ScoreCounter.score += 5;
      }
    }
    
  4. 真的就是这么简单。现在,在项目窗口中为EnemyTank预制体添加新的脚本,它将更新场景中你当前所有的敌人坦克。

  5. 尝试这样做:向场景中添加几个额外的敌人坦克,观察它们跟随你并在你射击它们时消失。

这里,我们给敌人坦克设置了一个弱点,即生命值。通过创建一个简短的脚本,坦克能够追踪自己的生命值并在被击中时检测到。一旦坦克的生命值耗尽,它就会从游戏中移除。

现在我们有两个目标可以射击:一个是动画目标,另一个是坦克。然而,它们都用红色切片表示。尝试将指向坦克的切片设置为不同的颜色。你需要复制一个IndicatorSlice预制体,并更改IndicatorControl脚本,以便在调用CreateSliceNewSlice函数时,它能知道应该使用哪种类型的切片。

作为进一步的挑战,一旦我们给一个生物体赋予生命值,玩家应该能够看到他们对它造成了多少伤害。有两种方法可以实现这一点。第一种是在坦克上方放置一组方块。然后,每次坦克失去生命值时,你将移除一个方块。第二种方法稍微复杂一些——在 GUI 中绘制一个条形图,并根据剩余的生命值改变其大小。为了使条形图在摄像机移动时保持在坦克上方,请查看文档中的Camera.WorldToScreenPoint

生成敌人坦克

游戏初期拥有有限数量的敌人并不适合我们游戏的长久乐趣。因此,我们需要制作一些生成点。随着坦克的摧毁,这些生成点将使玩家保持警惕。

本节中我们将创建的脚本将保持游戏世界中充满玩家可能想要摧毁的敌人坦克:

  1. 本节我们需要另一个新脚本。创建后,将其命名为SpawnPoint

  2. 这个脚本从几个变量开始。第一个变量将保存对我们EnemyTank预制体的引用。我们需要它来生成副本。

    public GameObject tankPrefab;
    
  3. 第二个变量用于跟踪已生成的坦克。当坦克被摧毁时,我们将创建一个新的。通过这个变量,我们防止游戏因敌人过多而变得混乱。生成的坦克数量将等同于生成点的数量。

    private GameObject currentTank;
    
  4. 第三个变量用于设置玩家与生成坦克之间的距离,以防止坦克在玩家上方生成。如果玩家处于此距离之外,可以生成新坦克。如果玩家在范围内,则不会生成新坦克。

    public float minPlayerDistance = 10;
    
  5. 我们将使用的第一个函数是FixedUpdate。它会先检查是否需要生成一个新的坦克。如果需要,它会调用SpawnTank函数来进行生成:

    public coid FixedUpdate() {
      if(CanSpawn())
        SpawnTank();
    }
    
  6. 接下来,我们创建CanSpawn函数。该函数的第一行检查我们是否已经有了一个坦克,如果有则返回false。第二行使用Vector3.Distance来确定玩家当前的距禨。最后一行将这个距离与玩家需要达到的最小距离进行比较,然后返回结果:

    public bool CanSpawn() {
      if(current != null) return false;
    
      float currentDistance = Vector3.Distance(PlayerPosition.position, transform.position);
      return currentDistance > minPlayerDistance;
    }
    
  7. 最后一个函数SpawnTank,首先检查tankPrefab引用是否已连接。如果没有东西可以生成,它就不能继续。第二行使用Instantiate函数来复制预制体。为了将其存储在我们的变量中,我们使用as GameObject以确保正确的类型。最后一行将新坦克移动到生成点的位置,因为我们不希望坦克在随机位置出现。

    public void SpawnTank() {
      if(tankPrefab == null) return;
    
      currentTank = Instantiate(tankPrefab) as GameObject;
      currentTank.transform.position = transform.position;
    }
    

    注意:

    我们再次选择使用InstantiateDestroy函数来处理敌军坦克的创建和销毁,因为它们的简单性和速度。另外,我们也可以创建一个可用敌人列表。然后,每当玩家消灭一个敌人,我们可以将其关闭(而不是完全销毁),只需将一个旧的移动到需要的位置(而不是创建一个新的),重置旧坦克的状态,并重新激活它。编程任何事情都会有多种方法,这只是其中一种替代方案。

  8. 返回 Unity,创建一个空的GameObject,并将其重命名为SpawnPoint

  9. 向其添加我们刚刚创建的SpawnPoint脚本。

  10. 接下来,选择出生点,通过将EnemyTank预制体从Prefabs文件夹拖拽到相应的值上,连接预制体引用。

  11. 现在,将SpawnPoint对象通过从Hierarchy窗口拖拽并放入Prefabs文件夹中,将其转变为预制体。

  12. 最后,用新的点来填充城市。在每个角落放置一个会工作得很好。生成敌军坦克

在这里,我们为游戏创建了出生点。每个点都会生成一辆新坦克。当一辆坦克被摧毁时,在出生点会创建一辆新的。随意构建游戏并在你的设备上尝试。这一节和这一章现在完成了,准备收尾。

为每辆坦克设置一个出生点是很好的,直到我们想要很多坦克,或者希望它们都从同一个位置出生。这里的挑战是,你需要让一个出生点跟踪多辆坦克。如果任何一辆坦克被摧毁,应该创建一辆新的。你肯定需要一个数组来跟踪所有坦克。此外,你还可以为出生过程实现一个延迟,这样就不会有多个坦克堆叠在同一个位置出生。这可能导致它们突然跳跃,因为NavMeshAgent组件会尽力防止它们占据同一空间。另外,玩家可能会认为他们只在与一辆坦克战斗,而实际上在同一个位置可能有几辆坦克。

既然你已经拥有所需的知识和工具,作为一个进一步的挑战,尝试创建其他类型的敌军坦克。你可以尝试改变大小和速度。它们也可以有不同的强度,或者你可以让摧毁敌军坦克时获得更多分数。也许,有一辆坦克实际上是在玩家射击时给玩家加分。尽情地玩这个游戏,享受其中的乐趣。

总结

在本章中,我们了解了 NavMeshes 和寻路。我们还进行了一些与人工智能相关的工作。这可能是最简单的人工智能类型之一,但追逐行为对所有类型的游戏都至关重要。为了利用这些功能,我们创建了一个敌方坦克。它追逐玩家并向他们开火以减少他们的得分。为了给玩家一些优势,我们给敌方坦克增加了生命值。玩家也可以射击敌方坦克以及目标来获得分数。我们还创建了一些生成点,这样每当一辆坦克被摧毁时,就会生成一辆新的。就整体游戏玩法而言,我们的坦克大战游戏基本上已经完成。

在下一章中,我们将创建一个新游戏。为了探索移动平台的一些特殊功能,我们将制作一个猴子球游戏。我们将几乎从屏幕上移除所有按钮,转而使用新的控制方法。我们将利用设备的倾斜传感器作为我们的转向方式。此外,我们还将使用触摸屏来摧毁敌人或收集香蕉。

第六章:移动设备的特性——触摸和倾斜

在上一章中,我们学习了路径查找和人工智能。我们将坦克大战游戏扩展到了包括敌方坦克。我们为它们创建了生成点,并让它们向玩家射击。此外,玩家获得了摧毁坦克的能力。一旦坦克被摧毁,玩家将获得一些分数,并且会生成新的敌方坦克。

在本章中,我们将通过探索移动设备的某些特性来开发一个新游戏。我们将创建一个猴子球游戏。玩家将控制一个超大仓鼠球中的猴子,尝试在时间耗尽前到达迷宫的终点,同时收集香蕉。为了移动,他们将不得不倾斜移动设备。为了收集香蕉,玩家将不得不触摸屏幕上香蕉所在的位置。

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

  • 触摸控制

  • 倾斜控制

  • 猴子球游戏

我们将为本章创建一个新项目,因此启动 Unity,我们将开始。

设置开发环境

与每个项目一样,我们需要做一些准备工作以准备我们的开发环境。别担心,本章的设置简单直接。让我们按照以下步骤进行操作:

  1. 第一步当然是启动 Unity 并创建一个新项目。它应该是一个 3D 项目,将其命名为Ch6_MonkeyBall会很合适。

  2. 当 Unity 完成初始化后,这是设置我们构建设置的完美时机。打开构建设置窗口,从平台列表中选择Android,然后点击切换平台以改变目标平台。

  3. 当你处于构建设置窗口时,选择玩家设置以在检查器中打开玩家设置。调整公司名称产品名称,最重要的是捆绑标识符

  4. 当用户倾斜他们的设备时,当新的一边成为底部时,整个屏幕将调整其方向。由于整个游戏都是围绕倾斜设备设计的,因此在玩家游戏过程中屏幕方向可能会随时改变,从而破坏他们的游戏体验。因此,在玩家设置中,找到分辨率展示部分,确保默认方向没有设置为自动旋转,这会导致 Unity 在我们玩游戏时改变游戏的方向。这里的其他任何选项都可以为我们所用。

  5. 我们需要创建几个文件夹以保持项目的组织性。在项目窗口中应创建Scripts(脚本)、Models(模型)和Prefabs(预制体)文件夹。由于将来我们可能会有数十个关卡和地图,因此创建一个Scenes(场景)文件夹也是个好主意。

  6. 最后,我们必须为本项目导入资源。我们需要一个作为玩家的猴子、一个要收集的香蕉、一个示例地图和一些围栏。幸运的是,所有这些资源都已准备就绪,并包含在本章的初始资源中。将Monkey.blendMonkey.psdBall.psdBanana.blendBanana.psdMonkeyBallMap.blendGrass.psdFence.blendWood.psd导入到您刚才创建的Models文件夹中。

我们刚刚完成了本章项目的设置。再次强调,项目开始时的一点点努力将节省时间并避免后期出现挫折;随着项目规模的扩大,开始时的组织工作变得非常重要。

一个基本的环境

在我们深入探讨倾斜和触摸控制的乐趣之前,我们需要一个基本的测试环境。在使用新的控制方案时,最好在一个简单且易于控制的环境中工作,然后再引入真实关卡复杂性。让我们按照以下步骤创建我们的环境:

  1. 在 Unity 顶部,通过导航到GameObject | 3D Object选择Cube,创建一个新立方体,它将成为我们基本环境的基础。将其重命名为Ground,以便我们可以跟踪它。

  2. 检查器面板中将立方体的位置设置为每个轴上的0,这样我们就可以围绕世界原点进行操作。同时,将它的XZ缩放检查器中设置为10,为我们提供足够的空间来移动并测试我们的猴子。

  3. 接下来,我们需要一个名为Fence的第二个立方体。这个立方体的位置值应为X-5Y1Z0,以及XY0.2缩放和Z10

  4. 层次结构窗口中选择Fence,您可以按键盘上的Ctrl + D来创建一个副本。我们需要总共四个,沿着我们的Ground立方体的每一边放置:一个基本的环境

现在我们有一个基本的测试环境,它将允许我们使用控制装置,而不必担心整个关卡的所有复杂性。一旦我们的控制装置在这个环境中按照我们想要的方式工作,我们将把我们的猴子引入到一个新的环境。

倾斜控制

现代移动设备提供了各种各样的内部传感器来检测并提供关于周围世界的信息。尽管你可能没有这样想过,但你一定非常熟悉用于打电话的麦克风和扬声器。还有用于连接互联网的 Wi-Fi 接收器和用于拍照的摄像头。此外,你的设备几乎肯定有一个磁力计,用于与 GPS 配合提供方向。

我们现在感兴趣的传感器是陀螺仪。这个传感器可以检测设备的局部旋转。一般来说,它是手机中众多传感器之一,用于确定设备在世界中的方向和移动。我们将使用它来控制我们的猴子。当用户左右倾斜设备时,猴子就会左右移动。当设备上下倾斜时,猴子就会前进和后退。通过这些步骤,我们可以创建一个脚本来让我们以这种方式控制猴子:

  1. 首先,创建一个新脚本并将其命名为MonkeyBall

  2. 我们第一个变量将保存一个对附加到球体的Rigidbody组件的引用。这是让我们实际让它滚动并与世界中的物体碰撞的关键:

    public Rigidbody body;
    
  3. 接下来的两个变量将让我们控制设备倾斜如何影响游戏中的移动。第一个将允许我们消除任何太小的移动。这让我们避免了来自环境或可能不是完全准确的传感器的随机移动。第二个将让我们在控制感觉过于迟缓或过快时,调整倾斜输入的大小:

    public float minTilt = 5f;
    public float sensitivity = 1f;
    
  4. 目前最后一个变量将跟踪设备被倾斜了多少。它迫使用户如果想要朝相反方向移动,就需要来回倾斜设备,以抵消移动:

    private Vector3 totalRotate = Vector3.zero;
    
  5. 我们这个脚本的第一个函数非常简短。为了从陀螺仪获取输入,我们首先必须打开它。我们将在Awake函数中这样做,以便从游戏一开始就跟踪它:

    public void Awake() {
      Input.gyro.enabled = true;
    }
    
  6. 我们脚本的下一个函数是Update。它首先从陀螺仪获取rotationRate的值。这是一个每秒弧度的值,表示用户沿着每个轴倾斜设备的速度有多快。为了使它更容易理解,我们在将其存储在变量之前,将rotationRate的值乘以Mathf.Rad2Deg将其转换为每秒度数:

    public void Update() {
      Vector3 rotation = Input.gyro.rotationRate * Mathf.Rad2Deg;
    

    注意

    当你将设备屏幕朝向你握在手中时,设备的x轴指向右边。y轴是垂直向上的,位于设备的顶部,而z轴则直接从屏幕中心指向你。

  7. 接下来,我们要确保每个轴上的移动足够大,以实际让我们的猴子移动。通过对每个值使用Mathf.Abs,我们找到轴移动的绝对值。然后,我们将其与我们寻找的最小倾斜量进行比较。如果移动太小,我们在rotation变量中将其归零:

    if(Mathf.Abs(rotation.x) < minTilt) rotation.x = 0;
    if(Mathf.Abs(rotation.y) < minTilt) rotation.y = 0;
    if(Mathf.Abs(rotation.z) < minTilt) rotation.z = 0;
    
  8. 最后,对于我们的Update函数,我们通过将新移动添加到我们的totalRotate变量来跟踪新移动。为此,我们需要重新排列这些值。玩家期望能够将设备顶部向自己倾斜以向后移动,远离以向前移动。这是x轴移动,但与我们需要移动猴子相比,它从我们的设备中反方向输入,因此值前有负号。接下来,我们交换yz轴的旋转,因为玩家将期望通过左右倾斜设备来左右移动,这是y轴移动。如果我们将其应用于猴子的y轴,他只能在原地旋转。因此,移动被视为每秒的速度而不是每帧的速度;我们需要乘以Time.deltaTime

    TotalRotate += new Vector3(-rotation.x, rotation.z, -rotation.y) * Time.deltaTime;
    }
    
  9. 目前最后一个函数是FixedUpdate函数。在对刚体进行修改和处理时,最好在FixedUpdate中进行。刚体实际上是将我们连接到 Unity 物理引擎的部分,而且它只在这个函数中更新。我们在这里所做的就是给刚体添加一些扭矩,或者说旋转力。我们使用收集到的总量乘以我们的sensitivity来给玩家提供他们预期的控制速度:

    public void FixedUpdate() {
      body.AddTorque(totalRotate * sensitivity);
    }
    
  10. 为了使用我们的新脚本,需要对球体进行一些修改。首先创建一个球体供我们操作;可以通过导航至游戏对象 | 3D 对象 | 球体找到。将其重命名为MonkeyBall,并将其位置稍微设在我们地面方块之上。

  11. 接下来,给物体赋予Ball.psd材质,这样我们就能看到它旋转而不仅仅是移动。材质的双色调特性将使我们能够轻松看到它在场景中滚动。

  12. 刚体组件可以通过在 Unity 顶部导航至组件 | 物理 | 刚体找到。添加一个新的刚体组件。

  13. 此外,将我们的MonkeyBall脚本添加到球体上,并将新的刚体组件拖到检查器面板中的Body槽。

  14. 在这一点上,拥有Unity Remote尤为重要。将设备连接并运行Unity Remote,你可以拿起它来控制球体。随意调整敏感度和最小倾斜度,直到找到感觉自然的控制设置。由于设备、硬件以及所用架构的多样性,不同设备之间的倾斜速率可能很容易有所不同。然而,特别是在这个阶段,你必须找到现在适合你设备的设置,并在游戏更加完善后再考虑其他设备的兼容性。

  15. 如果你发现球体滚动时的视线不好,移动摄像头以获得更好的视角。但确保它继续沿着世界的z轴向前指。

  16. 当所有设置完成后,确保保存场景。将其命名为MonkeyBall通过倾斜控制

我们利用陀螺仪为你提供了球的转向控制。通过测量玩家倾斜设备的方式,我们能够相应地给球添加运动。通过在简单地图上滚动,我们可以微调我们的控制,确保一切正常工作。

与相机一起跟随

为了让玩家真正感觉到他们正在控制球,相机需要跟随球移动。当地图和关卡变得比一个相机镜头能展示的更大更复杂时,这一点尤为重要。最简单的解决方案是将相机设置为球的子对象,但这会使它与球一起旋转,我们的控制也会变得混乱。所以,让我们按照以下步骤设置相机跟随球移动:

  1. 我们首先需要创建一个新的脚本,并将其命名为CameraFollow

  2. 这个脚本非常简单。它有一个单一变量来跟踪正在跟随的对象:

    public Transform ball;
    
  3. 脚本中唯一的函数是LateUpdate函数。我们使用这个函数,因为它在所有其他内容有机会进行正常更新之后执行。脚本要做的就是移动到球的新位置:

    public void LateUpdate() {
      transform.position = ball.position;
    }
    
  4. 为了使用这个脚本,我们需要一个新的空GameObject组件。将其命名为CameraPivot

  5. 将其定位在(大约)球的中心。这是实际上会移动以跟随球的位置。在这一点上,创建的GameObject不需要完美定位;它只需要足够接近,这样更容易对齐相机。

  6. 接下来,在层次结构窗口中找到主相机,并将其设置为CameraPivot的子对象。

  7. 主相机组件的X位置设置为0。只要X保持为零,且相机继续沿着z轴相对向前指向,你就可以自由移动它以找到一个观察球的好位置。Y位置为2Z位置为-2.5X旋转为35也效果不错。

  8. 接下来,将CameraFollow脚本添加到CameraPivot对象上。

  9. 最后,将场景中的MonkeyBall拖拽到新的CameraFollow脚本组件的Ball槽中。然后,去试试看!!与相机一起跟随

现在我们有一个滚动的球和一个跟随它的相机。相机只是更新其位置以跟上球的步伐,但它作为一个效果非常好。作为玩家,我们肯定会感觉到我们正在控制球及其运动。

添加猴子

现在我们离球很近并且跟随它移动,我们需要一些更有趣的东西来观察。在本节中,我们将在球上添加猴子。此外,为了确保他不会被疯狂地旋转,我们将制作一个新的脚本来保持他直立。按照以下步骤进行操作:

  1. 创建一个新的空GameObject,并将其重命名为MonkeyPivot

  2. 将其设置为MonkeyBall脚本的子对象,并将位置归零。

  3. 接下来,将猴子添加到场景中,并将其设置为MonkeyPivot GameObject 的子对象。

  4. 为了更容易看到球内的猴子,我们需要让猴子稍微透明一些。选择MonkeyBall并找到材质底部上的渲染模式(Rendering Mode)设置。将其更改为透明(Transparent),我们就能进行调整。

  5. 现在,点击反照率(Albedo)右侧的颜色选择器(Color Picker)框,并将A滑块,即 alpha 值,调整为128;这样我们就能透视球体内部了。

  6. 缩放并移动猴子,直到他填满球体的中心。

    提示

    你也可以借此机会为猴子摆个姿势。如果展开层级(Hierarchy)窗口中的猴子,你将能够看到构成他骨骼的所有骨头。现在给他一个酷炫的姿势,将使我们的玩家在游戏中的体验更好。

    添加猴子

  7. 目前我们的猴子和球体看起来很酷,但当我们实际播放时,猴子在球内晕头转向地旋转。我们需要打开MonkeyBall脚本,修复他的旋转动作:

  8. 首先,在脚本顶部我们需要两个新的变量。第一个将追踪我们刚才创建的空的GameObject。第二个将为我们提供更新猴子旋转的速度。我们希望看起来像是猴子在移动球体,所以他需要面向球体移动的方向。这里的速度是指他转向正确方向的速度:

    public Transform monkeyPivot;
    public float monkeyLookSpeed = 10f;
    
  9. 接下来,我们需要一个新的LateUpdate函数。这会再次检查monkeyPivot变量是否真的为脚本所填充。如果没有,我们就无法进行其他操作:

    public void LateUpdate() {
      if(monkeyPivot != null) {
    
  10. 我们首先需要弄清楚球体移动的方向。做到这一点最简单的方法是获取刚体(Rigidbody)组件的velocity,即我们的 body 变量。它是一个Vector3,表示我们当前移动的速度和方向。由于我们不希望猴子指向上下,所以我们为零y轴移动:

    Vector3 velocity = body.velocity;
    velocity.y = 0;
    
  11. 接下来,我们需要弄清楚猴子当前面向的方向。我们之前在使用坦克时已经使用过前进值。它只是我们在 3D 空间中面向的方向。同样,为了避免上下看,我们将y轴归零:

    Vector3 forward = monkeyPivot.forward;
    forward.y = 0;
    
  12. 为了避免移动时突然改变方向,并与帧率保持一致,我们必须计算一个step变量。这是基于我们的速度和自上一帧以来经过的时间,这一帧我们可以旋转多少:

    float step = monkeyLookSpeed * Time.deltaTime;
    
  13. 然后,我们需要通过使用Vector3.RotateTowards找到一个新面向的方向。它包括我们当前面向的方向,接着是我们想要面向的方向以及两个速度。第一个速度指定了这一帧中角度可以改变多少,第二个指定了向量的大小或长度可以改变多少。我们不关心向量大小的变化,所以给它赋予零值:

    Vector3 newFacing = Vector3.RotateTowards(forward, velocity, step, 0);
    
  14. 最后,通过将newFacing向量传递给Quaternion.LookRotation来计算新的旋转,并将结果应用到猴子旋转上。这将使猴子面向移动方向,防止它与球一起旋转:

    monkeyPivot.rotation = Quaternion.LookRotation(newFacing);
    }
    }
    
  15. 要使其工作,请将MonkeyPivot对象拖放到MonkeyBall脚本组件上的Monkey Pivot槽中。猴子将旋转以面向球的移动方向,同时保持直立:添加猴子

我们刚刚完成了将猴子添加到球中的工作。通过给猴子一个酷炫的姿势,玩家会更多地将其作为一个角色来参与。然而,当猴子在球内疯狂旋转时,看起来有点奇怪,因此我们更新了脚本,使他能保持直立并面向球的移动方向。现在,它几乎看起来像是猴子在控制球。

保持猴子在板上

如果游戏中没有失败的风险,那还有什么乐趣?为了测试我们的猴子和倾斜控制,我们在基本环境周围设置了一个安全围栏,防止它们翻倒。然而,每个游戏都需要一点风险来增加刺激感。通过移除安全围栏,我们引入了翻倒和游戏失败的风险。但是,通常如果你掉落了,会有重试游戏的选择。为此,我们现在将创建一个传统上称为kill volume的区域。这只是一个在玩家掉入时重置玩家的区域。让我们按照以下步骤来创建它:

  1. 首先,创建一个新脚本并将其命名为KillVolume

  2. 这个脚本有一个单一变量。它将跟踪猴子球掉入后放置的位置:

    public Transform respawnPoint;
    
  3. 这个脚本还有一个单一函数OnTriggerEnter。每当具有Rigidbody组件的对象进入触发器体积时,都会调用此函数。它接收进入的对象作为碰撞器:

    public void OnTriggerEnter(Collider other) {
    
  4. 该函数简单地将进入体积的物体的位置更改为我们想要重新生成它的点的位置。我们游戏中唯一会移动的是猴子球,所以我们不需要担心检查进入的是什么。我们还设置了velocityzero,这样当玩家重新获得控制时,它就不会突然移动:

    other.transform.position = respawnPoint.position;
    other.attachedRigidbody.velocity = Vector3.zero;
    }
    
  5. 接下来,我们需要一个名为RespawnPoint的新空GameObject

  6. 将此对象定位在我们球开始的大致位置。这是球在掉出场地后将被放置的点。

  7. 现在,创建另一个空的GameObject并将其命名为KillVolume。当玩家掉入该对象时,它将捕捉并重置游戏。

  8. 将其位置设置为Y 轴-10X 轴和 Z 轴0。这将使其位于玩家将要到达的位置下方。对于未来关卡来说,重要的是这个体积位于玩家通常所在位置的下方。如果不是这样,他们可能会错过它,永远下落,或者突然跳回到起点,在前往他们应该到达的区域时穿过它。

  9. 我们需要给对象一个盒子碰撞器组件,并附加我们的KillVolume脚本。

  10. 为了让 Unity 调用OnTriggerEnter函数,我们需要勾选是触发器的选项。否则,它将与体积碰撞,玩家看起来就像是漂浮着。

  11. 接下来,我们需要使体积足够大,以便在玩家掉入时能够捕捉到他们。为此,将盒子碰撞器组件的大小设置为X 轴和 Z 轴100

  12. 层次结构窗口中的RespawnPoint对象拖到检查器中的KillVolume脚本组件的重生点槽上。如果没有它,玩家在掉出地图后将无法返回。

  13. 最后,从我们的基础环境中删除Fence立方体,这样我们就可以测试一下了。你可以移动球体,当它从地面方块掉落时,会撞击KillVolume并返回到RespawnPoint位置。保持猴子在板上

现在我们能够在玩家掉出地图时重置他们。重要的是要检测他们何时不再在地图上,并且在应该重置时不要打断他们。这就是为什么我们做得这么大,并将其放在关卡主要区域的下方。但是,将体积放置得太远低于游戏区域是一个坏主意,否则玩家在游戏重置之前会下落很长时间。

赢得或失去游戏

既然我们已经具备了移动和如果掉出地图就重置的能力,我们只需要找到一种方法来赢得或输掉游戏。这种类型的游戏传统上是根据你从地图一端移动到另一端的速度来判定的。如果你在计时器耗尽之前未能到达终点,那么游戏就结束了。让我们按照以下步骤为游戏创建一个终点线和计时器:

  1. 我们需要一个新的名为VictoryVolume的脚本。

  2. 我们首先用一对变量来跟踪玩家的信息。如果玩家在限定时间内到达终点,第一个变量将被激活并展示给玩家。第二个变量只有在时间耗尽时才会显示:

    public GameObject victoryText;
    public GameObject outOfTimeText;
    
  3. 下一个变量将跟踪 GUI 中的Text对象,以显示完成关卡剩余的当前时间:

    public Text timer;
    
  4. 这个变量用于设置玩家完成关卡可用的时间,单位为秒。在为大型版本的游戏调整检查器面板时,最好让多人测试关卡,以便了解完成关卡需要多长时间:

    public float timeLimit = 60f;
    
  5. 脚本最后一个变量将简单地跟踪计时器是否能够倒计时。通过将其设置为private并默认为true,计时器将从关卡加载的那一刻开始计时:

    private bool countDown = true;
    
  6. 脚本第一个函数是Awake,这是初始化的最佳位置。它只做一件事,就是关闭两个消息。稍后我们会根据玩家的表现开启相应的消息:

    public void Awake() {
      victoryText.SetActive(false);
      outOfTimeText.SetActive(false);
    }
    
  7. 为了检测玩家是否越过终点线,我们将使用与KillVolume脚本相同的OnTriggerEnter函数。不过,首先我们会检查是否仍在为玩家计时。如果我们不再为他们计时,那么他们肯定已经用完了时间并且失败了。因此,我们不应该让他们越过终点线并获得胜利:

    public void OnTriggerEnter(Collider other) {
      if(countDown) {
    
  8. 接下来,我们开启告知玩家他们已经获胜的文本。我们总得让他们知道胜利了,现在就是合适的时候:

    victoryText.SetActive(true);
    
  9. 函数接下来要做的是本质上关闭猴子球的物理效果,防止它继续滚动。通过使用attachedRigidbody,我们访问到与物体连接的Rigidbody组件,这是连接到 Unity 物理引擎的部分。然后,我们将它的isKinematic属性设置为true,基本上告诉它将由脚本控制,而不是由物理引擎控制:

    other.attachedRigidbody.isKinematic = true;
    
  10. 最后,该函数停止计算玩家的剩余时间:

    countDown = false;
    }
    }
    
  11. 脚本的最后一个函数是Update函数,它首先检查以确保计时器正在运行:

    public void Update() {
      if(countDown) {
    
  12. 然后它从完成关卡剩余的时间中减去自上一帧以来的时间:

    timeLimit -= Time.deltaTime;
    
  13. 接下来,我们在屏幕上更新剩余的时间。屏幕上的文本必须是字符串形式,或者说是文字。像我们剩余的时间这样的数字并不是文字,所以我们使用ToString函数将其转换为正确的数据类型以便显示。如果仅此而已,那也是可以的,但它会显示一堆玩家不会关心的额外小数位。因此,我们传递0.00给函数。我们告诉它当数字变成文字时,我们希望它具有的格式和有多少个小数位。这使得它对玩家更有意义,也更容易阅读:

    timer.text = timeLimit.ToString("0.00");
    
  14. 在检查玩家是否超时后,我们开启告知他们已经失败的文本,并关闭时间显示。同时我们也停止计时。如果他们已经超时,继续计时又有什么意义呢?

    if(timeLimit <= 0) {
      outOfTimeText.SetActive(true);
      timer.gameObject.SetActive(false);
      countDown = false;
    }
    }
    }
    
  15. 现在,我们需要回到 Unity,让这个脚本工作。首先创建一个新的空GameObject,并将其命名为VictoryPoint

  16. 它将需要三个子立方体。记住,你可以通过导航到GameObject | 3D Object | Cube来找到它们。

  17. 第一个方块应定位在X1Y1Z0的位置。此外,将其缩放为X0.25Y2Z0.25

  18. 第二个方块应具有与第一个相同的所有设置,除了X的位置为-1,这会将它移动到对象的另一侧。

  19. 最后一个方块需要X0Y2.5Z0的位置。它的缩放比例需要设置为X2.25Y1Z0.25。这这三个方块共同构成了一个基本外观的终点线,它将突出于游戏板的其他部分。

  20. 接下来,我们需要为 GUI 创建一些文本对象。通过导航到GameObject | UI | Text来创建三个对象。

  21. 第一个应命名为Timer;这将处理显示,显示玩家到达终点线还剩多少时间。它需要锚定在左上角Pos X80Pos Y-20。它还需要宽度130高度30。我们可以将默认文本更改为0.00,以便我们更好地了解在游戏中它的样子。字体大小20对齐方式左中将为我们很好地定位它。

  22. 第二个文本对象应命名为Victory;当玩家到达终点线时,它将显示消息。它需要锚定在居中Pos XPos Y0。它需要宽度200高度60,这样我们就有足够的空间绘制消息。将默认文本更改为You Win!,将字体大小增加到50,并选择居中对齐,以便我们在屏幕中央获得一个清晰的大消息。

  23. 最后一个文本对象应命名为OutOfTime;当玩家在计时器归零前未能到达终点时,它将显示消息。除了宽度需要设置为500以适应其更大的默认文本You Ran Out Of Time!外,它与其他对象共享所有相同的设置。

  24. 接下来,我们需要选择VictoryPoint并为其添加BoxCollider组件,以及我们的VictoryVolume脚本。

  25. BoxCollider组件需要勾选Is Trigger复选框。中心X需要0Y需要1Z需要0。此外,大小X应为1.75Y应为2Z应为0.25

  26. 最后,将我们刚刚创建的每个文本对象拖动到VictoryVolume脚本组件上的适当槽位。游戏的胜利与失败

我们刚刚完成了一个设定,玩家可以通过这个设定赢得或输掉游戏。如果你现在尝试一下,你应该能在屏幕左上角看到计时器倒计时。当你及时到达终点线时,会显示一条好消息来提示你。如果你没能成功到达,则会显示另一条消息。

这是我们将为这款游戏创建的整个界面,但它仍然相当乏味。利用你在第二章中学到的技能,看起来不错——图形界面来设计界面。它应该看起来令人愉悦和兴奋,甚至可以是猴子主题的。为了使其更高级,你可以尝试设置它,让剩余时间接近零时改变颜色和大小,让玩家一眼就能看出完成该关卡剩余的时间。

终点线看起来也很单调,因为它只是由方块组成。尝试自己创建一个新的终点线。它可以在上面横幅上有一个终点线标志,就像比赛中的那样。也许它可以看起来更圆润一些。如果你想让它更高级,你可以考虑在终点线前面创建第二个计时器。这样玩家可以看着世界,他们的主要焦点在这里,并知道剩余的时间。

组装复杂的环境。

一个方块地图并不能提供很有趣的游戏体验。它非常适合我们设置控制,但玩家会觉得这并不有趣。因此,我们需要一些更好的东西。在这里,我们将设置一个更复杂的环境,包括斜坡、桥梁和弯道。我们还将使用一些围栏来帮助和引导玩家。让我们按照以下步骤进行:

  1. 首先,将MonkeyBallMap模型添加到场景中。

  2. 将其缩放属性在每个轴上设置为100,并将其位置属性在每个轴上设置为0

  3. 如果地图看起来是白色的,那么为其应用Grass纹理。这个地图为我们提供了一个良好的起点平台,一个半管斜坡,几个弯道,以及一座短桥。总的来说,玩家将面临许多基本挑战。

  4. 为了让我们的球能够实际使用这个地图,它需要一些碰撞器来使其具有物理特性。在层次结构窗口中展开MonkeyBallMap,并选择FlatBitsHalfPipe

  5. 在这些对象上添加一个网格碰撞器组件,就像我们为坦克城市的某些部分所做的那样。记住,可以通过导航到组件 | 物理 | 网格碰撞器来找到它。

  6. 接下来,我们有Fence模型。通过这个模型,我们可以在边缘放置护栏或者在玩家路径中设置障碍来帮助或阻碍玩家。首先将Fence模型拖入场景,并将其缩放设置为100,以保持与地图的比例。

  7. 为了使围栏能够物理地阻挡玩家,它们需要一个碰撞器。对于两个子围栏对象,添加一个 BoxCollider 组件,可以通过导航到 组件 | 物理 | 盒碰撞器 来找到。

  8. 此外,如果围栏在场景中显示为白色,请确保你将 Wood 文理应用到两个围栏部件上。

  9. 创建一个新的空 GameObject 并将其命名为 Fences。然后,将其 位置 属性在每一个轴上设置为 0。这个对象将帮助我们保持组织有序,因为最终我们可能会有很多围栏部件。

  10. 现在,在 层次结构 窗口中展开 Fence 模型,并使 PostPostWithSpokes 成为 Fences 空的 GameObject 的子对象。然后,删除 Fence 对象。这样做,我们打破了预制体的连接,消除了重新创建它的风险。如果我们只是用 Fence 对象来组织,那么如果我们对原始模型文件进行更改,就有可能删除我们在场景中设置它们时所做的一切工作。

  11. 我们需要将围栏放置在战略位置,以影响玩家玩游戏的方式。我们可能想要放置它们的第一个地方是起始区域周围,为玩家提供一个游戏开始的良好安全环境。记住,你可以使用 Ctrl + D 来复制围栏部件,这样你就总会有足够的围栏。组装复杂环境

  12. 放置围栏的第二个地方是在半管之后,正好在桥前。在这里,它们可以帮助玩家在尝试过小桥之前重新定位自己:组装复杂环境

  13. 我们可以放置围栏的最后一个地方可能会阻碍玩家。如果我们把它们放在最后平台的中间,我们就会迫使玩家绕行,并在到达终点前冒着跌落的危险。

  14. 说到终点线,现在其他一切都已布置完毕,我们需要将其移至适当位置。将其放置在较低平台的末端。在这里,玩家必须面对地图上的所有挑战,并在最终达成胜利前多次冒着跌落的危险。组装复杂环境

这就是设置我们复杂环境的全部内容。我们让玩家有机会在强制他们导航一系列挑战并到达终点之前先定位自己。试一试吧。我们的游戏看起来真的很不错。

这里的第一个挑战可能相当明显。尝试自己制作一个带有坡道、桥梁、滑梯和障碍的地图。你可能可以用围栏制作一个大型迷宫。否则,你可以改变关卡,使其实际上要求玩家沿着一些直线路径和坡道向上,这意味着玩家需要足够速度来完成。可能还需要进行几次跳跃。让玩家沿着坡道下滑以获得速度,然后跳到另一个平台上。无论你的新关卡变成什么样,确保KillVolume在它的下方,并且覆盖足够大的区域。你永远不知道玩家会如何玩,以及他们会如何卡住自己。

地图本身看起来很不错,但周围的区域还需要加工。利用你之前章节学到的技能——为世界添加一个天空盒,比默认的看起来更好。同时,调整一下光线。一个单一的定向光不错,但不够有趣。创建一些光源模型放置在地图周围。然后,烘焙光照贴图以产生一些高质量的阴影。

添加香蕉

当涉及到猴子游戏时,玩家最明显要收集的物品就是香蕉。然而,仅仅在世界上拥有可收集的物品是不够的;我们还得向玩家展示这些物品是可以被收集的。通常,这意味着物品在旋转、弹跳、发光、产生火花或展示其他特殊效果。对于我们的游戏,我们将使香蕉在原地旋转的同时上下弹跳。下面是完成这个效果的步骤:

  1. 首先,我们需要一个新的脚本。创建一个并命名为BananaBounce

  2. 这个脚本从三个变量开始。第一个是香蕉上下移动的速度,单位是每秒米。第二个是香蕉从起始位置会移动多高。第三个是香蕉每秒在原地旋转多少度。这些变量共同使我们能够轻松控制和调整香蕉的运动:

    public float bobSpeed = 1.5f;
    public float bobHeight = 0.75f;
    public float spinSpeed = 180f;
    
  3. 下一个变量将跟踪实际移动的对象。通过使用两个对象来设置和控制香蕉,我们能够将位置和旋转分离,使一切变得更容易:

    public Transform bobber;
    
  4. 这个脚本的函数是Update。它首先检查以确保我们的bobber变量已被填充。如果没有它,我们就无法进行操作使香蕉移动:

    public void Update() {
      if(bobber != null) {
    
  5. 接下来,我们使用PingPong函数为我们的香蕉计算一个新位置。这个函数会在零和传递给它的第二个值之间反弹一个值。在这个案例中,我们使用当前时间乘以我们的速度来确定在这场游戏中香蕉可能移动了多远。通过给它一个高度,我们得到一个从零到我们最大高度来回移动的值。然后我们将其乘以一个向上向量,并将其应用到我们的localPosition上,使香蕉能够上下移动:

      float newPos = Mathf.PingPong(Time.time * bobSpeed, bobHeight);
      bobber.localPosition = Vector3.up * newPos;
    }
    
  6. 最后,我们使用之前用于旋转炮塔的同一个Rotate函数,让香蕉在原地旋转。它会以我们设定的任何速度不断旋转。

    transform.Rotate(Vector3.up * Time.deltaTime * spinSpeed);
    }
    
  7. 接下来,我们需要回到 Unity 并设置这些香蕉。为此,我们首先需要在场景中添加Banana模型。如果它是白色的,确保为其添加Banana纹理。

  8. 要让新香蕉弹跳,需要添加我们的BananaBounce脚本,否则它就不会在那里弹跳。

  9. Banana的子对象需要放在我们脚本组件中的Bobber槽位上。

  10. 然后,把它变成一个预制体,在地图上散布一些:在初始区域放几个,在终点线附近放几个,沿途也放一些。添加香蕉

如果你现在尝试游戏,你应该会看到有几个快乐弹跳的香蕉。通过使用Mathf.PingPong函数,我们很容易就能创建这种效果。如果没有它,我们需要做很多额外的计算来确定我们是向上还是向下移动以及移动了多远。

收集香蕉作为收藏品很棒,但现在哪个游戏只有一种拾取物品呢?尝试制作一些其他拾取物品的模型。最明显的就是香蕉束,比如你在杂货店可以买到的那些,或者是实际长在香蕉树上的大串香蕉。不过,你还可以选择硬币、能量水晶、古老猴子图腾、检查点、分数乘数,或者任何可能吸引你注意的东西。

使用触摸收集香蕉

现代移动设备最明显的特点之一就是触摸屏。设备使用用户的指尖电导性和许多微小的接触点来确定被触摸的位置。为了探索我们的游戏触摸界面的可能性,我们将让玩家戳屏幕上的香蕉,而不是跑过去收集它们。Unity 为我们提供了轻松访问触摸输入的方法。通过将输入与射线投射结合,就像我们之前让坦克开火一样,我们可以确定 3D 空间中被用户触摸的物体。对于我们来说,这意味着我们可以让玩家触摸并收集那些香蕉。要做到这一点,请按照以下步骤操作:

  1. 首先,我们需要一个新脚本。创建一个,并将其命名为BananaTouch

  2. Update函数是这段脚本中唯一的函数。它首先检查玩家是否以任何方式触摸屏幕。Input类为我们提供了touchCount值,这只是一个计数器,用来记录当前触摸设备屏幕的手指数量。如果没有手指触摸,我们不想浪费时间做任何工作,所以我们会提前退出return:并准备好再次检查下一帧,看玩家是否触摸了屏幕。

    public void Update() {
      if(Input.touchCount <= 0) return;
    
  3. 接下来,我们创建一个foreach循环。这是一个将检查触摸列表中的每个项目的循环,但它不会跟踪触摸的索引。然后我们检查每个触摸的阶段,以判断它是否刚刚开始触摸屏幕。每个触摸都有五个可能的状态:开始移动静止结束已取消

    foreach(Touch next in Input.touches) {
      if(next.phase == TouchPhase.Began) {
    

    这里是每个状态的描述:

    • 开始:当用户首次触摸屏幕时,会进入此触摸阶段。

    • 移动:当用户在屏幕上移动手指时,会进入此触摸阶段。

    • 静止:此触摸阶段与上一个阶段相反;当用户的 finger 在屏幕上不移动时发生。

    • 结束:当用户的手指离开屏幕时,会进入此触摸阶段。这是触摸完成的正常方式。

    • 已取消:当跟踪触摸时发生错误时,会进入此触摸阶段。这种阶段通常在手指触摸屏幕但不移动一段时间后最常发生。触摸系统并不完美,所以它会假设错过了手指离开屏幕的动作,并取消该触摸。

  4. 接下来,我们创建一对变量。就像我们的坦克一样,第一个变量用于保存被我们的射线投射命中的对象。第二个是一个Ray类型的变量,它只是一个用于存储空间中的一个点和方向向量的容器。ScreenPointToRay函数是相机专门提供的,用于将屏幕上 2D 空间的触摸位置转换为游戏世界中 3D 空间的位置:

    RaycastHit hit;
    Ray touchRay = Camera.main.ScreenPointToRay(next.position);
    
  5. 函数的最后一步是调用Raycast函数。我们将射线和跟踪变量传递给该函数。如果击中了对象,我们向它发送一个消息,告诉它已被触摸,就像用我们的坦克射击东西一样。此外,还需要几个花括号来结束if语句、循环和函数:

          if(Physics.Raycast(touchRay, out hit)) {
            hit.transform.gameObject.SendMessage("Touched", SendMessageOptions.DontRequireReceiver);
          }
        }
      }
    }
    
  6. 在尝试之前,我们需要更新我们的BananaBounce脚本,为其添加一些生命值,并在生命值耗尽时允许其被摧毁。所以,现在就打开它吧。

  7. 首先,我们需要一对变量。第一个是health。实际上,这只是摧毁香蕉所需的触摸次数。如果我们有不同类型的香蕉,它们可以各有不同的生命值。第二个变量是香蕉移动速度的调节器。每次香蕉失去生命值,它的移动速度就会减慢,表明它还剩下多少生命值:

    public int health = 3;
    public float divider = 2f;
    
  8. 接下来,我们需要添加一个新函数。这个Touched函数将接收来自我们的BananaTouch脚本的消息。它的工作原理类似于我们用坦克射击的方式。它做的第一件事是减少剩余的生命值:

    public void Touched() {
      health--;
    
  9. 在造成一些伤害之后,我们可以通过进行一些除法运算来减慢香蕉的移动速度。这样玩家就能轻松判断他们的触摸是否成功:

      bobSpeed /= divider;
      spinSpeed /= divider;
    
  10. 最后,函数会检查香蕉是否已经耗尽生命值。如果是,我们使用Destroy函数来移除它,就像敌方坦克一样:

      if(health <= 0) {
        Destroy(gameObject);}
    }
    
  11. 当你回到 Unity 时,需要将我们新的BananaTouch脚本附加到MonkeyBall对象上。由于它的工作原理,它实际上可以放在任何对象上,但最好是将玩家控制脚本保持在一起,并放在它们所控制的对象上。

  12. 接下来,为其中一个香蕉添加一个球体碰撞器组件,你可以通过导航到组件 | 物理 | 球体碰撞器来找到它。如果我们对一个进行更改并更新预制体,场景中的所有香蕉都将被更新。

  13. 勾选是触发器复选框,这样香蕉就不会阻挡我们猴子的移动。它们仍然可以被触摸,同时允许我们的猴子穿过它们。

  14. 碰撞器还需要被放置在玩家在击中时通常会触摸的位置。因此,将中心设置为X0Y0.375Z0。此外,确保将半径设置为0.5

  15. 最后,确保点击应用按钮,位于检查器面板右上角,以更新场景中的所有香蕉。使用触摸收集香蕉

现在尝试游戏,你应该能够触摸任何香蕉。最初,所有香蕉会像之前一样均匀地上下移动。当你触摸它们时,由于我们做的除法运算,你触摸的香蕉会移动得慢一些,然后最终消失。这让我们的玩家能够轻松地看出哪些香蕉被触摸过,哪些没有。

在游戏中拥有可收集物体之后,下一步是给玩家赋予意义。这通常是通过给它们一些积分值来实现的。在这里尝试这样做。它与我们之前摧毁敌方坦克时的积分系统非常相似。如果你之前创建了一些其他的收集物,你可以设置它们每个拥有不同的生命值。因此,它们也可以给你不同的积分。调整数字和设置,直到找到玩家互动起来会感到有趣的东西。

总结

在本章中,我们了解了现代移动设备的特点。我们创建了一个猴子球游戏来尝试这个功能。我们访问了设备的陀螺仪来检测它何时被旋转。这让我们的猴子能够被引导。在为玩家创建了一个更复杂、更有趣的运动环境后,我们创建了一串会原地旋转同时上下浮动的香蕉。我们还利用触摸屏让玩家能够收集香蕉。

在下一章中,我们将暂时放下我们的猴子球游戏。市场上最受欢迎的移动游戏之一,愤怒的小鸟,是一种独特且并不罕见的游戏类型。为了了解 Unity 中的物理学以及 2D 风格游戏的可能性,我们将制作一个愤怒的小鸟克隆版。我们还将探讨视差滚动,以帮助我们创建一个令人愉悦的背景。在你意识到之前,我们将创造出所有你一直希望玩到的愤怒的小鸟关卡。

第七章:重量级投掷——物理与 2D 摄像机

在上一章中,你了解了移动设备的特殊功能以及如何创建触摸和倾斜控制。我们还制作了一个 Monkey Ball 游戏来使用这些新控制。通过倾斜设备来控制球的方向,并通过触摸屏幕收集香蕉。我们还通过创建计时器和终点线,为游戏添加了一些胜利和失败的条件。

在本章中,我们将暂时放下 Monkey Ball 游戏,去探索 Unity 的物理引擎。我们还会看看创建 2D 游戏体验时可用的选项。为此,我们将重新制作市场上最受欢迎的移动游戏之一,愤怒的小鸟。我们将使用物理引擎来投掷小鸟并摧毁结构。我们还将看看如何创建一个关卡选择屏幕。

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

  • Unity 物理引擎

  • 视差滚动

  • 2D 管线

  • 关卡选择

我们将为本章创建一个新项目,启动 Unity,让我们开始吧!

在 3D 世界中制作 2D 游戏

在游戏开发中,最鲜为人知的事实之一是可以在 3D 游戏引擎中,例如 Unity,制作 2D 风格的游戏。与其它所有事物一样,它也有自己的一套优势和劣势,但为了生成令人满意的游戏体验,这个选择可能是非常值得的。最显著的优势是可以为游戏使用 3D 资源。这使得动态光照和阴影可以轻松地包含在内。然而,在使用 2D 引擎时,任何阴影都需要直接绘制到资源中,而且很难使其具有动态效果。关于劣势,是在 3D 世界中使用 2D 资源。虽然可以使用它们,但为了达到所需的细节并防止其出现像素化,需要较大的文件大小。然而,大多数 2D 引擎都使用矢量艺术,这样无论图像如何缩放,其线条都能保持平滑。此外,可以为 3D 资源使用常规动画,但任何 2D 资源通常都需要逐帧动画。总的来说,对于许多开发者而言,优势已经超过了劣势,他们创造了大量外观精美的 2D 游戏,你可能永远也不会意识到这些游戏实际上是在 3D 游戏引擎中制作的。

为了满足开发者对 2D 游戏支持的不断增长的需求,Unity 团队一直在努力为 3D 引擎创建优化的 2D 流水线。创建项目时,你可以选择 2D 默认设置,优化资产以便在 2D 游戏中使用。尽管 Unity 仍然没有直接支持矢量图形,但许多其他功能已经优化,以便在 2D 世界中更好地工作。最大的功能之一是物理引擎的 2D 优化,我们将在本章重点讨论这个问题。我们将使用所有原则,这些原则同样适用于 3D 物理中,这将节省我们在设置和工作中的麻烦。

设置开发环境

为了探索在主要以 3D 引擎中制作 2D 游戏,以及物理的使用,我们将重新制作一个广受欢迎的 2D 游戏,愤怒的小鸟。然而,在我们深入游戏的核心之前,我们需要设置开发环境,以便我们为 2D 游戏创作进行优化。让我们使用以下步骤来完成此操作:

  1. 首先,我们需要在 Unity 中创建一个新项目,将其命名为 Ch7_AngryBirds 非常合适。同时,我们还需要在 模板 下选择 2D,这样所有默认设置都会为我们的 2D 游戏做好准备。

  2. 我们还需要确保在 构建设置 字段中将目标平台更改为 Android,并将 捆绑标识符 设置为适当的值。我们不希望以后还要为此担心。

  3. 你会立即注意到一些不同之处。首先,在场景中移动时,你只能从左右和上下平移。这是一个可以在 场景 视图的顶部中间通过点击小 2D 按钮来切换的设置。此外,如果你在 层次结构 窗口中选择了相机,你可以看到它在 场景 视图中仅显示为一个白色盒子。这是因为它的 投影 设置默认为使用 正交 模式,你可以在 检查器 面板中看到这一点。

    注意

    每个相机都有两个关于如何渲染游戏的选项。透视相机利用物体与相机的距离来渲染一切,模仿现实世界;距离相机较远的物体绘制得比距离较近的物体小。正交相机在渲染时不考虑这一点;物体的绘制不会基于它们与相机的距离而缩放。

  4. 接下来,我们需要一个地面。因此,请转到 Unity 的菜单栏,导航到 GameObject | 3D Object | Cube。这将作为一个简单的地面非常合适。

  5. 为了让它看起来有点像地面,创建一个绿色材质,并将其应用到 Cube GameObject 上。

  6. 地面立方体需要足够大以覆盖我们的整个游戏区域。为此,将立方体的缩放属性设置为X轴上的100Y轴上的10,以及Z轴上的5。同时,将它的位置属性设置为X轴上的30Y轴上的-5,以及Z轴上的0。由于沿x轴不会有任何移动,地面只需要足够大以供我们场景中的其他物体着陆即可。但是,它需要足够宽和高,以防止摄像机看到边缘。

  7. 为了优化我们 2D 游戏中地面的立方体,我们需要更改其碰撞器。在层次结构窗口中选择Cube GameObject,并在检查器面板中查看它。右键点击Box Collider组件,选择移除组件。接下来,在 Unity 顶部导航至组件 | 物理 2D | Box Collider 2D。这个组件的工作原理与普通的Box Collider组件一样,只是它没有深度限制。

  8. 目前,由于缺乏光线,地面看起来相当暗。在 Unity 的菜单栏中,导航至GameObject | Light | Directional Light,以向场景中添加一些亮度。

  9. 接下来,我们需要确保所有将在场景中飞行的物体不会移动得太远,从而引发问题。为此,我们需要创建一些触发器体积。最简单的方法是创建三个空的GameObjects,并为每个分配一个Box Collider 2D组件。确保勾选Is Trigger复选框,将它们转换为触发器体积。

  10. 将一个放在地面物体的每个端部,最后一个 GameObject 大约在 50 个单位的高度上。然后,将它们缩放以与地面形成一个盒子。每个的厚度都不应超过一个单位。

  11. 为了让体积实际上能够阻止物体移动得太远,我们需要创建一个新的脚本。创建一个新的脚本,并将其命名为GoneTooFar

  12. 这个脚本有一个单独的短函数,OnTriggerEnter2D。我们使用这个函数来销毁可能进入该体积的任何物体。这个函数被 Unity 的物理系统用来检测物体何时进入触发器体积。我们稍后会详细介绍这一点,但现在,需要知道两个对象中的一个,要么是体积本身,要么是进入它的物体,需要有一个Rigidbody组件。在我们的例子中,所有我们可能希望它们进入触发器时移除的东西都将具有Rigidbody组件:

    public void OnTriggerEnter2D(Collider2D other) {
      Destroy(other.gameObject);
    }
    
  13. 最后,回到 Unity 并将脚本添加到三个触发器体积对象上。设置开发环境

我们已经为我们的 2D 游戏完成了初步设置。通过将项目类型从3D更改为2D,Unity 的默认设置会改变以优化 2D 游戏创作。最立即注意到的是,现在摄像机处于正交视图,使一切看起来都变得扁平。我们还为我们的场景创建了一个地面和一些触发器体积。这些将共同防止我们的鸟类和任何其他物体走得太远。

物理

在 Unity 中,物理模拟主要关注Rigidbody组件的使用。当Rigidbody组件附加到任何对象上时,它将被物理引擎接管。该对象将受到重力影响下落,并撞击任何带有碰撞器的物体。在我们的脚本中,使用OnCollision函数组和OnTrigger函数组至少需要将Rigidbody组件附加到两个交互对象中的一个。然而,Rigidbody组件可能会干扰我们可能让对象进行的任何特定移动。但是,Rigidbody组件可以被标记为运动学,这意味着物理引擎不会移动它,只有当我们的脚本移动它时,它才会移动。我们用于坦克的CharacterController组件是一个特殊的、修改过的Rigidbody。在本章中,我们将大量使用Rigidbody组件,将我们的鸟类、块和猪与物理引擎连接起来。

构建块

我们将创建的第一个物理对象是猪城堡所使用的块。我们会创建三种类型的块:木头、玻璃和橡胶。通过这些简单的块,我们可以轻松构建多种关卡和结构,供鸟类撞击破坏。

我们将创建的每个块在很大程度上都相似。因此,我们将从基本的木制木板开始,然后在此基础上创建其他类型的块。让我们按照以下步骤来创建这些块:

  1. 首先,我们将创建一个木制木板。为此,我们需要另一个立方体。将其重命名为Plank_Wood

  2. 将木板的缩放值设置为X 轴0.25Y 轴和 Z 轴2。它在xy轴上的缩放定义了玩家看到的大小。在z轴上的缩放有助于确保它会被场景中的其他物理对象击中。

  3. 接下来,使用plank_wood纹理创建一个新材质,并将其应用到立方体上。

  4. 为了将这个新的木制木板转变为适合我们游戏的物理对象,我们需要移除立方体的Box Collider组件,并替换为Box Collider 2D组件。同时,添加一个Rigidbody组件。确保你的木板被选中;在 Unity 的菜单栏中,导航到组件 | 物理 2D | Rigidbody 2D

  5. 接下来,我们需要让木板在我们的游戏中正常工作;我们需要创建一个新脚本,并将其命名为Plank

  6. 这个脚本以一堆变量开始。前两个变量用于跟踪木板的血量。我们需要将总血量与当前血量分开,这样当对象被削弱到一半血量时,我们就能检测到。在这一点上,我们将使用接下来的三个变量来更改对象材质以显示损坏。最后一个变量用于对象耗尽血量并被销毁时。我们将使用它来增加玩家的得分:

    public float totalHealth = 100f;
    private float health = 100f;
    
    public Material damageMaterial;
    public Renderer plankRenderer;
    private bool didSwap = false;
    
    public int scoreValue = 100;
    
  7. 对于脚本的第一个功能,我们使用Awake进行初始化。确保对象当前的血量与其总血量相同,并将didSwap标志设置为false

    public void Awake() {
      health = totalHealth;
      didSwap = false;
    }
    
  8. 接下来,我们使用OnCollisionEnter2D函数,这是通常在 3D 中使用的OnCollisionEnter函数的 2D 优化版本。这是一个特殊函数,由Rigidbody组件触发,为我们提供了关于对象与何物碰撞以及如何碰撞的信息。我们使用这些信息来查找collision.relativeVelocity.magnitude。这是物体碰撞的速度,我们将其用作伤害以减少当前血量。接下来,函数检查血量是否已经减少到一半,如果是,则调用SwapToDamaged函数。通过使用didSwap标志,我们确保该函数只被调用一次。最后,函数检查血量是否降至零以下。如果是,对象将被销毁,我们调用LevelTracker脚本(我们很快就会制作)以增加玩家的得分:

    public void OnCollisionEnter2D(Collision2D collision) {
      health -= collision.relativeVelocity.magnitude;
    
      if(!didSwap && health < totalHealth / 2f) {
        SwapToDamaged();
      }
    
      if(health <= 0) {
        Destroy(gameObject);
        LevelTracker.AddScore(scoreValue);
      }
    }
    
  9. 最后,对于脚本,我们有SwapToDamaged函数。它首先将didSwap标志设置为true。接下来,它检查以确保plankRendererdamageMaterial变量有对其他对象的引用。最终,它使用plankRenderer.sharedMaterial值将材质更改为看起来损坏的材质:

    public void SwapToDamaged() {
      didSwap = true;
      if(plankRenderer == null) return;
    
      if(damageMaterial != null) {
        plankRenderer.sharedMaterial = damageMaterial;
      }
    }
    
  10. 在将我们的Plank脚本添加到对象之前,我们需要创建之前提到的LevelTracker脚本。现在创建它。

  11. 这个脚本相当简短,从单个变量开始。该变量将跟踪玩家在当前关卡的得分,是静态的,因此当对象被销毁时可以轻松更改得分:

    private static int score = 0;
    
  12. 接下来,我们使用Awake函数以确保玩家在开始关卡时从零开始:

    public void Awake() {
      score = 0;
    }
    
  13. 最后,对于脚本,我们添加了AddScore函数。这个函数简单地接收传递给它的分数并增加玩家的得分。它也是静态的,所以它可以在场景中的任何对象上被调用,而无需引用脚本:

    public static void AddScore(int amount) {
      score += amount;
    }
    
  14. 回到 Unity,我们需要使用plank_wood_damaged纹理创建一个新材质。这将是脚本将切换到的材质。

  15. 我们需要将Plank脚本添加到我们的Plank_Wood对象中。将Damaged Material引用连接到新材质,将Plank Renderer引用连接到对象的Mesh Renderer组件。

  16. 当我们创建不同类型的板子时,可以调整总健康值来赋予它们不同的强度。木板的这个值设为25效果相当不错。

  17. 接下来,创建一个空的GameObject,并将其重命名为LevelTracker

  18. LevelTracker脚本添加到对象上,它将开始跟踪玩家的分数。

  19. 如果你想看到木板的实际效果,将其定位在地面上方,然后点击播放按钮。游戏一开始,Unity 的物理引擎就会接管,并让板子受重力落下。如果它一开始足够高,你将能够看到它在失去生命值时切换纹理。

  20. 为了制作我们需要的另外两种板子,选择Plank_Wood对象,并按Ctrl + D两次进行复制。将其中一个板子重命名为Plank_Glass,另一个重命名为Plank_Rubber

  21. 接下来,创建三种新材料。一种是用于橡胶板,颜色应为紫色;另一种应使用plank_glass纹理,用于玻璃板;最后一种材料在玻璃板损坏时应使用plank_glass_damaged纹理。将新材料应用到新板子的适当位置。

  22. 至于新板子的生命值,玻璃的值设为15,橡胶的值设为100效果会很好。

  23. 最后,将这三种板子转换为预制体,并使用它们构建一个待击倒的结构。可以自由缩放它们以制作不同大小的块,但不要改变z轴。此外,所有块都应该在z轴上定位为0,而你的结构应该在大约x轴上的30为中心点。构建块

我们已经为游戏中将要被击倒的结构创建了所需的构建块。我们使用了Rigidbody组件将它们与物理引擎连接起来。同时,我们还创建了一个跟踪它们生命值的脚本,并在生命值降至一半以下时切换到损坏的材料。对于这个游戏,我们坚持使用所有物理组件的 2D 优化版本。它们的工作方式与 3D 版本完全相同,只是没有第三个坐标轴。

木材和玻璃作为基本块非常适用。然而,如果我们打算制作更难的关卡,我们需要一些更坚固的材料。尝试制作一个石块。为其创建两种纹理和材料,以展示其原始和损坏的状态。

物理材料

物理材料是一种特殊的材料,它专门告诉物理引擎两个物体应该如何交互。这不会影响物体的外观。它定义了碰撞体的摩擦力和弹性。我们将使用它们让橡胶板具有弹性,让玻璃板具有滑动性。通过这几个步骤,我们可以快速实现物理材料,创建出令人满意的效果:

  1. 物理材料与其它所有内容的创建方式相同,即在项目面板中创建。在项目面板内右键点击,导航至创建 | 2D 物理材料。创建两个物理材料,将其中一个命名为Glass,另一个命名为Rubber

  2. 选择其中一个,并在检查器窗口中查看它。2D 版本只有两个值(3D 版本有一些额外的值,但它们只在更复杂的情况下使用):

    • 摩擦力:这个属性控制沿着表面滑动时失去的运动量。值为零表示没有摩擦力,比如冰;值为一时表示摩擦力很大,比如橡胶。

    • 弹性:这个属性指的是物体在撞击其他物体或被撞击时,有多少能量会被反射回来。零表示没有能量被反射,而值为一时,物体将反射所有能量。

  3. 对于Glass材料,将摩擦力值设为0.1弹性设为0。对于Rubber材料,将摩擦力设为1弹性设为0.8

  4. 接下来,选择你的Plank_Glass预制体,并查看其Box Collider 2D组件。要应用你的新物理材料,只需从项目面板逐个拖放它们到材质槽中。对你的Plank_Rubber预制体做同样的操作,任何时候有物体撞击它们之一,这些材料都将用来控制它们的交互。

我们创建了一对物理材料。它们控制两个碰撞体在相互碰撞时的交互方式。使用这些材料,我们可以控制任何碰撞体拥有的摩擦力和弹性。

角色

拥有一堆通用块只是这个游戏的开始。接下来,我们将创建一些角色来为游戏增添活力。我们需要一些邪恶的猪来摧毁,还需要一些好的鸟来投向它们。

创建敌人

我们的首个角色将是敌人猪。它们本身实际上什么都不做。所以,它们实际上只是我们之前制作的看起来像猪的木块。然而,为了使它们的毁灭成为游戏的目标,我们将扩展我们的LevelTracker脚本来监视它们,如果它们全部被摧毁,则触发游戏结束事件。我们还将扩展脚本以在屏幕上更新分数,并将其保存以供以后使用。与只能看到一面的立方体木板不同,猪是作为平面纹理创建的,并由 Unity 的 2D 管线作为精灵使用。下面是创建我们愤怒的小鸟游戏中的猪的步骤:

  1. 猪的创建方式与木板的创建方式相似;但是,它们使用了一个特殊的 2D 对象,称为精灵。精灵实际上只是一个始终面向屏幕的平面物体。大多数 2D 游戏都是用一系列的精灵来制作所有对象。你可以通过导航至游戏对象 | 2D 对象 | 精灵来创建一个。将其命名为Pig

  2. 为了让新的精灵看起来像只猪,从项目面板中拖动pig_fresh图像,并将其拖放到Sprite Renderer组件的Sprite槽中。

  3. 接下来,添加一个Circle Collider 2D组件和一个Rigidbody 2D组件。Circle Collider 2D组件与我们之前使用的Sphere Collider组件类似,但它是为在 2D 游戏中工作而优化的。

  4. 在我们能够在游戏中使用猪之前,我们需要更新Plank脚本,使其能够处理精灵图像以及材质的变化。因此,我们打开它并在开始处添加一个变量。这个变量简单地跟踪要切换到哪个精灵:

    public Sprite damageSprite;
    
  5. 然后,我们需要在SwapToDamaged函数的末尾添加一小部分代码。这个if语句检查是否有可切换的精灵。如果有,我们将通用的渲染器变量转换为SpriteRenderer,这样我们就可以访问它上面的sprite变量,并更新为新图像:

    if(damageSprite != null) {
      SpriteRenderer spriteRend = plankRenderer as SpriteRenderer;
      spriteRend.sprite = damageSprite;
    }
    
  6. Plank脚本添加到猪身上,并用Sprite Renderer组件填充Plank Renderer槽。同时,将pig_damage图像放入Damage Sprite槽中。通过稍微修改这个脚本,我们可以在之后节省很多麻烦,比如当我们可能想要追踪不仅仅是猪的摧毁情况时。

  7. 现在,将猪转换成预制体并添加到你的结构中。记住,你需要将它们在z轴上的位置设为零,但你可以随意调整它们的大小、健康值和分数值,以增加一些多样性。

  8. 接下来,我们需要扩展LevelTracker脚本。打开它,我们可以添加一些更多的代码。

  9. 首先,我们需要在脚本的最开始添加一行,这样我们就可以编辑在 GUI 中显示的文本。就像我们之前做的那样,在脚本的最顶部添加这一行,那里还有以using开头的另外两行:

    using UnityEngine.UI;
    
  10. 我们将在脚本的开始处添加一些变量。第一个变量,顾名思义,将保存我们场景中所有的猪的列表。下一个是一个标志,用来表示游戏已经结束。我们还有三个Text变量,以便在玩家玩游戏时更新他们的分数,告诉他们游戏结束的原因以及他们的最终得分。最后一个变量将允许你打开或关闭最后的屏幕,告诉玩家他们是否赢了:

    public Transform[] pigs = new Transform[0];
    
    private gameOver = false;
    
    public Text scoreBox;
    public Text finalMessage;
    public Text finalScore;
    
    public GameObject finalGroup;
    
  11. 接下来,我们需要在Awake函数中添加一行。这确保了在游戏开始时,告诉玩家游戏如何结束的 GUI 对象组是关闭的:

    FinalGroup.SetActive(false);
    
  12. LateUpdate函数中,我们首先检查游戏是否已经结束。如果没有,我们调用另一个函数来检查是否所有的猪都被摧毁了。同时,我们更新玩家的分数显示,无论是在游戏进行中还是游戏结束屏幕上:

    public void LateUpdate() {
      if(!gameOver) {
        CheckPigs();
    
        scoreBox.text = "Score: " + score;
        finalScore.text = "Score: " + score;
      }
    }
    
  13. 接下来,我们添加 CheckPigs 函数。这个函数遍历猪的列表,查看它们是否都被摧毁。如果它发现有一个没有被摧毁,就会退出函数。否则,游戏被标记为结束,并给玩家一条信息。我们还会关闭游戏内得分并开启游戏结束的一组 GUI 对象:

    private void CheckPigs() {
      for(int i=0;i<pigs.Length;i++) {
        if(pigs[i] != null) return;
      }
    
      gameOver = true;
      finalMessage.text = "You destroyed the pigs!";
    
      scoreBox.gameObject.SetActive(false);
      finalGroup.SetActive(true);
    }
    
  14. OutOfBirds 函数将由我们稍后要创建的弹弓调用,当玩家没有鸟可以发射到猪身上时。如果游戏尚未结束,该函数将结束游戏并为玩家设置适当的信息。它还会关闭游戏内得分,并开启游戏结束的一组 GUI 对象,就像前一个函数一样:

    public void OutOfBirds() {
      if(gameOver) return;
    
      gameOver = true;
      finalMessage.text = "You ran out of birds!";
    
      scoreBox.gameObject.SetActive(false);
      finalGroup.SetActive(true);
    }
    
  15. 最后,我们有 SaveScore 函数。这里我们使用 PlayerPrefs 类。它让你可以轻松地存储和检索少量数据,非常适合我们当前的需求。我们只需要提供一个唯一的键来保存数据。为此,我们使用一个简短字符串与 Application.loadedLevel 提供的关卡索引组合。接下来,我们使用 PlayerPrefs.GetInt 来检索上次保存的分数。如果没有,则返回我们传递给函数的零作为默认值。我们将新分数与旧分数进行比较,并使用 PlayerPrefs.SetInt 来保存更高的新分数。最后,Application.LoadLevel 函数可以用来加载我们游戏中的任何其他场景。所有你打算加载的场景都必须添加到文件菜单中的构建设置窗口中,并且可以通过使用它们的名称或索引来加载,如下所示:

    public void SaveScore() {
      string key = "LevelScore" + Application.loadedLevel;
      int previousScore = PlayerPrefs.GetInt(key, 0);
      if(previousScore < score) {
        PlayerPrefs.SetInt(key, score);
      }
    
      Application.LoadLevel(0);
    }
    

    注意

    请注意,使用 PlayerPrefs 是在 Unity 中存储保存信息的最简单方法。然而,它并不是最安全的。如果你有在计算机注册表中更改值的经验,你可以轻松地从游戏外部找到并更改这些 PlayerPrefs 值。这并不意味着它不适合存储游戏信息。你只需要意识到这一点,以防你制作游戏时希望防止玩家黑客攻击并更改游戏存档中的值。

  16. 接下来,我们需要创建一些 GUI 对象,以便玩家在游戏中了解自己的表现。记得你可以通过导航到GameObject | UI来找到它们。我们将需要三个文本对象、一个按钮和一个面板。

  17. 第一个文本对象应命名为 Score。它将在关卡进行时显示玩家的得分。将其锚定在画布区域的左上角。

  18. 按钮需要成为面板的子对象。它应该锚定在屏幕中心,位置稍低于中心。同时,将按钮的文本更改为有意义的文字;这里使用返回关卡选择会很合适。

  19. 对于点击操作,我们需要点击加号来添加新的事件。选择LevelTracker脚本的SaveScore函数。否则,我们将无法记录玩家的最高分并结束关卡。

  20. 最后两个文本对象也应该被设置为面板的子对象。其中一个命名为Message;它会告诉玩家关卡结束的原因。另一个应命名为FinalScore,在玩家完成时显示他们的得分。它们都需要锚定在屏幕中心,同时将FinalScore对象放置在按钮上方,消息在它的上方。

  21. 最后,我们场景中所有的猪对象都需要通过拖放每个猪到检查器窗口下的Pigs值来添加到LevelTracker脚本的列表中。同时,将每个文本对象放入其槽位,并将面板放入最终组槽位中。创建敌人

我们创建了猪,并更新了LevelTracker脚本来跟踪它们。这些猪实际上就像木板,但它们是圆形而不是盒子。更新的LevelTracker脚本监听所有猪被摧毁的实例,并在那时触发游戏结束屏幕。它还在游戏进行时绘制分数,并在关卡结束时保存这个分数。

我们的游戏还没有完全运作起来,但这并不意味着它必须看起来像 Unity 提供的默认设置。使用你之前章节的技能,让已有的界面元素看起来更好。即使只是改变字体,也会让我们的游戏看起来大不相同。也许甚至尝试更改Panel的背景图像,为我们的游戏结束屏幕添加最后的亮点。

创建盟友

接下来,我们需要一些东西来投掷向猪和它们的防御工事。这里,我们将创建最简单的红鸟。红鸟本质上只是一个石头。它没有特殊能力,除了生命值之外,它的代码也没有特别之处。你还会注意到,鸟是一个 3D 模型,这使它拥有了猪所缺少的阴影。让我们按照以下步骤来创建红鸟:

  1. 红鸟是另一个 3D 模型,因此它的设置方式与木板类似。创建一个空的游戏对象,将其命名为Bird_Red,并将适当的模型从birds模型中作为子对象添加,将其位置和缩放调整到大约一个单位大小,并将模型沿x轴旋转对齐。如果稍微向摄像机方向旋转,玩家就能看到鸟的脸,同时仍然能够给玩家在看向游戏场地的印象。

  2. 接下来,给它一个圆形碰撞器 2D组件和一个刚体 2D组件。

  3. 现在,我们需要创建一个名为Bird的新脚本。这个脚本将成为我们所有鸟的基础,跟踪它们的生命值并在适当的时候触发它们的特殊能力。

  4. 脚本从三个变量开始。第一个将跟踪鸟类的当前生命值。第二个是一个标志,这样鸟类只会使用一次特殊能力。它被标记为protected,这样我们的所有鸟类都可以使用它,同时防止外部干扰。最后一个将保存对我们刚体组件的引用:

    public float health = 50;
    protected bool didSpecial = false;
    public Rigidbody2D body;
    
  5. Update函数在激活鸟类的特殊能力之前会进行三次检查。首先,它会检查是否已经完成,然后检查屏幕是否被触摸。我们可以通过检查左键鼠标来轻松检查在本帧中是否进行了触摸操作,Unity 在我们触摸屏幕时会触发这个动作。最后,它会检查鸟类是否有刚体组件,以及是否被其他脚本控制:

    public void Update() {
      if(didSpecial) return;
      if(!Input.GetMouseButtonDown(0)) return;
      if(body == null || body.isKinematic) return;
    
      DoSpecial();
    }
    
  6. 对于红鸟来说,DoSpecial函数仅将其标志设置为true。它被标记为virtual,这样我们就可以为其他鸟类重写该函数,让它们做一些花哨的事情:

    protected virtual void DoSpecial() {
      didSpecial = true;
    }
    
  7. OnCollisionEnter2D函数与木板类似,根据碰撞的强度减少生命值,并在生命值耗尽时销毁鸟类:

    public void OnCollisionEnter2D(Collision2D collision) {
      health -= collision.relativeVelocity.magnitude;
      if(health < 0)
        Destroy(gameObject);
    }
    
  8. 回到 Unity,并将脚本添加到Bird_Red对象。

  9. 完成鸟类创建的过程,将其转化为预制体,并从场景中删除。接下来我们将创建的弹弓会在游戏开始时处理鸟类的创建。

我们创建了一只红鸟。它的设置与我们其他的物理对象一样。我们还创建了一个脚本来处理鸟的生命值。这个脚本将在我们为游戏创建其他鸟类时进一步扩展。

控制

接下来,我们将赋予玩家与游戏互动的能力。首先,我们将创建一个弹弓来投掷鸟类。之后,我们将创建相机控制。我们甚至将创建一个漂亮的背景效果,使我们的游戏外观更加完善。

使用弹弓攻击

为了攻击猪堡垒,我们有了基本的鸟类弹药。我们需要创建一个弹弓,将这种弹药投向猪。它还将处理在关卡开始时生成鸟类,并在使用鸟类后自动重新装填。当弹弓中没有鸟类时,它会通知LevelTracker脚本,游戏将结束。最后,我们将创建一个脚本来防止物理模拟持续过长时间。我们不想让玩家坐下来观看一只猪慢慢滚过屏幕。因此,脚本会在一段时间后开始减弱刚体组件的运动,使它们停下来,而不是继续滚动。为了完成所有这些工作,我们将按照以下步骤进行:

  1. 为了开始创建弹弓,将弹弓模型添加到场景中,并将其定位在原点。如有必要,将其缩放到大约四个单位的高度。为Fork模型应用浅棕色材质,为Pouch模型应用深棕色材质。

  2. 接下来,我们需要四个空的 GameObject。将它们都设置为Slingshot对象的子对象。

    将第一个 GameObject 命名为FocalPoint,并将其放置在弹弓叉齿之间。这将是我们发射所有鸟的中心点。

    第二个 GameObject 是Pouch。首先,将其X 轴的旋转设置为0Y 轴的旋转设置为90Z 轴的旋转设置为0,使蓝色箭头沿着我们的游戏场指向前方。接下来,将pouch模型设置为该对象的子对象,将其X 轴和 Y 轴的位置设置为0Z 轴的位置设置为-0.5,旋转设置为X 轴270度,Y 轴90度,Z 轴0度。这样,在不制作完整的袋子模型的情况下,袋子将出现在当前鸟的前面。

    第三个 GameObject 是BirdPoint;这将定位正在发射的鸟的位置。将其设置为Pouch点的子对象,并将其X 轴的位置设置为0.3Y 轴Z 轴的位置设置为0

    最后一个 GameObject 是WaitPoint;待发射的鸟将位于这个点后面。将其X 轴的位置设置为-4Y 轴的位置设置为0.5Z 轴的位置设置为0

  3. 接下来,旋转Fork模型,以便我们能够看到叉子的两个叉齿,同时它看起来是指向前方。X 轴270度,Y 轴290度,以及Z 轴0度将会很合适。

  4. Slingshot脚本将提供玩家的大部分互动功能。现在创建它。

  5. 我们从这个脚本开始使用一组变量。第一组变量将保存之前提到的阻尼器的引用。第二组变量将跟踪将在关卡中使用的鸟。接下来是一组变量,用于跟踪准备发射的当前鸟。第四组变量保存我们刚才创建的点的引用。maxRange变量是从焦点到玩家可以将袋子拖动的距离。最后两个变量定义了鸟被发射的力度:

    public RigidbodyDamper rigidbodyDamper;
    
    public GameObject[] levelBirds = new GameObject[0];
    private Rigidbody2D[] currentBirds;
    private int nextIndex = 0;
    public Transform waitPoint;
    public Rigidbody2D 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;
    
  6. 与其他脚本一样,我们使用Awake函数进行初始化。levelBirds变量将保存所有将在关卡中使用的鸟类预制体的引用。我们首先创建每个预制体的实例,并将其刚体保存在currentBirds变量中。每个鸟的刚体组件上的isKinematic变量设置为true,这样在不使用时它就不会移动。接下来,它准备好第一个要发射的鸟,最后,它将剩余的鸟定位在waitPoint后面:

    public void Awake() {
      currentBirds = new Rigidbody2D[levelBirds.Length];
      for(int i=0;i<levelBirds.Length;i++) {
        GameObject nextBird = Instantiate(levelBirds[i]) as GameObject;
        currentBirds[i] = nextBird.GetComponent<Rigidbody2D>();
        currentBirds[i].isKinematic = true;
      }
    
      ReadyNextBird();
      SetWaitPositions();
    }
    
  7. ReadyNextBird函数首先检查是否已经没有鸟可供发射。如果是这样,它会找到LevelTracker脚本来告诉它没有鸟可以发射了。nextIndex变量跟踪列表中待玩家发射的鸟的当前位置。接下来,该函数将下一个鸟存储在toFireBird变量中,并将其设置为之前创建的BirdPoint对象的子对象;其位置和旋转会被重置为零。最后,发射和瞄准标志会被重置:

    public void ReadyNextBird() {
      if(currentBirds.Length <= nextIndex) {
        LevelTracker tracker = FindObjectOfType(typeof(LevelTracker)) as LevelTracker;
        tracker.OutOfBirds();
        return;
      }
    
      toFireBird = currentBirds[nextIndex];
      nextIndex++;
    
      toFireBird.transform.parent = pouchBirdPoint;
      toFireBird.transform.localPosition = Vector3.zero;
      toFireBird.transform.localRotation = Quaternion.identity;
    
      didFire = false;
      isAiming = false;
    }
    
  8. SetWaitPositions函数使用waitPoint的位置来定位弹弓后面所有剩余的鸟:

    public void SetWaitPositions() {
      for(int i=nextIndex;i<currentBirds.Length;i++) {
        if(currentBirds[i] == null) continue;
        Vector3 offset = Vector3.right * (i – nextIndex) * 2;
        currentBirds[i].transform.position = waitPoint.position – offset;
      }
    }
    
  9. Update函数首先检查玩家是否已经发射了一只鸟,并观察rigidbodyDamper.allSleeping变量以判断所有物理对象是否已经停止移动。一旦它们停止,下一只鸟就会被准备好发射。如果我们还没有发射,会检查瞄准标志并调用DoAiming函数来处理瞄准。如果玩家既没有瞄准也没有刚刚发射鸟,我们会检查触摸输入。如果玩家触摸的位置足够接近焦点,我们会标记玩家已经开始瞄准:

    public void Update() {
      if(didFire) {
        if(rigidbodyDamper.allSleeping) {
          ReadyNextBird();
          SetWaitPositions();
        }
        return;
      }
      else if(isAiming) {
        DoAiming();
      }
      else {
        if(Input.touchCount <= 0) return;
        Vector3 touchPoint = GetTouchPoint();
        isAiming = Vector3.Distance(touchPoint, focalPoint.position) < maxRange / 2f;
      }
    }
    
  10. 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);
      }
    }
    
  11. GetTouchPoint函数使用ScreenPointToRay来找出玩家在 3D 空间中触摸的位置。这类似于我们触摸香蕉时的操作;然而,由于这个游戏是 2D 的,我们只需查看射线原点并返回其z轴值为零:

    private Vector3 GetTouchPoint() {
      Ray touchRay = Camera.main.ScreenPointToRay(Input.GetTouch(0).position);
      Vector3 touchPoint = touchRay.origin;
      touchPoint.z = 0;
      return touchPoint;
    }
    
  12. 最后,对于这个脚本,我们有FireBird函数。这个函数首先将我们的didFire标志设置为true。接下来,它通过查找袋子位置到focalPoint的方向来确定需要发射鸟的方向。它还使用它们之间的距离来确定发射鸟所需的力度,并将其限制在我们的最小和最大力度之间。然后,它通过清除其父对象并将isKinematic标志设置为false来释放鸟,找到其Rigidbody组件。为了发射它,我们使用AddForce函数,并传递方向乘以力度。同时传递ForceMode2D.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.transform.parent = null;
      toFireBird.isKinematic = false;
      toFireBird.AddForce(new Vector2(direction.x, direction.y) * power, ForceMode2D.Impulse);
    
      pouch.position = focalPoint.position;
    
      rigidbodyDamper.ReadyDamp();
    }
    
  13. 在我们能够使用Slingshot脚本之前,我们需要创建RigidbodyDamper脚本。

  14. 这个脚本从以下六个变量开始。前两个变量定义了在抑制移动之前需要等待的时间以及抑制的幅度。接下来的两个变量跟踪是否可以应用抑制以及何时开始。接下来是一个变量,它将被填充为当前场景中所有刚体的列表。最后,有一个allSleeping标志,当移动停止时,它将被设置为true

    public float dampWaitLength = 10f;
    public float dampAmount = 0.9f;
    private float dampTime = -1f;
    private bool canDamp = false;
    private Rigidbody2D[] rigidbodies = new Rigidbody2D[0];
    
    public bool allSleeping = false;
    
  15. ReadyDamp函数首先使用FindObjectsOfType填充刚体列表。当需要开始抑制时,将dampTime标志设置为当前时间与等待时长的总和。它表示脚本可以执行抑制并重置allSleeping标志。最后,它使用StartCoroutine调用CheckSleepingRigidbodies函数。这是一种特殊的调用函数方式,使它们在后台运行,而不会阻止游戏的其余部分运行:

    public void ReadyDamp() {
      rigidbodies = FindObjectsOfType(typeof(Rigidbody2D)) as Rigidbody2D[];
      dampTime = Time.time + dampWaitLength;
      canDamp = true;
      allSleeping = false;
    
      StartCoroutine(CheckSleepingRigidbodies());
    }
    
  16. FixedUpdate函数中,我们首先检查是否可以抑制移动以及是否到了执行抑制的时候。如果是,我们会遍历所有刚体,对每个刚体的旋转速度和线性速度应用我们的抑制。那些由脚本控制、已经处于休眠状态(意味着它们已经停止移动)的动力学刚体将被跳过:

    public void FixedUpdate() {
      if(!canDamp || dampTime > Time.time) return;
    
      foreach(Rigidbody2D next in rigidbodies) {
        if(next != null && !next.isKinematic && !next.isSleeping()) {
          next.angularVelocity *= dampAmount;
          next.velocity *= dampAmount;
        }
      }
    }
    
  17. CheckSleepingRigidbodies函数是特殊的,它将在后台运行。这是通过函数开头的IEnumerator标志和中间的yield return null行实现的。这些使得函数可以定期暂停,并在等待函数完成时防止游戏其余部分冻结。函数开始时创建一个检查标志,并使用它来检查所有刚体是否已经停止移动。如果发现有一个仍在移动,标志将被设置为false,函数将暂停到下一帧,届时将再次尝试。当到达末尾时,因为所有刚体都处于休眠状态,它将allSleeping标志设置为true,以便下一次使用弹弓时做好准备。同时,在玩家准备发射下一只鸟时,它也会停止抑制:

    private IEnumerator CheckSleepingRigidbodies() {
      bool sleepCheck = false;
    
      while(!sleepCheck) {
        sleepCheck = true;
    
        foreach(Rigidbody2D next in rigidbodies) {
          if(next != null && !next.isKinematic && !next.IsSleeping()) {
            sleepCheck = false;
            yield return null;
            break;
          }
        }
      }
    
      allSleeping = true;
      canDamp = false;
    }
    
  18. 最后,我们有AddBodiesToCheck函数。这个函数将被任何在玩家发射鸟之后生成新物理对象的物体使用。它开始时创建一个临时列表并扩展当前列表。接下来,它将临时列表中的所有值添加到扩展后的列表中。最后,在临时列表之后添加刚体列表:

    public void AddBodiesToCheck(Rigidbody2D[] toAdd) {
      Rigidbody2D[] temp = rigidbodies;
      rigidbodies = new Rigidbody2D[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];
      }
    }
    
  19. 回到 Unity,将这两个脚本添加到Slingshot对象中。在Slingshot脚本组件中,连接到Rigidbody Damper脚本组件的引用以及每个点的引用。此外,根据关卡需要,将红色小鸟预制体引用添加到Level Birds列表中。

  20. 为了防止物体滚回到弹弓中,请在Slingshot上添加一个Box Collider 2D组件,并将其定位在Fork模型的支架处。

  21. 为了完成弹弓的外观,我们需要创建将袋子与叉连接的弹性带子。我们首先通过创建SlingshotBand脚本来实现这一点。

  22. 脚本从两个变量开始,一个用于带子结束的点,另一个用于引用将绘制它的LineRenderer变量:

    public Transform endPoint;
    public LineRenderer lineRenderer;
    
  23. 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);
    }
    
  24. LateUpdate函数中,我们将lineRenderer变量的端点位置设置为endPoint值。这个点会随着袋子移动,因此我们需要不断更新渲染器:

    public void LateUpdate() {
      if(endPoint == null) return;
      if(lineRenderer == null) return;
    
      lineRenderer.SetPosition(1, endPoint.position);
    }
    
  25. 返回 Unity,并创建一个空游戏对象。将其命名为Band_Near,并使其成为Slingshot对象的子对象。

  26. 作为这个新点的子对象,创建一个圆柱体和一个名为Band的第二个空游戏对象

  27. 给圆柱体一个棕色材质,并将其定位在弹弓叉近端。确保移除胶囊碰撞器组件,以免造成妨碍。同时,不要害怕进行缩放,以便更好地适应弹弓的外观。

  28. Band对象上,添加位于组件菜单下效果中的线渲染器组件。将其放置在圆柱体的中心后,为对象添加SlingshotBand脚本。

  29. 材质下的线渲染器组件中,你可以将棕色材质放入槽中以给带子着色。在参数下,将开始宽度设置为0.5,将结束宽度设置为0.2,以设置线条的大小。

  30. 接下来,创建另一个空游戏对象,并将其命名为BandEnd_Near。使其成为Pouch对象的子对象,并将其定位在袋子内部。

  31. 现在,将脚本的引用连接到其线渲染器和端点。

  32. 为了制作第二条带子,复制我们刚才创建的四个对象,并根据叉的另一端定位它们。这条带子的端点只需沿着z轴向后移动,以使其避开小鸟。

  33. 最后,将其整个转换为预制体,以便在其他关卡中轻松复用。使用弹弓攻击

我们创建了一个用于发射小鸟的弹弓。我们使用了前一章学到的技术来处理触摸输入,并在玩家瞄准和射击时追踪玩家的手指。如果你保存你的场景,并将相机定位在观察弹弓的位置,你会注意到它已经完成,尽管还不是很完善。可以向猪堡垒发射小鸟,尽管我们只能在 Unity 的场景视图中看到破坏效果。

通过相机观看

在这一点上,游戏在技术上是可以玩的,但有点难以看清正在发生的事情。接下来,我们将创建一个控制系统来控制摄像机。该系统将允许玩家将摄像机向左和右拖动,当小鸟被发射时跟随小鸟,并在一切停止移动后返回弹弓位置。还将有一组限制,以防止摄像机走得太远,看到我们不希望玩家看到的东西,比如我们为关卡创建的地形或天空的边缘。我们只需要一个相对简短的脚本来控制和管理工作。让我们按照以下步骤创建它:

  1. 为了开始并保持一切有序,创建一个新的空GameObject,并将其命名为CameraRig。同时,为了简化,将其在每个轴上的位置设置为 0。

  2. 接下来,创建三个空的GameObject,并将它们命名为LeftPointRightPointTopPoint。将它们的Z 轴位置设置为-5。将LeftPoint对象定位在弹弓前方,并在Y 轴上设置为3的位置。RightPoint对象需要定位在你创建的猪结构前方。TopPoint对象可以位于弹弓上方,但在Y 轴上需要设置为8。这三个点将定义当拖动和跟随小鸟时,摄像机可以移动的范围限制。

  3. 将这三个点以及Main Camera对象设置为CameraRig对象的子对象。

  4. 现在,我们创建CameraControl脚本。这个脚本将控制摄像机的所有移动和交互。

  5. 本脚本的变量从对弹弓的引用开始;我们需要这个引用以便在发射时跟随当前的小鸟。接下来是对我们刚刚创建的点进行引用。接下来的一组变量控制摄像机在没有输入的情况下停留多长时间,然后返回查看弹弓以及返回的速度。dragScale变量控制当玩家在屏幕上拖动手指时,摄像机实际移动的速度,使场景能够跟随手指移动。最后一组变量控制摄像机是否可以跟随当前的小鸟以及跟随的速度:

    public Slingshot slingshot;
    public Transform rightPoint;
    public Transform leftPoint;
    public Transform topPoint;
    
    public float waitTime = 3f;
    private float headBackTime = -1f;
    private Vector3 waitPosition;
    private float headBackDuration = 3f;
    
    public float dragScale = 0.075f;
    
    private bool followBird = false;
    private Vector3 followVelocity = Vector3.zero;
    public float followSmoothTime = 0.1f;
    
  6. Awake函数中,我们首先确保摄像机没有跟随小鸟,并让它等待一段时间后再去查看弹弓。这样,当关卡开始时,你可以先将摄像机指向猪堡垒,并在给玩家一个机会观察他们面对的情况之后,移动到弹弓位置:

    public void Awake() {
      followBird = false;
      StartWait();
    }
    
  7. StartWait函数设置它将开始返回弹弓的时间,并记录它从哪个位置返回。这允许你创建一个平滑的过渡:

    public void StartWait() {
      headBackTime = Time.time + waitTime;
      waitPosition = transform.position;
    }
    
  8. 然后,我们有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();
      }
    }
    
  9. FollowBird函数首先通过检查Slingshot脚本上的toFireBird变量确保有一个鸟可供跟随,如果没有找到鸟则停止跟随。如果有鸟,该函数就会确定一个新的移动点,该点将直接对准鸟。然后它使用Vector3.SmoothDamp函数平滑地跟随鸟。这个函数类似于弹簧——离目标位置越远,移动物体的速度越快。使用followVelocity变量使其保持平滑移动。最后,它调用另一个函数,以限制摄像机在我们先前设置的限制点内的位置:

    private void FollowBird() {
      if(slingshot.toFireBird == null) {
        followBird = false;
        return;
      }
    
      Vector3 targetPoint = slingshot.toFireBird.transform.position;
      targetPoint.z = transform.position.z;
    
      transform.position = Vector3.SmoothDamp(transform.position, targetPoint, ref followVelocity, followSmoothTime);
      ClampPosition();
    }
    
  10. DragCamera函数中,我们使用当前触控的deltaPosition值来确定自上一帧以来它移动了多远。通过缩放这个值并从摄像机位置减去该向量,函数使摄像机随着玩家在屏幕上的拖动而移动。此函数还调用ClampPosition函数,以确保摄像机位置保持在游戏场内:

    private void DragCamera() {
      transform.position -= new Vector3(Input.GetTouch(0).deltaPosition.x, Input.GetTouch(0).deltaPosition.y, 0) * dragScale;
      ClampPosition();
    }
    
  11. ClampPosition函数首先获取摄像机的当前位置。然后它将x位置夹紧在leftPointrightPoint变量的x位置之间。接下来,y位置被夹紧在leftPointtopPoint变量的y位置之间。最后,将新位置重新应用到摄像机的变换中:

    private void ClampPosition() {
      Vector3 clamped = transform.position;
      clamped.x = Mathf.Clamp(clamped.x, leftPoint.position.x, rightPoint.position.x);
      clamped.y = Mathf.Clamp(clamped.y, leftPoint.position.y, topPoint.position.y);
      transform.position = clamped;
    }
    
  12. 最后,我们有BackToLeft函数。它首先使用时间和我们的持续时间变量来确定摄像机返回到弹弓时应该完成多少进度。它记录摄像机的当前位置,并在xy轴上使用Mathf.SmoothStep找到一个位于waitPosition变量和leftPoint变量之间的适当距离的新位置。最后,应用新位置:

    private void BackToLeft() {
      float progress = (Time.time – headBackTime) / headBackDuration;
      Vector3 newPosition = transform.position;
      newPosition.x = Mathf.SmoothStep(waitPosition.x, leftPoint.position.x, progress);
      newPosition.y = Mathf.SmoothStep(waitPosition.y, leftPoint.position.y, progress);
      transform.position = newPosition;
    }
    
  13. 接下来,回到 Unity 并将新脚本添加到Main Camera对象。连接到弹弓和每个点的引用以完成设置。

  14. 将摄像机定位以指向你的猪堡垒,并将整个装置转变成一个预制体。

我们创建了一个摄像机装置,让玩家在玩游戏时可以观看所有的动作。现在相机将跟随从弹弓发射的小鸟,并且可以被玩家拖动。通过定位几个对象的位置,这种移动受到了限制,以防止玩家看到我们不想让他们看到的东西;如果相机闲置足够长的时间,它也会返回来观察弹弓。

相机在许多移动游戏中的另一个功能是通过捏合来进行缩放的手势。这对于用户来说是一个非常简单的手势,但对我们来说要实现好可能会很复杂。尝试在这里实现它。你可以使用Input.touchCount来检测是否有两个手指触摸屏幕。然后,使用Vector2.Distance函数,如果你记录了上一帧的距离,就可以确定它们是相互靠近还是远离。一旦确定了缩放方向,只需改变相机的ortographicSize变量,以改变可以看到的范围;确保包括一些限制,这样玩家就不能无限地放大或缩小。

既然我们已经有了制作完整关卡所需的所有部分,我们需要更多的关卡。我们至少还需要两个关卡。你可以使用积木和猪来创建你想要的任何关卡。最好保持结构围绕与我们的第一个关卡相同的中心点,这样玩家处理起来会更简单。同时,在制作关卡时也要考虑关卡的难度,以便最终拥有简单、中等和困难难度的关卡。

创建视差背景

许多 2D 游戏的一个出色特性是视差滚动背景。这仅仅意味着背景是由以不同速度滚动的层次创建的。你可以把它想象成你从汽车窗户向外看。远处的物体看起来几乎不动,而近处的物体则快速移动。在 2D 游戏中,它给人以深度的错觉,并为游戏的外观增添了不错的触感。对于这个背景,我们将在单个平面上叠加几种材质。还有其他几种方法可以创建这种效果,但我们将使用一个脚本来实现,此外它还允许你控制每一层的滚动速度。让我们按照以下步骤来创建它:

  1. 我们将从创建ParallaxScroll脚本开始这一部分。

  2. 这个脚本从三个变量开始。前两个变量跟踪每种材质以及它们滚动的速度。第三个变量记录相机的最后位置,这样我们可以跟踪相机在每一帧中移动了多远:

    public Material[] materials = new Material[0];
    public float[] speeds = new float[0];
    
    private Vector3 lastPosition = Vector3.zero;
    
  3. Start函数中,我们记录相机的初始位置。这里我们使用Start而不是Awake,以防止相机在游戏开始时需要进行任何特殊的移动:

    public void Start() {
      lastPosition = Camera.main.transform.position;
    }
    
  4. 接下来,我们使用LateUpdate函数在摄像机移动后进行更改。它首先找到摄像机的新的位置,并通过比较x轴的值来确定它移动了多远。接下来,它遍历材质列表。循环首先使用mainTextureOffset收集材质当前纹理的偏移量。然后,将摄像机的移动乘以材质的速度从偏移量的x轴中减去,以找到新的水平位置。接着,将新的偏移量应用到材质上。最后,该函数记录摄像机在上一个帧中的位置,以供下一帧使用:

    public void LateUpdate() {
      Vector3 newPosition = Camera.main.transform.position;
      float move = newPosition.x – lastPosition.x;
    
      for(int i=0;i<materials.Length;i++) {
        Vector2 offset = materials[i].mainTextureOffset;
        offset.x -= move * speeds[i];
        materials[i].mainTextureOffset = offset;
      }
    
      lastPosition = newPosition;
    }
    
  5. 回到 Unity,创建六个新的材质。每个背景纹理一个:skyhills_tallhills_shortgrass_lightgrass_darkfronds。除了sky之外的所有材质,都需要使用透明渲染模式。如果不用这种模式,我们将无法在分层时看到所有纹理。

  6. 在我们能够对背景中的图像进行平铺之前,我们需要调整它们的导入设置。逐个选择它们,并查看检查器窗口。由于我们选择制作一个 2D 游戏,Unity 默认将所有图像作为精灵导入,这会导致我们的图像边缘被夹紧,无法重复。对于所有背景图像,将纹理类型选项更改为纹理,并将环绕模式选项更改为重复。这样我们就可以以无限滚动背景的方式使用它们。

  7. 我们还需要调整这些新材质的平铺(Tiling)选项。对于所有材质,将Y 轴保持为1。对于X 轴,将sky设为5hills_tall设为6hills_shot设为7grass_dark设为8fronds设为9grass_light设为10。这将偏移所有纹理特征,使得长距离的平移不会看到特征规律地排列。

  8. 接下来,创建一个新的平面。将其命名为Background,并移除其网格碰撞器组件。同时,附加我们的ParallaxScroll脚本。

  9. 将其位置设置为X 轴上的30Y 轴上的7,以及Z 轴上的10。将其旋转设置为X 轴上的90Y 轴上的180,以及Z 轴上的0。同时,将缩放设置为X 轴上的10Y 轴上的1,以及Z 轴上的1.5。总的来说,这些设置使平面面向摄像机并填充背景。

  10. 在平面的网格渲染器组件中,展开材质列表,并将大小值设置为6。按顺序将我们的新材质添加到列表槽中,顺序为skyhills_tallhills_shortgrass_darkfrondsgrass_light。对视差滚动脚本组件中的材质列表也执行相同的操作。

  11. 最后,在视差滚动脚本组件中,将速度列表中的大小值设置为6,并按顺序输入以下值:0.030.0240.0180.0120.0060。这些值将会使材质均匀柔和地移动。

  12. 在这一点上,将背景变成预制体将使其在以后容易重用。创建视差背景

我们创建了一个视差滚动效果。这个效果将平移一系列背景纹理,为我们的 2D 游戏提供深度的错觉。要轻松查看它的效果,请按播放按钮并在场景视图中抓住相机,左右移动以查看背景变化。

我们还有两个关卡需要添加背景。这里的挑战是创建你自己的背景。使用你在本节中学到的技术来创建一个夜晚风格的背景。它可以包括一个静止的月亮,而其他所有内容在镜头中滚动。为了增加一个技巧,创建一个云层,它随着相机和背景的其他部分慢慢横穿屏幕。

添加更多小鸟

我们还需要为我们的关卡创建最后一组资产:其他的小鸟。我们将创建三只更多的小鸟,每只都有独特的特殊能力:加速的黄色小鸟,分裂成多只小鸟的蓝色小鸟,以及爆炸的黑色小鸟。有了这些,我们的鸟群就完整了。

为了更容易地创建这些小鸟,我们将利用一个称为继承的概念。继承允许脚本在不需要重写的情况下扩展其继承的功能。如果使用得当,这将非常强大,在我们的情况下,它将有助于快速创建多个大致相似的角色。

黄色小鸟

首先,我们将创建黄色小鸟。在很大程度上,这只鸟的功能与红色小鸟完全相同。然而,当玩家第二次触摸屏幕时,小鸟的特殊能力被激活,其速度会增加。通过扩展我们之前创建的Bird脚本,这只鸟的创建变得相当简单。由于继承的强大力量,我们在这里创建的脚本仅包含几行代码。让我们按照以下步骤来创建它:

  1. 首先,按照创建红色小鸟的相同方式,使用YellowBird模型来创建黄色小鸟。

  2. 我们不是使用Bird脚本,而是将创建YellowBird脚本。

  3. 这个脚本需要扩展Bird脚本,因此在我们新脚本的第四行,用Bird替换MonoBehaviour。它应该类似于以下代码片段:

    public class YellowBird : Bird {
    
  4. 这个脚本添加了一个单一变量,用于乘以小鸟的当前速度:

    public float multiplier = 2f;
    
  5. 接下来,我们重写DoSpecial函数,并在调用时乘以小鸟的body.velocity变量:

    protected override void DoSpecial() {
      didSpecial = true;
      body.velocity *= multiplier;
    }
    
  6. 回到 Unity,将脚本添加到你的新小鸟中,连接Rigidbody组件引用,并将其变成一个预制体。在你的弹弓列表中添加一些,以便在你的关卡中使用这只鸟。

我们创建了黄色小鸟。这只鸟很简单。当玩家触摸屏幕时,它会直接修改其速度,以突然获得速度的提升。正如你很快会看到的,我们使用这种风格的脚本来创建我们所有的鸟。

蓝色小鸟

接下来,我们将创建蓝色小鸟。当玩家触摸屏幕时,这种鸟会分裂成三只鸟。它还将通过继承扩展Bird脚本,减少编写创建鸟所需的代码量。让我们按照以下步骤进行:

  1. 同样,像前两只鸟一样开始构建你的蓝色小鸟,替换相应的模型。你还应该调整Circle Collider 2D组件的Radius值,以适应这种小鸟的小尺寸。

  2. 接下来,我们创建BlueBird脚本。

  3. 再次,调整第四行,使脚本扩展Bird而不是MonoBehaviour

    public class BlueBird : Bird {
    
  4. 这个脚本有三个变量。第一个变量是当鸟分裂时要生成的预制体列表。下一个是每个新发射鸟之间的角度差。最后一个变量是为了避免生成的鸟相互卡住,而将它们稍微提前生成的位置值:

    public GameObject[] splitBirds = new GameObject[0];
    public float launchAngle = 15f;
    public float spawnLead = 0.5f;
    
  5. 接下来,我们重写DoSpecial函数,像其他鸟一样,首先标记我们完成了特殊动作。接下来,它计算要生成的鸟的一半数量,并创建一个空列表来存储新生成鸟的刚体:

    protected override void DoSpecial() {
      didSpecial = true;
    
      int halfLength = splitBirds.Length / 2;
      Rigidbody2D[] newBodies = new Rigidbody2D[splitBirds.Length];
    
  6. 函数通过遍历鸟类列表,跳过空槽继续执行。它在它们的位置生成新鸟;尝试存储对象的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;
    
      Rigidbody2D nextBody = next.GetComponent<Rigidbody2D>();
      if(nextBody == null) continue;
    
      newBodies[i] = nextBody;
    
  7. 使用Quaternion.Euler,创建一个新的旋转,使新鸟沿着从主路径分叉的路径偏转。新鸟的速度设置为当前鸟的旋转速度。计算偏移量,然后沿着新路径向前移动,以便为其他生成的鸟让路:

    Quaternion rotate = Quaternion.Euler(0, 0, launchAngle * (i – halfLength));
    nextBody.velocity = rotate * nextBody.velocity;
    Vector2 offset = nextBody.velocity.normalized * spawnLead;
    next.transform.position += new Vector3(offset.x, offset.y, 0);
    }
    
  8. 在循环之后,函数使用FindObjectOfType查找当前场景中的弹弓。如果找到,将其更改为跟踪第一个新生成的鸟作为被发射的鸟。新的刚体列表也被设置为rigidbodyDamper变量,以便添加到其刚体列表中。最后,脚本销毁其附着的鸟,完成鸟被分裂的错觉:

    Slingshot slingshot = FindObjectOfType(typeof(Slingshot)) as Slingshot;
    if(slingshot != null) {
      slingshot.toFireBird = newBodies[0];
      slingshot.rigidbodyDamper.AddBodiesToCheck(newBodies);
    }
    
    Destroy(gameObject);
    }
    
  9. 在将脚本添加到你的新鸟之前,我们实际上需要两只蓝色小鸟:一只负责分裂,另一只不分裂。复制你的鸟,并将一个命名为Bird_Blue_Split,另一个命名为Bird_Blue_Normal。在分裂的鸟上添加新脚本,而在普通鸟上添加Bird脚本。

  10. 将两只鸟都转变成预制体,并将普通鸟添加到另一只鸟的待分裂鸟列表中。

我们创建了蓝色小鸟。当用户点击屏幕时,这种鸟会分裂成多只鸟。实际上这个效果需要两只看起来完全相同的鸟,一只负责分裂,另一只被分裂成两半但不执行特殊动作。

实际上,我们可以将任何想要生成的对象添加到蓝色小鸟分裂时产生的对象列表中。这里的挑战是创建一个彩虹鸟。这种鸟可以分裂成不同类型的鸟,不仅仅是蓝色。或者,它可能是一个石鸟,分裂成石块。为了增加挑战,创建一个神秘鸟,在分裂时从其列表中随机选择一种鸟。

黑色小鸟

最后,我们有了黑色小鸟。当玩家触摸屏幕时,这只鸟会爆炸。与之前讨论的所有鸟一样,它将扩展Bird脚本;从红色小鸟继承使得黑色小鸟的创建变得容易得多。让我们使用这些步骤来完成它:

  1. 与其他鸟一样,这个鸟最初是以与红色小鸟相同的方式创建的,调整Circle Collider 2D组件上的Radius值以适应其增加的大小。

  2. 同样,我们创建一个新的脚本来扩展Bird脚本。这次,它被称为BlackBird

  3. 不要忘记调整第四行以扩展Bird脚本,而不是MonoBehaviour

    public class BlackBird : Bird {
    
  4. 这个脚本有两个变量。第一个变量是爆炸的大小,第二个是它的强度:

    public float radius = 2.5f;
    public float power = 25f;
    
  5. 再次,我们重写DoSpecial函数,首先标记我们已经这样做。接下来,我们使用Physics2D.OverlapCircleAll获取在鸟爆炸范围内的所有对象列表,其 3D 版本是Physics.OverlapSphere。然后,我们计算爆炸来自哪里,这仅仅是我们鸟的位置向下移动三个单位。我们将其向下移动,因为向上抛射碎片比向外推射碎片的爆炸更有趣。然后函数遍历列表,跳过任何空槽和没有刚体的对象:

    protected override void DoSpecial() {
      didSpecial = true;
    
      Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position, radius);
    
      Vector2 explosionPos = new Vector2(transform.position.x, transform.position.y) – (Vector2.up * 3);
    
      foreach(Collider2D hit in colliders) {
        if(hit == null) continue;
        if(hit.attachedRigidbody != null) {
    
  6. 如果对象存在并且附加了Rigidbody组件,我们需要计算爆炸将如何影响这个对象,模拟爆炸强度随距离增加而减弱的方式。首先,我们通过获取另一个对象的位置来节省一些输入。接下来,我们计算它相对于爆炸位置的位置。通过将相对位置的大小或长度除以我们的radius变量,我们可以计算出需要向被击中的对象施加多大的力。最后,我们使用AddForceAtPosition给对象一个像在特定位置爆炸一样的踢力。ForceMode2D.Impulse变量用于立即施加力:

    Vector3 hitPos = hit.attachedRigidbody.transform.position;
    Vector2 dir = new Vector2(hitPos.x, hitPos.y) – explosionPos;
    float wearoff = 1 – (dir.magnitude / radius);
    Vector2 force = dir.normalized * power * wearoff;
    hit.attachedRigidbody.AddForceAtPosition(force, explosionPos, ForceMode2D.Impulse);
    }
    }
    
  7. 最后,函数销毁了已爆炸的鸟:

    Destroy(gameObject);
    }
    
  8. 与最后两只鸟一样,将你的新脚本应用于新鸟并将其变成预制体。现在,在每个级别选择弹弓武器库时,你有四种鸟可以选择。

我们创建了第四只也是最后一只鸟:黑色小鸟。当用户触摸屏幕时,这只鸟会爆炸,将附近的一切抛向空中。这可以是一种有趣的鸟来玩,对于摧毁你的猪堡垒非常有效。

我们模仿的游戏中的黑色小鸟具有额外的定时爆炸能力,在撞击到某物后触发。尝试为我们的黑色小鸟创建一个计时器,以重现这一效果。你需要重写OnCollisionEnter函数来启动计时器,并使用LateUpdate来倒计时。一旦计时器时间耗尽,你可以使用我们的DoSpecial函数来实际引发爆炸。

既然你知道如何引发爆炸,我们又有了一个挑战:创建一个爆炸箱子。你需要扩展Plank脚本来实现它,当箱子受到足够伤害时,触发爆炸。为了增加挑战性,你可以配置箱子,使其不是直接爆炸,而是抛出几个炸弹,这些炸弹在撞击到某物时爆炸。

黑色小鸟

关卡选择

最后,我们需要创建一个关卡选择屏幕。通过这个场景,我们可以访问并开始玩我们之前创建的所有关卡。我们还将显示每个关卡当前的最高分。一个新的场景和一个脚本就能很好地管理我们的关卡选择。让我们按照以下步骤进行操作:

  1. 最后一部分开始时保存我们当前的场景,并按Ctrl + N创建一个新场景;我们将它命名为LevelSelect

  2. 对于这个场景,我们需要创建一个名为LevelSelect的简短脚本。

  3. 这个脚本将和 GUI 中的按钮一起工作,告诉玩家高分和加载关卡。然而,在我们能做到这一点之前,我们需要在脚本的最前面添加一行,和其他需要更新 GUI 的脚本一样,与其他using行一起:

    using UnityEngine.UI;
    
  4. 第一个也是唯一的变量是我们想要更新的所有按钮文本的列表,以及它们关联关卡的分数:

    public Text[] buttonText = new Text[0];
    
  5. 第一个函数是Awake函数。这里,它会遍历所有按钮,找到它对应的高分,并更新文本以显示它。PlayerPrefs.GetInt与我们之前用来保存高分的SetInt函数相反:

    public void Awake() {
      for(int i=0;i<buttonText.Length;i++) {
        int levelScore = PlayerPrefs.GetInt("LevelScore" + (i + 1), 0);
        buttonText[i].text = "Level " + (i + 1) + "\nScore: " + levelScore;
      }
    }
    
  6. 这个脚本的第二个也是最后一个函数是LoadLevel。它将从 GUI 按钮接收一个数字,并使用它来加载玩家想要玩的关卡:

    public void LoadLevel(int lvl) {
      Application.LoadLevel(lvl);
    }
    
  7. 回到 Unity,并将脚本添加到Main Camera对象。

  8. 接下来,我们需要创建三个按钮。没有这些,我们的玩家将无法选择一个关卡来玩。使每个按钮为200单位大小,并将它们排成一行放在屏幕中央。同时,将字体大小增加到25,以便文本易于阅读。

  9. 将每个按钮的Text子对象拖动到Main Camera组件的Level Select脚本组件中的Button Texts列表。它们在这个列表中的顺序就是它们将改变文本和高分信息显示的顺序。

  10. 同时,每个按钮都需要一个新的点击事件。为对象选择主相机,然后导航到LevelSelect | LoadLevel (int) 函数。然后,每个按钮都需要一个数字。在按钮文本列表中的其文本子项的按钮应该有数字1,因为它将显示第一关的信息。第二个按钮有2,第三个有3,依此类推。每个按钮必须有与列表中顺序相同的数字,否则它们将加载与玩家预期不同的关卡。

  11. 最后,打开构建设置并将你的场景添加到构建中的场景列表中。通过点击并拖动列表中的场景,你可以重新排序它们。确保你的LevelSelect场景排在第一位,并且在右侧的索引为零。其余的场景可以按照你希望的任何顺序出现。但是要注意,它们将与按钮以相同的顺序关联。关卡选择

我们创建了一个关卡选择屏幕。它有一个与游戏中关卡相关联的按钮列表。当按下按钮时,Application.LoadLevel会开始那个关卡。我们还使用了PlayerPrefs.GetInt来获取每个关卡的高分。

在这里,挑战在于设计 GUI 样式,使屏幕看起来很棒。一个标志和背景将大有帮助。此外,如果你有超过三个关卡,请查看滚动条GUI 对象。这个对象将允许你创建一个函数,当用户浏览比屏幕上容易看到的更大的关卡列表时,可以偏移关卡按钮。

总结

在本章中,我们了解了 Unity 中的物理系统,并重新制作了极其流行的移动游戏,愤怒的小鸟。使用 Unity 的物理系统,我们能够制作出我们想要玩的所有关卡。通过这个游戏,我们还探索了 Unity 的 2D 管线,用于创建优秀的 2D 游戏。我们的鸟和弹弓是 3D 资源,使我们能够对它们进行光照和阴影处理。然而,猪和背景是 2D 图像,这减少了我们的光照选项,但使资源具有更高的细节。2D 图像在创建背景的视差滚动效果方面也至关重要。最后,构成关卡的方块看似 2D,实际上是 3D 方块。我们还创建了一个关卡选择屏幕。从这里,玩家可以看到他们的高分并选择我们创建的任何关卡。

在下一章中,我们将回到上一章开始制作的猴子球游戏。我们将创建并添加所有使游戏完整的特殊效果。我们将添加每个猴子球游戏都需要的声音弹跳和爆裂效果。我们还会添加各种粒子效果。当香蕉被收集时,它们将产生一个小型爆炸,而不是简单地消失。

第八章:特效 - 声音与粒子

在上一章中,我们从 Monkey Ball 游戏中短暂休息,以了解 Unity 中的物理和 2D 游戏。我们创建了一个愤怒的小鸟的克隆版。这些鸟利用物理原理在空中飞行并摧毁猪和它们的结构。我们利用视差滚动制作了一个令人愉悦的背景效果。我们还创建了一个关卡选择屏幕,通过它可以加载游戏的各种场景。

在本章中,我们将回到 Monkey Ball 游戏。我们将添加许多特殊效果,以丰富游戏体验。首先,我们会了解 Unity 在处理音频时提供的控制方法。然后,我们将在游戏中添加背景音乐和猴子移动的声音。接下来,我们将学习粒子系统,为猴子创建尘埃轨迹。最后,我们将结合本章介绍的效果,为用户收集香蕉时创建爆炸效果。

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

  • 导入音频剪辑

  • 播放音效

  • 理解 2D 和 3D 音效

  • 创建粒子系统

打开你的 Monkey Ball 项目,让我们开始吧。

理解音频

与其他资源一样,Unity 团队努力工作,使得处理音频变得简单且无忧。Unity 能够导入和利用广泛的音频格式,让您可以在其他程序中以可编辑的格式保存文件。

导入设置

音频剪辑有一系列重要的设置。它们让你可以轻松控制文件类型和压缩。下面的截图展示了我们在导入音频剪辑时要处理的一些设置:

导入设置

前面截图中的选项如下:

  • 强制单声道:这个复选框将导致 Unity 将多声道文件更改为单个声道的音频数据。

  • 后台加载:这将导致在将音频文件加载到内存时,不会暂停整个游戏。对于不需要立即使用的大型文件,最好使用这个选项。

  • 预加载音频数据:这将导致音频信息尽可能快地加载。这对于需要几乎立即使用的小文件来说是最好的。

  • 加载类型:这控制了在游戏播放时文件如何被加载;你可以从以下三个可用选项中选择:

    • 加载时解压缩:在第一次需要时从文件中移除压缩。这个选项的开销使得它非常不适合大型文件。这对于你经常听到的短声音来说是最好的选择,比如射击游戏中的枪声。

    • 内存中压缩:只有在播放时才会解压缩文件。当文件在内存中暂存时,它保持压缩状态。这对于不常听到的短到中等长度的声音来说是一个好选项。

    • 流式传输:这将在播放时加载音频,例如从网络流式传输音乐或视频。这个选项最适合背景音乐等事物。

  • 压缩格式:这允许你选择用于减少音频文件大小的压缩格式类型。PCM格式将为你提供最大的文件大小和最佳的音频质量。Vorbis格式可以为你提供最小的文件大小,但随着大小的减小,质量也会降低。ADPCM格式会根据音频文件的布局进行调整,以使文件大小处于中等水平。

  • 质量:仅当选择Vorbis作为压缩格式时使用。降低此值可以减少项目中文件的大小,但同时也会使音频引入越来越多的失真。

  • 采样率设置:这让你可以确定 Unity 中维护的音频文件的细节程度。保留采样率选项将保持原始文件中使用的设置。优化采样率选项将允许 Unity 为你的文件选择一个合适的设置。覆盖采样率选项将让你访问采样率的值并为你音频选择一个特定的设置。较小的值可以减少整个文件的大小,但会降低质量。

音频监听器

为了在游戏中实际听到声音,每个场景都需要一个音频监听器组件。默认情况下,任何新场景中首先包含的主相机对象以及你可能创建的任何新相机都附有音频监听器组件。你的场景中一次只能有一个音频监听器组件。如果有一个以上的组件,或者在没有组件的情况下尝试播放声音,Unity 将在你的控制台日志中填满抱怨和警告。音频监听器组件还为任何 3D 声音效果提供精确的位置定位。

音频源

音频源组件就像一个扬声器,它控制用于播放任何声音效果的设置。如果剪辑是 3D 的,此对象的位置与音频监听器组件以及所选模式的相对位置决定了剪辑的音量。以下屏幕截图显示了音频源组件的各种设置,随后是它们的解释:

音频源

  • 音频剪辑:这是此音频源组件默认播放的音频文件。

  • 输出:对于复杂的音频效果,可以将 Unity 的新音频混合器对象之一放在这里。这些允许你在音频最终播放之前,对音频及其可能应用的效果或混合进行具体控制。

  • 静音:这是一种快速切换播放声音的音量开关的方法。

  • 绕过效果:这允许你切换应用于音频源组件的任何特殊滤镜。

  • 绕过听众效果:这允许音频忽略可能应用于音频监听器的任何特殊效果。这对于不应该被世界扭曲的背景音乐来说是一个好的设置。

  • 绕过混响区域:这允许你控制是否让混响区域(控制环境音频的过渡区域)影响声音。

  • 唤醒时播放:这将导致音频剪辑在场景加载或对象生成时立即开始播放。

  • 循环:这将导致播放的剪辑在播放时重复。

  • 优先级:这决定了播放文件的相对重要性。值0表示最重要的,最适合音乐,而256表示最不重要的文件。根据系统不同,一次只能播放如此多的声音。播放文件的列表从最重要的开始,当达到这个限制时结束,如果有更多的声音超过限制,则排除那些值最低的。

  • 音量:这决定了剪辑播放时的音量大小。

  • 音调:这缩放了剪辑的播放速度。

  • 立体声平衡:这调整了声音在左右扬声器中均匀输出的程度,向左或右扬声器倾斜。

  • 空间混合:这是应用于音频源组件的 3D 效果的百分比。这影响诸如衰减和多普勒效应等因素。

  • 混响区域混合:(混响区域用于创建环境音频效果之间的过渡。)这个设置让你调整这些区域将对来自这个音频源的声音产生多大影响。Audio Source

前述截图中的设置如下:

  • 3D 声音设置:这包含了一组特定于播放 3D 音频剪辑的设置。音量空间扩散混响选项可以通过使用组末的图表进行调整。这允许你创建更动态的过渡,当玩家接近音频源组件时:

    • 多普勒级别:这决定了移动声音需要应用多少多普勒效应。多普勒效应是当声源向你靠近或远离你时,你所经历音调的变化。一个典型的例子是一辆汽车在疾驰而过时鸣喇叭。

    • 音量衰减:这控制了声音随距离减小的音量。有三种类型的衰减:

      • 对数衰减:这是在声源中心较近的距离处声音突然快速衰减。

      • 线性衰减:这是一种与距离成正比的衰减方式,声音最大值为最小距离,最小值为最大距离

      • 自定义衰减:这允许你通过调整组末的图表来创建自定义衰减。当图表被更改时,它也会自动被选择。

    • 如果音频监听器组件比最小距离值更近,音频将以当前音量水平播放。在此距离之外,声音将根据衰减模式逐渐减小。

    • 扩散:这调整了声音在扬声器空间中覆盖的区域量。当使用一个以上的扬声器时,它变得更加重要。

    • 超过最大距离值后,声音将停止过渡,基于组底部图表的情况。

添加背景音乐

既然我们已经了解了可用的音频设置,现在是把知识付诸实践的时候了。我们将从添加一些背景音乐开始。这将必须是一个 2D 音效,这样无论音频源组件在哪里,我们都能舒适地听到它。我们还将创建一个简短的脚本来淡入音乐,以减少音效对玩家突然而至的冲击。我们将使用以下步骤来完成这个任务:

  1. 我们将从创建一个新脚本开始,并将其命名为FadeIn

  2. 这个脚本从四个变量开始。第一个变量是脚本需要达到的目标音量。第二个是过渡所需秒数。第三个变量是过渡开始的时间。最后一个变量跟踪与脚本同一对象上附加的音频源组件,允许我们定期更新它,如下所示:

    public float maxVolume = 1f;
    public float fadeLength = 1f;
    private float fadeStartTime = -1f;
    private AudioSource source;
    
  3. 接下来,我们利用Awake函数。它首先检查是否有附加的音频源组件,并用它来填充我们的source变量。如果找不到,则销毁游戏对象并退出函数:

    public void Awake() {
      source = gameObject.GetComponent<AudioSource>();
      if(source == null) {
        Destroy(gameObject);
        return;
      }
    
  4. Awake函数通过将音量设置为0来结束,并在尚未播放时开始播放音频:

    source.volume = 0;
    
    if(!source.isPlaying)
      source.Play();
    }
    
  5. 为了随时间引起过渡,我们使用Update函数。它会检查fadeStartTime变量的值是否小于零,如果是,则将其设置为当前时间。这样可以避免场景初始化可能引起的卡顿:

    public void Update() {
      if(fadeStartTime < 0)
        fadeStartTime = Time.time;
    
  6. 接下来,函数检查过渡时间是否已经结束。如果结束了,将音频源组件的音量设置为maxVolume,并销毁脚本以释放资源:

    if(fadeStartTime + fadeLength < Time.time) {
      source.volume = maxVolume;
      Destroy(this);
      return;
    }
    
  7. 最后,通过计算自淡入开始以来经过的时间和过渡的长度之间的比例,来计算当前的进度。进度的百分比乘以maxVolume的值,并应用于音频源组件的音量:

    float progress = (Time.time – fadeStartTime) / fadeLength;
    source.volume = maxVolume * progress;
    }
    
  8. 回到 Unity,我们需要创建一个新的空游戏对象并将其命名为Background

  9. 将我们的FadeIn脚本和一个音频源组件添加到我们的对象中;可以通过导航到组件 | 音频 | 音频源来找到这些。

  10. 如果你还没有这样做,请在你的 项目 面板中创建一个 Audio 文件夹,并导入本章 起始资源 文件夹中包含的音频文件。由于这些文件体积小,且当前游戏的需求,它们的默认导入设置将完全适用。

  11. 层次结构 窗口中选中你的 Background 对象,并将 Background 音频拖到 AudioClip 插槽。

  12. 确保在 音频源 组件中勾选了 唤醒时播放循环 复选框。音量空间混合 选项也需要设置为 0,以使文件在游戏中全程播放,但在开始时不会发出声音。

我们为游戏添加了背景音乐。为了让声音保持恒定且不具有方向性,我们将音乐作为 2D 声音使用。我们还创建了一个脚本,以便在游戏开始时渐入音乐。这为玩家提供了平滑过渡到游戏的方式,防止声音的突然冲击。如果你的背景音乐太大以至于无法听到游戏中的其他声音,请在你的 Background 对象的 检查器 面板中降低 最大音量 值,以获得更愉悦的体验。

背景音乐对游戏体验有很大的贡献。没有一些恐怖的音乐,恐怖场景几乎就不那么可怕了。没有那令人敬畏的音乐,老板们也就显得不那么威严了。为你的其他游戏寻找一些好的背景音乐。对于 愤怒的小鸟 来说,一些轻松愉快的音乐非常适合;而对于坦克大战游戏,则应该选择一些更具工业感且快节奏的音乐,以保持心跳加速。

戳香蕉

为了理解 3D 音频效果,我们将为香蕉添加一个声音,每当玩家触碰它们时就会触发。这将使玩家在成功触摸到香蕉时获得额外的反馈,同时还能指示被触摸香蕉的距离和方向。让我们按照以下步骤来创建这个效果:

  1. 首先,我们需要一个名为 BananaPoke 的新脚本。

  2. 这个脚本有一个变量 source,用于跟踪附加到对象上的 音频源 组件:

    private AudioSource source;
    
  3. 与我们之前的脚本一样,我们使用 Awake 函数找到对 音频源 组件的引用,为我们节省了一些在编辑器中的工作:

    public void Awake() {
      source = gameObject.GetComponent<AudioSource>();
    }
    
  4. 当玩家在屏幕上触摸香蕉时,会向香蕉发送一条消息,调用Touched函数。我们在第六章《移动设备的特性——触摸和倾斜》中创建的BananaBounce脚本中使用了这个函数来调整其生命值。如果我们有音频源组件,可以再次使用它来播放音效。PlayOneShot函数使用音频源组件的位置和设置来播放快速音效。如果没有这个,我们将无法从同一个音频源组件中快速连续播放许多音效。我们需要传递给它的只是要播放的音频剪辑。在这种情况下,音频剪辑已经附加到音频源组件本身:

    public void Touched() {
      if(source != null)
        source.PlayOneShot(source.clip);
    }
    
  5. 然后,我们需要在项目面板中将新的脚本和音频源组件添加到Banana预设中。

  6. 需要将BananaPoke声音文件从Audio文件夹拖拽到新的音频源组件的音频剪辑槽中。

  7. 为了让游戏一开始不会听到烦人的爆音,取消勾选唤醒时播放选项。

  8. 接下来,我们想要听到触摸香蕉时距离上的差异。将空间混合设置改为1,以便将 2D 音效转变为 3D 音效。

  9. 最后,我们需要将音量衰减的值更改为线性衰减,并将最大距离设置为50。这让我们根据距离舒适且容易地听到音效的音量变化。

在 3D 世界中,我们期望大多数声音来自一个特定的方向,并且随着距离的增加而衰减。在 3D 游戏中创建类似效果,玩家能够轻松判断游戏世界中事物的位置以及它们可能有多远。这对于需要玩家能够听到潜在的敌人、障碍物或奖励的游戏尤为重要,以便他们能够找到或避开它们。

我们的坦克大战游戏有许多可以轻易潜行接近我们的敌人,因为它们在接近时没有声音。坦克通常不被认为是安静的机器。找一个引擎轰鸣声或者制作一个,并将其添加到敌方坦克中。这将给玩家一些关于敌人可能在哪里以及他们有多远的指示。此外,不同类型的坦克有不同的引擎类型。每个引擎的声音都有点不同。因此,在处理这件事时,为每种类型的坦克找到不同的引擎噪音,给玩家提供更多关于角落处可能存在的危险指示。

了解粒子系统

粒子系统为游戏的最终外观增添了很多效果。它们可以表现为火、魔法波、雨或许多其他你能想到的效果。它们通常很难制作得很好,但如果做得好,它们是值得努力的。特别是在使用移动平台时,请记住,少即是多。较大的粒子比大量粒子更有效。如果你的粒子系统在一个小空间内包含成千上万的粒子,或者为了增强效果而复制自身,你需要重新考虑设计并找到更有效的解决方案。

粒子系统设置

每个粒子系统都包含大量组件,每个组件都有自己的设置。大多数可用的设置有常量曲线两个常量之间的随机两个曲线之间的随机等选项。常量选项将是一个特定的值。曲线选项将是一个随时间沿曲线变化的设定值。两个随机设置在相应的值类型之间选择一个随机值。这在一开始可能看起来有些令人困惑,但随着你使用它们,它们会变得更加易懂。

正如你将在下面的屏幕截图和描述中看到的,我们将逐一了解粒子系统的每个部分:

粒子系统设置

  • 粒子系统中的第一部分,即初始模块,包含了 Unity 中每个发射器使用的所有设置:

    • 持续时间:这表示发射器持续的时间。循环系统在此时间后会重复自己。非循环系统在此时间后停止发射新粒子。

    • 循环:这个复选框决定了系统是否循环。

    • 预加热:如果勾选此复选框,如果循环系统已经有机会循环一段时间,它将开始循环。这对于应该已经点燃的火把来说很有用,而不是在玩家进入房间时开始。

    • 启动延迟:当粒子系统首次触发时,这将阻止粒子系统在给定的秒数内发射粒子。

    • 起始生命周期:这是一个单独的粒子将持续的秒数。

    • 起始速度:这是粒子生成时最初移动的速度。

    • 起始大小:这决定了粒子生成时的大小。使用较大的粒子总是比使用较小的粒子更好,因此需要更多的粒子。

    • 起始旋转:这将旋转发射的粒子。

    • 起始颜色:这是粒子生成时的颜色色调。

    • 重力修改器:这会给粒子一个更大或更小的重力效果。

    • 继承速度:如果粒子系统在移动,这将导致粒子获得其变换动量的一部分。

    • 模拟空间:这决定了粒子是随游戏对象移动而移动(即局部)还是保持在它们在世界中的位置。

    • 唤醒时播放:如果勾选此复选框,发射器将在生成或场景开始时立即开始发射粒子。

    • 最大粒子数:这限制了该系统在单一时间内支持的粒子总数。只有当粒子的发射速率(或其生命周期)足够大以至于超过其销毁速率时,这个值才会起作用。

    粒子系统设置

  • 发射模块控制粒子的发射速度:

    • 速率:如果设置为时间,它表示每秒创建的粒子数。如果设置为距离,它表示系统移动时每单位距离的粒子数。

    • 爆发:这仅在将速率选项设置为时间时使用。它允许你在系统的时序中设置特定数量的粒子发射的点。

    粒子系统设置

  • 如前一个截图所示,形状模块控制系统如何发射粒子。它具有以下选项:

    • 形状:这决定了发射点将采取的形式。每个选项都附带一些决定其大小的附加值字段。

    • 球体:这是粒子向所有方向发射的点。半径参数决定了球体的大小。从壳体发射选项指定粒子是从球体表面发射还是从球体内部体积发射。

    • 半球体:顾名思义,这是球体的一半。半径参数和从壳体发射选项在这里与球体的工作方式相同。

    • 圆锥体:这在一个方向发射粒子。角度参数决定形状更接近圆锥体还是圆柱体。半径参数决定了形状发射点的大小。当选项设置为体积体积壳体时,使用长度参数来指定可用于生成粒子的空间量。选项将决定粒子从哪里发射。基础从形状的底圆盘发射。基础壳体选项从圆锥体的底部但在形状的表面周围发射。体积将从形状内部的任何位置发射,而体积壳体从形状的表面发射。

    • 盒子:这从类似立方体的形状发射粒子。盒子 X盒子 Y盒子 Z选项决定了盒子的大小。

    • 网格:这允许你选择一个模型作为发射点。然后你可以选择从组成网格的每个顶点三角形发射粒子。

    • 圆形:这从单个点沿 2D 平面发射粒子。半径决定了发射的大小,弧度决定了使用圆的多少。从边缘发射决定粒子是从圆的内边缘还是外边缘发射。

    • 边缘:这会沿着一条线从单一方向发射粒子。半径参数决定了发射区域的长度。

    • 随机方向:这决定了粒子的方向是由所选形状的表面法线确定,还是随机选择。

    粒子系统设置

  • 生命周期内速度变化模块允许你在粒子生成后控制它们的动量:

    • XYZ:这些定义了粒子动量沿每个轴的每秒单位数。

    • 空间:这决定了速度是局部应用于系统的变换还是相对于世界。

    粒子系统设置

  • 生命周期内限制速度模块如果粒子的移动超过指定值,则会减弱其移动:

    • 独立轴:这允许你为每个轴定义一个独特的值,以及该值是局部的还是相对于世界的。

    • 速度:这是粒子在施加阻尼之前需要移动的速度。

    • 阻尼:这是粒子速度减少的百分比。它的值可以是零到一之间的任何值。

    粒子系统设置

  • 生命周期内力变化模块为每个粒子在其生命周期内添加一个恒定的移动量:

    • XYZ:这些定义了需要沿每个轴施加的力。

    • 空间:这决定了力是局部应用于系统的变换,还是在世界空间中应用。

    • 随机化:如果XYZ是随机值,这将导致每一帧随机选择施加的力的大小,从而产生随机值的统计平均。

    粒子系统设置

  • 生命周期内颜色变化模块允许你为粒子在生成后过渡的一系列颜色进行定义。

  • 按速度着色模块导致粒子在其速度变化时通过定义的颜色范围过渡:

    • 颜色:这是过渡的一系列颜色。

    • 速度范围:这定义了粒子必须达到的速度,以便在颜色范围的最小和最大端。

    粒子系统设置

  • 生命周期内尺寸变化模块会改变粒子在其生命周期内的尺寸。

  • 按速度调整尺寸模块根据粒子的速度调整每个粒子的大小,如下所示:

    • 尺寸:这是粒子过渡时调整的大小。

    • 速度范围:这定义了尺寸值的每个最小和最大值。

    粒子系统设置

  • 生命周期内旋转模块在粒子被生成后随着时间的推移对粒子进行旋转。

  • 按速度旋转模块使得粒子在速度更快时旋转得更多:

    • 角速度:这是粒子旋转的每秒度数速度。

    • 速度范围:这是如果角速度值未设置为恒定时的最小和最大范围。

    粒子系统设置

  • 外部力模块增强了风区对象的影响效果。风区模拟了风对粒子系统和 Unity 中树木的影响。

  • 碰撞模块允许粒子与物理游戏世界发生碰撞和交互:

    • 如果设置为平面,你可以定义多个平面供粒子碰撞。这比世界碰撞的处理速度更快:

      • 平面:这是一个定义碰撞表面的变换列表。粒子只会与变换的本地、正 y 侧发生碰撞。任何在点另一侧的粒子将被销毁。

      • 可视化:这为你提供了将平面显示为实体表面或网格表面的选项。

      • 缩放平面:这调整了可视化选项的大小。它不会影响实际碰撞表面的尺寸。

      • 粒子半径:这用于定义用于计算粒子与平面碰撞的球体的大小。

    • 如果设置为世界,则粒子将与场景中的每个碰撞器发生碰撞。这对处理器来说可能是一个很大的负担。

      • 碰撞层:这定义了一个粒子可以与之碰撞的层列表。只有在此列表中勾选的层的碰撞器将用于碰撞计算。

      • 碰撞质量:这定义了此粒子系统的碰撞计算的精确度。选项将精确计算每一个粒子的碰撞。选项将使用近似值,并在每个帧中限制新的计算次数。选项的计算频率低于选项。如果碰撞质量设置为,则体素大小参数决定了系统估算碰撞点的精确度。

    • 阻尼:当粒子与表面碰撞时,这会从粒子中移除定义的比例速度。

    • 弹跳:这允许粒子保持其定义的速度比例,特别是沿着被撞击表面的法线方向。

    • 生命周期损失:这是生命周期的百分比。当粒子发生碰撞时,会从这个百分比中移除粒子的生命周期。随着时间的推移,或者通过碰撞,粒子的生命周期降至零时,它将被移除。

    • 最小销毁速度:如果粒子在碰撞后的速度低于这个值,粒子将被销毁。

    • 发送碰撞消息:如果勾选此复选框,则附加到粒子系统以及与之发生碰撞的对象上的脚本将在每一帧被告知发生碰撞。每帧只发送一条消息,而不是每个粒子。

    粒子系统设置

  • 子发射器模块允许在粒子系统的每个粒子的生命周期中的点产生额外的粒子系统:

    • 出生列表中的任何粒子系统将在粒子首次创建时产生,并跟随粒子。这可以用来创建火球或烟雾轨迹。

    • 碰撞列表在粒子撞击某物时产生粒子系统。这可以用于雨滴飞溅效果。

    • 死亡列表在粒子被销毁时产生粒子。它可以用来产生烟花爆炸效果。

    粒子系统设置

  • 纹理图动画模块使得粒子在其生命周期内翻动一系列的粒子。所使用的纹理在渲染器模块中定义:

    • 瓷砖:这定义了图中的行数和列数。这将决定可用的总帧数。

    • 动画:这为您提供了整张图单行的选项。如果此选项设置为单行,则所使用的行可以随机选择或通过使用随机行复选框和的值来指定。

    • 随时间帧:这定义了粒子在帧之间的过渡方式。如果设置为常数,系统将只使用一个帧。

    • 循环:这是粒子在其生命周期内循环动画的次数。

    粒子系统设置

  • 渲染器模块决定了每个粒子在屏幕上的绘制方式,如下所示:

    • 渲染模式:这定义了粒子在游戏世界中定位自己的方法:

      • 广告牌:这将始终直接面向相机。

      • 拉伸广告牌:这将使粒子面向相机,但会根据相机的速度、粒子的速度或特定值来拉伸它们。

      • 水平广告牌:这在游戏世界的 XZ 平面上是平的。

      • 垂直广告牌:这将始终面向玩家,但沿 Y 轴始终保持直立。

      • 如果设置为网格,您可以定义一个模型作为粒子使用,而不是平面。

    • 法线方向:这用于通过调整每个平面的法线来对粒子进行光照和阴影处理。值为1时,法线直接指向相机,而值为0时,法线指向屏幕中心。

    • 材质:这定义了用于渲染粒子的材质。

    • 排序模式:这决定了绘制粒子的顺序,按距离或年龄排序。

    • 排序微调:这导致粒子系统比正常情况下更早地被绘制。值越高,它将在屏幕上越早被绘制。这影响了系统是出现在其他粒子系统或部分透明物体的前面还是后面。

    • 投射阴影:这决定了粒子是否能够阻挡光线。

    • 接收阴影:这决定了粒子是否会被其他物体投射的阴影影响。

    • 最大粒子尺寸:这是单个粒子允许占满的屏幕空间总量。无论粒子的实际大小如何,它都不会占据超过这个屏幕空间。

    • 排序层层内顺序:这些在使用 2D 游戏时很有用。它们分别决定了粒子处于哪个层级以及在该层级中的绘制位置。

    • 反射探针:这些也可以用来反射世界,而不仅仅是粒子。当反射的是世界而不是粒子时,可以使用锚点覆盖来定义一个自定义的位置来采样反射。

这里有大量的信息。你将最常使用初始发射形状模块。它们控制任何粒子系统的主要特性。其次,你可能会使用渲染器模块来改变粒子系统所使用的纹理,以及生命周期颜色模块来调整褪色效果。当这些部分有效地结合在一起时,将为你的游戏带来非常棒的效果,完善游戏的外观。学习它们能做什么的最好方法就是玩转这些设置,看看会发生什么。实验和一些教程,比如接下来的几节,是成为粒子系统创建专家的最佳途径。

创建灰尘轨迹。

为了让玩家更好地感受到角色实际上是处于世界中并与世界接触的,他们常常被赋予在环境中移动时能够踢起小灰尘云的能力。这是一个小效果,但为任何游戏增添了不少润色。我们将给我们的猴子球增加踢起小灰尘云的能力。让我们按照以下步骤进行:

  1. 首先,我们需要创建一个新的粒子系统,通过导航到GameObject | Particle System。将其命名为DustTrail

  2. 默认情况下,粒子系统会以圆锥形状发射小白球。对于灰尘效果,我们需要更有趣的东西。将本章Starting Assets文件夹中的纹理导入到你的项目中的Particles文件夹里。这些是由 Unity 提供的粒子纹理,它们在引擎的旧版本中出现过。

  3. 接下来,我们需要在Particles文件夹中创建一个新的材质。将其命名为DustPoof

  4. 要更改新材质的Shader属性,请转到Particles | Alpha Blended,并将DustPoof纹理放入Particle Texture图像槽中。这样可以将材质设置为部分透明,并且能够与世界以及其他正在发射的粒子良好融合。

  5. 要更改我们的DustPoof粒子系统的外观,请将材质放入Renderer模块的Material槽中。

  6. 系统中的粒子存在时间过长且移动距离太远,因此将Start Lifetime设置为0.5Start Speed设置为0.2。这样粒子会在消失前仅从地面稍微升起一点。

  7. 我们还需要使粒子更适合我们猴子的大小。将Start Size设置为0.3,以使它们大小适中。

  8. 看到所有粒子都是完全相同的方向有点奇怪。为了使方向不同,将Start Rotation更改为Random Between Two Constants,方法是点击输入字段右侧的小下拉箭头。然后,将两个新的输入字段设置为-180180,使所有粒子具有随机的旋转。

  9. 粒子的棕色是可行的,但并不总是与我们的关卡地形的颜色和性质相匹配。点击Start Color旁边的颜色字段,并使用弹出的Color Picker窗口选择基于环境的新颜色。这将使粒子在从游戏场地表面被踢起时更有意义。

  10. 最后,对于Initial模块,我们需要将Simulation Space设置为World,这样粒子就会随着猴子移动而留在原地,而不是跟随他。

  11. Emission中,我们需要确保有足够的粒子以产生适量的扬尘。将Rate设置为20以产生轻微的扬尘效果。

  12. 接下来,我们将调整Shape模块,使粒子能够在球的整个区域下发射。确保将Shape设置为ConeAngle设置为25Radius设置为0.5

  13. 使用颜色随生命周期变化模块,我们可以平滑粒子的突然出现和消失。点击模块名称左侧的复选框以激活它。点击颜色右侧的白条,打开渐变编辑器窗口。在渐变编辑器中,点击颜色条上方将添加一个新的标志,该标志将控制粒子在其生命周期内的透明度。此条形的左侧对应于粒子生命的开始,右侧对应于粒子生命的结束。我们需要总共四个标志。最开始的标志,将Alpha值设置为0,第二个标志,位置值为20Alpha值为255,第三个标志在位置50处,Alpha255,最后一个标志在最后,Alpha值为0。这将使尘埃粒子在开始时快速淡入,之后慢慢淡出,平滑它们的出现和消失过渡。创建尘埃轨迹

  14. 我们可以通过使用大小随生命周期变化模块,使粒子在出现和消失时增大和缩小,从而进一步平滑过渡。确保通过其名称旁边的复选框激活它。点击大小右侧的曲线条,粒子系统曲线编辑器将在检查器面板底部的预览区域中打开。在这里,我们可以调整任何小钻石形状的键,以控制粒子在其生命周期中的大小。与渐变编辑器的情况一样,左侧是粒子生命的开始,右侧是结束。右键点击它,我们可以添加新的键来控制曲线。要创建弹出效果,请将第一个键放在最左侧底部。第二个键应该放在顶部,与底部的0.2值相对应。第三个在顶部和底部的0.4值处效果很好。第四个应该在最右侧,大约设置为左侧的0.6,这些数字表示我们在初始模块中设置的开始大小的百分比,如下截图所示:创建尘埃轨迹

  15. 最后,为了完成我们的粒子系统的外观,我们将使用旋转随生命周期变化模块,为粒子增加一点旋转。将值更改为两个常数之间的随机值,并将两个值字段设置为-4545,使粒子在其生命周期中稍微旋转。

  16. 为了让我们的猴子使用粒子系统,将其设置为MonkeyPivot对象的子对象,并将其位置设置为X0Y-0.5Z0。同时,确保旋转设置为X270Y0Z0。这将使其位于猴子球的底部并向空中抛出粒子。由于它是MonkeyPivot的子对象,它不会随着球的旋转而旋转,因为我们已经使对象补偿了球的旋转。

  17. 尝试一下。当我们的猴子四处移动时,他在身后留下了一条很好的灰尘轨迹。如果根据关卡的材质进行定制,这种效果可以非常出色,无论是草地、沙地、木材、金属还是其他任何材质。

  18. 你可能会注意到,即使我们的猴子从地图边缘飞出去,效果仍然在持续播放。我们将创建一个新脚本来根据猴子球是否真正接触地面来切换粒子效果。现在创建一个名为DustTrail的新脚本。

  19. 这个脚本的第一个变量将保存对我们试图控制的粒子系统的引用。第二个变量将是一个标志,表示球是否真正接触地面:

    public ParticleSystem dust;
    private bool isTouching = false;
    
  20. 我们使用OnCollisionStay函数来判断球是否触碰到了任何物体。这个函数与上一章中使用的OnCollisionEnter函数类似。不过,那个函数是在我们的鸟撞击到某物的那一刻被 Unity 调用的,而这个函数则是在每一帧球持续接触另一个碰撞体时被调用。当它被调用时,我们只需设置一个标志来标记我们正在触碰某物:

    public void OnCollisionStay() {
      isTouching = true;
    }
    
  21. 因为物理系统只在FixedUpdate循环中改变,所以我们使用这个函数来更新我们的粒子系统。在这里,我们首先检查是否正在触碰某物,并且粒子系统当前没有发射任何东西,这由其isPlaying变量指示。如果条件满足,我们使用Play函数开启粒子系统。然而,如果球没有触碰任何物体,并且粒子系统当前正在播放,我们使用Stop函数来关闭它:

    public void FixedUpdate() {
      if(isTouching && !dust.isPlaying) {
        dust.Play();
      }
      else if(!isTouching && dust.isPlaying) {
        dust.Stop();
      }
    
  22. FixedUpdate函数的最后,我们将标志设置为false,这样它就可以在下一帧更新我们是否需要开启或关闭粒子系统:

      isTouching = false;
    }
    
  23. 接下来,将新脚本添加到MonkeyBall对象上。正如上一章所学的,如果我们没有将它附加到与球的Rigidbody组件相同的对象上,我们将无法接收到使脚本正常工作的碰撞信息。

  24. 最后,将你的DustTrail粒子系统拖放到Dust槽中,这样你的脚本才能真正控制它。

  25. 再试一次。现在我们的猴子可以轻松地四处移动并产生一些灰尘轨迹,直到它从关卡的边缘掉落,跳下平台,或者以其他方式悬在空中。创建灰尘轨迹

我们让我们的猴子球具有扬起灰尘的能力。我们还根据球是否真正接触地面来控制灰尘的开启和关闭。这个小效果使角色在游戏中显得更加脚踏实地。它还可以根据拖尾持续的时间,让你感受到角色的速度。

我们之前讨论过的使角色更加接地气的另一个好效果是阴影。如果你还没有这样做,请确保为你的环境添加一些阴影细节。不过,你可能注意到,由于球体部分透明,实时阴影无法在其上生效。这时,我们之前在坦克上使用的 blob 阴影就派上用场了。

即使球体没有移动,我们的效果也会持续运行。尝试调整粒子系统是否播放,基于其刚体组件的速度。在上一章中,我们稍微调整了刚体组件的速度,如果你需要复习可以看看。作为一个额外的挑战,查看粒子系统的emissionRate变量。尝试让球体速度加快时,效果产生更多的粒子。

组合在一起

到目前为止,我们学习了各自独立的声音效果和粒子系统。它们各自可以为场景增添很多,设定氛围,并赋予游戏独特的润色。然而,有许多效果是无法独立存在的。例如,爆炸效果,如果没有视觉和听觉效果的结合,就不会那么令人印象深刻。

爆炸的香蕉

当事物爆炸时摧毁它们会让人感到更加满足。要制造一次恰当的爆炸,需要同时具备粒子效果和声音效果。我们将从创建一个爆炸预设开始。然后,更新香蕉,使它们在摧毁时产生爆炸。以下步骤将帮助我们创建香蕉爆炸效果:

  1. 首先,我们需要创建一个新的粒子系统,并将其命名为Explosion

  2. 我们希望我们的爆炸效果看起来更像是一次真正的爆炸。这时,我们的第二个粒子纹理就发挥作用了。为其创建一个新材质,命名为Smoke

  3. 这次,通过选择粒子 | 附加来设置着色器属性。这将使用一种附加混合方法,使粒子整体看起来更亮,同时仍然将粒子的 alpha 与背后的物体混合。

  4. 确保将新材质的粒子纹理属性设置为Smoke

  5. 同时,将你的Smoke材质拖放到粒子系统的渲染器模块中的材质槽内。

  6. 我们不希望这次爆炸持续得太久。因此,在初始模块中,将持续时间设置为0.5,并将开始生命周期设置为1,使其比原来的时间短得多。

    注意

    当处理像爆炸这样短暂爆发的效果时,可能很难看出我们的更改如何影响粒子系统的外观。完成这个粒子系统后,我们将不得不取消勾选循环复选框,但现在保持勾选状态会使得查看和工作变得更加容易。

  7. 接下来,为了防止粒子飞得太远,将起始速度设置为0.5,使爆炸效果集中且局限于一个较小的区域。

  8. 为了让爆炸有足够的粒子,需在发射模块中将速率设置为120

  9. 为了让爆炸看起来更真实,需要在形状模块中将形状改为球体。同时,将半径设置为0.5。如果你对改变爆炸的大小感兴趣,可以调整半径发射速率。两者都增加会得到更大的爆炸效果,而两者都减少则得到较小的爆炸效果。

    注意

    这种基本的爆炸效果仅仅是一种视觉上的爆炸,大多数情况都是如此。要制作根据环境改变或受环境影响而改变外观的爆炸效果,将需要额外的脚本编写和模型考虑,这超出了本书的范围。

  10. 我们游戏中的爆炸效果仍然不像真正的爆炸,所有的粒子都从边缘突然出现。这时就需要用到生命周期颜色模块。首先,我们需要通过在 alpha 通道添加新标志来消除粒子的突现。在大约边缘向内20%的位置添加两个新标志,并调整所有四个标志,使粒子在开始时淡入,结束时淡出。

  11. 渐变编辑器的渐变条底部的标志控制粒子在其生命周期中过渡的颜色。为了得到一个像样的爆炸效果,我们需要再添加两个标志,一个放在三分之一的位置,另一个放在三分之二的位置,将所有四个标志均匀地间隔开。爆炸通常开始时颜色较亮,接着在爆炸能量达到顶峰时颜色更亮,然后随着能量开始消散时颜色再次变亮,最后能量完全消失时为黑色。你选择的每种颜色都会影响爆炸的颜色。对于普通爆炸,可以选择黄色和橙色。对于科幻空间爆炸,可以选择蓝色或绿色。或者,如果是异形孢子云,可以使用紫色。发挥你的想象力,选择适合你想要爆炸效果的色彩。![爆炸的香蕉]

  12. 现在我们已经设置好所有参数,确保勾选了Play On Awake,这样爆炸在创建的那一刻就会开始,并取消勾选Looping,这样它就不会永远播放。如果你想在这个时候测试你的粒子系统,可以查看当选择任何粒子系统时,在Scene窗口右下角出现的StopSimulatePause按钮。这些按钮就像你的音乐播放器按钮一样,控制粒子系统的播放。

  13. 如果我们现在开始创建爆炸效果,它们在生成初始粒子群后会仅仅停留在场景中,尽管玩家永远看不到它们。这就是为什么我们需要一个新的脚本来在它们完成作用后摆脱它们。创建一个新的脚本,并将其命名为Explosion

  14. 这个脚本有一个单一的变量,即跟踪表示其存在的粒子系统:

    public ParticleSystem particles;
    
  15. 它也只有一个函数。Update函数每一帧都会检查粒子系统是否存在或者是否已经停止播放。在任一情况下,整体对象都会被销毁,这样我们可以节省资源:

    public void Update() {
      if(particles == null || !particles.isPlaying)
        Destroy(gameObject);
    }
    
  16. 然后,我们需要将我们的新脚本添加到Explosion对象中。同时,将Particle System组件拖到Script组件中的Particles槽位。

  17. 为了让爆炸声能被听到,我们还需要在Explosion对象上添加一个Audio Source组件。

  18. 确保勾选了其Play On Awake选项。为了让声音在 3D 空间中有意义,将Spatial Blend属性设置为1。同时,设置为Linear Rolloff,并将Max Distance设置为50,这样我们可以听到它。

  19. 我们的香蕉拥有和汽车一样的爆炸声音是没有意义的。相反,我们有一个很好的小爆裂声,这将使最终效果与那些仅仅减少香蕉健康值的效果区分开来。为此,在Audio Source组件的AudioClip槽位上设置BananaPop音频文件。

  20. 在我们设置好所有爆炸参数后,使用Explosion对象创建一个新的预制体,并将其从场景中删除。

  21. 接下来,我们需要更新BananaBounce脚本,当它失去健康时实际生成爆炸效果。现在打开它。

  22. 首先,在脚本开始部分添加一个新的变量。这将简单地跟踪我们希望在香蕉失去健康后生成的预制体:

    public GameObject explosion;
    
  23. 接下来,我们需要在Touched函数中使用Destroy函数后立即添加一行。这行代码仅仅在香蕉的位置创建一个新的爆炸实例:

    Instantiate(explosion, transform.position, transform.rotation);
    
  24. 最后,在Project面板中找到你的Banana预制体,并将Explosion预制体拖到新的Explosion槽位中。如果你不这样做,将永远不会创建爆炸效果,而且每当香蕉失去健康时 Unity 都会报错。爆炸的香蕉

如前所述截图所示,我们已经创建了一个爆炸效果。在 Unity 旧的粒子系统的一些纹理的帮助下,我们让它看起来像真正的爆炸,而不是仅仅是一团彩色的球。我们还为爆炸效果添加了声音。结合粒子系统和音频源,我们可以创建许多效果,比如我们的爆炸效果,如果只使用其中一种,效果就会显得较弱。我们还更新了香蕉,使其在被玩家摧毁时产生爆炸。尝试调整香蕉音频的平衡,每次触摸香蕉之间的音量差异以及爆炸本身。我们通过粒子系统在视觉上和通过音频源在听觉上为玩家提供的信息越多,效果就会越好。

香蕉并不是这个世界上唯一可以爆炸的东西。在我们的第二款游戏中,我们摧毁的坦克只是消失了。尝试为《坦克大战》游戏添加一些新的爆炸效果。每次坦克被摧毁时,都应该以壮观的方式爆炸。此外,无论坦克的炮弹击中什么,炮弹往往会爆炸。尝试在炮弹射击点产生爆炸效果,而不是移动红色球体。这将给玩家更好的射击目标和感觉。

《愤怒的小鸟》游戏也可以加入一些爆炸效果,尤其是黑色的小鸟。每当有东西被摧毁时,都应该释放出一些粒子效果,并产生一些声响。否则,当物体突然消失时,游戏会看起来有些奇怪。

总结

在本章中,我们了解了 Unity 中的特效,特别是音频和粒子系统。我们从了解 Unity 如何处理音频文件开始。通过为球添加背景音乐和一些吱吱声,我们将所学内容付诸实践。然后我们继续了解粒子系统,并为球创建了尘埃轨迹。最后,我们将这两种技能结合在一起,为收集香蕉时创建爆炸效果。粒子系统和音频效果为游戏的最终润色和外观增添了很多。

在下一章中,我们将通过查看 Unity 中的优化来共同完善我们的游戏体验。我们将了解一些用于追踪性能的工具。我们还将创建自己的工具来追踪脚本特定部分的性能。我们将探讨资源压缩以及我们可以更改的其他点以最小化应用程序的占用空间。最后,将讨论在使用游戏和 Unity 时最小化延迟的关键点。

第九章:优化

在上一章中,我们学习了关于游戏特效的知识。我们为 Monkey Ball 游戏添加了背景音乐。我们还为我们的猴子创建了尘埃轨迹。通过结合音频效果和粒子系统,当玩家收集香蕉时我们创建了爆炸效果。这些共同丰富了游戏体验,使我们的游戏看起来非常完整。

在本章中,我们将探讨优化的各种选项。我们从应用程序占用空间着手,探讨如何减少它,然后进一步查看游戏性能,最后探索可能导致延迟的关键区域,以及如何减少它们的影响。

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

  • 最小化应用程序占用空间

  • 跟踪性能

  • 减少延迟

  • 遮挡剔除

在本章中,我们将同时处理我们的 Monkey Ball 和 Tank Battle 游戏。首先打开 Monkey Ball 项目来开始本章的学习。

最小化应用程序占用空间

游戏成功的关键之一在于游戏本身的大小。许多用户会迅速卸载那些看起来不必要的大的应用程序。此外,所有移动应用商店都根据应用程序本身的大小对游戏如何提供给用户设置了限制。熟悉缩小游戏大小的各种选项是控制游戏分发方式的关键。

当致力于最小化占用空间时,首先需要注意的是 Unity 在构建游戏时如何处理资源。只有那些在构建中至少一个场景中使用过的资源才会被实际包含在游戏中。如果资源不在场景本身或者不在场景中引用的资源中,那么它就不会被包含。这意味着你可以拥有资源的测试版本或不完整版本;只要它们没有被引用,它们就不会影响你游戏的最终构建大小。

Unity 还允许你以你需要的工作格式保存资源。当最终构建时,所有资源都会转换为适合其类型的适当版本。这意味着你可以将模型保存在与你的建模程序本机格式中,它们将在构建游戏时转换为 FBX 文件。否则,你可以将图像保存为 Photoshop 文件,或你工作的任何其他格式,并在构建游戏时适当转换为 JPG 或 PNG。

编辑器日志

当你准备好最终处理游戏的占用空间时,可以确切地找出导致游戏比预期更大的原因。在控制台窗口的右上角有一个下拉菜单按钮。这个菜单中有打开编辑器日志

编辑器日志

编辑器日志是 Unity 在运行时输出信息的位置。这个文件会记录有关当前 Unity 编辑器版本的信息,执行对你的许可证的任何检查,并包含一些关于你导入的资源的详细信息。日志还将包含有关构建后游戏中包含的文件大小和资源的详细信息。以下屏幕截图显示了编辑器日志的一个示例:

编辑器日志

在这里,我们可以看到最终构建方面的细分。每个资源类别都有其大小以及占总构建大小的百分比。同时,我们还获得了一个列表,列出了实际包含在游戏中的每个资源,按文件大小进行了组织,在添加到构建之前。当你寻找可以缩小的资源时,这些信息会非常有用。

资源压缩

在模型、纹理和音频的导入设置窗口中,有一些影响导入资源的尺寸和质量的选项。通常,受影响的是质量的降低。然而,特别是在为移动设备开发游戏时,资源质量可以在达到计算机所需水平以下很多,而不会在设备上注意到差异。一旦你了解了每种资源类型可用的选项,你将能够就游戏的质量做出最佳决策。在使用这些选项中的任何一个时,寻找一个在引入不需要的伪影之前能最小化尺寸的设置。

模型

无论你使用什么程序或方法来创建你的模型,最终总会有一个顶点位置和三角形的列表,以及一些对纹理的引用。模型的大部分文件大小来自顶点位置列表。为了确保你的游戏中的模型具有最高质量,从你选择的建模程序开始。删除所有额外的顶点、面和未使用的对象。这不仅能让你在构建最终游戏时得到较小的文件,还能减少你在编辑器中的导入时间。

模型的导入设置窗口由三个页面组成,提供了更多调整质量的选项。每个页面标签对应于模型的相应部分,允许你微调每一个部分。

模型标签页

模型标签页上,你可以影响网格的导入方式。在优化模型使用方面,这里有许多关键选项。一旦你的游戏看起来和玩起来的效果如你所愿,你应该始终仔细查看这些设置,看看是否能让它们工作得更好:

模型标签页

以下是模型标签页中的各种设置:

  • 缩放因子文件缩放:这些选项允许您控制模型的默认视觉大小。文件缩放参数是 Unity 在导入模型时计算的大小。缩放因子参数允许您调整 Unity 在导入模型时应用的额外缩放。

  • 网格压缩:此选项允许您选择对模型应用多少压缩。压缩效果相当于合并顶点以减少必须为网格存储的细节总量。如果过度使用此设置,可能会在网格中引入不希望出现的异常。因此,应始终选择不会引入任何伪影的最高设置。

  • 读写启用:此选项仅在您想在游戏运行时通过脚本操作网格时有用。如果您从未用任何脚本接触网格,请取消勾选此框。尽管这不会影响最终构建的大小,但它会影响运行游戏所需的内存量。

  • 优化网格:此选项使 Unity 重新排序描述模型的三角形列表。此选项始终是一个好的选择,应该勾选。唯一可能需要取消勾选的情况是,如果您基于三角形的特定顺序操作游戏或网格。

  • 导入混合形状:混合形状与普通动画中的关键帧相似,但它们作用于网格细节本身,而不是骨骼的位置。通过取消勾选此框,您可以节省游戏和项目中的空间,因为 Unity 将不需要计算和存储它们。

  • 生成碰撞器:此选项几乎总是建议不勾选。此选项将为模型中的每个网格添加网格碰撞器组件。这些碰撞器在处理游戏中的物理时计算相对昂贵。如果可能,您应该始终使用一组明显更简单的盒子碰撞器球体碰撞器

  • 交换 UV:Unity 支持具有两组 UV 坐标的模型。通常,第一组用于普通纹理,第二组用于物体的光照图。如果您生成自己的光照图 UV,Unity 可能会识别错误的顺序。勾选此框将强制 Unity 改变它们的使用顺序。

  • 生成光照图 UV:仅当您处理需要静态阴影的物体时,才应使用此选项。如果物体不需要,这将会引入过多的顶点信息并增加资源的大小。

  • 法线:此选项用于计算或导入法线信息。法线被材质用于确定顶点或三角形面向的方向以及光照应该如何影响它。如果网格从未使用需要法线信息的材质,请确保将其设置为

  • 切线:这个选项用于计算或导入切线信息。切线被材质用于通过凹凸贴图和类似的特效来模拟细节。就像法线设置一样,如果你不需要它们,就不要导入它们。如果法线设置为,这个设置会自动变灰,并且不再导入。

  • 平滑角度:在计算法线时,这个选项允许你定义两个面之间的角度需要多接近,才能在它们共享的边缘上平滑着色。

  • 分割切线:这会导致你的网格在 UV 接缝处重新计算切线。这对于修复高细节模型中的一些光照不规则性非常有用。

  • 保持四边形:Unity 通常会将所有面转换为三角形进行渲染。如果你使用 DirectX 11 进行渲染,这个选项将保持你的面作为四边形进行镶嵌。

  • 导入材质:这个选项允许你控制导入模型时是否创建新材质。如果取消勾选,导入时不会创建新模型。

  • 材质命名:这允许你控制导入的模型命名任何新创建的材质的方式。

  • 材质搜索:Unity 可以使用多种方法来查找已经创建的模型上要使用的材质。本地材质文件夹选项只会在导入模型的旁边名为Materials的文件夹中查找。递归向上选项会从模型所在的文件夹以及通过父级向上的根资源文件夹中查找。全项目选项会在整个项目中搜索具有正确名称的材质。

“绑定”标签页

如以下截图所示,动画绑定调整的选项非常少:

绑定标签页

在优化你的动画绑定时,你真正需要记住的只有两件事。第一,如果资源不进行动画处理,那么就不要导入它。将动画类型设置为,Unity 就不会尝试导入绑定或任何无用的动画。第二件需要记住的事情是移除所有不必要的骨骼。一旦导入 Unity,删除那些实际上对动画或角色没有影响的绑定中的所有对象。Unity 可以将你可能用于动画的反向运动学转换为正向运动学,因此在 Unity 启动后,可以删除用于它的引导。

那里的优化游戏对象复选框实际上并不帮助游戏的整体优化。它只是在层次窗口中隐藏额外的绑定对象,这样你就不必处理它们。当在编辑器中处理复杂的绑定时,这个复选框也可以非常有帮助。

“动画”标签页

绑定标签一样,如果模型没有动画,不要导入动画。在首次导入资源时取消勾选导入动画复选框,可以防止在 Unity 中向你的GameObject组件添加任何额外的组件。此外,如果任何额外的动画意外地被添加到你的最终构建中,它们可能会迅速使你的应用程序变得过大。以下截图突出了动画标签:

The Animations tab

  • 动画压缩:此选项调整 Unity 处理动画中多余关键帧的方式。在大多数情况下,默认选项效果很好。可用的各种选项如下:

    • 关闭:只有当你需要高精度动画时,才应使用此选项。这是最大且成本最高的设置选择。

    • 关键帧减少:此选项将根据以下错误设置减少动画使用的关键帧数量。本质上,如果一个关键帧对动画没有明显的影响,它将被忽略。

    • 最佳:此选项与上一个选项相同,但此外它还会压缩动画的文件大小。然而,在运行时,动画仍然需要与上一个选项相同的处理器资源来进行计算。

  • 旋转误差:此选项是在执行关键帧减少时,关键帧之间将被忽略的度数差。

  • 位置误差:此选项是在执行关键帧减少时,关键帧之间将被忽略的移动距离。

  • 缩放误差:此选项是在执行关键帧减少时,关键帧之间将被忽略的动画大小调整量。

纹理

很难想象一个高质量的游戏里面没有大量的图像。纹理有一系列选项来控制在使用游戏时保留多少细节。通常,最好选择不会在图像中引入明显瑕疵的最低质量设置。此外,最好使用大小为 2 的幂次的纹理以提高处理速度。而且,很少有处理器能够处理大于1024像素大小的纹理。通过将图像大小控制在或低于这个尺寸,你可以在最终游戏中节省大量的内存和空间。

Textures

  • 纹理类型:此选项影响图像将被视为哪种类型的纹理。最好选择最适合图像预期用途的类型。以下选项展示了可以使用各种类型的纹理:

    • 纹理:此选项是在处理 3D 游戏时最常见和默认的设置。这应该用于你的普通模型纹理。

    • 法线图:这个选项用于特殊效果,如凹凸贴图。使用这种类型纹理的材料还需要从模型的导入设置中获取法线和切线信息。

    • 编辑器 GUI 和旧版 GUI:除非你在使用特殊的编辑器脚本或其他特殊情况,否则你不会使用这个设置。这非常类似于精灵设置。

    • 精灵(2D 和 UI):这个选项在处理 2D 游戏时是最常见和默认的设置。这应该始终用于你的平面 2D 角色和 UI 元素。

    • 光标:这个设置对我们的 Android 平台来说并不是特别相关。它允许你创建自定义鼠标指针,这对于大多数 Android 设备来说并不常见。

    • 立方体贴图:当你在处理自定义反射或天空盒类型的材质时,你的图像应该使用这个选项。这会自动将图像环绕,使其像球面或立方体的边缘一样重复。

    • Cookie:这些纹理用于灯光上,它们改变光线从光源物体的发射方式,就像我们用于坦克车头灯的那种。

    • 光照图:我们在坦克大战游戏中使用了 Unity 的光照图系统。然而,这个系统并不总是适用于所有情况。因此,当你需要在 Unity 外部制作自定义光照图时,请选择这个选项。

    • 高级:这个选项让你能够完全控制所有与导入图像相关的设置。只有当你对你的纹理有特殊用途或需要精确控制它们时,你才需要这个设置。

  • 读写启用:当纹理类型设置为高级时,此复选框可用。只有当你计划在游戏运行时通过脚本操作纹理时,才应该勾选此项。如果未勾选,Unity 不会在 CPU 上维护数据副本,从而为游戏的其他部分释放内存。

  • 生成 Mip Maps:这个选项是另一个高级设置,它允许你控制纹理较小版本的创建。当纹理在屏幕上显示得很小的时候,这些较小版本的纹理就会被使用,从而减少绘制纹理及其在屏幕上使用的对象所需的处理量。

  • 过滤模式:这个选项适用于所有纹理类型。它影响当你非常接近图像时图像的显示效果。点过滤会使图像看起来块状化,而双线性三线性则会模糊像素。通常,点过滤是速度最快的模式;三线性是速度最慢的模式,但能提供最佳视觉效果。

  • 最大尺寸:此选项调整图像在游戏中使用时可以有多大。这允许你处理非常大的图像,但以适当的小尺寸导入到 Unity 中。一般来说,大于1024的值都不是好选择,不仅因为内存需求增加,而且由于大多数移动设备根本无法处理更大的贴图。通常,1024 大小的纹理应该保留给你的主要角色和其他非常重要物体。对于中等和低重要性物体,在移动设备上 256 大小表现良好。对于你的所有物体,如果能将它们的纹理合并到共享的 1024 纹理中,它们对游戏的影响会比它们有单独的小纹理要小。选择尽可能小的尺寸将大大影响最终构建中纹理的占用空间。

  • 格式:此选项调整图像的导入方式以及每个像素可以保留的细节量。压缩格式最小,而真彩提供最多的细节。

音频

为游戏提供高品质的声音总是会增加游戏最终的大小。音频是游戏不可或缺的资产之一,但合适的包含水平可能难以把握。在音频程序中处理声音时,尽量保持简短,以减小其大小。此外,要考虑到大多数玩家并没有高级耳机或扬声器来听你的音频,因此在他们注意到差异之前,音频质量可以大幅度降低。音频导入设置都会影响它们在构建大小中的占用空间或运行游戏所需的内存。

音频

  • 强制单声道:此设置将多声道音频转换为单声道。虽然大多数设备技术上能够播放立体声,但它们并不总是具有让声音产生差异所需的多个扬声器。勾选此框可以显著减小音频文件的大小,通过将所有声道合并为单个较小的声道。多声道音频文件用于根据声音来自哪个扬声器来制造方向感的错觉。这实际上需要为每个扬声器使用单独的音效文件。单声道音频文件对所有扬声器使用相同的音效文件,因此在游戏中需要的数据和空间要少得多。

  • 后台加载预加载音频数据:这两个设置共同定义音频信息的加载和准备播放时间。后台加载参数决定游戏是否在其他游戏数据加载前等待文件加载完成。对于长或大的文件,如背景音乐,勾选此框是个好主意。预加载音频数据参数决定文件是否应尽快加载。对于你马上需要使用的任何音频剪辑,应该勾选这个选项。

  • 加载类型:此设置影响游戏运行时,系统内存将使用多少来处理音频文件的加载。加载时解压缩选项使用最大内存,最适合小而短的声音。内存中压缩选项仅在播放时解压缩文件,使用中等数量的内存,最适合中等大小的文件。流式传输选项意味着只有当前正在播放的文件部分存储在运行时内存中。这就像从互联网上流式传输视频或音乐。这个选项最适合大文件,但一次应该只由少数几个使用。

  • 压缩格式:这决定了要对音频文件应用哪种数据缩减,使其足够小以包含在游戏中。PCM格式将保留大部分原始音频,因此文件大小也将是最大的。ADPCM格式将提供中等程度的压缩,但也会因此降低一些质量。Vorbis格式可以为你提供尽可能小的文件大小,但以最大程度降低质量为代价。

  • 质量和采样率设置:这些控制当你应用前一个选项的压缩时,将保留多少细节。如果文件大小仍然过大,你可以降低整体质量以使其在可接受范围内。然而,降低质量会牺牲声音质量。在目标设备上出现可听见的伪迹之前,始终寻求最低的设置。

玩家设置

通过转到 Unity 的工具栏,导航到编辑 | 项目设置 | 玩家,打开你的游戏的玩家设置窗口。在针对 Android 的平台特定设置中,我们在其他设置下还有几个选项,这些选项将影响我们游戏的最终大小和速度。

渲染

渲染设置组控制你的游戏如何在屏幕上绘制游戏。这控制了使用的光照和阴影计算类型。它还允许你优化绘制构成游戏场景的许多对象所需的计算数量。以下是渲染窗口的截图:

渲染

渲染窗口中看到的设置如下:

  • 渲染路径:这一组选项主要控制光照和阴影渲染的质量。渲染路径下的选项如下:

    • 正向渲染:这将是你的最常见设置。它支持来自单个方向光的实时阴影。这个选项是 Unity 中渲染光照的正常基准。

    • 延迟渲染:这将为你提供最高质量的光照和阴影,但系统处理它的成本最高。并非每个系统都能支持它,而且它恰好是 Unity Pro 独有的功能。

    • 传统顶点光照:这种渲染方法是旧系统的一部分。它也是处理成本最低的方法。这种方法没有实时阴影,光照计算也高度简化。较旧的机器和移动设备将默认使用此模式。

    • 传统延迟渲染(光照预通过):这种方法也是旧系统的一部分。较新的延迟方法对此进行了高度改进,通常来说,不应当使用这种方法。只有在有特殊案例或需要支持特定平台时,你才需要选择这种方法。

  • 多线程渲染:运行程序的过程和步骤系列称为线程。可以启动许多这样的线程,并让它们同时处理程序的不同部分。Unity 利用了编程的这一点,以提高渲染系统的速度和质量。然而,这需要一个更强大的处理器才能有效运行。

  • 静态批处理:这是 Unity Pro 的一个功能,通过将标记为静态的相同对象分组,可以显著提高渲染速度。对于每组,它然后在一个地方渲染一个对象,而不是单独渲染每个对象。这个设置可能会增加最终构建的大小,因为 Unity 需要保存关于静态对象的额外信息以实现这一功能。

  • 动态批处理:这与静态批处理的工作方式相同,但有两个主要区别。首先,它适用于 Unity Pro 和 Basic 用户。其次,它将未标记为静态的对象分组。

  • GPU 蒙皮:这个设置对于较旧的移动设备不太适用,它更多地用于最新的移动设备和其他同时具有 CPU 和 GPU 的系统。这允许通常在网格上进行的计算,如通过骨骼进行动画和变形的计算,在 GPU 上进行而不是 CPU。这将释放资源以处理游戏的其他部分,为玩家提供最佳体验。

优化

优化设置组允许你调整 Unity 编译项目及涉及资源的方式。在接近游戏最终构建时,每个设置都应该仔细考虑。总的来说,这些设置有可能极大地影响你的游戏运行效果。以下是优化窗口的截图:

优化

  • API 兼容性级别:此设置决定了最终构建中包含哪一组.NET 函数。".Net 2.0"选项将包括所有可用的函数,产生最大的占用空间。".Net 2.0 子集"选项是函数的一小部分,仅包括你的编程最有可能使用的函数。除非你需要一些特殊功能,否则应始终选择".Net 2.0 子集"选项。

  • 预烘焙碰撞网格:此选项通过将物理计算从场景加载移动到游戏构建来节省你加载关卡时的时间。这意味着你的构建大小会增大,但处理速度会降低。

  • 预加载着色器:当一个网格使用尚未在游戏场景中使用的新着色器时,系统需要处理并计算该着色器将如何渲染物体。此选项将在场景开始时处理该信息,以避免在尝试进行计算时可能导致游戏停滞。

  • 预加载资源:此选项与之前的选项相同,但它是为着色器以外的资源和预制件而设的。当你首次实例化一个对象时,它需要被加载到内存中。这将改变为在场景开始时加载此列表中的所有资源。

  • 剥离级别:此设置是 Unity Pro 版独有的功能。它允许你在编译前通过移除所有多余的代码来减少最终构建的大小。系统功能被分组到所谓的库中以便于引用。"Strip Assemblies"选项会从最终构建中移除未使用的库。"使用微型的 mscorlib"选项执行与前一选项相同的操作,但使用的是库的最小化形式。尽管这个库显著较小,但它可供你的代码使用的函数较少。然而,除非你的游戏非常复杂,否则这不应造成影响。

  • 启用内部分析器:此选项允许你获取关于游戏在设备上运行的信息。这确实会在游戏运行时处理信息的过程中引入一些开销,但其影响小于 Unity 编辑器引入的开销。通过在命令提示符中使用adb logcat命令可以获取这些信息。

  • 优化网格数据:此设置将从所有网格中移除任何未由应用在它们上面的材质使用的额外信息。这包括法线切线以及其他一些信息。它还会导致构成网格的三角形数据为最佳处理和渲染而重新排序。除非你有非常特殊的情况,否则这是一个始终应该勾选的好选项。

跟踪性能

Unity 为我们提供了许多工具,让我们可以确定游戏运行得有多好。我们将要介绍的第一款工具对 Unity 专业版和基础版用户都是现成的。然而,这些信息相当有限,尽管它仍然有用。第二款工具仅对 Unity 专业版用户开放。它提供了更多关于性能的详细信息和数据。最后,我们将创建自己的工具,让我们可以详细查看脚本的性能。

编辑器统计

游戏窗口的右上角,有一个标有统计的按钮。点击这个按钮会打开一个窗口,为我们提供有关游戏运行情况以及处理所需时间的信息。这个窗口中的大多数信息关注的是游戏渲染的好坏,主要涉及到当前屏幕上的对象数量、正在动画的对象数量以及它们占用的内存量。此外,还有一些关于游戏中声音以及可能发生的任何网络流量的信息。以下截图显示了统计标签:

编辑器统计

  • 音频部分关注场景中播放的各种音频剪辑。它包含有关游戏音量以及处理所有这些音频所需的内存信息。音频部分包括以下详细信息:

    • 级别:这是游戏音量的大小,以分贝为单位。它实际上只是一种特殊的音量测量形式,并代表游戏中正在播放的每个音频剪辑的总和。

    • DSP 负载:这是处理场景中数字音频剪辑的成本。它表示为游戏使用的内存的百分比。

    • 剪辑:这是由于系统过载而没有播放的音频文件百分比。根据设备处理器的性能,设备一次只能播放有限数量的音频剪辑。根据检查器面板中音频源组件的优先级设置,任何额外的音频剪辑都会被忽略。

    • 流加载:这是处理任何必须边播放边流的音频所需的成本。它同样是使用内存的百分比。

  • 图形部分关注的是游戏的渲染以及进行此操作所需的内存。它包含有关游戏运行速度、正在渲染的对象数量以及对象细节程度的信息。大多数时候,在使用统计窗口时,你会查看这个部分。此分组标题右侧的FPS值是估计游戏运行速度的一个很好的指标。这是每秒处理的帧数,后面是处理游戏中单个帧所需的时间(毫秒)。图形部分包括以下详细信息:

    • CPU:这一部分分为两个小节。主要的部分是处理运行游戏所使用代码所需的时间。渲染线程的部分是在屏幕上绘制游戏所有部分所需的时间。结合起来,你可以了解到游戏中运行最耗时的部分。

    • 批处理:当使用玩家设置渲染组内的静态动态批处理时,第一个数字表示为批渲染过程创建了多少组,通过批处理节省的值是因为批处理过程而避免的绘制调用次数。节省的越多,意味着在屏幕上绘制游戏所需的工作量越少。

    • 三角形:最终,3D 图形中的每个模型都是由一系列三角形组成的。这个值是场景中相机看到并渲染的三角形总数。三角形越少,图形处理在屏幕上绘制模型时所需的工作量就越少。

    • 顶点:模型文件中的大部分信息与每个顶点的世界位置、法线方向和纹理位置有关。这个值是相机看到并渲染的顶点总数。每个模型顶点的数量越少,计算渲染的速度就越快。

    • 屏幕:这是当前游戏窗口的宽度和高度,以像素为单位。同时显示该尺寸渲染所需的内存量。较小的尺寸会减少游戏的细节,但也使得游戏更容易渲染。

    • SetPass 调用:这基本上是绘制场景中所有内容在屏幕上时,需要调用着色器不同部分的次数。它更多地基于场景中不同材质的数量,而不是物体的数量。

    • 阴影投射器:当你使用实时阴影时,会用到这个统计信息。实时阴影是昂贵的。如果可能,不应在移动设备上使用。然而,如果你必须使用它们,请尽量减少投射阴影的物体数量。仅限于那些用户能够看到阴影的大物体。特别是小型静态物体不需要投射阴影。

    • 可见的蒙皮网格:这是当前在相机视图中带有骨骼的物体总数。蒙皮网格通常是你的角色以及任何会动的东西。由于需要额外的计算来使它们移动和随动画变化,所以它们比静态网格更昂贵。

    • 动画:这只是场景中正在播放的动画总数。

  • 只有当在多人游戏中连接到其他玩家时,网络统计信息组才会可见。这些信息通常包括游戏连接的人数以及这些连接的速度。

性能分析器

分析器窗口,在 Unity 的工具栏中通过导航到窗口 | 分析器找到,是分析游戏运行情况的一个很好的工具。它为我们提供了系统每个部分及其工作量的多彩分解。这个工具唯一真正不幸的部分是它仅对 Unity Pro 用户可用。以下截图显示了分析器窗口:

分析器

首先打开分析器窗口,然后我们可以在窗口中玩游戏,并观察工具为我们提供相当详细的正在进行中的情况分解。我们可以点击任何点,并在窗口底部查看有关该帧的详细信息。提供的信息与您点击的点的特定信息有关,如CPU 使用率渲染内存等。

CPU 使用率信息在尝试查找游戏中处理时间过长的部分时特别有用。处理成本的高峰非常容易凸显出来。点击一个高峰,我们可以看到游戏中的每个部分在使这一帧变得昂贵时的分解情况。对于这些部分中的大多数,我们可以深入到导致问题的确切对象或函数。然而,我们只能定位到函数级别。仅仅因为我们知道代码中问题的大概位置,分析器窗口并不会告诉我们具体是函数的哪部分导致了问题。

为了实际工作,分析器需要挂接到游戏的每个部分。这会在游戏速度上引入一些额外的成本。因此,在分析提供的信息时,最好考虑相对成本,而不是将每个成本视为一个确切值。

跟踪脚本性能

Unity 提供的所有这些工具都很好,但它们并不总是正确的解决方案。Unity 基础用户无法访问分析器窗口。此外,分析器编辑器统计相对泛化。我们可以通过分析器获得更多细节,但信息并不总是足够,除非你不得不浏览一堆菜单。在下一部分中,我们将创建一个特殊的脚本,能够跟踪任何脚本特定部分的性能。它绝对应该成为您开发工具包中的常备部分。让我们按照以下步骤在我们的 Monkey Ball 游戏中创建脚本:

  1. 首先,我们需要一个特殊的类来跟踪我们的性能统计数据。为此,创建一个新脚本,并将其命名为TrackerStat

  2. 要开始这个脚本,我们需要启用与各种 GUI 元素交互的能力。转到脚本的最顶部,并在以using开头的其他行旁边添加这一行:

    using UnityEngine.UI;
    
  3. 接下来,我们需要更改类定义行。我们不希望或需要扩展MonoBehaviour类。因此,找到以下代码行:

    public class TrackerStat : MonoBehaviour {
    

    然后,将其更改为以下代码:

    public class TrackerStat {
    
  4. 这个脚本从四个变量开始。第一个变量将用作 ID,通过提供不同的键值,我们可以同时跟踪多个脚本。第二个变量将跟踪被跟踪代码段平均所需的时间。第三个变量只是被跟踪代码被调用的总次数。第四个变量是代码执行所需的最长时间:

    public string key = "";
    public float averageTime = 0;
    public int totalCalls = 0;
    public float longestCall = 0;
    
  5. 接下来,我们还有两个变量。它们将实际跟踪脚本执行所需的时间。第一个变量包括跟踪开始的时间。第二个变量是一个标记,表示跟踪已开始。

    public float openTime = 0;
    public bool isOpen = false;
    
  6. 本脚本的第三组也是最后一组变量用于存储实际显示我们状态信息的 Text 对象的引用:

    private Text averageLabel;
    private Text totalLabel;
    private Text longestLabel;
    
  7. 本脚本的第一个函数是Open。当我们想要开始跟踪一段代码时,会调用这个函数。它首先检查代码是否已经被跟踪。如果是,那么它会使用Debug.LogWarning控制台窗口发送警告。接下来,它设置标记表示代码正在被跟踪。最后,该函数通过使用Time.realtimeSinceStartup跟踪调用它的时刻,其中包含自游戏开始以来的实际秒数。

    public void Open() {
      if(isOpen) {
        Debug.LogWarning("Tracking is already open. Key: " + key);
      }
    
      isOpen = true;
      openTime = Time.realtimeSinceStartup;
    }
    
  8. 下一个函数Close起到了前一个函数的相反作用。当我们要跟踪的代码结束时会被调用。跟踪应该停止的时间被传递给它。这是为了尽量减少执行多余的代码。与上一个函数一样,它会检查是否正在跟踪,如果该函数没有被跟踪,它会发出另一个警告并提前退出。接下来,通过将isOpen标志设置为false来清除它。最后,计算自跟踪开始以来的时间,并调用AddValue函数。

    public void Close(float closeTime) {
      if(!isOpen) {
        Debug.LogWarning("Tracking is already closed. Key: " + key);
        return;
      }
    
      isOpen = false;
      AddValue(closeTime – openTime);
    }
    
  9. AddValue函数接收callLength,即跟踪的代码段执行所需的时间长度。然后它使用一些计算将值添加到averageTime中。接下来,该函数将当前的longestCall与新的值进行比较并更新它,如果新的值大于当前的值。然后函数增加totalCalls,最后在屏幕上更新显示新值的文本。

    public void AddValue(float callLength) {
      float totalTime = averageTime * totalCalls;
      averageTime = (totalTime + callLength) / (totalCalls + 1);
    
      if(longestCall < callLength) {
        longestCall = callLength;
      }
    
      totalCalls++;
    
      averageLabel.text = averageTime.ToString();
      totalLabel.text = totalCalls.ToString();
      longestLabel.text = longestCall.ToString();
    }
    
  10. 我们的脚本中最后一个函数CreateTexts在我们首次创建此类实例以跟踪某段代码时被调用。它首先计算 GUI 元素的垂直位置。通过使用我们将在下一个脚本中创建的ScriptTracker.NewLabel函数,我们可以节省一些工作量;它会自动处理创建和基本设置显示状态信息的 Text 对象。我们只需传递一个名称以在层次结构窗口中使用它,并在它给我们新对象时设置位置和大小。

    public void CreateTexts(int position) {
      float yPos = -45 – (30 * position);
    
      Text keyLabel = ScriptTracker.NewLabel(key + ":Key");
      keyLabel.text = key;
      keyLabel.rectTransform.anchoredPosition = new Vector2(75, yPos);
      keyLabel.rectTransform.sizeDelta = new Vector2(150, 30);
    
      averageLabel = ScriptTracker.NewLabel(key + ":Average");
      averageLabel.rectTransform.anchoredPosition = new Vector2(200, yPos);
      averageLabel.rectTransform.sizeDelta = new Vector2(100, 30);
    
      totalLabel = ScriptTracker.NewLabel(key + ":Total");
      totalLabel.rectTransform.anchoredPosition = new Vector2(200, yPos);
      totalLabel.rectTransform.sizeDelta = new Vector2(100, 30);
    
      longestLabel = ScriptTracker.NewLabel(key + ":Longest");
      longestLabel.rectTransform.anchoredPosition = new Vector2(200, yPos);
      longestLabel.rectTransform.sizeDelta = new Vector2(100, 30);
    }
    
  11. 接下来,我们需要创建另一个新脚本,并将其命名为ScriptTracker。这个脚本将允许我们进行实际性能跟踪。

  12. 正如我们对上一个脚本所做的那样,我们需要在脚本顶部的其他using行旁边添加一行,以便脚本可以创建和与 GUI 对象交互。

    using UnityEngine.UI;
    
  13. 这个脚本从一个单一变量开始。这个变量维护当前正在跟踪的所有状态。注意这里使用的static;它允许我们从游戏中的任何地方轻松更新列表:

    private static TrackerStat[] stats = new TrackerStat[0];
    
  14. 本脚本的第一个函数Open允许我们开始跟踪代码的执行。它使用static标志,因此任何脚本都可以轻松调用该函数。一个key值被传递给函数,允许我们将跟踪调用分组。函数首先创建一个变量来保存要开始跟踪的状态的索引。接下来,它遍历当前的状态集以找到匹配的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;
        }
      }
    
  15. Open函数继续检查是否找到了状态。只有当我们遍历完当前状态列表并且找不到匹配的key时,index变量才会小于零。如果没有找到,我们首先检查状态列表是否为空,如果为空,我们通过调用CreateLabels函数创建一些显示标签。然后我们调用AddNewStat来设置新的跟踪状态。我们很快就会创建这两个函数。然后index被设置为新的状态的索引。最后,通过使用状态的Open函数触发状态开始跟踪。

      if(index < 0) {
        if(stats.Length <= 0) {
          CreateLabels();
        }
    
        AddNewStat(key);
        index = stats.Length – 1;
      }
    
      stats[index].Open();
    }
    
  16. AddNewStat函数接收要创建的状态的键。它首先将状态列表存储在一个临时变量中,并将状态列表的大小增加一个。然后,每个值从临时列表转移到更大的状态列表中。最后,创建一个新状态,并将其分配到状态列表的最后一个槽位。然后,设置key并调用其CreateTexts函数,以便它可以在屏幕上显示。

    private static void AddNewStat(string key) {
      TrackerStatp[] 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;
      stats[stats.Length – 1].CreateTexts(stats.Length – 1);
    }
    
  17. 接下来,我们有Close函数。这个函数接收要关闭的状态的键值。它首先找到调用函数的时间,以最小化将跟踪的额外代码量。然后通过遍历状态列表找到匹配的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);
    }
    
  18. CreateLabels函数处理屏幕上文本标签的创建,这样我们可以轻松地了解每一段显示信息的含义。就像我们之前的脚本一样,它使用NewLabel函数来处理文本对象的基本创建,传递一个在层次结构窗口中显示的名称。然后设置要在屏幕上显示的文本,将其定位在屏幕左上角,并设置其大小。

    private static void CreateLabels() {
      Text keyLabel = NewLabel("TrackerLabel:Key");
      keyLabel.text = "Key";
      keyLabel.rectTransform.anchoredPosition = new Vector2(75, -15);
      keyLabel.rectTransform.sizeDelta = new Vector2(150, 30);
    
      Text averageLabel = NewLabel("TrackerLabel:Average");
      averageLabel.text = "Average";
      averageLabel.rectTransform.anchoredPosition = new Vector2(200, -15);
      averageLabel.rectTransform.sizeDelta = new Vector2(100, 30);
    
      Text totalLabel = NewLabel("TrackerLabel:Total");
      totalLabel.text = "Total";
      totalLabel.rectTransform.anchoredPosition = new Vector2(275, -15);
      totalLabel.rectTransform.sizeDelta = new Vector2(50, 30);
    
      Text longestLabel = NewLabel("TrackerLabel:Longest");
      longestLabel.text = "Longest";
      longestLabel.rectTransform.anchoredPosition = new Vector2(350, -15);
      longestLabel.rectTransform.sizeDelta = new Vector2(100, 30);
    }
    
  19. 此脚本的最后一个静态函数是NewLabel函数。它处理我们在脚本其余部分使用的每个文本对象的基本创建。它首先尝试查找画布对象,如果找不到则创建一个新的。为了使用我们的文本对象,我们需要画布,这样它们实际上才能被绘制。

    public static Text NewLabel(string labelName) {
      Canvas canvas = GameObject.FindObjectOfType<Canvas>();
      if(canvas == null) {
        GameObject go = new GameObject("Canvas");
        go.AddComponent<RectTransform>();
        canvas = go.AddComponent<Canvas>();
      }
    
  20. 接下来,NewLabel函数通过使用传递给它的名称创建一个新的GameObject,并将其设置为画布的子对象。然后它添加了RectTransform组件,以便它可以在 2D 空间中定位自己,并将其锚定在左上角。然后给文本对象一个CanvasRenderer组件,这样它实际上可以在屏幕上绘制,并添加一个Text组件,这样它实际上就是一个文本对象。然后我们使用Resources.GetBuiltinResource函数为文本对象获取 Unity 的默认Arial字体,再将其返回给函数的调用者。

    GameObject label = new GameObject(labelName);
    label.transform.parent = canvas.transform;
    
    RectTransform labelTrans = label.AddComponent<RectTransform>();
    labelTrans.anchorMin = Vector2.up;
    labelTrans.anchorMax = Vector2.up;
    
    label.AddComponent<CanvasRenderer>();
    Text textComp = label.AddComponent<Text>();
    textComp.font = Resources.GetBuiltinResource(typeof(Font), "Arial.ttf") as Font;
    return textComp;
    }
    
  21. 要测试这些脚本,请打开你的BananaBounce脚本。在Update函数的开始处,添加以下行以开始跟踪运行所需的时间:

    ScriptTracker.Open("BananaBounce Update");
    
  22. Update函数的末尾,我们需要用相同的键调用Close函数:

    ScriptTracker.Close("BananaBounce Update");
    
  23. 最后,启动游戏并查看结果(如下截图所示):跟踪脚本性能

我们创建了一个用于测试代码特定部分的工具。通过将任何代码片段包裹在函数调用中,并发送一个唯一的 ID,我们可以确定执行代码需要多长时间。通过平均调用脚本,并包裹代码的不同部分,我们可以确切地确定脚本中哪些部分需要最长的时间来完成。我们还可以找出代码部分是否被调用得太多次。这两种情况都是优化处理和减少延迟的理想点。

在部署游戏之前,请确保删除与此工具的所有引用。如果它被留在最终关卡中,可能会增加不必要的 CPU 负载。这种对游戏的不良影响可能导致游戏无法玩。一定要记得清除那些仅用于编辑器调试的工具使用情况。

最小化延迟

延迟是用于描述比预期慢的应用程序的一个模糊概念。它最常见于应用程序的帧率中。大多数游戏以大约 60 FPS 的速度运行,如果降至 30 FPS 或更低,则被认为是延迟的。然而,延迟及其问题更深层次,包括输入响应性、网络连接以及文件读写等问题。作为开发者,我们不断努力提供尽可能高的体验质量,同时保持用户期望的速度和响应性。这基本上取决于用户设备上的处理器是否能够处理提供游戏体验的成本。游戏中的几个简单对象将导致快速处理,但几个复杂对象将需要最多的处理。

遮挡剔除

遮挡对于拥有大量对象的游戏来说非常有效。在其基本形式中,任何在摄像机侧面或后面的内容都是不可见的,因此不会绘制。在 Unity Pro 中,我们可以设置遮挡剔除。这将计算摄像机实际可以看到的内容,并且不绘制任何被遮挡的视图。在使用这些工具时,必须达到一个平衡。计算不可见内容所需的成本需要小于直接绘制对象的成本。没有确切的数字可以表示一个场景可能需要多长时间来渲染。这完全取决于你所选择的渲染设置以及模型和纹理的细节。作为一个经验法则,如果你有许多经常被较大对象遮挡的小对象,那么选择遮挡剔除是正确的。

我们将为坦克大战游戏添加遮挡剔除,因为它是唯一一个有足够大的对象来经常遮挡视图的游戏。让我们按照以下步骤进行设置:

  1. 现在打开坦克大战游戏。如果你完成了挑战并添加了额外的碎片和障碍物,这一部分将对你特别有效。

  2. 通过转到 Unity 的工具栏并导航到窗口 | 遮挡剔除来打开遮挡窗口。这个窗口是修改与游戏中遮挡相关的各种设置的主要入口。不幸的是,这是一个仅限 Unity Pro 的功能。如果你在 Unity Basic 中尝试打开该窗口,除了在控制台中收到错误消息外,不会有任何结果。

  3. 切换到烘焙页面,我们可以查看与遮挡剔除相关的选项:遮挡

    • 最小遮挡体:这应该设置为可以阻挡其他物体视野的最小物体的尺寸。像大石头和房子这样的事物是很好的遮挡体。像家具或书籍这样的小物体通常太小,无法阻挡任何重要的视野。

    • 最小孔洞:这是场景中可以看到其他对象的最小缝隙。较小的值需要更详细的计算。较大的值成本较低,但更有可能导致对象随着玩家的移动而在视野中闪烁。

    • 背面阈值:这个设置让系统对可能位于其他对象内部的对象进行额外检查。值为100意味着不进行检查,从而节省计算时间。值为5将需要进行大量额外的计算,以确定所有对象相对于彼此的位置。

  4. 在当前阶段,对于我们来说,默认设置将工作得很好。你理想的情况是找到一组在渲染成本降低和计算应渲染内容成本之间平衡的设置。

  5. 为了让遮挡系统与动态对象一起工作,我们需要设置多个遮挡区域。要创建它们,请创建一个空的GameObject,并添加一个在 Unity 工具栏中通过导航到Component | Rendering | Occlusion Area可以找到的Occlusion Area组件。

  6. 你需要创建并操作这些对象。它们需要覆盖任何动态对象和相机可能存在的整个区域。为此,创建并定位足够的区域以覆盖我们游戏中的街道。它们的大小可以像使用Box Collider组件时一样编辑。你还可以使用区域每侧的小圆柱来操纵场域。确保它们足够高,以覆盖所有目标(如下面的截图所示):Occlusion

  7. 接下来,在Occlusion窗口底部点击Bake。Unity 编辑器右下角会出现一个进度条,它会告诉你计算还需要多长时间。这个过程通常会花费一些时间,特别是当你的游戏变得越来越复杂时。对于我们简单的坦克大战游戏,这个过程不会特别长。我们场景中内容很少,处理时间只需几秒钟。一个充满细节的大型关卡可能需要一整天来处理。

  8. 当烘焙过程完成后,Occlusion窗口将切换到Visualization标签,如果可以找到的话,应在你的Scene窗口中选择相机。如果没有,现在选择它。在Scene视图中,Unity 会给我们展示遮挡剔除是如何工作的。只有那些可以看到的对象是可见的,其余的将被关闭(如下面的截图所示):Occlusion

我们已经了解了设置遮挡剔除的基本流程。我们查看了遮挡窗口并了解了那里可用的设置。遮挡剔除对于减少场景中的绘制调用数量非常有效。然而,这种减少需要与存储和检索遮挡计算的成本相平衡。通过选择适当的技术和合适的视单元格大小来实现这种平衡。现在尝试调整不同的值,找到一个可以在不过量提供信息的情况下提供适当细节的单元格大小。

减少延迟的小贴士

以下是一些处理和避免游戏中延迟的小贴士。不是所有这些都会适用于你制作的每一个游戏,但它们对于每个项目都是值得牢记的:

  • 在创建材质时,如果可能的话避免使用透明度。它们比正常的不透明材质更昂贵。此外,如果你避免使用它们,还可以省去处理深度排序的许多麻烦。

  • 每个对象使用一个材质。你的游戏中绘制调用越多,每帧渲染的时间就会越长。即使材质看起来并没有做什么,每个网格也会根据其上的材质进行一次绘制。特别是对于移动平台,保持每个对象一个材质,可以最小化绘制调用次数,最大化渲染速度。

  • 尽可能合并纹理。你制作的不是每个纹理都会利用到整张图像。只要有可能,就合并同一场景中对象的纹理。这最大化了图像的有效使用,同时减少了最终构建的大小和利用纹理所需的内存量。

  • 层级窗口中使用空的GameObject组件来分组对象。这虽然不是特定于减少延迟,但它会使你的项目更容易操作。特别是在大型复杂关卡中,你将能够减少在场景中搜索对象的时间,从而有更多时间制作优秀的游戏。

  • 控制台窗口是你的好朋友。在担心你的游戏不能运行之前,首先查看一下 Unity 中的控制台窗口或底部的栏。两者都会显示 Unity 对于你游戏当前设置可能有的任何抱怨。这里的消息非常适合指引你解决问题。如果你不确定这些消息想要告诉你什么,可以针对这些消息进行一次谷歌搜索,你应该能轻松地从众多 Unity 用户那里找到一个解决方案。如果你的代码似乎不起作用,而 Unity 也没有对此抱怨,使用Debug.Log函数向控制台打印消息。这将帮助你找到代码可能意外退出的地方或找到不是预期值的变量。

  • 设备测试很重要。在编辑器中工作固然好,但没有什么能比在目标设备上进行测试更好。当游戏在设备上运行时,你能更直观地感受到游戏的表现。编辑器总会引入一些额外的处理开销。此外,你用来工作的电脑通常会比你可能打算部署游戏的移动设备要强大。

总结

在本章中,我们了解了在 Unity 中进行优化的各种选择。首先,我们查看了一些用于减小游戏资产文件大小同时保持质量的设置。接下来,我们学习了一些影响整个游戏的设置。之后,我们探索了追踪游戏性能的选项。我们首先了解了一些由 Unity 提供的用于追踪性能的工具。然后,我们创建了自己的工具,详细追踪脚本性能。接着,我们查看了一些减少游戏中延迟的选项,包括利用遮挡剔除。现在我们知道了所有这些工具和选项,请回顾我们创建的游戏并进行优化。让它们尽可能做到最好。

在这本书中,我们学到了很多。我们从学习 Unity、Android 以及如何让它们协同工作开始。我们的旅程继续探索 Unity 的 GUI 系统,并创建了一个井字游戏。然后,在学习任何游戏都需要的基本资产的同时,我们开始创建一个坦克大战游戏。随着一些特殊相机效果和灯光的加入,我们的坦克大战游戏得到了扩展。通过引入一些敌人并让它们追逐玩家,我们完成了坦克大战游戏的制作。我们的猴子球游戏教会了我们如何在游戏中利用触摸和倾斜控制。在短暂离开这个游戏后,我们创建了一个类似愤怒的小鸟的克隆游戏,同时学习了物理知识以及与 Unity 的 2D 管线工作的选项。然后,我们回到猴子球游戏,通过增加声音和粒子效果来完善它。最后,我们的旅程以学习如何优化我们的游戏结束。感谢您阅读这本书。我们希望您在 Unity 和 Android 上创造那些您一直梦寐以求的精彩游戏的过程中,享受这段经历。

posted @ 2024-05-22 15:09  绝不原创的飞龙  阅读(81)  评论(0编辑  收藏  举报