Unity-实战第二版-全-

Unity 实战第二版(全)

原文:Unity in Action 3e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

前言

我从 1982 年开始编程游戏。这并不容易。我们没有互联网。资源仅限于几本大多数都很糟糕的书和杂志,它们提供了迷人的但令人困惑的代码片段,至于游戏引擎——好吧,根本就没有!编写游戏是一场巨大的艰难斗争。

我多么羡慕你,读者,手中握着这本书的力量。Unity 引擎为许多人打开了游戏编程的大门。Unity 通过成为一个强大、专业的游戏引擎,同时仍然对初学者来说既负担得起又易于接近,成功地实现了卓越的平衡。

也就是说,在正确的指导下。我曾经在一个由魔术师管理的马戏团中度过了一段时间。他足够善良,收留了我,并帮助我成为一名优秀的表演者。“当你站在舞台上,”他宣布,“你做出了一份承诺。而这个承诺就是‘我不会浪费你的时间。’”

我最喜爱《Unity in Action》这本书的地方就是它的“行动”部分。乔·霍金(Joe Hocking)不会浪费你任何时间,他会让你快速进入编码状态——而且不仅仅是无意义的代码,而是你可以理解并从中构建的有趣代码,因为他知道你不仅仅想读他的书,也不仅仅想编写他的示例代码——你想要编写的是你自己的游戏。

在他的指导下,你将能够比你预期的更快地做到这一点。跟随乔的步骤,但当你觉得准备好了,不要害羞地偏离他的道路,独立前行。跳到你最感兴趣的部分——尝试实验,大胆而勇敢!如果你迷失了方向,你总是可以回到文本。

但让我们不要在这个前言中拖延时间——整个游戏开发的未来都在迫不及待地等待你开始!在你的日历上标记这一天,因为今天是一切改变的日子。它将永远被铭记为你开始制作游戏的那一天。

——杰西·谢尔(Jesse Schell),Schell Games 的首席执行官,《游戏设计艺术》的作者

前言

我已经编程游戏有一段时间了,但直到相对较晚才开始使用 Unity。当我最初开始开发游戏时,Unity 还不存在;它的第一个版本是在 2005 年发布的。从一开始,它作为游戏开发工具就有着很大的潜力,但直到几个版本之后才真正崭露头角。特别是,像 iOS 和 Android(统称为移动)这样的平台直到后来才出现,而这些平台在 Unity 日益显著的地位中起到了重要作用。

最初,我把 Unity 看作是一种好奇心,一个值得关注的有趣开发工具,但并不真正使用。在那段时间里,我为桌面电脑和网站编写游戏,并为各种客户做项目。我使用像 Blitz3D 和 Adobe Flash 这样的工具,编程起来很棒,但在很多方面都有局限性。随着这些工具开始显得过时,我一直寻找更好的游戏开发方法。

我大约从 Unity 的 3.0 版本开始尝试使用它,然后完全转向它来为 Synapse Games 的开发工作服务。最初,我在 Synapse 公司做网页游戏,但最终我们转向了移动游戏。然后我们又回到了起点,因为 Unity 使我们能够从单一代码库部署到网页和移动设备!

我一直认为分享知识非常重要,并且已经教授游戏开发课程好几年了。我这样做的一个很大原因是我众多导师和老师树立的榜样。(顺便说一句,你可能听说过我的其中一位老师,因为他是一个非常鼓舞人心的人:兰迪·波许在 2008 年去世前不久发表了“最后的演讲”。)我在几所学校教授过课程,并且一直想写一本关于游戏开发的书。

在很多方面,我写在这里的内容是我希望在我最初学习 Unity 时就能存在的书。Unity 的众多优点之一是拥有一个巨大的学习资源宝库,但这些资源往往以不集中的片段形式存在(如脚本参考或独立的教程),需要大量挖掘才能找到你需要的东西。理想情况下,我会有一本书,把所有我需要知道的内容集中在一个地方,并以清晰和逻辑的方式呈现,所以现在我为你们写这样一本书。我针对的是那些已经知道如何编程但却是 Unity 新手的读者,也许也是游戏开发的新手。项目选择反映了我在快速连续完成各种自由职业项目的过程中获得技能和自信的经验。

在学习使用 Unity 开发游戏的过程中,你正在踏上一次激动人心的冒险之旅。对我来说,学习如何开发游戏意味着要忍受很多麻烦。而你们,则有一个单一连贯的资源可以学习:这本书!

致谢

我想感谢 Manning Publications 给我机会写这本书。与我合作的编辑,包括 Robin de Jongh 和特别感谢 Dan Maharry,在整个过程中帮助了我,他们的反馈使这本书更加完善。Becky Whitney 接管了这本书第三版的主要编辑工作,而 Candace West 在第二版中担任这一角色。我还要衷心感谢在本书的开发和生产过程中与我合作的许多人:Deirdre Hiam,项目编辑;Sharon Wilkey,校对编辑;Jason Everett,校对;以及 Mihaela Batinić,审阅编辑。

我的写作在每一步都受益于审稿人的严格审查。感谢 Aharon Sharim Rani、Alain Couniot、Alain Lompo、Alberto Simões、Bradley Irby、Brent Boylan、Chris Lundberg、Cristian Antonioli、David Moskowitz、Erik Hansson、Francesco Argese、Hilde Van Gysel、James Matlock、Jan Kroken、John Ackley、John Guthrie、Jose San Leandro、Joseph W. White、Justin Calleja、Kent R. Spillner、Krishna Chaitanya Anipindi、Martin Tidman、Max Weinbrown、Nenko Ivanov Tabakov、Nick Keers、Owain Williams、Robert Walsh、Satej Kumar Sahu、Scott Chaussée 和 Walter Stoneburner。特别感谢技术发展编辑 Scott Chaussee 和校对 Christopher Haupt 的技术审稿工作。René van den Berg 和 Shiloh Morris 在第二版中承担了这些角色,而 René在第三版中担任技术校对,Robin Dewson 进行了技术编辑。我还想感谢 Jesse Schell 为我的书撰写序言。

接下来,我想感谢那些使我的 Unity 体验变得富有成效的人。当然,这始于 Unity Technologies 公司,它是 Unity(游戏引擎)的制造商。我也感激 Stack Exchange 上的游戏开发社区(gamedev.stackexchange.com);在撰写第一版时,我几乎每天都访问那个问答网站,从他人那里学习并回答问题。而我使用 Unity 的最大推动力来自 Synapse Games 的老板 Alex Reeve。同样,我也从我的同事那里学到了技巧和技术,无论是在那个工作还是在之后我所持有的每一个工作中,它们都体现在我写的代码中。

最后,我想感谢我的妻子,弗吉尼亚,在我写书期间她的支持。在我开始工作之前,我从未真正理解一个书项目会占据你多少生活,以及它如何影响你周围的人。非常感谢你的爱和鼓励。

关于本书

应该阅读这本书的人

Unity in Action, 第三版 是一本关于在 Unity 中编程游戏的书。把它看作是经验丰富的程序员的 Unity 入门。本书的目标非常明确:将一些编程经验但没有 Unity 经验的人,教会他们如何使用 Unity 开发游戏。

最佳的教学发展方式是通过示例项目,让学生通过实践学习,这正是本书所采用的方法。我将把主题作为构建示例游戏的步骤来介绍,并鼓励你在探索本书的同时在 Unity 中构建这些游戏。我们将在每几章中通过一系列项目,而不是在整个书中开发一个单一的项目。(有时其他书籍采用“单一项目”的方法,但如果你对早期章节不感兴趣,这可能会让你难以从中跳入。)

这本书的编程内容比大多数 Unity 书籍(尤其是初学者书籍)都要严格。Unity 经常被描绘为不需要编程功能列表,这是一个误导性的观点,它不会教人们他们需要知道的内容,以便制作商业游戏。如果你还不懂得如何编程计算机,我建议你访问各种“免费互动编码课程”网站(例如learnprogramming.online),然后在学会编程后回来阅读这本书。

不要担心确切的编程语言;本书中使用了 C#,但其他语言的知识也能很好地迁移。尽管本书的前部分花时间介绍新概念,并会仔细地引导你开发第一个 Unity 游戏,但剩余的章节会更快地移动,以便带你通过多个游戏类型的多个项目。本书以一个章节描述部署到各种平台,包括网页和移动设备,但本书的主要内容并不针对最终的部署目标,因为 Unity 非常平台无关。

至于游戏开发的其它方面,广泛的艺术学科覆盖会削弱本书的覆盖范围,并且大部分内容将关于 Unity 之外(例如,动画软件)的软件。艺术任务的讨论将限于 Unity 特定或所有游戏开发者都应该知道的部分。(注意,然而,附录 C 是关于自定义对象建模的。)

本书是如何组织的:一个路线图

第一章介绍了 Unity,这个跨平台的游戏开发环境。你将了解 Unity 中一切的基础组件系统,以及如何编写和执行基本脚本。

第二章进展到编写 3D 移动演示,涵盖了鼠标和键盘输入等主题。定义和操作 3D 位置和旋转被彻底解释。

第三章将移动演示转变为第一人称射击游戏,教你射线投射和基本人工智能。射线投射(向场景中发射一条线并查看它与什么相交)是各种游戏中有用的操作。

第四章涵盖了导入和创建艺术资源。这是本书中唯一不专注于代码的章节,因为每个项目都需要(基本的)模型和纹理。

第五章教你如何在 Unity 中创建一个 2D 益智游戏。尽管 Unity 最初是专为 3D 图形设计的,但现在它对 2D 图形的支持非常出色。

第六章扩展了 2D 游戏解释,加入了平台游戏机制。特别是,我们将为玩家实现控制、物理和动画。

第七章介绍了 Unity 中最新的 GUI 功能。每个游戏都需要一个用户界面,Unity 的最新版本提供了一个改进的创建 UI 的系统。

第八章展示了如何创建另一个 3D 动作演示,这次仅从第三人称视角观看。实现第三人称控制将演示关键的 3D 数学运算,您还将学习如何与动画角色协同工作。

第九章介绍了如何在您的游戏中实现交互式设备和物品。玩家将有多种操作这些设备的方式,包括直接触摸它们,触摸游戏中的触发器,或者在控制器上按按钮。

第十章涵盖了如何与互联网进行通信。您将学习如何通过使用标准互联网技术,如 HTTP 请求从服务器获取 XML 或 JSON 数据来发送和接收数据。

第十一章教授如何编程音频功能。Unity 对短音效和长音乐曲目都有很好的支持;这两种类型的音频对于几乎所有的视频游戏都至关重要。

第十二章将指导您将不同章节的内容整合成一个单一的游戏。此外,您还将学习如何编写点按控制程序以及如何保存玩家的游戏。

第十三章介绍了构建最终应用程序的方法,包括部署到桌面、网络、移动甚至 VR 等多个平台。Unity 使您能够为每个主要游戏平台创建游戏!

四个附录提供了有关场景导航、外部工具、Blender 和学习资源的额外信息。

关于代码

书中的所有源代码,无论是在代码列表还是代码片段中,都使用固定宽度字体,如这样,使其与周围文本区分开来。在大多数列表中,代码都有注释来指出关键概念。代码格式化得很好,以便在书中的可用页面空间内适应,通过添加换行和仔细使用缩进来实现。

所需的唯一软件是 Unity;本书使用 Unity 2020.3.12,这是我撰写本书时的当前默认版本。某些章节偶尔会讨论其他软件,但这些被视为可选的额外内容,而不是您学习的主要内容。

警告:Unity 项目会记住它们是在哪个版本的 Unity 中创建的,如果您尝试在另一个版本中打开它们,将会发出警告。如果在打开本书的示例下载时看到该警告,请点击继续并忽略它。

书中散布的代码列表通常显示在现有代码文件中需要添加或更改的内容;除非是某个代码文件首次出现,否则不要用后续列表替换整个文件。虽然您可以下载完整的可工作示例项目进行参考,但通过输入代码列表并查看工作样本来学习是最好的。这些下载可以从出版社的网站(www.manning.com/books/unity-in-action-third-edition)和 GitHub(github.com/jhocking/uia-3e)获取。

liveBook 讨论论坛

购买《Unity in Action, 第三版》包括对 Manning 的在线阅读平台 liveBook 的免费访问。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落添加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/#!/book/unity-in-action-third-edition/discussion。您还可以在livebook.manning.com/#!/discussion了解更多关于 Manning 的论坛和行为准则。

Manning 对读者的承诺是提供一个平台,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要书籍在印刷中,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

Joe Hocking 是一位专注于互动媒体开发的软件工程师。他目前供职于高通公司,在为 BUNDLAR 工作期间编写了第三版的大部分内容,而在 Synapse Games 工作期间编写了第一版。他还曾在伊利诺伊大学芝加哥分校、芝加哥艺术学院和哥伦比亚学院教授课程。他与妻子和两个孩子住在芝加哥郊外。他的网站是www.newarteest.com

关于封面插图

《Unity in Action, 第三版》封面上的插图标题为“大苏丹的司仪习惯”。大苏丹是奥斯曼帝国苏丹的另一个称呼。这幅插图取自托马斯·杰弗里斯的《不同国家古今服饰汇编》,由伦敦 1757 年至 1772 年间出版。扉页声明这些是手工着色的铜版雕刻,用阿拉伯树胶增强。杰弗里斯(1719-1771)被称为“乔治三世的地理学家”。他是当时的领先地图供应商,为政府和其它官方机构雕刻和印刷地图,并制作了各种商业地图和地图集,尤其是北美地区的。他作为地图制作者的工作激发了他对所调查地区的当地服饰习俗的兴趣,这些习俗在这四卷集中得到了精彩的展示。

对遥远土地的迷恋和为了娱乐而旅行在 18 世纪末是相对较新的现象,像这样的收藏品在当时很受欢迎,它们向游客和沙发旅行者介绍了其他国家的居民。杰弗里斯作品中的插图多样性生动地描绘了大约 200 年前世界各国独特性和个性的多样性。自那时以来,着装规范已经改变,当时丰富多样的地区和国家多样性已经逐渐消失。现在很难区分一个大陆的居民与另一个大陆的居民。或许,如果我们乐观地看待这个问题,我们是以文化和视觉多样性换取了更加丰富多彩的个人生活,或者更加丰富多彩和有趣的智力和技术生活。

在难以区分一本计算机书籍与另一本的时候,曼宁通过书籍封面上的设计,庆祝了计算机行业的创新和进取精神。这些设计基于两百年前丰富多样的地区生活,通过杰弗里斯的画作得以重现。

第一部分 初步步骤

是时候开始学习使用 Unity 了。如果你对 Unity 一无所知,那也没关系!我将首先解释 Unity 是什么,包括在其中编程游戏的基本原理。然后我们将通过一个教程来学习如何在 Unity 中开发一个简单的游戏。这个第一个项目将教会你几种特定的游戏开发技巧,并为你提供一个关于整个流程的良好概述。接下来是第一章!

1 了解 Unity

本章涵盖

  • 为什么 Unity 是一个很好的选择

  • 操作 Unity 编辑器

  • 在 Unity 中进行编程

如果你和我一样,你很久以前就有开发视频游戏的念头。但从玩游戏到制作游戏是一个很大的跳跃。多年来出现了许多游戏开发工具,我们将讨论其中最新且最强大的工具之一。

Unity 是一个用于创建针对各种平台的游戏的专业级游戏引擎。它不仅是一个每天被成千上万的资深游戏开发者使用的专业开发工具,也是新手游戏开发者最易于接触的现代工具之一。直到最近,游戏开发的新手从一开始就会面临许多令人望而却步的障碍,但 Unity 使得学习这些技能变得容易。

因为你在阅读这本书,所以你很可能对计算机技术感兴趣,并且已经使用其他工具开发过游戏,或者构建过其他类型的软件,例如桌面应用程序或网站。创建一个视频游戏与编写任何其他类型的软件在本质上并没有太大的区别;它主要是一个程度上的差异。例如,视频游戏比大多数网站更具交互性,因此涉及不同类型的代码,但创建两者所涉及的技术和流程是相似的。

如果你已经在学习游戏开发的道路上克服了第一个障碍,即学习了软件编程的基础知识,那么你的下一步就是选择一些游戏开发工具,并将你的编程知识转化为游戏领域。Unity 是一个非常适合工作的游戏开发环境选择。

关于术语的警告

本书是关于在 Unity 中进行编程的,因此主要对程序员感兴趣。尽管许多其他资源讨论了游戏开发和 Unity 的不同方面,但在这本书中,编程是重点。

顺便提一下,请注意,在游戏开发的背景下,“开发者”这个词可能有陌生的含义:“开发者”在像网络开发这样的学科中是“程序员”的同义词,但在游戏开发中,“开发者”通常指任何参与游戏工作的人,而“程序员”是其中的一个特定角色。其他类型的游戏开发者是艺术家和设计师,但本书专注于编程。

首先,前往 www.unity.com 了解更多关于该软件的信息。尽管 Unity 最初的重点是 3D 游戏,但 Unity 在 2D 游戏方面也表现出色,本书涵盖了这两者。实际上,即使是在 3D 项目中演示,许多主题(如保存数据、播放音频等)也适用于两者。第 1.2 节将指导你作为新手安装 Unity,但首先让我们讨论选择这个工具的具体原因。

1.1 为什么 Unity 如此出色?

让我们更仔细地看看本章开头的描述:Unity 是一个用于创建针对各种平台的游戏的专业质量游戏引擎。这是对简单直接的问题“什么是 Unity?”的一个相当直接的回答。但这个答案究竟意味着什么,为什么 Unity 如此出色?

1.1.1 Unity 的优势和优点

游戏引擎提供了许多适用于许多游戏的特性。使用特定引擎实现的游戏将获得所有这些特性,同时添加特定于该游戏的定制艺术资源和游戏代码。Unity 拥有物理模拟、法线贴图、屏幕空间环境遮挡(SSAO)、动态阴影……等等。许多游戏引擎都吹嘘这样的特性,但 Unity 在类似前沿的游戏开发工具中具有两个主要优势:极其高效的工作流程和高度跨平台支持。

视觉工作流程是一种相当独特的设计,与大多数其他游戏开发环境不同。其他游戏开发工具通常是由多个不同部分组成的复杂混合体,必须进行整理,或者可能是一个需要你设置自己的集成开发环境(IDE)、构建链等编程库,而 Unity 的开发工作流程则由一个复杂的视觉编辑器支撑。

编辑器用于布置游戏中的场景,并将艺术资源和代码结合成交互式对象。这个编辑器的优点在于它能够快速高效地构建专业质量的游戏,为开发者提供工具,使他们能够极其高效地工作,同时仍然使用视频游戏领域中最先进的技术列表。

注意:大多数其他拥有中心视觉编辑器的游戏开发工具也伴随着有限的和不灵活的脚本支持,但 Unity 并没有这个缺点。尽管为 Unity 创建的一切最终都要通过视觉编辑器进行,但这个核心界面可以用来将项目链接到在 Unity 游戏引擎中运行的定制代码。经验丰富的程序员不应该轻视这个开发环境,错误地认为它是一个具有有限编程能力的点击式游戏创建器!

编辑器特别有助于进行快速迭代,通过原型设计和测试的循环来精炼游戏。你可以在编辑器中调整对象,甚至在游戏运行时移动它们。此外,Unity 允许你通过编写脚本来自定义编辑器本身,这些脚本可以向界面添加新功能和菜单。

除了编辑器显著的生产力优势外,Unity 工具集的另一个主要优势是高度跨平台支持。Unity 不仅在部署目标方面是多平台的(你可以部署到 PC、网页、移动设备或游戏机),而且在开发工具方面也是多平台的(你可以在 Microsoft Windows 或 Apple macOS 上开发游戏)。这种平台无关性在很大程度上是因为 Unity 最初是仅限 Mac 的软件,后来才移植到 Windows。第一个版本于 2005 年发布,最初只支持 Mac,但几个月内 Unity 就更新了,使其也能在 Windows 上运行。

后续版本逐渐增加了更多的部署平台,例如 2006 年的跨平台网页播放器、2008 年的 iPhone、2010 年的 Android,甚至像 Xbox 和 PlayStation 这样的游戏机。最近,Unity 还增加了对 WebGL 的部署,这是网页浏览器中图形的新框架,甚至支持扩展现实(XR)平台,如 Oculus 和 VIVE——包括虚拟现实(VR)和增强现实(AR)。很少有游戏引擎支持像 Unity 那样多的部署目标,也没有哪个引擎能让跨平台部署如此简单。

除了这些主要优势外,第三个更微妙的好处来自于构建游戏对象所使用的模块化组件系统。在组件系统中,组件 是功能包的组合,对象是通过组件的集合构建起来的,而不是作为严格的类层次结构。组件系统是一种不同的(通常更灵活的)面向对象编程(OOP)方法,它通过组合而不是继承来构建游戏对象。图 1.1 展示了一个示例比较。

CH01_F01_Hocking3

图 1.1 继承与组合

在组件系统中,对象存在于一个扁平的层次结构中,不同的对象有不同的组件集合。相比之下,继承结构中的不同对象位于树的完全不同的分支上。组件排列促进了快速原型设计,因为你可以快速混合和匹配组件,而无需在对象更改时重构继承链。

虽然如果你没有自定义组件系统,可以编写代码来实现,但 Unity 已经有一个强大的组件系统,并且这个系统甚至与可视化编辑器集成。你不仅可以在代码中操作组件,还可以在可视化编辑器中附加和分离组件。同时,你不仅限于通过组合构建对象;你仍然可以选择在代码中使用继承,包括基于继承的所有最佳实践设计模式。

1.1.2 需要注意的缺点

Unity 有许多优点,使其成为开发游戏的一个很好的选择,我强烈推荐它,但如果不提及它的弱点,我会感到疏忽。特别是,视觉编辑器和复杂编码的结合,尽管与 Unity 的组件系统非常有效,但却是非同寻常的,可能会造成困难。在复杂的场景中,你可能会失去对场景中哪些对象附加了特定组件的跟踪。Unity 确实提供了一个用于查找附加脚本的搜索功能,但它可能更健壮;有时你仍然会遇到需要手动检查场景中的所有内容以找到脚本链接的情况。这种情况并不常见,但一旦发生,可能会很繁琐。

另一个可能会让有经验的程序员感到惊讶和沮丧的缺点是,链接外部代码库可能很困难。实际上,Unity 的旧版本根本不支持外部代码库,因此它们必须手动复制到每个项目中。现在 Unity 带有包管理器,库(或)是从一个中央共享位置引用的。这些包对于 Unity 本身提供的可选功能(Unity 不会自动包含每个项目中不需要的功能)工作得很好,未来的章节偶尔会要求你安装用于高级字体处理等功能的包。然而,创建自己的包可能很复杂,这使得在多个项目中共享代码变得尴尬。你可能发现直接在项目之间手动复制代码并处理未来的版本不匹配要简单一些,但这并不是一个理想的权衡。

注意:过去,与版本控制系统(如 Git 或 Subversion)一起工作一直是 Unity 的一个显著弱点,但较新版本的 Unity 运行良好。你可能会发现过时的资料告诉你 Unity 不支持版本控制,但新的资料会描述一个项目中哪些文件和文件夹需要放入仓库,哪些不需要。要开始,请阅读 Unity 的文档(mng.bz/BbhD)或查看 GitHub 维护的.gitignore 文件(mng.bz/g7nl)。

第三个弱点与有时令人眼花缭乱的选项数组有关。Unity 为某些功能提供了多种方法,但并不总是清楚你应该使用哪种方法。在某种程度上,这种情况对于正在积极开发中的工具来说是不可避免的,但仍然会给用户带来困惑和不适。这种演化的混乱可能会让 Unity 的老手也感到困惑,因此 Unity 的新手肯定会时不时地遇到困惑。本书突出了这些功能并提供指导。

例如,第七章解释了如何为 Unity 游戏开发用户界面(UI)。嗯,Unity 实际上有三个 UI 系统(这些系统在 mng.bz/r60X上进行了比较),因为随着系统的不断开发,它们在改进其前辈。本书涵盖了第二个 UI 系统(Unity UI,或 uGUI),因为它仍然比不完整的第三个 UI 系统(UI Toolkit)更受欢迎,但几年内 UI Toolkit 成熟到生产就绪阶段,我也不会感到惊讶。在此期间,新来者可能难以决定 UI 方法。

1.1.3 使用 Unity 构建的示例游戏

你已经听说过 Unity 的优缺点,但你可能仍然需要证明其开发工具可以提供一流的结果。访问 Unity 画廊unity.com/case-study,查看使用 Unity 开发的不断更新的游戏和模拟列表。本节探讨了几个游戏,展示了多个类型和部署平台。所有游戏名称均为各自游戏公司的商标,截图也归这些公司所有,版权所有。

桌面(Windows,Mac,Linux)和控制台(PlayStation,Xbox,Switch)

由于 Unity 编辑器运行在相同的平台上,部署到 Windows 或 Mac 通常是目标平台中最直接的选择。同时,在 Unity 中开发的控制台游戏也经常在 PC 上发布,这得益于 Unity 的简单跨平台部署。以下是一些不同类型的桌面和控制台游戏的例子:

  • Fall Guys(图 1.2),由 Mediatonic(Mediatonic Limited 的商标)开发的一款混乱的 3D 动作游戏

CH01_F02_Hocking3

图 1.2 Fall Guys

  • Cuphead(图 1.3),由 Studio MDHR 开发的一款 2D 平台游戏

CH01_F03_Hocking3

图 1.3 Cuphead

移动端(iOS 和 Android)

Unity 还可以将游戏部署到移动平台,如 iOS(iPhone 和 iPad)和 Android(手机和平板电脑)。以下是一些不同类型的移动游戏的例子:

  • Monument Valley 2(图 1.4),由 ustwo 开发的一款解谜游戏

CH01_F04_Hocking3

图 1.4 Monument Valley 2

  • Guns of Boom(图 1.5),由 Game Insight 开发的一款第一人称射击游戏

CH01_F05_Hocking3

图 1.5 Guns of Boom

  • Animation Throwdown(图 1.6),由 Kongregate 开发的一款收藏卡牌游戏

CH01_F06_Hocking3

图 1.6 Animation Throwdown

虚拟现实(Oculus,VIVE,PlayStation VR)

Unity 甚至可以将游戏部署到 XR 平台,包括虚拟现实头戴设备。以下是一些不同类型的 VR 游戏的例子:

  • Beat Saber(图 1.7),由 Beat Games 开发的一款节奏游戏

CH01_F07_Hocking3

图 1.7 Beat Saber

  • I Expect You to Die(图 1.8),由 Schell Games 开发的一款逃脱解谜游戏

CH01_F08_Hocking3

图 1.8 我期待你死去

如这些示例所示,Unity 的优势确实可以转化为商业品质的游戏。但即使 Unity 在其他游戏开发工具上具有显著优势,新来者也可能误解编程在开发过程中的作用。

Unity 常常被描绘为无需编程即可使用的一系列功能,这是一个误导性的观点,它不会教人们他们需要知道的内容,以便制作商业游戏。虽然确实可以在不涉及程序员的情况下使用预存组件构建相当复杂的原型(这本身就是一个相当大的成就),但要从一个有趣的原型过渡到一个准备发布的精良游戏,则需要严格的编程。

1.2 如何使用 Unity

前一节详细讨论了 Unity 视觉编辑器的生产力优势,所以让我们来看看界面是什么样的,以及它是如何操作的。如果你还没有这样做,请访问 www.unity.com 并点击“开始使用”来下载程序。在这里,你可以看到提供的各种订阅计划的概述。本书中的所有内容在免费版本中都可以使用,所以选择“个人”选项卡,然后点击免费个人版下面的按钮。Unity 的付费版本主要区别在于商业许可条款,而不是底层功能。

网站为新用户和回访用户提供了不同的下载。区别仅仅在于新用户的下载将启动一个软件向导,引导用户进入入门教程,而回访用户的下载将直接进入主应用程序,没有任何介绍。所以即使你是 Unity 的新手,也请下载回访用户的版本并跳过入门内容(毕竟,它与本书的内容重复)。

实际上,你会下载一个轻量级的安装管理器,而不是主要的 Unity 应用程序。这个管理应用程序被称为 Unity Hub,它的存在是为了简化同时安装和使用多个 Unity 版本。如图 1.9 所示,当你启动 Unity Hub 时,安装编辑器将是第一件事。安装默认推荐的版本;本书使用 Unity 2020.3.12(截至本书写作时的当前默认版本)。如果你以后想安装额外的 Unity 版本(比默认版本更新的版本),请点击 Unity Hub 侧菜单中的“安装”。

CH01_F09_Hocking3

图 1.9 Unity Hub 首次启动与后续启动对比

提示:到您阅读这本书的时候,可能已经发布了更新的 Unity 版本。高级功能可能会发生变化,甚至界面的外观也可能不同,但本书涵盖的基本概念仍然适用。本书中的解释通常仍然适用于当前的任何 Unity 未来版本。

警告:项目会记住它们是在哪个 Unity 版本中创建的,如果您尝试在另一个版本中打开它们,将会发出警告。有时这并不重要(例如,如果在此书样本下载打开时出现警告,请忽略它),但有时您不希望在一个错误的版本中打开项目。

从安装编辑器继续,转到“学习”标签页以下载第一个项目。选择任何项目进行浏览(您无论如何也不会做太多),但请注意图 1.10 显示了 Karting。Unity 将下载并启动所选项目。您可能会看到一个关于导入文件以设置新项目的警告消息;请意识到导入可能需要几分钟。

一旦新项目最终加载完成,选择“加载场景”以关闭初始弹出窗口。如果文件浏览器底部的编辑器中尚未打开,请导航到 Assets/Karting/Scenes/,双击 MainScene(场景文件有 Unity 立方体图标)。您应该看到一个类似于图 1.10 的屏幕。

CH01_F10_Hocking3

图 1.10 Unity 界面部分

Unity 的界面分为几个部分:场景标签页、游戏标签页、工具栏、层次结构标签页、检查器、项目标签页和控制台标签页。每个部分都有不同的用途,但所有部分对于游戏制作的生命周期都是至关重要的:

  • 您可以在项目标签页中浏览所有文件。

  • 您可以使用场景标签页来定位当前场景中的对象。

  • 工具栏包含用于处理场景的控制。

  • 您可以在层次结构标签页中拖放对象关系。

  • 检查器列出了所选对象的信息,包括链接的代码。

  • 您可以在游戏视图中测试播放,同时查看控制台标签页中的错误输出。

这是 Unity 中的默认布局;所有视图都在标签页中,可以移动或调整大小,停靠在屏幕的不同位置。稍后,您可以尝试自定义布局,但现在,默认布局是了解所有视图功能的最佳方式。

1.2.1 场景视图、游戏视图和工具栏

界面最显著的部分是中间的场景视图。在这里,您可以查看游戏世界的外观并移动对象。场景中的网格对象以网格(稍后定义)的形式出现。您还可以看到场景中的其他对象,它们由图标和彩色线条表示:摄像机、灯光、音频源、碰撞区域等等。请注意,您在这里看到的视图与运行中的游戏视图不同——您可以随意在场景中环顾四周,而无需受限于游戏的视图。

定义:网格对象是空间中的视觉对象。3D 图形中的视觉效果由许多连接的线条和形状构成——因此得名网格

游戏视图不是屏幕的独立部分,而是一个位于场景旁边的另一个标签(在视图的左上角寻找标签)。界面上有几个地方有多个这样的标签;如果您点击不同的标签,视图将替换为新活动的标签。当游戏运行时,您在这个视图中看到的是游戏。每次运行游戏时无需手动切换标签,因为当游戏开始时,视图会自动切换到游戏视图。

小贴士:当游戏运行时,您可以切换回场景视图,允许您检查运行场景中的对象。这种能力在游戏运行时查看正在发生的事情时非常有用,并且是一个在大多数游戏引擎中不可用的有用调试工具。

说到运行游戏,只需点击场景视图正上方的播放按钮就这么简单。界面的整个顶部部分被称为工具栏,播放按钮位于正中央。图 1.11 将完整的编辑器界面拆分,只显示顶部的工具栏以及位于其下的场景/游戏标签。

CH01_F11_Hocking3

图 1.11 编辑器截图裁剪以显示工具栏、场景和游戏视图

工具栏的左侧是场景导航和变换对象的按钮——用于环顾场景和移动对象。我建议您花时间练习这些,因为它们是您在 Unity 视觉编辑器中将要做的最重要的活动之二。(它们如此重要,以至于在接下来的部分中会有它们自己的章节。)

工具栏的右侧是布局和图层下拉菜单的位置。如前所述,Unity 界面的布局是灵活的,因此布局菜单允许您切换布局。至于图层菜单,那是目前可以忽略的高级功能(图层将在未来的章节中介绍)。

1.2.2 鼠标和键盘

场景导航主要使用鼠标完成,同时使用一些修改键来改变鼠标的操作。三种主要的导航操作是移动、环绕和缩放。具体的鼠标移动取决于你使用的鼠标,并在附录 A 中描述。这三种移动涉及在按住 Alt(或 Mac 上的 Option)和 Ctrl(Mac 上的 Command)组合键的同时点击和拖动。花几分钟在场景中移动,以了解移动、环绕和缩放的作用。

提示:尽管 Unity 可以使用单键或双键鼠标,但我强烈推荐使用三键鼠标(是的,三键鼠标在 Mac 上也能正常工作)。

对象的变换也是通过三种主要操作完成的,三种场景导航操作与三种变换相对应:平移、旋转和缩放(图 1.12 展示了在立方体上的变换)。

CH01_F12_Hocking3

图 1.12 应用三个变换:平移、旋转和缩放。(较浅的线条是变换前的对象状态。)

当你在场景中选择一个对象时,你可以将其移动(在数学上准确的技术术语是 平移),旋转它,并调整其大小。将场景导航操作联系起来,移动对应于摄像机的平移,环绕对应于旋转,缩放对应于缩放。除了工具栏上的按钮外,你可以通过按键盘上的 W、E 或 R 来切换这些功能。当你激活变换时,你会注意到一组彩色箭头或圆圈出现在场景中的对象上;这是变换工具,你可以点击并拖动这个工具来应用变换。

第四个工具位于变换按钮旁边。称为 矩形工具,它专为 2D 图形设计。这个工具结合了移动、旋转和缩放。同样,第五个按钮用于结合移动、旋转和缩放的工具,用于 3D 对象。我个人更喜欢分别操作三个变换,但你可能会觉得组合工具更方便。

Unity 还有一系列键盘快捷键,用于加快各种任务的速度。请参阅附录 A 了解它们。有了这些,接下来我们将介绍界面的其他部分!

1.2.3 层次视图和检查器面板

观察屏幕的任一边,你会在左侧看到层次标签,在右侧看到检查器标签(见图 1.13)。层次列出了场景中每个对象的名称,并根据场景中的层次链接关系将名称嵌套在一起。基本上,这是一种通过名称选择对象而不是在场景视图中搜索并点击对象的方法。层次链接关系将对象组合在一起,就像文件夹一样,允许你将整个组作为一个整体移动。

CH01_F13_Hocking3

图 1.13 编辑器截图裁剪以显示层次结构和检查器标签页

检查器 显示关于当前选中对象的信息。选择一个对象,检查器就会填充有关该对象的信息。显示的信息基本上是组件列表,你甚至可以给对象附加或移除组件。所有游戏对象至少有一个组件,即变换组件,所以你总会看到至少有关位置和旋转的信息在检查器中。通常,对象在这里会列出几个组件,包括附加到它们上的脚本。

1.2.4 项目和控制台标签页

在屏幕底部,你会看到项目视图和控制台(见图 1.14)。与场景和游戏一样,这些不是屏幕的两个独立部分,而是你可以切换的标签页。

项目 显示项目中的所有资源(艺术、代码等)。具体来说,在视图的左侧是项目目录的列表;当你选择一个目录时,视图的右侧会显示该目录中的单个文件。项目中的目录列表类似于层次结构中的列表视图,但层次结构显示场景中的对象;项目显示可能不在任何特定场景中包含的文件(包括场景文件——当你保存场景时,它会在项目中显示出来!)。

CH01_F14_Hocking3

图 1.14 编辑器截图裁剪以显示项目和控制台标签页

TIP 项目视图反映了磁盘上的资源目录,但通常,你不应该通过在操作系统的文件资源管理器中直接访问资源文件夹来移动或删除文件。如果你在项目视图中执行这些操作,Unity 将与该文件夹保持同步。

控制台 标签页是代码消息出现的地方。其中一些消息将是故意放置的调试输出,但 Unity 如果在脚本中遇到问题,也会发出错误消息。

1.3 使用 Unity 编程入门

现在我们来看看在 Unity 中编程的过程是如何工作的。虽然可以在视觉编辑器中布置艺术资源,但你需要编写代码来控制它们并使游戏交互。Unity 中的复杂编程使用 C# 作为编程语言来完成。

启动 Unity 并创建一个新项目:在 Unity Hub 中选择“新建”,或者在 Unity 已运行时选择“文件”>“新建项目”。为项目输入一个名称,保留默认的 3D 模板(后续章节将介绍 2D),然后选择你想要保存项目的地方。Unity 项目只是一个包含各种资源和设置文件的目录,所以你可以将项目保存在电脑上的任何位置。点击“创建”,然后 Unity 将暂时消失,以设置项目目录。

或者,您可以打开第一章的示例项目。我强烈建议您在一个新项目中尝试遵循即将到来的说明,然后在检查您的作品之后查看完成的示例,但这完全取决于您。在 Unity Hub 中选择“添加”以将下载的项目文件夹添加到列表中,然后单击列表中的项目。

警告:如果您打开的是本书的示例项目而不是创建一个新项目,Unity 可能会显示以下消息:“重建库,因为找不到资产数据库!”这指的是项目的库文件夹;该文件夹包含 Unity 生成并在工作期间使用的文件,但在分发这些文件时不是必需的。

当 Unity 再次出现时,您将看到一个空白项目。接下来,让我们讨论程序如何在 Unity 中执行。

1.3.1 在 Unity 中运行代码:脚本组件

Unity 中的所有代码执行都是从与场景中的对象链接的代码文件开始的。最终,这种代码执行是之前描述的组件系统的一部分;游戏对象是由组件集合构建的,而这个集合可以包括执行脚本的脚本。

注意:Unity 将代码文件称为脚本,使用的是在浏览器中运行 JavaScript 时最常见的脚本定义:代码在 Unity 游戏引擎中执行,而不是作为其自己的可执行文件运行的编译代码。但不要混淆,因为许多人将这个词定义为不同的含义;例如,脚本通常指的是短小、自包含的实用程序。Unity 中的脚本更类似于单个 OOP 类,并且附加到场景中对象的脚本是对象实例。

如您从描述中可能推测到的,在 Unity 中,脚本组件——请注意,不是所有脚本,只有继承自 MonoBehaviour 的脚本,MonoBehaviour 是脚本组件的基类。MonoBehaviour 定义了将组件附加到游戏对象的无形基础,并且(如列表 1.1 所示)从它继承提供了一些自动运行的方法,您可以实现这些方法。这些方法包括 Start(),当对象变为活动状态时调用一次(通常是在带有该对象的场景加载后立即调用),以及 Update(),它每帧都会被调用。当您将这些代码放入这些预定义方法中时,您的代码就会被运行。

定义:是循环游戏代码的单个周期。几乎所有视频游戏(不仅是在 Unity 中,而且在一般视频游戏中)都是围绕核心游戏循环构建的,其中代码在游戏运行时循环执行。每个周期包括绘制屏幕——因此得名(就像电影的一系列静态帧)。

列表 1.1 基本脚本组件的代码模板

using System.Collections;                    ❶
using System.Collections.Generic;
using UnityEngine;
public class HelloWorld : MonoBehaviour {    ❷
    void Start() {
        // do something once                 ❸
    }

    void Update() {
        // do something every frame          ❹
    }
}

❶ 包含 Unity 和.NET/Mono 类的命名空间。

❷ 继承的语法

❸ 在此处放置只运行一次的代码。

❹ 在此处放置每帧运行的代码。

当你创建一个新的 C#脚本时,文件包含的内容是这样的:定义一个有效 Unity 组件的最小样板代码。Unity 在应用程序的深处藏有一个脚本模板,当你创建一个新的脚本时,Unity 会复制这个模板并将类重命名为与文件名匹配(在我的例子中是 HelloWorld.cs)。Unity 还为 Start()和 Update()提供了空壳,因为这两个是最常见的调用自定义代码的地方。

要创建一个脚本,从创建菜单中选择 C#脚本,你可以通过在资产菜单下(注意资产和游戏对象都有创建的列表,但它们是不同的菜单)或通过在项目视图中右键单击来访问它。为新脚本输入一个名称,例如 HelloWorld。如本章后面所述(见图 1.16),你会点击并拖动这个脚本文件到场景中的对象上。双击脚本,它将自动在另一个程序中打开以进行编辑,如下一节所述。

1.3.2 使用 Visual Studio,内置的 IDE

编程并不是在 Unity 中完成的,而是代码作为独立的文件存在,你需要将这些文件指向 Unity。脚本文件可以在 Unity 中创建,但你仍然需要使用文本编辑器或 IDE 来编写那些最初为空的文件。Unity 附带 Microsoft Visual Studio,这是一个 C#的 IDE(图 1.15 展示了其外观)。你可以访问visualstudio.microsoft.com了解更多关于这个软件的信息。

CH01_F15_Hocking3

图 1.15 Visual Studio 界面的一部分

注意:如果 Unity 打开的不是 Visual Studio,你可能需要切换外部工具首选项。前往首选项 > 外部工具 > 外部脚本编辑器以选择一个 IDE。

注意:Visual Studio 将文件组织成称为“解决方案”的分组。Unity 自动生成一个包含所有脚本文件的解决方案,所以你通常不需要担心这一点。

可用的 Visual Studio 版本有很多(许多程序员更喜欢 Visual Studio Code),或者你可以使用来自不同公司的 IDE,如 JetBrains Rider。切换到不同的 IDE 就像在 Unity 的首选项中转到外部工具一样简单。我通常使用 Visual Studio for Mac,但你可以使用不同的 IDE,并且不会遇到任何问题来跟随这本书的内容。在本章介绍之后,我不会再谈论 IDE。

总要记住,尽管代码是在 Visual Studio 中编写的,但代码并不是在那里运行的。IDE 基本上是一个高级的文本编辑器,代码是在你点击 Unity 中的播放时运行的。

1.3.3 打印到控制台:Hello World!

好吧,你已经在项目中有一个空脚本了,但你还需要一个场景中的对象来附加脚本。回想一下图 1.1 描述的组件系统;脚本是一个组件,因此它需要被设置为对象上的组件之一。

选择 GameObject > 创建空对象,一个空 GameObject 将出现在层次列表中。现在将脚本从项目视图拖动到层次视图,并将其放在空 GameObject 上。如图 1.16 所示,Unity 将突出显示放置脚本的合法位置,并将脚本放在 GameObject 上会将其附加到该对象。

CH01_F16_Hocking3

图 1.16 如何将脚本链接到 GameObject

为了验证脚本是否已附加到对象,选择 GameObject 并查看检查器视图。你应该看到列出了两个组件:Transform 组件,这是所有对象都有的基本位置/旋转/缩放组件,且不能被移除,在其下方是你的脚本。

备注:最终,这种从一处拖动对象并将其放置到另一处的动作将变得习以为常。在 Unity 中,许多链接(不仅限于将脚本附加到对象)都是通过将对象拖放到彼此上方来创建的。

当脚本链接到对象时,你将看到如图 1.17 所示的内容,脚本在检查器中显示为一个组件。现在脚本将在你播放场景时执行,尽管目前还没有发生任何事情,因为你还没有编写任何代码。让我们接下来做这件事!

CH01_F17_Hocking3

图 1.17 在检查器中显示的链接脚本

双击脚本以打开它并返回到列表 1.1。在学习新的编程环境时,最经典的做法是让它打印出“Hello World!”文本,因此请在以下列表中的 Start() 方法内添加该行。

列表 1.2 添加控制台消息

...
void Start() {
    Debug.Log("Hello World!");    ❶
}
...

❶ 在此处添加日志命令。

Debug.Log() 命令会在 Unity 的控制台视图中打印一条消息。同时,该行代码会放入 Start() 方法中,因为,如前所述,该方法会在对象变为活动状态时立即被调用。点击编辑器中的播放后,Start() 将被调用一次。一旦添加了日志命令,保存脚本,点击 Unity 中的播放,并切换到控制台视图。你会看到“Hello World!”消息出现。恭喜你——你已经编写了你的第一个 Unity 脚本!当然,代码将在后面的章节中变得更加复杂,但这是一个重要的第一步。

警告:始终记得在调整脚本后保存文件!一个相当常见的错误是在调整代码后立即点击 Unity 中的播放而不保存,导致游戏仍然使用你调整之前的代码。

“Hello World!” 简要步骤

让我们重申并总结一下上一页的步骤:

  1. 创建一个新的项目。

  2. 创建一个新的 C# 脚本。

  3. 创建一个空 GameObject。

  4. 将脚本拖放到对象上。

  5. 将日志命令添加到脚本中。

  6. 点击播放!

现在是保存场景的时候了;这将创建一个带有 Unity 图标的.unity 文件。场景文件是当前游戏中加载的所有内容的快照,这样你可以在以后重新加载这个场景。保存这个场景可能几乎看起来不值得,因为它如此简单(一个空的 GameObject)——但如果你不保存场景,当你退出 Unity 后回到项目时,你会发现它又是空的。

脚本中的错误

要查看 Unity 如何指示错误,故意在 HelloWorld 脚本中输入一个拼写错误。例如,如果你输入了一个多余的括号符号,错误信息将在控制台标签页中显示一个红色的错误图标。

CH01_UN01_Hocking3

控制台标签页中显示的脚本错误

习惯于阅读这些错误信息,因为这将是你解决代码中问题的主要方式。注意信息的结构:它首先指出哪个文件有错误,然后显示该文件中的行号,最后提供发生错误的描述。

摘要

  • Unity 是一个多平台开发工具。

  • Unity 的视觉编辑器有几个部分协同工作。

  • 脚本作为组件附加到对象上。

  • 使用 Visual Studio 在脚本中编写代码。

2 构建一个让你置身于 3D 空间的演示

本章节涵盖

  • 理解 3D 坐标空间

  • 在场景中放置玩家

  • 编写移动物体的脚本

  • 实现 FPS 控制

第一章以传统的“Hello World!”介绍了一种新的编程工具;现在,是时候深入一个非平凡的 Unity 项目了,一个具有交互性和图形的项目。你将把物体放入场景,并编写代码使玩家能够在场景中行走。基本上,这将是一个没有怪物的《Doom》(类似于图 2.1 中的描绘)。Unity 中的视觉编辑器使新用户能够立即开始组装一个 3D 原型,而无需先编写大量的模板代码(例如初始化 3D 视图或建立渲染循环)。

CH02_F01_Hocking3

图 2.1 3D 演示的截图(基本上,《Doom》没有怪物)

很有诱惑力立即在 Unity 中开始构建场景,尤其是这样一个(在概念上!)简单的项目。但是,在开始之前暂停并规划你要做什么总是一个好主意,而且现在这一点尤为重要,因为你对这个过程还不太熟悉。

注意,每个章节的项目都可以从本书的网站上下载(mng.bz/VBY5)。首先在 Unity 中打开项目,然后打开主场景(通常只命名为 Scene)以运行和检查。在你学习的过程中,我建议你自己输入所有代码,并将下载的样本仅作为参考使用。

2.1 在开始之前...

Unity 使新手容易开始,但在你构建完整的场景之前,让我们先过一下几个要点。即使在与 Unity 这样灵活的工具一起工作时,你也需要有一个明确的目标感。你还需要掌握 3D 坐标如何运作,否则你一尝试在场景中定位物体就会迷失方向。

2.1.1 规划项目

在开始编写任何代码之前,你总是想要停下来问问自己,“我在这里要构建什么?”游戏设计是一个巨大的主题,有大量令人印象深刻的大型书籍专注于如何设计游戏。幸运的是,为了我们的目的,你只需要在心中有一个关于这个简单演示的简要概述,就可以开发一个基本的学习项目。这些初始项目本身设计不会太复杂,以免分散你学习编程概念。在你掌握了游戏开发的基本原理之后,你可以(并且应该!)担心更高级的设计问题。

对于这个第一个项目,你将构建一个基本的单人第一人称射击(FPS)场景。我们将创建一个房间供玩家导航,玩家将从他们的角色视角看到世界,并且可以通过使用鼠标和键盘来控制角色。现在可以移除完整游戏的所有有趣复杂性,以便专注于核心机制:在 3D 空间中移动。图 2.2 展示了这个项目的路线图,列出了我在脑海中构建的清单:

  1. 设置房间:创建地板、外墙和内墙。

  2. 放置灯光和相机。

  3. 创建玩家对象(包括在顶部附加相机)。

  4. 编写移动脚本:用鼠标旋转,用键盘移动。

CH02_F02_Hocking3

图 2.2 3D 演示的路线图

不要被这个路线图中的所有内容吓倒!听起来这个章节有很多步骤,但 Unity 会让它们变得简单。接下来关于移动脚本的章节之所以内容广泛,仅仅是因为我们将逐行分析,以便你能够详细理解所有概念。

这个项目是一个第一人称演示,为了保持艺术要求简单;因为你看不到自己,所以“你”可以是一个顶部带有相机的圆柱形!现在你需要理解 3D 坐标是如何工作的,这样在视觉编辑器中放置所有内容就会变得容易。

2.1.2 理解 3D 坐标空间

如果你思考一下我们刚开始的简单计划,它有三个方面:一个房间、一个视野和控件。所有这些项目都依赖于你理解如何在 3D 计算机模拟中表示位置和运动。如果你是 3D 图形的新手,你可能还不知道这些内容。

所有这些最终都归结为表示空间中点的数字,这些数字与空间相关联的方式是通过坐标轴。如果你回想起数学课,你可能见过并使用过 x 轴和 y 轴(见图 2.3)来为页面上的点分配坐标。这被称为笛卡尔坐标系

CH02_F03_Hocking3

图 2.3 x 轴和 y 轴上的坐标定义了一个 2D 点。

两个轴提供了 2D 坐标,所有点都在同一平面上。三个轴用于定义 3D 空间。因为 x 轴沿着页面水平方向,y 轴沿着页面垂直方向,所以我们现在想象一个垂直于 x 轴和 y 轴的第三轴,从页面中直插出来。图 2.4 展示了 3D 坐标空间的 x 轴、y 轴和 z 轴。场景中所有具有特定位置的对象都将具有 x 轴、y 轴和 z 坐标:玩家的位置、墙的位置等等。

CH02_F04_Hocking3

图 2.4 x 轴、y 轴和 z 轴上的坐标定义了一个 3D 点。

在 Unity 的场景视图中,你可以看到这三个轴被显示出来。在检查器中,你可以输入定位对象所需的三个数字。你不仅会使用这三个数字坐标编写代码来定位对象,还会定义沿着每个轴的移动距离。

左手系与右手系坐标

每个轴的正负方向是任意的,无论轴指向哪个方向,坐标仍然有效。你只需要在给定的 3D 图形工具(动画工具、游戏开发工具等)中保持一致性。

但在几乎所有情况下,x 轴向右延伸,y 轴向上延伸;不同工具之间的区别在于 z 轴是进入页面还是从页面出来。这两个方向被称为左手系右手系;如图所示,如果你将大拇指沿着 x 轴方向,食指沿着 y 轴方向,那么中指就会指向 z 轴。

CH02_UN01_Hocking3

左手系和右手系的 z 轴指向不同的方向。

Unity 使用左手坐标系,许多 3D 艺术应用程序也是如此。许多其他工具使用右手坐标系(例如 OpenGL),所以如果你看到不同的坐标方向,不要感到困惑。

现在你已经为这个项目制定了计划,并且知道如何使用坐标在 3D 空间中定位对象,是时候开始构建场景了。

2.2 开始项目:在场景中放置对象

让我们在场景中创建和放置对象。首先,你将设置所有静态场景——地板和墙壁。然后你将在场景周围放置灯光,并定位摄像机。最后,你将创建玩家对象,即你将附加脚本以在场景中四处走动的对象。图 2.5 显示了所有对象放置就绪的编辑器外观。

CH02_F05_Hocking3

图 2.5 编辑器中的场景,包含地板、墙壁、灯光、摄像机和玩家

第一章展示了如何在 Unity 中创建新项目,所以你现在将这样做。在 Unity Hub 中选择“新建”(或编辑器中的“文件 > 新建项目”),然后在弹出的窗口中命名你的新项目。场景一开始几乎是空的,首先创建的对象是最明显的。

2.2.1 景观:地板、外墙和内墙

在屏幕顶部选择 GameObject 菜单,然后悬停在 3D Object 上以查看下拉菜单。选择 Cube 以在场景中创建一个新的立方体对象(稍后我们将使用其他形状,如 Sphere 和 Capsule)。调整这个对象的位置和缩放,以及它的名称,以创建地板。图 2.6 显示了检查器中地板应设置哪些值(它最初只是一个立方体,在你将其拉伸之前)。

注意 您可以将位置数值视为您想要的任何单位,只要在整个场景中保持一致即可。最常见的单位是米,我通常选择米,但有时我也使用英尺,甚至见过其他人决定使用英寸!

重复相同的步骤来创建房间的外墙。每次都可以创建新的立方体,或者您可以通过使用标准快捷键复制并粘贴现有对象。移动、旋转和缩放墙壁以在楼面周围形成边界。尝试不同的数字(例如,缩放为 1、4、50)或使用第 1.2.2 节中介绍的变换工具(记住,在 3D 空间中移动和旋转的数学术语是 变换)。

小贴士 回想一下第一章中的导航控制,以从不同角度查看场景或放大以获得鸟瞰视图。如果您在场景中迷路了,请按 F 键重置当前选中对象的视图。

CH02_F06_Hocking3

图 2.6 楼面检查器视图

一旦外墙就位,创建内墙以进行导航。将内墙放置在您喜欢的位置;想法是在编写移动代码后创建走廊和障碍物。墙壁最终具有的确切变换值将取决于您如何旋转和缩放立方体以适应,以及对象在层次视图中的链接方式。如果您需要从示例中复制有效值,请下载示例项目并参考那里的墙壁。

小贴士 在层次视图中将对象拖到彼此上方以建立链接。带有附加对象的物体被称为 父对象;附加到父对象的对象被称为 子对象。当父对象移动(或旋转或缩放)时,子对象会与其一起变换。

定义 对象(与父对象和子对象的概念密切相关)是位于层次结构底部且本身没有父对象的对象。因此,所有根对象都是父对象,但并非所有父对象都是根对象。

您还可以创建空的游戏对象来组织场景。从游戏对象菜单中选择创建空对象。通过将可见对象链接到根对象,可以折叠其层次列表。例如,在图 2.7 中,墙壁都是空根对象(命名为 Building)的子对象,这样层次列表看起来就会很整齐。

CH02_F07_Hocking3

图 2.7 展示墙壁和楼面组织在空对象下的层次视图

在将任何子对象链接到它之前,请确保将空根对象(位置和旋转设置为 0, 0, 0,缩放设置为 1, 1, 1)的变换选项重置,以避免子对象位置出现任何异常。

什么是 GameObject?

所有场景对象都是 GameObject 类的实例,类似于所有脚本组件都继承自 MonoBehaviour 类的方式。当空对象被实际命名为 GameObject 时,这一事实更为明确,但无论对象被命名为 Floor、Camera 还是 Player,这一事实仍然是真实的。

GameObject 实际上是一系列组件的容器。GameObject 的主要目的是为 MonoBehaviour 提供一些可以附加的东西。物体在场景中的确切位置取决于已经添加到该 GameObject 的哪些组件。立方体对象有 Cube 组件,球体对象有 Sphere 组件,依此类推。

如果您还没有保存更改的场景,请记住保存。现在场景中有一个房间,但我们仍然需要设置灯光。让我们接下来处理这个问题。

2.2.2 光源和相机

通常,你使用方向光和一系列点光源来照亮 3D 场景。从一个方向光开始。场景默认情况下可能已经有一个了,如果没有,可以通过选择 GameObject > Light 并选择方向光来创建一个。

光源类型

你可以创建几种类型的光源,这些光源由它们如何以及在哪里投射光线来定义。三种主要类型是点光源、聚光灯和方向光源。

点光源中,所有光线都从一个点发出并向所有方向投射,就像现实世界中的灯泡一样。光线在近距离更亮,因为光线是聚集在一起的。

聚光灯中,所有光线都从一个点发出,但只在一个有限的锥体中投射。当前项目中没有使用聚光灯,但这些灯光通常用于突出显示关卡的一部分。

方向光中,所有光线都是平行的并且均匀投射,以相同的方式照亮场景中的所有物体。这就像现实世界中的太阳一样。

方向光的位子不会影响从它发出的光,只会影响光源面对的方向,所以从技术上讲,你可以在场景的任何地方放置那个光。我建议将方向光放置在房间上方较高的位置,这样它就会直观地感觉像太阳,而且在你操作场景的其他部分时不会碍事。旋转这个光并观察它对房间的影响;我建议在 x 轴和 y 轴上稍微旋转以获得良好的效果。

当你在检查器中查看时,你会看到一个强度设置(见图 2.8)。正如其名所示,该设置控制光的亮度。如果这是唯一的光源,它就必须更亮,但由于你还会添加许多点光源,这个方向光可以相当暗——例如,0.6 强度。此光还应略带黄色调,就像太阳一样,而其他光源将是白色的。

CH02_F08_Hocking3

图 2.8 检查器中的方向光设置

对于点光源,使用相同的菜单创建几个,并将它们放置在房间周围的暗处,以确保所有墙壁都被照亮。你不希望太多,因为如果游戏中有许多光源,性能可能会下降。在每个角落放置一个应该就足够了(我建议将它们提升到墙壁的顶部),再加上一个放置在场景上方较高的位置(例如,Y 位置为 18),以给房间中的光线增加多样性。

注意,点光源在 Inspector 中添加了一个 Range 设置(见图 2.9)。这控制了光能达到多远;而方向性光源在整个场景中均匀地投射光线,点光源在物体较近时更亮。靠近地板的点光源应该有大约 18 的范围,但放置在较高位置的光源应该有大约 40 的范围以照亮整个房间。将靠近地板的光源强度设置为 0.8,而较高的光源则通过强度 0.4 减少额外光线以填充空间。

CH02_F09_Hocking3

图 2.9 Inspector 中的点光源设置

玩家需要看到场景的另一种对象是相机,但“空”场景自带了一个主相机,所以你会使用它。如果你需要创建新的相机(例如,用于多人游戏中的分屏视图),Camera 是同一 GameObject 菜单中的另一个选择,就像 Cube 和 Lights 一样。我们将相机定位在玩家的顶部周围,以便看起来是通过玩家的眼睛看到的。

2.2.3 玩家的碰撞器和视点

对于这个项目,用一个简单的原始形状来表示玩家就足够了。在 GameObject 菜单中(记住,将鼠标悬停在 3D Object 上以展开菜单),点击 Capsule。Unity 创建了一个两端圆润的圆柱形形状;这个原始形状将代表玩家。将此对象定位在 y 轴上的 1.1 处(对象高度的一半,再加上一点以避免与地板重叠)。你可以将对象沿 x 轴和 z 轴移动到任何你喜欢的地方,只要它在房间内且不接触任何墙壁。将对象命名为 Player。

在 Inspector 中,你会注意到这个对象被分配了一个胶囊碰撞器。对于一个胶囊对象来说,这是一个合理的默认选择,就像立方体对象默认有盒子碰撞器一样。但这个特定的对象将是玩家,因此需要比大多数对象稍微不同类型的组件。通过点击该组件右上角的菜单图标(如图 2.10 所示)来移除胶囊碰撞器;这将显示一个包括移除组件选项的菜单。碰撞器是一个围绕对象的绿色网格,因此删除胶囊碰撞器后,你会看到绿色网格消失。

CH02_F10_Hocking3

图 2.10 在 Inspector 中移除组件

我们将不为这个对象分配胶囊碰撞器,而是分配一个 角色控制器。在检查器的底部有一个标有“添加组件”的按钮;点击该按钮以打开可以添加的组件菜单。在菜单的物理部分,您将找到角色控制器;选择该选项。正如其名称所示,此组件将允许对象像角色一样行为。

您需要完成最后一步来设置玩家对象:附加相机。如前文 2.2.1 节所述,对象可以在层次结构视图中相互拖动。将相机对象拖动到玩家胶囊上,以将相机附加到玩家。现在调整相机位置,使其看起来像玩家的眼睛(我建议位置为 0, 0.5, 0)。如有必要,将相机的旋转重置为 0, 0, 0(如果已经旋转了胶囊,这将不适用)。

您已经创建了此场景所需的所有对象。剩下的是编写代码来移动玩家对象。

2.3 使事物移动:应用变换的脚本

要让玩家在场景中行走,您将编写附加到玩家的移动脚本。记住,组件是添加到对象的功能模块,而脚本是一种组件。最终,这些脚本将响应键盘和鼠标输入,但首先您将使玩家原地旋转。

这个简单的开始将教会您如何在代码中应用变换。记住,三个变换是平移、旋转和缩放;旋转对象意味着改变旋转。但关于这个任务,除了“这涉及到旋转”之外,还有更多需要了解的。

2.3.1 可视化运动的编程方式

对象动画(如使其旋转)归结为每帧移动一小量,帧不断播放。单独的变换会立即应用,而不是在一段时间内可见地移动。但重复应用变换会使对象看起来像一系列静止的图画在翻书一样可见地移动。图 2.11 说明了这是如何工作的。

CH02_F11_Hocking3

图 2.11 运动的外观:在静止图片之间转换的循环过程

记住,脚本组件有一个 Update() 方法,该方法每帧运行一次。要旋转立方体,请在 Update() 内添加旋转立方体一小量的代码。此代码将每帧重复运行。听起来很简单,对吧?

2.3.2 编写代码以实现图示

现在,让我们将刚刚讨论的概念付诸实践。创建一个新的 C# 脚本(记住,从“资产”菜单打开“创建”子菜单),命名为 Spin,并编写以下代码(在输入后不要忘记保存文件!)。

列表 2.1 使对象旋转

using System.Collections;
using System.Collections.Generic;
using UnityEngine;                       ❶

public class Spin : MonoBehaviour {
    public float speed = 3.0f;           ❷

    void Update() {
        transform.Rotate(0, speed, 0);   ❸
    }
}

❶ 将 Unity 的类拉入此脚本。

❷ 声明一个用于旋转速度的公共变量。

❸ 将旋转命令放在这里,使其每帧运行。

要将脚本组件添加到玩家对象,从项目视图拖动脚本并将其拖放到层次结构视图中的 Player 上。现在点击播放,你会看到视图旋转;你已经编写了使对象移动的代码!这段代码基本上是新的脚本模板加上两行新添加的代码,所以让我们看看这两行代码做了什么。

首先,我们在类定义的顶部添加了速度变量(数字后面的 f 告诉计算机将其视为浮点值;否则,C# 将十进制数字视为双精度浮点数)。旋转速度被定义为变量而不是常量,因为 Unity 在脚本组件中对公共变量做了一些方便的处理,如下面的提示所述。

提示:公共变量在 Inspector 中暴露,以便在将组件添加到游戏对象后调整组件的值。这被称为 序列化 值,因为 Unity 保存了变量的修改状态。

图 2.12 展示了在选中 Player 对象时,Inspector 中组件的外观。你可以输入一个新的数字,然后脚本将使用该值而不是代码中定义的默认值。这是一种方便的方法,可以在视觉编辑器中调整不同对象的组件设置,而不是硬编码每个值。

CH02_F12_Hocking3

图 2.12 Inspector 显示脚本中声明的公共变量

从列表 2.1 中检查的第二行是 Rotate() 方法。它在 Update() 内部,因此命令会每帧运行。Rotate() 是 Transform 类的一个方法,因此通过该对象的 transform 组件(如大多数面向对象的语言,如果你只输入 transform,则隐含 this.transform)使用点符号调用。transform 每帧旋转速度度数,从而产生平滑的旋转运动。但为什么 Rotate() 的参数被列为 (0, speed, 0) 而不是,比如说,(speed, 0, 0)?

回想一下,在三维空间中存在三个轴,分别标记为 x、y 和 z。理解这些轴与位置和运动的关系相对直观,但这些轴也可以用来描述旋转。航空学以类似的方式描述旋转,因此处理三维图形的程序员经常使用从航空学借来的术语:俯仰、偏航和滚转。图 2.13 展示了这些术语的含义:俯仰是围绕 x 轴的旋转,偏航是围绕 y 轴的旋转,而滚转是围绕 z 轴的旋转。

CH02_F13_Hocking3

图 2.13 飞机俯仰、偏航和滚转旋转的示意图

既然我们可以描述围绕 x、y 和 z 轴的旋转,这意味着 Rotate()方法的三个参数是 X、Y 和 Z 旋转。因为我们想让玩家只围绕侧面旋转,而不是上下倾斜,所以应该只给出 Y 旋转的数值,而 X 和 Z 旋转为 0。

希望你能猜到如果你将参数更改为(speed, 0, 0)并播放场景会发生什么。现在就试试!接下来,你需要理解关于旋转和 3D 坐标轴的另一个微妙之处,体现在 Rotate()方法的可选第四个参数中。

2.3.3 理解局部与全局坐标空间

默认情况下,Rotate()方法作用于局部坐标。你可以使用的另一种坐标是全局坐标。你通过使用可选的第四个参数并写入 Space.Self 或 Space.World 来告诉方法是否使用局部或全局坐标,如下所示:Rotate(0, speed, 0, Space.World)。

参考第 2.1.2 节中关于 3D 坐标空间的解释,并思考这些问题:原点(0, 0, 0)在哪里?x 轴指向哪个方向?坐标系本身可以移动吗?

结果表明,每个对象都有自己的原点,以及三个轴的方向,这个坐标系随着对象移动。这被称为局部坐标。整个 3D 场景也有自己的原点和三个轴的方向,这个坐标系永远不会移动。这被称为全局坐标。因此,当你指定局部或全局给 Rotate()方法时,你是在告诉它围绕哪个对象的 x、y 和 z 轴旋转(见图 2.14)。

CH02_F14_Hocking3

图 2.14 局部与全局坐标轴

如果你刚开始接触 3D 图形,这个概念可能会让你感到困惑。不同的轴在图 2.14 中有所表示(注意平面的“左”方向与世界的“左”方向是不同的),但理解局部和全局最简单的方法是通过一个例子。

选择玩家对象,然后稍微倾斜一下(比如 X 旋转的 30 度)。这将使局部坐标发生变化,使得局部和全局旋转看起来不同。现在尝试运行带有和没有添加 Space.World 到参数中的 Spin 脚本。如果你觉得难以可视化正在发生的事情,尝试从玩家对象中移除旋转组件,并代替旋转一个放置在玩家前面的倾斜的立方体。当你将命令设置为局部或全局坐标时,你会看到物体围绕不同的轴旋转。

2.4 用于环顾四周的脚本组件:MouseLook

现在,你将使旋转响应鼠标输入(即,此脚本附加到的对象的旋转,在这种情况下将是玩家)。你将通过几个步骤来完成,逐步为角色添加新的移动能力。首先,玩家将只能左右旋转,然后玩家将只能上下旋转。最终,玩家将能够向所有方向看(同时水平垂直旋转),这种行为被称为 鼠标查看

由于我们将使用三种类型的旋转行为(水平、垂直和两者),你将首先编写支持所有三种行为的框架。创建一个新的 C#脚本,命名为 MouseLook,并编写以下代码。

列表 2.2 带有枚举旋转设置的 MouseLook 框架

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MouseLook : MonoBehaviour {
  public enum RotationAxes {                             ❶
    MouseXAndY = 0,
    MouseX = 1,
    MouseY = 2
  }
  public RotationAxes axes = RotationAxes.MouseXAndY;    ❷

  void Update() {
    if (axes == RotationAxes.MouseX) {
      // horizontal rotation here                        ❸
    }
    else if (axes == RotationAxes.MouseY) {
      // vertical rotation here                          ❹
    }
    else {
      // both horizontal and vertical rotation here      ❺
    }
  }
}

❶ 定义一个枚举数据结构,将名称与设置关联起来。

❷ 在 Unity 的编辑器中声明一个公共变量来设置。

❸ 仅在此处放置水平旋转的代码。

❹ 仅在此处放置垂直旋转的代码。

❺ 在这里放置水平和垂直旋转的代码。

注意,在 MouseLook 脚本中使用枚举来选择水平或垂直旋转。定义枚举数据结构允许你通过名称设置值,而不是输入数字并试图记住每个数字的含义(0 是水平旋转吗?是 1 吗?)。如果你声明一个类型为该枚举的公共变量,它将在检查器中显示为下拉菜单(见图 2.15),这对于选择设置非常有用。

CH02_F15_Hocking3

图 2.15 检查器显示公共枚举变量为下拉菜单。

移除旋转组件(与之前移除玩家胶囊的方式相同,使用右上角的菜单),然后将此新脚本附加到玩家对象上。使用检查器中的轴下拉菜单切换旋转方向。在设置好水平/垂直旋转设置后,你可以为条件语句的每个分支填写代码。

警告:在更改此轴的菜单设置之前,请确保停止游戏。Unity 允许你在游戏过程中编辑检查器(以测试设置更改),但在停止游戏后,它会撤销更改。

命名空间

命名空间 是一个可选的编程结构,用于在项目中组织代码。因为命名空间不是强制的,所以它们被省略了,既包括 Unity 创建的脚本文件,也包括这本书的示例项目。实际上,如果你还不熟悉命名空间,你可能希望暂时跳过这个讨论。

尽管这本书的示例代码没有使用命名空间,但你应该强烈考虑在自己的项目中使用它们,因为这样会在大型代码库中建立更清晰的组织结构。命名空间包含相关的类和接口,将类放入命名空间可以解决命名冲突的问题。如果两个类在不同的命名空间中,它们可以拥有相同的名称。

要将一个类放入命名空间中,将其放在大括号内,如下所示:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace UnityInAction {

  public class MouseLook : MonoBehaviour {
    ...
  }
}

要在其他代码(例如,使用下一节中引入的 GetComponent 语句)中访问该类,要么其他代码也必须在同一命名空间中,或者你需要在代码中添加一个如 using UnityInAction; 的语句。并且命名空间不会干扰脚本组件,所以你仍然可以在 Unity 的编辑器中无障碍地使用该类。

2.4.1 跟随鼠标移动的水平旋转

第一个也是最简单的分支是用于水平旋转。首先,编写与列表 2.1 中相同的旋转命令,使对象旋转。别忘了声明一个用于旋转速度的公共变量;在 axes 之后但在 Update() 之前声明新变量,并将其命名为 sensitivityHor,因为在你涉及多个旋转之后,speed 这个名字太泛了。这次将变量的值增加到 9,因为接下来的几个列表中的代码需要更大的值。调整后的代码应如下所示。

列表 2.3 水平旋转,尚未响应鼠标

...
*public RotationAxes axes = RotationAxes.MouseXAndY;*    ❶
public float sensitivityHor = 9.0f;                    ❷

*void Update() {
  if (axes == RotationAxes.MouseX) {*
    transform.Rotate(0, sensitivityHor, 0);            ❸
  *}*
...

❶ 已在脚本中斜体化的代码;这里展示是为了参考。

❷ 声明一个用于旋转速度的变量。

❸ 将 Rotate 命令放在这里,以便每帧运行。

将 MouseLook 组件的 Axes 菜单设置为水平旋转并播放脚本;视图将像之前一样旋转。下一步是使旋转响应鼠标移动,因此让我们介绍一个新的方法:Input.GetAxis()。Input 类有一系列用于处理输入设备(如鼠标)的方法,GetAxis() 方法返回与鼠标移动相关的数字(正 1 到 -1,取决于移动方向)。GetAxis() 方法接受所需轴的名称作为参数,水平轴称为 Mouse X。

如果将旋转速度乘以轴值,旋转将响应鼠标移动。速度将根据鼠标移动进行缩放,缩小到零甚至反向。现在,Rotate 命令看起来如下所示。

列表 2.4 调整后的 Rotate 命令以响应鼠标

...
transform.Rotate(0, Input.GetAxis("Mouse X") * sensitivityHor, 0);   ❶
...

❶ 注意使用 GetAxis() 获取鼠标输入。

警告:确保在 Mouse X 中输入一个空格。此命令的轴名称由 Unity 定义,而不是我们代码中的轴名称。为此轴输入 MouseX 是一个常见的错误。

点击播放,然后移动鼠标。当你从一侧移动鼠标到另一侧时,视图将从一侧旋转到另一侧。这非常酷!下一步是将旋转改为垂直而不是水平。

2.4.2 带限制的垂直旋转

对于水平旋转,我们一直使用 Rotate()方法,但对于垂直旋转,我们将采取不同的方法。虽然该方法适用于应用变换,但它也有些不灵活。它只适用于无限制地增加旋转,这对于水平旋转来说是可行的,但垂直旋转需要限制视图可以倾斜的上限和下限。此列表显示了 MouseLook 的垂直旋转代码;代码的详细解释将立即跟随。

列表 2.5 MouseLook 的垂直旋转

...
public float sensitivityHor = 9.0f;
public float sensitivityVert = 9.0f;                                         ❶

public float minimumVert = -45.0f;
public float maximumVert = 45.0f;

private float verticalRot = 0;                                               ❷

void Update() {
  if (axes == RotationAxes.MouseX) {
    transform.Rotate(0, Input.GetAxis("Mouse X") * sensitivityHor, 0);
  }
  else if (axes == RotationAxes.MouseY) {
    verticalRot -= Input.GetAxis("Mouse Y") * sensitivityVert;               ❸
    verticalRot = Mathf.Clamp(verticalRot, minimumVert, maximumVert);        ❹

    float horizontalRot = transform.localEulerAngles.y;                      ❺

    transform.localEulerAngles = new Vector3(verticalRot, horizontalRot, 0); ❻
  }
...

❶ 声明用于垂直旋转的变量。

❷ 声明一个用于垂直角度的私有变量。

❸ 根据鼠标移动来增加垂直角度。

❹ 将垂直角度限制在最小和最大限制之间。

❺ 保持相同的 Y 角度(即,没有水平旋转)。

❻ 从存储的旋转值创建一个新的向量。

将 MouseLook 组件的 Axes 菜单设置为垂直旋转并播放新脚本。现在,当你上下移动鼠标时,视图不会左右旋转,而是会上下倾斜。倾斜会在上下极限处停止。

此代码引入了几个需要解释的新概念。首先,这次我们没有使用 Rotate()方法,因此需要一个变量来存储旋转角度(这个变量在这里称为 verticalRot,并记住垂直旋转是围绕 x 轴进行的)。Rotate()方法增加当前旋转,而此代码直接设置旋转角度。这区别于说“将角度增加 5”和“将角度设置为 30”。我们仍然需要增加旋转角度,这就是为什么代码中有 -= 运算符:从旋转角度中减去一个值,而不是将角度设置为那个值。不使用 Rotate(),我们可以以各种方式操作旋转角度,而不仅仅是增加它。旋转值乘以 Input.GetAxis(),就像水平旋转的代码一样,但现在我们请求 Mouse Y,因为那是鼠标的垂直轴。

在下一行进一步操作旋转角度。我们使用 Mathf.Clamp()来保持旋转角度在最小和最大限制之间。这些限制是在代码中较早声明的公共变量,并确保视图只能倾斜 45 度上下。Clamp()方法不仅限于旋转,但通常对于保持数字变量在限制之间非常有用。为了看看会发生什么,尝试注释掉 Clamp()行;现在倾斜不会在上下极限处停止,甚至允许你完全颠倒旋转!显然,颠倒观看世界是不理想的,这就是为什么有这些限制。

因为 transform 的角度属性是一个 Vector3,我们需要创建一个新的 Vector3,将旋转角度传递给构造函数。Rotate()方法为我们自动化了这个过程,增加旋转角度然后创建一个新的向量。

定义 一个向量是存储在一起作为一个单元的多个数字。例如,一个 Vector3 是三个数字(标记为 x、y、z)。

警告 我们需要创建一个新的 Vector3 而不是在变换中更改现有向量的值,因为这些值对于变换来说是只读的。这是一个常见的错误,可能会让你陷入困境。

欧拉角与四元数

你可能想知道为什么属性被称为 localEulerAngles 而不是 localRotation。首先,你需要了解四元数。

四元数是另一种表示旋转的数学结构。它们与欧拉角不同,欧拉角是我们一直采用的 x、y、z 轴方法的名字。还记得关于俯仰、偏航和滚转的整个讨论吗?嗯,那种表示旋转的方法使用的是欧拉角。四元数是……不同的。解释四元数很难,因为它们是高等数学中一个晦涩的方面,涉及通过四个维度的运动。要获得详细解释,请尝试访问 Cprogramming.com 网站上的“使用四元数执行 3D 旋转”( mng.bz/xX0B)。

解释为什么使用四元数来表示旋转要容易一些:在旋转值之间进行插值(通过一系列中间值逐渐从一个值变为另一个值)在使用四元数时看起来更平滑、更自然。

回到最初的问题,我们使用 localEulerAngles,因为 localRotation 是一个四元数,而不是欧拉角。Unity 还提供了一个欧拉角属性,使操作旋转更容易理解;欧拉角属性会自动转换为四元数值。Unity 在幕后处理更复杂的数学,所以你不必担心自己处理。

需要更多的代码来设置 MouseLook 的一个旋转:同时进行水平和垂直旋转。

2.4.3 同时进行水平和垂直旋转

这段最后的代码也不会使用 Rotate(),原因相同:垂直旋转角度在增加后会被限制在一定的范围内。这意味着现在需要直接计算水平旋转。记住,Rotate()是自动增加旋转角度的过程,如下所示。

列表 2.6 水平和垂直 MouseLook

...
else {
  verticalRot -= Input.GetAxis("Mouse Y") * sensitivityVert;
  verticalRot = Mathf.Clamp(verticalRot, minimumVert, maximumVert);

  float delta = Input.GetAxis("Mouse X") * sensitivityHor;        ❶
  float horizontalRot = transform.localEulerAngles.y + delta;     ❷

  transform.localEulerAngles = new Vector3(verticalRot, horizontalRot, 0);
}
...

❶ delta 是旋转改变的量。

❷ 通过 delta 增加旋转角度。

前几行,处理 verticalRot 的,与列表 2.5 中的完全相同。记住,围绕对象的 x 轴旋转是垂直旋转。因为水平旋转不再使用 Rotate()方法处理,这就是 delta 和 horizontalRot 行所做的事情。“Delta”是表示改变量的常见数学术语,因此我们的 delta 计算是旋转应该改变的数量。然后,将这个改变量加到当前的旋转角度上,以得到期望的新旋转角度。

最后,使用这两个角度(垂直和水平)创建一个新的向量,并将其分配给变换组件的角度属性。

禁止玩家进行物理旋转

尽管这对当前项目来说还不是很重要,但大多数现代第一人称射击游戏都使用一个复杂的物理模拟,影响场景中的所有对象。这个模拟导致对象弹跳和滚动。尽管这种行为对大多数对象来说看起来和效果都很好,但玩家的旋转需要完全由鼠标控制,而不受物理模拟的影响。

因此,鼠标输入脚本通常会在玩家的 Rigidbody 上设置 freezeRotation 属性。将此 Start()方法添加到 MouseLook 脚本中:

...
void Start() {
    Rigidbody body = GetComponent<Rigidbody>();
    if (body != null) {                          ❶
        body.freezeRotation = true;
    }
}

❶ 此组件可能尚未添加,请检查它是否存在。

(Rigidbody 是对象可以拥有的附加组件。物理模拟作用于 Rigidbody 组件,并操纵它们所附加的对象。)

如果你在我们讨论的各种更改和添加的地方感到困惑,这个列表包含了完整的最终脚本。或者,下载示例项目。

列表 2.7 完成的 MouseLook 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MouseLook : MonoBehaviour {
  public enum RotationAxes {
    MouseXAndY = 0,
    MouseX = 1,
    MouseY = 2
  }
  public RotationAxes axes = RotationAxes.MouseXAndY;

  public float sensitivityHor = 9.0f;
  public float sensitivityVert = 9.0f;

  public float minimumVert = -45.0f;
  public float maximumVert = 45.0f;

  private float verticalRot = 0;

  void Start() {
    Rigidbody body = GetComponent<Rigidbody>();
    if (body != null) {
        body.freezeRotation = true;
    }
  }

  void Update() {
    if (axes == RotationAxes.MouseX) {
      transform.Rotate(0, Input.GetAxis("Mouse X") * sensitivityHor, 0);
    }
    else if (axes == RotationAxes.MouseY) {
      verticalRot -= Input.GetAxis("Mouse Y") * sensitivityVert;
      verticalRot = Mathf.Clamp(verticalRot, minimumVert, maximumVert);

      float horizontalRot = transform.localEulerAngles.y;

      transform.localEulerAngles = new Vector3(verticalRot, horizontalRot, 0);
    }
    else {
      verticalRot -= Input.GetAxis("Mouse Y") * sensitivityVert;
      verticalRot = Mathf.Clamp(verticalRot, minimumVert, maximumVert);

      float delta = Input.GetAxis("Mouse X") * sensitivityHor;
      float horizontalRot = transform.localEulerAngles.y + delta;

      transform.localEulerAngles = new Vector3(verticalRot, horizontalRot, 0);
    }
  }
}

当你设置轴菜单并运行新代码时,你可以在移动鼠标的同时向所有方向张望。太棒了!但你仍然被困在一个地方,就像被安装在炮塔上一样四处张望。下一步是移动到场景中。

2.5 键盘输入组件:第一人称控制

根据鼠标输入四处张望是第一人称控制的重要部分,但你只完成了一半。玩家还需要根据键盘输入进行移动。让我们编写一个键盘控制组件来补充鼠标控制组件;创建一个新的 C#脚本名为 FPSInput 并将其附加到玩家(与 MouseLook 脚本一起)。目前,将 MouseLook 组件设置为仅水平旋转。

提示:这里解释的键盘和鼠标控制被分成单独的脚本。你不必以这种方式结构化代码,可以将所有内容打包到一个玩家控制脚本中。但组件系统(如 Unity 中的系统)在功能被分成几个较小的组件时,通常是最灵活的,因此也最有用。

你在上一节中编写的代码只影响了旋转,但现在我们将改变对象的位置。参考列表 2.1;将其输入 FPSInput,但将 Rotate()改为 Translate()。当你点击播放时,视图会向上滑动而不是旋转。

尝试更改参数值以查看运动如何改变(特别是尝试交换前两个数字)。在尝试了一段时间后,你可以继续添加键盘输入。

列表 2.8 列表 2.1 中的旋转代码,经过一些小的修改

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FPSInput : MonoBehaviour {
  public float speed = 6.0f;                 ❶

  void Update() {
    transform.Translate(0, speed, 0);        ❷
  }
}

❶ 初始时可能会太快,但稍后会进行修正。

❷ 将 Rotate()改为 Translate()。

2.5.1 响应按键

根据按键移动的代码与根据鼠标旋转的代码类似。同样使用 GetAxis()方法,并且以类似的方式使用。这个列表演示了如何使用它。

列表 2.9 根据按键进行位置移动

...
void Update() {
  float deltaX = Input.GetAxis("Horizontal") * speed;    ❶
  float deltaZ = Input.GetAxis("Vertical") * speed;
  transform.Translate(deltaX, 0, deltaZ);
}
...

❶ 水平和垂直是键盘映射的间接名称。

与之前一样,GetAxis()值乘以速度来确定移动量。之前,请求的轴总是“鼠标某个东西”,现在我们传递水平或垂直。这些名称是 Unity 中输入设置的抽象;如果你在项目设置下的编辑菜单中查看,然后在输入管理器下查看,你会找到一个抽象输入名称列表以及映射到这些名称的确切控制。左右箭头键和字母 A 和 D 都映射到水平,而上下箭头键和字母 W 和 S 都映射到垂直。

注意,移动值应用于 x 和 z 坐标。正如你可能在使用 Translate()方法进行实验时注意到的,x 坐标在左右移动,而 z 坐标向前和向后移动。

输入新的移动代码后,你应该可以通过按箭头键或 W、A、S、D 字母键来移动,这是大多数 FPS 游戏中的标准。移动脚本几乎完成了,但我们还有一些调整需要讨论。

2.5.2 设置与电脑速度无关的移动速率

目前还不明显,因为你只是在你的电脑上运行代码,但如果你在不同的机器上运行代码,它们的运行速度会不同。这是因为一些电脑可以比其他电脑更快地处理代码和图形。目前,玩家在不同电脑上的移动速度会不同,因为移动代码与电脑的速度相关联。这被称为帧率依赖性,因为移动代码依赖于游戏的帧率。

想象你在两台电脑上运行这个演示,一台每秒 30 帧(fps),另一台每秒 60 帧。这意味着第二台电脑上的 Update()会被调用两次,每次应用相同的速度值 6。在 30fps 下,移动速度将是每秒 180 单位,而在 60fps 下的移动将是每秒 360 单位。对于大多数游戏来说,这种速度变化的移动是不好的消息。

解决方案是调整移动代码,使其不依赖于帧率。这种移动速度不依赖于游戏的帧率。实现这一点的方法是在每个帧率上不应用相同的速度值。相反,根据电脑的运行速度,将速度值放大或缩小。这是通过将速度值乘以另一个称为 deltaTime 的值来实现的。

列表 2.10 使用 deltaTime 实现帧率无关的移动

...
void Update() {
  float deltaX = Input.GetAxis("Horizontal") * speed;
  float deltaZ = Input.GetAxis("Vertical") * speed;
  transform.Translate(deltaX * Time.deltaTime, 0, deltaZ * Time.deltaTime);
}
...

这只是一个简单的更改。Time 类具有用于计时的属性和方法,其中一个属性是 deltaTime。我们知道delta意味着变化量,所以这意味着 deltaTime 是时间的变化量。具体来说,deltaTime 是帧之间的时间量。帧之间的时间量在不同帧率下会有所不同(例如,30 fps 的 deltaTime 为 1/30 秒),所以将速度值乘以 deltaTime 将根据不同的计算机调整速度值。

现在所有计算机上的移动速度都将相同。但移动脚本仍然没有完成。当你四处移动时,你可以穿过墙壁,所以我们需要进一步调整代码以防止这种情况。

2.5.3 为碰撞检测移动 CharacterController

直接更改对象的变换不应用碰撞检测,因此角色会穿过墙壁。为了应用碰撞检测,我们想要做的是使用 CharacterController,这是一个使对象移动更像游戏中的角色的组件,包括与墙壁碰撞。回想一下,在我们设置玩家时,我们附加了一个 CharacterController,所以现在我们将使用 FPSInput 中的移动代码来使用该组件。

列表 2.11 替换 Transform 移动 CharacterController

...
private CharacterController charController;                ❶

void Start() {
  charController = GetComponent<CharacterController>();    ❷
}

void Update() {
  float deltaX = Input.GetAxis("Horizontal") * speed;
  float deltaZ = Input.GetAxis("Vertical") * speed;
  Vector3 movement = new Vector3(deltaX, 0, deltaZ);
  movement = Vector3.ClampMagnitude(movement, speed);      ❸

  movement *= Time.deltaTime;
  movement = transform.TransformDirection(movement);       ❹
  charController.Move(movement);                           ❺
}
...

❶ 参考 CharacterController 的变量

❷ 访问同一对象上附加的其他组件。

❸ 将对角线移动限制为与沿轴移动相同的速度。

❹ 将移动向量从局部坐标转换为全局坐标。

❺ 告诉 CharacterController 按该向量移动。

这段代码片段介绍了几个新概念。首先需要指出的是,用于引用 CharacterController 的变量。这个变量创建了对对象的本地引用(代码对象,即不要与场景对象混淆);多个脚本可以引用这个 CharacterController 实例。

这个变量最初是空的,所以在你可以使用引用之前,你需要为它分配一个对象。这就是 GetComponent()发挥作用的地方;该方法返回附加到同一 GameObject 上的其他组件。而不是在括号内传递参数,你使用 C#语法在尖括号<>内定义类型。

一旦你有了 CharacterController 的引用,你就可以在控制器上调用 Move()方法。向该方法传递一个向量,类似于鼠标旋转代码使用向量作为旋转值的方式。同样,类似于限制旋转值的方式,使用 Vector3.ClampMagnitude()来限制向量的长度到移动速度。使用限制是因为,否则,对角线移动的长度将大于沿轴直接移动的长度(想象一下直角三角形的边和斜边)。

但这里的移动向量有一个棘手的地方,这与局部和全局有关,正如我们之前在讨论旋转时提到的。我们将创建一个向量,其值用于向左移动,比如说。但这实际上是 玩家 的左侧,然而,这可能与 世界 的左侧完全不同——也就是说,我们在这里讨论的是局部空间中的左侧,而不是全局空间。

我们需要将定义在全局空间中的移动向量传递给 Move() 方法,因此我们需要将局部空间向量转换为全局空间向量。这个转换过程涉及复杂的数学计算,但幸运的是,Unity 已经为我们处理了这些数学问题,我们只需调用 TransformDirection() 方法即可,嗯,转换方向。

在这个上下文中,变换 的定义是指从一个坐标系转换到另一个坐标系(如果你不记得坐标系是什么,请参考第 2.3.3 节)。不要与其他变换的定义混淆,包括 Transform 组件和对象在场景中移动的动作。这是一个有点过载的术语,因为所有这些含义都指代同一个基本概念。

现在测试移动代码。如果你还没有这样做,将 MouseLook 组件设置为水平和垂直旋转。你可以完全环顾场景,并通过使用键盘控制来在场景中飞行。如果你想让玩家在场景中飞行,这非常棒,但如果你想让玩家行走而不是飞行怎么办?

2.5.4 调整组件以实现行走而不是飞行

现在碰撞检测已经生效,脚本可以包含重力,玩家将保持在地板上。声明一个重力变量,并使用该值作为 y 轴。

列表 2.12 向移动代码添加重力

...
public float gravity = -9.8f;
...
void Update() {
  ...
  movement = Vector3.ClampMagnitude(movement, speed);

  movement.y = gravity;             ❶

  movement *= Time.deltaTime;
  ...

❶ 使用重力值而不是仅仅 0。

现在玩家有一个恒定的向下力,但它并不总是指向正下方,因为玩家对象可以随着鼠标上下倾斜。幸运的是,我们需要的所有东西都已经就位,所以我们只需要对玩家上组件的设置进行一些小的调整。首先,将玩家对象上的 MouseLook 组件设置为仅水平旋转。将 MouseLook 组件添加到相机对象上,并将该设置为仅垂直旋转。没错;你将有两个对象响应鼠标!

因为玩家对象现在只进行水平旋转,所以重力向下的力不再倾斜。相机对象是玩家对象的子对象(记得我们在 Hierarchy 视图中做的那件事吗?),所以尽管相机垂直旋转独立于玩家,但相机在水平方向上与玩家一起旋转。

精炼完成的脚本

使用 RequireComponent 属性来确保脚本需要的其他组件也被附加。有时其他组件是可选的(也就是说,代码中会说,“如果这个其他组件也被附加,那么……”),但有时你希望其他组件是强制性的。将 RequireComponent 添加到脚本的顶部以强制依赖,并在括号内提供所需的组件作为参数。

同样,如果你将 AddComponentMenu 属性添加到你的脚本顶部,那么该脚本将被添加到 Unity 编辑器的组件菜单中。告诉属性你想要添加的菜单项的名称,然后当你点击检查器底部的添加组件时,可以选择脚本。方便!同时添加了这两个属性的脚本看起来可能像这样:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
[AddComponentMenu("Control Script/FPS Input")]
public class FPSInput : MonoBehaviour {
...

列表 2.13 显示了完整的完成脚本。除了对玩家上组件设置的微小调整外,玩家可以在房间里四处走动。即使应用了重力变量,你仍然可以通过在检查器中将重力设置为 0 来使用此脚本进行飞行移动。

列表 2.13 完成的 FPSInput 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
[AddComponentMenu("Control Script/FPS Input")]
public class FPSInput : MonoBehaviour {
  public float speed = 6.0f;
  public float gravity = -9.8f;

  private CharacterController charController;

  void Start() {
    charController = GetComponent<CharacterController>();
  }

  void Update() {
    float deltaX = Input.GetAxis("Horizontal") * speed;
    float deltaZ = Input.GetAxis("Vertical") * speed;
    Vector3 movement = new Vector3(deltaX, 0, deltaZ);
    movement = Vector3.ClampMagnitude(movement, speed);

    movement.y = gravity;

    movement *= Time.deltaTime;
    movement = transform.TransformDirection(movement);
    charController.Move(movement);
  }
}

恭喜你构建了这个 3D 项目!在本章中,我们涵盖了大量的内容,现在你对如何在 Unity 中编写移动代码已经非常熟悉。尽管这个第一个演示很令人兴奋,但它离成为一个完整的游戏还差得很远。毕竟,项目计划将这个描述为一个基本的 FPS 场景,如果你不能射击,那射击游戏又是什么呢?所以,为这一章的项目给自己一个应得的掌声,然后准备下一步。

摘要

  • 3D 坐标空间由 x、y 和 z 轴定义。

  • 房间中的物体和灯光设置场景。

  • 第一人称场景中的玩家本质上是一个摄像头。

  • 移动代码在每个帧中重复应用小的变换。

  • FPS 控制包括鼠标旋转和键盘移动。

3 向 3D 游戏添加敌人和弹丸

本章涵盖

  • 对准和射击,包括玩家和敌人的射击

  • 检测并响应击中

  • 制作四处游荡的敌人

  • 在场景中生成新对象

上一章中的移动演示相当酷,但仍然不是一个真正的游戏。让我们将这个移动演示变成一个第一人称射击游戏。如果你现在考虑我们还需要什么,归结起来就是射击的能力和射击的目标。

首先,我们将编写脚本,使玩家能够向场景中的对象射击。然后,我们将构建敌人来填充场景,包括四处游荡和被击中时做出反应的代码。最后,我们将使敌人能够反击,向玩家发射火球。第二章中的任何脚本都不需要更改;相反,我们将向项目中添加脚本——处理额外功能的脚本。

我选择第一人称射击游戏作为这个项目的几个原因之一。一个简单的原因是 FPS 游戏很受欢迎:人们喜欢射击游戏,所以让我们做一个射击游戏。一个更微妙的原因与你会学到的技术有关;这个项目是学习 3D 模拟中几个基本概念的好方法。例如,射击游戏是教授光线投射的绝佳方式。稍后我们将深入了解这是什么,但就现在而言,你需要知道的是,它是一个在 3D 模拟中许多任务中非常有用的概念。尽管光线投射在许多情况下都很有用,但使用光线投射对于射击来说最直观。

创建游荡的目标进行射击为我们提供了一个很好的理由来探索计算机控制角色的代码,以及使用发送消息和生成对象的技巧。实际上,这种游荡行为是光线投射有价值的另一个地方,所以我们将在学习射击后立即查看技术的另一种应用。同样,在这个项目中展示的消息发送方法在其他地方也很有用。在未来的章节中,你将看到这些技术的其他应用,甚至在这个项目中我们也会探讨不同的情境。

最终,我们将一次添加一个新功能来处理这个项目,游戏在每一步都是可玩的,但同时也总感觉下一部分还有缺失的部分要工作。这个路线图将步骤分解成小而可理解的变化,每次只添加一个新功能:

  1. 编写代码使玩家能够向场景中射击。

  2. 创建对击中做出反应的静态目标。

  3. 让目标四处游荡。

  4. 自动生成游荡的目标。

  5. 允许目标/敌人向玩家发射火球。

注意:本章的项目假设你已经有一个可以在此基础上构建的第一人称移动演示。我们在第二章中创建了一个移动演示,但如果你直接跳到了这一章,你需要下载第二章的示例文件。

3.1 通过射线投射射击

在 3D 演示中引入的第一个新功能是射击。环顾四周和移动对于第一人称射击游戏当然是关键功能,但直到玩家可以影响模拟并应用他们的技能之前,这还不是一款游戏。在 3D 游戏中,射击可以通过几种方法实现,其中最重要的方法之一是射线投射。

3.1.1 什么是射线投射?

如其名所示,射线投射 是将射线投射到场景中。清楚了吗?好吧,那么射线究竟是什么?

定义:射线 是场景中从某一点出发并沿特定方向延伸的想象或不可见线。

在射线投射中,你创建一个射线,然后确定它与什么相交。图 3.1 阐述了这一概念。考虑当你从枪中发射子弹时会发生什么:子弹从枪的位置开始,然后沿直线向前飞行,直到击中某个物体。射线与子弹的路径类似,射线投射与发射子弹并查看它击中了什么类似。

CH03_F01_Hocking3

图 3.1 射线是一个想象中的线,射线投射是找到这条线与什么相交。

如你所想,射线投射背后的数学通常很复杂。不仅计算一条线与 3D 平面的交点很棘手,而且你还需要对场景中所有网格对象的全部多边形都这样做(记住,网格对象 是由许多连接的线和形状构成的 3D 可视化)。幸运的是,Unity 处理了射线投射背后的困难数学,但你仍然需要担心更高级的问题,比如射线从哪里投射以及为什么。

在这个项目中,对于后一个问题(为什么)的答案是模拟子弹射入场景。对于第一人称射击游戏,射线通常从摄像机位置开始,然后穿过摄像机视场的中心。换句话说,你正在检查摄像机正前方的物体;Unity 提供了使这项任务变得简单的命令。让我们看看这些命令。

3.1.2 使用 ScreenPointToRay 命令进行射击

你将通过投射从摄像机开始并延伸到视场中心的射线来实现射击。Unity 提供了 ScreenPointToRay() 方法来执行此操作。

图 3.2 展示了调用此方法时发生的情况。它创建了一个从摄像头开始并投射到角度的射线,通过给定的屏幕坐标。通常,鼠标位置坐标用于 鼠标拾取(选择鼠标下的对象),但对于第一人称射击,使用屏幕中心。一旦有了射线,就可以将其传递给 Physics.Raycast() 方法,使用该射线进行射线投射。

CH03_F02_Hocking3

图 3.2 ScreenPointToRay() 方法从摄像头通过给定的屏幕坐标投射射线。

让我们编写使用我们刚才讨论的方法的代码。在 Unity 中,创建一个新的 C# 脚本,命名为 RayShooter,将其附加到摄像头(而不是玩家对象),然后在此列表中编写代码。

列表 3.1 将附加到摄像头的 RayShooter 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RayShooter : MonoBehaviour {
  private Camera cam;

  void Start() {
    cam = GetComponent<Camera>();                                          ❶
  }

  void Update() {
    if (Input.GetMouseButtonDown(0)) {                                     ❷
      Vector3 point = new Vector3(cam.pixelWidth/2, cam.pixelHeight/2, 0); ❸
      Ray ray = cam.ScreenPointToRay(point);                               ❹
      RaycastHit hit;
      if (Physics.Raycast(ray, out hit)) {                                 ❺
        Debug.Log("Hit " + hit.point);                                     ❻
      }
    }
  }
}

❶ 访问附加到同一对象的其他组件。

❷ 响应左(第一个)鼠标按钮。

❸ 屏幕的中间是其宽度和高度的一半。

❹ 使用 ScreenPointToRay() 在该位置创建射线。

❺ 射线投射将信息填充到引用变量中。

❻ 获取射线击中的坐标。

在此代码列表中,你应该注意几个要点。首先,在 Start() 中检索了 Camera 组件,就像上一章中的 CharacterController 一样。然后,将剩余的代码放在 Update() 中,因为它需要反复检查鼠标,而不是只检查一次。Input.GetMouseButtonDown() 方法返回 true 或 false,取决于鼠标是否被点击,因此将此命令放在条件语句中意味着只有当鼠标被点击时,包含的代码才会运行。你希望在玩家点击鼠标时射击,因此对鼠标按钮进行了条件检查。

创建了一个向量来定义射线的屏幕坐标(记住向量是一组相关数字存储在一起)。摄像头的 pixelWidth 和 pixelHeight 值给出了屏幕的大小,所以将这些值除以二就得到了屏幕中心。尽管屏幕坐标是二维的,只有水平和垂直分量,没有深度,但创建了一个 Vector3,因为 ScreenPointToRay() 需要这种数据类型(可能是因为计算射线涉及到 3D 向量的算术)。使用这组坐标调用了 ScreenPointToRay(),结果得到一个 Ray 对象(一个代码对象,而不是游戏对象;两者有时可能会混淆)。

然后,射线被传递到 Raycast() 方法,但传递的不仅仅是这个对象。还有一个 RaycastHit 数据结构;RaycastHit 是关于射线交点的信息集合,包括交点发生的位置和被交对象。C# 语法中的 out 确保在命令中操作的数据结构与命令外存在的对象是相同的,而不是在不同函数作用域中的分离副本。

在设置好这些参数后,Physics.Raycast() 方法可以执行其工作。此方法检查给定射线的交点,填充关于交点的数据,并在射线击中任何物体时返回 true。因为返回的是一个布尔值,所以这个方法可以被放入条件检查中,就像你之前使用的 Input.GetMouseButtonDown() 一样。

目前,代码会发出一个控制台消息来指示何时发生交点。这个控制台消息显示了射线击中的点的 3D 坐标(我们在第二章讨论的 x、y、z 值)。但可能很难可视化射线击中的确切位置;同样,也可能很难确定屏幕中心的位置(射线射击通过的位置)。让我们添加视觉指示器来解决这两个问题。

3.1.3 添加瞄准和击中的视觉指示器

我们下一步是添加两种视觉指示器:屏幕中心的瞄准点和场景中射线击中的标记。对于第一人称射击游戏,后者通常是弹孔,但现在你将在该位置放置一个空白球体(并在 1 秒后使用协程移除球体)。图 3.3 显示了你将看到的内容。

定义 协程 是一种处理随时间增量执行的任务的方式。相比之下,大多数函数会让程序等待它们完成。

首先,让我们添加指示器来标记射线击中的位置。列表 3.2 显示了添加此功能后的脚本。在场景中四处走动,射击;看到球体指示器非常有趣!

CH03_F03_Hocking3

图 3.3 在添加瞄准和击中视觉指示器后重复射击

列表 3.2 添加了球体指示器的 RayShooter 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RayShooter : MonoBehaviour {
  private Camera cam;

  void Start() {
    cam = GetComponent<Camera>();
  }

  void Update() {                                             ❶
    if (Input.GetMouseButtonDown(0)) {
      Vector3 point = new Vector3(cam.pixelWidth/2, cam.pixelHeight/2, 0);
      Ray ray = cam.ScreenPointToRay(point);
      RaycastHit hit;
      if (Physics.Raycast(ray, out hit)) {
        StartCoroutine(SphereIndicator(hit.point));           ❷
      }
    }
  }

  private IEnumerator SphereIndicator(Vector3 pos) {          ❸
    GameObject sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
    sphere.transform.position = pos;

    yield return new WaitForSeconds(1);                       ❹

    Destroy(sphere);                                          ❺
  }
}

❶ 此函数主要与列表 3.1 中的射线投射代码相同。

❷ 在击中时启动一个协程。

❸ 协程使用 IEnumerator 函数。

❹ yield 关键字告诉协程在哪里暂停。

❺ 删除此 GameObject 并清除其内存。

新的方法是 SphereIndicator(),以及在现有的 Update() 方法中的一行修改。此方法在场景中的某个点创建一个球体,然后在第二秒后移除该球体。从射线投射代码中调用 SphereIndicator() 确保会有视觉指示器显示射线击中的确切位置。此函数使用 IEnumerator 定义,该类型与协程的概念相关联。

从技术上讲,协程不是异步的(异步操作不会停止其他代码的运行;想想在网站脚本的下载图像),但通过巧妙地使用枚举器,Unity 使协程的行为类似于异步函数。协程的秘密在于 yield 关键字;该关键字使协程暂时暂停,将程序流程交回,并在下一帧从该点继续。这样,协程似乎在程序的背景中运行,通过部分运行然后返回到程序其余部分的重复循环。

如其名所示,StartCoroutine()使协程开始运行。一旦协程开始,它就会一直运行直到函数完成;在运行过程中会暂停。注意一个微妙但重要的点,即传递给 StartCoroutine()的方法名后有括号:这种语法意味着你正在调用该函数,而不是传递其名称。被调用的函数会一直运行,直到遇到 yield 命令,此时函数会暂停。

SphereIndicator() 在特定位置创建一个球体,暂停以等待 yield 语句,然后在协程恢复后销毁球体。暂停的长度由 yield 返回的值控制。在协程中有几种类型的返回值可以工作,但最直接的是返回要等待的具体时间长度。返回 WaitForSeconds(1)会使协程暂停 1 秒。创建一个球体,暂停 1 秒,然后销毁球体:这个序列设置了一个临时的视觉指示器。

列表 3.2 给了你标记射线击中位置的指示器。但你还需要在屏幕中央有一个瞄准点。

列表 3.3 瞄准视觉指示器

...
void Start() {
  cam = GetComponent<Camera>();

  Cursor.lockState = CursorLockMode.Locked;          ❶
  Cursor.visible = false;                            ❶
}

void OnGUI() {
  int size = 12;                                     ❷
  float posX = cam.pixelWidth/2 - size/4;
  float posY = cam.pixelHeight/2 - size/2;
  GUI.Label(new Rect(posX, posY, size, size), "*");  ❸
}
...

❶ 将鼠标光标隐藏在屏幕中央。

❷ 这只是这个字体的大致大小。

❸ GUI.Label()命令在屏幕上显示文本。

RayShooter 类中已添加了一种新的方法,称为 OnGUI()。Unity 自带了一个基本的和更高级的 UI 系统。由于基本系统有很多限制,我们将在未来的章节中构建一个更灵活的高级 UI,但到目前为止,使用基本 UI 在屏幕中央显示一个点要容易得多。与 Start()和 Update()类似,每个 MonoBehaviour 都会自动响应 OnGUI()方法。该函数在 3D 场景渲染后每帧运行一次,导致在 OnGUI()期间绘制的所有内容都显示在 3D 场景之上(想象在风景画上贴上贴纸)。

定义 渲染 是计算机绘制 3D 场景像素的动作。尽管场景是用 x、y 和 z 坐标定义的,但你在显示器上实际看到的是彩色像素的二维网格。为了显示 3D 场景,计算机需要计算二维网格中所有像素的颜色;运行该算法被称为 渲染

在 OnGUI()内部,代码定义了显示的 2D 坐标(稍微调整以考虑标签的大小),然后调用 GUI.Label()。该方法显示一个文本标签。因为传递给标签的字符串是一个星号(*),所以你会在屏幕中央看到这个字符。现在在我们的初生 FPS 游戏中瞄准要容易得多!

列表 3.3 还将光标设置添加到 Start()方法中。所发生的一切只是设置了光标可见性和锁定状态的值。如果你省略光标值,脚本仍然可以完美工作,但这些设置使得第一人称控制工作得更加顺畅。鼠标光标将保持在屏幕中央,为了避免视图杂乱,光标将变为不可见,并且只有在按下 Esc 键时才会重新出现。

警告:始终记住,你可以按 Esc 键解锁鼠标光标,以便将其从游戏视图的中间移开。当鼠标光标被锁定时,无法点击播放按钮并停止游戏。

这就完成了第一人称射击代码……好吧,至少完成了交互的玩家端,但我们仍然需要处理目标。

3.2 编写响应式目标脚本

能够射击固然很好,但截至目前,玩家没有任何东西可以射击。我们将创建一个目标物体,并给它一个响应被击中的脚本。或者更确切地说,我们将稍微修改射击代码,以便在击中目标时通知目标,然后目标上的脚本会在收到通知时做出反应。

3.2.1 确定被击中的物体

首先,你需要创建一个新的射击目标。创建一个新的立方体对象(GameObject > 3D Object > Cube),然后将 Y 缩放设置为 2,将 X 和 Z 保持为 1。将新物体放置在 0, 1, 0 的位置,使其位于房间中央的地板上,并将物体命名为 Enemy。

创建一个名为 ReactiveTarget 的新脚本,并将其附加到新创建的盒子。很快,你将为这个脚本编写代码,但现在先让它保持默认设置;你提前创建这个脚本文件,因为下一个代码列表需要它存在才能编译。

返回 RayShooter 并按照以下列表修改射线投射代码。运行新代码并射击新目标;调试信息将出现在控制台而不是场景中的球体指示器。

列表 3.4 检测目标物体是否被击中

...
if (Physics.Raycast(ray, out hit)) {
  GameObject hitObject = hit.transform.gameObject;                   ❶
  ReactiveTarget target = hitObject.GetComponent<ReactiveTarget>();
  if (target != null) {                                              ❷
    Debug.Log("Target hit");
  } else {
    StartCoroutine(SphereIndicator(hit.point));
  }
}
...

❶ 检索射线击中的物体。

❷ 检查物体上的 ReactiveTarget 组件。

注意,你从 RaycastHit 中检索物体,就像检索球体指示器的坐标一样。技术上,击中信息并不返回被击中的游戏对象;它指示被击中的 Transform 组件。然后你可以通过 transform 属性访问 gameObject。

然后,你使用 GetComponent()方法在对象上检查它是否是反应性目标(即是否附加了 ReactiveTarget 脚本)。正如你之前看到的,该方法返回附加到 GameObject 上的特定类型的组件。如果没有附加该类型的组件,GetComponent()不会返回任何内容。你检查是否返回了 null,并在每种情况下运行不同的代码。

如果被击中的物体是反应性目标,代码将发出调试信息而不是启动球体指示器的协程。现在让我们通知目标物体它被击中,以便它能够做出反应。

3.2.2 通知目标它被击中

代码中只需要一行更改,如下所示。

列表 3.5 向目标对象发送消息

...
if (target != null) {
  target.ReactToHit();        ❶
} else {
  StartCoroutine(SphereIndicator(hit.point));
}
...

❶ 调用目标的方法而不是仅仅发出调试信息。

现在,射击代码调用目标的方法,因此让我们编写那个目标方法。在 ReactiveTarget 脚本中,编写下一列表中的代码。当你射击目标物体时,目标物体会倾倒并消失;参见图 3.4。

列表 3.6 当被击中时死亡的 ReactiveTarget 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ReactiveTarget : MonoBehaviour {

  public void ReactToHit() {                ❶
    StartCoroutine(Die());
  }

  private IEnumerator Die() {               ❷
    this.transform.Rotate(-75, 0, 0);

    yield return new WaitForSeconds(1.5f);

    Destroy(this.gameObject);               ❸
  }
}

❶ 被射击脚本调用的方法

❷ 推倒敌人,等待 1.5 秒,然后摧毁敌人。

❸ 一个脚本可以销毁自己(就像它可以销毁一个单独的对象一样)。

这段代码的大部分内容你应该已经从之前的脚本中熟悉了,所以我们只会简要地介绍它。首先,你定义 ReactToHit()方法,因为这是射击脚本中调用的方法名。这个方法启动一个与之前球体指示器代码相似的协程;主要区别在于它操作的是这个脚本的对象,而不是创建一个单独的对象。像 this.gameObject 这样的表达式指的是这个脚本附加到的 GameObject(this 关键字是可选的,因此代码可以不带有任何内容地引用 gameObject)。

协程函数的第一行使物体倾倒。正如在第二章中讨论的那样,旋转可以定义为围绕每个坐标轴 x、y 和 z 的角度。因为我们不希望物体左右旋转,所以将 Y 和 Z 设置为 0,并给 X 旋转分配一个角度。

CH03_F04_Hocking3

图 3.4 目标物体被击中后倾倒

注意:变换是即时应用的,但你可能更喜欢看到物体倾倒时的移动。一旦你开始寻找这本书之外的更高级主题,你可能想查找tweens,这是一种用于使物体在一段时间内平滑移动的系统。

方法中的第二行使用了协程至关重要的 yield 关键字,暂停函数在此处并返回在恢复之前需要等待的秒数。最后,游戏对象在函数的最后一行销毁自己。Destroy(this.gameObject)在等待时间后调用,就像代码在之前调用 Destroy(sphere)一样。

警告:务必在 this.gameObject 上调用 Destroy(),而不是简单地调用 this!不要混淆这两个;this 仅指代此脚本组件,而 this.gameObject 指代脚本附加到的对象。

目标现在对被射击做出反应——太好了!但它自己不做任何事情,所以让我们添加更多行为,使这个目标成为一个合适的敌人角色。

3.3 基本游荡型 AI

一个静态目标并不特别有趣,所以让我们编写代码让敌人四处游荡。游荡的代码几乎是人工智能(AI)或计算机控制实体的最简单例子。在这种情况下,实体是游戏中的敌人,但它也可能是现实世界中的机器人,或者是一个下棋的声音,例如。

3.3.1 绘制基本 AI 的工作原理图

存在多种 AI 实现方法(实际上,AI 是计算机科学家研究的主要领域)。就我们的目的而言,我们将坚持使用一种简单的方法。随着你变得更加熟练,你的游戏变得更加复杂,你可能想要探索各种 AI 实现方法。

图 3.5 展示了基本过程。在每一帧中,AI 代码将扫描其环境以确定是否需要做出反应。如果出现障碍物,敌人会转向面对不同的方向。无论敌人是否需要转向,它都会始终稳步前进。因此,敌人会在房间内来回弹跳,始终前进并转向以避开墙壁。

CH03_F05_Hocking3

图 3.5 基本 AI:前进和避开障碍物的循环过程

代码看起来相当熟悉,因为它通过使用与移动玩家前进相同的命令来推动敌人前进。AI 代码也将使用射线投射,类似于射击,但应用在不同的上下文中。

3.3.2 使用射线投射“看到”障碍物

正如你在本章引言中看到的,射线投射是 3D 模拟中用于多个任务的技术。一个容易理解的任务是射击,但射线投射还可以用于另一个任务,即扫描场景。鉴于扫描场景是 AI 代码中的一步,这意味着射线投射被用于 AI 代码中。

之前,你创建了一个从摄像机出发的射线,因为玩家就是从那里看的。这次,你将创建一个从敌人出发的射线。第一个射线从屏幕中心射出,但这次射线将在角色前方射出;图 3.6 展示了这一点。然后,就像射击代码使用 RaycastHit 信息来确定是否击中了什么以及在哪里一样,AI 代码将使用 RaycastHit 信息来确定敌人前方是否有物体,如果有,距离有多远。

CH03_F06_Hocking3

图 3.6 使用射线投射“看到”障碍物

射线追踪用于射击和用于 AI 的射线追踪之间的一个区别是射线的半径。对于射击,射线被视为无限薄的,但对于 AI,射线将被视为具有很大的横截面积。从代码的角度来看,这意味着使用 SphereCast()方法而不是 Raycast()方法。这种差异的原因是子弹很小,而检查角色前方是否有障碍物需要我们考虑角色的宽度。

创建一个新的脚本名为 WanderingAI,将其附加到目标对象(与 ReactiveTarget 脚本一起),并编写下一列表中的代码。现在播放场景,你应该看到敌人正在房间内徘徊;你仍然可以射击目标,它将以与之前相同的方式做出反应。

列表 3.7 基本 WanderingAI 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WanderingAI : MonoBehaviour {
  public float speed = 3.0f;                                   ❶
  public float obstacleRange = 5.0f;

  void Update() {
    transform.Translate(0, 0, speed * Time.deltaTime);         ❷

    Ray ray = new Ray(transform.position, transform.forward);  ❸
    RaycastHit hit;
    if (Physics.SphereCast(ray, 0.75f, out hit)) {             ❹
      if (hit.distance < obstacleRange) {
        float angle = Random.Range(-110, 110);                 ❺
        transform.Rotate(0, angle, 0);
      }
    }
  }
}

❶ 移动速度和反应障碍物的距离的值

❷ 每一帧都持续向前移动,无论转向与否。

❸ 与角色在同一位置且指向同一方向的射线

❹ 在射线周围进行圆形体积的射线追踪。

❺ 向一个半随机的新方向转向。

此列表添加了几个变量来表示移动速度和 AI 对障碍物做出反应的距离。然后,在 Update()方法中添加 transform.Translate()以实现持续向前移动(包括使用 deltaTime 进行帧率无关的运动)。在 Update()中,你还会看到与之前射击脚本非常相似的射线追踪代码;再次,这里使用相同的射线追踪技术来观察而不是射击。射线是通过使用敌人的位置和方向创建的,而不是使用相机。

如前所述,射线追踪计算是通过 Physics.SphereCast()方法完成的。此方法使用半径参数来确定要检测射线周围多远处的交点,但在其他所有方面,它与 Physics.Raycast()方法完全相同。这种相似性包括命令如何填充击中信息,如何检查交点,以及如何使用距离属性确保只有在敌人接近障碍物时才做出反应(而不是房间对面的墙壁)。

当敌人前方有障碍物时,代码会以半随机的方式旋转角色到一个新的方向。我说“半随机”是因为这些值被限制在这个情况下有意义的最大和最小值。具体来说,我们使用了 Unity 提供的 Random.Range()方法来获取一个在约束之间的随机值。在这种情况下,约束略超过一个精确的左转或右转,允许角色足够地转向以避开障碍物。

3.3.3 跟踪角色的状态

当前行为的一个奇怪之处在于,敌人被击中后倒下后仍然继续向前移动。这是因为,目前,Translate()方法每帧都会运行,无论发生什么。让我们对代码进行一些小的调整,以跟踪角色是否存活——或者用另一种(更技术性的)方式来说,我们想要跟踪角色的存活状态。

让代码跟踪并针对对象的当前状态做出不同的响应是编程许多领域的常见代码模式,而不仅仅是 AI。这种方法的更复杂实现被称为状态机,甚至可能是有限状态机

定义:一个有限状态机(FSM)是一种代码结构,其中跟踪对象的当前状态,状态之间存在明确的转换,并且代码根据状态的不同而表现不同。

我们不会实现完整的 FSM,但也不是巧合,FSM的缩写经常出现在 AI 讨论中。完整的 FSM 会有许多状态,对应于复杂 AI 应用的各种行为,但在这个基本的 AI 中,我们只需要跟踪角色是否存活。接下来的列表在脚本顶部添加了一个布尔值 isAlive,并且代码需要偶尔对该值进行条件检查。有了这些检查,只有当敌人存活时,移动代码才会运行。

列表 3.8 添加了存活状态的 WanderingAI 脚本

...
private bool isAlive;                                  ❶

void Start() {
  isAlive = true;                                      ❷
}

void Update() {
  if (isAlive) {                                       ❸
    transform.Translate(0, 0, speed * Time.deltaTime);
    ...
  }
}

public void SetAlive(bool alive) {                     ❹
  isAlive = alive;
}
...

❶ 跟踪敌人是否存活的布尔值

❷ 初始化该值。

❸ 只有当角色存活时才移动。

❹ 公共方法,允许外部代码影响“存活”状态

ReactiveTarget 脚本现在可以告诉 WanderingAI 脚本敌人是否存活。

列表 3.9 ReactiveTarget 在死亡时通知 WanderingAI

...
public void ReactToHit() {
    WanderingAI behavior = GetComponent<WanderingAI>();
    if (behavior != null) {                              ❶
        behavior.SetAlive(false);
    }
    StartCoroutine(Die());
}
...

❶ 检查这个角色是否有 WanderingAI 脚本;可能没有。

AI 代码结构

本章中的 AI 代码包含在一个单独的类中,这样学习和理解它就变得简单直接。这种代码结构对于简单的 AI 需求来说非常合适,所以不要担心你做错了什么,或者更复杂的代码结构是绝对必要的。对于更复杂的 AI 需求(例如具有多种高度智能角色的游戏),更健壮的代码结构可以帮助促进 AI 的开发。

正如第一章中关于组合与继承的例子所暗示的,有时你可能想要将 AI 的某些部分拆分到单独的脚本中。这样做将使你能够混合和匹配组件,为每个角色生成独特的行为。考虑一下你角色之间的相似性和差异性,这些差异将指导你设计代码架构。例如,如果你的游戏中有一些敌人会冲向玩家移动,而另一些则会在阴影中潜行,你可能想要将移动(Locomotion)作为一个独立的组件。然后你可以为 LocomotionCharge 和 LocomotionSlink 创建脚本,并在不同的敌人上使用不同的移动组件。

你想要的精确 AI 代码结构取决于你特定游戏的设计;没有一种正确的方法来做这件事。Unity 使设计这种灵活的代码架构变得容易。

3.4 生成敌人预制件

目前场景中只有一个敌人,当它死亡时,场景为空。让我们让游戏生成敌人,以便每当敌人死亡时,就会有一个新的敌人出现。在 Unity 中,这可以通过使用预制件轻松完成。

3.4.1 什么是预制件?

预制件是一种灵活的方法来视觉化定义交互对象。简而言之,一个预制件是一个完全 fleshed-out 游戏对象(已附加并设置好组件),它并不存在于任何特定的场景中,而是作为一个可以复制到任何场景的资产。

这种复制可以手动完成,以确保敌人对象(或其他预制件)在每一个场景中都是相同的。更重要的是,预制件也可以从代码中生成;你可以通过在脚本中使用命令将对象的副本放置到场景中,而不仅仅是通过在视觉编辑器中手动操作。

定义:资产是任何在项目视图中出现的文件;这些可以是 2D 图像、3D 模型、代码文件、场景等等。我在第一章中简要提到了这个术语,但直到现在才强调它。

预制件的副本称为实例,类似于实例指的是从类中创建的特定代码对象。尽量保持术语的一致性:预制件指的是存在于任何场景之外的游戏对象;实例指的是放置在场景中的对象的副本。

定义:与面向对象术语类似,实例化是指创建一个实例的动作。

3.4.2 创建敌人预制件

要创建预制件,首先在场景中创建一个将成为预制件的对象。因为我们的敌人对象将成为预制件,所以我们已经完成了第一步。现在我们只需将对象从层次结构视图拖动下来,并将其放入项目视图中;这将自动将该对象保存为预制件(见图 3.7)。

CH03_F07_Hocking3

图 3.7 从层次结构拖动对象到项目以创建预制件。

在层次结构视图中,原始对象的名称将变为蓝色,以表示它现在已链接到预制件。我们实际上不再需要场景中的对象(我们将生成预制件,而不是使用场景中已经存在的实例),所以现在删除敌人对象。如果你想进一步编辑预制件,只需在项目视图中双击预制件以打开它,然后点击层次结构视图左上角的返回箭头再次关闭它。

WARNING 自从 Unity 的早期版本以来,处理预制件的接口已经得到了很大的改进,但编辑预制件仍然可能引起混淆。例如,当你双击一个预制件后,你实际上并不在任何一个场景中,所以当你完成编辑预制件后,记得在层次结构视图中点击返回箭头。此外,如果你嵌套预制件(即一个预制件包含其他预制件),使用它们可能会变得令人困惑。

现在我们有了实际要生成到场景中的预制件对象,所以让我们编写代码来创建预制件的实例。

3.4.3 从不可见的 SceneController 实例化

尽管预制件本身在场景中不存在,但必须有一个对象存在于场景中,以便敌人生成代码可以附加到它。我们将创建一个空的游戏对象并将脚本附加到该对象,但该对象在场景中是不可见的。

TIP 在 Unity 开发中,使用空 GameObject 来附加脚本组件是一种常见的模式。这个技巧用于抽象任务,这些任务不适用于场景中的任何特定对象。Unity 脚本旨在附加到可见对象,但并非每个任务都适合这种方式。

选择 GameObject > 创建空对象,将新对象重命名为 Controller,并确保其位置为 0, 0, 0。(技术上,位置并不重要,因为对象是不可见的,但如果你将来要将任何对象作为其父对象,将其放置在原点会使生活更简单。)创建一个名为 SceneController 的脚本。

列表 3.10 SceneController 生成敌人预制件

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SceneController : MonoBehaviour {
  [SerializeField] GameObject enemyPrefab;                  ❶
  private GameObject enemy;                                 ❷

  void Update() {

    if (enemy == null) {                                    ❸
      enemy = Instantiate(enemyPrefab) as GameObject;       ❹
      enemy.transform.position = new Vector3(0, 1, 0);
      float angle = Random.Range(0, 360);
      enemy.transform.Rotate(0, angle, 0);
    }
  }
}

❶ 用于链接到预制件对象的序列化变量

❷ 用于跟踪场景中敌人实例的私有变量

❸ 仅当场景中不存在其他敌人时才生成新的敌人。

❹ 复制预制件对象的函数

将此脚本附加到控制器对象,然后在检查器中你会看到一个敌人预制件的变量槽位。这与公共变量类似,但有一个重要的区别。

小贴士:为了在 Unity 编辑器中引用对象,我建议用 SerializeField 装饰变量,而不是将它们声明为 public。如第二章所述,public 变量会出现在检查器中(换句话说,它们会被 Unity 序列化),所以大多数教程和示例代码都会使用 public 变量来序列化所有值。但是,这些变量也可以被其他脚本修改(毕竟,它们是 public 变量),而 SerializeField 属性允许你保持变量私有。C#默认将变量设置为 private,除非明确将其设置为 public,这在大多数情况下更好,因为你想在检查器中公开该变量,但不想让其他脚本更改其值。

警告:在版本 2019.4 之前,Unity 存在一个 bug,即 SerializeField 会导致编译器发出警告,指出该字段未初始化。如果你遇到这个 bug,脚本仍然可以正常工作,所以技术上你可以忽略这些警告或者通过在这些字段中添加= null 来消除它们。

将预制体资产从项目拖动到空变量槽位。当鼠标靠近时,你应该会看到槽位高亮显示,以指示可以在此处链接对象(见图 3.8)。一旦敌人预制体被链接到 SceneController 脚本,播放场景以查看代码的实际效果。敌人将像之前一样出现在房间中央,但现在如果你射击敌人,它将被一个新的敌人替换。这比只有一个永远消失的敌人要好得多!

CH03_F08_Hocking3

图 3.8 将敌人预制体链接到脚本的预制体槽位。

小贴士:将对象拖放到检查器的变量槽位中的这种方法是许多脚本中常见的一种实用技巧。在这里,我们将预制体链接到脚本,但你也可以链接场景中的对象,甚至可以链接到特定的组件(而不是整个 GameObject)。在未来的章节中,我们将经常使用这种技巧。

这个脚本的核心理法是 Instantiate()方法,所以请注意那行代码。当我们实例化预制体时,就在场景中创建了一个副本。默认情况下,Instantiate()返回一个通用的 Object 类型的新对象,但 Object 直接使用相当无用的,我们需要将其处理为 GameObject。在 C#中,使用 as 关键字进行类型转换,将一个类型的代码对象转换为另一个类型(使用语法 original-object as new-type)。

实例化的对象存储在 enemy 中,这是 GameObject 类的一个私有变量。(保持预制体和预制体实例之间的区别清晰:enemyPrefab 存储预制体;enemy 存储实例。)检查存储对象的 if 语句确保 Instantiate() 只在 enemy 为空(或用代码员的话说 null)时调用。变量一开始是空的,所以实例化代码在会话一开始就运行了一次。然后,Instantiate() 返回的对象存储在 enemy 中,这样实例化代码就不会再次运行。

因为敌人被射击后会自我销毁,这会导致敌人变量清空并再次运行 Instantiate()。这样,敌人就始终存在于场景中。

销毁 GameObject 和内存管理

当一个对象自我销毁时,现有的引用变为 null 是有些意外的。在像 C# 这样的内存管理编程语言中,通常你不能直接销毁对象;你只能解除引用,这样它们就可以自动销毁。在 Unity 中这仍然成立,但 GameObject 在幕后处理的方式使得它们看起来是直接被销毁的。

要在场景中显示对象,Unity 必须引用其场景图中的所有对象。因此,即使你在代码中移除了对 GameObject 的所有引用,这个场景图引用仍然会阻止对象被自动销毁。正因为如此,Unity 提供了 Destroy() 方法来告诉游戏引擎,“从场景图中移除此对象。”作为幕后功能的一部分,Unity 还重载了 == 操作符,在检查 null 时返回 true。技术上,该对象仍然存在于内存中,但它可能已经不再存在,所以 Unity 让它看起来像 null。你可以通过在销毁的对象上调用 GetInstanceID() 来确认这一点。

注意,Unity 的开发者已经考虑过将这种行为更改为更标准的内存管理。如果他们这样做,这个生成代码也需要相应地更改,可能需要将 (enemy==null) 检查与一个新的参数(如 (enemy.isDestroyed))交换。

(如果大部分讨论对你来说都是希腊语,那么就不用担心;这是一个针对对这些晦涩细节感兴趣的人的旁征博引的技术讨论。)

3.5 通过实例化对象进行射击

好吧,让我们给敌人添加一些更多的功能。就像我们对玩家所做的那样,首先我们让它们移动——现在让我们让它们射击!正如我在介绍射线投射时提到的,那只是实现射击的一种方法。另一种方法涉及实例化预制体,所以让我们采用这种方法来让敌人射击。本节的目标是在游戏时看到图 3.9。

CH03_F09_Hocking3

图 3.9 敌人向玩家射击火球。

3.5.1 创建投射物预制体

这次,射击将涉及场景中的弹体。使用射线投射进行射击基本上是瞬时的,鼠标点击的瞬间就会记录击中。但这次敌人将发射飞球穿过空气。诚然,它们会移动得相当快,但不是瞬间的,这给了玩家躲避的机会。我们不会使用射线投射来检测击中,而是使用碰撞检测(与防止移动玩家穿过墙壁的相同碰撞系统)。

代码将以与敌人相同的方式生成飞球:通过实例化一个预制体。正如前一小节所述,创建预制体的第一步是在场景中创建一个将成为预制体的对象,因此让我们创建一个飞球。

首先,选择“游戏对象”>“3D 对象”>“球体”。将新对象重命名为 Fireball。现在创建一个新的脚本,也称为 Fireball,并将其附加到该对象上。我们将在该脚本中编写代码,但暂时将其保留为默认设置,同时我们处理火球对象的几个其他部分。为了使其看起来像火球而不是一个灰色的球体,我们将给对象一个明亮的橙色。表面属性,如颜色,是通过材料控制的。

定义 一种材料是一组信息,它定义了任何 3D 对象(该材料附加到的对象)的表面属性。这些表面属性可以包括颜色、光泽度,甚至细微的粗糙度。

选择“资产”>“创建”>“材料”。将新材料命名为火焰并将其拖放到场景中的对象上。在项目视图中选择材料,以便在检查器中查看材料的属性。如图 3.10 所示,单击标签为 Albedo 的颜色块(这是一个技术术语,指的是表面的主要颜色)。单击它将在自己的窗口中弹出一个颜色选择器;滑动彩虹色的环和主要选择区域以将颜色设置为橙色。

CH03_F10_Hocking3

图 3.10 设置材料的颜色

我们还将使材料变得更亮,使其看起来更像火焰。调整 Emission 值(检查器中的其他属性之一)。复选框默认关闭,因此请将其打开以使材料变亮。

现在,您可以通过将对象从层次结构拖动到项目来将火球对象转换为预制体,就像您处理敌人预制体时做的那样。与敌人一样,我们现在只需要预制体,因此请删除层次结构中的实例。太好了——我们有一个新的预制体可以用作弹体!接下来是编写使用该弹体射击的代码。

3.5.2 射击弹体并与目标碰撞

让我们对敌人进行调整,以便发射火球。因为识别玩家的代码需要一个新的脚本(就像识别目标的代码需要 ReactiveTarget 一样),首先创建一个新的脚本,并将其命名为 PlayerCharacter。将此脚本附加到场景中的玩家对象上。现在打开 WanderingAI,并将此列表中的代码添加进去。

列表 3.11 为发射火球添加的 WanderingAI 修改

...
[SerializeField] GameObject fireballPrefab;                    ❶
private GameObject fireball;
...
if (Physics.SphereCast(ray, 0.75f, out hit)) {
  GameObject hitObject = hit.transform.gameObject;
  if (hitObject.GetComponent<PlayerCharacter>()) {             ❷
    if (fireball == null) {                                    ❸
      fireball = Instantiate(fireballPrefab) as GameObject;    ❹
      fireball.transform.position =
        transform.TransformPoint(Vector3.forward * 1.5f);      ❺
      fireball.transform.rotation = transform.rotation;
    }
  }
  else if (hit.distance < obstacleRange) {
    float angle = Random.Range(-110, 110);
    transform.Rotate(0, angle, 0);
  }
}
...

❶ 在任何方法之前添加这两个字段,就像在 SceneController 中一样。

❷ 玩家被检测的方式与 RayShooter 中的目标对象相同。

❸ 与 SceneController 相同的空 GameObject 逻辑

❹ Instantiate()方法在这里与 SceneController 中的用法相同。

❺ 将火球放置在敌人前方,并指向相同的方向。

你会注意到,这个列表中的所有注释都指的是之前脚本中类似(或相同)的部分。之前的代码列表显示了发射火球所需的所有内容;现在我们正在将代码的片段混合和重新组合以适应新的上下文。

就像在 SceneController 中一样,你需要在脚本顶部添加两个 GameObject 字段:一个序列化变量用于链接预制件,一个私有变量用于跟踪代码创建的实例。在执行射线投射后,代码会检查被击中的对象上的 PlayerCharacter;这与射击代码检查被击中的对象上的 ReactiveTarget 的方式相同。当场景中还没有火球时,实例化火球的代码与实例化敌人的代码类似。不过,这次放置实例的位置是在敌人前方,并指向相同的方向。

一旦所有新的代码都到位,当你选择敌人预制件时,检查器中会出现一个新的 Fireball Prefab 槽位,就像 Scene-Controller 组件中的敌人预制件槽位一样。在项目视图中单击敌人预制件(双击实际上打开预制件,但只需单击一次即可选择它),检查器将显示该对象的组件,就像你在场景中选择了对象一样。尽管在编辑预制件时,关于界面不灵活的警告通常适用,但界面使得在不打开预制件的情况下调整预制件上的组件变得容易,这正是我们所做的。如图 3.11 所示,将 Fireball 预制件从项目拖动到检查器中的 Fireball Prefab 槽位(再次,就像你处理 SceneController 一样)。

CH03_F11_Hocking3

图 3.11 将火球预制件链接到脚本的预制件槽位。

现在当玩家直接在敌人前方时,敌人将会向玩家开火……嗯,尝试开火。明亮的橙色球体出现在敌人前方,但只是静静地在那里,因为我们还没有为其编写脚本。现在让我们来做这件事。

列表 3.12 反应碰撞的火球脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Fireball : MonoBehaviour {
  public float speed = 10.0f;
  public int damage = 1;

  void Update() {
    transform.Translate(0, 0, speed * Time.deltaTime);              ❶
  }

  void OnTriggerEnter(Collider other) {                             ❷
    PlayerCharacter player = other.GetComponent<PlayerCharacter>();
    if (player != null) {                                           ❸
      Debug.Log("Player hit");
    }
    Destroy(this.gameObject);
  }
}

❶ 向其面向的方向前进。

❷ 当另一个对象与此触发器碰撞时调用

❸ 检查其他对象是否为 PlayerCharacter。

这段代码的关键新部分是 OnTriggerEnter() 方法,当对象发生碰撞时(例如与墙壁或玩家碰撞)会自动调用。目前,这段代码还不能完全工作;如果你运行它,火球会由于 Translate() 行而向前飞行,但触发器不会运行,通过销毁当前火球来排队生成新的火球。还需要对火球对象上的组件进行一些其他调整。第一个更改是将碰撞器设置为触发器。要调整这一点,请转到检查器并点击 Sphere Collider 组件中的 Is Trigger 复选框。

提示 设置为触发器的碰撞器组件仍然会响应接触/重叠其他对象,但将不再阻止其他对象物理上穿过。

火球还需要一个 Rigidbody 组件,这是 Unity 中物理系统使用的组件。通过给火球添加 Rigidbody 组件,你可以确保物理系统能够为该对象注册碰撞触发器。在检查器的底部点击添加组件,并选择 Physics > Rigidbody。在添加的组件中,取消选择 Use Gravity(见图 3.12),这样火球就不会被重力拉下。

CH03_F12_Hocking3

图 3.12 在 Rigidbody 组件中关闭重力。

现在开始游戏,当火球击中物体时,火球会被销毁。因为火球发射代码在场景中没有火球时运行,敌人会向玩家发射更多的火球。现在只剩下最后一件事要做:让玩家对被击中做出反应。

3.5.3 玩家受伤

之前,你创建了一个 PlayerCharacter 脚本,但留空了。现在,你将编写代码让玩家对被击中做出反应。

列表 3.13 可受伤害的玩家

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerCharacter : MonoBehaviour {
  private int health;

  void Start() {
    health = 5;                           ❶
  }

  public void Hurt(int damage) {
    health -= damage;                     ❷
    Debug.Log($"Health: {health}");       ❸
  }
}

❶ 初始化健康值。

❷ 减少玩家的健康值。

❸ 使用字符串插值构造消息。

列表定义了一个玩家的健康字段,并按命令减少健康值。在后面的章节中,我们将介绍文本显示,以在屏幕上显示信息,但到目前为止,我们只能通过调试消息来显示有关玩家健康的信息。

定义 字符串插值是一种将代码的评估(例如,变量的值)插入到字符串中的机制。包括 C# 在内的几种编程语言支持字符串插值。例如,看看列表 3.13 中的健康信息。

现在,你需要回到火球脚本中调用玩家的 Hurt() 方法。将火球脚本中的调试行替换为 player.Hurt(damage),以告知玩家他们被击中。这就是我们需要的最后一部分代码!

哇!这一章内容相当紧凑,引入了大量的代码。将前一章与这一章结合起来,你现在已经拥有了第一人称射击游戏的大部分功能。

摘要

  • 射线是投射到场景中的想象中的线。

  • 光线投射操作对射击和感知障碍物都很有用。

  • 使角色四处游荡涉及到基本的 AI。

  • 通过实例化预制件来创建新对象。

  • 协程用于在时间上分散函数。

4 为你的游戏开发图形

本章涵盖

  • 理解游戏开发中使用的艺术资产

  • 通过白盒构建原型关卡

  • 在 Unity 中使用 2D 图像

  • 导入自定义 3D 模型

  • 制作粒子效果

我们主要关注的是游戏的功能,而不是游戏的外观。这并非偶然——这本书主要关于在 Unity 中编程游戏。然而,了解如何处理和改进视觉效果同样重要。在我们回到书籍主要关注编码游戏各个部分之前,让我们花一章的篇幅学习游戏艺术,这样你的项目就不会总是以只有空白盒子四处滑动而告终。

游戏中的所有视觉内容都是由艺术资产组成的。但那究竟意味着什么呢?

4.1 理解艺术资产

艺术资产是游戏使用的单个视觉信息单元(通常是一个文件)。这个总称适用于所有视觉内容:图像文件是艺术资产,3D 模型是艺术资产,等等。实际上,艺术资产只是资产的一种特定类型,你已了解到它是游戏使用的任何文件(如脚本)——因此 Unity 中的主要资产文件夹。表 4.1 描述了构建游戏时使用的五种主要艺术资产类型。

表 4.1 艺术资产类型

艺术资产类型 定义
2D 图像 平面图片。为了进行现实世界的类比,2D 图像就像绘画和照片。
3D 模型 3D 虚拟对象(几乎是网格对象的同义词)。为了进行现实世界的类比,3D 模型就像雕塑。
材质 定义附着在材质上的任何对象表面属性的信息包。这些表面属性可以包括颜色、光泽度,甚至细微的粗糙度。
动画 定义相关对象运动的信包。这些是在事先创建的详细运动序列,而不是在实时计算位置代码。
粒子系统 一种有序的机制,用于创建和控制大量小移动对象。许多视觉效果,如火焰、烟雾或喷水,都是通过这种方式创建的。

为新游戏创建艺术通常从 2D 图像或 3D 模型开始,因为那些资产构成了其他一切的基础。正如名称所暗示的,2D 图像是 2D 图形的基础,而3D 模型是 3D 图形的基础。具体来说,2D 图像是平面图片。即使你对游戏艺术没有先前的了解,你也可能已经从网站上的图形中熟悉了 2D 图像;另一方面,3D 模型可能需要为新来者定义。

定义一个模型是一个 3D 虚拟对象。第一章介绍了术语网格对象,而3D 模型实际上是一个同义词。这两个术语经常互换使用,但网格对象严格指 3D 对象的几何形状(连接的线和形状),而模型则稍微模糊一些,通常包括对象的其它属性。

列表中接下来的两种资源类型是材质动画。与 2D 图像和 3D 模型不同,材质和动画在独立状态下不执行任何操作,并且对于新手来说理解起来更困难。2D 图像和 3D 模型可以通过现实世界的类比来理解:前者是绘画,后者是雕塑。材质和动画与现实世界没有直接的联系。相反,两者都是抽象的信息包,叠加在 3D 模型之上。实际上,材质在第三章中已经以基本的形式被介绍过了。

定义一个材质是定义它所附加的任何对象表面属性(如颜色、光泽等)的信息包。分别定义表面属性使得多个对象可以共享一个材质(例如,所有的城堡墙壁)。

继续使用艺术类比,你可以将材质视为雕塑所用的媒介(如粘土、黄铜、大理石等)。同样,动画也是附加到可见对象上的一个抽象信息层。

定义一个动画是定义相关对象运动的信息包。因为这些运动可以独立于对象本身定义,所以它们可以以混合匹配的方式与多个对象一起使用。

具体来说,想象一个角色四处走动的场景。角色的整体位置由游戏代码处理(例如,你在第二章中编写的移动脚本)。但脚部触地、手臂摆动和臀部旋转的详细动作是一个正在回放的动画序列;这个动画序列是一个艺术资源。

为了帮助您理解动画和 3D 模型之间的关系,让我们用一个木偶戏的类比:3D 模型是木偶,动画师是操纵木偶移动的木偶师,而动画是木偶动作的记录。这样定义的运动是在事先创建的,通常是小规模的运动,不会改变对象的整体定位。这与之前章节中在代码中执行的大型规模运动形成对比。

表 4.1 中的最后一种艺术资源是粒子系统。粒子系统对于创建视觉效果,如火焰、烟雾或喷水效果非常有用。

定义一个粒子系统是创建和控制大量移动对象的有序机制。这些移动对象通常是小的——因此得名粒子——但不必总是这样。

粒子(受粒子系统控制的单个对象)可以是任何你选择的网格对象。但为了大多数效果,粒子将是一个显示图片的方形(例如火焰火花或烟雾)。

创建游戏艺术的大部分工作是在外部软件中完成的,而不是在 Unity 本身中。材质和粒子系统是在 Unity 中创建的,但其他艺术资产是使用外部软件创建的。有关外部工具的更多信息,请参阅附录 B;用于创建 3D 模型和动画的多种艺术应用程序被使用。在外部工具中创建的 3D 模型然后被保存为 Unity 导入的艺术资产。我在附录 C 中解释如何建模时使用 Blender(从www.blender.org下载),但这仅仅是因为 Blender 是开源的,因此所有读者都可以使用。

注意:本章的项目下载中包含一个名为scratch的文件夹。尽管该文件夹与 Unity 项目位于同一位置,但它不是 Unity 项目的一部分;那里是我放置额外外部文件的地方。

在完成本章的项目时,你将看到大多数这些类型的艺术资产示例(动画目前较为复杂,将在本书后面的章节中讨论)。你将构建一个使用 2D 图像、3D 模型、材质和粒子系统的场景。在某些情况下,你将引入已经存在的艺术资产并学习如何将它们导入 Unity,但在其他时候(尤其是粒子系统),你将在 Unity 内部从头创建艺术资产。

本章仅对游戏艺术创作进行了初步探讨。因为本书侧重于 Unity 编程,对艺术学科的广泛覆盖将减少本书所能涵盖的内容。创建游戏艺术是一个庞大的主题,本身就能填满几本书。在大多数情况下,游戏程序员需要与专注于该领域的游戏艺术家合作。尽管如此,对于游戏程序员来说,了解 Unity 如何与艺术资产协同工作以及可能甚至创建自己的粗略替代品(通常称为程序员艺术)是非常有用的。

注意:本章没有直接要求使用前几章的项目。但你会想要有类似于第二章中的移动脚本,这样你就可以在构建的场景中四处走动。如有必要,你可以从项目下载中获取玩家对象和脚本。同样,本章结束时将移动与之前章节中创建的对象相似的对象。

4.2 构建基本 3D 场景:白盒建模

我们将要讨论的第一个内容创建主题是白盒化。这个过程通常是计算机上构建关卡的第一步(在纸上设计关卡之后)。正如其名所示,你用空白几何形状(白色盒子)遮挡场景的墙壁。查看表 4.1 中的艺术资产列表,这种空白场景是最基本的 3D 模型,它提供了一个基础,可以在其上显示 2D 图像。

如果你回想起第二章中创建的原始场景,那基本上就是白盒化(你只是还没有学到这个术语)。本节的一些内容将是第二章开头所做工作的重复,但这次我们会更快地覆盖这个过程,并讨论更多新术语。

注意 另一个经常使用的术语是灰盒化。它的意思相同。我倾向于使用白盒化,因为这是我首先学到的术语,但其他人使用灰盒化,这也是可以接受的。实际使用的颜色无论如何都会有所不同,就像蓝图不一定是蓝色一样。

4.2.1 白盒化解释

使用空白几何形状遮挡场景有几个作用。首先,这个过程使你能够快速构建一个草图,这个草图会随着时间的推移逐步完善。这个活动与关卡设计密切相关,或者说是关卡设计师的工作。

定义 关卡设计是规划和创建游戏场景(或关卡)的学科。关卡设计师是关卡设计的实践者。

随着游戏开发团队规模的扩大和团队成员的专业化,一个常见的关卡构建工作流程是关卡设计师通过白盒化创建关卡的第一版。这个粗糙的关卡随后交给艺术团队进行视觉润色。但在小型团队中,即使同一个人既设计关卡又为游戏创建艺术,这种先进行白盒化然后润色视觉的工作流程通常效果最好。毕竟,你必须从某个地方开始,而白盒化提供了一个清晰的基础,可以在此基础上构建视觉元素。

白盒化的第二个作用是关卡可以快速达到可玩状态。这个关卡可能还没有完成(实际上,白盒化后的关卡离完成还很远),但这个粗糙版本是功能性的,可以支持游戏玩法。至少,玩家可以在场景中四处走动(想想第二章中的演示)。这样,你可以在投入大量时间和精力进行详细工作之前,测试以确保关卡组合得很好(例如,房间的大小是否适合这个游戏?)。如果有什么问题(比如说你意识到空间需要更大),在白盒化阶段进行更改和重新测试要容易得多。

此外,能够玩到正在建造中的关卡是一种巨大的士气提升。不要低估这个好处:为场景制作所有视觉元素可能需要花费大量时间,而且不得不长时间等待才能在游戏中体验任何这些工作,这可能会开始感觉像是一场苦役。白箱建造立即构建一个完整的(如果有些原始)关卡,然后随着游戏的不断改进,玩这个游戏会变得非常兴奋。

好吧,现在你理解了为什么关卡从白箱开始。现在让我们来构建一个关卡吧!

4.2.2 为关卡绘制平面图

在计算机上构建关卡的过程与在纸上设计关卡的过程相似。我们不会深入讨论关卡设计;正如第二章关于游戏设计所述,关卡设计(它是游戏设计的一个子集)是一个庞大的学科,可以单独填满一本书。为了我们的目的,我们将绘制一个基本关卡,计划中涉及的设计很少,以便我们有一个目标去努力。

图 4.1 是四个房间通过一个中央走廊连接的简单布局的俯视图。现在我们需要的计划就是这个:一些分隔的区域和内部墙壁来放置。在一个真正的游戏中,你的计划会更加广泛,包括敌人、物品等。

CH04_F01_Hocking3

图 4.1 关卡平面图:四个房间和一个中央走廊

你可以通过构建这个平面图来练习白箱建造,或者你也可以自己绘制一个简单的关卡来练习这一步骤。对于这个练习来说,房间布局的具体细节并不重要。对我们来说,重要的是要绘制一个平面图,这样我们才能继续下一步。

4.2.3 按照计划布置原语

根据绘制的平面图构建白箱关卡涉及定位和缩放许多空白盒子,使其成为图中的墙壁。如 2.2.1 节所述,选择 GameObject > 3D Object > Cube 来创建一个空白盒子,你可以根据需要定位和缩放它。

在 Unity 中进行更高级别的关卡编辑

在本章介绍的工作流程中,关卡首先使用原语进行初步布局,然后在外部 3D 艺术工具中构建最终关卡几何形状。然而,Unity 还提供了 ProBuilder,这是一个更强大的关卡编辑工具。你仍然可以选择使用它来为在外部 3D 艺术工具中详细化的关卡进行初步布局,但 ProBuilder 甚至可以成为你唯一的关卡设计工具。

打开包管理器窗口(选择 Window > Package Manager),在 Unity 注册表中搜索 ProBuilder。一旦安装了该包,它就会像 Unity 网站上描述的那样运行(unity.com/features/probuilder)。

同时,编辑关卡的不同方法被称为构造实体几何学(CSG)。在该方法中,你使用称为刷子的形状,从初始原型到最终关卡几何形状的所有内容都在 Unity 中构建。有关更多信息,请访问实时 CSG(realtimecsg.com)。

第一个对象将是场景的地板。在检查器中,重命名对象并将其降低到-0.5 Y,以考虑盒子的自身高度(图 4.2 展示了这一点)。然后沿 x 轴和 z 轴拉伸对象。

CH04_F02_Hocking3

图 4.2 检查器视图中的盒子已定位并缩放以适应地板

重复这些步骤以创建场景的墙壁。你可能想要通过将墙壁设置为公共基对象的子对象来清理层次结构视图(记住,将根对象定位在 0, 0, 0,然后在层次结构中将其拖动到上面),但这不是必需的。此外,在场景周围放置一些简单的灯光,以便你可以看到它;参照第二章,通过在 GameObject 菜单的 Light 子菜单中选择灯光来创建灯光。完成白盒化后,关卡应该看起来像图 4.3 所示。

CH04_F03_Hocking3

图 4.3 图 4.1 中地板平面图的白盒关卡

设置玩家对象或相机以移动(使用角色控制器和移动脚本创建玩家;如果需要完整说明,请参阅第二章)。现在你可以绕着原始场景走动,体验你的工作并对其进行测试。这就是白盒化的方法!很简单——但现在你只有空白几何形状,所以让我们用墙上的图片来装饰几何形状。

将白盒几何形状导出到外部艺术工具

在为关卡添加视觉效果时,大部分工作都是在外部 3D 艺术应用程序(如 Blender)中完成的。因此,你可能希望在艺术工具中保留白盒几何形状以供参考。默认情况下,Unity 中没有导出 Unity 内部布局的原始形状的选项,但 Unity 提供了一个可选包(称为 FBX Exporter),该包将此功能添加到编辑器中。

打开包管理器并搜索 FBX Exporter。这是一个预览包,因此你需要在包管理器窗口的高级菜单中选择显示预览包。安装该包后,它将按照 Unity 文档中的描述运行(mng.bz/AOYW)。

偶然的是,对于使用 ProBuilder 制作的关卡,你不需要这个包,因为之前提到的这个高级关卡编辑工具已经包含了模型导出功能。

4.3 使用 2D 图像纹理化场景

到目前为止,关卡只是一个粗略的草图。它是可玩的,但很明显,场景的视觉效果还需要做更多的工作。提高关卡外观的下一步是应用纹理。

定义 纹理 是指用于增强 3D 图形的 2D 图像。这就是该术语的全部含义;不要混淆,认为纹理的任何用途都是该术语定义的一部分。无论图像如何使用,它仍然被称为纹理。

备注 纹理 通常既用作动词也用作名词。除了名词定义外,该词还描述了在 3D 图形中使用 2D 图像的动作。

纹理在 3D 图形中有多种用途,但最直接的使用是将它们显示在 3D 模型的表面上。在本章的后面部分,我们将讨论这对于更复杂模型的工作方式,但对于我们的白盒级别,2D 图像将充当覆盖墙壁的壁纸(见图 4.4)。

CH04_F04_Hocking3

图 4.4 比较纹理前后级别

如图 4.4 所示的比较可知,纹理将原本明显不真实的数字结构变成了砖墙。纹理的其他用途包括用于切割形状的蒙版和用于使表面凹凸不平的法线贴图。稍后,你可能需要查阅附录 D 中提到的资源中关于纹理的更多信息。

4.3.1 选择文件格式

可用于保存 2D 图像的文件格式有很多,那么你应该使用哪一种呢?Unity 支持使用许多文件格式,因此你可以选择表 4.2 中显示的任何一种。

表 4.2 Unity 支持的 2D 图像文件格式

文件类型 优点和缺点
PNG 常用于网络。无损压缩;具有 alpha 通道。
JPG 常用于网络。有损压缩;无 alpha 通道。
GIF 常用于网络。有损压缩;无 alpha 通道。(技术上,损失并非来自压缩;而是当图像转换为 8 位时数据丢失。最终,这导致相同的结果。)
BMP Windows 上的默认图像格式。无压缩;无 alpha 通道。
TGA 常用于 3D 图形;在其他地方则较为罕见。无或有损压缩;具有 alpha 通道。
TIFF 常用于数字摄影和出版。无或有损压缩;无 alpha 通道。
PICT 旧 Mac 上的默认图像格式。有损压缩;无 alpha 通道。
PSD Adobe Photoshop 的原生文件格式。无压缩;具有 alpha 通道。使用此文件格式的最主要原因可能是直接使用 Photoshop 文件的优势。

定义 alpha 通道 用于在图像中存储透明度信息。可见颜色包含三个信息通道:红色、绿色和蓝色。Alpha 是一个额外的信息通道,虽然不可见,但控制图像的透明度。

虽然 Unity 可以接受表 4.2 中显示的任何图像类型导入并用作纹理,但这些文件格式在它们支持的功能上差异很大。对于作为纹理导入的 2D 图像,有两个因素特别重要:图像是如何压缩的,以及它是否有 alpha 通道?

Alpha 通道是一个简单的考虑因素。由于 alpha 通道在 3D 图形中经常使用,因此具有 alpha 通道的图像更受欢迎。

图像压缩是一个稍微复杂一些的考虑因素,但归结起来就是“有损压缩是坏事。”既不压缩又无损压缩都能保持图像质量,而有损压缩在减小文件大小的过程中会降低图像质量(因此得名有损)。

在这两个考虑因素之间,我推荐的 Unity 纹理文件格式是 PNG 和 TGA。在 PNG 广泛用于互联网之前,Targas(TGA)曾是纹理 3D 图形的首选文件格式。如今,PNG 在技术上几乎相当,但更广泛地使用,因为它在网页和纹理中都很实用。

PSD 也是 Unity 纹理的常用推荐格式,因为它是一个高级文件格式,而且方便的是,你在 Photoshop 中工作的同一个文件也可以在 Unity 中使用。但我倾向于将工作文件与导出到 Unity 的“完成”文件分开(这种思维方式稍后还会在 3D 模型中使用)。

结果是,我在示例项目中提供的所有图像都是 PNG 格式,我也建议你使用该文件格式。做出这个决定后,是时候将一些图像导入 Unity 并将其应用到空白场景中了。

4.3.2 导入图像文件

让我们开始创建和准备我们将要使用的纹理。用于纹理级别的图像通常是可平铺的,这样它们就可以在地板等大面积上重复使用。

定义:一个可平铺的图像(有时也称为无缝平铺)是一个当并排放置时,相对边缘相匹配的图像。这样,图像可以重复使用,而重复之间没有任何可见的接缝。3D 纹理的概念就像网页上的壁纸。

你可以通过多种方式获取可平铺的图像,包括通过操纵照片甚至手工绘制。这些技术的教程和解释可以在许多书籍和网站上找到,但我们现在不想陷入其中。相反,让我们从提供此类图像目录的许多网站之一中获取一些可平铺的图像。

我从www.textures.com(见图 4.5)获取了一些图像,用于应用到该级别的墙壁和地板上。找到一些你认为适合地板和墙壁的图像;我选择了 BrickRound0067 和 BrickLargeBare0032。

CH04_F05_Hocking3

图 4.5 从 Textures.com 获取的无缝拼接的石头和砖块图像

下载你想要的图像,并准备将它们用作纹理。技术上,你可以直接使用下载的图像,但它们并不理想用作纹理。尽管它们当然是可以平铺的(你使用这些图像的重要原因),但它们的大小不正确,文件格式也不正确。

纹理的大小(以像素为单位)应该是 2 的幂。出于技术效率的考虑,图形芯片喜欢处理大小为 2^N 的纹理:4、8、16、32、64、128、256、512、1024、2048(下一个数字是 4096,但到那时图像太大,无法用作纹理)。在你的图像编辑器(Photoshop、GIMP 或任何其他编辑器;参考附录 B),将下载的图像缩放到 256 × 256 像素,并保存为 PNG 格式。

现在,将文件从计算机中的位置拖动到 Unity 的项目视图中。这将把文件复制到你的 Unity 项目中(见图 4.6),此时它们被导入为纹理,可以在 3D 场景中使用。如果拖动文件会显得尴尬,你可以在项目上右键单击并选择“导入新资源”来访问文件选择器。

CH04_F06_Hocking3

图 4.6 将图像从 Unity 外部拖动到项目视图中以导入它们。

TIP 当你的项目开始变得更加复杂时,将你的资源组织到单独的文件夹中可能是一个好主意。在项目视图中,为脚本和纹理创建文件夹,然后将资源移动到相应的文件夹中。只需将文件拖动到新文件夹即可。

WARNING Unity 在文件夹名称中响应几个关键字,并以特殊方式处理这些特殊文件夹的内容。这些关键字是 Resources、Plugins、Editor 和 Gizmos。本书后面将介绍一些特殊文件夹的功能,但到目前为止,请避免使用这些单词命名任何文件夹。

现在,图像已导入 Unity 作为纹理,准备使用。但我们如何将纹理应用到场景中的对象上呢?

4.3.3 应用图像

技术上,纹理不是直接应用到几何体上的。相反,纹理可以是材质的一部分,而材质则应用到几何体上。正如引言中解释的那样,材质是一组定义表面属性的信息;这些信息可以包括要显示在表面的纹理。这种间接性很重要,因为相同的纹理可以与多个材质一起使用。话虽如此,通常每个纹理都对应不同的材质,因此为了方便,Unity 允许你将纹理拖放到对象上,然后它会自动创建一个新的材质。

如果你将纹理从项目视图拖动到场景中的对象上,Unity 将创建一个新的材质并将其应用到对象上。图 4.7 说明了这个操作。现在尝试使用地板的纹理来做这个操作。

CH04_F07_Hocking3

图 4.7 应用纹理的一种方法是将它们从项目拖动到场景对象上。

除了这种方便的自动创建材质的方法之外,创建材质的“正确”方法是选择 Assets > Create > Material;新资产将出现在项目视图中。现在选择材质以在检查器中显示其属性(你将看到类似于图 4.8 的内容),并将纹理拖动到主纹理槽中;这个设置被称为 Albedo(这是基础颜色的技术术语),纹理槽是标签左侧的方形。同时,将材质从项目拖动到场景中的对象上以应用材质。现在尝试使用墙壁纹理执行这些步骤:创建一个新的材质,将墙壁纹理拖动到这个材质中,并将材质拖动到场景中的墙壁上。

CH04_F08_Hocking3

图 4.8 选择一个材质以在检查器中查看它,然后拖动纹理到材质属性中。

你现在应该看到石头和砖块图像出现在地板和墙壁对象表面,但这些图像看起来相当拉伸和模糊。单个图像被拉伸以覆盖整个地板。相反,你希望图像在地板表面上重复几次。

你可以通过使用材质的平铺属性来设置这种外观。在项目中选择材质,然后在检查器中更改平铺数字(每个方向都有单独的 X 和 Y 值用于平铺)。确保你正在设置主贴图的平铺,而不是次级贴图(此材质可选地使用次级纹理贴图以实现高级效果)。默认平铺是 1(这意味着没有平铺,图像被拉伸覆盖整个表面);将数字更改为类似 8 的值,看看场景中会发生什么。将两个材质中的数字都更改为看起来不错的平铺。

注意:像这样调整平铺属性仅适用于纹理化白箱几何体。在一个精良的游戏中,地板和墙壁将使用更复杂的艺术工具构建,这包括设置它们的纹理。

太棒了——现在场景的地板和墙壁上已经应用了纹理!你还可以将纹理应用到场景的天空。让我们看看这个过程。

4.4 使用纹理图像生成天空视觉效果

砖石纹理为墙壁和地板提供了更加自然的外观。然而,目前天空是空白的,看起来不自然;我们还想让天空看起来更真实。完成这项任务最常见的方法是使用天空的图片进行一种特殊的纹理处理。

4.4.1 什么是天空盒?

默认情况下,相机的背景颜色是深蓝色。通常,这种颜色会填充视图中的任何空白区域(例如,这个场景的墙壁上方),但可以将天空的图片渲染为背景。这就是天空盒的作用所在。

定义 一个 天空盒 是围绕相机的一个立方体,每个面都贴有天空的图片。无论相机朝向哪个方向,它都在看天空的图片。

正确实现天空盒可能有些棘手;图 4.9 展示了天空盒是如何工作的示意图。为了使天空盒看起来像远处的背景,需要使用渲染技巧。幸运的是,Unity 已经为你处理了所有这些。

CH04_F09_Hocking3

图 4.9 天空盒的示意图

新场景已经预置了一个简单的默认天空盒。这就是为什么天空从浅蓝到深蓝有渐变,而不是一个平坦的深蓝色。打开照明窗口(窗口 > 渲染 > 照明),切换到环境标签,然后注意第一个设置是天空盒材质。此窗口有多个与 Unity 的高级照明系统相关的设置面板,但到目前为止,我们只关心第一个设置。

就像之前提到的砖块纹理一样,天空盒图片可以从各种网站上获取。搜索 skybox textures 或从本书的示例项目中获取。例如,我从 Heiko Irrgang 在 93i.de/ 获取了 TropicalSunnyDay 天空盒图片集。一旦将这个天空盒应用到场景中,你将看到类似于图 4.10 的效果。

CH04_F10_Hocking3

图 4.10 带有天空背景图片的场景

与其他纹理一样,天空盒图片首先被分配给一个材质,然后在场景中使用。让我们看看如何创建一个新的天空盒材质。

4.4.2 创建新的天空盒材质

首先,创建一个新的材质(像往常一样,可以右键点击并选择创建,或者从资产菜单中选择创建),然后选择该材质以在检查器中查看其设置。接下来,你需要更改此材质使用的着色器。材质设置的顶部有一个着色器菜单(见图 4.11)。在 4.3 节中,我们基本上忽略了此菜单,因为默认设置对大多数标准纹理化来说已经足够好,但天空盒需要特殊的着色器。

CH04_F11_Hocking3

图 4.11 可用着色器的下拉菜单

定义 一个 着色器 是一个简短的程序,它概述了绘制表面的指令,包括是否使用任何纹理。计算机使用这些指令在渲染图像时计算像素。最常见的着色器会根据光线将材质的颜色变暗,但着色器也可以用于各种视觉效果。

每个材质都有一个控制它的着色器(你可以将材质视为着色器的一个实例)。新材质默认设置为标准着色器。此着色器显示材质的颜色(包括纹理),并在表面应用光线和阴影。

对于天空盒,Unity 有一个不同的着色器。单击菜单以查看下拉列表(见图 4.11)中所有可用的着色器。选择天空盒部分,并在子菜单中选择 6 面。使用此着色器后,材质现在有六个大纹理槽(而不是标准着色器中只有的小反照率纹理槽)。这六个纹理槽对应于立方体的六个面,因此这些图像应该在边缘处匹配以看起来无缝。例如,图 4.12 显示了晴朗天空盒的图像。

CH04_F12_Hocking3

图 4.12 天空盒侧面的六个图像

以与您导入砖块纹理相同的方式将天空盒图像导入 Unity:将文件拖动到项目视图或右键单击项目并选择导入新资产。我们需要更改一个细微的导入设置;单击导入的纹理以在检查器中查看其属性,并将包裹模式设置(如图 4.13 所示)从重复更改为限制。完成时不要忘记单击应用。通常,纹理可以在表面上重复平铺,为了看起来无缝,图像的相对边缘会混合在一起。但这种边缘混合会在图像相遇的天空处产生微弱的线条,因此限制设置(类似于第二章中的限制()函数)将限制纹理的边界并消除这种混合。

CH04_F13_Hocking3

图 4.13 通过调整包裹模式来调整正确的微弱边缘线。

现在,您可以将这些图像拖动到天空盒材质的纹理槽中。图像名称对应于要分配给它们的纹理槽(例如左侧或前方)。一旦所有六个纹理都连接起来,您就可以使用这种新材料作为场景的天空盒。再次打开照明窗口,并将此新材料设置为天空盒槽;要么将材质拖到该槽中,要么单击小圆圈图标以打开文件选择器。现在单击播放以查看新的天空盒。

TIP 默认情况下,Unity 将在编辑器的场景视图中显示天空盒(或至少其主要颜色)。在编辑对象时,您可能会发现这种颜色分散注意力,因此您可以切换天空盒的开启或关闭。在场景视图面板的顶部有按钮可以控制可见内容;查找效果按钮以切换天空盒的开启或关闭。

哇哦——您已经学会了如何为场景创建天空视觉效果!天空盒是一种优雅的方式来创建环绕玩家的广阔氛围的幻觉。在您的关卡中润色视觉效果的下一步是创建更复杂的 3D 模型。

4.5 使用自定义 3D 模型

在前面的章节中,我们讨论了将纹理应用到关卡的大平面墙壁和地板上。那么更详细的对象怎么办?如果你想在房间里放置有趣的家具怎么办?你可以通过在外部 3D 艺术应用程序中构建 3D 模型来实现这一点。回想一下本章引言中的定义:3D 模型是游戏中的网格对象(三维形状)。好吧,你将导入一个简单长椅的 3D 网格。

广泛用于建模 3D 对象的应用程序包括 Autodesk Maya 和 Autodesk 3ds Max。这两个都是昂贵的商业工具,因此本章的示例使用开源应用程序 Blender。示例下载包括一个可用的.blend 文件;图 4.14 展示了 Blender 中的长椅模型。如果你对学习如何建模自己的对象感兴趣,你将在附录 C 中找到一个关于在 Blender 中建模这个长椅的练习。

CH04_F14_Hocking3

图 4.14 Blender 中的长椅模型

除了你自己或你合作的艺术家创建的定制模型外,许多 3D 模型可以从游戏艺术网站上下载。3D 模型的一个很好的资源是 Unity Asset Store,在 Unity 内部或assetstore.unity.com都可以访问。

4.5.1 选择哪种文件格式?

在你获得外部艺术工具制作的模型后,你需要从该软件导出资产。就像 2D 图像一样,在导出 3D 模型时,有多种文件格式可供你使用,这些文件类型各有优缺点。表 4.3 列出了 Unity 支持的 3D 文件格式。

表 4.3 Unity 支持的 3D 模型文件格式

文件类型 优点和缺点
FBX 网格和动画;当可用时,推荐选项。
COLLADA (DAE) 网格和动画;当 FBX 不可用时,另一个不错的选择。
OBJ 仅网格;这是一个文本格式,因此有时在互联网上流传输时很有用。
3DS 仅网格;一个相当古老且原始的模型格式。
DXF 仅网格;一个相当古老且原始的模型格式。
Maya 通过 FBX 工作;需要安装此应用程序。
3ds Max 通过 FBX 工作;需要安装此应用程序。
Blender 通过 FBX 工作;需要安装此应用程序。

选择选项归结为文件是否支持动画。因为 COLLADA 和 FBX 是唯一两个包含动画数据的选项,所以这两个选项是选择的对象。当它可用时(并非所有 3D 工具都有导出选项),FBX 导出通常效果最佳,但如果你在没有 FBX 导出的工具上使用,COLLLADA 也效果不错。在我们的案例中,Blender 支持 FBX 导出,因此我们将使用该文件格式。

glTF 文件格式

虽然 FBX 是具有内置支持的最好 3D 格式,但你可能更愿意在 Unity 中使用 glTF 文件。这种较新的 3D 文件格式在当今越来越受欢迎。glTF 规范由 Khronos Group 开发,他们是 COLLADA 背后的同一群人,并且他们在github.com/KhronosGroup/UnityGLTF维护了一个 Unity 插件。

个人而言,我发现他们的 glTF 插件难以操控,更倾向于使用由名为 Siccity 的用户制作的 GLTFUtility 包,可在github.com/Siccity/GLTFUtility找到。

注意,表 4.3 的底部列出了几个 3D 艺术应用程序。Unity 允许您直接将那些应用程序的文件拖放到您的项目中。这个功能看起来很方便,但也有一些注意事项。

首先,Unity 不会直接加载那些应用程序文件;相反,它在幕后导出模型并加载那个导出的文件。因为模型无论如何都会被导出为 FBX,所以最好显式执行这一步骤。此外,这个导出需要您安装相关的应用程序。如果您计划在多台计算机之间共享文件(例如,一个开发团队合作),这个要求会变得很麻烦。我不建议在 Unity 中直接使用 3D 艺术应用程序文件。

4.5.2 导出和导入模型

好了,现在是时候从 Blender 中导出模型,然后将其导入到 Unity 中。首先,在 Blender 中打开长椅,然后选择文件 > 导出 > FBX。一旦文件保存,以相同的方式将其导入 Unity,就像导入图片一样。将 FBX 文件从计算机拖到 Unity 的项目视图中,或者在项目上右键单击并选择导入新资产。3D 模型将被复制到 Unity 项目中,并显示出来,准备放入场景中。

注意:示例下载包含 .blend 文件,以便您可以练习从 Blender 中导出 FBX 文件。即使您最终没有建模,您可能也需要将下载的模型转换为 Unity 接受的格式。如果您想跳过所有涉及 Blender 的步骤,请使用提供的 FBX 文件。

您应该立即更改一些导入设置。首先,Unity 默认导入的模型规模非常小(参考图 4.15,它显示了您在选择模型时在检查器中看到的内容);将缩放因子更改为 50 以部分抵消 0.01 单位的转换。您还可能想点击生成碰撞体复选框,但这不是必需的;如果没有碰撞体,您可以穿过长椅。然后,切换到导入设置中的动画选项卡,并取消选择导入动画(此模型没有动画)。在做出这些更改后,在底部点击应用。

CH04_F15_Hocking3

图 4.15 调整 3D 模型的导入设置。

这样就处理好了导入的网格。现在轮到纹理了。以与之前为墙壁导入砖块相同的方式导入长椅纹理(图 4.16 中的图像):将图像文件从本项目的初始文件夹拖到 Unity 的项目视图中,或者在项目中右键单击并选择导入新资产。图像看起来有些奇怪,图像的不同部分出现在长椅的不同部分;模型的纹理坐标被编辑以定义这种图像到网格的映射。

CH04_F16_Hocking3

图 4.16 长椅纹理的 2D 图像

定义 纹理坐标是每个顶点的一组额外值,用于将多边形分配到纹理图像的区域。把它想象成包装纸;3D 模型是被包装的盒子,纹理是包装纸,而纹理坐标代表盒子上包装纸将放置的点。

注意 即使你不想建模长椅,你可能也想阅读附录 C 中关于纹理坐标的详细解释。纹理坐标(以及像UVsmapping这样的其他相关术语)在游戏编程时可能很有用。

当 Unity 导入 FBX 文件时,它也会生成一个与 Blender 中材质设置相同的材质。如果 Blender 中使用的图像文件已经被导入 Unity,生成的材质将自动链接到该纹理。如果自动链接不正确,或者你需要使用不同的纹理图像,那么你可以提取模型的材质进行进一步编辑。参考图 4.15;在材质选项卡下,你应该找到一个标有提取材质的按钮。现在你可以选择材质资产,然后将图像拖到 Albedo,就像你为砖墙所做的那样。

新材质通常太亮,所以你可能想将平滑度设置降低到 0(更平滑的表面更亮)。最后,调整好所有需要调整的设置后,你可以将长椅放入场景中。从项目视图中拖动模型并将其放置在级别的某个房间中;当你拖动鼠标时,你应该能在场景中看到它。一旦将长椅放置到位,你应该能看到类似于图 4.17 的样子。恭喜你——你已经为级别创建了一个纹理模型!

注意 我们不会在本章中这样做,但通常,你还会用在外部工具中创建的模型替换白盒几何形状。新的几何形状可能看起来几乎相同,但你将拥有更多的灵活性来控制纹理。

CH04_F17_Hocking3

图 4.17 级别中导入的长椅

使用 Mecanim 动画角色

您创建的模型是静态的,放置后保持静止。您也可以在 Blender 中动画化,然后在 Unity 中播放动画。创建 3D 动画的过程既漫长又复杂,但这不是一本关于动画的书,所以我们不会在这里讨论这个话题。正如已经提到的建模,许多现有资源可以帮助您学习更多关于 3D 动画的知识。但请注意:这是一个巨大的话题。动画师是游戏开发中的一个专门角色,这也是有原因的。

Unity 有一个称为 Mecanim 的复杂系统,用于管理模型上的动画。特殊的名称 Mecanim 标识了作为旧动画系统的替代品添加到 Unity 中的较新、更先进的动画系统。旧的系统仍然存在,被称为遗留动画。但在 Unity 的下一个版本中,它可能会被淘汰,届时 Mecanim 将成为唯一的动画系统。

尽管我们在这个章节中没有处理任何动画,但我们在未来的章节中将在角色上播放动画。

4.6 通过粒子系统创建效果

除了 2D 图像和 3D 模型之外,游戏艺术家创建的剩余视觉内容类型是粒子系统。本章引言中的定义解释说,粒子系统是创建和控制大量移动对象的有序机制。粒子系统对于创建如火焰、烟雾或喷水等视觉效果非常有用。图 4.18 中的火焰效果就是使用粒子系统创建的。

CH04_F18_Hocking3

图 4.18 使用粒子系统创建的火焰效果

与大多数其他艺术资产在外部工具中创建并导入项目不同,粒子系统是在 Unity 内部创建的。Unity 提供了灵活且强大的工具来创建粒子效果。

注意:与 Mecanim 动画系统的情况类似,Unity 曾经有一个旧的遗留粒子系统,并为新的系统起了一个特殊名称,Shuriken。到目前为止,遗留粒子系统已经被淘汰,因此不再需要单独的名称。

首先,创建一个新的粒子系统并观察默认效果播放。从 GameObject 菜单中选择 Effects > Particle System,您将看到基本白色烟雾球从新对象向上喷射。或者更确切地说,当您选择对象时,您会看到粒子向上喷射。当您选择粒子系统时,粒子播放面板显示在屏幕角落,并指示已过去的时间(见图 4.19)。

CH04_F19_Hocking3

图 4.19 粒子系统的播放面板

默认效果看起来已经很漂亮了,但让我们来看看您可以用来自定义效果的参数。

4.6.1 调整默认效果的参数

图 4.20 显示了粒子系统的所有设置列表。我们不会逐个查看列表中的每个设置;相反,我们将关注那些与制作火焰效果相关的设置。一旦你理解了几个设置的工作原理,其余的应该相当容易理解。实际上,每个设置的标签都是一个完整的信息面板。最初,只有第一个信息面板是展开的;其余的面板都是折叠的。点击设置的标签以展开该信息面板。

提示:许多设置由检查器底部的曲线控制。这条曲线表示值随时间的变化:图表的左侧表示粒子首次出现的时间,右侧表示粒子消失的时间,底部是 0 的值,顶部是最大值。在图表周围拖动点,或双击或右击曲线以插入新点。

根据图 4.20 中的指示调整粒子系统的参数,它看起来会更像一团火焰。

CH04_F20_Hocking3

图 4.20 检查器显示粒子系统的设置(突出显示火焰效果的设置)。

4.6.2 为火焰应用新的纹理

现在粒子系统看起来更像一团火焰,但效果仍然需要粒子看起来像火焰,而不是白色的团块。这需要将一个新的图像导入到 Unity 中。图 4.21 展示了我所绘制的图像;我画了一个橙色的小点,并使用涂抹工具绘制出火焰的触须(然后我用黄色绘制了同样的东西)。

CH04_F21_Hocking3

图 4.21 用于火焰粒子的图像

无论你使用示例项目中的这个图像,绘制自己的图像,还是下载一个类似的图像,你都需要将图像文件导入到 Unity 中。如前所述,将图像文件拖动到项目视图中,或者选择“资产”>“导入新资产”。

就像 3D 模型一样,纹理不是直接应用于粒子系统的。你将纹理添加到材质中,然后将该材质应用于粒子系统。创建一个新的材质,然后选择它以在检查器中查看其属性。将火焰图像从项目拖动到纹理槽中。这样就将火焰纹理链接到火焰材质,因此现在你想要将材质应用于粒子系统。图 4.22 显示了如何进行此操作:选择粒子系统,在设置底部展开渲染器,然后将材质拖动到材质槽中。

CH04_F22_Hocking3

图 4.22 将材质分配给粒子系统。

就像为天空盒材质所做的,你需要更改粒子材质的着色器。在材质设置中靠近顶部的着色器菜单处单击,以查看可用的着色器列表。对于粒子材质,除了标准默认设置外,还需要在“粒子”子菜单下的着色器之一。如图 4.23 所示,在这种情况下,我们想要标准非光照。现在将材质的渲染模式切换到加性。这将使粒子看起来像雾蒙蒙的,并使场景变亮,就像火光一样。

CH04_F23_Hocking3

图 4.23 设置火粒子材质的着色器

定义:加性是一种着色器效果,它将粒子的颜色添加到其后面的颜色上,而不是替换像素。这使得像素变得更亮,使得粒子上的黑色变得不可见。这些着色器与 Photoshop 中加性图层效果具有相同的视觉效果。

警告:更改此着色器可能会导致 Unity 发出需要应用到系统的警告。在检查器底部单击“应用到系统”按钮。

将火材质分配给火粒子效果后,现在看起来就像图 4.18 所示。这看起来像一股相当逼真的火焰,但效果不仅仅在静止时才起作用。接下来,让我们将其附加到一个移动的对象上。

4.6.3 将粒子效果附加到 3D 对象

创建一个球体(记住,GameObject > 3D Object > Sphere)。创建一个新的脚本,命名为 BackAndForth,并将其附加到新球体上。

列表 4.1 沿直线路径移动对象

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BackAndForth : MonoBehaviour {
    public float speed = 3.0f;
    public float maxZ = 16.0f;                    ❶
    public float minZ = -16.0f;

    private int direction = 1;                    ❷

    void Update() {
        transform.Translate(0, 0, direction * speed * Time.deltaTime);

        bool bounced = false;
        if (transform.position.z > maxZ || transform.position.z < minZ) {
            direction = -direction;               ❸
            bounced = true;
        }
        if (bounced) {                            ❹
            transform.Translate(0, 0, direction * speed * Time.deltaTime);
        }
    }
}

❶ 这些是对象移动之间的位置。

❷ 对象当前正在向哪个方向移动?

❸ 切换方向来回。

❹ 如果对象改变了方向,则在新的方向上应用第二个移动。

运行此脚本,球体将在该级别的中央走廊来回滑动。现在你可以将粒子系统设置为球体的子对象,火光将随球体移动。就像该级别的墙壁一样,在层次结构视图中,将粒子对象拖动到球体对象上。

警告:通常在将对象设置为另一个对象的子对象后,需要重置对象的位置。例如,我们希望粒子系统位于 0, 0, 0(这是相对于父对象的)。Unity 将保留对象在链接为子对象之前的位置。

现在粒子系统会随球体移动。然而,火光并没有随着移动而偏转,这看起来很不自然。这是因为,默认情况下,粒子只在粒子系统的局部空间中正确移动。为了完成燃烧的球体,在粒子系统设置中找到“模拟空间”(位于图 4.20 顶部的面板中)并将它从局部切换到世界。

注意:在这个脚本中,对象在一条直线上来回移动,但视频游戏通常有对象在复杂的路径上移动。Unity 提供了对复杂导航和路径的支持;请参阅docs.unity3d.com/Manual/Navigation.html了解相关信息。

我相信,到这个时候,你一定迫不及待地想应用自己的想法,并为这个示例游戏添加更多内容。你应该这么做——你可以创建更多的艺术资产,或者甚至通过引入第三章中开发的射击机制来测试你的技能。在下一章中,我们将转换到不同的游戏类型,并从一个新的游戏开始。即使未来的章节将切换到其他游戏类型,这些前四章中的所有内容仍然适用并且有用。

摘要

  • 艺术资产是指所有单个图形的术语。

  • 白盒是关卡设计师用来勾勒空间的有用第一步。

  • 纹理是显示在 3D 模型表面的二维图像。

  • 3D 模型是在 Unity 之外创建的,并以 FBX 文件的形式导入。

  • 粒子系统用于创建许多视觉效果(如火焰、烟雾、水等)。

第二部分:熟悉环境

你已经在 Unity 中构建了你的第一个游戏原型,所以现在你准备好通过尝试其他游戏类型来挑战自己。在这个阶段,使用 Unity 工作的节奏应该已经熟悉:创建一个具有某种功能的脚本,将这个对象拖动到检查器中的那个槽位,等等。你不再那么频繁地遇到界面的细节问题,这意味着剩余的章节不需要重复介绍基础知识。让我们逐一浏览一系列额外的项目,这些项目将逐步教你更多关于在 Unity 中开发游戏的知识。

5 使用 Unity 的 2D 功能构建记忆游戏

本章涵盖

  • 在 Unity 中显示 2D 图形

  • 使对象可点击

  • 以编程方式加载新图像

  • 使用 UI 文本维护和显示状态

  • 加载关卡和重新开始游戏

到目前为止,我们一直在处理 3D 图形,但你也可以在 Unity 中使用 2D 图形。因此,在本章中,你将构建一个 2D 游戏。你将开发经典的儿童游戏记忆:你将显示一组卡片背面,当点击时显示卡片正面,并计分。这些机制涵盖了你在 Unity 中开发 2D 游戏所需了解的基本知识。

尽管 Unity 最初是作为 3D 游戏的工具而诞生的,但它也常用于 2D 游戏。自 2013 年 4.3 版本以来,Unity 已经内置了 2D 图形支持,但在此之前,2D 游戏已经在 Unity 中开发(特别是利用 Unity 跨平台特性的移动游戏)。在 Unity 的早期版本中,游戏开发者需要第三方框架在 Unity 的 3D 场景中模拟 2D 图形。最终,核心编辑器和游戏引擎被修改以包含 2D 图形,本章将介绍这一功能。

Unity 中的 2D 工作流程与开发 3D 游戏的工作流程大致相同:导入艺术资产,将它们拖入场景,并编写脚本将其附加到对象上。2D 图形中的主要艺术资产称为精灵

定义精灵是直接显示在屏幕上的 2D 图像,与显示在 3D 模型表面上的图像(即纹理)相对。

你可以将 2D 图像导入 Unity 作为精灵,就像你可以将图像导入为纹理一样(参见第四章)。技术上,这些精灵将是 3D 空间中的对象,但它们将是所有面向垂直于 z 轴的平面。因为它们都将朝向同一方向,你可以将相机直接对准精灵,玩家将能够仅沿 x 轴和 y 轴(在二维中)辨别它们的移动。

在第二章中,我们讨论了坐标轴:有三个维度增加了垂直于你已熟悉的 x 轴和 y 轴的 z 轴。二维只是那些 x 轴和 y 轴(这就是你在数学课上老师所谈论的!)。

5.1 为 2D 图形设置一切

你将创建经典的记忆游戏。对于那些不熟悉这个游戏的人来说,一系列卡片被倒扣分发。每张卡片都有一个位于其他地方的匹配卡片,但玩家只能看到卡片的背面。玩家可以一次翻两张卡片,试图找到匹配的卡片;如果选中的两张卡片不匹配,它们将翻转回来,然后玩家可以再次猜测。

图 5.1 展示了我们将要构建的游戏的草图;将其与第二章中的路线图图进行比较。这次的草图精确地描述了玩家将看到的内容(而我们的 3D 场景草图描述了玩家周围的空间以及摄像机移动的位置,以便玩家可以看到)。现在您知道了您将要构建的内容,是时候开始工作了!

CH05_F01_Hocking3

图 5.1 记忆游戏外观的草图

5.1.1 准备项目

第一步是收集并显示游戏所需的图形。与之前构建 3D 示例的方式类似,您想要通过组合游戏运行所需的最小图形集来开始新游戏,然后在该部分就绪后,您可以开始编程功能。

这意味着您需要创建图 5.1 中所示的所有内容:隐藏卡的背面、翻面时的卡片正面系列、一个角上的得分显示和一个对角上的重置按钮。我们还需要屏幕的背景,所以总的来说,我们的艺术需求汇总为图 5.2。

CH05_F02_Hocking3

图 5.2 记忆游戏所需的艺术资产

提示:与以往一样,包括所有必要的艺术资产在内的项目完整版本可以从 mng.bz/VBY5,本书的网站上下载。您可以从那里复制图片用于您自己的项目。

收集所需的图像,然后在 Unity 中创建一个新项目。在出现的“新建项目”窗口中,您会注意到项目模板(如图 5.3 所示),它允许您在 2D 和 3D 模式之间切换。在前几章中,我们处理了 3D 图形,因为那是默认值,所以我们没有关注这个设置。然而,在本章中,您在创建新项目时将想要选择 2D 模板。

CH05_F03_Hocking3

图 5.3 使用这些按钮以 2D 或 3D 模式创建新项目。

在为本章创建的新项目并设置为 2D 后,我们可以开始将我们的图像放入场景中。

2D 编辑模式与 2D 场景视图

新项目的 2D/3D 设置调整了 Unity 编辑器内的两个设置,如果您愿意,以后可以手动调整这两个设置。这两个设置是 2D 编辑模式和 2D 场景视图。2D 场景视图控制场景在 Unity 中的显示方式;切换场景视图顶部的 2D 按钮。

CH05_UN01_Hocking3

2D 场景视图切换

您可以通过打开编辑菜单并从项目设置下拉列表中选择编辑来设置 2D 编辑模式。在这些设置中,您将看到默认行为模式设置,可以选择 3D 或 2D。

CH05_UN02_Hocking3

编辑 > 项目设置 > 编辑中的默认行为模式设置

将编辑器设置为 2D 模式会导致导入的图像被设置为精灵。正如你在第四章中看到的,图像通常导入为纹理,但在检查器中很容易切换。只需选择资产以查看其设置,并在做出任何更改后记得点击应用。

2D 编辑器模式还会导致新场景缺少默认的 3D 照明设置;这种照明对 2D 场景没有害处,但不是必需的。如果你需要手动删除它,请删除新场景附带的方向光,并在照明窗口中关闭天空盒(点击小圆圈图标用于文件选择器,并从列表中选择无)。

5.1.2 显示 2D 图像(又称精灵)

将所有图像文件拖入项目视图以导入它们,确保图像被导入为精灵而不是纹理。(如果编辑器设置为 2D,这是自动的。选择一个资产以在检查器中查看其导入设置。)现在将 table_top 精灵(我们的背景图像)从项目视图拖到空场景中。与网格对象一样,在检查器中有一个用于精灵的变换组件;输入 0, 0, 5 以定位背景图像。

注意:另一个需要注意的导入设置是每单位像素。因为 Unity 之前是一个 3D 引擎,后来添加了 2D 图形,所以 Unity 中的一个单位不一定等于图像中的一个像素。你可以将每单位像素设置设置为 1:1,但我建议将其保留为默认的 100:1(因为 1:1 时物理引擎无法正常工作,默认设置更适合与其他代码的兼容性)。

创建打包的精灵图集

虽然在这个项目中我们将使用单独的图像,但你可以在单个图像中排列多个精灵。当动画的多个帧组合成一个图像时,这个图像通常被称为精灵表,但将多个图像组合成一个的更通用术语是图集

动画精灵在 2D 游戏中很常见,我们将在下一章实现它们。多个帧可以作为多个图像导入,但游戏通常将所有动画帧排列在精灵表中。基本上,所有单独的帧都显示在一个大图像上的网格中。

除了将动画帧保持在一起,精灵图集也常用于静态图像。这是因为图集可以通过两种方式优化精灵的性能:通过紧密打包以减少图像中的浪费空间,以及通过减少视频卡的绘制调用(每次加载新图像都会让视频卡做更多的工作)。

可以使用外部工具如 TexturePacker(见附录 B)创建精灵图集,这种方法肯定可行。但 Unity 包括精灵打包功能,可以自动打包多个精灵。要使用此功能,请在编辑器设置中启用 Sprite Packer(选择编辑 > 项目设置并将模式切换到始终启用)。现在你可以创建包含单个精灵的 Sprite Atlas 资源。有关更多信息,请参阅 Unity 的文档mng.bz/ZxOZ

X 和 Y 位置上的 0 是直接的(这个精灵将填充整个屏幕,所以你希望它在中心),但 Z 位置上的 5 可能看起来有些奇怪。对于 2D 图形,难道不是只有 X 和 Y 才重要吗?嗯,X 和 Y 是唯一影响对象在 2D 屏幕上定位的值,但 Z 值对于堆叠对象仍然很重要。

Z 值较低的位置更靠近相机,因此 Z 值较低的精灵会显示在其他精灵的上方(参见图 5.4)。因此,背景精灵应该具有最高的 Z 值。你将背景设置为正 Z 位置,然后给其他所有东西一个 0 或负 Z 位置。

CH05_F04_Hocking3

图 5.4 精灵沿 z 轴堆叠

由于前面提到的每单位像素设置,其他精灵将以最多两位小数的值定位。100:1 的比率意味着图像中的 100 像素是 Unity 中的 1 个单位;换句话说,1 像素是 0.01 个单位。但在你将更多精灵放入场景之前,让我们为这个游戏设置相机。

5.1.3 将相机切换到 2D 模式

现在我们来调整场景中主相机的设置。你可能认为由于场景视图设置为 2D,你在 Unity 中看到的内容就是你将在游戏中看到的内容。然而,有些不太直观的是,情况并非如此。

警告:场景视图是否设置为 2D 与运行游戏中的相机视图无关。

结果表明,无论场景视图是否设置为 2D 模式,游戏中的相机都是独立设置的。这在许多情况下都很有用,这样你就可以切换场景视图回 3D 模式来在场景中处理某些效果。这种脱节确实意味着你在 Unity 中看到的内容不一定是你将在游戏中看到的内容,而且对于初学者来说很容易忘记这一点。

需要调整的最重要相机设置是投影。由于你是在 2D 模式下创建的新项目,所以相机投影可能已经是正确的,但了解这一点并再次检查仍然很重要。在层次结构中选择相机以在检查器中显示其设置,然后查找投影设置(见图 5.5)。对于 3D 图形,设置应该是透视,但对于 2D 图形,相机投影应该是正交的。

CH05_F05_Hocking3

图 5.5 调整 2D 图形的相机设置

定义:正交投影是指没有明显透视的平面摄像机视图。这与透视摄像机视图相反,在透视摄像机视图中,较近的物体看起来更大,线条会退入远方。

虽然投影模式是 2D 图形最重要的摄像机设置,但我们还有其他一些设置需要调整。接下来,我们将查看位于投影下的尺寸。摄像机的正交尺寸决定了从屏幕中心到屏幕顶部的摄像机视图大小。换句话说,将尺寸设置为所需屏幕像素尺寸的一半。如果你后来将部署游戏的分辨率设置为相同的像素尺寸,你将获得像素完美的图形。

定义:像素完美意味着屏幕上的一个像素对应图像中的一个像素(否则,显卡在放大以适应屏幕时会使图像略微模糊)。

假设你想要一个像素完美的 1024 × 768 屏幕。这意味着摄像机的高度应该是 384 像素。除以 100(因为像素到单位的缩放比例),你得到 3.84 作为摄像机大小。同样,这个数学是 SCREEN_SIZE / 2 / 100f(f 代表浮点数,而不是整数值)。鉴于背景图像是 1024 × 768(选择资产以检查其尺寸),那么显然这个 3.84 的值就是我们想要的摄像机值。

在检查器中需要进行的剩余调整是摄像机的背景颜色和 Z 位置。如前所述,对于精灵来说,较高的 Z 位置意味着更远离场景。因此,摄像机应该有一个相当低的 Z 位置;将摄像机的位置设置为 0, 0, -100。接下来,确保摄像机的清除标志设置为实色而不是天空盒;此设置确定摄像机背景。摄像机的背景颜色可能是黑色;默认颜色是蓝色,如果屏幕宽度大于背景图像(这很可能是情况),那么这种颜色看起来会很奇怪。点击背景旁边的颜色块,并将颜色选择器设置为黑色。

现在将场景保存为“场景”并点击播放。你会看到游戏视图充满了我们的桌面精灵。正如你所看到的,到达这个点并不完全直接(再次强调,这是因为 Unity 是一个最近才添加了 2D 图形的 3D 游戏引擎)。但是桌面是完全空的,所以我们的下一步是在桌子上放一张卡片。

5.2 构建卡片对象并使其对点击做出反应

现在所有图像都已导入并准备好使用,让我们构建构成游戏核心的卡片对象。在《记忆》游戏中,所有卡片最初都是面朝下的,只有在选择一对卡片翻转时才会临时面朝上。为了实现这一功能,你需要创建由多个堆叠在一起的精灵组成的对象。然后,你将编写代码,使得卡片在鼠标点击时显示出来。

5.2.1 使用精灵构建对象

将一张卡片图片拖入场景。使用一张卡片正面,因为你会在上边添加一张卡片背面来隐藏图片。技术上,现在的位置并不重要,但最终会很重要,所以你可以将卡片定位在-3, 1, 0。现在将卡片背面精灵拖入场景。将这个新的精灵设置为之前卡片精灵的子对象(记住,在层次结构中,将子对象拖到父对象上),然后将其位置设置为 0, 0, -0.1(请注意,这个位置是相对于父对象的,这意味着“在 X 和 Y 上保持相同,但在 Z 上更靠近。”)

注意:在这个设置中,卡片背面和正面是单独的对象。这使得图形设置更简单,显示“正面”就像关闭“背面”一样简单。然而,由于 Unity 即使在看起来是 2D 的场景中也总是 3D 的,你可以制作一个可以翻转的 3D 卡片。这会使得设置更复杂,但可能对某些图形效果有优势。没有一种正确的方法来实现这些事情,只是不同的利弊需要权衡。

提示:与我们在 3D 中使用的移动、旋转和缩放工具不同,在 2D 模式下,我们使用一个称为矩形工具的单个操作工具。在 2D 模式下,此工具会自动选择,或者你可以点击 Unity 左上角的控制按钮中的第五个按钮。使用此工具时,点击并拖动对象可以在二维空间内执行所有三个操作(移动/旋转/缩放)。

如图 5.6 所示,卡片背面就位后,图形就准备好了一个可以揭示的响应式卡片。

CH05_F06_Hocking3

图 5.6 卡片背面精灵的层次链接和位置

5.2.2 鼠标输入代码

为了响应玩家点击,卡片精灵需要有一个碰撞器组件。新的精灵默认没有碰撞器,所以不能被点击。你将把一个碰撞器附加到根卡片对象上,而不是卡片背面,这样只有卡片正面而不是卡片背面会接收到鼠标点击。

要做到这一点,请在层次结构中选择根卡片对象(不要在场景中点击卡片,因为卡片背面在最上面,你会选择那个部分)。然后点击检查器中的添加组件按钮。选择物理 2D(不是物理,因为该系统是用于 3D 物理的,这是一个 2D 游戏),然后选择一个盒子碰撞器。

除了碰撞器之外,卡片还需要一个脚本来对玩家的点击做出反应,所以让我们编写一些代码。创建一个新的脚本名为 MemoryCard,并将其附加到根卡片对象上(再次,不是卡片背面)。下面的列表显示了当点击时使卡片发出调试消息的代码。

列表 5.1 点击时发出调试消息

dusing System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MemoryCard : MonoBehaviour {
    public void OnMouseDown() {            ❶
        Debug.Log("testing 1 2 3");        ❷
    }
}

❶ 当对象被点击时,会调用该函数。

❷ 目前只需向控制台发出一个测试消息。

提示:如果你还没有这个习惯,将你的资产组织到单独的文件夹中可能是个好主意。为脚本创建文件夹,并在项目视图中拖动文件。请小心避免 Unity 响应的特殊文件夹名称:Resources、Plugins、Editor 和 Gizmos。在本书的后面部分,我们将介绍这些特殊文件夹的一些功能,但现在避免使用这些单词命名任何文件夹。

很好——现在我们可以点击卡片了!就像 Update()一样,OnMouseDown()是 MonoBehaviour 提供的另一个函数,这次是在对象被点击时响应。玩玩游戏,并观察控制台中的消息。但这只是为了测试而打印到控制台;我们希望卡片被揭示

5.2.3 点击时揭示卡片

重新编写代码以匹配此列表(代码现在还不能运行,但不用担心)。

列表 5.2 当点击卡片时隐藏背面的脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MemoryCard : MonoBehaviour {
    [SerializeField] GameObject cardBack;       ❶

    public void OnMouseDown() {
        if (cardBack.activeSelf) {              ❷
            cardBack.SetActive(false);          ❸
        }
    }
}

❶ 在检查器中出现的变量

❷ 仅当对象当前处于活动/可见状态时运行禁用代码。

❸ 将对象设置为非活动/不可见。

我们在脚本中添加了两个关键功能:对场景中对象的引用,以及 SetActive()方法,该方法可以禁用该对象。第一部分,对场景中对象的引用,与我们之前章节中做的是类似的:将变量标记为序列化,然后将对象从层次结构拖动到检查器中的变量上。设置好对象引用后,代码现在将影响场景中的对象。

代码中的第二个关键添加是 SetActive 命令。此命令将使任何 GameObject 失效,使其不可见。如果我们现在将场景中的 card_back 拖动到检查器中此脚本的变量,那么在游戏中点击卡片时,卡片背面将消失;隐藏卡片背面将揭示卡片正面;我们为记忆游戏完成了另一个重要的任务!但仍然只有一张卡片,所以现在让我们创建一堆卡片。

提示:当脚本有一个序列化变量时忘记拖动对象,这是一个相当常见的错误,因此识别控制台标签中的错误信息是有用的。使用尚未设置的序列化变量的代码将抛出空引用错误。实际上,每当代码尝试使用尚未设置的变量时,无论是否为序列化变量,都会抛出空引用错误。

5.3 显示各种卡片图像

我们已经编写了一个卡片对象,它最初显示卡片背面,但在点击时揭示自己。那是一张单独的卡片,但游戏需要一个完整的卡片网格,大多数卡片上有不同的图像。我们将通过使用之前章节中看到的一些概念以及一些你之前没有见过的概念来实现卡片网格。第三章介绍了使用不可见的 SceneController 组件和实例化对象副本的概念。这次 SceneController 将为不同的卡片应用不同的图像。

5.3.1 以编程方式加载图像

我们正在创建的游戏有四张卡牌图像。桌上的所有八张卡牌(每种符号两张)将通过克隆相同的原始图像来创建,所以所有卡牌最初都将有相同的符号。我们将在脚本中更改卡牌上的图像,通过程序加载不同的图像。

为了检查图像如何被程序化分配,让我们编写简单的测试代码(稍后将被替换)来演示这个技术。首先,将此代码添加到 MemoryCard 脚本中。

列表 5.3 用于演示更改精灵图像的测试代码

...
[SerializeField] Sprite image;                         ❶
void Start() {
    GetComponent<SpriteRenderer>().sprite = image;     ❷
}
...

❶ 引用将要加载的精灵资源

❷ 设置这个 SpriteRenderer 组件的精灵。

保存此脚本后,新的图像变量将出现在检查器中,因为它已被设置为序列化。从项目视图中拖动一个精灵(选择一张卡牌图像,而不是场景中已有的图像)到图像槽中。现在运行场景,你将看到卡牌上的新图像。

理解这段代码的关键是了解 SpriteRenderer 组件。你会在图 5.7 中注意到,卡牌背面对象只有两个组件:场景中所有对象的标准 Transform 组件和一个新组件,称为 SpriteRenderer。这个组件使 GameObject 成为一个精灵对象,并确定将显示哪个精灵资源。注意,组件中的第一个属性被称为 sprite,并链接到项目视图中的一个精灵;该属性可以在代码中操作,这正是这个脚本所做的。

CH05_F07_Hocking3

图 5.7 场景中的一个精灵对象附带了 SpriteRenderer 组件。

就像在前面章节中处理 CharacterController 和自定义脚本一样,GetComponent()方法返回同一对象上的其他组件,因此我们用它来引用 SpriteRenderer 对象。SpriteRenderer 的精灵属性可以被设置为任何精灵资源,所以这段代码将这个属性设置为顶部声明的 Sprite 变量(我们在编辑器中用精灵资源填充了它)。

好吧,这并不太难!但这只是一个单独的图像。我们有四张图像要使用,所以现在删除列表 5.3 中的新代码(它只是演示了技术的工作原理),为下一部分做准备。

5.3.2 从不可见的 SceneController 设置图像

记得在第三章中,我们在场景中创建了一个不可见对象来控制对象的生成。在这里,我们也将采取同样的方法,使用一个不可见对象来控制与场景中任何特定对象无关的更抽象的特征。

首先,创建一个空的游戏对象(记住,选择 GameObject > 创建空)。然后在项目视图中创建一个新的脚本,命名为 SceneController,并将此脚本资产拖到控制器游戏对象上。在编写新脚本中的代码之前,将下一列表的内容添加到 MemoryCard 脚本中,而不是列表 5.3 中看到的内容。

列表 5.4 MemoryCard 中的新公共方法

...
[SerializeField] SceneController controller;

private int _id;
public int Id {
    get {return _id;}                                ❶
}

public void SetCard(int id, Sprite image) {          ❷
    _id = id;
    GetComponent<SpriteRenderer>().sprite = image;   ❸
}
...

❶ 添加了获取函数(在 C#和 Java 等语言中常见的习语)

❷ 其他脚本可以使用以向此对象传递新精灵的公共方法

❸ 与删除的代码演示中相同的 SpriteRenderer 代码行

与之前的列表相比,主要的变化是我们现在在 SetCard()中设置精灵图像,而不是在 Start()中设置。因为这是一个接受精灵作为参数的公共方法,你可以从其他脚本中调用此函数并设置此对象上的图像。请注意,SetCard()还接受一个 ID 号作为参数,并且代码会存储该数字。尽管我们目前不需要 ID,但很快我们将编写比较卡片以进行匹配的代码,而这个比较将依赖于卡片的 ID。

备注:根据你过去使用的编程语言,你可能不熟悉获取器设置器的概念。简而言之,它们是在你尝试访问与它们关联的属性时运行的函数(例如,检索 card.Id 的值)。使用获取器和设置器有多种原因,但在此情况下,Id 属性是只读的,因为我们有一个只获取值而不设置的函数。

最后,请注意代码中有一个用于控制器的变量。即使当 SceneController 开始克隆卡片对象以填充场景时,卡片对象也需要一个指向控制器的引用来调用其公共方法。通常情况下,当代码在场景中引用对象时,请将 Unity 编辑器中的控制器对象拖动到检查器中的序列化变量槽中。对于这个单独的卡片,只需这样做一次,之后的所有副本都将具有该引用。现在,在 MemoryCard 中添加了额外的代码,请在 SceneController 中编写此代码。

列表 5.5 Memory 游戏的 SceneController 第一次尝试

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SceneController : MonoBehaviour {
    [SerializeField] MemoryCard originalCard;        ❶
    [SerializeField] Sprite[] images;                ❷

    void Start() {
        int id = Random.Range(0, images.Length);
        originalCard.SetCard(id, images[id]);        ❸
    }
}

❶ 场景中卡片的引用

❷ 用于引用精灵资源的数组

❸ 调用我们添加到 MemoryCard 中的公共方法。

目前,这是一个简短的代码片段,用于演示从 SceneController 操作卡片的原理。其中大部分应该你已经很熟悉(例如,在 Unity 的编辑器中,将卡片对象拖动到检查器中的序列化变量槽中),但图像数组是新的。如图 5.8 所示,在检查器中你可以设置元素的数量。将数组长度输入为 4,然后将卡片图像的精灵拖放到数组槽中。现在这些精灵可以像任何其他对象引用一样在数组中访问。

CH05_F08_Hocking3

图 5.8 填充好的精灵数组

顺便提一下,我们在第三章中使用了 Random.Range()方法,所以希望你能回忆起来。那里确切的边界值并不重要,但这次需要注意的是,最小值是包含的,可能被返回,而返回值总是低于最大值。

点击播放以运行此新代码。每次运行场景时,你都会看到不同的图像被应用到露出的卡片上。下一步是创建一个完整的卡片网格,而不仅仅是单张卡片。

5.3.3 实例化卡片网格

SceneController 已经引用了卡片对象,因此现在你将使用 Instantiate()方法(参见下一列表)多次克隆对象,就像我们在第三章中生成对象时做的那样。

列表 5.6 克隆卡片八次并在网格中定位

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SceneController : MonoBehaviour {
  public const int gridRows = 2;                           ❶
  public const int gridCols = 4;                           ❶
  public const float offsetX = 2f;                         ❶
  public const float offsetY = 2.5f;                       ❶

  [SerializeField] MemoryCard originalCard;
  [SerializeField] Sprite[] images;

  void Start() {
    Vector3 startPos = originalCard.transform.position;    ❷

    for (int i = 0; i < gridCols; i++) {
      for (int j = 0; j < gridRows; j++) {                 ❸
        MemoryCard card;                                   ❹
        if (i == 0 && j == 0) {
          card = originalCard;
        } else {
          card = Instantiate(originalCard) as MemoryCard;
        }

        int id = Random.Range(0, images.Length);
        card.SetCard(id, images[id]);

        float posX = (offsetX * i) + startPos.x;
        float posY = -(offsetY * j) + startPos.y;
        card.transform.position = new Vector3(posX, posY, startPos.z); 
                                                           ❺
      }
    }
  }
}

❶ 确定网格空间数量和放置距离的值

❷ 第一张卡片的定位;所有其他卡片都将从这个位置偏移。

❸ 使用嵌套循环定义网格的列和行

❹ 原始卡片或副本的容器引用

❺ 对于 2D 图形,只需要偏移 X 和 Y;保持 Z 不变。

尽管这个脚本比之前的列表长得多,但解释起来并不多,因为大部分新增的都是直接的变量声明和数学运算。这段代码中最奇怪的部分可能是以 if (i == 0 && j == 0)开始的 if/else 语句。这个条件要么使用原始卡片对象作为第一个网格槽,要么克隆卡片对象用于所有其他网格槽。因为原始卡片已经在场景中存在,如果你在循环的每次迭代中都复制卡片,你会在场景中多出一张卡片。然后,根据循环的迭代次数,通过偏移来定位卡片。

提示:就像移动 3D 对象一样,2D 对象可以通过在 Update()中重复增加 transform.position 来实现平滑的移动。但是,正如你在移动第一人称玩家时看到的,当直接调整 transform.position 时,不会应用碰撞检测。因此,下一章的代码将通过调整 rigidbody2D.velocity 来移动精灵。

现在运行代码,将创建一个包含八张卡片的网格(如图 5.9 所示)。准备卡片网格的最后一个步骤是将它们组织成对,而不是保持随机。

CH05_F09_Hocking3

图 5.9 当你点击它们时显示的八张卡片网格

5.3.4 洗牌卡片

我们不会让每一张卡片都是随机的,而是定义一个包含所有卡片 ID(数字 0 到 3 各两次,每张卡片一对)的数组,然后对这个数组进行洗牌。在设置卡片时,我们将使用这个卡片 ID 数组,而不是让每个卡片都是随机的。

列表 5.7 从洗牌列表放置卡片

...
void Start() {                                            ❶
   Vector3 startPos = originalCard.transform.position;

   int[] numbers = {0, 0, 1, 1, 2, 2, 3, 3};              ❷
   numbers = ShuffleArray(numbers);                       ❸

   for (int i = 0; i < gridCols; i++) {
      for (int j = 0; j < gridRows; j++) {
        MemoryCard card;
        if (i == 0 && j == 0) {
          card = originalCard;
        } else {
          card = Instantiate(originalCard) as MemoryCard;
        }

        int index = j * gridCols + i;
        int id = numbers[index];                          ❹
        card.SetCard(id, images[id]);

        float posX = (offsetX * i) + startPos.x;
        float posY = -(offsetY * j) + startPos.y;
        card.transform.position = new Vector3(posX, posY, startPos.z);
      }
   }
}

private int[] ShuffleArray(int[] numbers) {               ❺
   int[] newArray = numbers.Clone() as int[];
   for (int i = 0; i < newArray.Length; i++ ) {
      int tmp = newArray[i];
      int r = Random.Range(i, newArray.Length);
      newArray[i] = newArray[r];
      newArray[r] = tmp;
   }
   return newArray;
}
...

❶ 这部分列表主要是为了显示新增内容的位置。

❷ 声明一个整数数组,包含所有四个卡片精灵的 ID 对。

❸ 调用一个函数来洗牌数组的元素。

❹ 从洗牌后的列表中检索 ID,而不是随机数。

❺ Knuth 洗牌算法的实现

现在,当你点击“播放”时,卡片网格将是一个随机排列的混合,恰好揭示每种卡片的图像各两张。卡片数组通过 Knuth(也称为 Fisher-Yates)洗牌算法进行处理,这是一种简单而有效的洗牌数组元素的方法。此算法遍历数组,并将数组的每个元素与另一个随机选择的数组位置交换。

你可以点击所有牌来揭示它们,但记忆游戏应该是成对进行的。我们需要更多的代码。

5.4 制作和计分匹配

制作一个完全功能的记忆游戏的最后一步是检查匹配。尽管我们现在有一个在点击时揭示的卡片网格,但各种卡片在没有任何方式影响彼此。在记忆游戏中,每次揭示一对牌时,我们都应该检查揭示的牌是否匹配。

这种抽象逻辑——检查匹配并相应地做出反应——要求牌在点击时通知 SceneController。这需要 SceneController 中显示的添加。

列表 5.8 SceneController,必须跟踪已揭示的牌

...
private MemoryCard firstRevealed;
private MemoryCard secondRevealed;

public bool canReveal {
    get {return secondRevealed == null;}     ❶
}
...
public void CardRevealed(MemoryCard card) {
    // initially empty
}
...

❶ 返回 false 的 getter 函数,如果已揭示第二张牌

The CardRevealed() 方法将稍后填充;我们现在需要空的框架,以便在 MemoryCard 中引用而不会出现编译错误。注意,我们再次有一个只读的 getter,这次用于确定是否可以揭示另一张牌。玩家只有在两张牌尚未揭示的情况下才能揭示另一张牌。

我们还需要修改 MemoryCard 以调用(目前为空的)方法,以便在点击牌时通知 SceneController。根据此列表修改 MemoryCard 中的代码。

列表 5.9 揭示牌的 MemoryCard 修改

...
public void OnMouseDown() {
   if (cardBack.activeSelf && controller.canReveal) {     ❶
      cardBack.SetActive(false);
      controller.CardRevealed(this);                      ❷
   }
}

public void Unreveal() {                                  ❸
   cardBack.SetActive(true);
}
...

❶ 检查控制器的 canReveal 属性,以确保一次只揭示两张牌。

❷ 当这张牌被揭示时通知控制器。

❸ 一个公共方法,以便 SceneController 可以再次隐藏卡片(通过将 card_back 重新打开)

如果你在 CardRevealed() 中放入一个调试语句来测试对象之间的通信,你会在点击卡片时看到测试消息出现。让我们首先处理一个已揭示的对。

5.4.1 存储和比较已揭示的牌

卡片对象被传递到 CardRevealed() 中,所以让我们开始跟踪已揭示的卡片。

列表 5.10 在 SceneController 中跟踪已揭示的牌

...
public void CardRevealed(MemoryCard card) {
  if (firstRevealed == null) {                                         ❶
    firstRevealed = card;
  } else {
    secondRevealed = card;
    Debug.Log("Match? " + (firstRevealed.Id == secondRevealed.Id));    ❷
  }
}
...

❶ 根据第一个变量是否已被占用,将牌对象存储在两个牌变量之一中。

❷ 比较两张已揭示的牌的 ID。

列表将揭示的牌存储在两个牌变量之一中,具体取决于第一个变量是否已被占用。如果第一个变量为空,则填充它;如果它已被占用,则填充第二个变量并检查牌 ID 是否匹配。调试语句在控制台打印出 true 或 false。

目前,代码不会对匹配做出响应——它只会检查它们。现在让我们编写响应程序。

5.4.2 隐藏不匹配的牌

我们将再次使用协程,因为对不匹配牌的反应应该暂停,以便玩家可以看到牌。请参阅第三章以获取协程的完整解释;简而言之,使用协程将允许我们在检查匹配之前暂停。此列表显示了更多要添加到 SceneController 中的代码。

列表 5.11 SceneController 得分或隐藏未匹配的匹配

...
private int score = 0;                            ❶
...
public void CardRevealed(MemoryCard card) {
  if (firstRevealed == null) {
    firstRevealed = card;
  } else {
    secondRevealed = card;
    StartCoroutine(CheckMatch());                 ❷
  }
}

private IEnumerator CheckMatch() {
  if (firstRevealed.Id == secondRevealed.Id) {
    score++;                                      ❸
    Debug.Log($"Score: {score}");                 ❹
  }
  else {
    yield return new WaitForSeconds(.5f);

    firstRevealed.Unreveal();                     ❺
    secondRevealed.Unreveal();
  }

  firstRevealed = null;                           ❻
  secondRevealed = null;
}
...

❶ 在 SceneController 顶部附近添加到列表中

❷ 在此函数中,唯一更改的行是当两张牌都显示时调用协程

❸ 如果显示的牌具有匹配的 ID,则增加分数。

❹ 使用字符串插值构造消息。

❺ 如果牌不匹配,则不显示牌。

❻ 无论是否匹配,都要清除变量。

首先,添加一个分数值来跟踪。然后,当第二张牌显示时,启动一个协程来检查匹配。这个协程有两个代码路径,取决于牌是否匹配。如果匹配,协程不会暂停;跳过 yield 命令。如果不匹配,协程会在调用 Unreveal()并再次隐藏两张牌之前暂停半秒钟。最后,无论是否匹配,存储牌的变量都会被置为 null,为揭示更多牌铺平道路。

当你玩游戏时,不匹配的牌会在隐藏之前短暂显示。当你得分匹配时,会显示调试信息,但我们希望分数以屏幕上的标签形式显示。

5.4.3 分数文本显示

在游戏中,向玩家展示信息是 UI(用户界面)存在的一半原因(另一半是接收玩家的输入。UI 按钮将在下一节讨论)。

定义 UI 代表 用户界面。另一个与之密切相关的术语是 GUI,或 图形用户界面,它指的是界面的视觉部分,如文本和按钮,这也是很多人在说 UI 时所指的。

Unity 有多种创建文本显示的方法,但使用 TextMeshPro 包是最佳方法。这个高级文本系统是由外部开发的,后来被 Unity 收购。

TextMeshPro 可能已经安装(在创建新项目时,Unity 会安装几个常用包),如果没有,则必须在包管理器中安装它。从菜单中选择 Window > Package Manager 打开该窗口,然后滚动到左侧列表中的 TextMeshPro,如图 5.10 所示。选择该包,然后点击安装按钮。

CH05_F10_Hocking3

图 5.10 通过包管理器安装 TextMeshPro

安装了该包后,您可以通过访问 GameObject 菜单并选择 3D Object > Text - TextMeshPro 在场景中创建一个 TextMeshPro 对象。由于这是 TextMeshPro 在本项目中首次使用,TMP Importer 窗口将自动出现。点击导入 TMP Essentials 按钮,在所需资源下载完成后,文本对象将出现在场景中。

注意 3D 文本可能听起来与 2D 游戏不兼容,但别忘了,这实际上是一个看起来平面的 3D 场景,因为它是通过正交相机看到的。这意味着如果我们想的话,我们可以将 3D 对象放入 2D 游戏中——它们将以平面视角显示。

警告 TextMeshPro 也列在 GameObject > UI 下。后面的章节将介绍 Unity 的 UI 系统,您将在那些章节中使用那个其他 GameObject。不要混淆这两个版本;虽然两者都是 TextMeshPro 对象,但我们在这个章节中并没有使用 Unity 的高级 UI 系统。

选择新的文本对象以查看其设置在检查器中。将此对象定位在 -2.3, 3.1, -10;即向左 230 像素和向上 310 像素,将其放置在左上角并靠近相机,以便它能够覆盖其他游戏对象。此外,由于新文本一开始很大,因此将宽度减小到 5 和高度减小到 1。

滚动到 TextMeshPro 设置。我们可以以无数种方式自定义文本,但现在我们将保留大多数默认设置。图 5.11 显示我们将更改的设置,您可以在 Unity 文档中了解它们的所有信息(mng.bz/RqQP)。

CH05_F11_Hocking3

图 5.11 此文本对象的检查器设置

在大文本输入框中输入 Score: 并将字体大小减小到 8。在游戏中操作这个文本对象只需要在得分代码中进行少量调整。

列表 5.12 在文本对象上显示得分

...
using TMPro;         ❶
...
[SerializeField] TMP_Text scoreLabel;
...
private IEnumerator CheckMatch() {
  if (firstRevealed.Id == secondRevealed.Id) {
     score++;
     scoreLabel.text = $"Score: {score}";
  }
...

❶ 包含 TextMeshPro 代码。

如您所见,文本是对象的属性,您可以将其设置为新的字符串。将得分变量放入字符串中,以显示该值。

将场景中的文本对象拖到 SceneController 中您添加的 scoreLabel 变量上,然后点击播放。现在,当您玩游戏并制作匹配时,应该会看到得分显示。太棒了——游戏工作啦!

5.5 重启按钮

到目前为止,记忆游戏已经完全可用。您可以玩游戏,所有基本功能都已就绪。但这个可玩的核心仍然缺少玩家在完成的游戏中期望或需要的整体功能。例如,目前您只能玩一次游戏;您需要退出并重新启动才能再次玩游戏。让我们在屏幕上添加一个控制按钮,以便玩家可以在不退出的情况下重新开始游戏。

这个功能可以分为两个任务:创建一个 UI 按钮,并在点击该按钮时重置游戏。图 5.12 显示了带有开始按钮的游戏外观。

CH05_F12_Hocking3

图 5.12 完整的记忆游戏屏幕,包括开始按钮

顺便说一下,这两个任务并不特定于 2D 游戏。所有游戏都需要 UI 按钮,所有游戏都需要重置的能力。我们将讨论这两个主题,以完善本章内容。

5.5.1 通过 SendMessage 编程 UIButton 组件

首先,通过从项目视图拖动按钮精灵到场景中。给它一个位置如 4.5, 3.25, -10;这样会将按钮放置在右上角(即向右 450 像素,向上 325 像素)并使其靠近相机,以便它能够显示在其他游戏对象之上。因为我们希望能够点击这个对象,所以给它一个碰撞器(就像卡片对象一样,选择添加组件 > 物理引擎 2D > 矩形碰撞器 2D)。

注意:正如前节所述,Unity 提供了多种创建 UI 显示的方式,包括在 Unity 后续版本中引入的高级 UI 系统。现在,我们将使用标准显示对象构建单个按钮。未来的章节将教你关于高级 UI 功能;2D 和 3D 游戏的 UI 理想上应该使用该系统构建。

现在创建一个新的脚本名为 UIButton 并将其分配给按钮对象。

列表 5.13 创建通用和可重用 UI 按钮的代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UIButton : MonoBehaviour {
  [SerializeField] GameObject targetObject;                    ❶
  [SerializeField] string targetMessage;
  public Color highlightColor = Color.cyan;

  public void OnMouseEnter() {
    SpriteRenderer sprite = GetComponent<SpriteRenderer>();
    if (sprite != null) {
      sprite.color = highlightColor;                           ❷
    }
  }
  public void OnMouseExit() {
    SpriteRenderer sprite = GetComponent<SpriteRenderer>();
    if (sprite != null) {
      sprite.color = Color.white;
    }
  }

  public void OnMouseDown() {
    transform.localScale = new Vector3(1.1f, 1.1f, 1.1f);      ❸
  }
  public void OnMouseUp() {
    transform.localScale = Vector3.one;
    if (targetObject != null) {
      targetObject.SendMessage(targetMessage);                 ❹
    }
  }
}

❶ 引用目标对象以通知点击事件。

❷ 当鼠标悬停在按钮上时,为按钮着色。

❸ 当按钮被点击时,按钮的大小会略微弹出。

❹ 当按钮被点击时,向目标对象发送消息。

这段代码的大部分内容都发生在一系列的 OnMouseSomething 函数中。就像 Start() 和 Update() 一样,这些是一系列自动对所有脚本组件在 Unity 中可用的函数。MouseDown 在第 5.2.2 节中已经提到,但其他这些函数也会在对象有碰撞器的情况下响应鼠标交互。MouseEnter 和 MouseExit 是一对事件,用于在鼠标光标悬停在对象上:当鼠标光标第一次移动到对象上时,MouseEnter 触发,而当鼠标光标移开时,MouseExit 触发。同样,MouseDown 和 MouseUp 是一对用于点击鼠标的事件。当鼠标按钮被物理按下时,MouseDown 触发,而当鼠标按钮释放时,MouseUp 触发。

你可以看到,当鼠标悬停在精灵上时,代码会着色精灵,当点击时,会缩放精灵。在这两种情况下,你可以看到变化(颜色或缩放)发生在鼠标交互开始时,当鼠标交互结束时,属性会返回默认值(白色或缩放 1)。对于缩放,代码使用所有 GameObject 都有的标准变换组件。然而,对于着色,代码使用精灵对象具有的 SpriteRenderer 组件;精灵被设置为在 Unity 编辑器中通过公共变量定义的颜色。

除了将比例恢复到 1 之外,当鼠标释放时还会调用 SendMessage()。SendMessage()会调用该 GameObject 所有组件中给定名称的函数。在这里,消息的目标对象以及要发送的消息都由序列化变量定义。这样,同一个 UIButton 组件可以用于所有类型的按钮,不同按钮的目标对象在检查器中设置为不同的对象。

通常,在强类型语言如 C#中进行面向对象编程时,你需要知道目标对象类型才能与该对象通信(例如,调用对象的公共方法,如调用 targetObject.SendMessage())。但 UI 元素的脚本可能有多种目标类型,因此 Unity 提供了 SendMessage()方法,即使你不知道目标对象的确切类型,也可以与目标对象通信特定的消息。

警告:使用 SendMessage()比调用已知类型的公共方法对 CPU 效率更低(即使用 object.SendMessage("Method")与 component.Method()相比),因此只有在使代码更易于理解和操作的情况下才使用 SendMessage()。一般来说,只有在许多不同类型的对象可能接收消息的情况下才会如此。在这种情况下,继承或接口的不灵活性将阻碍游戏开发过程并抑制实验。

编写完此代码后,将按钮检查器中的公共变量连接起来。高亮颜色可以设置为任何你喜欢的颜色(尽管默认的青色在蓝色按钮上看起来相当不错)。同时,将 SceneController 对象放入目标对象槽中,然后输入 Restart 作为消息。

如果你现在玩游戏,右上角的复位按钮会根据鼠标颜色变化,并在点击时产生轻微的视觉弹出效果。但是,当你点击按钮时,会发出错误消息;在控制台中,你会看到一个关于没有接收器用于重启消息的错误。那是因为我们还没有在 SceneController 中编写 Restart()方法,所以让我们添加它。

5.5.2 从 SceneController 调用 LoadScene

按钮中的 SendMessage()方法试图在 SceneController 中调用 Restart(),所以现在让我们添加它。

列表 5.14 重新加载级别的 SceneController 代码

...
using UnityEngine.SceneManagement;      ❶
...
public void Restart() {
   SceneManager.LoadScene("Scene");     ❷
}
...

❶ 包含 SceneManagement 代码。

❷ 如果你的场景有不同的名称,请更改此字符串中的名称。

你可以看到 Restart()所做的唯一一件事就是调用 LoadScene()方法。该方法加载一个已保存的场景资产(当你点击 Unity 中的保存场景时创建的文件)。将你想加载的场景名称传递给该方法。在我的情况下,场景以 Scene 名称保存,但如果你使用了不同的名称,请将那个名称传递给该方法。

点击播放按钮查看会发生什么。翻开几张牌并完成一些匹配。如果你然后点击重置按钮,游戏将重新开始,所有牌都隐藏起来,得分为 0。太好了,这正是我们想要的!

如名称 LoadScene()所示,这个方法可以加载不同的场景。但场景加载时究竟会发生什么,为什么这会重置游戏?发生的情况是,当前级别(场景中的所有对象,以及因此附加到这些对象的所有脚本)的内容都会从内存中清除,然后加载新场景的所有内容。因为新场景是当前场景(在这种情况下)的保存资产,所以所有内容都会从内存中清除,然后从头开始重新加载。

小贴士:你可以在加载关卡时标记特定的对象,以排除默认内存清除。Unity 提供了 DontDestroyOnLoad()方法来在多个场景中保持对象的存在。你将在后面的章节中使用这个方法在代码架构的部分。

另一个游戏成功完成!嗯,完成这个词是相对的;你总是可以添加更多功能,但所有初始计划中的内容都已经完成。这个二维游戏中的许多概念也适用于三维游戏,特别是检查游戏状态和加载关卡。是时候再次转换方向,离开这个记忆游戏,转向新的项目。

摘要

  • 在 Unity 中使用正交相机显示二维图形。

  • 为了像素级的图形,相机的尺寸应该是屏幕高度的一半。

  • 点击精灵之前,你需要首先为它们分配二维碰撞器。

  • 可以通过编程方式加载精灵的新图像。

  • 可以使用 3D 文本对象创建 UI 文本。

  • 加载关卡会重置场景。

6 创建基本 2D 平台游戏

本章涵盖

  • 持续移动精灵

  • 播放精灵图集动画

  • 使用 2D 物理(碰撞、重力)

  • 实现侧滚动游戏的相机控制

让我们创建一个新的游戏,继续学习 Unity 的 2D 功能。第五章介绍了基本概念,所以本章将在这些基础上创建一个更复杂的游戏。具体来说,你将构建一个 2D 平台游戏的核心功能。也称为平台游戏机,这种常见的 2D 动作游戏以经典游戏如超级马里奥兄弟最为知名:从侧面观看的角色在平台上奔跑和跳跃,视图滚动以跟随。图 6.1 显示了最终结果。

CH06_F01_Hocking3

图 6.1 本章的最终产品

本项目将教授如何左右移动玩家、播放精灵动画以及添加跳跃能力等概念。我们还将介绍平台游戏中常见的几个特殊功能,如单向地板和移动平台。从这个外壳到完整游戏的过程基本上意味着反复重复这些概念。

要开始,请像上一章一样在 2D 模式下创建一个新项目:从 Unity Hub 中选择新建,或从文件菜单中选择新建项目;然后在出现的窗口中选择 2D。在新项目中,创建两个文件夹,分别命名为 Sprites 和 Scripts,以包含各种资源。你可以调整相机,就像第五章中那样,但现在只需将大小减少到 4。这个项目不需要完美的相机设置,尽管你可能需要调整大小以制作一个准备发布的精良游戏。

小贴士:屏幕中央的相机图标可能会碍事,因此您可以使用 Gizmos 菜单将其隐藏。在场景视图的顶部有一个 Gizmos 的标签。该术语指的是编辑器中的抽象形状和图标。点击 Gizmos,然后点击旁边的 Camera 图标,即可看到一个按字母顺序排列的列表。

现在保存空场景(当然,在您工作时定期点击保存),以在这个项目中创建场景资产。目前一切都是空的,所以第一步将是引入艺术资产。

6.1 设置图形

在您能够编程 2D 平台游戏的功能之前,您需要将图像导入到项目中(记住,2D 游戏中的图像被称为精灵而不是纹理),然后将这些精灵放置到场景中。这个游戏将是 2D 平台游戏的外壳,玩家控制的字符在一个基本且大部分为空的场景中奔跑,所以您只需要几个用于平台的精灵和用于玩家的精灵。让我们分别介绍每个,因为尽管这个例子中的图像很简单,但其中涉及一些不明显的考虑因素。

6.1.1 放置场景

简而言之,你需要一个单独的空白白色图像来使用。一个名为 blank.png 的图像包含在本章的示例项目中;下载示例项目,并从那里复制 blank.png。然后将 PNG 拖入新项目的 Sprites 文件夹中,并在检查器中确保导入设置表明它是一个精灵而不是纹理(对于 2D 项目应该是自动的,但值得再次检查)。

你现在所做的是本质上与第四章中的白盒化相同,但是在 2D 而不是 3D 中。2D 中的白盒化使用精灵而不是网格,但保持了为玩家阻挡空白地板和墙壁的活动。

要放置地板对象,将空白精灵拖动到场景中,如图 6.2 所示(大约位置 0.15, -1.27, 0),将缩放设置为 50, 2, 1,并将其名称更改为 Floor。然后拖入另一个空白精灵,将其缩放设置为 6, 6, 1,将其放置在右侧的地板上(大约位置 2, -0.63, 0),并将其命名为 Block。

CH06_F02_Hocking3

图 6.2 地板平台放置

足够简单;现在地板和方块已经完成。你还需要另一个对象,即玩家的角色。

6.1.2 导入精灵图集

你需要的唯一其他艺术资产是玩家的精灵,因此也从示例项目中复制 stickman.png。但与空白图像不同,这个 PNG 是一系列单独的精灵组合成的一个图像。如图 6.3 所示,stickman 图像是两个动画的帧:站立空闲和行走循环。

CH06_F03_Hocking3

图 6.3 Stickman 精灵图集——一行六帧

我们不会详细介绍如何动画化,但可以说空闲循环是游戏开发者常用的术语。空闲指的是在无所事事时的微妙动作,而循环则是一个持续循环的动画。

如第五章所述,一个图像文件可能是一系列精灵图像打包在一起,而不仅仅是一个精灵。当多个精灵图像是动画的帧时,这样的图像被称为精灵图集。在 Unity 中,作为多个精灵导入的图像在项目视图中仍然作为一个单一资产出现,但如果点击资产上的箭头,它将展开并显示所有单个精灵图像。图 6.4 显示了它的样子。

CH06_F04_Hocking3

图 6.4 将精灵图集切割成单独的帧

将 stickman.png 拖入 Sprites 文件夹以导入图像,但这次在检查器中更改许多导入设置。选择精灵资产,将精灵模式设置为多个,然后点击精灵编辑器打开该窗口。在窗口的左上角点击切片,将类型设置为按单元格大小网格(如图 6.4 所示),使用大小 32, 64(这是精灵图集中每个帧的大小),然后点击切片以查看帧被分割。现在关闭精灵编辑器窗口,并点击应用以保留更改。

注意:Sprite 编辑器窗口需要 2D Sprite 包。创建新的 2D 项目应该会自动安装该包,但如果未安装,请打开“窗口”>“包管理器”,并在窗口左侧的列表中查找 2D Sprite。选择该包,然后点击“安装”按钮。

警告:如果窗口太小,Sprite 编辑器窗口顶部的按钮会被隐藏。如果您看不到“切片”按钮,请尝试拖动窗口的角落来调整大小。

现在精灵资产已被拆分,所以点击箭头展开帧。将一个(可能是第一个)棍子人精灵拖入场景,将其放置在地板中间,并命名为 Player。在那里,玩家对象已经出现在场景中!

6.2 移动玩家左右

现在图形设置好了,让我们开始编写玩家的移动代码。首先,场景中的玩家实体需要一些额外的组件,以便我们控制。如前几章简要提到的,Unity 中的物理模拟作用于具有特殊 Rigidbody 组件的对象,您希望物理(特别是碰撞和重力)作用于角色。

同时,角色还需要一个 Collider 组件来定义其碰撞检测的边界。这些组件之间的区别微妙但很重要:Collider 定义了物理作用的对象形状,而 Rigidbody 告诉物理模拟哪些对象要作用。

注意:这些组件被保留为独立(尽管它们密切相关),因为许多不需要物理模拟的对象确实需要与其他受物理作用的对象发生碰撞。

另一个需要注意的微妙之处是,Unity 为 2D 游戏有一个独立的物理系统,而不是 3D 物理。因此,在本章中,您将使用来自 Physics 2D 部分的组件,而不是列表中的常规 Physics 部分。

在场景中选择 Player。在检查器中,点击“添加组件”,然后选择“Physics 2D”>“Rigidbody 2D”,如图 6.5 所示。然后再次点击“添加组件”以添加“Physics 2D”>“Box Collider 2D”。Rigidbody 需要一些微调,因此在检查器中将碰撞检测设置为连续,开启“约束”>“冻结旋转 Z”(通常,物理模拟在移动对象时会尝试旋转对象,但游戏中的角色不像普通对象那样表现),并将重力比例减少到 0(您稍后会重置此设置,但现在您不希望有重力)。玩家实体现在已准备好控制移动的脚本。

CH06_F05_Hocking3

图 6.5 添加和调整 Rigidbody 2D 组件

6.2.1 编写键盘控制

首先,你需要让玩家左右移动;在平台游戏中,垂直移动也很重要,但你可以稍后再处理。在 Scripts 文件夹中创建一个名为 PlatformerPlayer 的 C# 脚本,然后将它拖放到场景中的 Player 对象上。打开脚本并从下面的列表中写入代码。

列表 6.1 使用箭头键移动的 PlatformerPlayer 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlatformerPlayer : MonoBehaviour {
  public float speed = 4.5f;

  private Rigidbody2D body;

  void Start() {
    body = GetComponent<Rigidbody2D>();                        ❶
  }

  void Update() {
    float deltaX = Input.GetAxis("Horizontal") * speed;
    Vector2 movement = new Vector2(deltaX, body.velocity.y);   ❷
    body.velocity = movement;
  }
}

❶ 需要将这个其他组件附加到这个 GameObject 上

❷ 仅设置水平移动;保留现有的垂直移动。

编写代码后,点击播放,你可以使用箭头键来移动玩家。这段代码与前面章节中的移动代码相当相似,主要区别在于它作用于 Rigidbody2D 而不是 CharacterController。CharacterController 用于 3D 游戏中,所以对于 2D 游戏,你使用 Rigidbody 组件。请注意,移动是应用于 Rigidbody 的速度,而不是像位置这样的东西。

NOTE 这段代码不需要使用 delta time。在前面章节中,我们需要考虑帧之间的时间来达到帧率无关的移动,但在这个章节中我们不需要这样做。在这里,我们正在调整速度,速度本身是帧率无关的,而不是位置。在前面章节中,我们直接调整位置。

TIP 默认情况下,Unity 会为箭头键输入应用一点加速度。但对于平台游戏来说,这可能会感觉有点迟缓。为了获得更快的控制,将水平输入的灵敏度(Sensitivity)和重力(Gravity)增加到 6。要找到这些设置,请选择 Edit > Project Settings > Input Manager;你将看到一个长长的列表,但水平输入是第一个部分。

太棒了——这个项目在水平移动方面已经完成了大部分!你只需要解决碰撞检测问题。

6.2.2 与方块碰撞

如你所注意到的,玩家现在可以直接穿过方块。地板和方块上没有碰撞器,所以玩家可以穿过它们。为了解决这个问题,将 Box Collider 2D 添加到 Floor 和 Block 上:在场景中选择每个对象,在检查器中点击 Add Component,然后选择 Physics 2D > Box Collider 2D。

这就是你需要做的全部!现在点击播放,玩家将无法穿过方块。与第二章中移动玩家一样,如果你直接调整了玩家的位置,碰撞检测将不会工作。但如果你将移动应用于玩家的物理组件,Unity 的内置碰撞检测就可以工作。换句话说,移动 Transform.position 会忽略碰撞检测,所以在这里你是在移动脚本中操作 Rigidbody2D.velocity。

将碰撞器添加到更复杂的艺术作品中可能稍微有点困难,但坦白说,在那个情况下并不难很多。即使艺术作品不是精确的矩形,你可能仍然想使用盒子碰撞器,并大致包围场景中障碍物的形状。或者,你也可以尝试其他碰撞器形状,包括任意的自定义多边形形状。图 6.6 说明了如何与奇形怪状的对象的多边形碰撞器一起工作。

CH06_F06_Hocking3

图 6.6 使用编辑碰撞器按钮编辑多边形碰撞器的形状。

无论如何,碰撞检测现在正在工作,所以下一步是让玩家在移动时进行动画。

6.3 播放精灵的动画

当 stickman.png 被导入时,它被分割成多个帧以进行动画。现在让我们播放这个动画,这样玩家就不会滑动,而是看起来像在行走。

6.3.1 解释 Mecanim 动画系统

如第四章简要提到的,Unity 中的动画系统称为Mecanim。它设计得可以让你直观地为一个角色设置复杂的动画网络,然后用最少的代码来控制这些动画。该系统对 3D 角色最有用(因此,我们将在未来的章节中更详细地介绍它),但对 2D 角色也很有用。

动画系统的核心由两种类型的资产组成:动画剪辑和动画器控制器。注意动画动画器的区别:剪辑是播放的单独动画循环,而控制器是控制何时播放动画的网络。这个网络是一个状态机图,图中的状态是可能播放的不同动画。控制器根据它观察到的条件在状态之间切换,并在每个状态下播放不同的动画。

当你将 2D 动画拖入场景时,Unity 会自动创建这两种类型的资产。也就是说,当你将动画的帧拖入场景时,Unity 会自动使用这些帧创建一个动画剪辑和一个动画控制器。如图 6.7 所示,展开精灵资产的所有帧,选择帧 0-1,将它们拖入场景,并在确认窗口中键入 stickman_idle 名称。

CH06_F07_Hocking3

图 6.7 在动画组件中使用精灵图帧的步骤

将框架拖入场景视图的动作在资产视图中创建了两个东西:一个名为 stickman_idle 的剪辑和一个名为 stickman_0 的控制器。这个动作还在场景中创建了一个名为 stickman_0 的对象,但你不需要它,所以删除它。将控制器 stickman 重命名为不带后缀的名称。太好了——你创建了角色的空闲动画!

现在重复此过程以进行行走动画。选择帧 2-5,将它们拖入场景,并将动画命名为 stickman_walk。这次,删除场景中的 stickman_2 和新控制器;只需要一个动画控制器来控制两个动画剪辑,因此保留旧的一个并删除 stickman_2 和新创建的一个。

要将控制器应用于您的玩家角色,在场景中选择 Player,然后单击 Add Component 以选择 Miscellaneous > Animator。如图 6.7 所示,将 stickman 控制器拖入检查器中的控制器槽。选择 Player 后,打开 Window > Animation > Animator(如图 6.8 所示)。Animator 窗口中的动画显示为块,称为 状态,控制器在运行时在这些状态之间切换。这个特定的控制器已经包含空闲状态,但您需要添加一个行走状态;将 stickman_walk 动画剪辑从 Assets 拖入 Animator 窗口。

默认情况下,空闲动画会播放得太快。要降低空闲速度,选择空闲动画状态,并在右侧面板中将 Speed 设置为 0.2。此更改后,动画已全部设置好,以进行下一步操作。

6.3.2 从代码中触发动画

现在,您已经在动画控制器中设置了动画状态,您可以在这些状态之间切换以播放不同的动画。如前所述,状态机根据它所监视的条件来切换状态。在 Unity 的动画控制器中,这些条件被称为 参数,因此让我们添加一个。图 6.8 指出了相关控件:选择 Parameters 选项卡,并单击 + 按钮以显示参数类型菜单。添加一个名为 speed 的浮点参数。

CH06_F08_Hocking3

图 6.8 动画窗口,显示动画状态和转换

接下来,您需要根据该参数在动画状态之间进行切换。右键单击 stickman_idle 并选择 Make Transition;这将开始从空闲状态拖出箭头。单击 stickman_walk 以连接到该状态,由于转换是单向的,因此也右键单击 stickman_walk 以返回该状态。

现在选择从空闲状态到转换(您可以直接单击箭头),取消选中 Has Exit Time,并在底部单击 + 以添加条件(如图 6.8 所示)。将条件设置为速度 Greater(大于)0.1,以便在满足该条件时状态将进行转换。现在再次为 walk-to-idle 转换执行此操作:选择从 walk 的转换,取消选中 Has Exit Time,添加条件,并将条件设置为速度 Less(小于)0.1。

最后,PlatformerPlayer 脚本可以操作动画控制器,如本列表所示。

列表 6.2 在移动过程中触发动画

...
private Animator anim;
...
void Start() {
  body = GetComponent<Rigidbody2D>();                              ❶
  anim = GetComponent<Animator>();
}

void Update() {
  ...
  anim.SetFloat("speed", Mathf.Abs(deltaX));                       ❷
  if (!Mathf.Approximately(deltaX, 0)) {                           ❸
    transform.localScale = new Vector3(Mathf.Sign(deltaX), 1, 1);  ❹
  }
}
...

❶ 现有代码以帮助显示新代码的位置

❷ 即使速度为负,速度也大于零。

❸ 浮点数并不总是精确的,因此请使用 Approximately() 进行比较。

❹ 移动时,将比例设置为正或负 1 以面向右或左。

哇,控制动画的代码几乎没多少!大部分工作都是由 Mecanim 处理的,只需要很少的代码来操作动画。玩玩游戏,四处移动,观察玩家精灵的动画。这个游戏真的进展得很快,所以接下来进行下一步!

6.4 添加跳跃能力

玩家可以来回移动,但还没有进行垂直移动。垂直移动(包括从边缘掉落和跳到更高的平台)是平台游戏的一个重要部分,所以让我们接下来实现它。

6.4.1 从重力中掉落

有些反直觉,在玩家能够跳跃之前,它需要重力来与之对抗。正如你可能记得的,你之前将玩家的 Rigidbody 的重力比例设置为 0。这样做是为了让玩家不会因为重力而掉落。现在,将其改回 1:在场景中选择 Player 对象,在检查器中找到 Rigidbody,然后在重力比例中输入 1。

现在重力正在影响玩家,但(假设你已经向地板对象添加了 Box Collider)地板正在支撑他们。从地板的边缘走开,掉入虚无。默认情况下,重力对玩家的影响相对较弱,所以你将想要增加其影响的大小。物理模拟包括一个全局重力设置,你可以在编辑菜单中调整它。具体来说,选择编辑 > 项目设置 > 物理设置 2D。如图 6.9 所示,在各个控制和设置的上端,你应该看到重力 Y;将其更改为 -40。

CH06_F09_Hocking3

图 6.9 物理设置中的重力强度

你可能已经注意到一个微妙的问题:下落的角色会粘在地板的一侧。为了看到这个问题,从平台边缘走开,然后立即反向移动回到平台。哎呀,不太好!幸运的是,Unity 使得修复这个问题变得很容易。只需将 Physics 2D > Platform Effector 2D 组件添加到 Block 和 Floor 上。这个效应用户场景中的对象表现得更像平台游戏中的平台。图 6.10 指出了两个需要调整的设置:在碰撞器上设置 Used By Effector,并在效应用户上关闭 Use One Way(我们将使用这个后者的设置来处理其他平台,但现在不这么做)。

CH06_F10_Hocking3

图 6.10 检查器中的碰撞器和效应用户设置

这样就处理了垂直运动的下落部分,但你仍然需要处理上升部分。

6.4.2 应用向上的冲量

下一个需要的行为是跳跃。当玩家点击跳跃按钮(我们将使用空格键)时,会应用一个向上的冲击。虽然你的代码直接改变了水平移动的速度,但你将保持垂直速度不变,以便重力可以发挥作用。相反,除了重力之外,物体还可以受到其他力的作用,所以你会添加一个向上的力。将此代码添加到 PlatformerPlayer 脚本中。

列表 6.3 按下空格键时跳跃

...
public float jumpForce = 12.0f;
...
body.velocity = movement;                                       ❶

if (Input.GetKeyDown(KeyCode.Space)) {                          ❷
  body.AddForce(Vector2.up * jumpForce, ForceMode2D.Impulse);
}
...

❶ 现有代码以帮助显示新代码的位置

❷ 仅当按下空格键时才添加力。

重要的行是 AddForce() 命令。代码向 Rigidbody 添加向上的力,并以脉冲模式执行。脉冲是一种突然的冲击,与持续施加的力相对。因此,当按下空格键时,此代码会施加一个突然向上的冲击。

同时,重力继续影响跳跃的玩家,当玩家跳跃时形成了一个漂亮的弧线。然而,你可能已经注意到另一个问题,让我们来解决这个问题。

6.4.3 检测地面

跳跃控制有一个微妙的问题:玩家可以在空中跳跃!如果玩家已经在空中(无论是由于跳跃还是由于下落),按下空格键会施加向上的力,但不应这样做。相反,跳跃控制应该只在玩家在地面上时工作。因此,你需要检测玩家是否在地面上。

列表 6.4 检查玩家是否在地面上

...
private BoxCollider2D box;
...
box = GetComponent<BoxCollider2D>();                        ❶
...
body.velocity = movement;

Vector3 max = box.bounds.max;
Vector3 min = box.bounds.min;
Vector2 corner1 = new Vector2(max.x, min.y - .1f);          ❷
Vector2 corner2 = new Vector2(min.x, min.y - .2f);          ❷
Collider2D hit = Physics2D.OverlapArea(corner1, corner2);

bool grounded = false;
if (hit != null) {                                          ❸
  grounded = true;
}

if (grounded && Input.GetKeyDown(KeyCode.Space)) {          ❹
...

❶ 让这个组件使用玩家的碰撞体作为一个检查区域。

❷ 检查碰撞体的最小 Y 值以下。

❸ 如果检测到玩家下方有碰撞体 . . .

❹ . . . 将“接地”条件添加到跳跃条件中。

在此代码到位后,玩家不能再空中跳跃。此脚本添加的修改检查玩家下方的碰撞体,并在跳跃的条件语句中考虑它们。具体来说,代码首先获取玩家的碰撞框边界,然后在玩家下方相同宽度的区域内寻找重叠的碰撞体。该检查的结果存储在 grounded 变量中,并在条件语句中使用。

6.5 平台游戏附加功能

到目前为止,玩家移动的最关键方面,即行走和跳跃,已经实现。让我们通过向玩家周围的环境添加新功能来完善这个平台游戏演示。

使用瓦片地图设计关卡

对于我们的项目,地板和平台是空白、白色的矩形。一个完成的游戏应该有更漂亮的图形,但一个与关卡大小相当的图像对于电脑来说处理起来会太大。解决这个问题的最常见方法是使用瓦片地图。简单来说,这是一种通过许多小块瓦片图像构建一个较大、组合图像的技术。这张图片展示了瓦片地图的一个示例。

CH06_UN01_Hocking3

瓦片地图

注意,地图由在整个地图中重复的小块组成。这样,没有单个图像非常大,但整个屏幕可以用自定义艺术作品覆盖。在 Unity 中,可以通过在“窗口”>“包管理器”中查找 2D 瓦片地图编辑器来找到官方的瓦片地图系统。

您可以在 Unity 文档中找到详细信息(docs.unity3d.com/Manual/class-Tilemap.html)。或者,您可以使用像 SuperTiled2Unity (www.seanba.com/supertiled2unity) 这样的外部库,它导入在 Tiled 中创建的瓦片图,Tiled 是一个流行的(且免费)瓦片图编辑器。

6.5.1 不寻常的地面:斜坡和一维平台

目前,这个演示有正常的、可以站立的地面。然而,平台游戏中使用了许多有趣的平台类型,所以让我们实现一些其他选项。您将创建的第一个不寻常的地面是一个斜坡。复制 Floor 对象,将复制的旋转设置为 0, 0, -25,将其移动到左侧(大约 -3.47, -1.27, 0),并将其命名为 Slope。参考图 6.1 来查看其外观。

如果现在开始玩,玩家在移动时可以正确地上下滑动,但在空闲时由于重力会慢慢滑动。为了解决这个问题,让我们在玩家既站在地面上又空闲时关闭重力。幸运的是,您已经检测到地面,因此可以在新代码中重用它。实际上,只需要一行新代码。

列表 6.5 在地面上站立时关闭重力

...
body.gravityScale = (grounded && Mathf.Approximately(deltaX, 0)) ? 0 : 1;  ❶
if (grounded && Input.GetKeyDown(KeyCode.Space)) {                         ❷
...

❶ 检查在地面上且未移动的状态。

❷ 用于帮助显示新代码位置的现有代码

通过对移动代码的调整,您的玩家角色可以正确地导航斜坡。接下来,一维平台是平台游戏中常见的另一种不寻常的地面。我指的是你可以跳过但仍然可以站立的平台;玩家在正常完全实体的平台底部撞头。

因为它们在平台游戏中相当常见,Unity 提供了一维平台的功能。如您所回忆的,当您之前添加 Platform Effector 组件时,一维设置是关闭的。现在将其打开!要创建一个新的平台,复制 Floor 对象,将其缩放为 10, 1, 1,放置在地板上方,位置约为 -1.68, 0.11, 0,并将对象命名为 Platform。哦,别忘了在 Platform Effector 组件中打开 Use One Way。

玩家从平台下方跳过,但在从上方下来时站在上面。我们有一个可能需要解决的问题,如图 6.11 所示。Unity 可能会显示平台精灵在玩家精灵之上(为了更容易看到这一点,可以将跳跃力设置为 7 进行测试),但您可能希望玩家在上方。您可以像在第五章中做的那样调整玩家的 Z 位置,但这次您将调整其他内容以展示另一种选项。精灵渲染器有一个排序顺序,可以用来控制哪些精灵出现在上方。在玩家的 Sprite Renderer 组件中将 Order in Layer 设置为 1。

CH06_F11_Hocking3

图 6.11 平台精灵与玩家精灵重叠

这样就处理了斜坡地板和单向平台。我将介绍另一种不寻常的地板,但实现起来要复杂得多。

6.5.2 实现移动平台

平台游戏中最常见的第三种不寻常的地板是移动平台。实现它需要一个新的脚本来控制平台本身,以及更改玩家移动脚本来处理移动平台。你将编写一个脚本,它接受两个位置,即起点和终点,并使平台在这两者之间弹跳。首先,创建一个新的 C# 脚本,命名为 MovingPlatform,并在其中编写此代码。

列表 6.6 用于移动地板的 MovingPlatform 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MovingPlatform : MonoBehaviour {
  public Vector3 finishPos = Vector3.zero;           ❶
  public float speed = 0.5f;

  private Vector3 startPos;
  private float trackPercent = 0;                    ❷
  private int direction = 1;                         ❸

  void Start() {
    startPos = transform.position;                   ❹
  }

  void Update() {
    trackPercent += direction * speed * Time.deltaTime;
    float x = (finishPos.x - startPos.x) * trackPercent + startPos.x;
    float y = (finishPos.y - startPos.y) * trackPercent + startPos.y;
    transform.position = new Vector3(x, y, startPos.z);

    if ((direction == 1 && trackPercent > .9f) ||
    (direction == -1 && trackPercent < .1f)) {       ❺
      direction *= -1;
    }
  }
}

❶ 移动的位置

❷ 从起点到终点的“轨道”上的距离

❸ 当前移动方向

❹ 场景中的放置是移动的起始位置

❺ 在起点和终点改变方向。

绘制自定义 gizmos

你将要编写的代码大多数是用于运行游戏,但 Unity 脚本也可以影响 Unity 的 编辑器。Unity 常被忽视的功能之一是添加新菜单和窗口的能力。你的脚本也可以在场景视图中绘制自定义辅助图像;这类辅助图像被称为 gizmos

你已经熟悉像显示碰撞器的绿色盒子这样的 gizmos。这些是内置在 Unity 中的,但你也可以在脚本中绘制自己的 gizmos。例如,绘制显示平台移动路径的线可能很有用,如这里所示。

CH06_UN02_Hocking3

自定义 gizmo

绘制该线的代码很简单。通常,当你编写影响 Unity 编辑界面的代码时,你需要在顶部添加 using UnityEditor;(因为大多数编辑器函数都位于该命名空间中),但在这个例子中,你甚至不需要这样做。将此方法添加到 MovingPlatform 脚本中:

...
void OnDrawGizmos() { 
  Gizmos.color = Color.red;
  Gizmos.DrawLine(transform.position, finishPos);
}
...

你需要了解一些关于此代码的知识。一是所有这些都在一个名为 OnDrawGizmos() 的方法中发生。像 Start() 或 Update() 一样,OnDrawGizmos() 是 Unity 识别的另一个方法名。在方法内部有两行代码:一行设置绘图颜色,另一行告诉 Unity 从平台的位置绘制到终点位置。

类似的命令也用于其他 gizmo 形状。DrawLine() 通过使用起点和终点来定义一条线,但类似的命令 DrawRay() 用于在给定方向上绘制一条线。这对于可视化来自 AI 角色的射线非常有用。

Gizmos 默认情况下仅在场景视图中可见,但请注意,游戏视图中顶部有一个 Gizmos 按钮。哦,尽管这个项目是一个 2D 游戏,但在 3D 游戏中绘制自定义 gizmos 的工作方式也是一样的。

将此脚本拖放到平台对象上。太棒了——当你播放场景时,平台会左右移动!现在你需要调整玩家的移动脚本,以便将玩家附加到移动平台上。以下是需要进行的更改。

列表 6.7 在 PlatformerPlayer 中处理移动平台

...
  body.AddForce(Vector2.up * jumpForce, ForceMode2D.Impulse);
}

MovingPlatform platform = null;
if (hit != null) {
  platform = hit.GetComponent<MovingPlatform>();    ❶
}
if (platform != null) {                             ❷
  transform.parent = platform.transform;
} else {
  transform.parent = null;
}

anim.SetFloat("speed", Mathf.Abs(deltaX));          ❸
...

❶ 检查玩家下方的平台是否为移动平台。

❷ 要么将平台附加到平台上,要么清除 transform.parent。

❸ 现有代码以帮助显示新代码的位置

现在玩家在跳上平台后会随着平台移动。这个变化主要归结于将玩家作为平台的子对象;记住,当你设置父对象时,子对象会随着父对象移动。列表 6.7 使用 GetComponent()检查检测到的地面是否为移动平台。如果是,则将该平台设置为玩家的父对象;否则,玩家将脱离任何父对象。

然而,有一个大问题:玩家继承了平台的缩放,导致缩放异常。这可以通过反缩放(将玩家缩放以抵消平台缩放)来修复。

列表 6.8 纠正玩家缩放

...
  anim.SetFloat("speed", Mathf.Abs(deltaX));

  Vector3 pScale = Vector3.one;                      ❶
  if (platform != null) {
    pScale = platform.transform.localScale;
  }
  if (!Mathf.Approximately(deltaX, 0)) {
    transform.localScale = new Vector3(
    Mathf.Sign(deltaX) / pScale.x, 1/pScale.y, 1);   ❷
  }
}
...

❶ 如果不在移动平台上,则默认缩放为 1

❷ 用新代码替换现有的缩放。

反缩放的数学很简单:将玩家设置为 1 除以平台的缩放。然后,当玩家的缩放乘以平台的缩放时,剩下的缩放为 1。这个代码的唯一难点是乘以移动值的符号;如你之前所回忆的,玩家根据移动方向翻转。

这样,移动平台就完全实现了。这个平台游戏演示只需要最后的润色。

6.5.3 摄像机控制

将摄像机移动是您将添加到这个 2D 平台游戏的最后一个功能。创建一个名为 FollowCam 的脚本,将其拖放到摄像机上,然后在其中编写以下内容。

列表 6.9 FollowCam 脚本以跟随玩家移动

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FollowCam : MonoBehaviour {
  public Transform target;

  void LateUpdate() {
    transform.position = new Vector3(
    target.position.x, target.position.y, transform.position.z);  ❶
  }
}

❶ 在改变 X 和 Y 的同时保留 Z 位置。

编写完代码后,将玩家对象拖动到检查器中脚本的目标槽位。播放场景,摄像机就会移动,将玩家保持在屏幕中心。你可以看到代码将目标对象的位子应用到摄像机上,并且你将玩家设置为目标对象。注意,方法名是 LateUpdate()而不是 Update();这是 Unity 识别的另一个名称。LateUpdate()也会在每一帧执行,但它发生在每一帧的 Update()之后。

摄像机始终与玩家精确同步移动,这有点令人不快。在大多数平台游戏中,摄像机都有各种微妙但复杂的动作,随着玩家的移动,突出显示不同部分的游戏级别。实际上,平台游戏的摄像机控制是一个令人惊讶的深入话题;尝试搜索“平台游戏摄像机”并查看所有结果。然而,在这种情况下,你只是要让摄像机的移动更平滑,不那么令人不快;这个列表进行了相应的调整。

列表 6.10 平滑摄像机移动

...
public float smoothTime = 0.2f;

private Vector3 velocity = Vector3.zero;
...
void LateUpdate() {
  Vector3 targetPosition = new Vector3(
  target.position.x, target.position.y, transform.position.z);   ❶

  transform.position = Vector3.SmoothDamp(transform.position,
  targetPosition, ref velocity, smoothTime);                     ❷
}
...

❶ 在改变 X 和 Y 的同时保留 Z 位置。

❷ 从当前位置平滑过渡到目标位置

主要的改动是调用了一个名为 SmoothDamp()的函数;其他改动(如添加时间和速度变量)都是为了支持这个函数。这是一个 Unity 提供的函数,用于使值平滑过渡到新值。在这种情况下,这些值是摄像机和目标的位置。

现在摄像机与玩家移动得更加平滑。你实现了玩家的移动、几种平台类型,现在又实现了摄像机控制。看起来这一章的项目已经完成了!

概述

  • 图集是处理 2D 动画的常见方式。

  • 游戏中的角色不会像现实世界中的物体那样行为,因此你必须相应地调整它们的物理属性。

  • Rigidbody 对象可以通过施加力或直接设置它们的速度来控制。

  • 2D 游戏中的关卡通常使用瓦片图来构建。

  • 一个简单的脚本可以使摄像机平滑地跟随玩家。

7 将 GUI 添加到游戏中

本章涵盖

  • 比较旧的和新的 GUI 系统

  • 为界面创建画布

  • 使用锚点定位 UI 元素

  • 向 UI 添加交互性(按钮、滑块等)

  • 从 UI 广播和监听事件

在本章中,你将为 3D 游戏构建一个 2D 界面显示。到目前为止,我们在构建第一人称演示时一直专注于虚拟场景本身。但每个游戏都需要除了游戏发生的虚拟场景之外的抽象交互和信息显示。这对所有游戏都适用,无论是 2D 还是 3D,第一人称射击游戏还是益智游戏。因此,尽管本章中的技术将用于 3D 游戏,但它们也适用于 2D 游戏。

这些抽象交互显示被称为UI,或者更具体地说,GUI。GUI(代表图形用户界面)指的是界面的视觉部分,如文本和按钮(见图 7.1)。技术上,UI 包括非图形控件,如键盘或游戏手柄,但人们通常在提到“用户界面”时指的是图形部分。

CH07_F01_Hocking3

图 7.1 为游戏创建的 GUI

虽然任何软件都需要某种形式的 UI 以便用户控制它,但游戏通常以与其他软件略有不同的方式使用它们的 GUI。例如,在一个网站上,GUI 基本上就是网站(从视觉表现的角度来看)。然而,在游戏中,文本和按钮通常是游戏视图上的额外叠加,这是一种称为抬头显示 (HUD) 的显示方式。

定义 一个抬头显示 (HUD) 将图形叠加在世界的视图之上。HUD 的概念起源于军用飞机——其目的是让飞行员能够在不低头的情况下看到关键信息。同样,叠加在游戏视图上的 GUI 被称为 HUD。

本章展示了如何使用 Unity 中的 UI 工具构建游戏的 HUD。正如你在第五章中看到的,Unity 提供了多种创建 UI 显示的方式。本章演示了取代 Unity 最初 UI 系统的先进 UI 系统。我还讨论了之前的 UI 系统以及新系统的优势。

要了解 Unity 中的 UI 工具,你将在第三章的 FPS 项目基础上进行构建。本章的项目涉及以下步骤:

  1. 规划界面

  2. 在显示上放置 UI 元素

  3. 编程与 UI 元素之间的交互

  4. 使 GUI 对场景中的事件做出响应

  5. 使场景对 GUI 上的操作做出响应

复制第三章的项目并打开副本以开始本章的工作。像往常一样,你需要的艺术资源在样本下载中。设置好这些文件后,你就可以开始构建游戏的 UI 了。

注意:本章中的所有示例都是基于第三章创建的 FPS 游戏。但本章的内容在很大程度上独立于那个基础项目;我们只是在现有的游戏演示上添加了一个图形界面。尽管我建议你下载第三章的项目,但你完全可以使用你喜欢的任何游戏演示。

7.1 在你开始编写代码之前...

要开始构建 HUD,你首先需要了解 UI 系统的工作方式。Unity 提供了多种构建游戏 HUD 的方法,因此我们需要了解这些系统是如何工作的。然后我们可以简要规划 UI 并准备我们需要的艺术资源。

7.1.1 立即模式 GUI 还是高级 2D 界面?

从其第一个版本开始,Unity 就附带了一个立即模式 GUI 系统。立即模式系统使得在屏幕上放置可点击的按钮变得容易。列表 7.1 展示了执行此操作的代码:只需将此脚本附加到场景中的任何对象上。

定义 立即模式 指的是每帧明确发出绘制命令——而不是一次性定义所有视觉元素,然后对于每一帧,系统知道要绘制什么,而不需要你再次告诉它。后一种方法称为 保留模式

作为立即模式 UI 的另一个示例,回忆一下第三章中显示的目标光标。这个 GUI 系统完全基于代码,没有在 Unity 的编辑器中做任何工作。

列表 7.1 使用立即模式 GUI 的按钮示例

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BasicUI : MonoBehaviour {
   void OnGUI() {                                          ❶
      if (GUI.Button(new Rect(10, 10, 40, 20), "Test")) {  ❷
         Debug.Log("Test button");
      }
   }
}

❶ 每渲染完其他所有内容后每帧调用的函数

❷ 参数:位置 X,位置 Y,宽度,高度,文本标签

列表中代码的核心是 OnGUI() 方法。与 Start() 和 Update() 类似,每个 MonoBehaviour 都会自动响应 OnGUI()。该函数在 3D 场景渲染后每帧运行,提供了一个放置 GUI 绘图命令的位置。此代码绘制了一个按钮;请注意,按钮的命令每帧执行(即在立即模式风格中)。按钮命令用于条件语句中,当按钮被点击时作出响应。

由于立即模式 GUI 使得以最小的努力在屏幕上放置几个按钮变得容易,我们有时会在未来的章节中使用它作为示例。但默认按钮是那个系统中唯一容易创建的东西,因此 Unity 的较新版本现在有一个基于编辑器中布局的 2D 图形的新界面系统。这个较新的界面系统需要更多的工作来设置,但你可能会希望在完成的游戏中使用它,因为它会产生更精致的结果。

较新的 UI 系统在保留模式下工作,因此图形只布局一次,然后每帧绘制,而不需要不断重新定义。在这个系统中,UI 的图形放置在 Unity 的编辑器中。这比立即模式 UI 提供两个优势:(1)你可以在放置 UI 元素时看到 UI 的外观,并且(2)这个系统使得使用自己的图像自定义 UI 变得简单直接。

注意:第一章提到 Unity 有三个 UI 系统(这些系统在mng.bz/205X进行了比较),因为相继开发的系统在先前的系统基础上进行了改进。本书涵盖了第二个 UI 系统(Unity UI,或 uGUI),因为它仍然比不完整的第三个 UI 系统(UI Toolkit)更受欢迎。

要使用此系统,你需要导入图像并将对象拖动到场景中。接下来,让我们规划这个 UI 的外观。

7.1.2 规划布局

大多数游戏的 HUD 由一些重复的 UI 控制组成。因此,这个项目不需要非常复杂,你就可以学习如何构建游戏的 UI。你将在屏幕角落的主游戏视图上方放置一个得分显示和一个设置按钮(见图 7.2)。设置按钮将弹出一个包含文本字段和滑块的弹出窗口。

CH07_F02_Hocking3

图 7.2 计划的 GUI

在这个例子中,这些输入控制将用于设置玩家的名字和移动速度,但最终这些 UI 元素可以控制与你的游戏相关的任何设置。嗯,这个计划相当简单!下一步是引入所需的图像。

7.1.3 导入 UI 图像

这个 UI 需要一些图像来显示按钮等元素。UI 是由第五章中的图形等 2D 图像构建的,所以你将遵循相同的两个步骤:

  1. 导入图像(如果需要,设置为精灵)。

  2. 将精灵拖动到场景中。

要完成这些步骤,首先将图像拖动到项目视图中以导入它们。然后,在检查器中,将它们的纹理类型设置更改为精灵(2D 和 UI)。

警告:在 3D 项目中,纹理类型设置默认为纹理,在 2D 项目中默认为精灵。如果你想在 3D 项目中使用精灵,你需要手动调整此设置。

从样本下载中获取所有必要的图像(见图 7.3),然后将它们导入到你的项目中。确保所有导入的资产都设置为精灵;你可能需要调整导入后显示的设置中的纹理类型。

CH07_F03_Hocking3

图 7.3 本章项目所需的图像

这些精灵包括你将创建的按钮、得分显示和弹出窗口。现在图像已经导入,让我们将这些图形放到屏幕上。

7.2 设置 GUI 显示

艺术资产与我们第五章使用的 2D 精灵相同,但我们将以不同的方式使用这些资产。Unity 提供了特殊工具,可以将图像作为显示在 3D 场景上而不是场景一部分的 HUD。在定位 UI 元素时使用了一些特殊技巧,因为可能需要在不同的屏幕上显示的显示需求。

7.2.1 为界面创建画布

UI 系统工作方式的最基本且不明显的一个方面是,所有图像都必须附加到画布对象。

提示:Canvas 是 Unity 渲染游戏 UI 的一种特殊对象。

打开游戏对象菜单,查看你可以创建的对象;在 UI 类别中,选择画布。画布对象将出现在场景中(可能将对象重命名为 HUD 画布会更清晰)。此对象代表整个屏幕范围,与 3D 场景相比非常大,因为它将屏幕上的一个像素缩放到场景中的一个单位。

警告:当你创建画布对象时,也会自动创建一个事件系统对象。该对象用于 UI 交互,但你可以忽略它。

切换到 2D 视图模式(参见图 7.4),然后在层次结构中双击画布以缩放并完全查看它。当整个项目是 2D 时,2D 视图模式是自动的,但在 3D 项目中,必须单击此切换按钮来在 UI 和主场景之间切换。要返回查看 3D 场景,关闭 2D 视图模式,然后双击建筑以缩放到该对象。

CH07_F04_Hocking3

图 7.4 场景视图中一个空白的画布对象

提示:不要忘记第四章中的这个提示:场景视图面板顶部有按钮可以控制可见内容,所以请查看那里以找到效果按钮来关闭天空盒。

画布有一些你可以调整的设置。第一个是渲染模式选项。将其保留在默认设置(屏幕空间—叠加),但你应该知道三个可能设置的含义:

  • 屏幕空间 —叠加 —将 UI 作为 2D 图形渲染在相机视图之上。(这是默认设置。)

  • 屏幕空间 —相机 —也在相机视图中渲染 UI,但 UI 元素可以旋转以产生透视效果。

  • 世界空间 —将画布对象放置在场景中,就像 UI 是 3D 场景的一部分。

除了初始默认设置之外,其他两种模式有时可能对特定效果有用,但稍微复杂一些。

另一个重要的设置是像素完美。此设置会导致渲染微妙地调整图像的位置,使它们始终完美清晰(与在像素之间定位时模糊它们相反)。请选择该复选框。现在 HUD 画布已设置好,但仍然是空的,需要精灵。

7.2.2 按钮、图像和文本标签

画布对象定义了一个用于显示 UI 的区域,但它仍然需要精灵来显示。参考图 7.2 中的 UI 原型,你会看到左上角有一个方块/敌人的图像,旁边显示分数的文字,右上角有一个齿轮形状的按钮。因此,GameObject 菜单的 UI 部分包含创建图像、文本或按钮的选项。创建每种类型的一个,但在适用的情况下使用 TextMeshPro 版本。也就是说,选择 GameObject > UI > Image,然后 Text - TextMeshPro,然后 Button - TextMeshPro。

注意:正如第五章所述,你需要安装 TextMeshPro 包,所以如果 UI 对象的菜单中没有显示 TextMeshPro 版本,请转到 Window > Package Manager。当你第一次创建 TextMeshPro 对象时,TMP Importer 窗口将自动出现。点击导入 TMP Essentials 按钮。

要正确显示,UI 元素需要是画布对象的子对象。Unity 会自动完成这项工作,但请记住,就像通常一样,你可以拖动对象在 Hierarchy 视图中进行父子链接(见图 7.5)。

CH07_F05_Hocking3

图 7.5 Hierarchy 视图中链接了图像的画布

画布内的对象可以为了定位目的相互关联,就像场景中的任何其他对象一样。例如,你应该将文本对象拖到图像上,以便文本随图像移动。默认按钮对象也有一个文本对象作为其子对象,但这个项目的按钮不需要文本标签,所以删除默认的文本对象。

大致将 UI 元素放置到它们的角落。在下一节中,我们将使位置精确;现在,只需拖动对象直到它们大致到位。点击并拖动图像对象到画布的左上角;按钮放在右上角。

提示:正如第五章所述,你在 2D 模式下使用矩形工具。我将其描述为一种包含所有三个变换(移动、旋转和缩放)的单个操作工具。这些操作在 3D 中必须是单独的工具,但在 2D 中它们是组合的,因为这样少了一个要考虑的维度。在 2D 模式下,此工具会自动选择,或者你可以点击 Unity 左上角附近的按钮。

目前,图像是空的。如果你选择一个 UI 对象并查看检查器,你应该在图像组件的顶部附近看到一个源图像槽。如图 7.6 所示,从项目视图中拖动精灵(记住,不是纹理!)以将图像分配给对象。将敌人精灵分配给图像对象,将齿轮精灵分配给按钮对象(在分配精灵后点击设置原生大小以正确调整图像对象的大小)。

CH07_F06_Hocking3

图 7.6 将 2D 精灵分配给 UI 元素的 Image 属性。

这样就处理了敌图像和齿轮按钮的外观。至于文本对象,检查器有一系列设置(见图 7.7)。首先,在大的文本输入框中输入一个单独的数字;此文本稍后将会被覆盖,但它很有用,因为它看起来像编辑器内的分数显示。文本大小不正确,所以将字体大小更改为 24。然后点击第一个字体样式按钮以加粗,并将顶点颜色更改为黑色。您还希望将此标签设置为左对齐和中间垂直对齐。目前,其余设置可以保留为默认值。

CH07_F07_Hocking3

图 7.7 UI 文本对象的设置

注意:我们尚未涉及的最常见调整属性是字体。要使用 TextMeshPro 的 TrueType 字体,首先将字体导入 Unity,然后选择 Window > TextMeshPro > Font Asset Creator。

现在已经将精灵分配给 UI 图像,并且设置了分数文本,您可以点击播放来查看 3D 游戏顶部的 HUD。Unity 编辑器中显示的画布显示了屏幕的边界,UI 元素在图 7.8 所示的位置绘制到屏幕上。

CH07_F08_Hocking3

图 7.8 在编辑器中看到的 GUI(左侧)和游戏播放时的 GUI(右侧)

太好了,您已经创建了一个带有 2D 图像覆盖 3D 游戏的 HUD!还有一个更复杂的视觉设置需要处理:相对于画布定位 UI 元素。

7.2.3 控制 UI 元素的位置

所有 UI 对象都有一个锚点,在编辑器中以 X 形状显示(见图 7.9)。锚点是一种灵活的方式来定位 UI 上的对象。

CH07_F09_Hocking3

图 7.9 图像对象的锚点

定义:对象的锚点是对象连接到画布或屏幕的点。该对象的位置是相对于锚点测量的。

位置是像“x 轴上的 50 像素”这样的值。但这留下了一个问题:50 像素是从哪里开始的?这就是锚点发挥作用的地方。锚点的目的是保持对象相对于锚点的位置不变,而锚点则相对于画布移动。锚点被定义为类似于“屏幕中心”的东西,然后锚点将保持居中,即使屏幕大小发生变化。同样,将锚点设置为屏幕的右侧将保持对象相对于右侧的位置不变,即使屏幕大小发生变化(例如,如果游戏在不同的显示器上播放)。

理解我所谈论的内容最简单的方法是看到它在实际操作中的表现。选择图像对象,然后查看检查器。锚点设置将直接出现在变换组件下方(见图 7.10)。默认情况下,UI 元素锚点设置为居中,但您需要将此图像的锚点设置为左上角;图 7.10 展示了如何通过使用锚点预设进行调整。

CH07_F10_Hocking3

图 7.10 如何调整锚点设置

也要改变齿轮按钮的锚点。将此对象设置为右上角;点击右上角的锚点预设。现在尝试左右缩放窗口:点击并拖动游戏的视图边缘。多亏了锚点,UI 对象会保持在它们的角落,而画布改变大小。如图 7.11 所示,这些 UI 元素现在固定在位置,而屏幕移动。

CH07_F11_Hocking3

图 7.11 当屏幕改变大小时,锚点保持在原位。

TIP 锚点可以调整缩放和位置。我们在这章中不会探讨这个功能,但图像的每个角落都可以锚定到屏幕的不同角落。在图 7.11 中,图像的大小没有改变,但我们可以调整锚点,使得当屏幕改变大小时,图像也会随之拉伸。

所有的视觉设置都已经完成,现在是时候编程交互性了。

7.3 在 UI 中编程交互性

在你可以与 UI 交互之前,你需要有一个鼠标光标。如你所回忆,我们在 RayShooter 代码的 Start()方法中调整了光标设置。这些设置锁定并隐藏鼠标光标,这对于 FPS 游戏中的控制是有效的,但会干扰使用 UI。从 RayShooter 中删除这些行,以便你可以点击 HUD。

当你打开 RayShooter 时,你也可以确保在交互 GUI 时不要射击。这是相应的代码。

列表 7.2 在 RayShooter 代码中添加 GUI 检查

using UnityEngine.EventSystems;                      ❶
...
void Update() {
   *if (Input.GetMouseButtonDown(0) &&*                ❷
!EventSystem.current.IsPointerOverGameObject()) {    ❸
   *Vector3 point = new Vector3(
      camera.pixelWidth/2, camera.pixelHeight/2, 0);*
   ...

❶ 包含 UI 系统代码框架。

❷ 斜体代码已经在脚本中;此处显示仅供参考。

❸ 检查是否使用了 GUI。

现在你可以玩游戏并点击按钮,尽管它目前还没有任何功能。你可以观察按钮在鼠标悬停和点击时的着色变化。这种鼠标悬停和点击行为是每个按钮的默认着色,可以更改,但默认设置目前看起来很好。你可以加快默认的淡入淡出行为;淡入淡出持续时间是按钮组件中的一个设置,所以尝试将其减小到 0.01,看看按钮如何变化。

TIP 有时候,UI 的默认交互控制也会干扰游戏。还记得与画布一起自动创建的事件系统对象吗?该对象控制 UI 交互控制,默认情况下它使用箭头键与 GUI 交互。你可能需要关闭箭头键以避免意外与 GUI 交互:为此,在 EventSystem 的设置中取消选中“发送导航事件”复选框。

但点击按钮时没有发生其他任何事情,因为你还没有将其链接到任何代码。让我们在下一部分处理这个问题。

7.3.1 编程不可见的 UIController

通常,UI 交互是通过一系列标准步骤编程的,这些步骤对所有 UI 元素都是相同的:

  1. 在场景中创建一个 UI 对象(上一节中创建的按钮)。

  2. 编写一个在操作 UI 时调用的脚本。

  3. 将该脚本附加到场景中的对象。

  4. 将 UI 元素(如按钮)链接到具有该脚本的对象。

要遵循这些步骤,首先我们需要创建一个控制器对象以将其链接到按钮。创建一个名为 UIController 的脚本,并将其拖放到场景中的控制器对象上。

列表 7.3 用于编程按钮的 UIController 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;                                    ❶

public class UIController : MonoBehaviour {
   [SerializeField] TMP_Text scoreLabel;        ❷

   void Update() {
      scoreLabel.text = Time.realtimeSinceStartup.ToString();
   }

   public void OnOpenSettings() {               ❸
      Debug.Log("open settings");
   }
}

❶ 导入 TextMeshPro 代码框架。

❷ 在场景中引用文本对象以设置文本属性。

❸ 由设置按钮调用的方法

TIP 你可能想知道为什么我们需要为 SceneController 和 UIController 使用单独的对象。确实,这个场景非常简单,你可以有一个控制器同时处理 3D 场景和 UI。然而,随着游戏的复杂化,将 3D 场景和 UI 作为独立的模块,间接通信将变得越来越有用。这一概念不仅适用于游戏,也适用于软件的通用性:软件工程师将这一原则称为关注点分离

现在将对象拖到组件槽位以连接它们。将得分标签(我们之前创建的文本对象)拖到 UIController 文本槽位。UIController 中的代码设置了该标签上显示的文本。目前,该代码显示一个计时器以测试文本显示;这将被后来更改为得分。

接下来,向按钮添加一个 OnClick 条目以拖动控制器对象。选择按钮以在检查器中查看其设置。在底部附近,你应该看到一个 OnClick 面板;最初该面板是空的,但你可以点击+按钮添加一个条目(如图 7.12 所示)。每个条目定义了一个当按钮被点击时调用的单个函数;列表中有一个对象槽位和一个用于调用函数的菜单。将控制器对象拖到对象槽位,然后在菜单中查找 UIController;在该部分选择 OnOpenSettings()。

CH07_F12_Hocking3

图 7.12 按钮设置底部的 OnClick 面板

响应其他鼠标事件

OnClick 是按钮组件暴露的唯一事件,但 UI 元素可以响应多个交互。要超越默认交互,请使用 EventTrigger 组件。

向按钮对象添加一个新组件,并查找组件菜单的事件部分。从菜单中选择 EventTrigger。尽管按钮的 OnClick 只响应完整的点击(鼠标按钮按下然后释放),但让我们尝试响应鼠标按钮按下但不释放的情况。执行与 OnClick 相同的步骤,但响应不同的事件。首先向 UIController 添加另一个方法:

...
public void OnPointerDown() {
   Debug.Log("pointer down");
}
...

现在点击“添加新事件类型”以向事件触发器组件添加一个新类型。选择“指针按下”作为事件。这将为此事件创建一个空面板,就像“OnClick”一样。点击“+”按钮添加事件列表,将控制器对象拖到这个条目中,并在菜单中选择“OnPointerDown()”。就这样!

玩游戏并点击按钮,在控制台输出调试信息。再次强调,当前代码是随机输出以测试按钮的功能。我们想要打开一个设置弹窗,所以接下来让我们创建那个弹窗窗口。

7.3.2 创建弹窗窗口

UI 有一个按钮可以打开弹窗窗口,但目前还没有弹窗。那将是一个新的图像对象,以及一些附加到该对象上的控件(如按钮和滑块)。第一步是创建一个新的图像,所以选择“游戏对象”>“UI”>“图像”。就像之前一样,新的图像在检查器中有一个名为“源图像”的槽位。将精灵拖到该槽位以设置此图像。这次,使用名为“popup”的精灵。

通常,精灵会拉伸到整个图像对象上;这是分数和齿轮图像的工作方式,你点击“设置原生大小”按钮来调整对象的大小以匹配图像的大小。这是图像对象的默认行为,但弹窗将使用分割图像。

定义 一个分割图像被分割成九个部分,这些部分相对于彼此有不同的缩放比例。通过将图像的边缘与中间部分分别缩放,你可以确保图像可以缩放到任何你想要的大小,并且保持其清晰、锐利的边缘。在其他开发工具中,这类图像的名称中通常有“9”字样(如 9-slice、9-patch、scale-9),以表示图像的九个部分。

正如你在图 7.13 中看到的,图像组件有一个“图像类型”设置。此设置默认为“简单”,这是之前正确的图像类型。但对于弹窗,应将“图像类型”设置为“分割”。Unity 可能会显示一个错误,抱怨图像没有边框,所以我们将稍后纠正这一点。

CH07_F13_Hocking3

图 7.13 图像组件的设置,包括图像类型

错误发生是因为弹窗精灵还没有定义九个边框部分。为了设置这些部分,首先在项目视图中选择弹窗精灵。在检查器中,你应该能看到精灵编辑器按钮(见图 7.14);点击该按钮,精灵编辑器窗口将出现。

警告 如第六章所述,精灵编辑器窗口需要 2D 精灵包。创建 2D 项目可能会自动安装该包,但针对此项目,您需要打开“窗口”>“包管理器”,并在窗口左侧的列表中查找“2D 精灵”。选择该包,然后点击“安装”按钮。

CH07_F14_Hocking3

图 7.14 检查器中的精灵编辑器按钮和弹窗窗口

在精灵编辑器中,你可以看到表示图像如何切片的绿色线条。最初,图像不会有任何边框(所有边框设置均为 0)。将所有四边的边框宽度增加到 12,这将导致图 7.14 中显示的边框。因为所有四边(左、右、下和上)的边框都设置为 12 像素宽,边框线将相交成九个部分。关闭编辑器窗口并应用更改。

现在精灵已经定义了九个部分,切片图像将正常工作(并且图像组件设置将显示填充中心;确保该设置是开启的)。点击并拖动图像角落的蓝色指示器以缩放它(如果你看不到任何缩放指示器,请切换到第五章中描述的矩形工具)。边框部分将保持其大小,而中心部分将缩放。

由于边框部分保持其大小,切片图像可以缩放到任何大小,并且仍然具有清晰的边缘。这对于 UI 元素来说非常完美——不同的窗口可能有不同的大小,但应该看起来相同。对于这个弹出窗口,输入宽度为 250 和高度为 200,使其看起来像图 7.15(此外,将其居中对齐在位置 0, 0, 0)。

CH07_F15_Hocking3

图 7.15 将切片图像缩放到弹出窗口的尺寸

提示:UI 图像堆叠的方式由它们在层次结构视图中的顺序决定。在层次结构列表中,将弹出窗口对象拖放到其他 UI 对象之上(当然,始终附着在画布上)。现在在场景视图中移动弹出窗口;你可以看到图像如何重叠弹出窗口。最后,将弹出窗口拖放到画布层次结构的底部,以便它显示在所有其他内容之上。

弹出窗口对象已设置好,因此为它编写一些代码。创建一个名为 SettingsPopup 的脚本,并将其拖放到弹出窗口对象上。

列表 7.4 弹出窗口对象的 SettingsPopup 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SettingsPopup : MonoBehaviour {
   public void Open() {
      gameObject.SetActive(true);             ❶
   }
   public void Close() {
      gameObject.SetActive(false);            ❷
   }
}

❶ 打开对象以打开窗口。

❷ 使此对象失效以关闭窗口。

接下来,打开 UIController 进行一些调整。

列表 7.5 调整 UIController 以处理弹出窗口

...
[SerializeField] SettingsPopup settingsPopup;
void Start() {
   settingsPopup.Close();          ❶
}
...
public void OnOpenSettings() {
   settingsPopup.Open();           ❷
}
...

❶ 游戏开始时关闭弹出窗口。

❷ 将调试文本替换为弹出窗口的方法。

此代码为弹出窗口对象添加了一个槽位,因此将弹出窗口拖放到 UIController。当你玩游戏时,弹出窗口将最初关闭,当你点击设置按钮时,它将打开。

目前,没有方法可以再次关闭它,所以给弹出窗口添加一个按钮。步骤与之前创建按钮时基本相同:选择 GameObject > UI > Button - TextMeshPro,将新按钮放置在弹出窗口的右上角,将关闭精灵拖动到这个 UI 元素的 Source Image 属性,然后点击 Set Native Size 以正确调整图像大小。与之前的按钮不同,我们想要这个文本标签,因此选择文本对象,在文本字段中输入“关闭”,将字体大小调整为 14,并将顶点颜色设置为白色。在 Hierarchy 视图中,将此按钮拖动到弹出对象上,使其成为弹出窗口的子对象。最后,为了增加细节,调整按钮过渡效果,将淡入持续时间设置为 0.01,并将正常颜色设置为 210, 210, 210, 255。

要使按钮关闭弹出窗口,需要添加一个 OnClick 条目;在按钮的 OnClick 面板上点击+按钮,将弹出窗口拖动到对象槽中,并从函数列表中选择 SettingsPopup > Close()。现在开始游戏,这个按钮将关闭弹出窗口。

弹出窗口已添加到 HUD 中。不过,窗口目前是空的,所以让我们向它添加控件。

7.3.3 使用滑块和输入字段设置值

与我们之前创建的按钮一样,向设置弹出窗口添加控件涉及两个主要步骤。您创建附加到画布的 UI 元素,并将这些对象链接到脚本。我们需要的是文本字段和滑块控件,以及一个静态文本标签来标识滑块。选择 GameObject > UI > InputField - TextMeshPro 创建文本字段,GameObject > UI > Slider 创建滑块对象,以及 GameObject > UI > Text - TextMeshPro 创建文本标签对象(见图 7.16)。

CH07_F16_Hocking3

图 7.16 添加到弹出窗口的输入控件

通过在 Hierarchy 视图中拖动并将它们放置如图所示的位置,使所有三个对象成为弹出窗口的子对象,并使它们在弹出窗口的中间对齐。为了给滑块创建标签,将文本对象设置为“Speed”,并将其颜色设置为黑色。输入字段用于输入文本,大文本框中的内容在玩家输入其他内容之前显示;将此值设置为“Name”。您可以保留“内容类型”和“行类型”选项的默认设置;如果需要,可以使用“内容类型”来限制输入为仅字母或仅数字,而可以使用“行类型”从单行文本切换到多行文本。

警告:如果文本标签覆盖了滑块,您将无法点击滑块。在 Hierarchy 中将文本对象移到滑块上方,或者最好关闭 Raycast Target 设置(如图 7.7 所示展开额外设置),这样鼠标点击将忽略此对象。

警告:在这个示例中,你可能应该将输入字段保留在默认大小,但如果你决定缩小它,只减小宽度,不要减小高度。如果你将高度设置为小于 30,那么文本将无法显示。

至于滑块本身,在组件检查器的底部出现了一些设置。默认情况下,最小值设置为 0;保留它。默认情况下,最大值设置为 1,但在这个示例中将其设置为 2。同样,值和整数都可以保留在默认值;值控制滑块的起始值,整数将其限制为 0、1、2 而不是小数值(我们不希望有这种限制)。

所有对象都处理完毕。现在你需要编写与对象链接的代码;将以下列表中的方法添加到 SettingsPopup。

列表 7.6 弹出窗口的输入控件设置方法

...
public void OnSubmitName(string name) {        ❶
   Debug.Log(name);
}
public void OnSpeedValue(float speed) {        ❷
   Debug.Log($"Speed: {speed}");               ❸
}
...

❶ 当用户在输入字段中输入时触发

❷ 当用户调整滑块时触发

❸ 使用字符串插值构建消息

太好了!我们有了控件使用的方法。现在选择输入对象,在设置的最下面你会看到一个 On End Edit 面板;这里列出的事件是在用户完成输入时触发的。向这个面板添加一个条目,将弹出窗口拖到对象槽中,并在函数列表中选择 SettingsPopup.OnSubmitName()。

警告:务必在 End Edit 面板的上部选择函数,动态字符串,而不是下部,静态参数。OnSubmitName()函数出现在两个部分中,但在静态参数下选择它将只发送之前定义的单个字符串;动态字符串指的是在输入字段中输入的任何值。

对于滑块,遵循相同的步骤:在组件设置的最后部分(在这种情况下,面板是 OnValueChanged)查找事件面板,点击+添加一个条目,将设置弹出窗口拖入,并在动态值函数列表中选择 SettingsPopup.OnSpeedValue()。

现在两个输入控件都已连接到弹出窗口脚本中的代码。玩游戏,当你移动滑块或输入后按 Enter 键时,观察控制台。

通过使用 PlayerPrefs 在游戏之间保存设置

Unity 中可用于保存持久数据的几种方法,其中最简单的一种叫做 PlayerPrefs。Unity 提供了一种抽象的方式(也就是说,你不必担心细节),可以保存少量信息,这些信息在所有平台上(具有不同的文件系统)都有效。PlayerPrefs 对于大量数据来说并不太有用(在未来的章节中,我们将使用其他方法来保存游戏进度),但它非常适合保存设置。

PlayerPrefs 提供了简单的命令来获取和设置命名值(它的工作方式类似于哈希表或字典)。例如,您可以通过在 SettingsPopup 脚本的 OnSpeedValue() 方法中添加行 PlayerPrefs.SetFloat("speed", speed); 来保存速度设置。该方法将浮点值保存在名为 speed 的值中。

类似地,您可能希望将滑块初始化为保存的值。将以下代码添加到 SettingsPopup:

using UnityEngine.UI;       ❶
...
[SerializeField] Slider speedSlider;
void Start() {
   speedSlider.value = PlayerPrefs.GetFloat("speed", 1);
}
...

❶ 导入 UI 代码框架。

注意,获取命令既有要获取的值,也有默认值,以防速度之前未保存。

尽管控制生成调试输出,但它们仍然不影响游戏。使 HUD 影响游戏(反之亦然)是本章最后部分的主题。

7.4 通过响应事件更新游戏

到目前为止,HUD 和主游戏一直在相互忽视,但它们应该相互通信。这可以通过脚本引用来实现,就像您为其他类型的对象间通信所做的那样,但这种方法会有很大的缺点。特别是,这样做会将场景和 HUD 紧密耦合;您希望它们相对独立,这样您就可以自由地编辑游戏,而不用担心破坏了 HUD。

为了让 UI 警告场景中的动作,我们将使用广播信使系统。图 7.17 展示了该事件消息系统的工作原理:脚本可以注册以监听事件,其他代码可以广播事件,监听器将收到广播消息的警报。让我们回顾一下消息系统以实现这一点。

CH07_F17_Hocking3

图 7.17 我们将实现的广播事件系统图

提示 C# 确实有一个内置的事件处理系统,所以您可能会想知道为什么我们不使用它。嗯,内置的事件系统强制执行目标消息,而我们需要的是广播信使系统。目标系统要求代码确切知道消息的来源;广播可以来自任何地方。

7.4.1 集成事件系统

为了让 UI 警告场景中的动作,我们将使用广播信使系统。尽管 Unity 没有内置此功能,但您可以下载一个用于此目的的好代码库。此信使系统非常适合以解耦的方式将事件传达给程序的其他部分。当某些代码广播消息时,该代码不需要了解任何关于监听器的信息,这允许在切换或添加对象时具有很大的灵活性。

创建一个名为 Messenger 的脚本,并将代码粘贴到 github.com/jhocking/from-unity-wiki/blob/main/Messenger.cs。然后,您还需要创建一个名为 GameEvent 的脚本,并用列表 7.7 中的代码填充它。

列表 7.7 与 Messenger 一起使用的 GameEvent 脚本

public static class GameEvent {
   public const string ENEMY_HIT = "ENEMY_HIT";
   public const string SPEED_CHANGED = "SPEED_CHANGED";
}

此脚本定义了一些事件消息的常量;这样组织消息更加有序,你也不必到处记住并输入消息字符串。

现在事件消息系统已经准备好使用,让我们开始使用它。首先,我们将从场景与 HUD 进行通信,然后我们将进行相反方向的通信。

7.4.2 从场景广播和监听事件

到目前为止,分数显示已经显示了一个计时器来测试文本显示功能。但我们要显示被击中敌人的数量,所以让我们修改 UIController 中的代码。首先,删除整个 Update()方法,因为那是测试代码。当敌人死亡时,它将发出一个事件,所以下面的列表使 UIController 监听该事件。

列表 7.8 向 UIController 添加事件监听器

...
private int score;

void OnEnable() {
   Messenger.AddListener(GameEvent.ENEMY_HIT, OnEnemyHit);     ❶
}
void OnDisable() {
   Messenger.RemoveListener(GameEvent.ENEMY_HIT, OnEnemyHit);  ❷
}

void Start() {
   score = 0;
   scoreLabel.text = score.ToString();                         ❸

   settingsPopup.Close();
}

private void OnEnemyHit() {
   score += 1;                                                 ❹
   scoreLabel.text = score.ToString();
}
...

❶ 声明哪个方法响应 ENEMY_HIT 事件。

❷ 当对象被停用时,移除监听器以避免错误。

❸ 将分数初始化为 0。

❹ 在事件响应中增加分数。

首先注意 OnEnable()和 OnDisable()方法。与 Start()和 Update()类似,每个 MonoBehaviour 对象在激活或停用时都会自动响应。在 OnEnable()/OnDisable()中添加和删除监听器。这个监听器是广播消息系统的一部分,当接收到该消息时调用 OnEnemyHit()。OnEnemyHit()增加分数并将该值放入分数显示中。

事件监听器在 UI 代码中设置,所以现在每当敌人被击中时,我们需要广播该消息。响应击中的代码在 RayShooter 中,所以按照以下方式发出消息。

列表 7.9 从 RayShooter 广播事件消息

...
if (target != null) {
   target.ReactToHit();
   Messenger.Broadcast(GameEvent.ENEMY_HIT);    ❶
} else {
...

❶ 将消息广播添加到击中响应

在添加该消息后玩游戏,并观察在射击敌人时分数显示的变化。你应该会看到每次击中时计数都会增加。这涵盖了从 3D 游戏向 2D 界面发送消息,但我们还想要一个相反方向的例子。

7.4.3 从 HUD 广播和监听事件

在上一节中,场景广播了一个事件,并被 HUD 接收。以类似的方式,UI 控件可以广播一个消息,玩家和敌人都可以监听。这样,设置弹出窗口就可以影响游戏设置。打开 WanderingAI 并添加以下代码。

列表 7.10 向 WanderingAI 添加事件监听器

...
public const float baseSpeed = 3.0f;          ❶
...
void OnEnable() {
   Messenger<float>.AddListener(GameEvent.SPEED_CHANGED, OnSpeedChanged);
}
void OnDisable() {
   Messenger<float>.RemoveListener(GameEvent.SPEED_CHANGED, OnSpeedChanged);
}
...
private void OnSpeedChanged(float value) {    ❷
   speed = baseSpeed * value;
}
...

❶ 被速度设置调整的基本速度

❷ 在监听器中声明的用于事件 SPEED_CHANGED 的方法

OnEnable()和 OnDisable()也会在这里分别添加和删除事件监听器,但这次方法有了一个值。这个值用于设置游荡 AI 的速度。

提示:上一节中的代码使用了通用事件,但此消息系统也可以在消息中传递一个值。在监听器中支持一个值就像添加一个类型定义一样简单;注意监听器命令中添加的

现在在 FPSInput 中做出相同的更改以影响玩家的速度。下一列表中的代码几乎与列表 7.10 中的相同,只是玩家的 baseSpeed 有不同的数值。

列表 7.11 添加到 FPSInput 的事件监听器

...
public const float baseSpeed = 6.0f;     ❶
...
void OnEnable() {
   Messenger<float>.AddListener(GameEvent.SPEED_CHANGED, OnSpeedChanged);
}
void OnDisable() {
   Messenger<float>.RemoveListener(GameEvent.SPEED_CHANGED, OnSpeedChanged);
}
...
private void OnSpeedChanged(float value) {
   speed = baseSpeed * value;
}
...

❶ 这个值是从列表 7.10 中改变的。

最后,根据滑块广播 SettingsPopup 中的速度值。

列表 7.12 SettingsPopup 的广播消息

public void OnSpeedValue(float speed) {
   Messenger<float>.Broadcast(GameEvent.SPEED_CHANGED, speed);     ❶
   ...

❶ 将滑块值作为事件发送。

现在当你调整滑块时,敌人和玩家的速度都会改变。点击播放并尝试一下!

练习:改变产生敌人的速度

目前,只有场景中已有的敌人速度值会被更新,而新产生的敌人不会以正确的速度设置创建。我将把它留给你作为练习,去思考如何设置新产生敌人的速度。这里有一个提示:给 SceneController 添加一个 SPEED_CHANGED 监听器,因为敌人就是从那里产生的。

你现在知道如何使用 Unity 提供的新 UI 工具构建图形界面。这项知识将在所有未来的项目中派上用场,即使我们在探索不同的游戏类型时也是如此。

概述

  • Unity 既有即时模式的 GUI 系统,也有基于 2D 精灵的新系统。

  • 使用 2D 精灵构建 GUI 需要场景中有一个画布对象。

  • UI 元素可以被锚定到可调整画布上的相对位置。

  • 将 Active 属性设置为打开或关闭 UI 元素。

  • 解耦的消息系统是广播界面和场景之间事件的好方法。

8 创建第三人称 3D 游戏:玩家移动和动画

本章涵盖

  • 在场景中添加实时阴影

  • 使摄像机围绕其目标旋转

  • 使用 lerp 算法平滑地改变旋转

  • 处理跳跃、边缘和斜坡的地面检测

  • 为逼真的角色应用和控制动画

在本章中,你将创建另一个 3D 游戏,但这次你将进入一个新的游戏类型。在第二章中,你为第一人称游戏构建了一个移动演示。现在你将编写另一个移动演示,但这次将涉及第三人称移动。最重要的区别是摄像机相对于玩家的位置:在第一人称视角中,玩家通过他们的角色视角看到,而在第三人称视角中,摄像机放置在角色外部。这种视角可能对你来说很熟悉,比如在冒险游戏中,如长寿的塞尔达传说系列或较新的无主之地系列。(如果你想看到第一人称和第三人称视角的比较,请跳转到图 8.3。)

本章的项目是我们将在本书中构建的更具视觉吸引力的原型之一。图 8.1 显示了场景的构建方式。将其与我们在第二章中创建的第一人称场景图(图 2.2)进行比较。

CH08_F01_Hocking3

图 8.1 第三人称移动演示路线图

你可以看到房间构建是相同的,脚本的使用也大致相同。但玩家的外观以及摄像机的放置在每个情况下都不同。再次强调,将这定义为第三人称视角的是摄像机位于玩家角色外部,并朝向该角色。你将使用一个看起来像人类角色的模型(而不是原始的胶囊),因为现在玩家实际上可以看到自己。

回想一下,在第四章中讨论的两种艺术资产类型是 3D 模型和动画。如前几章所述,术语3D 模型几乎等同于网格对象;3D 模型是由顶点和多边形定义的静态形状(即网格几何)。对于人类角色,这种网格几何被塑造成头部、手臂、腿部等等(见图 8.2)。

CH08_F02_Hocking3

图 8.2 本章中我们将使用的模型的线框视图

如往常一样,我们将关注路线图中的最后一步:在场景中编程对象。以下是我们行动计划的重述:

  1. 将角色模型导入场景。

  2. 实现摄像机控制以观察角色。

  3. 编写一个脚本,使玩家能够在地面上四处奔跑。

  4. 将跳跃能力添加到移动脚本中。

  5. 根据模型的活动播放动画。

要修改它,请从第二章复制项目,或者创建一个新的 Unity 项目(确保设置为 3D,而不是第五章中的 2D 项目)并将第二章项目中的场景文件复制过来。无论哪种方式,也要从本章下载中获取刮擦文件夹,以获取我们将使用的角色模型。

注意:您将在第二章的围墙区域内构建本章的项目。您将保留墙壁和灯光,但替换玩家和所有脚本。如果您需要示例文件,请从该章节下载。

假设您是从第二章的完整项目(动作演示,不是后来的项目)开始的,让我们删除本章不需要的所有内容。首先,在层次列表中从玩家对象断开摄像机的连接(将摄像机对象从玩家对象拖离)。现在删除玩家对象;如果您没有先断开摄像机的连接,那么它也会被删除,但您想要的只是删除玩家胶囊并留下摄像机。或者,如果您不小心删除了摄像机,可以通过选择 GameObject > Camera 创建一个新的摄像机对象。

也要删除所有脚本(这涉及到从摄像机中移除脚本组件并在项目视图中删除文件),只留下墙壁、地板和灯光。

8.1 调整第三视角的摄像机视图

在您编写代码使玩家移动之前,您需要将一个角色放入场景中并设置摄像机以观察该角色。您将导入一个无脸的人形模型作为玩家角色,然后将摄像机放在上方以角度向下观察玩家。图 8.3 比较了场景在第一视角下的样子和场景在第三视角下的样子(将在本章中添加一些大块,您将在本章中添加)。您已经准备好了场景,所以现在您将把一个角色模型放入场景中。

CH08_F03_Hocking3

图 8.3 首视角和第三视角的并排比较

8.1.1 导入一个角色进行观察

本章下载的刮擦文件夹包含模型和纹理。正如您从第四章回忆的那样,FBX 是模型,TGA 是纹理。将 FBX 文件导入到项目中:要么将文件拖动到项目视图中,要么在项目视图中右键单击并选择导入新资产。

然后在检查器中调整模型的导入设置。在本章的后面部分,您将调整导入的动画,但到目前为止,您只需要在模型和材质选项卡中进行几个调整。首先,转到模型选项卡并将缩放因子值更改为 10(以部分抵消单位转换值 0.01),以便模型的大小是正确的。

下方一点,您会找到法线选项(见图 8.4)。此设置控制光照和阴影在模型上的显示,使用一个称为“法线”的 3D 数学概念。

CH08_F04_Hocking3

图 8.4 角色模型的导入设置

定义 法线是伸出多边形的方向向量,告诉计算机多边形面向哪个方向。这个面向方向用于光照计算。

法线默认设置为导入,这将使用导入的网格几何体中定义的法线。但这个特定的模型没有正确定义的法线,并且会对光线产生奇怪的反应。相反,将设置更改为计算,这样 Unity 将为每个多边形的面向方向计算一个向量。一旦调整了这些设置,请点击检查器中的应用按钮。

接下来,将 TGA 文件导入到项目中(以便将该图像指定为玩家材质的纹理)。转到材质选项卡,点击提取材质按钮。提取到您觉得合适的任何位置;然后选择出现的材质,将纹理图像拖动到检查器中的 Albedo 纹理槽中。一旦应用了纹理,您不会在模型的颜色上看到明显的改变(此纹理图像主要是白色),但画入纹理中的阴影将改善模型的外观。

应用纹理后,将玩家模型从项目视图拖动到场景中。将角色定位在 0, 1.1, 0,这样它就会位于房间的中心并抬起站在地板上。我们在场景中有一个第三人称角色!

注意 导入的角色手臂直直地伸出两侧,而不是更自然的下垂姿势。这是因为还没有应用动画;这种手臂伸出的位置被称为T-pose,标准是动画角色在动画之前默认为 T-pose。

8.1.2 向场景添加阴影

在我们继续之前,我想简单解释一下角色投射的阴影。在现实世界中,我们理所当然地认为会有阴影,但在游戏的虚拟世界中并不保证有阴影。幸运的是,Unity 可以处理这个细节,并且默认场景中的默认灯光已经打开了阴影。

在您的场景中选择方向光,然后在检查器中查找阴影类型选项。该设置(图 8.5)对于默认灯光已经是软阴影,但请注意菜单还有一个无阴影选项。

CH08_F05_Hocking3

图 8.5 从方向光投射阴影前后的效果

这就是在这个项目中设置阴影所需做的全部工作,但您还应该了解关于游戏阴影的更多内容。在场景中计算阴影是计算机图形中特别耗时的一部分,因此游戏通常会采取各种捷径来伪造以实现所需的视觉效果。

从角色投射出的阴影被称为实时阴影,因为阴影是在游戏运行时计算的,并且随着移动对象移动。一个完美的真实照明设置将使所有对象都能实时投射和接收阴影,但为了使阴影计算足够快,实时阴影的外观可能比较原始,而且游戏甚至可能限制哪些灯光可以投射阴影。注意,在这个场景中只有方向光在投射阴影。

在游戏中处理阴影的另一种常见方法是使用一种称为光照贴图的技术。

定义:光照贴图是应用于级别几何形状的纹理,其中阴影的图像被烘焙到纹理图像中。

定义:将阴影绘制到模型纹理上称为烘焙阴影

由于这些图像是在游戏运行之前生成的(而不是在游戏运行时),它们可以非常精致和逼真。缺点是,由于阴影是在游戏运行之前生成的,它们不会移动。因此,光照贴图非常适合用于静态级别的几何形状,但不适合像角色这样的动态对象。光照贴图是自动生成的,而不是手工绘制。计算机计算场景中的灯光如何照亮级别,同时在角落中逐渐积累微妙的黑暗。

是否使用实时阴影或光照贴图并不是一个非此即彼的选择。你可以设置灯光的剔除遮罩属性,以便仅对某些对象使用实时阴影,这样你就可以为场景中的其他对象使用更高品质的光照贴图。同样,尽管你几乎总是希望主要角色投射阴影,但有时你不想让角色接收阴影;所有网格对象(无论是网格渲染器还是着色网格渲染器组件)都有投射和接收阴影的设置。图 8.6 显示了当你选择地板时这些设置的外观。

CH08_F06_Hocking3

图 8.6 检查器中的投射阴影和接收阴影设置

定义:剔除是一个通用的术语,用于移除不需要的东西。这个词在计算机图形学中的许多上下文中都会出现,但在这个情况下,剔除遮罩是你想要从阴影投射中移除的对象集合。

好的,现在你已经了解了如何将阴影应用到你的场景中的基础知识。照明和着色一个级别本身就是一个很大的话题(关于级别编辑的书籍通常会花费多个章节来讨论光照贴图),但在这里我们将限制自己只在一个灯光上开启实时阴影。有了这个,让我们将注意力转向相机。

8.1.3 围绕玩家角色旋转相机

在第一人称演示中,相机在 Hierarchy 视图中链接到玩家对象,以便它们一起旋转。然而,在第三人称移动中,玩家角色将独立于相机面向不同的方向。因此,这次你不想在 Hierarchy 视图中将相机拖到玩家角色上。相反,相机的代码将随着角色的移动而移动其位置,但将独立于角色旋转。

首先,将相机放置到你想要的位置,相对于玩家;我选择了位置 0, 3.5, -3.75 以将相机放置在角色上方和后方(如果需要,重置旋转到 0, 0, 0)。然后创建一个名为 OrbitCamera 的脚本,并编写列表 8.1 中的代码。将脚本组件附加到相机上,然后将玩家角色拖动到脚本的“目标”槽中。现在你可以播放场景以查看相机代码的实际效果。

列表 8.1 旋转并观察目标的相机脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class OrbitCamera : MonoBehaviour {
    [SerializeField] Transform target;                              ❶

    public float rotSpeed = 1.5f;

    private float rotY;
    private Vector3 offset;

    void Start() {
        rotY = transform.eulerAngles.y;
        offset = target.position - transform.position;              ❷
    }

    void LateUpdate() {
        float horInput = Input.GetAxis("Horizontal");
        if (!Mathf.Approximately(horInput, 0)) {                    ❸
            rotY += horInput * rotSpeed;
        } else {
            rotY += Input.GetAxis("Mouse X") * rotSpeed * 3;        ❹
        }

        Quaternion rotation = Quaternion.Euler(0, rotY, 0);
        transform.position = target.position - (rotation * offset); ❺
        transform.LookAt(target);                                   ❻
    }
}

❶ 序列化引用要围绕旋转的对象

❷ 存储相机和目标之间的起始位置偏移。

❸ 可以使用箭头键缓慢旋转相机 . . .

❹ . . . 或者快速旋转鼠标。

❺ 维持起始偏移,根据相机的旋转进行偏移。

❻ 无论相机相对于目标的位置如何,始终面向目标。

在阅读列表时,注意目标的序列化变量。代码需要知道围绕哪个对象旋转相机,因此这个变量被序列化以在 Unity 编辑器中显示,并使玩家角色与其链接。接下来的几个变量是旋转值,它们的使用方式与第二章中的相机控制代码相同。

声明了一个偏移值;在 Start() 中设置偏移以存储相机和目标之间的位置差异。这样,在脚本运行期间可以保持相机的相对位置。换句话说,无论相机如何旋转,它都会保持在角色初始距离的位置。代码的其余部分位于 LateUpdate() 函数内。

提示:记住,LateUpdate() 是由 Mono-Behaviour 提供的另一种方法,它与 Update() 类似;它是在每一帧运行的方法。正如其名称所暗示的,区别在于 LateUpdate() 在 Update() 在所有对象上运行之后对所有对象进行调用。这样,你可以确保相机在目标移动之后更新。

首先,代码根据输入控制增加旋转值。此代码查看两个输入控制——水平箭头键和水平鼠标移动——因此使用条件语句在它们之间切换。代码检查是否按下了水平箭头键;如果是,则使用该输入,如果不是,则检查鼠标。通过分别检查两个输入,代码可以为每种输入类型以不同的速度旋转。

接下来,代码根据目标位置和旋转值定位相机。transform.position 这一行可能是这段代码中最大的“啊哈!”时刻,因为它提供了你之前没有见过的关键数学。将位置向量乘以四元数会导致根据该旋转进行偏移的位置(注意旋转角度是通过使用 Quaternion.Euler 转换为四元数的)。然后,这个旋转的位置向量被添加为从角色位置到计算相机位置的偏移。图 8.7 说明了计算的步骤,并详细解释了这一相当概念密集的代码行。

CH08_F07_Hocking3

图 8.7 计算相机位置的步骤

注意:你们中数学更敏锐的人可能会想,“嗯,第二章中提到的在坐标系之间转换的事情……我难道不能在这里也做吗?”是的,你可以通过使用旋转坐标系来转换偏移位置以获得旋转的偏移,但这需要首先设置旋转坐标系,而且不经过这一步会更直接。

最后,代码使用 LookAt()方法将相机指向目标;这个函数将一个对象(不仅仅是相机)指向另一个对象。之前计算出的旋转值被用来在目标周围定位相机,但在那一步中,相机只是定位而没有旋转。因此,如果没有最后的 LookAt()行,相机位置将围绕角色旋转,但并不一定指向它。试着注释掉那一行,看看会发生什么。

Cinemachine

我们刚刚编写了一个用于控制相机的自定义脚本。然而,Unity 还提供了 Cinemachine,一套用于高级相机控制的工具。对于本章中简单的相机行为来说,这个包可能有些过度,但对于许多项目来说,Cinemachine 非常值得尝试。

打开包管理器窗口(窗口 > 包管理器),在 Unity 注册表中搜索 Cinemachine。更多信息请参阅mng.bz/PXvP

相机有自己的围绕玩家角色旋转的脚本;接下来是移动角色的代码。

8.2 编程相机相对移动控制

现在角色模型已经导入 Unity,并且你已经编写了控制相机视图的代码,现在是时候编写在场景中移动角色的控制代码了。让我们编写相机相对控制,当按下箭头键时,将角色移动到各个方向,并旋转角色以面对这些不同的方向。

“相机相对”是什么意思?

相机相对这一概念有点不明显,但理解它至关重要。这与前几章中提到的局部与全局的区别类似:“左”指向不同的方向,当你指的是“局部对象的左侧”或“整个世界的左侧”时。以类似的方式,当你“将角色向左移动”时,你是指向角色的左侧,还是屏幕的左侧?

在第一人称游戏中,相机位于角色内部并与角色一起移动,因此不存在角色左侧与相机左侧的区别。然而,第三人称视角将相机放置在角色外部,因此相机的左侧可能与角色的左侧指向不同的方向。例如,如果相机面向角色的前方,方向实际上是相反的。因此,你必须决定在你的特定游戏和控制设置中你想发生什么。

虽然游戏有时会采取相反的方式,但大多数第三人称游戏使它们的控制与相机相关。当玩家按下左键时,角色移动到屏幕的左侧,而不是角色的左侧。随着时间的推移和通过尝试不同的控制方案进行实验,游戏设计师已经发现,当“左”意味着“屏幕的左侧”(这并非巧合,也是玩家的左侧)时,玩家发现控制更直观且更容易理解。

实现相机相对控制涉及两个主要步骤:首先将玩家角色旋转到面向控制方向,然后移动角色。接下来,让我们编写这两个步骤的代码。

8.2.1 将角色旋转到面向移动方向

首先,你需要编写代码使角色面向箭头键的方向。创建一个名为 RelativeMovement 的 C#脚本,使用列表 8.2 中的代码。将此脚本拖放到玩家角色上,然后将相机链接到脚本的 target 属性(就像你将角色链接到相机脚本的 target 一样)。现在,当你按下控制键时,角色将面向不同的方向,面向相对于相机的方向;当你没有按下任何箭头键时(即使用鼠标旋转时),角色将保持静止。

列表 8.2 相对于相机旋转角色

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RelativeMovement : MonoBehaviour {
    [SerializeField] Transform target;                               ❶

    void Update() {
        Vector3 movement = Vector3.zero;                             ❷

        float horInput = Input.GetAxis("Horizontal");
        float vertInput = Input.GetAxis("Vertical");
        if (horInput != 0 || vertInput != 0) {                       ❸

            Vector3 right = target.right;
            Vector3 forward = Vector3.Cross(right, Vector3.up);      ❹
            movement = (right * horInput) + (forward * vertInput);   ❺

            transform.rotation = Quaternion.LookRotation(movement);  ❻
        }
    }
}

❶ 此脚本需要一个相对于该对象移动的引用。

❷ 从向量(0,0,0)开始,并逐步添加移动分量。

❶ 仅在按下箭头键时处理移动。

❸ 通过使用目标右方向的叉积来计算玩家的前进方向。

❹ 将每个方向上的输入相加,以获得组合移动向量。

❺ LookRotation()计算一个指向该方向的四元数。

这个列表与列表 8.1 以相同的方式开始,有一个用于目标的序列化变量。就像之前的脚本需要一个指向它将围绕其旋转的对象的引用一样,这个脚本需要一个指向它将相对于其移动的对象的引用。然后我们到达 Update()函数。函数的第一行声明了一个值为 0、0、0 的 Vector3 变量。剩余的代码将在玩家按下任何按钮时替换此向量,但如果没有输入,有一个默认值是很重要的。

接下来,检查输入控制,就像你在之前的脚本中做的那样。这里设置了在场景中水平移动的 X 和 Z 值。记住,Input.GetAxis()在没有按键被按下时返回 0,当按键被按下时,它在 1 和-1 之间变化;将此值放入移动向量中,将移动设置为该轴的正方向或负方向(x 轴是左右,z 轴是前后)。

接下来的几行代码计算相对于摄像机的移动向量。具体来说,我们需要确定移动的侧向和前方方向。侧向方向很容易;目标变换有一个名为 right 的属性,这将指向摄像机的右方,因为摄像机被设置为目标对象。前方方向更复杂,因为摄像机向前和向下倾斜进入地面,但我们希望角色在垂直于地面的方向上移动。这个前方方向可以使用叉积来确定。

定义:叉积是一种可以对两个向量进行的数学运算。简而言之,两个向量的叉积是一个指向两个输入向量垂直方向的新向量。考虑一下 3D 坐标轴:z 轴垂直于 x 轴和 y 轴。不要将叉积与点积混淆;点积(在章节后面解释)是另一种但也很常见的向量数学运算。

在这种情况下,两个输入向量是右方向和上方向。记住,我们已经确定了摄像机的右方向。同时,Vector3 有几个用于常见方向的快捷属性,包括从地面直指上方的方向。这个向量垂直于这两个点,并且与地面垂直对齐。

将每个方向上的输入相加以获得组合移动向量。最后一行代码通过使用 Quaternion.LookRotation()将 Vector3 转换为四元数,并将该值赋值,将移动方向应用于角色。现在尝试运行游戏,看看会发生什么!

使用 lerp(线性插值)平滑旋转

目前,角色的旋转会瞬间切换到不同方向,但如果角色能够平滑旋转会更好。你可以使用一种称为lerp的数学运算来实现这一点。首先将此变量添加到脚本中:

public float rotSpeed = 15.0f;

然后将列表 8.2 末尾现有的 transform.rotation 行替换为以下代码:

      ...
      Quaternion direction = Quaternion.LookRotation(movement);
      transform.rotation = Quaternion.Lerp(transform.rotation,
          direction, rotSpeed * Time.deltaTime);
    }
  }
}

现在,不再直接将 LookRotation()的值用于旋转,而是间接地将其作为旋转的目标方向。Quaternion.Lerp()方法在当前旋转和目标旋转之间平滑地变化。

从一个值平滑地变化到另一个值的术语是插值;您可以在任何类型的两个值之间进行插值,而不仅仅是旋转值。Lerp线性插值的准缩写,Unity 还提供了用于向量和 float 值的 lerp 方法(用于插值位置、颜色或任何其他内容)。四元数也有一个与之密切相关的插值替代方法,称为slerp(用于球面线性插值)。对于较慢的转向,slerp 旋转可能看起来比 lerp 更好。

顺便提一下,这段代码以某种非传统的方式使用了 Lerp()函数。通常,第三个值会随时间变化,但在这里我们保持第三个值不变,而改变第一个值。在传统用法中,起始点和终点是固定的,但在这里我们保持将起始点逐渐靠近终点,从而实现对该终点的平滑插值。这种非传统用法在 Unity Answers 网站上有所解释(answers.unity.com/answers/730798/view.html)。

目前,角色在原地旋转而不移动;在下一节中,您将添加代码以使角色在场景中移动。

注意:由于侧向移动使用与环绕相机相同的键盘控制,当移动方向指向侧面时,角色会缓慢旋转。在这个项目中,这种控制的双重使用是期望的行为。

8.2.2 向该方向前进

如您从第二章回忆起来,为了在场景中移动玩家,您需要向玩家对象添加一个角色控制器组件。选择玩家,然后选择组件 > 物理 > 角色控制器。在检查器中,您应该略微减小控制器的半径到 0.4,但除此之外,默认设置对这个角色模型来说都是合适的。以下是 RelativeMovement 脚本中您需要添加的内容。

列表 8.3 添加代码以更改玩家的位置

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterController))]                   ❶
public class RelativeMovement : MonoBehaviour {
...
public float moveSpeed = 6.0f;

private CharacterController charController;

void Start() {
    charController = GetComponent<CharacterController>();         ❷
}

    void Update() {
        ...
          movement = (right * horInput) + (forward * vertInput);
          movement *= moveSpeed;                                  ❸
          movement = Vector3.ClampMagnitude(movement, moveSpeed); ❹
          ...
        }

        movement *= Time.deltaTime;                              ❺
        charController.Move(movement);
    }
}

❶ 周围的行是放置 RequireComponent()方法的上下文。

❷ 在前面的章节中看到过的模式,用于访问其他组件。

❸ 面向方向的大小为 1,所以需要与期望的速度值相乘。

❹ 将对角线移动限制在与轴上移动相同的速度。

❺ 总是乘以 deltaTime 来使移动不受帧率的影响。

如果现在您玩游戏,您将看到角色(处于 T 形姿势)在场景中移动。几乎整个列表都是您已经见过的代码,所以我会简要地回顾一下。

首先,代码顶部有一个 RequireComponent 属性。正如第二章中解释的那样,RequireComponent 将强制 Unity 确保 GameObject 具有命令中传入的类型组件。这一行是可选的;你不必要求它,但没有这个组件,脚本将会有错误。

接下来,声明一个移动值,然后获取这个脚本的字符控制器引用。如你从前面的章节中回忆的那样,GetComponent() 返回附加到给定对象的其它组件,如果搜索的对象没有明确定义,则假定是 this.gameObject.GetComponent()(与这个脚本相同的对象)。

移动值仍然基于输入控制分配,但现在你也考虑了移动速度。将所有移动轴乘以移动速度,然后使用 Vector3.ClampMagnitude() 限制向量的幅度为移动速度。限制是必要的,因为否则,对角线移动的幅度将大于沿轴直接移动的幅度(想象一下直角三角形的边和斜边)。

最后,在最后,你将移动值乘以 deltaTime 以获得与帧率无关的移动(回想一下,与帧率无关意味着角色在不同帧率的计算机上以相同的速度移动)。将移动值传递给 CharacterController.Move() 以进行移动。

这处理了所有水平移动。接下来,让我们处理垂直移动。

8.3 实现跳跃动作

在上一节中,你编写了使角色在地面周围跑动的代码。在章节介绍中,我也提到了使角色跳跃,所以现在让我们来做这个。大多数第三人称游戏都有跳跃的控制。即使它们没有,它们几乎总是有角色从边缘掉落时的垂直移动。我们的代码将处理跳跃和坠落。具体来说,此代码将始终有重力将玩家向下拉,但在玩家跳跃时偶尔会施加向上的冲击。

在编写此代码之前,让我们在场景中添加一些凸起的平台。游戏目前没有任何可以跳跃或坠落的地方!创建更多几个立方体对象,然后修改它们的位子和缩放,为玩家提供可以跳跃的平台。在示例项目中,我添加了两个立方体,并使用了以下设置:位置 5, 0.75, 5 和缩放 4, 1.5, 4;位置 1, 1.5, 5.5,和缩放 4, 3, 4。图 8.8 显示了凸起的平台。

CH08_F08_Hocking3

图 8.8 在稀疏的场景中添加的几个凸起的平台

8.3.1 应用垂直速度和加速度

如在列出 8.2 中的 RelativeMovement 脚本首次编写时所述,移动值是在单独的步骤中计算的,并逐步添加到移动向量中。此列表将垂直移动添加到现有的向量中。

列表 8.4 向 RelativeMovement 脚本添加垂直移动

...
public float jumpSpeed = 15.0f;
public float gravity = -9.8f;
public float terminalVelocity = -10.0f;
public float minFall = -1.5f;

private float vertSpeed;
...
void Start() {
    vertSpeed = minFall;                             ❶
    ...
}

    void Update() {
        ...
        if (charController.isGrounded) {             ❷
            if (Input.GetButtonDown("Jump")) {       ❸
                vertSpeed = jumpSpeed;
            } else {
                vertSpeed = minFall;
            }
        } else {                                     ❹
            vertSpeed += gravity * 5 * Time.deltaTime;
            if (vertSpeed < terminalVelocity) {
                vertSpeed = terminalVelocity;
            }
        }
        movement.y = vertSpeed;

        movement *= Time.deltaTime;                  ❺
        charController.Move(movement);
    }
}

❶ 在现有函数的开始处将垂直速度初始化为最小下落速度。

❷ CharacterController 具有 isGrounded 属性,用于检查控制器是否在地面上。

❸ 在地面上时响应跳跃按钮。

❹ 如果不在地面上,则应用重力,直到达到终端速度。

❺ 这段代码是现有的代码,仅用于参考新代码放置的位置。

如同往常,你首先在脚本顶部添加几个新变量以存储各种移动值,并正确初始化这些值。然后,跳到水平移动的大 if 语句之后,在那里添加另一个大 if 语句以处理垂直移动。具体来说,代码将检查角色是否在地面上,因为在这种情况下垂直速度的调整会有所不同。CharacterController 包括 isGrounded 属性,用于检查角色是否在地面上;如果角色控制器在上一帧与任何物体发生碰撞,则此值将为 true。

如果角色在地面上,垂直速度值(私有变量 vertSpeed)应该重置为 0。角色在地面上时不会下落,因此其垂直速度为 0;如果角色随后从边缘跳下,你会得到一个自然、流畅的动作,因为下落速度将从 0 开始加速。

注意:垂直速度并不是完全为 0;你将值设置为 minFall,即轻微的下移,这样角色在水平移动时始终会向下压地面。在起伏不平的地形上上下移动时需要一些向下的力。

如果按下跳跃按钮,则此地面速度值会有例外。在这种情况下,垂直速度应设置为较高的数值。if 语句检查 GetButtonDown(),这是一个新的输入函数,其工作方式与 GetAxis()类似,返回指定输入控制的状态。并且与水平和垂直输入轴类似,分配给跳跃的确切键可以通过转到“编辑”>“项目设置”下的输入管理器设置来定义(默认键分配是空格键,即空格键)。

回到更大的 if 条件,如果角色不在地面上,那么垂直速度应该由重力不断减少。请注意,此代码不是简单地设置速度值,而是递减它;这样,它不是恒定的速度,而是一种向下加速度,从而产生逼真的下落运动。跳跃将呈现自然弧线,因为角色的向上速度逐渐减少到 0,然后开始下落。

最后,代码确保下落速度不超过终端速度。请注意,运算符是小于而不是大于,因为向下是负速度值。然后,在大 if 语句之后,将计算出的垂直速度分配给移动向量的 y 轴。

而这就是实现真实垂直运动所需的所有内容!当角色不在地面上时,通过应用恒定的向下加速度,并在角色在地面上时适当地调整速度,代码就能创建出良好的下落行为。但这所有的一切都取决于正确检测地面,并且仍然存在一个微妙的错误需要修复。

8.3.2 修改地面检测以处理边缘和斜坡

如前节所述,CharacterController 的 isGrounded 属性指示角色控制器的底部在上一个帧中是否与任何物体发生了碰撞。尽管这种方法在大多数情况下都能检测到地面,但你可能会注意到,当角色离开边缘时,角色似乎在空中漂浮。

这是因为角色的碰撞区域是一个周围的胶囊(当你选择角色对象时可以看到它),当玩家离开平台的边缘时,这个胶囊的底部仍然会接触地面。图 8.9 展示了这个问题。这根本不行!

CH08_F09_Hocking3

图 8.9 展示了角色控制器胶囊接触平台边缘的示意图

同样,如果角色站在斜坡上,当前的地面检测将导致问题行为。现在尝试一下,通过在升高平台对面创建一个斜坡块来测试。创建一个新的立方体对象,并将其变换值设置为位置-1.5, 1.5, 5,旋转 0, 0, -25,缩放 1, 4, 4。

如果你从地面跳到斜坡上,你会发现你可以从斜坡中间跳起,从而上升到顶部。这是因为斜坡以斜角接触胶囊的底部,而代码目前将任何底部的碰撞都视为坚实的立足点。再次强调,这不行;角色应该滑回底部,而不是有一个坚实的立足点可以跳起。

注意:只在陡峭的斜坡上希望角色滑回底部。在浅斜坡上,例如不平整的地面上,你希望玩家不受影响地跑动。如果你想测试,可以通过创建一个立方体并将其设置为位置 5.25, 0.25, 0.25,旋转 0, 90, 75,缩放 1, 6, 3 来制作一个浅坡。

所有这些问题都有相同的根本原因:检查角色底部的碰撞并不是确定角色是否在地面的好方法。相反,让我们使用射线投射来检测地面。在第三章中,AI 使用射线投射来检测其前方的障碍物;让我们使用相同的方法来检测角色下方的表面。从玩家的位置向下发射一条射线。如果它刚好在角色脚下检测到碰撞,则玩家是站在地面上的。

这引入了一种新的情况来处理:当射线投射没有检测到角色下方的地面,但角色控制器正在与地面发生碰撞时。如图 8.9 所示,当角色从边缘走开时,胶囊仍然与平台发生碰撞。图 8.10 向图中添加了射线投射,以显示现在会发生什么:射线没有击中平台,但胶囊确实接触到了边缘。代码需要处理这种特殊情况。

CH08_F10_Hocking3

图 8.10 从边缘迈出时向下射线投射的示意图

在这种情况下,代码应该使角色从边缘滑落。角色仍然会下落(因为它没有站在地面上),但它也会从碰撞点推开(因为它需要将胶囊从它撞击的平台移开)。因此,代码将检测与角色控制器的碰撞,并通过轻微推开对这些碰撞做出响应。此列表调整了垂直移动,包括我们刚刚讨论的所有内容。

列表 8.5 使用射线投射检测地面

...
private ControllerColliderHit contact;                                     ❶
...
        bool hitGround = false;
        RaycastHit hit;
        if (vertSpeed < 0 &&                                               ❷
            Physics.Raycast(transform.position, Vector3.down, out hit)) {
            float check =                                                  ❸
                (charController.height + charController.radius) / 1.9f;
            hitGround = hit.distance <= check;
        }

        if (hitGround) {                                                   ❹
            if (Input.GetButtonDown("Jump")) {
                vertSpeed = jumpSpeed;
            } else {
                vertSpeed = minFall;
            }
        } else {
            vertSpeed += gravity * 5 * Time.deltaTime;
            if (vertSpeed < terminalVelocity) {
                vertSpeed = terminalVelocity;
            }

            if (charController.isGrounded) {                               ❺
                if (Vector3.Dot(movement, contact.normal) < 0) {           ❻
                    movement = contact.normal * moveSpeed;
                } else {
                    movement += contact.normal * moveSpeed;
                }
            }
        }
        movement.y = vertSpeed;

        movement *= Time.deltaTime;
        charController.Move(movement);
    }

    void OnControllerColliderHit(ControllerColliderHit hit) {              ❼
        contact = hit;
    }
}

❶ 需要在函数之间存储碰撞数据

❷ 检查玩家是否正在下落。

❸ 检查的距离(略微超出胶囊底部)

❹ 不要使用 isGrounded,检查射线投射的结果。

❸ 射线投射没有检测到地面,但胶囊接触到了地面。

❹ 根据角色是否面向接触点,做出轻微不同的响应。

❷ 在检测到碰撞时将碰撞数据存储在回调中。

此列表包含与上一个列表中大部分相同的代码;新代码穿插在现有的移动脚本中,此列表需要现有代码作为上下文。第一行在 RelativeMovement 脚本顶部添加了一个新变量。此变量用于存储函数之间碰撞的数据。

接下来的几行执行射线投射。此代码也位于水平移动下方,但在垂直移动的 if 语句之前。实际的 Physics.Raycast()调用应该从之前的章节中熟悉,但这次的具体参数不同。尽管投射射线的位置相同(角色的位置),但这次的方向将是向下而不是向前。然后,你检查射线击中某物时的距离;如果击中的距离是角色脚的距离,那么角色就站在地面上,因此将 hitGround 设置为 true。

警告:计算检查距离的方式并不明显,所以让我们详细说明一下。首先,取角色控制器的高度(即没有圆滑端的高度)然后加上圆滑端。将这个值除以二,因为光线是从角色的中间投射出去的(也就是说,已经下落了一半)以得到角色底部的距离。但你需要检查角色底部稍远的地方,以考虑到光线投射中的微小误差,所以用 1.9 而不是 2 来除,以得到稍微过远的距离。

在完成这个光线投射后,在垂直移动的 if 语句中使用 hitGround 而不是 isGrounded。大部分的垂直移动代码将保持不变,但需要添加代码来处理当角色控制器与地面碰撞,即使玩家不在地面上(即玩家从平台边缘走开)的情况。我们已经添加了一个新的 isGrounded 条件,但请注意,它嵌套在 hitGround 条件中,所以只有当 hitGround 没有检测到地面时才会检查 isGrounded。

碰撞数据包括一个 normal 属性(再次强调,法线向量表示某物面向的方向)它告诉我们从碰撞点移动的方向。但有一个棘手的问题是,你希望根据玩家已经移动的方向,以不同的方式处理从接触点推开。当之前的水平移动是朝向平台时,你想要替换那个移动,以便角色不会继续朝错误的方向移动;但当面对边缘时,你想要将之前的水平移动添加到之前,以保持远离边缘的前进动量。可以使用点积确定移动向量相对于碰撞点的方向。

定义:点积是可以在两个向量上进行的另一种数学运算。两个向量的点积范围在N-N之间(N由输入向量的模长决定)。正N表示它们指向完全相同的方向,而-N表示它们指向完全相反的方向。不要混淆点积和叉积;叉积是另一种不同的但也很常见的向量数学运算。

Vector3 包含一个 Dot()函数,用于计算两个给定向量的点积。如果你计算移动向量与碰撞法线的点积,当两个方向相互远离时,将返回一个负数,而当移动和碰撞方向相同时,将返回一个正数。

列表 8.5 的末尾添加了一个新的方法到脚本中。在之前的代码中,你正在检查碰撞法线,但这个信息是从哪里来的?实际上,与角色控制器的碰撞是通过 MonoBehaviour 提供的名为 OnControllerColliderHit()的回调函数报告的;为了在脚本的其他地方响应碰撞数据,这些数据必须存储在外部变量中。这就是这个方法在这里所做的一切:将碰撞数据存储在接触点中,以便在 Update()方法中使用这些数据。

现在平台边缘和斜坡上的错误已经得到纠正。你可以通过跨过边缘和跳上陡峭的斜坡来测试它。这个动作演示几乎完成了。角色在场景中移动正确,所以只剩下最后一件事:将角色从 T 形姿势中动画化出来。

8.4 在玩家角色上设置动画

除了由网格几何形状定义的更复杂的形状之外,人类角色还需要动画。在第四章中,你了解到动画是一组定义相关 3D 对象运动的信息包。我给出的具体例子是一个角色四处走动,而你现在要做的事情正是如此!

角色将在场景中奔跑,所以你需要分配一些使手臂和腿部来回摆动的动画。图 8.11 显示了当角色在场景中移动时播放动画的游戏外观。

CH08_F11_Hocking3

图 8.11 播放奔跑动画的角色在场景中移动

理解 3D 动画的一个好类比是木偶戏:3D 模型是木偶,动画师是木偶师,动画是木偶动作的记录。动画可以通过几种方法创建;现代游戏中大多数角色动画(当然,这一章中角色的所有动画)都使用一种称为骨骼动画的技术。

定义骨骼动画中,在模型内部设置了一系列骨骼,然后在动画过程中移动这些骨骼。当骨骼移动时,与该骨骼相连的模型表面也会随之移动。

正如其名所示,当模拟角色内部的骨骼时(图 8.12 说明了这一点),骨骼动画最直观,但骨骼是一个有用的抽象概念,任何时候你想让模型弯曲和伸展,同时仍然保持其动作的明确结构(例如,波浪般摆动的触手)时,都可以使用它。尽管骨骼移动是刚性的,但骨骼周围的模型表面可以弯曲和伸展。

CH08_F12_Hocking3

图 8.12 人类角色的骨骼动画

实现图 8.11 所示的结果涉及几个步骤:首先,在导入的文件中定义动画剪辑,然后设置控制器来播放这些动画剪辑,最后将动画控制器集成到你的代码中。角色模型上的动画将根据你将要编写的运动脚本进行回放。

当然,在执行任何这些步骤之前,你需要做的第一件事是开启动画系统。在项目视图中选择玩家模型,然后在检查器中查看其导入设置。选择动画选项卡,并确保已勾选导入动画。然后转到绑定选项卡,将动画类型从通用切换到人类(这是一个人类角色,自然)。请注意,最后一个菜单项还有一个遗留设置;通用和人类都是 Mecanim 框架内的设置。

解释 Unity 的 Mecanim 动画系统

Unity 拥有用于管理模型动画的复杂系统,称为 Mecanim。你在第六章中介绍了这个动画系统,但提到我们将在稍后进行更详细的介绍,因此本章的一些内容将是对之前解释的回顾,现在将重点关注 3D 动画而不是 2D 动画。

名称 Mecanim 指的是添加到 Unity 中的较新、更先进的动画系统,作为对旧动画系统的替代。旧系统仍然存在,被称为遗留动画,但在 Unity 的未来版本中可能会逐步淘汰,届时 Mecanim 将成为唯一的动画系统。

虽然你将要使用的所有动画都包含在我们角色模型的同一 FBX 文件中,但 Mecanim 方法的一个主要优点是你可以将来自其他 FBX 文件的动画应用到角色上。例如,所有的人类敌人可以共享一组单一的动画。这具有多个优点,包括保持所有数据组织有序(模型可以放在一个文件夹中,而动画可以放在另一个文件夹中)以及节省为每个单独的角色制作动画的时间。

点击检查器底部的应用按钮,将这些设置锁定到导入的模型上,然后继续定义动画剪辑。

警告:你可能会在控制台中看到一个警告(不是错误),内容为“转换警告:spine3 在人类变换之间”。这个特定的警告并不是一个担忧的原因;它表明导入的模型中的骨骼超出了 Mecanim 预期的骨骼范围。

8.4.1 在导入的模型中定义动画剪辑

为我们的角色设置动画的第一步是定义将要播放的各种动画剪辑。如果你考虑一个逼真的角色,不同的动作会在不同的时间发生:有时玩家在跑动,有时玩家在平台上跳跃,有时角色只是站立着,手臂下垂。每个动作都是一个独立的剪辑,可以单独播放。

通常,导入的动画是一个单一的长时间线,可以被切割成更短的独立动画。要分割动画片段,首先在检查器中选择动画选项卡。你会看到一个片段面板,如图 8.13 所示;这个面板列出了所有定义的动画片段,最初是一个导入的片段。你会注意到列表底部的+和-按钮;你使用这些按钮在列表中添加和删除片段。最终,你需要为这个角色添加四个片段,所以在你工作时根据需要添加和删除片段。

CH08_F13_Hocking3

图 8.13 动画设置中的片段列表

当你选择一个片段时,关于该片段的信息(如图 8.14 所示)将出现在列表下方区域。该信息区域顶部显示该片段的名称,你可以输入一个新的名称。将第一个片段命名为空闲。为这个动画片段定义开始和结束帧;这允许你从较长的导入动画中切出一段。空闲动画从总时间线的第 3 帧到第 141 帧,因此输入这些数字作为开始和结束。接下来是循环设置。

定义 循环 指的是反复播放的录制。一个循环动画片段是指当播放达到结束时,会从开始处再次播放。

CH08_F14_Hocking3

图 8.14 选择动画片段的信息

空闲动画是循环的,因此选择循环时间和循环姿态。顺便说一句,绿色指示点告诉你片段开始处的姿态与结束处的姿态是否匹配以实现正确的循环;当姿态有些不匹配时,指示器变为黄色,当开始和结束姿态完全不同时,指示器变为红色。

在循环设置下方是一系列与根变换相关的设置。单词在骨骼动画中的含义与在 Unity 中连接的层次结构中的含义相同:根对象是所有其他对象连接的基础对象。因此,动画根可以被认为是角色的基础,其他所有东西都是相对于这个基础移动的。

可以使用一些设置来设置这个基础,并且你可能想在处理自己的动画时在这里进行实验。然而,就我们的目的而言,三个基于菜单应该设置为身体方向、质心、质心,按此顺序。

现在点击应用,你已经为你的角色添加了一个空闲动画片段。为另外两个片段做同样的操作:行走从第 144 帧开始到第 169 帧结束,跑步从第 171 帧开始到第 190 帧结束。所有其他设置都应该与空闲相同,因为它们也是动画循环。

第四个动画剪辑是跳跃,这个剪辑的设置略有不同。首先,这不是一个循环,而是一个静态姿势,所以不要选择循环时间。将开始和结束设置为 190.5 和 191;这是一个单帧姿势,但 Unity 要求开始和结束必须不同。由于这些棘手的数字,下面的动画预览可能看起来不太对,但在游戏中这个姿势看起来会很好。点击应用以确认新的动画剪辑,然后继续下一步:创建动画控制器。

8.4.2 为这些动画创建动画控制器

下一步是为这个角色创建动画控制器。这一步允许我们设置动画状态并创建这些状态之间的转换。在不同的动画状态下播放不同的动画剪辑,然后我们的脚本将导致控制器在动画状态之间切换。

这可能看起来像是一个奇怪的间接步骤——在我们的代码和实际播放动画之间放置控制器的抽象。你可能熟悉可以直接从你的代码中播放动画的系统;确实,旧的 Legacy 动画系统正是以这种方式工作的,使用如 Play("idle")这样的调用。但这种间接性使我们能够在模型之间共享动画,而不仅仅是能够播放这个模型内部的动画。在本章中,我们不会利用这个功能,但请记住,当你在较大的项目中工作时,这可能很有帮助。你可以从多个来源获取你的动画,包括多个动画师,或者你可以从在线商店(如 Unity 资源商店)购买单个动画。

首先创建一个新的动画控制器资产(资源 > 创建 > 动画控制器——不是动画,这是一种不同类型的资源)。在项目视图中,你会看到一个带有有趣线条网络的图标(见图 8.15);将此资源重命名为 player。选择场景中的角色,你会注意到这个对象有一个名为 Animator 的组件;任何可以动画化的模型都有这个组件,除了变换组件和您添加的其他组件。Animator 组件有一个控制器槽,用于链接特定的动画控制器,因此拖放你的新控制器资产(并确保取消选中应用根运动)。

CH08_F15_Hocking3

图 8.15 动画控制器和动画组件

动画控制器是一个由连接的节点组成的树(因此该资产上的图标),您可以通过打开动画视图来查看和操作它。这是一个视图,就像场景或项目视图(如图 8.16 所示)一样,但这个视图默认情况下是关闭的。选择“窗口”>“动画”,然后从菜单中选择“动画控制器”(注意不要与动画窗口混淆;那是一个与动画控制器分开的选择)。这里显示的节点网络是当前选定的动画控制器(或所选角色的动画控制器)。

CH08_F16_Hocking3

图 8.16 带有完成动画控制器的动画视图

提示:请记住,您可以在 Unity 中移动选项卡并将它们停靠在任何您喜欢的地方以组织界面。我喜欢将动画控制器停靠在场景和游戏选项卡旁边。

初始时,我们只有两个默认节点,一个是“入口”,另一个是“任何状态”。您不会使用“任何状态”节点。相反,您将拖入动画剪辑以创建新的节点。在项目视图中,单击模型资产旁边的箭头以展开该资产并查看它包含的内容。该资产的内容中包含您定义的动画剪辑(见图 8.17),因此将这些剪辑拖入动画视图。不要担心行走动画(这可能对其他项目有用)并拖入空闲、跑步和跳跃。

CH08_F17_Hocking3

图 8.17 项目视图中的展开模型资产

右键单击“空闲”节点并选择“设置为层默认状态”。该节点将变为橙色,而其他节点保持灰色;默认动画状态是在游戏做出任何更改之前节点网络开始的地方。您需要用表示动画状态之间转换的线条将节点连接起来;右键单击一个节点并选择“创建转换”以开始拖动一个可以点击另一个节点以连接的箭头。按照图 8.16 中显示的图案连接节点(确保大多数节点在两个方向上都有转换,但不要从跳跃转换到跑步)。这些转换线决定了动画状态如何相互连接,并控制游戏中的状态变化。

过渡依赖于一组控制值,因此让我们创建这些参数。在左上角是参数选项卡(如图 8.16 所示);单击它以查看一个带有+按钮的面板,用于添加参数。添加一个名为 Speed 的浮点数和一个名为 Jumping 的布尔值。这些值将由我们的代码调整,并将触发动画状态之间的转换。单击转换线以在检查器中查看它们的设置(见图 8.18)。

CH08_F18_Hocking3

图 8.18 检查器中的过渡设置

这里是你调整参数变化时动画状态如何变化的地方。例如,单击空闲到奔跑转换以调整该转换的条件。在条件下,添加一个并设置为速度,大于,0.1。关闭具有退出时间(这将强制播放整个动画,而不是在转换发生时立即中断)。然后,单击设置标签旁边的箭头以查看整个菜单;其他转换应该能够中断此转换,因此将中断源菜单从无更改为当前状态。对表 8.1 中的所有转换重复此操作。

表 8.1 此动画控制器中所有转换的条件

转换 条件 中断
空闲到奔跑 速度大于 0.1 当前状态
奔跑到空闲 速度小于 0.1
空闲到跳跃 跳跃为真
奔跑到跳跃 跳跃为真
跳跃到空闲 跳跃为假

除了这些基于菜单的设置外,还有一个复杂的视觉界面,如图 8.18 所示,位于条件设置之上。此图允许你直观地调整转换的时间长度。默认的转换时间对于空闲和奔跑之间的转换看起来都很好,但所有跳跃到和从跳跃的转换都应该更短,以便角色能够更快地跳到跳跃动画。图表的阴影区域表示转换所需的时间;要查看更多细节,请按住 Alt 并左键单击(或在 Mac 上按住 Option 并左键单击)图表以在其上平移,并按住 Alt 并右键单击以缩放(这些是 Scene 视图中导航的相同控件)。使用阴影区域顶部的箭头将其缩小到所有三个跳跃转换都低于 4 毫秒。

最后,你可以通过逐个选择动画节点并调整转换顺序来完善动画网络。检查器将显示所有到和从该节点的转换列表;你可以拖动列表中的项目(它们的拖动手柄位于左侧的图标)来重新排序它们。确保空闲和奔跑节点上的跳跃转换都位于顶部,以便跳跃转换具有比其他转换更高的优先级。

当你查看这些设置时,你也可以更改播放速度,如果动画看起来太慢(奔跑在 1.5 倍速时看起来更好)。动画控制器已设置,因此现在你可以从移动脚本中操作动画。

8.4.3 编写操作动画器的代码

最后,你将在 RelativeMovement 脚本中添加方法。如前所述,设置动画状态的大部分工作是在动画控制器中完成的;只需要少量代码就可以操作一个丰富且流畅的动画系统,如这里所示。

列表 8.6 在动画组件中设置值

...
private Animator animator;
...
animator = GetComponent<Animator>();                         ❶
...
        animator.SetFloat("Speed", movement.sqrMagnitude);   ❷

        if (hitGround) {
            if (Input.GetButtonDown("Jump")) {
                vertSpeed = jumpSpeed;
            } else {
                vertSpeed = minFall;
                animator.SetBool("Jumping", false);
            }
        } else {
            vertSpeed += gravity * 5 * Time.deltaTime;
            if (vertSpeed < terminalVelocity) {
                vertSpeed = terminalVelocity;
            }
            if (contact != null ) {                          ❸
                animator.SetBool("Jumping", true);
            }

            if (charController.isGrounded) {
                if (Vector3.Dot(movement, contact.normal) < 0) {
                    movement = contact.normal * moveSpeed;
                } else {
                    movement += contact.normal * moveSpeed;
                }
            }
        }
...

❶ 在 Start() 函数内添加

❷ 在水平移动的整个 if 语句下方

❸ 不要在关卡开始时立即触发此值。

再次强调,列表中的大部分内容与之前的列表重复;动画代码是一些散布在现有移动脚本中的几行。挑选出动画器行,以找到需要在代码中添加的内容。

脚本需要引用 Animator 组件,然后代码在动画器上设置值(可以是浮点数或布尔值)。唯一稍微不那么明显的一小段代码是在设置跳跃布尔值之前的条件(contact != null)。这个条件防止动画器在游戏开始时播放跳跃动画。尽管角色在技术上会短暂地落下,但直到角色第一次接触地面之前,不会生成任何碰撞数据。

就这样!现在我们有一个很好的第三人称移动演示,带有相机相对控制和角色动画播放。

摘要

  • 第三人称视角意味着相机在角色周围移动,而不是在角色内部移动。

  • 模拟阴影,如实时阴影和光照贴图,可以提升图形效果。

  • 控制可以相对于相机,而不是相对于角色。

  • 你可以通过向下发射射线来提高 Unity 的地形检测。

  • 使用 Unity 的动画控制器设置复杂的动画,可以产生逼真的角色。

9 在游戏中添加交互式设备和物品

本章涵盖

  • 编程玩家可以打开的门

  • 启用物理模拟,使一摞箱子散开

  • 构建玩家存储在他们的库存中的可收集物品

  • 使用代码管理游戏状态,例如库存数据

  • 配备和使用库存物品

实现功能性物品是我们接下来要关注的话题。前几章涵盖了完整游戏的各种元素:移动、敌人、用户界面等。但我们的项目缺乏与其他东西交互的内容,也没有多少游戏状态。在本章中,你将学习如何创建像门这样的功能性设备。

我们还将讨论收集物品,这涉及到与关卡中的对象交互以及跟踪游戏状态。游戏通常需要跟踪状态,如玩家的当前统计数据、完成目标进度等。玩家的库存就是这类状态的例子,因此你将构建一个代码架构来跟踪玩家收集的物品。到本章结束时,你将构建一个真正感觉像游戏的动态空间!

我们将首先探索(如门)由玩家按键操作的操作设备。之后,你将编写代码以检测玩家与关卡中的对象发生碰撞,从而实现推动物体或收集库存物品等交互。然后,你将设置一个健壮的模型-视图-控制器(MVC)风格的代码架构来管理收集的库存数据。最后,你将编写接口以利用库存进行游戏玩法,例如需要钥匙才能打开门。

警告:前几章相对独立,技术上不需要早期章节的项目,但这次一些代码列表对第八章的脚本进行了编辑。如果你直接跳到本章,请下载第八章的示例项目以在此基础上构建。

示例项目将在关卡上随机分布这些设备和物品。一个精心打磨的游戏会在物品放置上有很多精心设计,但我们不需要精心规划一个只测试功能的关卡。然而,尽管对象的放置不需要计划,章节开头列出的项目符号却概述了我们实施事物的顺序。通常,解释会逐步构建代码,但如果你想在同一个地方看到所有完成的代码,你可以下载示例项目。

9.1 创建门和其他设备

虽然游戏中的层级主要由静态墙壁和景观组成,但它们通常也包含许多功能设备。我指的是玩家可以与之交互和操作的对象——比如可以打开的灯或开始旋转的扇子。具体的设备可以有很多种,通常只受限于你的想象力,但几乎所有这些设备都使用相同类型的代码来让玩家激活设备。在本章中,你将实现几个示例,然后你应该能够将相同的代码适应到各种其他设备上。

9.1.1 通过按键打开和关闭的门

你将要编写的第一种设备是能够打开和关闭的门,你将从一个通过按键操作门开始。游戏中可以有大量的设备,以及操作这些设备的方法。我们最终会看看一些变体,但门是游戏中最常见的交互式设备,使用物品通过按键操作是最直接的方法来开始。

场景中有几个地方墙壁之间存在缝隙,所以放置一个新的对象来阻挡缝隙。我创建了一个新的立方体对象,并将其变换设置为位置 2.5, 1.5, 17 和缩放 5, 3, 0.5,创建了图 9.1 中显示的门。

CH09_F01_Hocking3

图 9.1 门对象嵌入到墙的缝隙中

创建一个 C#脚本,命名为 DoorOpenDevice,并将其放置在门对象上。这段代码将使对象作为一个门来操作。

列表 9.1 命令打开和关闭门的脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DoorOpenDevice : MonoBehaviour {
   [SerializeField] Vector3 dPos;                 ❶

   private bool open;                             ❷

   public void Operate() {
      if (open) {                                 ❸
         Vector3 pos = transform.position - dPos;
         transform.position = pos;
      } else {
         Vector3 pos = transform.position + dPos;
         transform.position = pos;
      }
      open = !open;
   }
}

❶ 当门打开时偏移的位置量

❷ 用于跟踪门开启状态的布尔值

❸ 根据门的开启状态打开或关闭门。

第一个变量定义了门打开时应用的偏移量。门打开时会移动这个量,关闭时则会减去这个量。第二个变量是一个私有的布尔值,用于跟踪门是打开还是关闭。在 Operate()方法中,对象的变换被设置为新的位置,根据门是否已经打开,添加或减去偏移量;然后打开状态被切换开或关。

与其他序列化变量一样,dPos 出现在检查器中。但这是一个 Vector3 值,所以我们有三个输入框,都在一个变量名下。输入门打开时的相对位置;我决定让门滑动打开,所以偏移量是 0, -2.9, 0(因为门对象的高度为 3,向下移动 2.9 后,门的一小部分会露出地板)。

注意:变换是即时应用的,但你可能更喜欢在门打开时看到移动。如第三章所述,你可以使用 tween 使对象在一段时间内平滑移动。在不同的上下文中,“tween”这个词有不同的含义,但在游戏编程中,它指的是导致对象移动的代码命令;附录 D 提到了 Unity 的 tweening 系统。

其他代码需要调用 Operate()来使门打开和关闭(单个函数调用处理两种情况)。你还没有在玩家上放置那个其他脚本,所以编写这个脚本是下一步。

9.1.2 在开门前检查距离和朝向

创建一个新的脚本,并将其命名为 DeviceOperator。这个列表实现了一个控制键,用于操作附近的设备。

列表 9.2 玩家设备控制键

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DeviceOperator : MonoBehaviour {
  public float radius = 1.5f;                                    ❶

  void Update() {
    if (Input.GetKeyDown(KeyCode.C)) {                           ❷
      Collider[] hitColliders =
            Physics.OverlapSphere(transform.position, radius);   ❸
      foreach (Collider hitCollider in hitColliders) {
        hitCollider.SendMessage("Operate",
            SendMessageOptions.DontRequireReceiver);             ❹
      }
    }
  }
}

❶ 激活设备时玩家距离多远

❷ 当按下指定的键时做出响应。

❸ OverlapSphere()返回一个附近对象的列表。

❹ SendMessage()试图调用指定的函数,而不考虑目标类型。

列表中的大部分内容应该看起来很熟悉,但中心有一个关键的新方法。首先,确定操作设备距离的值。然后,在 Update()函数中,查找键盘输入。就像 RelativeMovement 脚本使用 GetButtonDown()和项目输入设置中的一个按钮一样,这次你将使用 GetKeyDown()来获取特定字母键的输入。

现在我们来到了关键的新方法:OverlapSphere()。这个方法返回一个数组,包含所有位于给定位置一定距离内的对象。通过传入玩家的位置和半径变量,这个方法可以检测到玩家附近的全部对象。你可以根据这个列表进行不同的操作(比如你可能想要引爆一个炸弹并施加爆炸力),但在这个情况下,你想要尝试在所有附近的对象上调用 Operate()方法。

那个方法是通过 SendMessage()而不是典型的点符号调用的,这种做法你也在之前的章节中看到过 UI 按钮的使用。就像之前那样,你使用 SendMessage()是因为你不知道目标对象的精确类型,而这个命令适用于所有 GameObject。但这次你将传递 DontRequireReceiver 选项给这个方法。这是因为 OverlapSphere()返回的大多数对象不会有 Operate()方法;通常,如果对象没有接收消息,SendMessage()会打印一个错误信息,但在这个情况下,错误信息会分散注意力,因为你已经知道大多数对象会忽略这个消息。

一旦代码编写完成,你可以将这个脚本附加到玩家对象上。现在你可以通过站在门附近并按下一个键来打开和关闭门。

你可以修复一个小细节。目前,玩家面向哪个方向无关紧要,只要玩家足够近即可。但你也可以调整脚本,使其仅操作玩家面向的设备,让我们这么做。回想第八章,你可以计算点积来检查面向。这是一个在两个向量上进行的数学运算,返回介于 -NN 之间的范围,其中 N 表示它们指向完全相同的方向,而 -N 表示它们指向完全相反的方向。嗯,当向量归一化时,N 为 1,结果是一个从 -1 到 1 的易于处理的范围。

定义 当一个向量被 归一化 时,结果将继续指向同一方向,但其长度(也称为其 大小)将被调整为 1。许多数学运算最适合使用归一化向量,因此 Unity 提供了返回归一化向量的属性。

这里是 DeviceOperator 脚本中的新代码。

列表 9.3 调整 DeviceOperator 以仅操作玩家面向的设备

...
foreach (Collider hitCollider in hitColliders) {
   Vector3 hitPosition = hitCollider.transform.position;
   hitPosition.y = transform.position.y;                                ❶

   Vector3 direction = hitPosition - transform.position;
   if (Vector3.Dot(transform.forward, direction.normalized) > .5f) {    ❷
      hitCollider.SendMessage("Operate",
            SendMessageOptions.DontRequireReceiver);
   }
}
...

❶ 垂直校正以确保方向不会指向上下

❷ 仅在面向正确方向时发送消息。

要使用点积,你首先确定要检查的方向。那将是玩家到物体的方向;通过从物体的位置减去玩家的位置(垂直位置已校正,因此方向将是水平的而不是指向降低的门)来制作一个方向向量。然后使用 Vector3.Dot() 函数调用该方向向量和玩家的前方方向。当点积接近 1(具体来说,此代码检查它是否大于 0.5)时,两个向量几乎指向同一方向。

进行此调整后,当玩家背对门时,门不会打开和关闭,即使玩家很近。并且这种操作设备的方法可以用于任何类型的设备。为了展示这种灵活性,让我们创建另一个示例设备。

9.1.3 操作颜色变化的显示器

我们已经创建了一个可以打开和关闭的门,但相同的设备操作逻辑可以用于任何类型的设备。你将创建另一个以相同方式操作的设备;这次,你将在墙上创建一个颜色变化的显示器。

创建一个新的立方体,并将其放置在墙壁的一侧几乎伸出墙壁的位置。例如,我选择了位置 10.9,1.5,-5。现在创建一个新的脚本,命名为 ColorChangeDevice,并将其(列表 9.4)附加到墙壁显示器上。走到墙壁显示器前,按下与门相同的“操作”键;你应该看到显示器颜色改变,如图 9.2 所示。

CH09_F02_Hocking3

图 9.2 嵌入墙中的颜色变化显示器

列表 9.4 改变颜色的设备脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ColorChangeDevice : MonoBehaviour {
   public void Operate() {                                  ❶
      Color random = new Color(Random.Range(0f,1f),
            Random.Range(0f,1f), Random.Range(0f,1f));      ❷
      GetComponent<Renderer>().material.color = random;     ❸
   }
}

❶ 声明一个与门脚本具有相同名称的方法。

❷ 这些数字是介于 0 到 1 之间的 RGB 值。

❸ 颜色设置在附加到对象上的材质中。

首先,声明与门脚本使用的相同函数名。Operate 是设备操作脚本使用的函数名,所以你需要使用这个名字来触发它。在这个函数内部,代码将随机颜色分配给对象的材质(记住,颜色不是对象的属性,而是对象有一个材质,而这个材质可以有颜色)。

注意:尽管颜色是通过红色、蓝色和绿色组件定义的,这与大多数计算机图形的标准一致,但 Unity 的 Color 对象中的值在 0 到 1 之间变化,而不是在大多数地方(包括 Unity 的颜色选择器 UI)常见的 0 到 255。

好吧,我们已经介绍了一种与游戏中的设备交互的方法,甚至实现了一些设备来演示。与物品交互的另一种方法是碰撞它们,所以让我们来看看这一点。

9.2 通过碰撞物体与物体交互

在上一节中,设备是通过玩家的键盘输入来操作的,但这并不是玩家与关卡中的物品交互的唯一方式。另一种直接的方法是响应与玩家的碰撞。Unity 通过在游戏引擎中内置碰撞检测和物理为你处理大部分工作。Unity 会为你检测碰撞,但你仍然需要编程对象以做出响应。

我们将介绍三种对游戏有用的碰撞响应:

  • 推开并倒下

  • 触发关卡中的设备

  • 联系时消失(用于物品拾取)

9.2.1 与启用了物理的障碍物碰撞

首先,你需要创建一堆盒子,然后当玩家撞到它时使这堆盒子倒塌。尽管涉及的物理计算很复杂,但 Unity 已经内置了所有这些,并将以逼真的方式散布盒子。

默认情况下,Unity 不会使用其物理模拟来移动对象。可以通过向对象添加 Rigidbody 组件来启用此功能。这个概念首次在第三章中讨论,因为敌人的火球也需要一个 Rigidbody 组件。正如我在那一章中解释的,Unity 的物理系统只会对具有 Rigidbody 组件的对象起作用。通过点击添加组件并进入物理(不是物理 2D!)菜单来查找 Rigidbody。

创建一个新的立方体对象,然后向其添加一个 Rigidbody 组件。创建几个这样的立方体并将它们整齐地堆叠起来。例如,在示例下载中,我创建了五个盒子并将它们堆叠成两层(见图 9.3)。

CH09_F03_Hocking3

图 9.3 要碰撞的五层盒子堆

现在箱子已经准备好对物理力做出反应。为了让玩家对箱子施加力量,将以下列表中所示的小修改添加到玩家上的 RelativeMovement 脚本(这是第八章中编写的脚本之一)中。

列表 9.5 向 RelativeMovement 脚本添加物理力

...
public float pushForce = 3.0f;                            ❶
...
void OnControllerColliderHit(ControllerColliderHit hit) {
   contact = hit;

   Rigidbody body = hit.collider.attachedRigidbody;       ❷
   if (body != null && !body.isKinematic) {
      body.velocity = hit.moveDirection * pushForce;      ❸
   }
}
...

❶ 要应用的力量大小

❷ 检查碰撞的物体是否具有 Rigidbody 以接收物理力。

❸ 将速度应用于物理体。

这段代码没有太多需要解释的:每当玩家与某个物体碰撞时,检查碰撞的物体是否具有 Rigidbody 组件。如果是,则向该 Rigidbody 应用速度。

玩游戏并撞向箱子堆;你应该看到它们真实地散开。这就是你需要在场景中的箱子堆上激活物理模拟所需要做的全部!Unity 内置了物理模拟,所以你不需要编写很多代码。这种模拟可以使物体在碰撞响应时移动,但另一种可能的响应是触发事件,所以让我们使用这些触发事件来控制门。

9.2.2 使用触发对象操作门

之前,门是通过按键操作的。这次它将在角色与场景中的另一个物体碰撞时打开和关闭。

创建另一个门并将其放置在另一个墙缝中(我复制了之前的门并将新门移动到 -2.5, 1.5, -17)。现在创建一个新的立方体作为触发对象,并选择碰撞器的“Is Trigger”复选框(这一步骤在第三章制作火球时已说明)。此外,将触发对象设置为“Ignore Raycast”层;检查器的右上角有一个“Layer”菜单。最后,你应该关闭此对象的“Cast Shadows”(记住,当你选择对象时,这个设置在“Mesh Renderer”下)。

警告:这些微小的步骤很容易被忽略但很重要:要使用对象作为触发器,请确保启用“Is Trigger”。在检查器中,寻找 Collider 组件中的复选框。此外,将层更改为“Ignore Raycast”,这样触发对象就不会出现在射线投射中。

注意:在第三章介绍触发对象时,该对象需要添加 Rigidbody 组件。这次触发不需要 Rigidbody 组件,因为触发对象将响应玩家(与之前碰撞墙壁的情况不同)。为了触发器能够工作,触发器或进入触发器的对象需要启用 Unity 的物理系统;Rigidbody 组件满足这一要求,但玩家的角色控制器也是如此。

将触发对象的位置和缩放调整到既包括门又围绕门周围的一个区域;我使用了位置 -2.5, 1.5, -17(与门相同)和缩放 7.5, 3, 6。此外,你可能还想将半透明材质分配给对象,以便你可以从视觉上区分触发体积和固体对象。通过使用 Assets 菜单创建一个新的材质,并在项目视图中选择该新材质。查看检查器,顶部设置是渲染模式(当前设置为默认值不透明);在此菜单中选择透明。

现在点击 Albedo 颜色样本以打开颜色选择器窗口。在窗口的主要部分选择绿色,并使用底部的滑块降低 alpha 值。将此材质从项目拖动到对象上;图 9.4 显示了带有此材质的触发器。

CH09_F04_Hocking3

图 9.4 围绕门的触发体积

定义 触发器 通常被称为 体积 而不是对象,以概念上区分固体对象和可以穿过的对象。

现在玩游戏,你可以自由地穿过触发体积。Unity 仍然会检测到与对象的碰撞,但这些碰撞不再影响玩家的移动。要响应碰撞,你需要编写代码。具体来说,你希望这个触发器控制门。创建一个新的脚本,命名为 DeviceTrigger。

列表 9.6 控制设备的触发器代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DeviceTrigger : MonoBehaviour {
   [SerializeField] GameObject[] targets;       ❶

   void OnTriggerEnter(Collider other) {        ❷
      foreach (GameObject target in targets) {
         target.SendMessage("Activate");
      }
   }

   void OnTriggerExit(Collider other) {         ❸
      foreach (GameObject target in targets) {
         target.SendMessage("Deactivate");
      }
   }
}

❶ 此触发器将激活的目标对象列表

❷ 当另一个对象进入触发体积时,调用 OnTriggerEnter() ...

❸ ... whereas OnTriggerExit() 被调用时,一个对象离开触发体积。

此列表定义了触发器的目标对象数组;尽管大多数时候它将是一个只有一个对象的列表,但可能有一个触发器控制多个设备。遍历目标数组,向所有目标发送消息。这个循环发生在 OnTriggerEnter() 和 OnTriggerExit() 方法内部。这些函数在另一个对象首次进入和退出触发体积时被调用一次(而不是在对象在触发体积内部时反复调用)。

注意,现在发送的消息与之前不同;现在你需要在门上定义 Activate() 和 Deactivate() 函数。将下一列表中的代码添加到 DoorOpenDevice 脚本中。

列表 9.7 将激活和去激活函数添加到 DoorOpenDevice 脚本中

...
public void Activate() {
   if (!open) {                                 ❶
      Vector3 pos = transform.position + dPos;
      transform.position = pos;
      open = true;
   }
}
public void Deactivate() {
   if (open) {                                  ❷
      Vector3 pos = transform.position - dPos;
      transform.position = pos;
      open = false;
   }
}
...

❶ 仅当门未打开时才打开门。

❷ 仅当门未关闭时才关闭门。

新的 Activate() 和 Deactivate() 方法与早期的 Operate() 方法几乎相同,区别在于现在有单独的函数来打开和关闭门,而不是只有一个函数处理这两种情况。

在放置了所有必要的代码后,你现在可以使用触发体积来打开和关闭门。将设备触发器脚本放在触发体积上,然后将门链接到该脚本的“目标”属性;在检查器中,首先设置数组的大小,然后将对象从层次结构视图拖动到目标数组的槽位中。因为你只想用这个触发器控制一个门,所以在数组的“大小”字段中输入 1,然后将那个门拖入目标槽位。

完成所有这些后,玩游戏并观察当玩家走向和远离门时门会发生什么。当玩家进入和离开触发体积时,门会自动打开和关闭。

这也是将交互性引入关卡的另一种极好的方法!但这种方法不仅适用于像门这样的设备;你还可以使用这种方法来制作可收集物品。

练习:在 2D 平台游戏中实现触发设备

在本章中,你已经在 3D 游戏中实现了触发器,但要在 2D 游戏中做同样的事情逻辑几乎完全相同;你只需对 2D 碰撞体做出反应,使用OnTrigger2D。作为一个练习,回到第六章的 2D 平台游戏,并在那个平台游戏中实现触发体积和设备。

9.2.3 在关卡中收集散落的物品

许多游戏包括玩家可以捡起的物品。这些物品包括装备、健康包和增强效果。与物品碰撞以捡起它们的基本机制很简单;大多数复杂的事情发生在捡起物品之后,但我们会稍后讨论这一点。

创建一个球体对象,并将其放置在场景中一个开阔区域腰部高度的位置。使对象变小(例如缩放 0.5, 0.5, 0.5),但除此之外,按照你处理大型触发体积的方式准备它。在碰撞器中选择“是触发器”设置,将对象设置为忽略射线投射层,然后创建一个新的材质,给对象一个独特的颜色。因为对象覆盖面积不大,所以不需要使其半透明,所以这次不要降低 alpha 滑块。此外,如第八章所述,有设置可以移除由此对象产生的阴影;是否使用阴影是一个判断问题,但对于像这样的小型拾取物品,我更喜欢将其关闭。

现在场景中的对象已经准备好了,创建一个新的脚本并将其附加到该对象上。将脚本命名为 CollectibleItem。

列表 9.8:与玩家接触时使项目删除自身的脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CollectibleItem : MonoBehaviour {
   [SerializeField] string itemName;            ❶

   void OnTriggerEnter(Collider other) {
      Debug.Log($"Item collected: {itemName}");
      Destroy(this.gameObject);
   }
}

❶ 在检查器中输入此物品的名称。

这个脚本非常简短且简单。给项目一个名称值,以便不同的项目可以出现在场景中。OnTriggerEnter()会销毁自身。同时,还会在控制台打印一条调试信息;最终它将被有用的代码所取代。

警告 确保调用 this.gameObject 上的 Destroy()而不是 this!不要混淆这两个;这仅指代此脚本组件,而 this.gameObject 指的是脚本附加到的对象。

在 Unity 中,你添加到代码中的变量应该在 Inspector 中可见。输入一个名称来标识此物品;我选择了“能量”作为我的第一个物品。然后复制几次物品并更改副本的名称;我还创建了矿石、健康和钥匙(这些名称必须是确切的,因为它们将在后面的代码中使用)。此外,为每个物品创建单独的材料,以赋予它们不同的颜色:我使用了浅蓝色能量、深灰色矿石、粉色健康和黄色钥匙。

小贴士 与我们在这里所做的不一样,在更复杂的游戏中,物品通常有一个用于查找更多数据的标识符。例如,一个物品可能被分配 ID 301,而 ID 301 与某个显示名称、图像、描述等相关联。

现在制作物品的预制体,这样你就可以在整个关卡中克隆它们。在第三章中,我解释了将对象从 Hierarchy 视图拖拽到 Project 视图会将该对象转换为预制体;为所有四个物品都这样做。

备注 对象的名称将在 Hierarchy 列表中变为蓝色;蓝色名称表示预制体的实例。右键单击预制体实例,选择 Select Prefab,然后选择该对象是其实例的预制体。

将预制体的实例拖拽出来,并将物品放置在关卡的可开放区域;甚至可以拖拽出同一物品的多个副本进行测试。玩游戏并遇到物品来收集它们。这相当不错,但目前在收集物品时没有任何动作发生。你将开始跟踪收集到的物品;为此,你需要设置库存代码结构。

9.3 管理库存数据和游戏状态

现在你已经编写了收集物品的功能,你需要为游戏库存设置背景数据管理器(类似于网络编码模式)。你将要编写的代码将与许多网络应用程序背后的 MVC 架构相似。这些数据管理器的优势在于将数据存储与屏幕上显示的对象解耦,这使得实验和迭代开发更加容易。即使数据和/或显示很复杂,应用程序某一部分的更改也不会影响其他部分。

话虽如此,不同游戏之间的这些结构差异很大,因为并非每个游戏都有相同的数据管理需求。例如,角色扮演游戏会有较高的数据管理需求,因此你可能想要实现类似于 MVC 架构的东西。然而,解谜游戏的数据管理需求很少,因此构建一个复杂的解耦数据管理器结构将是过度设计。相反,游戏状态可以在场景特定的控制器对象中跟踪(实际上,我们就是这样在前几章处理游戏状态的)。

在这个项目中,你需要管理玩家的库存。让我们设置所需的代码结构。

9.3.1 设置玩家和库存管理器

这里的基本思路是将所有数据管理分成独立的、定义良好的模块,每个模块负责其自身的责任区域。你将创建独立的模块来维护玩家状态(如玩家的健康)并在 InventoryManager 中维护库存列表。这些数据管理器将类似于 MVC 中的模型控制器是大多数场景中的一个不可见对象(在这里不需要,但回想一下前几章中的 SceneController),而场景的其余部分则类似于视图

更高级别的管理器管理器将跟踪所有独立的模块。除了保持所有管理器的列表外,这个高级管理器还将控制各种管理器的生命周期——特别是初始化它们。游戏中的所有其他脚本都可以通过主管理器访问这些集中化的模块。具体来说,其他代码可以使用主管理器中的静态属性来连接到所需的特定模块。

访问集中化共享模块的设计模式

经过多年的发展,出现了各种设计模式来解决将程序的一部分连接到程序中共享的集中化模块的问题。例如,Singleton 模式在原始的“四人帮”设计模式书中被确立。

但这种模式已经不被许多软件工程师所青睐,因此他们使用替代模式,如服务定位器和依赖注入。在我的代码中,我使用了一种介于静态变量简单性和服务定位器灵活性之间的折中方案。

这种设计使得代码易于使用,同时也允许替换不同的模块。例如,通过使用单例模式请求 InventoryManager 将始终引用同一个类,因此将你的代码紧密耦合到该类;相反,通过服务定位器请求 Inventory 则提供了返回 InventoryManager 或 DifferentInventoryManager 的选择。有时能够在不同版本的同一模块之间切换(例如在不同平台上部署游戏)是非常方便的。

为了使主管理器以一致的方式引用其他模块,这些模块都必须继承自一个共同的基类。你将通过接口来实现这一点;许多编程语言(包括 C#)允许你定义一种蓝图,其他类需要遵循。PlayerManager 和 InventoryManager 都将实现一个通用接口(在本例中称为 IGameManager),然后主管理器对象可以将 PlayerManager 和 InventoryManager 都视为 IGameManager 类型。图 9.5 展示了我所描述的设置。

CH09_F05_Hocking3

图 9.5 各种模块及其关系的图解

顺便说一下,虽然我一直在谈论的所有代码架构都由存在于背景中的无形模块组成,但 Unity 仍然需要将脚本链接到场景中的对象才能运行代码。就像你在之前的项目中为场景特定的控制器所做的那样,你将创建一个空 GameObject 来将这些数据管理者链接到。

9.3.2 编程游戏管理者

好吧,这就解释了你将要做的所有概念背后的内容;现在是时候编写代码了。首先,创建一个名为 IGameManager 的新脚本。

列表 9.9 数据管理者将实现的基接口

public interface IGameManager {
   ManagerStatus status {get;}     ❶

   void Startup();
}

❶ 这是一个你需要定义的枚举。

嗯,这个文件里几乎没有代码。注意,它甚至没有从 MonoBehaviour 继承;一个接口本身并不能做什么,它只是用来对其他类施加结构。这个接口声明了一个属性(一个有 getter 函数的变量)和一个方法;任何实现这个接口的类都需要实现这两个。状态属性告诉其他代码这个模块是否已经完成了初始化。Startup() 的目的是处理管理器的初始化,所以初始化任务在那里发生,并且函数设置管理器的状态。

注意,属性的类型是 ManagerStatus。那是一个你还没有编写的枚举,所以创建 ManagerStatus 脚本。

列表 9.10 ManagerStatus:IGameManager 状态的可能状态

public enum ManagerStatus {
   Shutdown,
   Initializing,
   Started
}

这又是一个几乎没有代码的文件。这次,你正在列出管理者可能处于的状态,从而确保状态属性始终是这些列出的值之一。

现在 IGameManager 已经编写好了,你可以在其他脚本中实现它。列表 9.11 和 9.12 包含了 InventoryManager 和 PlayerManager 的代码。

列表 9.11 InventoryManager

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InventoryManager : MonoBehaviour, IGameManager {
   public ManagerStatus status {get; private set;}            ❶

   public void Startup() {
      Debug.Log("Inventory manager starting...");             ❷
      status = ManagerStatus.Started;                         ❸
   }
}

❶ 属性可以在任何地方读取,但只能在脚本内部设置。

❷ 长时间运行的启动任务放在这里。

❸ 对于长时间任务,使用状态 Initializing。

列表 9.12 PlayerManager

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerManager : MonoBehaviour, IGameManager {    ❶
   public ManagerStatus status {get; private set;}

   public int health {get; private set;}
   public int maxHealth {get; private set;}

   public void Startup() {
      Debug.Log("Player manager starting...");

      health = 50;                                            ❷
      maxHealth = 100;                                        ❷

      status = ManagerStatus.Started;
   }

   public void ChangeHealth(int value) {                      ❸
      health += value;
      if (health > maxHealth) {
         health = maxHealth;
      } else if (health < 0) {
         health = 0;
      }

      Debug.Log($"Health: {health}/{maxHealth}");
   }
}

❶ 它们都继承了一个类并实现了一个接口。

❷ 这些值可以用保存的数据初始化。

❸ 其他脚本不能直接设置健康值,但可以调用这个函数。

目前,InventoryManager 是一个空壳,稍后将被填充,而 PlayerManager 拥有这个项目所需的所有功能。这两个管理者都继承自 MonoBehaviour 类并实现了 IGameManager 接口。这意味着管理者获得了 MonoBehaviour 的所有功能,同时还需要实现 IGameManager 施加的结构。IGameManager 的结构是一个属性和一个方法,因此管理者定义了这两件事。

状态属性被定义为可以从任何地方读取状态(获取器是公共的),但只能在脚本内部设置(设置器是私有的)。接口中的方法是 Startup(),因此两个管理器都定义了该函数。在两个管理器中,初始化立即完成(库存管理器目前什么也不做,而玩家管理器设置了一些值),因此状态被设置为 Started。但数据模块可能在其初始化过程中有长时间运行的任务(例如加载保存的数据),在这种情况下,Startup() 将启动这些任务并将管理器的状态设置为 Initializing。在那些任务完成后,将状态更改为 Started。

太好了!我们终于准备好使用主管理者将一切联系在一起了。创建一个额外的脚本,并将其命名为 Managers。

列表 9.13 管理器中的管理者!

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(PlayerManager))]                       ❶
[RequireComponent(typeof(InventoryManager))]

public class Managers : MonoBehaviour {
   public static PlayerManager Player {get; private set;}       ❷
   public static InventoryManager Inventory {get; private set;}

   private List<IGameManager> startSequence;                    ❸

   void Awake() {
      Player = GetComponent<PlayerManager>();
      Inventory = GetComponent<InventoryManager>();

      startSequence = new List<IGameManager>();
      startSequence.Add(Player);
      startSequence.Add(Inventory);

      StartCoroutine(StartupManagers());                        ❹
   }

   private IEnumerator StartupManagers() {
      foreach (IGameManager manager in startSequence) {
         manager.Startup();
      }

      yield return null;

      int numModules = startSequence.Count;
      int numReady = 0;

      while (numReady < numModules) {                           ❺
         int lastReady = numReady;
         numReady = 0;

         foreach (IGameManager manager in startSequence) {
            if (manager.status == ManagerStatus.Started) {
               numReady++;
            }
         }

         if (numReady > lastReady)
            Debug.Log($"Progress: {numReady}/{numModules}");
         yield return null;                                     ❻
      }

      Debug.Log("All managers started up");
   }
}

❶ 确保各种管理器存在。

❷ 其他代码用来访问管理器的静态属性

❸ 启动序列期间循环遍历的管理器列表

❹ 异步启动启动序列。

❺ 继续循环,直到所有管理器都已启动。

❻ 在再次检查之前暂停一帧。

此模式的最重要的部分是顶部的静态属性。这些属性使其他脚本可以使用 Managers.Player 或 Managers.Inventory 这样的语法来访问各个模块。这些属性最初是空的,但它们在代码在 Awake() 方法中运行时立即被填充。

TIP 就像 Start() 和 Update() 一样,Awake 也是由 MonoBehaviour 自动提供的方法之一。它与 Start() 类似,在代码首次运行时只运行一次。但在 Unity 的代码执行序列中,Awake() 比 Start() 运行得更早,允许执行必须在其他任何代码模块之前运行的初始化任务。

Awake() 方法还列出了启动序列,然后启动协程以启动所有管理器。具体来说,该函数创建一个列表,然后使用 List.Add() 向其中添加管理器。

DEFINITION List 是由 C# 提供的集合数据结构。List 对象类似于数组:它们使用特定类型声明,并按顺序存储一系列条目。但列表在创建后可以改变大小,而数组是在静态大小创建的,之后无法更改。

因为所有管理器都实现了 IGameManager 接口,所以这段代码可以将它们全部列出为该类型,并可以调用每个定义的 Startup() 方法。启动序列作为协程运行,以便它将异步执行,同时游戏的其他部分也在进行(例如,启动屏幕上的进度条动画)。

启动函数首先遍历整个管理器列表,并对每个管理器调用 Startup()。然后它进入一个循环,不断检查管理器是否已启动,并且只有在它们全部启动后才会继续。一旦所有管理器都已启动,启动函数最终会通知我们这一事实,然后最终完成。

TIP 你之前编写的管理器初始化如此简单,无需等待,但通常这种基于协程的启动序列可以优雅地处理长时间运行的异步启动任务,如加载数据。

现在所有代码结构都已编写。回到 Unity,创建一个新的空 GameObject;像往常一样,将这些空代码对象放置在 0, 0, 0 位置,并给对象一个描述性的名称,如 Game Managers。将 Managers、PlayerManager 和 InventoryManager 脚本组件附加到这个新对象上。

当你现在玩游戏时,场景中不应该出现任何可见的变化,但在控制台中,你应该看到一系列记录启动序列进度的消息。假设管理器启动正确,现在是时候开始编写库存管理器了。

9.3.3 在集合对象中存储库存:列表与字典

收集的物品列表也可以存储为列表对象。此列表将物品列表添加到 InventoryManager。

列表 9.14 向 InventoryManager 添加物品

...
private List<string> items;

public void Startup() {
   Debug.Log("Inventory manager starting...");

   items = new List<string>();         ❶

   status = ManagerStatus.Started;
}

private void DisplayItems() {          ❷
   string itemDisplay = "Items: ";
   foreach (string item in items) {
      itemDisplay += item + " ";
   }
   Debug.Log(itemDisplay);
}

public void AddItem(string name) {     ❸
   items.Add(name);

   DisplayItems();
}
...

❶ 初始化空物品列表。

❷ 打印当前库存的控制台消息。

❸ 其他脚本不能直接操作物品列表,但可以调用此方法。

此列表为 InventoryManager 增加了两个关键功能:一个用于存储物品的列表对象和一个公共方法 AddItem(),其他代码可以调用。此函数将物品添加到列表中,然后将其打印到控制台。现在让我们在 CollectibleItem 脚本中稍作调整,以调用新的 AddItem() 方法。

列表 9.15 在 CollectibleItem 中使用新的 InventoryManager

...
void OnTriggerEnter(Collider other) {
   Managers.Inventory.AddItem(itemName);
   Destroy(this.gameObject);
}
...

现在你四处收集物品时,你应该在控制台消息中看到你的库存增长。这非常酷,但它也暴露了列表数据结构的一个限制:当你收集相同类型的多个物品(例如收集第二个健康物品)时,你会看到两个副本都被列出,而不是将同一类型的所有物品聚合在一起(参见图 9.6)。根据你的游戏,你可能希望库存单独跟踪每个物品,但在大多数游戏中,库存应该聚合相同物品的多个副本。使用列表可以实现这一点,但使用字典更自然、更高效。

CH09_F06_Hocking3

图 9.6 列出多个相同物品的多次控制台消息

DEFINITION 字典是 C# 提供的另一种集合数据结构。字典中的条目通过标识符(或键)访问,而不是通过列表中的位置。这与哈希表类似,但更灵活,因为键可以是任何类型(例如,“返回此 GameObject 的条目”)。

将 InventoryManager 中的代码更改为使用字典而不是列表。用此列表中的代码替换列表 9.14 中的所有内容。

列表 9.16 InventoryManager 中的物品字典

...
private Dictionary<string, int> items;        ❶

public void Startup() {
   Debug.Log("Inventory manager starting...");

   items = new Dictionary<string, int>();

   status = ManagerStatus.Started;
}

private void DisplayItems() {
   string itemDisplay = "Items: ";
   foreach (KeyValuePair<string, int> item in items) {
      itemDisplay += item.Key + "(" + item.Value + ") ";
   }
   Debug.Log(itemDisplay);
}

public void AddItem(string name) {
   if (items.ContainsKey(name)) {             ❷
      items[name] += 1;
   } else {
      items[name] = 1;
   }

   DisplayItems();
}
...

❶ Dictionary 使用两种类型声明:键和值。

❷ 在输入新数据之前检查现有条目。

总体来说,这段代码看起来和之前一样,但存在一些微妙的不同之处。如果你还不熟悉 Dictionary 数据结构,请注意,这个是使用两种类型声明的。与只声明一个类型(即将被列出的值的类型)的 List 不同,Dictionary 声明了键的类型(即标识符的类型)和值的类型。

AddItem()方法中存在一些额外的逻辑。之前,每个物品都是追加到 List 中的,但现在你需要检查字典是否已经包含该物品;这就是 ContainsKey()方法的作用。如果是一个新条目,那么计数将从 1 开始,但如果条目已经存在,那么将增加存储的值。尝试使用新代码,你会看到库存消息中每个物品都有一个聚合计数(参见图 9.7)。

CH09_F07_Hocking3

图 9.7 带有相同物品多次聚合的控制台消息

哇,终于,收集到的物品现在被管理在玩家的库存中了!这可能看起来处理一个相对简单的问题需要很多代码,如果这就是整个目的,那么,是的,这将是过度设计的。然而,这个复杂的代码架构的目的是将所有数据保存在独立的灵活模块中,当游戏变得更加复杂时,这是一种有用的模式。例如,现在你可以编写 UI 显示,而代码的各个部分将更容易处理。

9.4 使用和装备项目的库存 UI

在游戏中,你的库存中的物品可以以多种方式使用,但所有这些用途都首先依赖于某种库存 UI,以便玩家可以看到他们收集到的物品。然后,当库存被展示给玩家时,你可以通过允许玩家点击他们的物品来将交互性编程到 UI 中。再次强调,你将编写几个特定的示例(装备钥匙和消耗生命药包),然后你应该能够将此代码适应到其他类型的物品。

注意:如第七章所述,Unity 既有较老的即时模式 GUI,也有较新的基于精灵的 UI 系统。在本章中,我们将使用即时模式 GUI,因为该系统实现起来更快,需要设置的工作更少;对于练习来说,设置工作越少越好。虽然基于精灵的 UI 系统更精致,但对于实际游戏,你可能会想要一个更精致的界面。

9.4.1 在 UI 中显示库存项目

要在 UI 显示中展示项目,你首先需要向 InventoryManager 添加几个额外的方法。目前,项目列表是私有的,只能在管理器内部访问。要显示列表,必须提供公共方法来访问数据。向 InventoryManager 添加以下列表中的两个方法。

列表 9.17 向 InventoryManager 添加数据访问方法

...
public List<string> GetItemList() {                      ❶
   List<string> list = new List<string>(items.Keys);
   return list;
}

public int GetItemCount(string name) {                   ❷
   if (items.ContainsKey(name)) {
      return items[name];
   }
   return 0;
}
...

❶ 返回所有字典键的列表

❷ 返回库存中该项目的数量

GetItemList() 方法返回库存中的项目列表。你可能正在想,“等等,我们不是刚刚花费了大量努力将库存从列表中转换过来吗?”现在的不同之处在于列表中每种项目只会出现一次。例如,如果库存中有两个急救包,那么列表中仍然只会出现一次“健康”这个词。这是因为列表是从字典的键创建的,而不是从每个单独的项目创建的。

GetItemCount() 方法返回给定项目在库存中的数量。例如,调用 GetItemCount("health") 来询问,“库存中有多少个急救包?”这样,UI 可以显示每个项目的数量,同时显示每个项目。

在 InventoryManager 中添加了这些方法后,你可以创建 UI 显示。让我们在屏幕顶部水平显示所有项目。项目将通过图标显示,因此你需要将这些图像导入到项目中。如果这些资产在名为 Resources 的文件夹中,Unity 会以特殊方式处理这些资产。

TIP 将资产放入 Resources 文件夹可以通过使用 Resources.Load() 方法在代码中加载。否则,资产只能通过 Unity 的编辑器放置在场景中。

图 9.8 显示了四个图标图像,以及显示这些图像放置位置的目录结构。创建一个名为 Resources 的文件夹,然后在其中创建一个名为 Icons 的文件夹。

CH09_F08_Hocking3

图 9.8 将设备图标图像资产放置在 Resources 文件夹中的图像

图标都已设置好,因此创建一个新的空 GameObject 命名为 Controller,然后给它分配一个新的脚本名为 BasicUI。

列表 9.18 基本 UI 显示库存

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BasicUI : MonoBehaviour {
   void OnGUI() {
      int posX = 10;
      int posY = 10;
      int width = 100;
      int height = 30;
      int buffer = 10;

      List<string> itemList = Managers.Inventory.GetItemList();
      if (itemList.Count == 0) {                                        ❶
         GUI.Box(new Rect(posX, posY, width, height), "No Items");
      }
      foreach (string item in itemList) {
         int count = Managers.Inventory.GetItemCount(item);
         Texture2D image = Resources.Load<Texture2D>($"Icons/{item}");  ❷
         GUI.Box(new Rect(posX, posY, width, height),
                 new GUIContent($"({count})", image));
         posX += width+buffer;                                          ❸
      }
   }
}

❶ 如果库存为空,显示一条消息。

❷ 方法从 Resources 文件夹加载资产。

❸ 在循环中每次都向侧面移动。

此列表显示收集的项目在水平行中(见图 9.9),同时显示收集的数量。如第三章所述,每个 MonoBehaviour 都会自动响应 OnGUI() 方法。该函数在 3D 场景渲染后立即每帧运行。

CH09_F09_Hocking3

图 9.9 库存 UI 显示

在 OnGUI() 函数内部,首先定义一些用于定位 UI 元素的位置值。当你遍历所有项目时,这些值会增加,以便在行中定位 UI 元素。具体绘制的 UI 元素是 GUI.Box;这些是非交互式显示,在框内显示文本和图像。

Resources.Load() 方法用于从 Resources 文件夹加载资产。这是一种通过名称加载资产的好方法;注意,项目的名称作为参数传递。你必须指定要加载的类型。否则,该方法的返回值是一个泛型对象。

UI 显示了已经收集到的物品。现在你可以使用这些物品了。

9.4.2 将钥匙装备用于锁着的门

让我们回顾几个使用库存物品的例子,这样你就可以推广到任何你想要的物品类型。第一个例子涉及装备一个打开门的钥匙。

目前,DeviceTrigger 脚本没有注意到你的物品(因为该脚本是在库存代码之前编写的)。此列表显示了如何调整该脚本。

列表 9.19 在 DeviceTrigger 中要求钥匙

...
public bool requireKey;

void OnTriggerEnter(Collider other) {
   if (requireKey && Managers.Inventory.equippedItem != "key") {
      return;
   }
...

如您所见,所需的所有内容只是一个新的公共变量和查找已装备钥匙的条件。requireKey 布尔值在检查器中作为复选框出现,这样您就可以要求某些触发器提供钥匙,而其他则不要求。OnTriggerEnter() 函数开始处的条件检查 InventoryManager 中的已装备钥匙;这需要您将下一列表中的代码添加到 InventoryManager 中。

列表 9.20 InventoryManager 的装备代码

...
public string equippedItem {get; private set;}
...
public bool EquipItem(string name) {
   if (items.ContainsKey(name) && equippedItem != name) {   ❶
      equippedItem = name;
      Debug.Log($"Equipped {name}");
      return true;
   }

   equippedItem = null;
   Debug.Log("Unequipped");
   return false;
}
...

❶ 检查库存中是否有该物品,并且该物品尚未装备。

在顶部,添加一个由其他代码检查的 equippedItem 属性。然后添加公共 EquipItem() 方法,允许其他代码更改装备的物品。如果该物品尚未装备,则该方法会装备该物品,如果该物品已装备,则 解除装备

最后,为了让玩家能够装备物品,将此功能添加到 UI 中。此列表为此目的添加了一行按钮。

列表 9.21 向 BasicUI 添加装备功能

...
      *foreach (string item in itemList) {*                      ❶
         ...
         *posX += width+buffer;
      }*

      string equipped = Managers.Inventory.equippedItem;
      if (equipped != null) {                                  ❷
        posX = Screen.width - (width+buffer);
        Texture2D image = Resources.Load($"Icons/{equipped}") as Texture2D;
        GUI.Box(new Rect(posX, posY, width, height),
                new GUIContent("Equipped", image));
      }

      posX = 10;
      posY += height+buffer;

      foreach (string item in itemList) {                      ❸
         if (GUI.Button(new Rect(posX, posY, width, height),
                  $"Equip {item}")) {                          ❹
            Managers.Inventory.EquipItem(item);
         }
         posX += width+buffer;
      }
   }
}

❶ 已在脚本中 italicized 的代码,此处仅供参考。

❷ 显示当前装备的物品。

❸ 遍历所有物品以创建按钮。

❹ 如果按钮被点击,则运行包含的代码。

GUI.Box() 再次用于显示装备的物品。但该元素是不可交互的,因此使用 GUI.Button() 绘制装备按钮行。该方法创建一个按钮,当点击时执行 if 语句内的代码。

在所有必要的代码就绪后,在 DeviceTrigger 中选择 requireKey 选项,然后玩游戏。尝试在装备钥匙之前进入触发体积;没有任何事情发生。现在收集一个钥匙并点击按钮来装备它。进入触发体积会打开门。

为了好玩,你可以在位置 -11, 5, -14 放一个钥匙,以添加一个简单的游戏挑战,看看你是否能想出如何到达钥匙。无论你是否尝试这样做,让我们继续使用健康包。

9.4.3 通过消耗健康包恢复玩家的生命值

使用物品恢复玩家生命值是另一个普遍有用的例子。这需要两个代码更改:InventoryManager 中的一个新方法和一个 UI 中的新按钮(分别见列表 9.22 和 9.23)。

列表 9.22 InventoryManager 中的新方法

...
public bool ConsumeItem(string name) {
   if (items.ContainsKey(name)) {         ❶
      items[name]--;
      if (items[name] == 0) {             ❷
         items.Remove(name);

      }
   } else {                               ❸
      Debug.Log($"Cannot consume {name}");
      return false;
   }

   DisplayItems();
   return true;
}
...

❶ 检查物品是否在库存中。

❷ 如果计数变为 0,则删除条目。

❸ 如果该物品不在库存中时的响应

列表 9.23 向 BasicUI 添加健康物品

...
      *foreach (string item in itemList) {*                              ❶
         *if (GUI.Button(new Rect(posX, posY, width, height),
                  $"Equip {item}")) {
            Managers.Inventory.EquipItem(item);
         }*

         if (item == "health") {                                       ❷
            if (GUI.Button(new Rect(posX, posY + height+buffer, width,
                        height), "Use Health")) {                      ❸
               Managers.Inventory.ConsumeItem("health");
               Managers.Player.ChangeHealth(25);
            }
         }

         posX += width+buffer;
      }
   }
}

❶ 斜体代码已经在脚本中,这里展示以供参考。

❷ 新代码的开始

❸ 如果按钮被点击,则运行包含的代码。

新的 ConsumeItem() 方法基本上是 AddItem() 的逆操作。它会检查库存中的物品,如果找到该物品,则递减。它对一些棘手的情况有响应,例如如果物品计数递减到 0。UI 代码调用这个新的库存方法,并调用 PlayerManager 从一开始就有的 ChangeHealth() 方法。

如果你收集一些健康物品并使用它们,你会在控制台中看到健康信息出现。就这样——展示了如何使用库存物品的多个示例!

摘要

  • 可以使用按键和碰撞触发器来操作设备。

  • 启用了物理的对象可以响应碰撞力或触发体积。

  • 复杂的游戏状态通过可以全局访问的特殊对象进行管理。

  • 对象集合可以组织在列表或字典数据结构中。

  • 跟踪物品的装备状态可以用来影响游戏的其它部分。

第三部分 强有力的结尾

你现在对 Unity 已经相当了解了。你知道如何编程玩家的控制,创建四处游荡的敌人,以及向游戏中添加交互式设备。你甚至知道如何使用 2D 和 3D 图形来构建游戏!这几乎是你需要知道的所有内容来开发一个完整游戏,但还不是全部。你仍然需要学习一些最后的任务,比如在游戏中添加音频,以及你需要了解如何将我们一直在使用的所有不同部件组合在一起。这是最后的冲刺阶段,只剩下四章了!

10 将你的游戏连接到互联网

本章涵盖了

  • 为天空生成动态视觉效果

  • 使用协程进行网络请求下载数据

  • 解析常见的数据格式,如 XML 和 JSON

  • 显示从互联网下载的图片

  • 向 Web 服务器发送数据

在本章中,你将学习如何通过网络发送和接收数据。前几章中构建的项目代表了各种游戏类型,但所有这些都局限于玩家的机器上。对于所有类型的游戏来说,连接到互联网并交换数据变得越来越重要。

许多游戏几乎完全在互联网上进行,与玩家社区的持续连接;这类游戏被称为大型多人在线(MMO),最广为人知的是 MMO 角色扮演游戏(MMORPG)。即使游戏不需要这种持续的连接,现代视频游戏通常也包含将分数报告给全球高分榜或记录分析以帮助改进游戏的功能。Unity 提供了对这种网络的支持,因此我们将介绍这些功能。

Unity 支持多种网络通信方法,因为不同的方法更适合不同的需求。本章涵盖了最通用的互联网通信方式:发出 HTTP 请求。

什么是 HTTP 请求?

我假设大多数读者都知道什么是 HTTP 请求,但这里有一个快速入门,以防万一:超文本传输协议(HTTP)是一种用于向 Web 服务器发送请求并接收响应的通信协议。当你点击网页上的链接时,你的浏览器(客户端)会向特定地址发送请求,然后该服务器会响应新的页面。HTTP 请求可以设置为各种方法,特别是 GET 或 POST,以检索或发送数据。

HTTP 请求是可靠的,这也是为什么互联网的大部分内容都是围绕它们构建的。请求本身以及处理此类请求的基础设施都设计得非常健壮,能够处理网络中广泛的各种故障。

作为一种良好的比较,想象一下现代单页 Web 应用是如何工作的(与基于服务器端生成网页的老式 Web 开发相对)。在一个围绕 HTTP 请求构建的在线游戏中,Unity 中开发的项目本质上是一个厚客户端,以 Ajax 风格与服务器通信。然而,这种方法的熟悉性可能会误导经验丰富的 Web 开发者。视频游戏通常比 Web 应用有更严格的表现要求,这些差异可能会影响设计决策。

警告:Web 应用和视频游戏之间的时间尺度可能差异很大。对于更新网站来说,半秒钟可能看起来很短,但在高强度的动作游戏中,暂停哪怕是一小部分时间都可能让人难以忍受。的概念绝对与情境相关。

在线游戏通常连接到专门为该游戏设计的服务器。然而,出于学习目的,我们将连接到一些免费可用的互联网数据源,包括可以下载的天气数据和图像。本章的最后部分要求你设置一个自定义的 Web 服务器;由于这个要求,该部分是可选的,尽管我会解释一种使用开源软件的简单方法来做这件事。

本章的计划是回顾 HTTP 请求的多种用途,以便你可以在 Unity 中了解它们是如何工作的:

  • 设置户外场景(特别是,构建可以响应天气数据的天空)

  • 编写代码从互联网请求天气数据

  • 解析响应并根据数据修改场景

  • 从互联网下载并显示图像

  • 向你的服务器(在这种情况下,是天气状况日志)发送数据

你在本章项目中使用的实际游戏关系不大。本章中的一切都将向现有项目添加新的脚本,而不会修改任何现有代码。对于示例代码,我使用了第二章中的移动演示,主要是为了在修改后能够以第一人称视角看到天空。

本章的项目与游戏玩法没有直接关联,但显然对于你创建的大多数游戏,你希望网络与游戏玩法相关联(例如,根据服务器的响应生成敌人)。接下来是第一步!

10.1 创建户外场景

由于我们将下载天气数据,我们首先设置一个户外区域,在那里天气将可见。其中最棘手的部分将是天空,但首先让我们花点时间将看起来像户外的纹理应用到关卡几何形状上。

就像在第四章中一样,我从www.textures.com获取了一些图像,用于应用到关卡墙壁和地板上。记住将下载的图像的大小更改为 2 的幂,例如 256 × 256。

然后将图像导入 Unity 项目,创建材质,并将图像分配给材质(即,将一个图像拖动到材质的纹理槽中)。将材质拖放到场景中的墙壁或地板上,并在材质中增加平铺(尝试一个或两个方向上的数字,如 8 或 9),这样图像就不会以难看的方式拉伸。一旦地面和墙壁处理完毕,就轮到处理天空了。

10.1.1 通过使用天空盒生成天空视觉效果

首先,像第四章中那样导入天空盒图像。再次,我从www.93i.de/获取了天空盒图像,但这次我除了 TropicalSunnyDay(在这个项目中天空将更加复杂)之外,还得到了 DarkStormy。只需从书籍的示例项目中获取它们或从其他地方下载天空盒图像。将这些纹理导入 Unity,并(如第四章中所述)将它们的 Wrap Mode 设置为 Clamp。

现在创建一个新的材质来用于这个天空盒。在这个材质的设置顶部,点击着色器菜单以查看所有可用着色器的下拉列表。向下移动到天空盒部分,并在子菜单中选择 6-Sided。使用这个着色器后,材质现在有六个纹理槽(而不是标准着色器中只有的小 Albedo 纹理槽)。

将 SunnyDay 天空盒图像拖动到新材质的纹理槽中。图像的名称对应于分配给它们的纹理槽(顶部、前面等)。一旦所有六个纹理都链接好了,你就可以使用这个新材质作为场景的天空盒。

通过打开照明窗口(窗口 > 渲染 > 照明)来分配这个天空盒材质。切换到环境选项卡,并将你的天空盒材质分配到窗口顶部的天空盒槽中(可以拖动材质到槽中,或者点击槽旁边的圆形按钮)。点击播放,你应该能看到类似于图 10.1 的效果。

CH10_F01_Hocking3

图 10.1 带有天空背景图片的场景

太好了,现在你有一个户外场景了!天空盒是一种优雅的方式来创建围绕玩家的广阔氛围的幻觉。但 Unity 内置的天空盒着色器确实有一个显著的限制:图像永远不会改变,导致天空看起来完全静止。我们将通过创建一个新的自定义着色器来解决这个问题。

10.1.2 通过代码设置受控的大气环境

TropicalSunnyDay 集中的图像在晴天看起来很棒,但如果我们想从晴天过渡到多云天气呢?这将需要一个包含多云天空图片的第二组天空图像,因此我们需要一个新的天空盒着色器。

正如第四章所述,着色器是一个包含如何渲染图像的指令的简短程序。这意味着你可以编写新的着色器,实际上也是如此。我们将创建一个新的着色器,它接受两组天空盒图像并在它们之间进行转换。为此目的从github.com/jhocking/from-unity-wiki/blob/main/SkyboxBlended.shader获取着色器。

在 Unity 中创建一个新的着色器脚本:就像创建一个新的 C#脚本一样,但选择标准表面着色器。将资产命名为 SkyboxBlended,然后双击着色器以打开脚本。从该网页复制代码并将其粘贴到着色器脚本中。顶行是 Shader "Skybox/Blended",这告诉 Unity 将新的着色器添加到 Skybox 类别下的着色器列表中(与常规天空盒相同的类别)。

注意:我们现在不会详细讲解着色器程序的所有细节。着色器编程是一个相当高级的计算机图形学话题,因此超出了本书的范围。您可以在阅读完本书后查找相关信息;如果是这样,可以从 Unity 手册开始,网址为mng.bz/wQzQ

现在您可以设置材质为 Skybox Blended 着色器。再次,选择材质,然后在材质设置的最顶部查找着色器菜单。现在有 12 个纹理槽位,分为两组,每组六张图像。与前一样,将 TropicalSunnyDay 图像分配给前六个纹理;对于剩余的纹理,使用 DarkStormy 天空盒图像集。

这个新的着色器还在设置的最顶部添加了一个混合滑块。混合值控制您想要显示每套天空盒图像的多少;当您调整滑块从一侧到另一侧时,天空盒会从晴朗过渡到多云。您可以通过调整滑块并玩游戏来测试,但在游戏运行时手动调整天空并不十分有帮助,所以让我们编写代码来过渡天空。

在场景中创建一个空对象并命名为 Controller。创建一个新的脚本并命名为 WeatherController。将此脚本拖放到空对象上,然后在脚本中编写以下代码。

列表 10.1 WeatherController 脚本从晴朗过渡到多云

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WeatherController : MonoBehaviour {
   [SerializeField] Material sky;                  ❶
   [SerializeField] Light sun;

   private float fullIntensity;

   private float cloudValue = 0f;

   void Start() {
      fullIntensity = sun.intensity;               ❷
   }

   void Update() {
      SetOvercast(cloudValue);
      cloudValue += .005f;                         ❸
   }

   private void SetOvercast(float value) {         ❹
      sky.SetFloat("_Blend", value);
      sun.intensity = fullIntensity - (fullIntensity * value);
   }
}

❶ 在项目视图中参考材质,而不仅仅是场景中的对象。

❷ 灯光的初始强度被认为是“全强度”。

❸ 每帧增加值以实现连续过渡。

❹ 调整材质的混合值和灯光的强度。

我会在代码中指出来几个要点,但关键的新方法是 SetFloat(),它几乎出现在底部。到那时为止的所有内容都应该相当熟悉,但那一个是新的。该方法在材质上设置一个数值。该方法的第一参数定义了具体是哪个值。在这种情况下,材质有一个名为 _Blend(注意代码中的材质属性以下划线开头)的属性。

至于其余的代码,我们定义了一些变量,包括材质和灯光。对于材质,您想要引用我们刚刚创建的混合天空盒材质,但灯光呢?这是为了让场景在从晴朗变为多云时也会变暗;随着混合值的增加,我们将降低灯光。场景中的方向光作为主光,为各个地方提供照明。将材质和灯光拖放到检查器中的变量上。

备注:Unity 的高级照明系统会考虑天空盒以实现逼真的效果。但这种方法与不断变化的天空盒不兼容,因此你可能想要冻结照明设置。在照明窗口中,你可以关闭底部的自动生成复选框;然后设置将仅在点击按钮时更新。将天空盒的混合设置为中间以获得平均外观,然后点击生成按钮(位于自动复选框旁边)以手动烘焙光照贴图(光照信息已保存到一个以场景命名的新文件夹中)。

当脚本开始运行时,它会初始化光线的强度。脚本将存储起始值,并将其视为全强度。这个全强度将在脚本中稍后用于调暗光线。

然后代码在每一帧增加一个值,并使用该值来调整天空。具体来说,它每帧调用一次 SetOvercast(),该函数封装了对场景进行的多次调整。我已经解释了 SetFloat() 的作用,所以我们将不再重复,最后一行调整了光线的强度。

现在播放场景以观看代码的运行。你会看到图 10.2 中的描述:在几秒钟内,你会看到场景从晴朗的一天过渡到黑暗和多云。

CH10_F02_Hocking3

图 10.2:场景从晴朗到多云的过渡前后对比

警告:Unity 的一个意想不到的怪癖是,材质上的混合变化是永久的。当游戏停止运行时,Unity 会重置场景中的对象,但直接从项目视图链接的资产(如天空盒材质)会永久更改。这仅在 Unity 的编辑器内发生(更改不会在游戏部署到编辑器外后传递),因此如果你忘记这一点,可能会导致令人沮丧的错误。

观看场景从晴朗变为多云的过渡非常酷。但这只是为实际目标所做的设置:让游戏中的天气与真实世界的天气条件同步。为此,我们需要开始从互联网下载天气数据。

10.2 从互联网服务下载天气数据

现在我们已经设置了户外场景,我们可以编写代码来下载天气数据并根据这些数据修改场景。这个任务将很好地展示如何通过使用 HTTP 请求来检索数据。许多网络服务提供天气数据;一个详尽的列表发布在 ProgrammableWeb (www.programmableweb.com)。我选择了 OpenWeather;代码示例使用了其 API(应用程序编程接口,一种使用代码命令而不是图形界面来访问其服务的方式),API 位于 openweathermap.org/api

定义:网络服务,或网络 API,是指连接到互联网并在请求时返回数据的服务器。网络 API 和网站之间没有技术上的区别;网站是一个返回网页数据的网络服务,浏览器将 HTML 数据解释为可见文档。

注意:网络服务通常需要您注册,即使是免费服务。例如,如果您访问 OpenWeather 的 API 页面,它有获取 API 密钥的说明,这是一个您将粘贴到请求中的值。

您将要编写的代码将围绕第九章中相同的 Managers 架构。这次,您将有一个从管理者的中心管理者初始化的 WeatherManager 类。WeatherManager 将负责检索和存储天气数据,但要做到这一点,它需要能够与互联网通信。

为了实现这一点,您将创建一个名为 NetworkService 的实用程序类来处理连接到互联网和制作 HTTP 请求的细节。然后,WeatherManager 可以告诉 NetworkService 进行这些请求并返回响应。图 10.3 显示了这种代码结构将如何运行。

CH10_F03_Hocking3

图 10.3 网络代码的结构

为了实现这一点,显然 WeatherManager 将需要访问 NetworkService 对象。您将通过在 Managers 中创建对象并在初始化时将 NetworkService 对象注入到各个管理者来解决此问题。这样,不仅 WeatherManager 将拥有对 NetworkService 的引用,您以后创建的任何其他管理者也将拥有。

要从第九章开始引入 Managers 的代码架构,首先复制 ManagerStatus 和 IGameManager(记住 IGameManager 是所有管理者必须实现的接口,而 ManagerStatus 是 IGameManager 使用的枚举)。您需要稍微修改 IGameManager 以适应新的 NetworkService 类,因此创建一个新的脚本名为 NetworkService(删除 :MonoBehaviour 并暂时将其留空;您稍后会填写它)然后调整 IGameManager。

列表 10.2 调整 IGameManager 以包含 NetworkService

public interface IGameManager {
   ManagerStatus status {get;}

   void Startup(NetworkService service);     ❶
}

❶ 启动函数现在接受一个参数:注入的对象。

接下来,让我们创建 WeatherManager 来实现这个稍微调整过的接口。创建一个新的 C# 脚本。

列表 10.3 WeatherManager 的初始脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WeatherManager : MonoBehaviour, IGameManager {
   public ManagerStatus status {get; private set;}

   // Add cloud value here (listing 10.8)
   private NetworkService network;

   public void Startup(NetworkService service) {
      Debug.Log("Weather manager starting...");

      network = service;       ❶

      status = ManagerStatus.Started;
   }
}

❶ 存储注入的 NetworkService 对象。

这个 WeatherManager 的初始版本实际上并没有做任何事情。目前,该类实现了 IGameManager 所需的最小量:从接口声明状态属性并实现 Startup() 函数。您将在接下来的几节中填充这个空框架。最后,复制第九章的 Managers 并调整它以启动 WeatherManager。

列表 10.4 调整后的 Managers 以初始化 WeatherManager

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(WeatherManager))]                  ❶

public class Managers : MonoBehaviour {
   public static WeatherManager Weather {get; private set;}

   private List<IGameManager> startSequence;

   void Awake() {
      Weather = GetComponent<WeatherManager>();

      startSequence = new List<IGameManager>();
      startSequence.Add(Weather);

      StartCoroutine(StartupManagers());
   }

   private IEnumerator StartupManagers() {
      NetworkService network = new NetworkService();        ❷

      foreach (IGameManager manager in startSequence) {
         manager.Startup(network);                          ❸
      }

      yield return null;

      int numModules = startSequence.Count;
      int numReady = 0;

      while (numReady < numModules) {
         int lastReady = numReady;
         numReady = 0;

         foreach (IGameManager manager in startSequence) {
            if (manager.status == ManagerStatus.Started) {
               numReady++;
            }
         }

         if (numReady > lastReady)
            Debug.Log($"Progress: {numReady}/{numModules}");

         yield return null;
      }

      Debug.Log("All managers started up");
   }
}

❶ 需要新的管理者而不是玩家和库存。

❷ 实例化 NetworkService 以注入所有管理器。

❸ 在启动时将网络服务传递给管理器。

这就是 Manager 代码架构所需的所有代码。正如你在前面的章节中所做的那样,在场景中创建游戏管理器对象,然后将 Manager 和 WeatherManager 附加到空对象上。即使管理器目前还没有做任何事情,你可以在设置正确时在控制台中看到启动消息。

哇,我们有很多样板代码要处理!现在我们可以继续编写网络代码。

10.2.1 使用协程请求 HTTP 数据

NetworkService 目前是一个空脚本,所以你可以在其中编写代码来制作 HTTP 请求。你需要了解的主要类是 UnityWebRequest。Unity 提供了 UnityWebRequest 类来与互联网通信。使用 URL 实例化请求对象将向该 URL 发送请求。

协程可以与 UnityWebRequest 类一起工作,等待请求完成。协程在第三章中介绍,我们使用它们来暂停代码一段时间。回想一下那里的解释:协程是似乎在程序后台运行的特殊函数,在运行部分并返回到程序其余部分的循环中。当与 StartCoroutine()方法一起使用时,yield 关键字使协程暂时暂停,将程序流程交回,并在下一帧从该点继续。

在第三章中,WaitForSeconds()协程产生的 yield,这是一个使函数暂停特定秒数的对象。在发送请求时产生协程将暂停函数,直到网络请求完成。这里的程序流程类似于在 Web 应用程序中制作异步 Ajax 调用:首先发送请求,然后继续执行程序的其余部分,过一段时间后收到响应。

那是理论部分;现在让我们编写代码

好的,让我们在我们的代码中实现这些功能。首先打开 NetworkService 脚本,并将默认模板替换为这个列表的内容。

列表 10.5 在 NetworkService 中制作 HTTP 请求

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

public class NetworkService {
   private const string xmlApi =                                             ❶
"http://api.openweathermap.org/data/2.5/weather?q=Chicago,
➥ us&mode=xml&appid=APIKEY";

   private IEnumerator CallAPI(string url, Action<string> callback) {
      using (UnityWebRequest request = UnityWebRequest.Get(url)) {           ❷

         yield return request.SendWebRequest();                              ❸

         if (request.result == UnityWebRequest.Result.ConnectionError) {     ❹
            Debug.LogError($"network problem: {request.error}");             ❹
         } else if (request.result == UnityWebRequest.Result.ProtocolError) {❹
            Debug.LogError($"response error: {request.responseCode}");
         } else {
            callback(request.downloadHandler.text);                          ❺
         }
      }
   }

   public IEnumerator GetWeatherXML(Action<string> callback) {
      return CallAPI(xmlApi, callback);                                      ❻
   }
}

❶ 发送请求的 URL

❷ 在 GET 模式下创建 UnityWebRequest 对象。

❸ 下载时暂停。

❹ 检查响应中的错误。

❺ 可以像原始函数一样调用委托。

❻ 通过相互调用的协程方法产生级联。

警告:Action 类型(在“理解回调如何工作”中解释)包含在 System 命名空间中;注意脚本顶部的附加 using 语句。不要忘记这个细节!

记住之前解释的代码设计:WeatherManager 将告诉 NetworkService 去获取数据。所有这些代码实际上还没有运行;你正在设置稍后将被 WeatherManager 调用的代码。为了探索这个代码列表,让我们从底部开始,逐步向上。

编写相互级联的协程方法

GetWeatherXML() 是一个协程方法,外部代码可以使用它来告诉 NetworkService 发起一个 HTTP 请求。请注意,这个函数的返回类型是 IEnumerator;在协程中使用的函数必须声明为 IEnumerator 类型。

起初,GetWeatherXML() 没有使用 yield 语句可能看起来有些奇怪。协程通过 yield 语句暂停,这意味着每个协程必须在某个地方产生。结果是,yield 可以通过多个方法级联。如果初始的协程方法本身调用了另一个方法,并且那个方法在中间部分产生了,那么协程将在那个第二个方法内部暂停,并在那里恢复。因此,CallAPI() 中的 yield 语句暂停了在 GetWeatherXML() 中启动的协程;图 10.4 展示了这段代码的流程。

CH10_F04_Hocking3

图 10.4 网络协程的工作方式

下一个可能让人头疼的是 Action 类型的回调参数。

理解回调的工作原理

当协程启动时,方法会带有一个名为 callback 的参数被调用,并且 callback 是 Action 类型。但什么是 Action?

定义 Action 类型是一个委托(C# 有几种处理委托的方法,但这种方法是最简单的)。委托是对其他方法/函数的引用。它们允许你将函数(或者更确切地说,函数的指针)存储在变量中,并将该函数作为参数传递给另一个函数。

如果你不太熟悉委托的概念,请意识到它们允许你传递函数,就像传递数字和字符串一样。没有委托,你不能传递函数以供稍后调用——你只能立即直接调用函数。有了委托,你可以告诉代码稍后要调用的其他方法。这对于许多用途都很有用,特别是实现回调函数。

定义 回调 是一个用于向调用对象返回信息的函数。对象 A 可以告诉对象 B 关于 A 中的一个方法。B 可以稍后调用 A 的方法以将信息返回给 A。

例如,在这种情况下,回调用于在等待 HTTP 请求完成后将响应数据返回。在 CallAPI() 中,代码首先发起一个 HTTP 请求,然后等待直到请求完成,最后使用 callback() 发送响应。

注意 Action 关键字使用的 <> 语法;尖括号中写出的类型声明了符合此 Action 所需的参数。换句话说,这个 Action 所指向的函数必须接受与声明类型匹配的参数。在这种情况下,参数是一个字符串,因此回调方法必须具有如下签名:

MethodName(string value)

在看到实际操作之后,回调的概念可能更容易理解,你将在列表 10.6 中看到它;这个初始解释是为了让你在看到额外的代码时能识别出正在发生的事情。

列表 10.5 的其余部分相当直接。请求对象在 using 语句内部创建,以便在完成该对象后清理对象的内存。条件检查 HTTP 响应中的错误。有两种类型的错误:请求可能因为网络连接不良而失败,或者返回的响应可能包含错误代码。声明了一个具有请求 URL 的 const 值。(顺便说一句,你应该在末尾替换 APIKEY 为你的 OpenWeather API 密钥。)

利用网络代码

这就完成了 NetworkService 中的代码。现在让我们在 WeatherManager 中使用 NetworkService。

列表 10.6 调整 WeatherManager 以使用 NetworkService

...
public void Startup(NetworkService service) {
   Debug.Log("Weather manager starting...");

   network = service;
   StartCoroutine(network.GetWeatherXML(OnXMLDataLoaded));  ❶

   status = ManagerStatus.Initializing;                     ❷
}

public void OnXMLDataLoaded(string data) {                  ❸
   Debug.Log(data);

   status = ManagerStatus.Started;
}
...

❶ 开始从互联网加载数据。

❷ 将状态从“Started”改为“Initializing”。

❸ 数据加载后的回调方法

在这个管理器中对代码进行了三项主要更改:启动一个协程从互联网下载数据,设置不同的启动状态,并定义一个回调方法以接收响应。

启动协程很简单。协程背后的大多数复杂性都由 NetworkService 处理,所以在这里你只需要调用 StartCoroutine()即可。然后你设置一个不同的启动状态,因为管理器还没有完成初始化;在启动完成之前,它需要从互联网接收数据。

警告:始终使用 StartCoroutine()启动网络方法;不要正常调用函数。这很容易忘记,因为在外部协程中创建请求对象不会生成任何编译器错误。

当你调用 StartCoroutine()方法时,你需要调用作为参数使用的方法。也就是说,实际上输入括号—()—,而不仅仅是函数名。在这种情况下,协程方法需要一个回调函数作为其一个参数,所以让我们定义这个函数。我们将使用 OnXMLDataLoaded()作为回调;注意,这个方法有一个字符串参数,这与 NetworkService 中的 Action声明相匹配。回调函数目前并不做很多事情;调试行只是将接收到的数据打印到控制台以验证数据是否正确接收。然后函数的最后一行将管理器的启动状态改为表示它已经完全启动。

点击播放以运行代码。假设你有稳定的互联网连接,你应该会在控制台看到一些数据出现。这些数据只是一个长字符串,但字符串是以一种我们可以利用的特定方式格式化的。

10.2.2 解析 XML

存在为长字符串的数据通常包含在字符串中嵌入的信息片段。你通过解析字符串来提取这些信息片段。

定义 解析 意味着分析一块数据并将其划分为单独的信息部分。

为了解析字符串,它需要以允许你(或者更确切地说,解析器代码)识别单独部分的方式格式化。在互联网上传输数据时,常用几种标准格式;最常见的一种标准格式是 XML

定义 XML 代表 可扩展标记语言。它是一套用于以结构化方式编码文档的规则,类似于 HTML 网页。

幸运的是,Unity(或者更确切地说,Unity 中内置的代码框架 Mono)提供了解析 XML 的功能。我们请求的天气数据格式为 XML,因此我们将在 WeatherManager 中添加代码来解析响应并提取云量。将 URL 输入到网页浏览器中查看响应数据;那里有很多内容,但我们只对包含类似的节点感兴趣。

除了添加解析 XML 的代码外,我们还将使用第七章中使用的相同信使系统。这是因为一旦下载并解析了天气数据,我们仍然需要通知场景。创建一个名为 Messenger 的脚本,并将github.com/jhocking/from-unity-wiki/blob/main/Messenger.cs中的代码粘贴进去。

然后你需要创建一个名为 GameEvent 的脚本。如第七章所述,这个信使系统非常适合以解耦的方式将事件传达给程序的其余部分。

列表 10.7 GameEvent 代码

public static class GameEvent {
   public const string WEATHER_UPDATED = "WEATHER_UPDATED";
}

一旦信使系统就位,调整 WeatherManager。

列表 10.8 在 WeatherManager 中解析 XML

using System;
using System.Xml;                                      ❶
...
public float cloudValue {get; private set;}            ❷
...
public void OnXMLDataLoaded(string data) {
   XmlDocument doc = new XmlDocument();
   doc.LoadXml(data);                                  ❸
   XmlNode root = doc.DocumentElement;

   XmlNode node = root.SelectSingleNode("clouds");     ❹
   string value = node.Attributes["value"].Value;
   cloudValue = Convert.ToInt32(value) / 100f;         ❺
   Debug.Log($"Value: {cloudValue}");

   Messenger.Broadcast(GameEvent.WEATHER_UPDATED);     ❻

   status = ManagerStatus.Started;
}
...

❶ 确保添加所需的 using 语句。

❷ 云量在内部修改,但在其他地方只读。

❸ 将 XML 解析为可搜索的结构。

❹ 从数据中提取单个节点。

❺ 将值转换为 0-1 浮点数。

❻ 向其他脚本广播消息。

你可以看到,最重要的更改是在 OnXMLDataLoaded()内部进行的。之前,该方法只是将数据记录到控制台以验证数据是否正确通过。这个列表添加了大量代码来解析 XML。

首先创建一个新的空 XML 文档;这是一个空容器,你可以用解析的 XML 结构填充它。下一行将数据字符串解析为 XML 文档包含的结构。然后我们从 XML 树的根开始,以便在后续代码中一切都可以向上搜索树。

在这一点上,你可以在 XML 结构中搜索节点以提取个别信息。在这种情况下, 是我们唯一感兴趣的节点。在 XML 文档中找到该节点,然后从该节点中提取值属性。这些数据定义了云值为 0-100 的整数,但我们需要它作为 0-1 的浮点数,以便稍后调整场景。将此转换为代码中添加的简单数学运算。

最后,在从完整数据中提取出云量值之后,广播一条消息,表明天气数据已更新。目前,没有任何东西在监听这条消息,但广播者不需要了解任何关于监听者的信息(实际上,这就是解耦消息系统的全部意义)。稍后,我们将向场景中添加一个监听器。

太好了——我们已经编写了解析 XML 数据的代码!但在我们将此值应用于可见场景之前,我想介绍一下另一种数据传输的选项。

10.2.3 解析 JSON

在继续进行项目中的下一步之前,让我们探索一种用于传输数据的替代格式。XML 是互联网上传输数据的一种常见格式;另一种常见的是 JSON

定义 JSON 代表 JavaScript 对象表示法。与 XML 的目的相似,JSON 被设计成一种轻量级的替代方案。尽管 JSON 的语法最初来源于 JavaScript,但该格式并非特定于任何一种语言,并且可以轻松地与各种编程语言一起使用。

与 XML 不同,Mono 并未附带该格式的解析器。幸运的是,有大量的优秀 JSON 解析器可供选择。Unity 本身提供了一个 JsonUtility 类,而外部开发的选项包括 Newtonsoft 的 Json.NET。我通常在我的游戏中使用 Json.NET,因为 Newtonsoft 的库在 Unity 之外的全 .NET 生态系统中被广泛使用。它可以使用 Unity 的新包管理器系统安装,这也是它在示例项目中安装的方式。

警告 Json.NET 实际上已经被 Unity 打包多次,本书使用的是 jilleJr 的包。然而,最近 Unity 将 Json.NET 打包为 com.unity.nuget.newtonsoft-json,并将其用作其他包的依赖项。因此,如果您安装了那些其他包之一(例如版本控制),那么您项目中已经包含了 Json.NET,再次尝试安装 Json.NET 将会导致错误。最简单的方法是在项目视图中展开(位于 Assets 下方)的 Packages 文件夹,并查找 Newtonsoft Json。

mng.bz/7l4y 的 GitHub 页面上有多个部分介绍如何安装,其中“通过纯 UPM 安装”解释了我们需要的步骤。正如在第一章中提到的,Unity 包管理器(UPM)与 Unity 自身制作的包一起使用最为简便。然而,UPM 正在越来越多地被外部包作者支持;例如,在第四章中提到的 glTF 包就是以这种方式安装的。虽然由 Unity 制作的包列在包管理器窗口中,并可以从那里选择,但外部创建的包需要通过调整清单文本文件来安装。

如 GitHub 页面所述,导航到您计算机上的 Unity 项目文件夹,打开其中的 Packages 文件夹,然后以任何文本编辑器打开 manifest.json。GitHub 上的安装文档列出了要粘贴到包清单中的所有文本,所以这样做。安装包始终涉及在依赖项块中添加条目;此外,一些包(例如,这个 JSON 库)还将具有作用域注册表供您添加。返回 Unity,新包的下载将需要一段时间。

现在,您可以使用这个库来解析 JSON 数据。我们一直从 OpenWeather API 获取 XML 数据,但正如所发生的那样,OpenWeather 还可以以 JSON 格式发送相同的数据。为此,修改 NetworkService 以请求 JSON。

列表 10.9 将 NetworkService 请求从 XML 更改为 JSON

...
private const string jsonApi =       ❶
"http://api.openweathermap.org/data/2.5/weather?q=Chicago,us&appid=APIKEY";
...
public IEnumerator GetWeatherJSON(Action<string> callback) {
    return CallAPI(jsonApi, callback);
}
...

❶ 这次 URL 略有不同。

这基本上与下载 XML 数据的代码相同,只是 URL 略有不同。此请求返回的数据具有相同的值,但格式不同。这次我们正在寻找一个类似"clouds":{"all":40}的块。

这次不需要太多额外的代码。那是因为我们为请求设置了代码,将其分解为单独的函数,所以每个后续的 HTTP 请求都很容易添加。太棒了!现在让我们修改 WeatherManager 以请求 JSON 数据而不是 XML。

列表 10.10 修改 WeatherManager 以请求 JSON

...
using Newtonsoft.Json.Linq;                                   ❶
...
public void Startup(NetworkService service) {
   Debug.Log("Weather manager starting...");

   network = service;
   StartCoroutine(network.GetWeatherJSON(OnJSONDataLoaded));  ❷

   status = ManagerStatus.Initializing;
}
...
public void OnJSONDataLoaded(string data) {
   JObject root = JObject.Parse(data);                        ❸

   JToken clouds = root["clouds"];                            ❹
   cloudValue = (float)clouds["all"] / 100f;                  ❹
   Debug.Log($"Value: {cloudValue}");                         ❹

   Messenger.Broadcast(GameEvent.WEATHER_UPDATED);            ❹

   status = ManagerStatus.Started;
}
...

❶ 一定要添加所需的 using 语句。

❷ 网络请求已更改

❸ 而不是 XML 容器,解析为 JSON 对象。

❹ 语法已更改,但此代码仍在执行相同的事情。

如您所见,处理 JSON 的代码与处理 XML 的代码类似。唯一的真正区别是数据被解析为 JSON 对象而不是 XML 文档容器。

注意 Json.NET 提供了多种解析数据的方法,这里使用的替代方法被称为 JSON Linq。这种替代方法不需要太多的设置,这对于像这样的小示例来说很方便。然而,主要方法需要首先创建一个新的类,其字段与 JSON 数据的结构相对应。然后使用命令 JsonConvert.DeserializeObject 通过填充这个类来使用数据。

定义 反序列化 几乎与 解析 具有相同的意思,只是暗示从数据中创建了一个代码对象。这是 序列化 的逆过程,意味着将代码对象编码成可以传输和存储的形式,例如 JSON 字符串。

除了不同的语法外,所有步骤都是相同的。从数据块中提取值(由于某种原因,这个值一直被称为 all,但这只是 API 的一个怪癖),进行一些简单的数学运算将值转换为 0-1 浮点数,并广播一个更新消息。完成这些后,就是将值应用到可见场景中的时候了。

10.2.4 基于天气数据影响场景

无论数据格式如何,一旦从响应数据中提取出云量值,我们就可以在 WeatherController 的 SetOvercast()方法中使用该值。无论是 XML 还是 JSON,数据字符串最终都会解析成一系列单词和数字。SetOvercast()方法接受一个数字作为参数。在第 9.1.2 节中,我们使用了每帧递增的数字,但我们可以同样容易地使用天气 API 返回的数字。以下是修改后的完整 WeatherController 脚本。

列表 10.11 响应下载天气数据的 WeatherController

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WeatherController : MonoBehaviour {
   [SerializeField] Material sky;
   [SerializeField] Light sun;

   private float fullIntensity;

   void OnEnable() {                                                      ❶
      Messenger.AddListener(GameEvent.WEATHER_UPDATED, OnWeatherUpdated);
   }
   void OnDisable() {
      Messenger.RemoveListener(GameEvent.WEATHER_UPDATED, OnWeatherUpdated);
   }

   void Start() {
      fullIntensity = sun.intensity;
   }

   private void OnWeatherUpdated() {
      SetOvercast(Managers.Weather.cloudValue);                           ❷
   }

   private void SetOvercast(float value) {
      sky.SetFloat("_Blend", value);
      sun.intensity = fullIntensity - (fullIntensity * value);
   }
}

❶ 添加/移除事件监听器。

❷ 使用 WeatherManager 中的云量值。

注意,这些变化不仅仅是添加;一些测试代码被移除了。具体来说,我们移除了每帧递增的本地云量值;我们不再需要它,因为我们将从 WeatherManager 使用值。

在 OnEnable()/OnDisable()中添加和移除监听器(这些是当对象开启或关闭时 MonoBehaviour 调用的函数)。这个监听器是广播消息系统的一部分,当接收到该消息时调用 OnWeatherUpdated()。OnWeatherUpdated()从 WeatherManager 获取云量值,并使用该值调用 SetOvercast()。这样,场景的外观就由下载的天气数据控制。

现在运行场景,你会看到天空根据天气数据中的云量进行更新。你可能看到请求天气信息需要花费一些时间;在实际游戏中,你可能希望在天空更新之前,将场景隐藏在加载屏幕后面。

超越 HTTP 的游戏网络

HTTP 请求是健壮和可靠的,但请求和接收响应之间的延迟对于许多游戏来说太慢了。因此,HTTP 请求是向服务器发送相对较慢的消息(如回合制游戏中的移动或任何游戏的得分提交)的好方法,但像多人 FPS 这样的东西需要不同的网络方法。

这些方法涉及各种通信技术,以及补偿延迟的技术。Unity 为多人游戏提供了一个 API,称为 MLAPI,但其他选项还包括 Mirror 或 Photon。

网络动作游戏的尖端技术是一个复杂的话题,超出了本书的范围。你可以自己查找更多信息,从 Unity 多人网络网站开始(docs-multiplayer.unity3d.com/)。

现在你已经知道如何从互联网获取数值和字符串数据,让我们用图像来做同样的事情。

10.3 添加网络广告牌

虽然来自 Web API 的响应几乎总是以 XML 或 JSON 格式化的文本字符串,但互联网上传输的其他类型的数据也很多。除了文本数据外,最常见的数据请求类型是图像。UnityWebRequest 对象也可以用来下载图像。

你将通过创建一个显示从互联网下载的图像的广告牌来学习这个任务。你需要编写两个步骤:下载要显示的图像并将该图像应用于广告牌对象。作为第三个步骤,你将改进代码,以便图像可以存储以用于多个广告牌。

10.3.1 从互联网加载图像

首先,让我们编写下载图像的代码。你将下载一些公共领域的风景摄影(见图 10.5)进行测试。下载的图像在广告牌上尚不可见;我将在下一节中展示一个显示图像的脚本,但在那之前,让我们先放置检索图像的代码。

CH10_F05_Hocking3

图 10.5 加拿大班夫国家公园的 Moraine 湖图像

下载图像的代码架构与下载数据的架构非常相似。一个新的管理模块(称为 ImagesManager)将负责要显示的下载图像。再次强调,连接到互联网和发送 HTTP 请求的细节将由 NetworkService 处理,而 ImagesManager 将调用 NetworkService 为其下载图像。

代码的第一个新增部分是在 NetworkService 中。此列表将图像下载添加到该脚本中。

列表 10.12 在 NetworkService 中下载图像

...
private const string webImage =                                           ❶
"http://upload.wikimedia.org/wikipedia/commons/c/c5/Moraine_Lake_17092005.jpg";
...
public IEnumerator DownloadImage(Action<Texture2D> callback) {            ❷
    UnityWebRequest request = UnityWebRequestTexture.GetTexture(webImage);
    yield return request.SendWebRequest();
    callback(DownloadHandlerTexture.GetContent(request));                 ❸
}
...

❶ 将此 const 放置在顶部附近,与其他 URL 一起。

❷ 此回调接收一个 Texture2D 而不是字符串。

❸ 使用 DownloadHandler 实用程序检索下载的图像。

下载图像的代码几乎与下载数据的代码相同。主要区别在于回调方法的类型;注意这次回调接收一个 Texture2D 而不是字符串。这是因为你正在发送相关的响应:你之前下载的是数据字符串——现在你正在下载图像。此列表包含 ImagesManager 的新代码。创建一个新的脚本并输入此代码。

列表 10.13 创建 ImagesManager 以检索和存储图像

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ImagesManager : MonoBehaviour, IGameManager {
   public ManagerStatus status {get; private set;}

   private NetworkService network;

   private Texture2D webImage;                           ❶

   public void Startup(NetworkService service) {
      Debug.Log("Images manager starting...");

      network = service;

      status = ManagerStatus.Started;
   }

   public void GetWebImage(Action<Texture2D> callback) {
      if (webImage == null) {                            ❷
         StartCoroutine(network.DownloadImage(callback));
      }
      else {
         callback(webImage);                             ❸
      }
   }
}

❶ 存储下载图像的变量

❷ 检查图像是否已存储。

❸ 如果有已存储的图像,立即调用回调(不下载)。

这段代码中最有趣的部分是 GetWebImage();此脚本中的其他所有内容都由实现管理接口的标准属性和方法组成。当调用 GetWebImage()时,它将通过回调函数返回网络图像。首先,它会检查 webImage 是否已经存储了图像。如果没有,它将调用网络调用以下载图像。如果 webImage 已经存储了图像,GetWebImage()将发送回存储的图像(而不是重新下载图像)。

注意:目前,下载的图像从未被存储,这意味着 webImage 始终为空。当 webImage 为非空时指定要执行的操作的代码已经就绪,因此你将在以下部分调整代码以存储该图像。这个调整在一个单独的部分中,因为它涉及一些复杂的代码技巧。

当然,就像所有管理模块一样,ImagesManager 需要添加到 Managers 中,以下列表详细说明了添加的内容。

列表 10.14 将新管理器添加到 Managers

...
[RequireComponent(typeof(ImagesManager))]
...
public static ImagesManager Images {get; private set;}
...
void Awake() {
   Weather = GetComponent<WeatherManager>();
   Images = GetComponent<ImagesManager>();

   startSequence = new List<IGameManager>();
   startSequence.Add(Weather);
   startSequence.Add(Images);

   StartCoroutine(StartupManagers());
}
...

与我们设置 WeatherManager 的方式不同,ImagesManager 中的 GetWebImage()在启动时不会自动调用。相反,代码会等待被调用;这将在下一节中发生。

10.3.2 在广告牌上显示图像

你刚刚编写的 ImagesManager 在未被调用之前不会做任何事情,因此现在我们将创建一个广告牌对象,该对象将调用 ImagesManager 中的方法。首先创建一个新的立方体,然后将其放置在场景中间,大约在位置 0, 1.5, -5 和缩放 5, 3, 0.5(见图 10.6)。

CH10_F06_Hocking3

图 10.6 显示下载图像前后的广告牌对象

你将创建一个操作方式与第九章中提到的颜色变化监视器类似的设备。复制 DeviceOperator 脚本并将其放在玩家上。你可能还记得,当按下 C 键时,该脚本将操作附近的设备。同时创建一个名为 WebLoadingBillboard 的广告牌设备脚本,将其放在广告牌对象上,并输入以下代码。

列表 10.15 WebLoadingBillboard 设备脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WebLoadingBillboard : MonoBehaviour {
   public void Operate() {
      Managers.Images.GetWebImage(OnWebImage);                ❶
   }

   private void OnWebImage(Texture2D image) {
      GetComponent<Renderer>().material.mainTexture = image;  ❷
   }
}

❶ 调用 ImagesManager 中的方法。

❷ 将下载的图像应用到回调中的材质。

这段代码主要做两件事:当设备被操作时,它会调用 ImagesManager.GetWebImage(),并将回调函数中的图像应用到材质上。纹理应用于材质,因此你可以更改广告牌上材质的纹理。图 10.6 显示了游戏运行后广告牌的外观。

AssetBundles:如何下载其他类型的资产

使用 UnityWebRequest 下载图像相对简单,但其他类型的资产,如网格对象和预制件呢?UnityWebRequest 有文本和图像的属性,但其他资产要复杂一些。

Unity 可以通过一种称为 AssetBundles 的机制下载任何类型的资产。简而言之,你首先将资产打包成一个包,然后 Unity 在下载包后可以提取资产。创建和下载 AssetBundles 的详细内容超出了本书的范围;如果你想了解更多,请从阅读 Unity 手册开始,网址为mng.bz/m1X2mng.bz/5Zn1

太好了,下载的图像已经显示在广告牌上了!但这段代码还可以进一步优化以支持多个广告牌。让我们接下来解决这个优化问题。

10.3.3 为重用缓存下载的图像

如第 10.3.1 节所述,ImagesManager 还未存储下载的图像。这意味着图像将多次为多个广告牌重新下载。这是低效的,因为每次都是相同的图像。为了解决这个问题,我们将调整 ImagesManager 以缓存已下载的图像。

定义 缓存 意味着在本地存储。最常见(但不仅限于此!)的上下文涉及从互联网下载的文件,例如图像。

关键在于在 ImagesManager 中提供一个回调函数,首先保存图像,然后调用来自 WebLoadingBillboard 的回调。这比当前直接使用来自 WebLoadingBillboard 的回调要复杂(因为代码在提前并不知道来自 WebLoadingBillboard 的回调将是什么)。换句话说,我们无法在 ImagesManager 中编写一个调用 WebLoadingBillboard 中特定方法的方法,因为我们还不知道那个特定方法是什么。解决这个难题的方法是使用 Lambda 函数。

定义 Lambda 函数(也称为 匿名函数)是一个没有名称的函数。这些函数通常在其他函数内部动态创建。

Lambda 函数是多种编程语言中支持的一种复杂代码特性,包括 C#。通过在 ImagesManager 中使用 Lambda 函数作为回调,代码可以动态地使用从 WebLoadingBillboard 传入的方法来创建回调函数。你不需要提前知道要调用哪个方法,因为这个 Lambda 函数在提前并不存在!这个列表展示了如何在 ImagesManager 中实现这种巫术。

列表 10.16 ImagesManager 中回调的 Lambda 函数

using System;
...
public void GetWebImage(Action<Texture2D> callback) {
   if (webImage == null) {
      StartCoroutine(network.DownloadImage((Texture2D image) => {
         webImage = image;       ❶
         callback(webImage);     ❷
      }));
   }
   else {
      callback(webImage);
   }
}
...

❶ 保存下载的图像。

❷ 在 Lambda 函数中使用回调而不是直接发送到 NetworkService。

主要变化在于传递给 NetworkService.DownloadImage() 的函数。之前,代码是通过传递来自 WebLoadingBillboard 的相同回调方法。然而,在变化之后,发送给 NetworkService 的回调是一个现场声明的单独的 Lambda 函数,它调用了来自 WebLoadingBillboard 的方法。请注意声明 Lambda 函数的语法:() => {}。

将回调作为一个单独的函数,使得除了调用 WebLoadingBillboard 中的方法之外,Lambda 函数还可以存储下载的图像的本地副本。因此,GetWebImage() 只需在第一次下载图像;所有后续调用都将使用本地存储的图像。

由于这种优化适用于后续调用,因此只有在多个广告牌上才会注意到效果。让我们复制广告牌对象,以便在场景中有一个第二个广告牌。选择广告牌对象,点击复制(在编辑菜单下或右键单击),并将复制品移动过来(例如,将 X 位置更改为 18)。

现在开始游戏并观察会发生什么。当你操作第一个广告牌时,图像从互联网下载时会出现明显的暂停。但当你走到第二个广告牌前,图像会立即出现,因为它们已经下载完毕。

这是对下载图像的重要优化(这就是为什么默认情况下网络浏览器会缓存图像的原因)。还有一个主要的网络任务需要完成:将数据发送回服务器。

10.4 将数据发布到 Web 服务器

我们已经讨论了多个下载数据的例子,但我们仍然需要看到发送数据的例子。本节确实需要你有一个服务器来发送请求,所以这一节是可选的。但是,很容易下载开源软件来设置服务器进行测试。

我推荐使用 XAMPP 作为测试服务器。访问www.apachefriends.org下载 XAMPP(在 macOS 上需要将.bz2 重命名为.dmg)并遵循安装说明。一旦安装并启动服务器,你可以使用地址 http://localhost/访问 XAMPP 的 htdocs 文件夹,就像访问互联网上的服务器一样。一旦 XAMPP 启动并运行,在 htdocs 中创建一个名为 uia 的文件夹;这就是你将放置服务器端脚本的地方。

无论你使用 XAMPP 还是你自己的现有 Web 服务器,实际的任务是在玩家达到场景中的检查点时将天气数据发送到服务器。这个检查点将是一个触发体积,就像第九章中的门触发器一样。你需要创建一个新的立方体对象,将其放置在场景的一侧,将碰撞器设置为触发器,并应用与第九章中相同的半透明材质(记住,设置材质的渲染模式)。图 10.7 显示了应用了绿色半透明材质的检查点对象。

CH10_F07_Hocking3

图 10.7 触发数据发送的检查点对象

现在触发对象已经在场景中,让我们编写它调用的代码。

10.4.1 跟踪当前天气:发送 POST 请求

由检查点对象调用的代码将通过几个脚本级联。与下载数据的代码一样,发送数据的代码将涉及 WeatherManager 告诉 NetworkService 发起请求,而 NetworkService 处理 HTTP 通信的细节。这显示了你需要对 NetworkService 进行的调整。

列表 10.17 调整 NetworkService 以发送数据

...
private const string localApi = "http://localhost/uia/api.php";       ❶
...
private IEnumerator CallAPI(string url, WWWForm form, Action<string> callback) {                                                      ❷
   using (UnityWebRequest request = (form == null) ?
      UnityWebRequest.Get(url) : UnityWebRequest.Post(url, form)) {   ❸

      yield return request.SendWebRequest();

      if (request.result == UnityWebRequest.Result.ConnectionError) {
         Debug.LogError($"network problem: {request.error}");
      } else if (request.result == UnityWebRequest.Result.ProtocolError) {
         Debug.LogError($"response error: {request.responseCode}");
      } else {
         callback(request.downloadHandler.text);
      }
   }
}

public IEnumerator GetWeatherXML(Action<string> callback) {
   return CallAPI(xmlApi, null, callback);                            ❹
}
public IEnumerator GetWeatherJSON(Action<string> callback) {
   return CallAPI(jsonApi, null, callback);
}

public IEnumerator LogWeather(string name, float cloudValue, Action<string> callback) {
   WWWForm form = new WWWForm();                                      ❺
   form.AddField("message", name);
   form.AddField("cloud_value", cloudValue.ToString());
   form.AddField("timestamp", DateTime.UtcNow.Ticks.ToString());      ❻

   return CallAPI(localApi, form, callback);
}
...

❶ 服务器端脚本的地址;如有需要,请更改此地址。

❷ 向 CallAPI()参数添加了参数

❸ 要么使用 WWWForm 进行 POST,要么不使用 GET

❹ 由于参数更改而修改的调用

❺ 定义一个带有要发送的值的表单。

❻ 与云量一起发送时间戳。

首先,注意 CallAPI() 有一个新参数。这是一个 WWWForm 对象,是一系列与 HTTP 请求一起发送的值。代码中的一个条件使用 WWWForm 对象的存在来改变创建的请求。通常我们想要发送一个 GET 请求,但 WWWForm 会将其改为 POST 请求以发送数据。代码中的所有其他更改都是对那个核心更改的反应(例如,由于 CallAPI() 参数而修改 GetWeather() 代码)。以下是你需要在 WeatherManager 中添加的代码。

列表 10.18 向 WeatherManager 添加代码以发送数据

...
public void LogWeather(string name) {
   StartCoroutine(network.LogWeather(name, cloudValue, OnLogged));
}
private void OnLogged(string response) {
   Debug.Log(response);
}
...

最后,通过在场景中的触发体积上添加一个检查点脚本来使用此代码。创建一个名为 CheckpointTrigger 的脚本,将其放在触发体积上,并输入下一个列表的内容。

列表 10.19 触发体积的 CheckpointTrigger 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CheckpointTrigger : MonoBehaviour {
   public string identifier;

   private bool triggered;                      ❶

   void OnTriggerEnter(Collider other) {
      if (triggered) {return;}

      Managers.Weather.LogWeather(identifier);  ❷
      triggered = true;
   }
}

❶ 跟踪检查点是否已经被触发。

❷ 调用以发送数据。

在检查器中会出现一个标识符槽位;可以将其命名为 checkpoint1。运行代码,当你进入检查点时,将会发送数据。但是,响应将指示一个错误,因为没有在服务器上放置脚本来接收请求。这是本节中的最后一步。

10.4.2 PHP 服务器端代码

服务器需要有一个脚本来接收从游戏发送过来的数据。编写服务器脚本超出了本书的范围,所以这里不会详细介绍。我们只需快速编写一个 PHP 脚本,因为这是最简单的方法。在 htdocs(或你的 web 服务器所在位置)中创建一个文本文件,并将其命名为 api.php(列表 10.20)。

列表 10.20 用 PHP 编写的服务器脚本,用于接收我们的数据

<?php 

$message = $_POST['message'];                                     ❶
$cloudiness = $_POST['cloud_value'];
$timestamp = $_POST['timestamp'];
$combined = $message." cloudiness=".$cloudiness." time=".$timestamp."\n";

$filename = "data.txt";                                           ❷
file_put_contents($filename, $combined, FILE_APPEND | LOCK_EX);   ❸

echo "Logged";

?>

❶ 将请求数据提取到变量中。

❷ 定义要写入的文件名。

❸ 写入文件。

注意,此脚本将接收到的数据写入 data.txt,因此你还需要在服务器上放置一个同名文本文件。一旦 api.php 就位,你将在触发游戏中的检查点时看到天气日志出现在 data.txt 中。太棒了!

摘要

  • 天空盒是为渲染在其他所有内容之后的天空视觉效果而设计的。

  • Unity 提供了 UnityWebRequest 来下载数据。

  • 常见的数据格式,如 XML 和 JSON,可以轻松解析。

  • 材料可以显示从互联网下载的图片。

  • UnityWebRequest 还可以将数据发布到网络服务器。

11 播放音频:音效和音乐

本章涵盖

  • 导入和播放各种音效的音频剪辑

  • 使用 2D 声音进行 UI 和场景中的 3D 声音

  • 调节播放时所有声音的音量

  • 在游戏进行时播放背景音乐

  • 在不同的背景曲调之间淡入淡出

虽然在视频游戏的内容中图形通常受到最多的关注,但音频同样重要。大多数游戏都会播放背景音乐和音效。因此,Unity 提供了音频功能,以便你可以在游戏中添加音效和音乐。Unity 可以导入和播放多种音频文件格式,调整声音的音量,甚至可以处理场景中特定位置播放的声音。

注意:2D 和 3D 游戏中的音频处理方式相同。尽管本章的示例项目是一个 3D 游戏,但我们所做的一切同样适用于 2D 游戏。

本章首先从音效而不是音乐开始。音效是与游戏中的动作(如玩家开火时播放的枪声)一起播放的短剪辑,而音乐的音频剪辑则更长(通常持续几分钟),播放并不直接与游戏中的事件相关联。最终,两者都归结为同一种音频文件和播放代码,但音乐的声音文件通常比音效的短剪辑大得多(实际上,音乐文件往往是游戏中最大的文件!)这一点值得单独介绍。

本章的完整路线图将是将一个没有声音的游戏进行以下操作:

  1. 导入音效的音频文件。

  2. 为敌人和射击播放音效。

  3. 编程一个音频管理器来控制音量。

  4. 优化音乐的加载。

  5. 分别控制音乐音量和音效,包括交叉淡入淡出曲目。

注意:在本章中,我们将在现有的游戏演示基础上简单地添加音频功能。本章中的所有示例都是基于第三章中创建的 FPS 构建的,你可以下载那个示例项目,但你也可以使用你喜欢的任何游戏演示。

一旦你将现有的游戏演示复制用于本章,你就可以着手第一步:导入音效。

11.1 导入音效

在你能够播放任何声音之前,显然你需要将声音文件导入到你的 Unity 项目中。首先,你将收集所需文件格式的声音剪辑,然后你将把文件带入 Unity 并调整它们以适应你的需求。

11.1.1 支持的文件格式

就像你在第四章中看到的艺术资源一样,Unity 支持多种音频格式,各有优缺点。表 11.1 列出了 Unity 支持的音频文件格式。

区分音频文件的主要考虑因素是应用的压缩方式。压缩可以减小文件的大小,但这是通过从文件中丢弃一些信息来实现的。音频压缩非常巧妙,只丢弃最不重要的信息,这样压缩后的声音仍然听起来不错。

表 11.1 Unity 支持的音频文件格式

文件类型 优点和缺点
WAV Windows 上的默认音频格式。未压缩声音文件。
AIF Mac 上的默认音频格式。未压缩声音文件。
MP3 压缩声音文件;为了获得更小的文件,牺牲了一部分质量。
OGG 压缩声音文件;为了获得更小的文件,牺牲了一部分质量。
MOD 音乐跟踪文件格式。一种高效的数字音乐。
XM 音乐跟踪文件格式。一种高效的数字音乐。

然而,压缩会导致少量质量的损失,因此当声音剪辑较短且不会成为大文件时,你应该选择未压缩的音频。较长的声音剪辑(尤其是音乐)应使用压缩音频,否则音频剪辑会过大。尽管如此,Unity 在这个决定上增加了一个小细节。

TIP 尽管音乐应该在最终的游戏中压缩,但 Unity 可以在导入文件后压缩音频。在 Unity 中开发游戏时,你通常希望即使对于较长的音乐,也使用未压缩的文件格式,而不是导入压缩音频。

由于 Unity 会在导入音频后对其进行压缩,因此你应该始终选择 WAV 或 AIF 文件格式。你可能需要根据短声音效果和较长的音乐(特别是告诉 Unity 何时应用压缩)调整不同的导入设置,但原始文件始终应该是未压缩的。

数字音频的工作原理

通常,音频文件存储的是当声音播放时在扬声器中产生的波形。声音是一系列通过空气传播的波,不同的声音是通过不同大小和频率的声音波产生的。音频文件通过在短时间间隔内重复采样这些波,并保存每个样本的波形状态来记录这些波。

采样频率更高的录音可以得到更准确的随时间变化的记录——变化之间的间隔更小。但更频繁的采样意味着需要保存更多的数据,从而导致文件更大。压缩声音文件通过多种技巧来减小文件大小,包括在听众听不到的声音频率上丢弃数据。

音乐跟踪器是一种特殊的序列软件,用于创作音乐。与传统音频文件存储原始声波波形不同,序列软件存储的更像是乐谱:跟踪文件是一系列音符,每个音符都存储了强度和音高等信息。这些“音符”由小波形组成,但由于在序列中重复使用相同的音符,因此存储的数据总量减少。以这种方式创作的音乐可以很高效,但这是一种相当专业的音频类型。

创建声音文件有多种方法(附录 B 提到了 Audacity 等工具,可以从麦克风录制声音),但为了我们的目的,我们将从众多免费声音网站之一下载声音。我们将使用从www.freesound.org下载的 WAV 格式剪辑。

警告:“免费”的声音提供在多种许可方案下,所以请确保你允许以你打算的方式使用声音剪辑。例如,许多免费声音仅限非商业用途。

样本项目使用以下公共领域声音效果(当然,你也可以选择下载自己的声音;查找旁边列出的 0 许可):

  • “thump” by hy96

  • “ding” by Daphne_in_Wonderland

  • “swish bamboo pole” by ra_gun

  • “fireplace” by leosalom

一旦你有了游戏中要使用的声音文件,下一步就是将声音导入到 Unity 中。

11.1.2 导入音频文件

在收集了一些音频文件之后,你需要将它们导入到 Unity 中。就像在第四章中处理艺术资产一样,在使用游戏之前,你必须将音频资产导入到项目中。

导入文件的操作很简单,与其他资产相同:将文件从电脑上的位置拖动到 Unity 中的项目视图中(在 Unity 中创建一个名为 Sound FX 的文件夹以将文件拖入)。嗯,这很简单!但就像其他资产一样,这些音频文件也有导入设置(如图 11.1 所示)可以在检查器中进行调整。

CH11_F01_Hocking3

图 11.1 音频文件导入设置

不要勾选“强制单声道”选项。这指的是单声道与立体声声音。通常,声音是以立体声录制的,文件中有两个波形,一个用于左耳/扬声器,一个用于右耳。为了节省文件大小,你可能想要将音频信息减半,以便将相同的波形发送到两个扬声器,而不是分别发送到左右扬声器。(当单声道关闭时,仅当单声道打开时才应用的归一化设置会变灰。)

在“强制转换为单声道”下方,你会看到“在后台加载”和“预加载音频数据”的复选框。预加载设置与平衡回放性能和内存使用有关;预加载音频将在声音等待使用时消耗内存,但可以避免等待加载。因此,你不想预加载长音频剪辑,但对于这种短声音效果,可以将其打开。

同时,在后台加载音频将允许程序在音频加载时继续运行;这对于长音乐剪辑来说通常是个好主意,这样程序就不会冻结。但这也意味着音频不会立即开始播放。通常,对于短声音剪辑,你希望关闭此设置,以确保它们在播放之前完全加载。因为导入的剪辑是短声音效果,你应该取消选中“在后台加载”。

最后,最重要的设置是加载类型和压缩格式。压缩格式控制存储的音频数据的格式。如前所述,音乐应该被压缩,所以在这种情况下选择 Vorbis(这是一个压缩音频格式的名称)。短声音剪辑不需要压缩,因此对于这些剪辑选择 PCM(脉冲编码调制,原始采样声音波的术语)。第三个设置,ADPCM,是 PCM 的一种变体,偶尔会产生略微更好的音质。

加载类型控制计算机如何加载文件中的数据。由于计算机的内存有限,而音频文件可能很大,有时你希望音频在流式传输到内存时播放,从而节省计算机不需要加载整个文件。但以这种方式流式传输音频时需要一些计算开销,因此当音频首先加载到内存中时,音频播放速度最快。即使如此,你也可以选择加载的音频数据是压缩形式还是解压缩以实现更快的回放。因为这些声音剪辑很短,它们不需要流式传输,可以设置为“加载时解压缩”。

最后一个选项是采样率设置;将其保留为“保留采样率”,这样 Unity 就不会更改导入文件中的样本。到此为止,声音效果都已导入并准备好使用。

11.2 播放声音效果

现在你已经将声音文件添加到项目中,你自然会想要播放这些声音。触发声音效果的代码并不难理解,但 Unity 中的音频系统确实有多个部分必须协同工作。

11.2.1 解释所涉及的内容:音频剪辑 vs. 源 vs. 听众

尽管您可能认为播放声音只是告诉 Unity 播放哪个剪辑这么简单,但结果是您必须定义三个部分才能在 Unity 中播放声音:AudioClip、AudioSource 和 AudioListener。将声音系统分解成多个组件的原因与 Unity 对 3D 声音的支持有关:不同的组件告诉 Unity 位置信息,Unity 使用这些信息来操纵 3D 声音。

2D 与 3D 声音

游戏中的声音可以是 2D 或 3D。2D 声音是您已经熟悉的:标准音频正常播放。2D 声音的称呼主要意味着不是 3D 声音

3D 声音是针对 3D 模拟的,可能您并不熟悉;这些是在模拟中有特定位置的声音。它们的音量和音调受监听者移动的影响。例如,在远处触发的一个声音效果会听起来很微弱。

Unity 支持这两种音频,您决定音频源应该播放 2D 声音还是 3D 声音。像音乐这样的东西应该是 2D 声音,但使用 3D 声音为大多数声音效果创建场景中的沉浸式音频。

作为一个类比,想象一下现实世界中的一个房间。房间里有立体声音响在播放 CD。如果一个人走进房间,他会清楚地听到声音。当他离开房间时,他会听得更不清楚,最终完全听不到。同样,如果我们把立体声音响在房间里移动,他会听到音乐随着移动而改变音量。如图 11.2 所示,在这个类比中,CD 是一个 AudioClip,立体声音响是一个 AudioSource,而那个人是 AudioListener。

CH11_F02_Hocking3

图 11.2 Unity 音频系统中您所控制的三个要素

三个部分中的第一个是音频剪辑。这是我们之前章节中导入的声音文件。这些原始波形数据是音频系统所做一切的基础,但音频剪辑本身并不做任何事情。

下一种对象是音频源。这是播放音频剪辑的对象。这是对音频系统实际所做事情的一种抽象,但它是一种有用的抽象,使得 3D 声音更容易理解。从特定音频源播放的 3D 声音位于该音频源的位置;2D 声音也必须从音频源播放,但位置并不重要。

Unity 音频系统中涉及的第三种对象是音频监听器。正如其名所示,这是接收从音频源投射出的声音的对象。这是在音频系统所做事情之上的一种抽象(显然,实际的监听者是游戏玩家!),但——就像音频源的位置给出了声音投射的位置一样——音频监听器的位置给出了声音被听到的位置。

使用音频混音器进行高级声音控制

音频混合器是控制 Unity 中音频的高级替代方法。而不是直接播放音频剪辑,音频混合器允许你处理音频信号并对你的剪辑应用各种效果。在 Unity 的文档中了解更多关于音频混合器的信息。例如,你可以观看 Unity 教程视频:mng.bz/Mlp3

虽然音频剪辑和音频源组件都需要分配,但当你创建一个新场景时,默认相机上已经有一个音频监听器组件。通常,你希望 3D 声音能够对观众的方位做出反应。

11.2.2 分配循环声音

好吧,现在让我们在 Unity 中设置我们的第一个声音!音频剪辑已经导入,默认相机有一个音频监听器组件,所以我们只需要分配一个音频源组件。我们将把噼啪声放在敌人预制体上,即四处游荡的敌人角色。

注意:因为敌人听起来像是在着火,你可能想给它一个粒子系统,让它看起来像是在着火。你可以通过将粒子对象制作成预制体,然后从资产菜单中选择导出包,来复制第四章中创建的粒子系统。或者,你也可以在这里重新执行第四章的步骤(首先双击敌人预制体以打开它进行编辑,而不是编辑场景)来从头创建一个新的粒子对象。

通常,你需要将预制体打开到场景中才能编辑它,但只需将组件添加到对象上就可以完成,而无需双击预制体来打开它。选择敌人预制体,使其属性出现在检查器中。现在添加一个新的组件:选择音频 > 音频源。一个音频源组件将出现在检查器中。

告诉音频源要播放哪个声音剪辑。将一个音频文件从项目视图拖动到检查器中的音频剪辑槽;我们将使用这个示例的“壁炉”声音效果(参见图 11.3)。

CH11_F03_Hocking3

图 11.3 音频源组件的设置

在设置中向下滚动一点,并选择播放于唤醒和循环(当然,确保没有勾选静音)。播放于唤醒告诉音频源在场景开始时立即开始播放(在下一节中,你将学习如何在场景运行时手动触发声音)。循环告诉音频源在播放结束后持续播放,重复音频剪辑。

你希望这个音频源能够投射 3D 声音。如前所述,3D 声音在场景中有一个独特的位置。音频源的这个方面是通过空间混合设置进行调整的,这是一个从 2D 到 3D 的滑块。为此音频源将其设置为 3D。

现在播放游戏并确保你的扬声器已打开。你可以听到从敌人那里传来的噼啪声,如果你移动远离,声音会变得微弱,因为你使用了 3D 音频源。

11.2.3 从代码中触发音效

将 AudioSource 组件设置为自动播放对于一些循环音效很有用,但对于大多数音效,你将希望通过代码命令来触发音效。这种方法仍然需要一个 AudioSource 组件,但现在音频源只有在程序告诉它时才会播放音剪辑,而不是始终自动播放。

将 AudioSource 组件添加到玩家对象(不是相机对象)。你不需要链接特定的音频剪辑,因为音频剪辑将在代码中定义。你可以关闭 Play On Awake,因为来自此源的声音将在代码中触发。此外,将空间混合调整到 3D,因为此声音位于场景中。现在,在处理射击的脚本 RayShooter 中添加下一列表中的添加内容。

列表 11.1 在 RayShooter 脚本中添加的音效

...
[SerializeField] AudioSource soundSource;
[SerializeField] AudioClip hitWallSound;          ❶
[SerializeField] AudioClip hitEnemySound;         ❶
...

if (target != null) {                             ❷
    target.ReactToHit();
    soundSource.PlayOneShot(hitEnemySound);       ❸
} else {
    StartCoroutine(SphereIndicator(hit.point));
    soundSource.PlayOneShot(hitWallSound);        ❹
}
...

❶ 引用你想要播放的两个声音文件

❷ 如果目标不为空,则玩家击中了敌人,所以……

❸ ……调用 PlayOneShot() 来播放击中敌人的声音,或者……

❹ ……如果玩家未击中,则调用 PlayOneShot() 来播放击中墙壁的声音。

新的代码在脚本顶部包含几个序列化变量。将玩家对象(具有 AudioSource 组件的对象)拖动到检查器中的 soundSource 槽。然后将要播放的音频剪辑拖动到音槽;“swish”用于击中墙壁,“ding”用于击中敌人。

添加的其他两行是 PlayOneShot() 方法。PlayOneShot() 会使音频源播放指定的音频剪辑。将这些方法添加到目标条件内部,以便在击中各种对象时播放声音。

注意:你可以在 AudioSource 中设置剪辑并调用 Play() 来播放剪辑。但是,多个声音会相互切断,所以我们使用了 PlayOneShot()。用以下代码替换 PlayOneShot() 并快速射击以查看(或者说,听到)问题:soundSource.clip=hitEnemySound; soundSource.Play();。

好吧,玩玩游戏,四处射击。你现在游戏中已经有了几个音效。这些基本步骤可以用来添加各种音效。然而,一个健壮的游戏音效系统需要的不仅仅是零散的声音;至少,所有游戏都应该提供音量控制。你将通过一个中央音频模块来实现这个控制功能。

11.3 使用音频控制界面

继续前几章中建立的代码架构,你将创建一个 AudioManager。回想一下,Managers 对象有一个用于游戏的各种代码模块的主列表,例如玩家库存的管理器。这次,你将创建一个音频管理器并将其添加到列表中。这个中央音频模块将允许你调节游戏中的音量,甚至可以静音。最初,你将只关注音效,但在后面的章节中,你将扩展 AudioManager 以处理音乐。

11.3.1 设置中央 AudioManager

设置 AudioManager 的第一步是放置 Managers 代码框架。从第十章的项目中复制 IGameManager、ManagerStatus 和 NetworkService;我们不会更改它们。(记住,IGameManager 是所有管理器必须实现的接口,而 ManagerStatus 是 IGameManager 使用的枚举。NetworkService 提供对互联网的调用,在本章中不会使用。)

注意:Unity 可能会发出警告,因为 NetworkService 已分配但未使用。您可以忽略 Unity 的警告;我们希望启用代码框架以访问互联网,尽管在本章中我们不使用该功能。

同时复制 Managers 文件,该文件将针对新的 AudioManager 进行调整。现在先保持原样(或者如果编译错误让您感到疯狂,可以注释掉错误部分!)创建一个新的脚本名为 AudioManager,Manager 代码可以引用它。

列表 11.2 AudioManager 的骨架代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AudioManager : MonoBehaviour, IGameManager {
   public ManagerStatus status {get; private set;}

   private NetworkService network;

   // Add volume controls here (listing 11.4)

   public void Startup(NetworkService service) {
      Debug.Log("Audio manager starting...");

      network = service;

      // Initialize music sources here (listing 11.11)   ❶

      status = ManagerStatus.Started;                    ❷
   }
}

❶ 任何长时间运行的启动任务放在这里。

❷ 如果有长时间运行的启动任务,将状态设置为初始化。

这段初始代码看起来像前几章的管理器;这是 IGameManager 所需的最小实现量。现在可以调整 Manager 脚本以使用新的管理器。

列表 11.3 使用 AudioManager 调整的 Managers 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(AudioManager))]

public class Managers : MonoBehaviour {
   public static AudioManager Audio {get; private set;}

   private List<IGameManager> startSequence;

   void Awake() {
      Audio = GetComponent<AudioManager>();     ❶

      startSequence = new List<IGameManager>();
      startSequence.Add(Audio);

      StartCoroutine(StartupManagers());
   }

   private IEnumerator StartupManagers() {
      NetworkService network = new NetworkService();

      foreach (IGameManager manager in startSequence) {
         manager.Startup(network);
      }

      yield return null;

      int numModules = startSequence.Count;
      int numReady = 0;

      while (numReady < numModules) {
         int lastReady = numReady;
         numReady = 0;

         foreach (IGameManager manager in startSequence) {
            if (manager.status == ManagerStatus.Started) {
               numReady++;
            }
         }

         if (numReady > lastReady)
            Debug.Log($"Progress: {numReady}/{numModules}");

         yield return null;
      }

      Debug.Log("All managers started up");
   }
}

❶ 在此项目中仅列出 AudioManager,而不是 PlayerManager 等。

如前几章所述,在场景中创建 Game Managers 对象,然后将两个 Managers 和 AudioManager 都附加到空对象上。玩游戏将在控制台显示管理器的启动消息,但音频管理器目前还没有任何操作。

11.3.2 音量控制 UI

在设置好基本的 AudioManager 后,现在是时候给它添加音量控制功能了。这些音量控制方法将被 UI 显示用于静音音效或调整音量。

您将使用第七章中重点介绍的 UI 工具。具体来说,您将创建一个带有按钮和滑块的弹出窗口来控制音量设置(见图 11.4)。我将列出涉及的步骤而不深入细节;如果您需要复习,请参考第七章。如果需要,在开始之前安装 TextMeshPro 和 2D Sprite 包(请参考第五章和第六章),然后:

  1. 将 popup.png 导入为精灵(设置纹理类型为精灵)。

  2. 在精灵编辑器中,在所有边设置 12 像素的边框(记得应用更改)。

  3. 在场景中创建一个画布(GameObject > UI > Canvas)。

  4. 为画布开启像素完美设置。

  5. (可选)将对象命名为 HUD Canvas 并切换到 2D 视图模式。

  6. 创建一个与该画布连接的图像(GameObject > UI > Image)。

  7. 将新对象命名为设置弹出窗口。

  8. 将弹出精灵分配给图像的源图像。

  9. 将图像类型设置为 Sliced 并开启填充中心。

  10. 将弹出图像定位在 0, 0 以居中。

  11. 将弹出图像缩放为 250 宽度和 150 高度。

  12. 创建一个按钮(GameObject > UI > Button - TextMeshPro)。

  13. 将按钮拖到弹出窗口中(在层次结构中拖动)。

  14. 将按钮放置在 0, 40 位置。

  15. 扩展按钮的层次结构以选择其文本标签。

  16. 将文本更改为切换声音。

  17. 创建一个滑块(GameObject > UI > Slider)。

  18. 将滑块拖到弹出窗口中,并放置在 0, 15 位置。

  19. 将滑块的值(检查器底部的值)设置为 1。

CH11_F04_Hocking3

图 11.4 静音和音量控制的 UI 显示

这些就是创建设置弹出窗口的所有步骤!现在弹出窗口已经创建,让我们编写与之协同工作的代码。这将涉及弹出窗口对象上的脚本以及弹出窗口脚本调用的音量控制功能。首先,根据此列表调整 AudioManager 中的代码。

列表 11.4 添加到 AudioManager 的音量控制

...
public float soundVolume {                      ❶
   get {return AudioListener.volume;}           ❷
   set {AudioListener.volume = value;}          ❷
}

public bool soundMute {                         ❸
   get {return AudioListener.pause;}
   set {AudioListener.pause = value;}
}

*public void Startup(NetworkService service) {*   ❹
   *Debug.Log("Audio manager starting...");

   network = service;*

   soundVolume = 1f;                            ❺

   *status = ManagerStatus.Started;
}*
...

❶ 具有 getter 和 setter 的音量属性

❷ 使用 AudioListener 实现 getter/setter。

❸ 为静音添加一个类似的属性。

❹ 已在脚本中添加斜体代码,此处仅供参考。

❺ 初始化值(0 到 1 范围;1 为全音量)。

已为 AudioManager 添加了 soundVolume 和 soundMute 属性。对于这两个属性,get 和 set 函数都是通过在 AudioListener 上使用全局值来实现的。AudioListener 类可以调节所有 AudioListener 实例接收到的所有声音的音量。设置 AudioManager 的 soundVolume 属性与在 AudioListener 上设置音量具有相同的效果。这里的优势在于封装:所有与音频相关的事情都在一个管理器中处理,无需管理器外部的代码了解实现的细节。

在将那些方法添加到 AudioManager 后,你现在可以编写一个弹出窗口的脚本。创建一个名为 SettingsPopup 的脚本,并添加此列表的内容。

列表 11.5 用于调整音量的 SettingsPopup 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SettingsPopup : MonoBehaviour {

   public void OnSoundToggle() {                            ❶
      Managers.Audio.soundMute = !Managers.Audio.soundMute;
   }

   public void OnSoundValue(float volume) {                 ❷
      Managers.Audio.soundVolume = volume;
   }
}

❶ 按钮将切换 AudioManager 的静音属性。

❷ 滑块将调整 AudioManager 的音量属性。

此脚本有两个影响 AudioManager 属性的方法:OnSoundToggle() 设置 soundMute 属性,而 OnSoundValue() 设置 soundVolume 属性。像往常一样,通过将脚本拖到 UI 中的 Settings Popup 对象上链接 SettingsPopup 脚本。

然后,为了从按钮和滑块中调用函数,将弹出窗口对象链接到那些控件中的交互事件。在按钮的检查器中,寻找标有 On Click 的面板。点击 + 按钮向此事件添加一个新条目。将 Settings Popup 拖到新条目中的对象槽位,然后在菜单中查找 SettingsPopup;选择 OnSoundToggle() 以使按钮调用该函数。

现在选择滑块并链接一个函数,就像您处理按钮时做的那样。首先在滑块设置的面板中查找交互事件;在这种情况下,面板被称为 OnValueChanged。点击 + 按钮添加一个新条目,然后将设置弹出窗口拖到对象槽位。在函数菜单中,找到 SettingsPopup 脚本,然后选择动态浮点下的 OnSoundValue()。

警告:请记住选择动态浮点函数下的功能,而不是静态参数!尽管该方法出现在列表的两个部分中,但在后一种情况下,它将只接收预先输入的一个值。

设置控制现在正在工作,但我们还需要解决一个脚本问题——弹出窗口目前总是覆盖整个屏幕。一个简单的解决方案是使弹出窗口仅在您按下 M 键时打开。创建一个新的脚本名为 UIController,将其链接到场景中的控制器对象,并编写以下代码。

列表 11.6 UIController 切换设置弹出窗口

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UIController : MonoBehaviour {
   [SerializeField] SettingsPopup popup;                 ❶

   void Start() {
      popup.gameObject.SetActive(false);                 ❷
   }

   void Update() {
      if (Input.GetKeyDown(KeyCode.M)) {                 ❸
         bool isShowing = popup.gameObject.activeSelf;
         popup.gameObject.SetActive(!isShowing);

         if (isShowing) {
            Cursor.lockState = CursorLockMode.Locked;    ❹
            Cursor.visible = false;                      ❹
         } else {                                        ❹
            Cursor.lockState = CursorLockMode.None;      ❹
            Cursor.visible = true;                       ❹
         }
      }
   }
}

❶ 引用场景中的弹出对象

❷ 初始化隐藏的弹出窗口

❸ 使用 M 键切换弹出窗口

❹ 同时切换光标和弹出窗口

要连接此对象引用,将设置弹出窗口拖到脚本上的槽位。现在播放并尝试更改滑块(记得通过按 M 键激活 UI)并在射击时听声音效果;您会听到声音效果根据滑块改变音量。

11.3.3 播放 UI 声音

现在,您将向 AudioManager 添加另一个功能,以便在按钮被点击时 UI 可以播放声音。这项任务比最初看起来更复杂,因为 Unity 需要一个 AudioSource。当场景中的对象发出声音效果时,很明显应该将 AudioSource 附加在哪里。但 UI 声音效果不是场景的一部分,因此您将为 AudioManager 设置一个特殊的 AudioSource,以便在没有其他音频源时使用。

创建一个新的空 GameObject 并将其附加为主游戏管理器对象的子对象;这个新对象将使用 AudioManager 的 AudioSource,因此将新对象命名为 Audio。向此对象添加一个 AudioSource 组件(这次保留 Spatial Blend 设置为 2D,因为 UI 在场景中没有特定的位置),然后添加以下代码以在 AudioManager 中使用此源。

列表 11.7 在 AudioManager 中播放声音效果

...
[SerializeField] AudioSource soundSource;     ❶
...
public void PlaySound(AudioClip clip) {       ❷
    soundSource.PlayOneShot(clip);
}
...

❶ 在检查器中的变量槽位引用新的音频源

❷ 播放没有其他来源的声音。

在管理器的检查器中会出现一个新的变量槽位;将 Audio 对象拖到这个槽位上。现在修改弹出脚本(如下所示)以添加 UI 声音效果。

列表 11.8 向 SettingsPopup 添加声音效果

...
[SerializeField] AudioClip sound;                           ❶
...
public void OnSoundToggle() {
   Managers.Audio.soundMute = !Managers.Audio.soundMute;
   Managers.Audio.PlaySound(sound);                         ❷
}
...

❶ 引用声音片段的检查器槽位

❷ 当按钮被点击时播放声音效果。

将 UI 音效拖到变量槽中;我使用了 2D 音效“thump。”当你点击 UI 按钮时,该音效会同时播放(当然,如果声音没有被静音的话!)尽管 UI 本身没有音频源,但 AudioManager 有一个播放音效的音频源。

太好了,我们已经设置好了所有的音效!现在让我们把注意力转向音乐。

11.4 添加背景音乐

你将向游戏中添加背景音乐,你将通过向 AudioManager 添加音乐来实现这一点。如章节引言中所述,音乐剪辑在本质上与音效没有区别。数字音频通过波形工作的方式相同,播放音频的命令也大致相同。主要区别是音频的长度,但这种差异会引发许多后果。

首先,音乐曲目往往会在计算机上消耗大量内存,并且必须优化这种内存消耗。你必须注意两个内存问题区域:在需要之前将音乐加载到内存中,以及加载时消耗过多内存。

使用第九章中介绍的 Resources.Load()命令优化音乐加载的时间。正如你所学的,这个命令允许你按名称加载资源。尽管这确实是一个方便的功能,但这不是从 Resources 文件夹加载资源的唯一原因。另一个关键考虑因素是延迟加载:通常,Unity 在场景加载时立即加载场景中的所有资源,但来自 Resources 的资源只有在代码手动获取它们时才会加载。在这种情况下,我们想要懒加载音乐音频剪辑。否则,即使音乐没有被使用,它也可能消耗大量内存。

定义 使用懒加载,文件不是提前加载,而是在需要时才延迟加载。通常,如果数据在使用前提前加载,响应速度会更快(例如,声音会立即播放),但懒加载可以在响应性不是很重要的情况下节省大量内存。

第二个内存考虑因素是通过从光盘流式传输音乐来处理的。如第 11.1.2 节所述,流式传输音频可以防止计算机需要一次性加载整个文件。加载风格是导入音频剪辑检查器中的一个设置。最终,播放背景音乐需要几个步骤,包括覆盖这些内存优化步骤。

11.4.1 播放音乐循环

播放音乐的过程涉及与 UI 音效相同的步骤序列(背景音乐也是场景内没有源的 2D 声音),因此我们将再次走完所有这些步骤:

  1. 导入音频剪辑。

  2. 为 AudioManager 设置 AudioSource。

  3. 编写代码在 AudioManager 中播放音频剪辑。

  4. 将音乐控件添加到用户界面中。

每个步骤都将略微修改以适应音乐而不是音效。让我们看看第一步。

第 1 步:导入音频剪辑

通过下载或录制曲目来获取一些音乐。对于示例项目,我去了www.freesound.org并下载了以下公共领域的音乐循环:

  • “loop” by Xythe/Ville Nousiainen

  • “Intro Synth” by noirenex

将文件拖入 Unity 以导入它们,然后在检查器中调整它们的导入设置。如前所述,音乐音频剪辑通常具有与音效音频剪辑不同的设置。首先,音频格式应设置为 Vorbis,用于压缩音频。记住,压缩音频将具有显著较小的文件大小。压缩也会稍微降低音频质量,但对于较长的音乐剪辑来说,这种轻微的降级是可以接受的折衷方案;在出现的滑块中将质量设置为 50%。

接下来要调整的下一个导入设置是加载类型。同样,音乐应从光盘流式传输,而不是完全加载。从加载类型菜单中选择流式传输。同样,打开后台加载,这样在音乐加载时游戏不会暂停或减慢。

即使调整了所有导入设置,资产文件也必须移动到正确的位置才能正确加载。记住,Resources.Load()命令要求资产在 Resources 文件夹中。创建一个名为 Resources 的新文件夹,在该文件夹内创建一个名为 Music 的文件夹,并将音频文件拖入 Music 文件夹(见图 11.5)。这样就完成了第 1 步。

CH11_F05_Hocking3

图 11.5 音乐音频剪辑放置在 Resources 文件夹内

第 2 步:为 AudioManager 设置 AudioSource

第 2 步是创建一个新的 AudioSource 以播放音乐。创建另一个空的 GameObject,将此对象命名为 Music 1(而不是 Music,因为我们将在本章后面添加 Music 2),并将其作为 Audio 对象的子对象。

将音频源组件添加到 Music 1,然后调整组件中的设置。取消选择“唤醒时播放”,但这次打开循环选项;而音效通常只播放一次,音乐则反复循环播放。将空间混合设置保留在 2D,因为音乐在场景中没有特定的位置。

您可能还想降低优先级值。对于音效,这个值并不重要,所以我们将其保留在默认的 128。但对于音乐,您可能希望降低这个值,所以我将音乐源设置为 60。这个值告诉 Unity 在分层多个声音时哪些声音最重要;有些反直觉,较低的值具有更高的优先级。当同时播放太多声音时,音频系统将开始丢弃声音;通过使音乐比音效具有更高的优先级,您确保当太多音效同时触发时,音乐仍然会播放。

第 3 步:编写代码在 AudioManager 中播放音频剪辑

音乐音频源已经设置好了,所以将以下条目添加到 AudioManager。

列表 11.9 在 AudioManager 中播放音乐

...
[SerializeField] AudioSource music1Source;

[SerializeField] string introBGMusic;                ❶
[SerializeField] string levelBGMusic;                ❶
...
public void PlayIntroMusic() {                       ❷
   PlayMusic(Resources.Load($"Music/{introBGMusic}") as AudioClip);
}
public void PlayLevelMusic() {                       ❸
   PlayMusic(Resources.Load($"Music/{levelBGMusic}") as AudioClip);
}

private void PlayMusic(AudioClip clip) {             ❹
   music1Source.clip = clip;
   music1Source.Play();
}

public void StopMusic() {
   music1Source.Stop();
}
...

❶ 在这些字符串中写入音乐名称。

❷ 从资源中加载开场音乐。

❸ 从资源中加载主音乐。

❹ 通过设置 AudioSource.clip 来播放音乐。

如往常一样,当你选择 Game Managers 对象时,新的序列化变量将在检查器中可见。将 Music 1 拖入音频源槽中。然后在两个字符串变量中输入音乐文件的名称:intro-synth 和 loop。

添加的其余代码调用加载和播放音乐的命令(或者在最后添加的方法中,停止音乐)。Resources.Load()命令从资源文件夹中加载命名的资产(考虑到文件被放置在资源文件夹内的 Music 子文件夹中)。该命令返回一个通用对象,但可以通过使用 as 关键字将该对象转换为更具体的类型(在这种情况下,是 AudioClip)。

加载的音频剪辑随后传递到 PlayMusic()方法。此函数将剪辑设置在 AudioSource 中,然后调用 Play()。正如我之前解释的,使用 PlayOneShot()实现音效更好,但将剪辑设置在 AudioSource 中是音乐的一个更稳健的方法,允许你停止或暂停正在播放的音乐。

第 4 步:向 UI 添加音乐控制

AudioManager 中的新音乐播放方法除非在其他地方被调用,否则不会做任何事情。让我们添加更多按钮到音频 UI,当点击时将播放不同的音乐。以下是步骤再次列出,附带少量解释(如有需要,请参阅第七章):

  1. 将弹出窗口的宽度更改为 350(以容纳更多按钮)。

  2. 创建一个新的 UI 按钮并将其附加到弹出窗口。

  3. 将按钮的宽度设置为 100,位置为 0, -20。

  4. 展开按钮的层级以选择文本标签,并将其设置为 Level Music。

  5. 重复这些步骤两次,以创建两个额外的按钮。

  6. 将一个放置在-105, -20 的位置,另一个放置在 105, -20 的位置(这样它们会出现在两侧)。

  7. 将第一个文本标签更改为 Intro Music,最后一个文本标签更改为 No Music。

现在弹出窗口有三个按钮用于播放不同的音乐。在 SettingsPopup 中编写一个方法,该方法将与每个按钮相关联。

列表 11.10 向 SettingsPopup 添加音乐控制

...
public void OnPlayMusic(int selector) {      ❶
   Managers.Audio.PlaySound(sound);

   switch (selector) {                       ❷
      case 1:
         Managers.Audio.PlayIntroMusic();
         break;
      case 2:
         Managers.Audio.PlayLevelMusic();
         break;
      default:
         Managers.Audio.StopMusic();
         break;
   }
}
...

❶ 此方法从按钮获取一个数字参数。

❷ 为每个按钮调用 AudioManager 中的不同音乐函数。

注意这次函数接受一个 int 参数;通常,按钮方法没有参数,只是通过按钮触发。在这种情况下,我们需要区分三个按钮,因此每个按钮将发送不同的数字。

按照典型的步骤将按钮连接到这段代码:在检查器中的 OnClick 面板中添加一个条目,将弹出窗口拖到对象槽中,并从菜单中选择适当的函数。这次,显示了一个用于输入数字的文本框,因为 OnPlayMusic()需要一个数字作为参数。输入 1 表示开场音乐,2 表示关卡音乐,其他任何内容表示没有音乐(我选择了 0)。OnMusic()中的 switch 语句根据数字播放开场音乐或关卡音乐,如果数字不是 1 或 2,则默认停止音乐。

当你在游戏播放时点击音乐按钮,你会听到音乐。太好了!代码正在从 Resources 文件夹中加载音频剪辑。音乐播放效率很高,尽管我们仍然需要添加两个细节:单独的音乐音量控制和在更改音乐时的淡入淡出。

11.4.2 单独控制音乐音量

游戏已经有了音量控制,目前这也影响了音乐。不过,大多数游戏都有单独的音效和音乐音量控制,所以让我们现在解决这个问题。

第一步是告诉音乐 AudioSource 忽略 AudioListener 上的设置。我们希望全局 AudioListener 的音量和静音设置继续影响所有音效,但我们不希望这个音量应用于音乐。列表 11.10 包含了告诉音乐源忽略 AudioListener 音量的代码。接下来的列表还添加了音乐音量和静音控制,所以将其添加到 AudioManager。

列表 11.11 在 AudioManager 中单独控制音乐音量

...
private float _musicVolume;                         ❶
public float musicVolume {
   get {
      return _musicVolume;
   }
   set {
      _musicVolume = value;

      if (music1Source != null) {                   ❷
         music1Source.volume = _musicVolume;
      }
   }
}
...
public bool musicMute {
   get {
      if (music1Source != null) {
         return music1Source.mute;
      }
      return false;                                 ❸
   }
   set {
      if (music1Source != null) {
         music1Source.mute = value;
      }
   }
}

*public void Startup(NetworkService service) {*       ❹
   *Debug.Log("Audio manager starting...");

   network = service;*       

   music1Source.ignoreListenerVolume = true;        ❺
   music1Source.ignoreListenerPause = true;         ❺

   *soundVolume = 1f;*
   musicVolume = 1f;

   *status = ManagerStatus.Started;*                  ❹
*}*                                                   ❹
...

❶ 私有变量,不会直接访问,只能通过属性的 getter 访问

❷ 直接调整 AudioSource 的音量。

❸ 如果 AudioSource 缺失时的默认值

❹ 已在脚本中使用的斜体代码,此处展示以供参考。

❺ 这些属性告诉 AudioSource 忽略 AudioListener 的音量。

这段代码的关键是意识到你可以直接调整 AudioSource 的音量,即使这个音频源正在忽略在 AudioListener 中定义的全局音量。音量和静音属性都用于操作单个音乐源。

Startup()方法将 ignoreListenerVolume 和 ignoreListenerPause 都设置为开启,初始化音乐源。正如其名称所暗示的,这些属性导致音频源忽略 AudioListener 上的全局音量设置。

你可以点击“播放”现在来验证音乐不再受现有音量控制的影响。让我们为音乐音量添加第二个 UI 控制;首先调整 SettingsPopup。

列表 11.12 在 SettingsPopup 中的音乐音量控制

...
public void OnMusicToggle() {
   Managers.Audio.musicMute = !Managers.Audio.musicMute;    ❶
   Managers.Audio.PlaySound(sound);
}

public void OnMusicValue(float volume) {
   Managers.Audio.musicVolume = volume;                     ❷
}
...

❶ 重复静音控制,但使用 musicMute。

❷ 重复音量控制,但使用 musicVolume。

这段代码不需要太多解释——它主要是重复声音音量控制。显然,使用的 AudioManager 属性已经从 soundMute/soundVolume 更改为 musicMute/musicVolume。

在编辑器中,创建一个按钮和滑块,就像你之前做的那样。以下步骤再次列出:

  1. 将弹出窗口的高度更改为 225(以容纳更多控件)。

  2. 创建一个 UI 按钮。

  3. 将按钮作为父级添加到弹出窗口中。

  4. 将按钮定位在 0, -60。

  5. 展开按钮的层次结构以选择其文本标签。

  6. 将文本更改为切换音乐。

  7. 创建一个滑块(来自相同的 UI 菜单)。

  8. 将滑块作为父级添加到弹出窗口中,并将其定位在 0, -85。

  9. 将滑块的值(在检查器的底部)设置为 1。

将这些 UI 控件链接到 SettingsPopup 中的代码。在 UI 元素的设置中找到 OnClick/OnValueChanged 面板,点击+按钮添加条目,将弹出对象拖到对象槽中,并从菜单中选择函数。您需要选择的函数是 Dynamic Float 部分中的 OnMusicToggle()和 OnMusicValue()。

运行此代码,你会看到控件分别影响音效和音乐。这已经很复杂了,但还有一个细节需要润色:音乐轨道之间的交叉淡入淡出。

11.4.3 在歌曲之间淡入淡出

作为最后的润色,让我们让 AudioManager 在不同的背景曲调之间淡入淡出。目前,音乐轨道之间的切换相当刺耳,声音突然切断并切换到新轨道。我们可以通过让上一个轨道的音量迅速减少,同时新轨道的音量从 0 迅速上升来平滑这个过渡。这是一段简单但巧妙的代码,结合了你刚才看到的音量控制方法,以及一个协程来随时间逐步改变音量。

列表 11.13 向 AudioManager 添加了许多位,但大多数都围绕一个简单的概念:既然我们现在有两个独立的音频源,我们将在不同的音频源上播放不同的音乐轨道,并逐步增加一个源的音量,同时同时降低另一个源的音量。(如往常一样,斜体代码已经在脚本中,这里显示以供参考。)

列表 11.13 在 AudioManager 中实现音乐之间的交叉淡入淡出

...
[SerializeField] AudioSource music2Source;            ❶

private AudioSource activeMusic;                      ❷
private AudioSource inactiveMusic;

public float crossFadeRate = 1.5f;
private bool crossFading;                             ❸
...
public float musicVolume {
   ...
   set {
      _musicVolume = value;

      if (music1Source != null && !crossFading) {
         music1Source.volume = _musicVolume;
         music2Source.volume = _musicVolume;          ❹
      }
   }
}
...
public bool musicMute {
   ...
   set {
      if (music1Source != null) {
         music1Source.mute = value;
         music2Source.mute = value;
      }
   }
}

*public void Startup(NetworkService service) {
   Debug.Log("Audio manager starting...");

   network = service;

   music1Source.ignoreListenerVolume = true;*
   music2Source.ignoreListenerVolume = true;
   *music1Source.ignoreListenerPause = true;*
   music2Source.ignoreListenerPause = true;

   *soundVolume = 1f;
   musicVolume = 1f;*

   activeMusic = music1Source;                        ❺
   inactiveMusic = music2Source;

   *status = ManagerStatus.Started;
}*
...
private void PlayMusic(AudioClip clip) {
   if (crossFading) {return;}
   StartCoroutine(CrossFadeMusic(clip));              ❻
}
private IEnumerator CrossFadeMusic(AudioClip clip) {
   crossFading = true;

   inactiveMusic.clip = clip;
   inactiveMusic.volume = 0;
   inactiveMusic.Play();

   float scaledRate = crossFadeRate * musicVolume;
   while (activeMusic.volume > 0) {
      activeMusic.volume -= scaledRate * Time.deltaTime;
      inactiveMusic.volume += scaledRate * Time.deltaTime;

      yield return null;                              ❼
   }

   AudioSource temp = activeMusic;                    ❽

   activeMusic = inactiveMusic;
   activeMusic.volume = musicVolume;

   inactiveMusic = temp;
   inactiveMusic.Stop();

   crossFading = false;
}

public void StopMusic() {
   activeMusic.Stop();
   inactiveMusic.Stop();
}
...

❶ 第二个 AudioSource(也要保留第一个)

❷ 跟踪哪个源是活动的,哪个是未活动的。

❸ 在交叉淡入淡出发生时避免错误的切换

❹ 调整两个音乐源的音量。

❺ 将一个初始化为活动的 AudioSource。

❻ 在更改音乐时调用协程。

❼ Yield 语句暂停一帧。

❽ 在交换活动和未活动时使用的临时变量

第一个添加是为第二个音乐源创建的变量。在保留第一个 AudioSource 对象的同时,复制该对象(确保设置相同——选择 Loop),然后将新对象拖到这个检查器槽中。代码还定义了 activeMusic 和 inactiveMusic 这两个 AudioSource 变量,但这些都是代码内部使用的私有变量,不在检查器中暴露。具体来说,这些变量定义了在任何给定时间哪个音频源被认为是活动的或未活动的。

现在代码在播放新音乐时会调用一个协程。这个协程在保持旧音乐在旧 AudioSource 上播放的同时,将新音乐设置在新的 AudioSource 上播放。然后,协程逐渐增加新音乐的音量,同时逐渐减少旧音乐的音量。一旦交叉淡入淡出完成(即音量完全交换),函数会交换哪个音频源被认为是活动状态和无效状态。

太好了!我们已经完成了游戏音频系统的背景音乐。

高级游戏音频插件,适用于 FMOD 和 Wwise

Unity 中的音频系统由 FMOD 提供支持,这是一个流行的音频编程库。Unity 集成了 FMOD 的许多功能,但更高级的音频功能可以通过 FMOD Studio 访问,该插件可在www.fmod.com/unity/获取。或者,Wwise 是另一个音频系统,它也提供了一个 Unity 插件:mng.bz/6mvD

本章中的示例坚持使用 Unity 内置的功能,因为核心功能构成了游戏音频系统最重要的功能。大多数游戏开发者通过这些核心功能就能很好地满足他们的音频需求,但这些插件对于那些希望使游戏音频更加复杂的人来说是有用的。

摘要

  • 音效应该是未压缩的音频,音乐应该是压缩的,但两者都使用 WAV 格式,因为 Unity 会对导入的音频应用压缩。

  • 音频剪辑可以是始终播放相同的 2D 声音,或者是对听众位置做出反应的 3D 声音。

  • 使用 Unity 的 AudioListener 可以轻松全局调整音效的音量。

  • 你可以为播放音乐的各个音频源设置音量。

  • 你可以通过设置各个音频源的音量来淡入淡出背景音乐。

12 将部分组合成一个完整的游戏

本章涵盖

  • 从其他项目组装对象和代码

  • 编程点按控制

  • 从旧 UI 系统升级到新系统

  • 根据目标加载新关卡

  • 设置胜负条件

  • 保存和加载玩家的进度

本章的项目将把之前章节中的所有内容串联起来。大多数章节都相当独立,我们没有从头到尾审视整个游戏。我将带你了解如何将分别引入的各个部分组合起来,以便你知道如何从所有这些部分构建一个完整的游戏。

我还将讨论游戏的整体结构,包括切换关卡和结束游戏(当你死亡时显示“游戏结束”,达到出口时显示“成功”)。我还会向你展示如何保存游戏,因为随着游戏规模的扩大,保存玩家的进度变得越来越重要。

警告:本章的大部分内容使用了在之前章节中已详细解释的任务,所以我会快速地通过这些步骤。如果某些步骤让你感到困惑,请参考相关章节(例如,关于 UI 的第七章)以获得更详细的解释。

本章的项目是一个动作角色扮演游戏(RPG)的演示。在这类游戏中,摄像机放置得较高,并且向下锐利地观察(见图 12.1),角色通过点击鼠标到你想要去的地方来控制。你可能熟悉《暗黑破坏神》这款游戏,它就是这样一款动作 RPG。我将切换到另一个游戏类型,以便在本书结束前再塞进一个类型!

CH12_F01_Hocking3

图 12.1 顶视图截图

完整来说,本章的项目将是迄今为止最大的游戏。它将具有以下功能:

  • 顶视图和点击移动

  • 点击设备以操作它们的能力

  • 可以收集的散落物品

  • 在 UI 窗口中显示的库存

  • 在关卡中游荡的敌人

  • 保存游戏并恢复进度的能力

  • 必须按顺序完成的三个关卡

哇,要包含的内容很多;幸好这几乎是最后一章了!

12.1 通过重新利用项目构建动作 RPG

我们将通过在第九章的项目基础上构建来开发动作 RPG 演示。复制该项目的文件夹,并在 Unity 中打开副本以开始工作。或者,如果你直接跳到了这一章,请下载第九章的示例项目以在此基础上构建。

我们基于第九章的项目进行构建的原因是,它离本章的目标最近,因此需要最少的修改(与其他项目相比)。最终,我们将从几个章节中汇集资源,所以从技术上讲,这并不比我们从那些项目开始并从第九章中提取资源有太大不同。

这里是第九章项目中包含内容的回顾:

  • 已经设置好动画控制器的角色

  • 一个跟随角色周围的三人称摄像机

  • 一个有地板、墙壁和斜坡的水平面

  • 灯光和阴影都已放置

  • 可操作设备,包括颜色变化的显示器

  • 可收集的库存物品

  • 后端管理器代码框架

这个功能丰富的列表涵盖了 RPG 示例中的相当一部分动作,但我们可能需要修改或添加更多。

12.1.1 从多个项目中组装资源和代码

前两项修改将是更新管理框架并引入计算机控制的敌人。对于前一个任务,回想一下在第十章中进行了框架的更新,这意味着这些更新不在第九章的项目中。对于后一个任务,回想一下你在第三章中编写了一个敌人。

更新管理框架

更新管理器是一个相当简单的任务,所以我们首先把它解决掉。在第十章中修改了 IGameManager 接口。

列表 12.1 调整后的 IGameManager

public interface IGameManager {
   ManagerStatus status {get;}

   void Startup(NetworkService service);
}

列表中的代码添加了对 NetworkService 的引用,因此也务必复制额外的脚本;从第十章项目中的位置拖拽文件(记住,Unity 项目是磁盘上的一个文件夹,所以从那里获取文件),并将其放入新项目中。现在修改 Managers 以适应更改后的接口。

列表 12.2 在 Managers 脚本中修改一些代码

...
private IEnumerator StartupManagers() {             ❶
   NetworkService network = new NetworkService();

   foreach (IGameManager manager in startSequence) {
      manager.Startup(network);
   }
   ...

❶ 该方法的调整在开头。

最后,调整 InventoryManager 和 PlayerManager 以反映更改后的接口。下面的列表显示了修改后的 InventoryManager 代码;PlayerManager 需要相同的代码修改,但名称不同。

列表 12.3 调整 InventoryManager 以反映 IGameManager

...
private NetworkService network;

public void Startup(NetworkService service) {
   Debug.Log("Inventory manager starting...");    ❶

   network = service;

   items = new Dictionary<string, int>();
   ...

❶ 在两个管理器中执行相同的调整,但更改名称

一旦所有的微小代码更改都完成,一切仍然应该像以前一样工作。这次更新应该无痕进行,游戏仍然会按原样运行。那次调整很简单,但下一次会更难。

将 AI 敌人迁移过来

除了第十章中的 NetworkServices 调整外,你还需要第三章中的 AI 敌人。实现敌人角色涉及大量的脚本和艺术资源,因此你需要导入所有这些资源。

首先,复制这些脚本(记住,WanderingAI 和 ReactiveTarget 是 AI 敌人的行为,Fireball 是发射的弹丸,敌人攻击 PlayerCharacter 组件,SceneController 负责生成敌人):

  • PlayerCharacter

  • SceneController

  • WanderingAI

  • ReactiveTarget

  • Fireball

同样,通过拖拽这些文件来获取火焰材质、火球预制体和敌人预制体。如果你是从第十一章而不是第三章获取的敌人,你可能还需要添加的火焰粒子材质。

在复制所有必需的资产后,资产之间的链接可能会断开,因此你需要将断开的资产中引用的对象重新链接,以便它们能够工作。特别是,检查所有预制体上的脚本,因为它们可能已经断开连接。例如,敌人预制体在检查器中缺少两个脚本,所以点击图 12.2 中指示的圆形按钮,从脚本列表中选择 WanderingAI 和 ReactiveTarget。同样,检查火球预制体,并在需要时重新链接该脚本。一旦你处理完脚本,检查材质和纹理的链接。

CH12_F02_Hocking3

图 12.2 将脚本链接到组件

现在将 SceneController 添加到控制器对象中,并将敌人预制体拖到检查器中该组件的敌人槽位。你可能需要将火球预制体拖到敌人的脚本组件上(选择敌人预制体,并在检查器中查看 WanderingAI)。此外,将 PlayerCharacter 附加到玩家对象上,以便敌人攻击玩家。

玩游戏,你会看到敌人四处游荡。敌人向玩家发射火球,尽管它们造成的伤害不大;选择火球预制体,并将其伤害值设置为 10。

注意:目前,敌人并不擅长追踪和击中玩家。在这种情况下,我会首先给敌人一个更宽的视野(使用第九章中的点积方法)。最终,你将在游戏打磨上花费大量时间,这包括迭代敌人的行为。虽然打磨游戏以使其更有趣对于游戏的发布至关重要,但这本书中不会涉及这一部分。

另一个问题是你编写第三章中的代码时,玩家的健康值是一个临时的添加,是为了测试而编写的。现在游戏有了 PlayerManager,所以根据下面的列表修改 PlayerCharacter,以便与该管理器中的健康值一起工作。

列表 12.4 调整 PlayerCharacter 以使用 PlayerManager 中的健康值

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerCharacter : MonoBehaviour {
   public void Hurt(int damage) {
      Managers.Player.ChangeHealth(-damage);    ❶
   }
}

❶ 使用 PlayerManager 中的值而不是 PlayerCharacter 中的变量。

在这个阶段,你已经有一个游戏演示,其中的角色是由多个先前项目中的部件组装而成的。场景中增加了一个敌人角色,使得游戏更具威胁性。但是控制方式和视角仍然是来自第三人称移动演示,所以让我们为动作角色扮演游戏实现点按控制。

12.1.2 编程点按控制:移动和设备

这个演示需要一个俯视图和鼠标控制玩家的移动(参考图 12.1)。目前,摄像头响应鼠标,而玩家响应键盘(如第八章中编程的那样),这与本章中你想要的效果相反。此外,你将修改颜色变化的显示器,以便通过点击来操作设备。在这两种情况下,现有的代码与所需的代码并不相去甚远;你将对移动和设备脚本进行调整。

设置场景的俯视图

首先,将相机提升到 8 Y 以定位它进行俯视图。你还将调整 OrbitCamera 以从相机中移除鼠标控制,并仅使用箭头键。

列表 12.5 调整 OrbitCamera 以移除鼠标控制

...
void LateUpdate() {
   rotY -= Input.GetAxis("Horizontal") * rotSpeed;      ❶
   Quaternion rotation = Quaternion.Euler(0, rotY, 0);
   transform.position = target.position - (rotation * offset);
   transform.LookAt(target);
}
...

❶ 反转之前的方向。

相机的近/远裁剪平面

当你在调整相机时,我想指出近/远裁剪平面。这些设置之前从未出现过,因为默认值是合适的,但在未来的项目中你可能需要调整这些设置。

如果你需要调整这些值,在场景中选择相机,并在检查器中查找裁剪平面部分;近和远都是你在这里输入的数字。这些值定义了多边形渲染的近和远边界。比近裁剪面更近或比远裁剪面更远的多边形不会被绘制。

你希望近/远裁剪平面尽可能靠近,同时仍然足够远,以便渲染场景中的所有内容。当这些平面相距太远(近裁剪面太近,或远裁剪面太远)时,渲染算法就再也无法判断哪个多边形更近。这导致了一种称为z-fighting(在 z 轴上)的典型渲染错误,其中多边形在彼此之上闪烁。

当相机被抬得更高时,你在玩游戏时的视角将是俯视图。然而,目前移动控制仍然使用键盘,所以让我们编写一个脚本来实现点按移动。

编写移动代码

这个代码的一般思路是自动将玩家移动到其目标位置(如图 12.3 所示)。这个位置是通过在场景中点击来设置的。这样,移动玩家的代码不是直接对鼠标做出反应,而是通过点击间接控制玩家的移动。

CH12_F03_Hocking3

图 12.3 点和点击控制的工作原理

备注:此移动算法对 AI 角色也很有用。而不是使用鼠标点击,目标位置可以是在角色跟随的路径上。

为了实现这一点,创建一个新的脚本名为 PointClickMovement,并替换玩家上的 RelativeMovement 组件。通过粘贴 RelativeMovement 的全部内容开始编写 PointClickMovement(因为你仍然需要大部分脚本来处理下落和动画)。然后,根据此列表调整代码。

列表 12.6 PointClickMovement 脚本中的新移动代码

...
public class PointClickMovement : MonoBehaviour {                     ❶
...
public float deceleration = 25.0f;
public float targetBuffer = 1.5f;

private float curSpeed = 0f;
private Vector3? targetPos;                                           ❷
...
void Update() {
   Vector3 movement = Vector3.zero;

   if (Input.GetMouseButton(0)) {                                     ❸
      Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);    ❹
      RaycastHit mouseHit;
      if (Physics.Raycast(ray, out mouseHit)) {
         targetPos = mouseHit.point;                                  ❺
         curSpeed = moveSpeed;
      }
   }

   if (targetPos != null) {                                           ❻
      if (curSpeed > moveSpeed * .5f) {                               ❼
         Vector3 adjustedPos = new Vector3(targetPos.Value.x, 
            transform.position.y, targetPos.Value.z);
         Quaternion targetRot = Quaternion.LookRotation(
            adjustedPos - transform.position);
         transform.rotation = Quaternion.Slerp(transform.rotation, 
            targetRot, rotSpeed * Time.deltaTime);
     }

     movement = curSpeed * Vector3.forward;
     movement = transform.TransformDirection(movement);

     if (Vector3.Distance(targetPos.Value, transform.position) < 
     ➥ targetBuffer) {
         curSpeed -= deceleration * Time.deltaTime;                   ❽
         if (curSpeed <= 0) {
            targetPos = null;
         }
      }
   }
   animator.SetFloat("Speed", movement.sqrMagnitude);                 ❾
   ...

❶ 在粘贴脚本后更正名称。

❷ 使用?符号将此值定义为“可空的”。

❸ 当鼠标点击时设置目标位置。

❹ 在鼠标位置进行射线投射。

❺ 将目标设置为被击中的位置。

❻ 如果设置了目标位置,则移动。

❼ 只有在快速移动时才朝向目标旋转。

❽ 当接近目标时减速至 0。

❾ 从这里向下,一切保持不变。

几乎在 Update()方法开始时的所有内容都被删除了,因为那段代码处理键盘移动。注意,新代码有两个主要的条件语句:一个在鼠标点击时运行,另一个在设置目标时运行。

提示:可空值是此脚本中使用的有用的编程技巧。注意,目标位置值被定义为 Vector3?而不是 Vector3;这是 C#声明可空值的语法。某些值类型(如 Vector3)通常不能设置为 null,但您可能会遇到需要 null 状态表示“未设置值”的情况。在这种情况下,您可以将其设置为可空值,允许您将值设置为 null,然后通过键入 targetPos.Value 来访问底层的 Vector3(或任何其他)。

当鼠标点击时,根据鼠标点击的位置设置目标。这是射线投射的另一个很好的用途:确定场景中鼠标光标下的哪个点。目标位置被设置为鼠标击中的位置。

关于第二个条件,首先旋转以面对目标。Quaternion.Slerp()以平滑的方式旋转以面对目标,而不是立即切换到那个旋转;在减速时(否则,当玩家到达目标时,玩家可能会以奇怪的方式旋转)仅当速度超过一半时旋转,以锁定旋转。然后,将玩家的前向方向从本地坐标转换为全局坐标(以向前移动)。最后,检查玩家和目标之间的距离:如果玩家几乎到达目标,则减少移动速度,并通过移除目标位置最终结束移动。

练习:关闭跳跃控制

目前,这个脚本仍然有来自 RelativeMovement 的跳跃控制。当按下空格键时,玩家仍然会跳跃,但在点对点移动中不应该有跳跃按钮。这里有一个提示:调整'if (hitGround)'条件分支内的代码。

这通过使用鼠标控制来处理玩家的移动。玩一下游戏来测试它。接下来,让我们让设备在点击时操作。

使用 A*和 NavMesh 进行路径查找

我们刚才编写的移动代码将玩家直接引导到目标。然而,游戏中的角色通常必须绕过障碍物找到路径,而不是直线移动。绕过障碍物导航角色被称为路径查找。因为这在游戏中是一个非常常见的情况,Unity 提供了一个内置的路径查找解决方案,称为 NavMesh。更多信息请参阅以下链接:

同时,尽管 NavMesh 是免费的并且工作良好,但许多开发者更喜欢从arongranberg.com/astar/提供的 A*路径查找项目。

使用鼠标操作设备

在第九章(以及在此处,直到我们调整代码),设备是通过按键来操作的。相反,它们应该在点击时操作。为此,你首先将创建一个所有设备都将继承的基础脚本;基础脚本将包含鼠标控制,设备将继承它。创建一个新的脚本名为 BaseDevice,并编写以下列表中所示的代码。

列表 12.7 当点击时操作的 BaseDevice 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BaseDevice : MonoBehaviour {
   public float radius = 3.5f;

   void OnMouseUp() {                                                ❶
      Transform player = GameObject.FindWithTag("Player").transform;
      Vector3 playerPosition = player.position;

      playerPosition.y = transform.position.y;                       ❷
      if (Vector3.Distance(transform.position, playerPosition) < radius) {
         Vector3 direction = transform.position - playerPosition;
         if (Vector3.Dot(player.forward, direction) > .5f) {
            Operate();                                               ❸
         }
      }
   }

   public virtual void Operate() {                                   ❹
      // behavior of the specific device
   }
}

❶ 点击时运行的函数

❷ 垂直位置的修正

❸ 如果玩家附近且面向,则调用 Operate()。

❹ virtual 标记一个可以被继承覆盖的方法。

大部分代码发生在 OnMouseDown 中,因为当对象被点击时,MonoBehaviour 会调用该方法。首先,它检查玩家(带有垂直位置修正,就像第九章中那样)的距离,然后使用点积来判断玩家是否面向设备。Operate()是一个空壳,由继承此脚本的设备来填充。

注意:此代码在场景中查找带有 Player 标签的对象,因此请将此标签分配给玩家对象。标签位于检查器顶部的下拉菜单中;你还可以定义自定义标签,但默认情况下已定义了几个标签,包括 Player。选择玩家对象进行编辑,然后选择 Player 标签。

现在 BaseDevice 已经编程,你可以修改 ColorChangeDevice 使其继承该脚本。这是新的代码。

列表 12.8 调整 ColorChangeDevice 以从 BaseDevice 继承

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ColorChangeDevice : BaseDevice {            ❶
   public override void Operate() {                      ❷
      Color random = new Color(Random.Range(0f,1f), 
         Random.Range(0f,1f), Random.Range(0f,1f));
      GetComponent<Renderer>().material.color = random;
   }
}

❶ 从 MonoBehaviour 继承 BaseDevice。

❷ 从基类重写此方法。

因为这个脚本继承自 BaseDevice 而不是 MonoBehaviour,所以它获得了鼠标控制功能。然后它重写了空的 Operate()方法来编程颜色变化行为。

将相同的更改(从 MonoBehaviour 继承 BaseDevice 并添加对 Operate 方法的覆盖)应用到 DoorOpenDevice。现在这些设备在被点击时会操作。同时移除玩家的 DeviceOperator 脚本组件,因为该脚本通过按键来操作设备。

这个新的设备输入带来了运动控制的问题:目前,运动目标在鼠标点击时设置,但你不想在点击设备时设置运动目标。你可以通过使用层来修复这个问题;类似于在玩家上设置标签的方式,对象可以被设置为不同的层,代码可以检查这一点。调整 PointClickMovement 以检查对象的层。

列表 12.9 在 PointClickMovement 中调整鼠标点击代码

...
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit mouseHit;
if (Physics.Raycast(ray, out mouseHit)) {
   GameObject hitObject = mouseHit.transform.gameObject;      ❶
   if (hitObject.layer == LayerMask.NameToLayer("Ground")) {  ❶
      targetPos = mouseHit.point;
      curSpeed = moveSpeed;
   }
}
...

❶ 添加的代码;其余为参考。

此列表在鼠标点击代码中添加了一个条件,以查看点击的对象是否在地面层。图层(就像标签一样)是检查器顶部的下拉菜单;点击它以查看选项。同样,默认情况下已经定义了几个图层。你想创建一个新的图层,所以请在菜单中选择编辑图层。在空图层槽中输入“地面”(可能是槽 8;代码中的 NameToLayer()函数将名称转换为图层编号,这样你就可以使用名称而不是数字)。

现在地面层已经被添加到菜单中,将地面对象设置为地面层——这意味着建筑物的地板,以及玩家可以行走的斜坡和平台。选择这些对象,然后在图层菜单中选择地面。

玩游戏时,点击颜色变化的监视器不会移动。太好了,点按控制已经完成!从以前的项目中引入这个项目的另一件事是 UI。

12.1.3 用新界面替换旧 GUI

第九章使用了 Unity 的旧即时模式 GUI,因为这种方法更容易编写代码。但第九章的 UI 看起来没有第七章的好,所以让我们引入那个界面系统。新的 UI 比旧的 GUI 更具有视觉上的精致;图 12.4 显示了你要创建的界面。

CH12_F04_Hocking3

图 12.4 本章项目 UI

首先,你需要设置 UI 图形。一旦 UI 图像都已经在场景中,你就可以将脚本附加到 UI 对象上。我将列出涉及到的步骤,但不会深入细节;如果你需要复习,请参考第七章。如果需要,在开始之前安装 TextMeshPro 和 2D Sprite 包(请参考第五章和第六章),然后:

  1. 将 popup.png 作为精灵导入(选择纹理类型)。

  2. 在精灵编辑器中,为所有边设置 12 像素的边框(记得应用更改)。

  3. 在场景中创建一个画布(GameObject > UI > Canvas)。

  4. 选择画布的像素完美设置。

  5. (可选)将对象命名为 HUD Canvas 并切换到 2D 视图模式。

  6. 创建一个与该画布连接的文本对象(GameObject > UI > Text - TextMeshPro)。

  7. 将文本对象的锚点设置为左上角,并将对象的位置设置为 120, -50。

  8. 将标签的顶点颜色设置为黑色,字体大小设置为 16,并输入文本“健康:”。

  9. 创建一个与该画布连接的图像(GameObject > UI > Image)。

  10. 将新对象命名为“库存弹出窗口”。

  11. 将弹出精灵分配给图像的源图像。

  12. 将图像类型设置为切片并选择填充中心。

  13. 将弹出图像定位在 0, 0 处,并将弹出图像的宽度缩放为 250,高度缩放为 150。

注意:回想一下如何在 3D 场景和 2D 界面之间切换视图:切换 2D 视图模式并双击 Canvas 或 Building 来放大该对象。

现在,你已经在角落里有了健康标签,在中心有一个大型的蓝色弹出窗口。在我们深入 UI 功能之前,先编程这些部分。界面代码将使用第七章中相同的消息传递系统,所以复制消息传递脚本。然后创建一个 GameEvent 脚本。

列表 12.10 使用此消息传递系统的 GameEvent 脚本

public static class GameEvent {
   public const string HEALTH_UPDATED = "HEALTH_UPDATED";
}

目前只定义了一个事件;在本章的其余部分,你将添加几个更多的事件。从 PlayerManager 广播此事件。

列表 12.11 从 PlayerManager 广播健康事件

...
public void ChangeHealth(int value) {
   health += value;
   if (health > maxHealth) {
      health = maxHealth;
   } else if (health < 0) {
      health = 0;
   }

   Messenger.Broadcast(GameEvent.HEALTH_UPDATED);     ❶
}
...

❶ 在此函数的末尾添加一行。

每次 ChangeHealth()完成后,事件都会广播,以通知程序其他部分健康已经改变。你想要根据此事件调整健康标签,所以创建一个 UIController 脚本。

列表 12.12 处理界面的 UIController 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

public class UIController : MonoBehaviour {
   [SerializeField] TMP_Text healthLabel;                  ❶
   [SerializeField] InventoryPopup popup;

   void OnEnable() {                                       ❷
      Messenger.AddListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
   }
   void OnDisable() {
      Messenger.RemoveListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
   }

   void Start() {
      OnHealthUpdated();                                   ❸

      popup.gameObject.SetActive(false);                   ❹
   }

   void Update() {
      if (Input.GetKeyDown(KeyCode.M)) {                   ❺
         bool isShowing = popup.gameObject.activeSelf;
         popup.gameObject.SetActive(!isShowing);
         popup.Refresh();
      }
   }

   private void OnHealthUpdated() {                        ❻
      string message = $"Health: {Managers.Player.health}/{Managers.Player.maxHealth}";
      healthLabel.text = message;
   }
}

❶ 在场景中引用 UI 对象。

❷ 设置健康更新事件的监听器。

❸ 在启动时手动调用函数。

❹ 在启动时手动初始化弹出窗口为隐藏状态。

❺ 使用 M 键切换弹出窗口。

❻ 事件监听器调用函数以更新健康标签。

从控制器对象中删除 BasicUI,并将此新脚本附加到画布上(特别是不要附加到控制器对象,该对象现在应该只有 SceneController)。此外,创建一个 InventoryPopup 脚本(现在添加一个空的 public Refresh()方法;其余将在以后填写),并将其附加到库存弹出窗口对象上。现在你可以将弹出窗口拖到 Canvas 对象的 UIController 组件中的参考槽位(然后对健康标签做同样的操作)。

当你受伤或使用健康包时,健康标签会改变,按下 M 键切换弹出窗口。最后需要调整的一个细节是,点击弹出窗口目前会导致玩家移动;与设备一样,当 UI 被点击时,你不想设置目标位置。调整 PointClickMovement。

列表 12.13 在 PointClickMovement 中检查 UI

using UnityEngine.EventSystems;
...
void Update() {
   Vector3 movement = Vector3.zero;
   if (Input.GetMouseButton(0) && !EventSystem.current.IsPointerOverGameObject()) {
   ...

注意,条件检查鼠标是否在 UI 上。这样,界面的整体结构就完成了,现在让我们专门处理库存弹出窗口。

实现库存弹出窗口

弹出窗口目前是空的,但它应该显示玩家的库存(如图 12.5 所示)。以下步骤将创建 UI 对象:

  1. 创建四个图像并将它们作为弹出窗口的父级(即,在层次结构中拖动对象)。

  2. 创建四个文本标签并将它们作为弹出窗口的父级。

  3. 将所有图像定位在 Y 轴 0 处,并将 X 值设置为-75、-25、25 和 75。

  4. 将文本标签定位在 Y 轴 45 处,并将 X 值设置为-75、-25、25 和 75。

  5. 将文本(不是锚点!)设置为居中对齐,垂直对齐为底部,高度为 60。

  6. 为所有文本标签输入 x2,设置顶点颜色为黑色,字体大小为 16。

  7. 在资源中,将所有库存图标设置为 Sprite(而不是纹理)。

  8. 将这些精灵拖到图像对象的源图像槽位(也设置原始大小)。

  9. 添加另一个文本标签和两个按钮,所有这些都在弹出窗口中作为父级。

  10. 将此文本标签定位在-140,-45 处,使用右对齐和中间垂直对齐。

  11. 在此标签的文本中键入“能量:”,将顶点颜色设置为黑色,并将字体大小设置为 14。

  12. 将两个按钮的宽度都设置为 60。对于位置,将 Y 设置为-50,X 设置为 0 或 70。

  13. 在层次结构中展开两个按钮,并在一个按钮上键入“装备”,在另一个按钮上键入“使用”。

CH12_F05_Hocking3

图 12.5 库存 UI 图

这些是库存弹出窗口的视觉元素;接下来是代码。将以下内容写入 InventoryPopup 脚本。

列 12.14 InventoryPopup 的完整脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;

public class InventoryPopup : MonoBehaviour {
   [SerializeField] Image[] itemIcons;                                  ❶
   [SerializeField] TMP_Text[] itemLabels;                              ❶

   [SerializeField] TMP_Text curItemLabel;
   [SerializeField] Button equipButton;
   [SerializeField] Button useButton;

   private string curItem;

   public void Refresh() {
      List<string> itemList = Managers.Inventory.GetItemList();

      int len = itemIcons.Length;
      for (int i = 0; i < len; i++) {
         if (i < itemList.Count) {                                      ❷
            itemIcons[i].gameObject.SetActive(true);
            itemLabels[i].gameObject.SetActive(true);

            string item = itemList[i];

            Sprite sprite = Resources.Load<Sprite>($"Icons/{item}");    ❸
            itemIcons[i].sprite = sprite;
            itemIcons[i].SetNativeSize();                               ❹

            int count = Managers.Inventory.GetItemCount(item);
            string message = $"x{count}";
            if (item == Managers.Inventory.equippedItem) {
               message = "Equipped\n" + message;                        ❺
            }
            itemLabels[i].text = message;

            EventTrigger.Entry entry = new EventTrigger.Entry();
            entry.eventID = EventTriggerType.PointerClick;              ❻
            entry.callback.AddListener((BaseEventData data) => {
               OnItem(item);                                            ❼
            });

            EventTrigger trigger = itemIcons[i].GetComponent<EventTrigger>();
            trigger.triggers.Clear();                                   ❽
            trigger.triggers.Add(entry);                                ❾
         }
         else {
            itemIcons[i].gameObject.SetActive(false);                   ❿
            itemLabels[i].gameObject.SetActive(false);                  ❿
         }
      }

      if (!itemList.Contains(curItem)) {
         curItem = null;
      }
      if (curItem == null) {                                            ⓫
         curItemLabel.gameObject.SetActive(false);
         equipButton.gameObject.SetActive(false);
         useButton.gameObject.SetActive(false);
      }
      else {                                                            ⓬
         curItemLabel.gameObject.SetActive(true);
         equipButton.gameObject.SetActive(true);
         if (curItem == "health") {                                     ⓭
            useButton.gameObject.SetActive(true);
         } else {
            useButton.gameObject.SetActive(false);
         }

         curItemLabel.text = $"{curItem}:";
      }
   }

   public void OnItem(string item) {                                    ⓮
      curItem = item;
      Refresh();                                                        ⓯
   }

   public void OnEquip() {
      Managers.Inventory.EquipItem(curItem);
      Refresh();
   }

   public void OnUse() {
      Managers.Inventory.ConsumeItem(curItem);
      if (curItem == "health") {
         Managers.Player.ChangeHealth(25);
      }
      Refresh();
   }
}

❶ 用于引用四个图像和文本标签的数组

❷ 在遍历所有 UI 图像时检查库存列表。

❸ 从资源加载精灵。

❹ 将图像调整到精灵的原始大小。

❺ 标签可能除了物品计数外还会说“装备”。

❻ 启用点击图标。

❼ Lambda 函数针对每个项目触发不同的操作

❽ 清除听众以从干净石板刷新。

❾ 将此监听器函数添加到 EventTrigger。

❿ 如果没有要显示的物品,则隐藏此图像/文本。

⓫ 如果没有选择物品,则隐藏按钮。

⓬ 显示当前选中的物品。

⓭ 只使用按钮用于健康物品。

⓮ 由鼠标点击监听器调用的函数

⓯ 在做出更改后刷新库存显示。

呼,这是一个很长的脚本!有了这个程序,现在是时候将界面中的所有内容链接起来。弹出窗口对象上的脚本组件现在有各种对象引用,包括两个数组;展开两个数组并将长度设置为 4(见图 12.6)。将四个图像拖到图标数组中,将四个文本标签拖到标签数组中。

CH12_F06_Hocking3

图 12.6 检查器中显示的数组

注意:如果您不确定哪个对象被拖到了哪里(它们看起来都一样),请点击检查器中的槽位,以在层次结构视图中突出显示该对象。

类似地,组件中的槽位引用弹出窗口底部的文本标签和按钮。在链接这些对象后,您将为两个按钮添加 OnClick 监听器。将这些事件链接到弹出窗口对象,并选择适当的 OnEquip()或 OnUse()。

最后,将事件触发器组件添加到所有四个物品图像上。InventoryPopup 脚本会修改每个图标的该组件,所以它们最好有这个组件!您可以在“添加组件”>“事件”下找到 EventTrigger。(通过点击组件右上角的齿轮按钮,复制/粘贴组件可能更方便,从一个对象中选择复制组件,然后在另一个对象上粘贴为新组件。)添加此组件,但不要分配事件监听器,因为那是在 InventoryPopup 代码中完成的。

这样就完成了库存用户界面!玩玩游戏,看看当你收集物品和点击按钮时库存弹出窗口如何响应。我们现在已经完成了从以前的项目中组装部件;接下来,我将解释如何从这个起点构建一个更庞大的游戏。

12.2 开发整体游戏结构

现在你已经有一个功能齐全的动作角色扮演游戏演示,我们将构建这个游戏的整体结构。我的意思是,游戏通过多个关卡的整体流程,通过击败关卡来推进游戏。我们从第九章的项目中得到了一个单一关卡,但本章的路线图指定了三个关卡。

做这件事将进一步解耦场景和 Managers 后端,因此你需要广播关于经理的消息(就像 PlayerManager 广播健康更新一样)。创建一个名为 StartupEvent 的新脚本(列表 12.15);在单独的脚本中定义这些事件,因为这些事件与可重用的 Managers 系统相关,而 GameEvent 是特定于游戏的。

列表 12.15 StartupEvent 脚本

public static class StartupEvent {
   public const string MANAGERS_STARTED = "MANAGERS_STARTED";
   public const string MANAGERS_PROGRESS = "MANAGERS_PROGRESS";
}

现在是时候开始调整经理们了,包括广播这些新事件!

12.2.1 控制任务流程和多个关卡

目前,项目只有一个场景,游戏经理对象就在那个场景中。问题是每个场景都会有一组自己的游戏经理,而你希望有一个所有场景共享的单个游戏经理集合。为了做到这一点,你将创建一个单独的启动场景,初始化经理,然后与其他游戏场景共享该对象。

我们还需要一个新的经理来处理游戏进度。创建一个名为 MissionManager 的新脚本。

列表 12.16 创建 MissionManager

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class MissionManager : MonoBehaviour, IGameManager {
   public ManagerStatus status {get; private set;}

   public int curLevel {get; private set;}
   public int maxLevel {get; private set;}

   private NetworkService network;

   public void Startup(NetworkService service) {
      Debug.Log("Mission manager starting...");

      network = service;

      curLevel = 0;
      maxLevel = 1;

      status = ManagerStatus.Started;
   }

   public void GoToNext() {
      if (curLevel < maxLevel) {          ❶
         curLevel++;
         string name = $"Level{curLevel}";
         Debug.Log($"Loading {name}");
         SceneManager.LoadScene(name);    ❷
      } else {
         Debug.Log("Last level");
      }
   }
}

❶ 检查是否达到最后一个关卡。

❷ Unity 加载场景的命令

在这个列表中,大部分内容都很正常,但请注意结尾附近的 LoadScene() 方法。尽管我之前提到过这个方法(在第五章中),但现在它更为重要。这是 Unity 加载场景文件的方法;在第五章中,你用它来重新加载游戏中的一个场景,但你可以通过传递场景文件名来加载任何场景。

将此脚本附加到场景中的游戏经理对象。同时将新组件添加到经理脚本中。

列表 12.17 向经理脚本添加新组件

...
[RequireComponent(typeof(MissionManager))]

public class Managers : MonoBehaviour {
   public static PlayerManager Player {get; private set;}
   public static InventoryManager Inventory {get; private set;}
   public static MissionManager Mission {get; private set;}
   ...
   void Awake() {
      DontDestroyOnLoad(gameObject);                                ❶

      Player = GetComponent<PlayerManager>();
      Inventory = GetComponent<InventoryManager>();
      Mission = GetComponent<MissionManager>();

      startSequence = new List<IGameManager>();
      startSequence.Add(Player);
      startSequence.Add(Inventory);
      startSequence.Add(Mission);

      StartCoroutine(StartupManagers());
   }

   private IEnumerator StartupManagers() {
      ...
        if (numReady > lastReady) {
          Debug.Log($"Progress: {numReady}/{numModules}");
          Messenger<int, int>.Broadcast(
            StartupEvent.MANAGERS_PROGRESS, numReady, numModules);  ❷
        }

        yield return null;
      }

      Debug.Log("All managers started up");
      Messenger.Broadcast(StartupEvent.MANAGERS_STARTED);           ❸
   }
   ...

❶ Unity 在场景间持久化对象的命令

❷ 带有与事件相关数据的启动事件广播。

❸ 无参数的启动事件广播。

这段代码的大部分内容你应该已经很熟悉了(添加 MissionManager 就像添加其他经理一样),但有两部分是新的。一个是发送两个整数值的事件;你之前已经看到了无参数的通用事件和只有一个数字的消息,但你可以用相同的语法发送任意数量的值。

另一段新代码是 DontDestroyOnLoad() 方法。这是 Unity 提供的一种在场景之间持久化对象的方法。通常,当加载新场景时,场景中的所有对象都会被清除,但通过在对象上使用 DontDestroyOnLoad(),您可以确保该对象在新场景中仍然存在。

分离启动和级别场景

由于游戏管理人员对象将在所有场景中持续存在,您必须将管理人员与游戏各个级别分开。在项目视图中,复制场景文件(编辑 > 复制)然后适当地重命名两个文件:一个为 Startup,另一个为 Level1。打开 Level1 并删除游戏管理人员对象(它将由 Startup 提供)。打开 Startup 并删除除了游戏管理人员、控制器、主摄像机、HUD 画布和 EventSystem 之外的所有内容。通过移除 OrbitCamera 组件并更改 Clear Flags 菜单从 Skybox 到 Solid Color 来调整摄像机。从控制器上移除脚本组件,并删除附加到 Canvas 的 UI 对象(健康标签和库存弹出窗口)。

当前 UI 是空的,因此创建一个新的滑动条(见图 12.7)然后关闭其交互设置。控制器对象不再有任何脚本组件,因此创建一个新的 StartupController 脚本(列表 12.18)并将其附加到控制器对象上。

CH12_F07_Hocking3

图 12.7 移除了所有不必要的元素的 Startup 场景

列表 12.18 新的 StartupController 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class StartupController : MonoBehaviour {
   [SerializeField] Slider progressBar;

   void OnEnable() {
      Messenger<int, int>.AddListener(StartupEvent.MANAGERS_PROGRESS, 
         OnManagersProgress);
      Messenger.AddListener(StartupEvent.MANAGERS_STARTED, 
         OnManagersStarted);
   }
   void OnDisable() {
      Messenger<int, int>.RemoveListener(StartupEvent.MANAGERS_PROGRESS, 
         OnManagersProgress);
      Messenger.RemoveListener(StartupEvent.MANAGERS_STARTED, 
         OnManagersStarted);
   }

   private void OnManagersProgress(int numReady, int numModules) {
      float progress = (float)numReady / numModules;
      progressBar.value = progress;                  ❶
   }

   private void OnManagersStarted() {
      Managers.Mission.GoToNext();                   ❷
   }
}

❶ 更新滑动条以显示加载进度。

❷ 在管理人员开始后加载下一场景。

接下来,将滑动条对象链接到检查器中的槽位。在准备阶段要做的最后一件事是将两个场景添加到构建设置中。构建应用程序将是下一章的主题,所以现在选择文件 > 构建设置以查看和调整场景列表。点击添加打开场景按钮将场景添加到列表中(加载两个场景并为每个场景执行此操作)。

备注:您需要将场景添加到构建设置中,以便它们可以被加载。如果不这样做,Unity 将不知道有哪些场景可用。您在第五章中不需要这样做,因为您实际上并没有切换级别——您只是重新加载了当前场景。

现在,您可以通过从 Startup 场景点击播放来启动游戏。游戏管理人员对象将在两个场景中共享。

警告:由于管理人员在启动场景中加载,您始终需要从该场景启动游戏。您可能记得在点击播放之前总是打开该场景,但此编辑器脚本将在您点击播放时自动切换到设置的场景:github.com/jhocking/from-unity-wiki/blob/main/SceneAutoLoader.cs

提示:默认情况下,当关卡加载时,照明系统会重新生成光照贴图。但这仅在您正在编辑关卡时有效;当游戏运行时加载关卡时,不会生成光照贴图。就像在第十章中做的那样,您可以在照明窗口(窗口 > 渲染 > 照明)中关闭自动照明,然后点击按钮手动烘焙光照贴图(记住,不要触摸创建的照明数据)。

这种结构变化处理了不同场景之间游戏管理器的共享,但你仍然在关卡内没有任何成功或失败条件。

12.2.2 通过达到出口完成关卡

为了处理关卡完成,您需要在场景中放置一个玩家可以触摸的对象,并且该对象将在玩家达到目标时通知 MissionManager。这将涉及 UI 对关卡完成消息的响应,因此向 GameEvent 添加另一个条目。

列表 12.19 将“关卡完成”添加到 GameEvent

public static class GameEvent {
   public const string HEALTH_UPDATED = "HEALTH_UPDATED";
   public const string LEVEL_COMPLETE = "LEVEL_COMPLETE";
}

现在向 MissionManager 添加一个新方法,以跟踪任务目标和广播新的事件消息。

列表 12.20 MissionManager 中的目标方法

...
public void ReachObjective() {
   // could have logic to handle multiple objectives
   Messenger.Broadcast(GameEvent.LEVEL_COMPLETE);
}
...

调整 UIController 脚本以响应该事件。

列表 12.21 UIController 中的新事件监听器

...
[SerializeField] TMP_Text levelEnding;
...
void OnEnable() {
   Messenger.AddListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
   Messenger.AddListener(GameEvent.LEVEL_COMPLETE, OnLevelComplete);
}
void OnDisable() {
   Messenger.RemoveListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
   Messenger.RemoveListener(GameEvent.LEVEL_COMPLETE, OnLevelComplete);
}
...
void Start() {
   OnHealthUpdated();

   levelEnding.gameObject.SetActive(false);
   popup.gameObject.SetActive(false);
}
...
private void OnLevelComplete() {
   StartCoroutine(CompleteLevel());
}
private IEnumerator CompleteLevel() {
   levelEnding.gameObject.SetActive(true);
   levelEnding.text = "Level Complete!";

   yield return new WaitForSeconds(2);      ❶

   Managers.Mission.GoToNext();
}
...

❶ 显示消息两秒钟后进入下一关卡。

您会注意到这个列表有一个对文本标签的引用。打开 Level1 场景进行编辑,并创建一个新的 UI 文本对象。这个标签将是一个显示在屏幕中间的关卡完成消息。将宽度设置为 240,高度设置为 60,对齐和垂直对齐都设置为居中,顶点颜色设置为黑色,字体大小设置为 22。在文本区域中键入 Level Complete!,然后将此文本对象链接到 UIController 的 levelEnding 引用。

最后,我们将创建一个玩家触摸以完成关卡的对象(图 12.8 展示了目标的外观)。这将与可收集物品类似:它需要一个材质和脚本,您将使整个对象成为一个预制件。

CH12_F08_Hocking3

图 12.8 玩家触摸以完成关卡的目标对象

在位置 18, 1, 0 处创建一个立方体对象。选择 Box Collider 的“Is Trigger”选项,在 Mesh Renderer 中关闭 Cast 和 Receive Shadows,并将对象设置为 Ignore Raycast 层。创建一个名为 objective 的新材质;使其呈现亮绿色,并将着色器设置为 Unlit > Color 以获得平坦、明亮的视觉效果。接下来,创建 ObjectiveTrigger 脚本,并将其附加到立方体对象上。

列表 12.22 ObjectiveTrigger 代码,用于放置目标对象

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ObjectiveTrigger : MonoBehaviour {
   void OnTriggerEnter(Collider other) {
      Managers.Mission.ReachObjective();       ❶
   }
}

❶ 调用 MissionManager 中的新目标方法。

将此对象从 Hierarchy 拖到 Project 视图中,将其转换为预制件;在未来的关卡中,您可以将预制件放入场景中。现在玩游戏并达到目标。当您通关关卡时,会显示完成消息。接下来,让我们有一个失败消息来显示您失败的情况。

12.2.3 被敌人捕获时失去关卡

失败条件是玩家生命耗尽(因为敌人攻击)。首先,在 GameEvent 中添加另一个条目:

public const string LEVEL_FAILED = "LEVEL_FAILED";

现在调整 PlayerManager,当玩家的生命值降至 0 时广播此消息。

列表 12.23 从 PlayerManager 广播等级失败

...
public void Startup(NetworkService service) {
   Debug.Log("Player manager starting...");

   network = service;

   UpdateData(50, 100);              ❶

   status = ManagerStatus.Started;
}

public void UpdateData(int health, int maxHealth) {
   this.health = health;
   this.maxHealth = maxHealth;
}

public void ChangeHealth(int value) {
   health += value;
   if (health > maxHealth) {
      health = maxHealth;
   } else if (health < 0) {
      health = 0;
   }

   if (health == 0) {
      Messenger.Broadcast(GameEvent.LEVEL_FAILED);
   }
   Messenger.Broadcast(GameEvent.HEALTH_UPDATED);
}

public void Respawn() {              ❷
   UpdateData(50, 100);
}
...

❶ 调用更新方法而不是直接设置变量。

❷ 重置玩家到初始状态。

为 MissionManager 添加一个用于重启等级的方法。

列表 12.24 可以重启当前等级的 MissionManager

...
public void RestartCurrent() {
   string name = $"Level{curLevel}";
   Debug.Log($"Loading {name}");
   SceneManager.LoadScene(name);
}
...

在此基础上,向 UIController 添加另一个事件监听器。

列表 12.25 在 UIController 中响应失败等级

...
Messenger.AddListener(GameEvent.LEVEL_FAILED, OnLevelFailed);
...
Messenger.RemoveListener(GameEvent.LEVEL_FAILED, OnLevelFailed);
...
private void OnLevelFailed() {
   StartCoroutine(FailLevel());
}
private IEnumerator FailLevel() {
   levelEnding.gameObject.SetActive(true);
   levelEnding.text = "Level Failed";         ❶

   yield return new WaitForSeconds(2);

   Managers.Player.Respawn();
   Managers.Mission.RestartCurrent();         ❷
}
...

❶ 重复使用相同的文本标签,但设置不同的消息。

❷ 在两秒暂停后重启当前等级。

玩游戏并让敌人射击你几次;等级失败消息将出现。干得好——玩家现在可以完成和失败等级了!在此基础上,游戏必须跟踪玩家的进度。

12.3 处理玩家在游戏中的进度

目前,单个等级独立运行,没有任何与整体游戏的关系。你将添加两个使游戏进度感觉更完整的东西:保存玩家的进度和检测游戏(不仅仅是等级)是否完成。

12.3.1 保存和加载玩家的进度

保存和加载游戏是大多数游戏的重要部分。Unity 和 Mono 提供了可用于此目的的 I/O 功能。不过,在开始使用之前,你必须为 MissionManager 和 InventoryManager 添加 UpdateData() 方法。该方法将像在 PlayerManager 中那样工作,并允许管理器外部的代码更新管理器内的数据。列表 12.26 和列表 12.27 展示了更改后的管理者。

列表 12.26 MissionManager 中的 UpdateData() 方法

...
public void Startup(NetworkService service) {
   Debug.Log("Mission manager starting...");

   network = service;

   UpdateData(0, 1);       ❶

   status = ManagerStatus.Started;
}

public void UpdateData(int curLevel, int maxLevel) {
   this.curLevel = curLevel;
   this.maxLevel = maxLevel;
}
...

❶ 通过使用新方法修改此行。

列表 12.27 InventoryManager 中的 UpdateData() 方法

...
public void Startup(NetworkService service) {
   Debug.Log("Inventory manager starting...");

   network = service;

   UpdateData(new Dictionary<string, int>());     ❶

   status = ManagerStatus.Started;
}

public void UpdateData(Dictionary<string, int> items) {
   this.items = items;
}

public Dictionary<string, int> GetData() {        ❷
   return items;
}
...

❶ 初始化一个空列表。

❷ 需要获取保存游戏代码的访问数据的方法。

现在各个管理者都拥有了 UpdateData() 方法,数据可以从新的代码模块中保存。保存数据将涉及一个称为 序列化 数据的过程。

定义 序列化 意味着将一批数据编码成可以存储的形式。

你将以二进制数据保存游戏,但请注意,C# 也完全能够保存文本文件。例如,在第十章中你处理过的 JSON 字符串就是作为文本序列化的数据。前面的章节使用了 PlayerPrefs,但在这个项目中,你将保存本地文件;PlayerPrefs 仅用于保存少量值,如设置,而不是整个游戏。创建 DataManager 脚本(列表 12.28)。

警告 在网页游戏中无法直接访问文件系统。这是网页浏览器的安全特性。为了保存网页游戏的数据,你可能需要编写下一章中描述的插件,或者将数据发送到你的服务器。

列表 12.28 DataManager 的新脚本

using System.Collections;
using System.Collections.Generic;
using System.Runtime.Serialization.Formatters.Binary;
using System.IO;
using UnityEngine;

public class DataManager : MonoBehaviour, IGameManager {
   public ManagerStatus status {get; private set;}

   private string filename;

   private NetworkService network;

   public void Startup(NetworkService service) {
      Debug.Log("Data manager starting...");

      network = service;

      filename = Path.Combine(
            Application.persistentDataPath, "game.dat");          ❶

      status = ManagerStatus.Started;
   }

   public void SaveGameState() {
      Dictionary<string, object> gamestate =
         new Dictionary<string, object>();                        ❷
      gamestate.Add("inventory", Managers.Inventory.GetData());
      gamestate.Add("health", Managers.Player.health);
      gamestate.Add("maxHealth", Managers.Player.maxHealth);
      gamestate.Add("curLevel", Managers.Mission.curLevel);
      gamestate.Add("maxLevel", Managers.Mission.maxLevel);

      using (FileStream stream = File.Create(filename)) {         ❸
         BinaryFormatter formatter = new BinaryFormatter();
         formatter.Serialize(stream, gamestate);                  ❹
      }
   }

   public void LoadGameState() {
      if (!File.Exists(filename)) {                               ❺
         Debug.Log("No saved game");
         return;
      }

      Dictionary<string, object> gamestate;                       ❻

      using (FileStream stream = File.Open(filename, FileMode.Open)) {
         BinaryFormatter formatter = new BinaryFormatter();
         gamestate = formatter.Deserialize(stream) as Dictionary<string, 
         ➥ object>;
      }

      Managers.Inventory.UpdateData((Dictionary<string, 
      ➥ int>)gamestate["inventory"]);                            ❼
      Managers.Player.UpdateData((int)gamestate["health"], 
      ➥ (int)gamestate["maxHealth"]);
      Managers.Mission.UpdateData((int)gamestate["curLevel"], 
      ➥ (int)gamestate["maxLevel"]);
      Managers.Mission.RestartCurrent();
   }
}

❶ 构建游戏.dat 文件的全路径。

❷ 将要序列化的字典

❸ 在文件路径创建一个文件。

❹ 将字典序列化为创建的文件的内容。

❺ 只有在文件存在的情况下才继续加载。

❻ 将加载的数据放入的字典

❼ 使用反序列化数据更新管理器。

在 Startup() 中,使用 Application.persistentDataPath 构建完整的文件路径,这是 Unity 提供用于存储数据的位置。确切的文件路径在不同平台上有所不同,但 Unity 通过这个静态变量在后面抽象化它。File.Create() 方法将创建一个二进制文件;如果你想创建一个文本文件,请调用 File.CreateText()。

警告:在构建文件路径时,不同计算机平台上的路径分隔符不同。C# 有 Path.DirectorySeparatorChar 来处理这个问题。

打开 Startup 场景以找到游戏管理器。将 DataManager 脚本组件添加到游戏管理器对象中,然后将新管理器添加到 Managers 脚本中。

列表 12.29 将 DataManager 添加到管理器中

...
[RequireComponent(typeof(DataManager))]
...
public static DataManager Data {get; private set;}
...
void Awake() {
   DontDestroyOnLoad(gameObject);

   Data = GetComponent<DataManager>();
   Player = GetComponent<PlayerManager>();
   Inventory = GetComponent<InventoryManager>();
   Mission = GetComponent<MissionManager>();

   startSequence = new List<IGameManager>();     ❶
   startSequence.Add(Player);
   startSequence.Add(Inventory);
   startSequence.Add(Mission);
   startSequence.Add(Data);

   StartCoroutine(StartupManagers());
}
...

❶ 管理器以这个顺序启动。

警告:因为 DataManager 使用其他管理器(以更新它们),你应该确保其他管理器在启动序列中出现的顺序更靠前。

最后,在 Level1 中,添加按钮以使用 DataManager 中的函数(如图 12.9 所示的按钮)。创建两个按钮,它们是 HUD Canvas 的父级(不在库存弹出窗口中)。将它们命名为(设置附加的文本对象)Save Game 和 Load Game,将锚点按钮设置为右下角,并将它们定位在 -100,65 和 -100,30。

CH12_F09_Hocking3

图 12.9 屏幕右下角的保存和加载按钮

这些按钮将链接到 UIController 中的函数,因此需要编写这些方法。

列表 12.30 UIController 中的保存和加载方法

...
public void SaveGame() {
   Managers.Data.SaveGameState();
}

public void LoadGame() {
   Managers.Data.LoadGameState();
}
...

将这些函数链接到按钮的 OnClick 监听器(在 OnClick 设置中添加一个列表,拖入 UIController 对象,并从菜单中选择函数)。现在玩游戏,捡起一些物品,使用健康包来增加你的健康,然后保存游戏。重新启动游戏并检查你的库存以验证它是否为空。点击加载;你现在有了你保存游戏时拥有的健康和物品!

12.3.2 通过完成三个关卡来击败游戏

如我们所保存的玩家进度所示,这个游戏可以有多个关卡,而不仅仅是测试的那个关卡。为了正确处理多个关卡,游戏必须检测到单个关卡以及整个游戏的完成。首先,添加另一个 GameEvent:

public const string GAME_COMPLETE = "GAME_COMPLETE";

现在修改 MissionManager,在最后一关之后广播该消息。

列表 12.31 从 MissionManager 广播游戏完成

...
public void GoToNext() {
   ...
   } else {
      Debug.Log("Last level");
      Messenger.Broadcast(GameEvent.GAME_COMPLETE);
   }
}

在 UIController 中响应该消息。

列表 12.32 向 UIController 添加事件监听器

...
Messenger.AddListener(GameEvent.GAME_COMPLETE, OnGameComplete);
...
Messenger.RemoveListener(GameEvent.GAME_COMPLETE, OnGameComplete);
...
private void OnGameComplete() {
   levelEnding.gameObject.SetActive(true);
   levelEnding.text = "You Finished the Game!";
}
...

尝试完成关卡以查看发生了什么:将玩家移动到关卡目标以完成关卡,就像以前一样。你首先会看到关卡完成的提示,但几秒钟后,它会变成完成游戏的提示。

添加更多级别

到目前为止,你可以添加任意数量的额外级别,并且 MissionManager 将监视最后一个级别。在这一章中,你将做的最后一件事是为项目添加几个更多级别,以展示游戏通过多个级别的发展。

将 Level1 场景文件复制两次,确保名称为 Level2 和 Level3,并将新级别添加到构建设置中(以便在游戏过程中加载;记得生成光照)。修改每个场景,以便你能区分级别;你可以自由地重新排列场景的大部分内容,但你必须保留几个基本游戏元素:标记为 Player 的玩家对象、设置为 Ground 层的地板对象以及目标对象、Controller、HUD 画布和 EventSystem。

构建共享的 HUD

UI 与级别一起被复制,导致有三个相同的 UI 设置。这对于这个小型学习项目来说是可以的,但对于具有许多级别的精炼游戏来说可能会很麻烦。相反,你应该将 UI 移动到一个中央位置,该位置在级别之间共享。

就像你在启动场景中做的那样,你可以将 UI(包括 HUD 画布和 EventSystem)放在一个单独的场景中,除了加载级别外还要加载。然而,与启动场景不同的是,你可能希望比简单地使用 DontDestroyOnLoad()函数更细致地控制 UI 的加载。该函数会导致对象在所有场景中持续存在,但游戏的每个场景中的 UI 并不相同。例如,游戏的起始菜单场景通常与所有级别的 UI 不同。

Unity 使用增量场景加载模式来解决此问题。以这种方式加载的场景是添加到已加载的内容上,而不是替换它。例如,修改此项目的代码以使用共享 UI 场景,只需在 MissionManager 中的每个标准 LoadScene()调用后立即添加一行代码如 SceneManager.LoadScene("HUDScene", LoadSceneMode.Additive);即可。关于这种可选场景加载模式的文档,请参阅mng.bz/v4GJ

你还需要调整 MissionManager 以加载新级别。通过将 UpdateData(0, 1)调用更改为 UpdateData(0, 3)来将 maxLevel 更改为 3。现在玩游戏,你最初将开始于 Level1;达到级别目标后,你将进入下一个级别!顺便说一句,你还可以在后面的级别中保存,以查看游戏将恢复该进度。

你现在已经知道了如何创建一个包含多个级别的完整游戏。接下来的明显任务就是最后一章:让你的游戏进入玩家的手中。

练习:将音频集成到完整游戏中

第十一章全部是关于在 Unity 中实现音频。我没有解释如何将这部分内容整合到本章的项目中,但到现在你应该已经理解了如何操作。我鼓励你通过将上一章的音频功能整合到本章的项目中来练习你的技能。这里有一个提示:更改键来切换音频设置弹出窗口,以免干扰库存弹出窗口。

摘要

  • Unity 使得在不同游戏类型的项目中重用资产和代码变得容易。

  • 另一个使用射线投射的绝佳用途是确定玩家在场景中的点击位置。

  • Unity 提供了简单的方法来加载关卡并在关卡之间持久化某些对象。

  • 你根据游戏中的各种事件来通过关卡。

  • 你可以使用 C# 中的 I/O 方法在 Application .persistentDataPath 存储数据。

13 将你的游戏部署到玩家的设备

本章涵盖

  • 为各种平台构建应用程序包

  • 分配构建设置,如应用图标或名称

  • 与网页游戏页面交互

  • 为移动平台上的应用程序开发插件

在本书中,你已经学会了如何在 Unity 中编程各种游戏,但到目前为止,关键的最后一步一直缺失:将这些游戏部署给玩家。除非游戏可以在 Unity 编辑器之外运行,否则除了开发者之外,对任何人来说都毫无兴趣。Unity 在最后一步中表现出色,能够为大量的游戏平台构建应用程序。最后一章将介绍如何为这些不同的平台构建游戏。

当我提到“为平台构建”时,我指的是生成将在该平台上运行的应用程序包。在每个平台(Windows、iOS 等)上,构建的应用程序的确切形式不同,但一旦生成了可执行文件,该应用程序包就可以在没有 Unity 的情况下播放,并且可以分发给玩家。单个 Unity 项目可以部署到任何平台,而无需为每个平台重新制作。

这种“一次构建,到处部署”的能力适用于你游戏中绝大多数功能,但并非所有功能。我估计在 Unity 中编写的 95%的代码(例如,本书中到目前为止我们几乎做的一切)都是平台无关的,并且可以在所有平台上正常工作。但一些特定任务在不同平台上有所不同,所以我们将讨论这些特定平台的发展领域。

Unity 能够为以下平台构建应用程序:

  • Windows PC

  • macOS

  • Linux

  • WebGL

  • Android

  • iOS

  • tvOS

  • Oculus VR

  • VIVE VR

  • Windows 混合现实

  • Microsoft HoloLens

  • Magic Leap

此外,通过联系平台所有者以获取访问权限,Unity 甚至可以为像这样的游戏机构建:

  • Xbox One

  • Xbox Series X

  • PlayStation 4

  • PlayStation 5

  • Nintendo Switch

呼吁,这个完整的列表真的很长!坦白说,这几乎有点滑稽地长,远远超过了大多数其他游戏开发工具支持的平台。本章特别关注列出的前六个平台,因为这些平台对大多数探索 Unity 的人来说是最感兴趣的,但请记住你有多少选择。

要查看所有这些平台,请打开构建设置窗口。这就是你在上一章中用来添加要加载的场景的窗口;要访问它,请选择文件 > 构建设置。在第十二章中,你只关心顶部的列表,但现在你想要注意底部的按钮(见图 13.1)。你会注意到列表占用了很多空间;当前活动的平台由 Unity 图标指示。

CH13_F01_Hocking3

图 13.1 构建设置窗口

注意:在安装 Unity 时,Unity Hub 会询问你想要哪些导出模块,并且你只能构建所选的模块。如果你后来想要安装最初未选择的模块,请转到 Unity Hub 中的安装,点击你想要修改的 Unity 版本的三点,然后在菜单中选择添加模块。

在此窗口的底部还有玩家设置和构建/切换平台按钮。点击玩家设置可以查看检查器中应用程序的设置,例如应用程序的名称和图标。另一个按钮的标签会根据你在平台列表中选择的平台而变化。如果你已经选择了活动平台,点击构建将启动构建过程。对于任何其他平台,点击切换平台将使其成为 Unity 当前正在处理的活跃平台。

警告:在大型项目中,切换平台通常需要相当长的时间才能完成;请确保你已经准备好等待。这是因为 Unity 会以针对每个平台最优化的方式重新压缩所有资产(如纹理)。

提示:构建并运行与构建做同样的事情,但它会自动运行构建的应用程序。我通常想手动完成这部分,所以我很少使用构建并运行。

当你点击构建时,首先出现的是一个文件选择器,这样你就可以告诉 Unity 在哪里生成应用程序包。一旦你选择了文件位置,构建过程就开始了。Unity 为当前活动的平台创建一个可执行的应用程序包。让我们回顾一下最流行的平台的构建过程:桌面、网页和移动。

13.1 首先为桌面构建:Windows、Mac 和 Linux

当初学习如何构建 Unity 游戏时,最简单的地方是将游戏部署到桌面计算机——Windows PC、macOS 或 Linux。因为 Unity 在桌面计算机上运行,这意味着你将为正在使用的计算机构建应用程序。

注意:在本节中,打开任何项目进行工作。说真的,任何 Unity 项目都可以。实际上,我强烈建议在每个部分使用不同的项目,以强调 Unity 可以构建任何平台上的任何项目!

13.1.1 构建应用程序

首先选择文件 > 构建设置以打开构建设置窗口。默认情况下,当前平台将设置为 PC、Mac 和 Linux,但如果不是当前设置,请从列表中选择正确的平台并点击切换平台。

在窗口的右侧,你会注意到目标平台菜单。这个菜单允许你选择 Windows PC、macOS 和 Linux。在左侧的列表中,这三个平台被视为一个平台,但实际上它们是非常不同的平台,所以请选择正确的平台。

一旦你选择了桌面平台,点击构建。如前所述,会弹出一个文件对话框,允许你选择构建的应用程序将去哪里。然后开始构建过程;对于大型项目,这可能需要一段时间,但对于你一直在制作的微型演示,构建过程应该很快。

自定义构建后脚本

尽管基本构建过程在大多数情况下都能正常工作,但您可能希望在构建游戏时每次都执行一系列步骤(例如,将帮助文件移动到与应用程序相同的目录)。您可以通过编写在构建过程完成后执行的脚本轻松自动化此类任务。

首先,在项目视图中创建一个新的文件夹,并将其命名为 Editor;任何影响 Unity 编辑器(包括构建过程)的脚本都必须放在名为 Editor 的文件夹中。在该文件夹中创建一个名为 TestPostBuild 的新脚本,并在其中编写以下代码:

using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;

public static class TestPostBuild {

   [PostProcessBuild]
   public static void OnPostprocessBuild(BuildTarget target, string 
   ➥ pathToBuiltProject) {
      Debug.Log($"build location: {pathToBuiltProject}");
   }
}

[PostProcessBuild]指令告诉脚本运行紧随其后的函数。该函数将接收构建应用程序的位置;然后您可以使用 C#提供的各种文件系统命令使用该位置。

应用程序将出现在您选择的地点;双击它以运行,就像任何其他程序一样。恭喜,这很简单!构建应用程序非常简单,但过程可以通过多种方式自定义;让我们看看如何调整构建过程。

提示:在 Windows 上使用 Alt-F4 或在 Mac 上使用 Cmd-Q 退出全屏游戏。完成的游戏应该有一个调用 Application.Quit()的按钮。

13.1.2 调整玩家设置:设置游戏名称和图标

返回构建设置窗口,但这次点击玩家设置而不是构建。在检查器中会出现一个巨大的设置列表(见图 13.2);这些设置控制构建应用程序的多个方面。

CH13_F02_Hocking3

图 13.2 检查器中显示的玩家设置

由于设置众多,您可能需要查阅 Unity 的手册。相关文档页面是mng.bz/4Koa

顶部的前几个设置最容易理解:公司名称、产品名称、版本和默认图标。为前三个输入值:公司名称是您的开发工作室名称,产品名称是这款特定游戏的名称,版本是随着游戏更新而增加的数字标识。然后从项目视图(如果需要,将图像导入到项目中)拖动一个图像,将其设置为图标;当应用程序构建时,此图像将作为应用程序的图标出现。

自定义应用程序的图标和名称对于使其看起来完整很重要。另一种自定义构建应用程序行为的有用方法是使用平台相关代码。

13.1.3 平台相关编译

默认情况下,您编写的所有代码将在所有平台上以相同的方式运行。但 Unity 提供了编译器指令(称为平台定义),这些指令会导致不同的代码在不同的平台上运行。您可以在手册中找到平台定义的完整列表,手册地址是mng.bz/Qq4w

如该页面所示,每个 Unity 支持的平台都有可用的指令,允许你在每个平台上运行不同的代码。通常,你的大部分代码不需要在平台指令内,但偶尔代码的某些小部分需要在不同的平台上以不同的方式运行。例如,某些代码组件仅在单个平台上存在,因此你需要在这些命令周围有平台编译器指令。以下列表显示了如何编写此类代码。

列表 13.1 PlatformTest 脚本展示如何编写平台相关代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlatformTest : MonoBehaviour {
   void OnGUI() {
#if UNITY_EDITOR                                                   ❶
      GUI.Label(new Rect(10, 10, 200, 20), "Running in Editor");
#elif UNITY_STANDALONE                                             ❷
      GUI.Label(new Rect(10, 10, 200, 20), "Running on Desktop");
#else
      GUI.Label(new Rect(10, 10, 200, 20), "Running on other platform");
#endif
   }
}

❶ 此部分仅在编辑器内运行。

❷ 仅在桌面/独立应用程序中

创建一个名为 PlatformTest 的脚本,并将此列表中的代码写入其中。将此脚本附加到场景中的对象(任何对象都可以用于测试),屏幕左上角将出现一条小消息。当你使用 Unity 编辑器播放游戏时,消息将显示为在编辑器中运行,但如果你构建游戏并运行构建的应用程序,消息将显示为在桌面上运行。每种情况下都在运行不同的代码!

对于这个测试,你使用了将所有桌面平台视为一个平台的平台定义,但如该文档页面所示,Windows、Mac 和 Linux 都有单独的平台定义。实际上,Unity 支持的所有平台都有平台定义,这样你就可以在每个平台上运行不同的代码。让我们继续下一个重要的平台:网页。

质量设置

构建的应用程序还受位于编辑菜单下的项目设置的影响。特别是,最终应用程序的视觉质量可以在那里进行调整。转到编辑菜单中的项目设置以打开该窗口,然后从左侧菜单中选择质量。

质量设置显示在窗口的右侧,最重要的设置是顶部的一排复选框网格。Unity 可以针对的不同平台以图标形式列在顶部,可能的质量设置沿侧面列出。为该平台可用的质量设置已勾选,正在使用的设置复选框高亮显示为绿色。大多数情况下,这些设置默认为非常低,但如果东西看起来不好,你可以将其更改为超高质量;如果你点击平台列下的向下箭头,将出现一个弹出菜单。

这个 UI 既有复选框又有默认菜单,这似乎有点多余,但就是这样。不同的平台通常有不同的图形能力,因此 Unity 允许你为不同的构建目标设置不同的质量级别(例如,桌面上的最高质量,移动设备上的较低质量)。

CH13_UN01_Hocking3

检查器中的质量设置网格

13.2 为网页构建

虽然桌面平台是构建的基本目标,但对于 Unity 游戏,另一个重要的平台是网页。部署到网页上的游戏在网页浏览器中运行,因此可以通过互联网进行游戏。

Unity Web Player 与 HTML5/WebGL 对比

最初,Unity 必须以在自定义浏览器插件中播放的形式部署网页构建。这长期以来一直是必要的,因为 3D 图形并未内置到网页浏览器中。然而,最终,大多数浏览器都采用了 WebGL,这是网页上 3D 图形的标准。技术上,WebGL 与 HTML5 是分开的,但这两个术语是相关的,并且在谈论网页上的 3D 时经常可以互换使用。

对于版本 5,Unity 将 WebGL 添加到了构建平台列表中,而在几个版本之后,浏览器插件被取消,使得 WebGL 成为网页构建的唯一途径。部分原因是 Unity(公司)内部做出的战略决策推动了 Unity 网页构建的这些变化。但这些变化也受到了浏览器制造商的推动,他们正在远离自定义插件,并拥抱 HTML5/WebGL 作为开发交互式网页应用(包括游戏)的方式。

13.2.1 在网页中嵌入游戏构建

打开一个不同的项目(再次强调,这是为了强调任何项目都可以使用),然后打开构建设置窗口。将平台切换到 WebGL,然后点击构建按钮。文件选择器将出现;为此应用程序输入名称 WebTest,如果需要,更改到一个安全的位置(不在 Unity 项目内的位置)。

构建过程现在将创建一个包含 index.html 网页的文件夹,以及包含所有游戏代码和其他资源的子文件夹。打开这个网页,游戏应该嵌入到原本空白的页面中间。您需要从 Web 服务器上运行游戏,而不是简单地打开 index.html 作为本地文件。就像第十章中提到的那样,如果您已经有了网站,可以使用现有的 Web 服务器,或者您可以在 http://localhost/ 上使用类似 XAMPP 的工具进行测试。

注意:您可能需要调整您的 Web 服务器设置,以正确处理 WebGL 构建中的压缩存档。Unity 的手册(mng.bz/XreG)解释了这些服务器设置,但如果您由于某种原因无法调整这些设置(例如,游戏将位于您无法配置的第三方网站上),您也可以告诉 Unity 在构建中包含一个解压缩器。在 WebGL 播放器设置的发布设置部分开启解压缩器回退。此设置默认关闭,因为浏览器的解压缩更好。但请注意,当此设置开启时,您可能不会注意到服务器配置不当。

这个网页没有什么特别之处;它只是一个用来测试你的游戏的示例。你可以自定义该页面的代码,甚至提供你自己的网页(稍后讨论)。最重要的自定义之一是启用 Unity 和浏览器之间的通信,所以让我们接下来看看这一点。

13.2.2 与浏览器中的 JavaScript 通信

Unity 网页游戏可以与浏览器(或者更确切地说,与浏览器中运行的 JavaScript)通信,这些消息可以双向传递:从 Unity 到浏览器,以及从浏览器到 Unity。要向浏览器发送消息,你需要在代码库中编写 JavaScript 代码,然后 Unity 有特殊的命令来使用该库中的函数。

同时,对于来自浏览器的消息,浏览器中的 JavaScript 通过名称识别一个对象,然后 Unity 将消息传递到场景中命名的对象。因此,你必须有一个在场景中的对象,它将接收来自浏览器的通信。

为了演示这些任务,在 Unity 中创建一个名为 WebTestObject 的新脚本。同时,在活动场景中创建一个名为 JSListener 的空对象(场景中的对象必须具有该确切名称,因为这是列表 13.4 中 JavaScript 代码使用的名称)。将新脚本附加到该对象,然后编写此列表中的代码。

列表 13.2 测试与浏览器通信的 WebTestObject 脚本

using System.Runtime.InteropServices;
using UnityEngine;

public class WebTestObject : MonoBehaviour {
   private string message;

   [DllImport("__Internal")]                            ❶
   private static extern void ShowAlert(string msg);

   void Start() {
      message = "No message yet";
   }

   void Update() {
      if (Input.GetMouseButtonDown(0)) {                ❷
         ShowAlert("Hello out there!");
      }
   }

   void OnGUI() {
      GUI.Label(new Rect(10, 10, 200, 20), message);    ❸
   }

   public void RespondToBrowser(string message) {       ❹
      this.message = message;
   }
}

❶ 从 JS 库导入函数。

❷ 在鼠标点击时调用导入的函数。

❸ 在屏幕左上角显示消息。

❹ 浏览器调用的函数

主要的新功能是 DllImport 命令。它将 JavaScript 库中的一个函数导入到 C#代码中使用。这显然意味着你一个 JavaScript 库,所以接下来编写它。

首先创建一个特殊的文件夹来包含它:创建一个名为 Plugins 的文件夹,并在其中创建一个名为 WebGL 的文件夹。现在在 WebGL 文件夹中放置一个名为 WebTest 的文件,扩展名为 jslib(因此为 WebTest.jslib);最简单的方法是在 Unity 外创建一个文本文件,重命名它,然后将文件拖入。Unity 会识别该文件为 JavaScript 库,因此在该文件中编写此代码。

列表 13.3 WebTest JavaScript 库

mergeInto(LibraryManager.library, {

   ShowAlert: function(msg) {                ❶
      window.alert(Pointer_stringify(msg));
   },

});

❶ 从 C#导入并调用的函数

jslib 文件包含一个包含函数的 JavaScript 对象以及将自定义对象合并到 Unity 库管理器的命令。请注意,编写的函数除了标准的 JavaScript 命令外还包括 Pointer_stringify();当从 Unity 传递字符串时,它会被转换为一个数字标识符,因此 Unity 提供该函数来查找指向的字符串。

现在再次为网页构建,以查看新代码的实际效果。当你在网页的 Unity 游戏部分内点击时,Unity 中的 WebTestObject 会调用 JavaScript 代码中的函数;尝试点击几次,你会在浏览器中看到一个警告框出现!

注意:Unity 还提供了 Application.ExternalEval() 来在浏览器中运行代码;ExternalEval 运行任意的 JavaScript 片段,而不是调用定义好的函数。这种方法已被弃用,应避免使用,但有时它的简单性很有用,比如使用 Application.ExternalEval("location.reload();") 来重新加载页面。

好的,你已经测试了从 Unity 游戏到网页中 JavaScript 的通信,但网页也可以向 Unity 发送消息,所以我们也来做这个。这会涉及到页面上的新代码和按钮;幸运的是,Unity 提供了一种简单的方式来自定义网页。具体来说,Unity 在构建到 WebGL 时会填充一个网页 模板,你可以选择一个自定义模板而不是默认模板。

默认模板可以在 Unity 安装文件夹中找到(在 Windows 上通常是 C:\Program Files\Unity\Editor\Data,在 Mac 上通常是 /Applications/Unity/Editor),位于 /WebGLSupport/BuildTools/WebGLTemplates 下。在文本编辑器中打开一个模板页面,你会看到模板主要是标准的 HTML 和 JavaScript,还有一些 Unity 替换为生成信息的特殊标签。虽然最好让 Unity 的内置模板保持原样,但它们(尤其是 最小 的一个)可以作为构建你自己的良好基础。你将把最小模板网页复制到你创建的自定义模板中。

在 Unity 的项目视图中,在 Assets 下的直接位置创建一个名为 WebGLTemplates 的文件夹(没有空格);这是自定义模板存放的地方。现在在这个文件夹内创建一个名为 WebTest 的子文件夹;这个文件夹用于你的新模板。在这里放入一个 index.html 文件(你可以从最小模板中复制网页),在文本编辑器中打开它,并写入以下代码。

列表 13.4 WebGL 模板以启用浏览器-Unity 通信

<!DOCTYPE html>
<html lang="en-us">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Unity WebGL Player | {{{ PRODUCT_NAME }}}</title>
    <style>body { background-color: #333; }</style>                            ❶
  </head>
  <body style="text-align: center">
    <canvas id="unity-canvas" width={{{ WIDTH }}} height={{{ HEIGHT }}} style="width: {{{ WIDTH }}}px; height: {{{ HEIGHT }}}px; background: {{{ BACKGROUND_FILENAME ? 'url(\'Build/' + BACKGROUND_FILENAME.replace(/'/g, '%27') + '\') center / cover' : BACKGROUND_COLOR }}}"></canvas>
    <br><input type="button" value="Send to Unity" onclick="SendToUnity();" /> ❷

    <script src="Build/{{{ LOADER_FILENAME }}}"></script>
    <script>
      var unityInstance = null;

      createUnityInstance(document.querySelector("#unity-canvas"), {
        dataUrl: "Build/{{{ DATA_FILENAME }}}",
        frameworkUrl: "Build/{{{ FRAMEWORK_FILENAME }}}",
        codeUrl: "Build/{{{ CODE_FILENAME }}}",
#if MEMORY_FILENAME
        memoryUrl: "Build/{{{ MEMORY_FILENAME }}}",
#endif
#if SYMBOLS_FILENAME
        symbolsUrl: "Build/{{{ SYMBOLS_FILENAME }}}",
#endif
        streamingAssetsUrl: "StreamingAssets",
        companyName: "{{{ COMPANY_NAME }}}",
        productName: "{{{ PRODUCT_NAME }}}",
        productVersion: "{{{ PRODUCT_VERSION }}}",
      }).then((createdInstance) => {
        unityInstance = createdInstance;
      });

      function SendToUnity() {
        unityInstance.SendMessage("JSListener",                                ❸
          "RespondToBrowser", "Hello from the browser!");
      }
    </script>
  </body>
</html>

❶ 将页面改为深色而不是白色

❷ 调用 JavaScript 函数的按钮

❸ SendMessage() 指向 Unity 中的命名对象。

如果你复制了最小模板,你会看到列表 13.4 只是在那里添加了几行。两个重要的新增内容是脚本标签中的一个函数和页面上的一个输入按钮;添加的样式改变了页面的颜色,使其更容易看到嵌入的游戏。按钮的 HTML 标签链接到一个 JavaScript 函数,该函数在 Unity 实例上调用 SendMessage()。这种方法在 Unity 中调用一个命名对象的函数;第一个参数是对象的名称,第二个参数是方法的名称,第三个参数是在调用方法时传递的字符串。

你已经制作了自定义模板,但你还必须告诉 Unity 使用这个模板而不是默认模板。再次打开 Player Settings(记住,在 Build Settings 窗口中点击 Player Settings)并找到网页设置中的 WebGL Template(如图 13.3 所示)。你会看到当前选择的是默认,但 WebTest(你创建的模板文件夹)也在列表中;点击它。

CH13_F03_Hocking3

图 13.3 WebGL 模板设置

在选择了自定义模板后,再次构建到 WebGL。打开生成的网页,这次页面的底部有一个按钮。点击按钮,你将在 Unity 中看到显示的更改消息!

这就完成了 Web 构建的浏览器通信。接下来是构建应用程序的下一个重要平台(或者更确切地说,是一组平台):移动。

13.3 为移动构建:iOS 和 Android

移动应用是 Unity 的重要构建目标之一。我的直观印象(完全非科学的)是,大多数使用 Unity 创建的商业游戏都是移动游戏。

定义 移动 是手持计算设备的一个类别。这个分类最初始于智能手机,但现在也包括平板电脑。最广泛使用的两个移动计算平台是 iOS(来自苹果)和 Android(来自谷歌)。

为移动应用设置构建过程比桌面或 Web 构建更复杂,因此这是一个可选的部分——可选的意思是,你可以阅读它,但实际上不需要遵循步骤。我仍然会像你正在一起工作一样写,但你需要购买 iOS 的开发者许可证并安装所有 Android 的开发者工具。

警告 移动设备经历了如此快速的变化,以至于在你看这篇文档的时候,确切的构建过程可能略有不同。高级概念可能仍然正确,但你应该查看在线的最新文档,以获取执行命令和按钮的详细说明。首先,这里有一些来自苹果(developer .apple.com/documentation/xcode)和谷歌(developer.android .com/studio/build)的文档页面。

触摸输入

移动设备上的输入方式与桌面或 Web 不同。移动输入是通过触摸屏幕完成的,而不是通过鼠标和键盘。Unity 具有处理触摸的输入功能,包括像 Input.touchCount 和 Input.GetTouch()这样的代码。

你可能想使用这些命令在移动设备上编写特定平台的代码。然而,以这种方式处理输入可能会很麻烦,因此有代码框架可供简化触摸输入的使用。例如,在 Unity 的 Asset Store 上搜索 Fingers 或 Lean Touch。

好吧,把这些注意事项放一边,我将解释 iOS 和 Android 的整体构建过程。记住,这些平台偶尔会改变构建过程的细节。

13.3.1 设置构建工具

移动设备都与你在其上开发的计算机分开,这种分离使得构建和部署到设备的过程稍微复杂一些。在点击构建之前,你需要设置各种专用工具。

设置 iOS 构建工具

从高层次来看,在 iOS 上部署 Unity 游戏的过程首先需要从 Unity 构建一个 Xcode 项目,然后使用 Xcode 将该 Xcode 项目构建成一个 iOS 应用程序包(IPA)。Unity 无法直接构建最终的 IPA,因为所有 iOS 应用都必须通过苹果的构建工具。这意味着您需要安装 Xcode(苹果的编程 IDE),包括 iOS SDK。

警告 部署 iOS 游戏时,您必须在 Mac 上工作——Xcode 仅在 macOS 上运行。在 Unity 内部开发游戏可以在 Windows 或 Mac 上进行,但构建 iOS 应用必须在 Mac 上进行。

从苹果的网站上获取 Xcode,在开发者部分:developer.apple.com/xcode/.

注意 您需要成为苹果开发者计划成员才能在 App Store 上销售您的 iOS 游戏。苹果的开发者计划费用为 99 美元/年;在 developer.apple.com/programs/ 注册。

Xcode 安装完成后,启动它并打开首选项以添加您的开发者账户。当 Xcode 在构建应用程序时访问您的账户时,您需要登录。

现在,回到 Unity 并切换到 iOS。您需要调整 iOS 应用的播放器设置(记住,打开构建设置并点击播放器设置)。您应该已经在播放器设置的 iOS 选项卡上,但如果需要,请点击带有 iOS 图标的选项卡。向下滚动到其他设置,然后查找识别。Bundle Identifier 需要调整,以便苹果能够正确识别应用程序。

注意 iOS 称之为 Bundle Identifier,而 Android 称之为 Package Name,但在两个平台上命名方式相同。标识符应遵循任何代码包的相同约定:所有小写,形式为 com.companyname.productname。

另一个适用于 iOS 和 Android 的重要设置是版本(这是应用程序的版本号)。大多数超出该范围的设置都是平台特定的;例如,iOS 添加了一个额外的构建号,与主要版本号分开。还有一个脚本后端设置;过去总是使用 Mono,但较新的 IL2CPP 后端支持 iOS 更新,如 64 位二进制文件。

注意 iOS 从 Unity 构建的版本既不支持真实设备(iPhone 和 iPad)也不支持 iOS 模拟器。默认情况下,iOS 从 Unity 构建的版本仅在真实设备上工作,但您可以通过在播放器设置中向下滚动到目标 SDK 来切换到为模拟器构建。实际上,我从未这样做过,因为我的所有“在真实设备之外”的测试工作都是在 Unity 本身完成的,如果我要进行 iOS 构建,那么我想要在真正的手机上运行它。

现在,在构建设置窗口中点击构建。选择构建文件的存储位置,这将在此位置生成一个 Xcode 项目;您可能想要点击按钮创建一个新文件夹,然后选择该新创建的文件夹。

生成的结果的 Xcode 项目可以修改(简单的修改可以是构建脚本的一部分)。无论如何,打开 Xcode 项目;构建文件夹中有许多文件,但双击 .xcodeproj 文件(它有一个蓝图图标)。Xcode 将以加载此项目的方式打开。Unity 已经处理了项目中所需的大部分设置,但你确实需要调整正在使用的配置文件。

iOS 配置文件

在 iOS 开发的所有方面中,配置文件是最不寻常的。简而言之,这些是用于身份验证和授权的文件。苹果严格控制哪些应用程序可以在哪些设备上运行;提交给苹果进行审核的应用程序使用特殊的配置文件,允许它们通过 App Store 运行,而开发中的应用程序使用特定于注册设备的配置文件。

你需要将你的 iPhone 的 UDID(一个特定于你的设备的 ID)和应用程序的 ID(Unity 中的包标识符)添加到苹果 iOS 开发者网站上的你的账户中。有关此过程的完整说明,请访问 developer.apple.com/support/code-signing/

CH13_UN02_Hocking3

在 iOS 开发中心如何管理配置文件

Xcode 将尝试自动设置签名配置文件,这就是为什么你之前在“首选项”中添加了你的账户。在 Xcode 左侧的项目列表中选择你的应用程序,与所选项目相关的几个选项卡将出现。点击“签名与能力”选项卡,然后点击“团队”菜单以选择注册在苹果开发者计划中的团队(见图 13.4)。如果出于某种原因你不想让 Xcode 自动管理签名,可以在构建设置选项卡中向下滚动到“签名”手动调整配置文件。

CH13_F04_Hocking3

图 13.4 Xcode 中的配置/签名设置

一旦设置了配置文件,你就可以构建应用程序了。从“产品”菜单中选择运行或归档。产品菜单有很多选项,包括诱人的“构建”名称,但对我们来说,有用的两个选项是运行和归档。构建生成可执行文件,但不为 iOS 打包,而运行和归档正是这样做的:

  • 运行将在通过 USB 线缆连接到计算机的 iPhone 上测试应用程序。

  • 归档将创建一个应用程序包,可以发送到其他注册的设备(无论是发布还是通过苹果所说的临时分发进行测试)。

归档不会直接创建应用程序包,而是在原始代码文件和 IPA 之间的中间阶段创建一个包。创建的归档将列在 Xcode 的 Organizer 窗口中;在那个窗口中,选择生成的归档,然后在右侧点击分发应用程序。点击后,您将被询问是否要在商店或临时分发应用程序。

如果您选择临时分发,您将得到一个 IPA 文件,可以发送给测试人员。您可以直接发送文件让他们通过 iTunes 安装,但设置一个网站来处理分发和安装测试构建会更方便。或者,对于已上传到商店但尚未提交的构建,可以使用 TestFlight (developer.apple.com/testflight/)。

设置 Android 构建工具

与 iOS 应用不同,Unity 可以直接生成最终的 Android 应用程序(无论是 APK,即 Android 应用包,还是 AAB,即 Android 应用包),这需要将 Unity 指向 Android SDK,其中包含必要的编译器。您可以选择在 Unity 中安装 Android SDK 以及 Android 构建模块,或者从 Android Studio 内部安装它,并在 Unity 的首选项中指向该文件位置(见图 13.5)。您可以从developer.android.com/studio下载 Android 构建工具。

CH13_F05_Hocking3

图 13.5 Unity 首选项设置以指向 Android SDK

在 Unity 的首选项中设置 Android SDK 后,您需要指定与应用程序标识符,就像您为 iOS 所做的那样。您将在玩家设置中找到包名;将其设置为 com.companyname.productname(如之前设置 iOS 的包标识符时所述)。然后点击构建以开始过程。与所有构建一样,Unity 将首先询问文件保存位置。然后它将在该位置创建一个 APK 文件。

现在您有了应用程序包,必须在设备上安装它。您可以通过从网络(如 Google Drive 这样的云存储对这一目的很有用)下载文件或将文件通过连接到计算机的 USB 电缆(这种方法被称为侧载)传输到 Android 手机上,将 APK 文件安装到 Android 手机上。通过 USB 传输文件的具体细节因设备而异,但一旦文件到达那里,就可以使用文件管理器应用程序安装文件。我不知道为什么文件管理器没有内置到 Android 中,但您可以从 Google Play Store 免费安装一个。在文件管理器中导航到您的 APK 文件,然后安装应用程序。

Android 构建的 APK 与 AAB

自从 Android 诞生以来,应用程序都是以 APK(Android 应用程序包)文件的形式进行分发的。然而,谷歌已经支持 AAB(Android 应用包,一种替代的应用文件格式)有一段时间了,并且已经开始要求将此格式用于提交到 Play Store 的应用程序。应用包允许 Play Store 生成一个专为特定用户下载的小型应用程序包,而不是将每个设备的支持都嵌入到单个应用程序包中,从而生成更小的文件。

Unity 在构建设置窗口中支持这两种格式。当您选择 Android 平台时,查找“构建应用包”复选框;对于 APK,请取消选中该复选框,或者对于 AAB,请选中该复选框。通常在测试时构建 APK 文件会更好(因为它们更容易在测试设备上安装),然后为最终版本提交到 Play Store 构建 AAB。

CH13_UN03_Hocking3

在 APK 和 AAB 之间切换 Android 构建的位置

如您所见,Android 的基本构建过程比 iOS 的构建过程简单得多。不幸的是,自定义构建和实现插件的流程比 iOS 复杂;您将在稍后了解如何操作。但在那之前,让我们先谈谈纹理压缩。

13.3.2 纹理压缩

资产可能会消耗大量内存,这尤其包括纹理。为了减少它们的文件大小,您可以通过各种方式压缩资产,每种方法都有其优缺点。由于这些优缺点,您可能需要调整 Unity 压缩纹理的方式。

在移动设备上管理纹理压缩是至关重要的,尽管从技术上讲,其他平台也经常压缩纹理。但您不必对其他平台的压缩那么关注,原因有很多——最主要的原因是平台在技术上更加成熟。在移动设备上,您需要更加关注纹理压缩,因为设备对这一细节更加敏感。

Unity 会自动为您压缩纹理。在大多数开发工具中,您需要自己压缩图像,但在 Unity 中,您通常导入未压缩的图像,然后在图像的导入设置中应用图像压缩(见图 13.6)。

CH13_F06_Hocking3

图 13.6 检查器中的纹理压缩设置

压缩设置在不同平台上有所不同,因此当您切换平台时,Unity 会重新压缩图像。最初,设置是默认值,您可能需要根据特定图像和特定平台进行调整。特别是,在 Android 上对图像压缩更为复杂。这主要是因为 Android 设备的碎片化:由于所有 iOS 设备都使用相当相同的视频硬件,iOS 应用可以针对其图形芯片(GPU)进行纹理压缩优化。Android 应用没有享受相同的硬件一致性,因此它们的纹理压缩必须针对最低的共同点。

更具体地说,所有 iOS 设备都使用(或者更确切地说,曾经使用,并且仍然与 PowerVR GPU 保持兼容性)PowerVR GPU。因此,iOS 应用可以在所有 iOS 设备上使用优化的 PowerVR 纹理压缩(PVRTC),或者甚至使用自 iPhone 6 版本以来所有 iPhone 都支持的较新的 ASTC 格式。一些 Android 设备也使用 PowerVR 芯片,但它们同样频繁地使用来自高通的 Adreno 芯片、ARM 的 Mali GPU 或其他选项。因此,Android 应用通常依赖于爱立信纹理压缩(ETC),这是一种所有 Android 设备都支持的更通用的压缩算法。Unity 默认为具有 alpha 通道的纹理使用 ETC2(更先进的第二个版本),因为原始 ETC 压缩格式没有 alpha 通道,但请注意,较旧的 Android 设备可能不支持 ETC2。

这种默认设置在大多数情况下效果相当不错,但如果您需要调整纹理的压缩,请调整图 13.6 中显示的设置。点击 Android 图标标签页以覆盖该平台的默认设置,然后使用格式菜单选择特定的压缩格式。特别是,您可能会发现某些关键图像需要解压缩;尽管它们的文件大小会大得多,但图像质量会更好。只要您压缩大多数纹理,并且仅在特定情况下选择不压缩,增加的文件大小可能不会太糟糕。在讨论了这一点之后,移动开发的最后一个主题是开发本地插件。

13.3.3 开发插件

Unity 内置了大量的功能,但这些功能主要限于所有平台都通用的功能。利用特定平台的工具包(例如 Android 上的 Play Game Services)通常需要为 Unity 添加插件。

TIP 在 iOS 和 Android 特定功能方面,有多种预制的移动插件可供使用;附录 D 列出了几个获取移动插件的地方。这些插件以这里描述的方式运行,但插件代码已经为您准备好了。

与移动插件通信的过程与与浏览器的通信过程类似。在 Unity 的那一侧,特殊的命令调用插件中的函数。在插件那一侧,插件可以使用 SendMessage() 向 Unity 场景中的对象发送消息。在不同的平台上,具体的代码可能不同,但基本思想始终相同。

警告:就像初始构建过程一样,移动原生开发的过程往往会频繁变化——不是过程的一端,而是原生代码部分。我会从高层次上介绍这些内容,但你应该在网上查找最新的文档。

两个平台的插件都在 Unity 中的同一位置。如果需要,在项目视图中创建一个名为 Plugins 的文件夹;然后在 Plugins 中为 Android 和 iOS 各创建一个文件夹。一旦它们被放入 Unity 中,插件文件也有针对它们应用的平台的设置。通常,Unity 会自动处理这些设置(iOS 插件设置为 iOS,Android 插件设置为 Android 等),但如果需要,请在检查器中查找这些设置。

iOS 插件

插件实际上只是被 Unity 调用的原生代码。首先,在 Unity 中创建一个脚本以处理原生代码;将这个脚本命名为 TestPlugin(参见下一列表)。

列表 13.5 调用 iOS 原生代码的 TestPlugin 脚本

using System;
using System.Collections;
using System.Runtime.InteropServices;
using UnityEngine;

public class TestPlugin : MonoBehaviour {
   private static TestPlugin _instance;

   public static void Initialize() {                    ❶
      if (_instance != null) {
         Debug.Log("TestPlugin instance was found. Already initialized");
         return;
      }
      Debug.Log("TestPlugin instance not found. Initializing...");

      GameObject owner = new GameObject("TestPlugin_instance");
      _instance = owner.AddComponent<TestPlugin>();
      DontDestroyOnLoad(_instance);
   }

   #region iOS                                          ❷
   [DllImport("__Internal")]                            ❸
   private static extern float _TestNumber();           ❸

   [DllImport("__Internal")]
   private static extern string _TestString(string test);
   #endregion iOS

   public static float TestNumber() {
      float val = 0f;
      if (Application.platform == RuntimePlatform.IPhonePlayer)
         val = _TestNumber();                           ❹
      return val;
   }

   public static string TestString(string test) {
      string val = "";
      if (Application.platform == RuntimePlatform.IPhonePlayer)
         val = _TestString(test);
      return val;
   }
}

❶ 对象是在这个静态函数中创建的,因此你不必在编辑器中创建它。

❷ 标识代码段落的标签;标签本身并不做任何事情。

❸ 参考 iOS 代码中的函数。

❹ 如果平台是 IPhonePlayer,则调用此函数。

首先,请注意静态 Initialize() 函数在场景中创建了一个永久对象,这样你就不必在编辑器中手动创建它。你之前没有看到过从头创建对象的代码,因为使用预制体在大多数情况下要简单得多,但在这个例子中,在代码中创建对象更干净(这样你就可以使用插件脚本而无需编辑场景)。

这里进行的主要魔法涉及 DllImport 和 static extern 命令。这些命令告诉 Unity 连接到你提供的原生代码中的函数。然后你可以在这段脚本的函数中使用这些引用的函数(同时检查代码是否在 iPhone/iOS 上运行)。

接下来,你将使用这些插件函数来测试它们。创建一个新的脚本名为 MobileTestObject,在场景中创建一个空对象,然后将脚本附加到该对象上。

列表 13.6 从 MobileTestObject 使用插件

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MobileTestObject : MonoBehaviour {
   private string message;

   void Awake() {
      TestPlugin.Initialize();                            ❶
   }

   // Use this for initialization
   void Start() {
      message = "START: " + TestPlugin.TestString("ThIs Is A tEsT");
   }

   // Update is called once per frame
   void Update() {

      // Make sure the user touched the screen
      if (Input.touchCount==0){return;}

      Touch touch = Input.GetTouch(0);                    ❷
      if (touch.phase == TouchPhase.Began) {
         message = "TOUCH: " + TestPlugin.TestNumber();
      }
   }

   void OnGUI() {
      GUI.Label(new Rect(10, 10, 200, 20), message);      ❸
   }
}

❶ 在开始时初始化插件。

❷ 响应触摸输入。

❸ 在屏幕角落显示一条消息。

列表中的脚本初始化插件对象,然后根据触摸输入调用插件方法。一旦在设备上运行,你会在屏幕角落看到测试信息在每次点击屏幕时改变。

最后要做的就是编写 TestPlugin 引用的原生代码。iOS 设备上的代码使用 Objective C 和/或 C(或 Swift,但我们不会使用该语言),因此你需要一个.h 头文件和一个.mm 实现文件。如前所述,它们需要放在项目视图中的 Plugins/iOS/文件夹中。在 TestPlugin.h 和 TestPlugin.mm 中创建它们;在.h 文件中,编写此代码。

列表 13.7 iOS 代码的 TestPlugin.h 头文件

#import <Foundation/Foundation.h>

@interface TestObject : NSObject {
NSString* status;
}

@end

寻找关于 iOS 编程的解释来了解这个头文件的作用;解释 iOS 编程超出了本书的范围。将此列表中的代码写入.mm 文件。

列表 13.8 TestPlugin.mm 实现

#import "TestPlugin.h"

@implementation TestObject
@end

NSString* CreateNSString (const char* string)
{
if (string)
return [NSString stringWithUTF8String: string];
else
return [NSString stringWithUTF8String: ""];
}

char* MakeStringCopy (const char* string)
{
if (string == NULL)
return NULL;

char* res = (char*)malloc(strlen(string) + 1);
strcpy(res, string);
return res;
}

extern "C" {
    const char* _TestString(const char* string) {
        NSString* oldString = CreateNSString(string);
        NSString* newString = [oldString lowercaseString];
        return MakeStringCopy([newString UTF8String]);
    }

    float _TestNumber() {
        return (arc4random() % 100)/100.0f;
    }
}

再次,对这个代码的详细解释略超出了本书的范围。注意,许多字符串函数都是为了将 Unity 对字符串数据的表示转换为原生代码。

TIP 本示例仅在一个方向上进行通信,即从 Unity 到插件。但原生代码也可以通过使用 UnitySendMessage()方法与 Unity 进行通信。你可以向场景中的命名对象发送消息;在初始化过程中,插件创建了 TestPlugin_instance 以发送消息。

在原生代码就绪后,你可以构建 iOS 应用并在设备上进行测试。角落的消息最初将全部为小写;然后点击屏幕,观察显示的数字。非常酷!

更多信息,请访问docs.unity3d.com/Manual/PluginsForIOS.html。这是创建 iOS 插件的方法,那么让我们也看看 Android。

Android 插件

要创建 Android 插件,Unity 方面的操作几乎完全相同。你根本不需要修改 MobileTestObject。在 TestPlugin 中添加这里显示的添加项。

列表 13.9 修改 TestPlugin 以使用 Android 插件

...
   #region iOS
   [DllImport("__Internal")]
   private static extern float _TestNumber();

   [DllImport("__Internal")]
   private static extern string _TestString(string test);
   #endregion iOS

#if UNITY_ANDROID
   private static Exception _pluginError;
   private static AndroidJavaClass _pluginClass;                  ❶
   private static AndroidJavaClass GetPluginClass() {             ❶
      if (_pluginClass == null && _pluginError == null) {         ❶
         AndroidJNI.AttachCurrentThread();
         try {
            _pluginClass = new AndroidJavaClass("com.testcompany.testplugin.TestPlugin");   ❷
         } catch (Exception e) {
            _pluginError = e;
         }
      }
      return _pluginClass;
   }

   private static AndroidJavaObject _unityActivity;
   private static AndroidJavaObject GetUnityActivity() {
      if (_unityActivity == null) {
         AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");          ❸
         _unityActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
      }
      return _unityActivity;
   }
#endif

   public static float TestNumber() {
      float val = 0f;
      if (Application.platform == RuntimePlatform.IPhonePlayer)
         val = _TestNumber();
#if UNITY_ANDROID
      if (!Application.isEditor && _pluginError == null)
         val = GetPluginClass().CallStatic<int>("getNumber");     ❹
#endif
      return val;
   }

   public static string TestString(string test) {
      string val = "";
      if (Application.platform == RuntimePlatform.IPhonePlayer)
         val = _TestString(test);
#if UNITY_ANDROID
      if (!Application.isEditor && _pluginError == null)
         val = GetPluginClass().CallStatic<string>("getString", test);
#endif
      return val;
   }
}

❶ Unity 提供的 AndroidJNI 功能

❷ 你编写的类的名称;根据需要更改此名称。

❸ Unity 为 Android 应用创建一个 Activity。

❹ 调用.plugin 中的函数

你会注意到大部分添加都是在 UNITY_ANDROID 平台定义内部发生的。正如本章前面所解释的,这些编译器指令使得代码仅适用于某些平台,在其他平台上被省略。而 iOS 代码没有做任何会在其他平台上出错的事情(它不会做任何事情,但也不会引起错误),Android 插件的代码只有在 Unity 设置为 Android 平台时才会编译。

特别注意对 AndroidJNI 的调用。这是 Unity 连接到原生 Android 的系统。另一个可能令人困惑的词是 Activity;在 Android 应用中,Activity 是一个应用进程。Unity 游戏是 Android 应用的一个 Activity,因此插件代码需要访问该 Activity,以便在需要时传递它。

最后,你需要原生 Android 代码。iOS 代码是用 Objective C 和 C 等语言编写的,而 Android 是用 Java(或 Kotlin,但我们将使用 Java)编写的。但你不能简单地提供插件的原始 Java 代码;插件必须是从 Java 代码打包的 JAR。在这里,Android 编程的细节超出了 Unity 介绍的范畴,但我们将简要介绍基础知识。首先,如果你在下载 Android SDK 时没有这样做,你应该安装 Android Studio。

图 13.7 展示了在 Android Studio 中设置插件项目的步骤(包含来自版本 4.2.1 的截图):

  1. 通过在启动窗口中选择或在文件 > 新建 > 新建项目下进行操作来创建一个新项目。

  2. 在出现的“新建项目”窗口中,选择无活动模板(因为这是一个插件,而不是独立的 Android 应用程序)并点击下一步。

  3. 现在将其命名为 TestPluginProj;对于这个测试,最小 SDK 版本不重要,但请记住项目位置,因为稍后你需要找到它。点击完成以创建新项目,如果需要加载,请再次点击完成以关闭窗口。

  4. 一旦出现编辑视图,选择文件 > 新建 > 新模块以添加库。

  5. 选择 Android 库,命名为 testplugin,将包名改为 com.testcompany.testplugin,然后点击完成。

  6. 在添加了该模块后,选择构建 > 选择构建变体;在打开的面板中,点击 TestPluginProj.testplugin 的活动构建变体并选择发布。

  7. 现在在项目面板的上方展开 testplugin > java,右键单击 com.testcompany.testplugin,并选择新建 > Java 类。

  8. 打开一个小窗口以配置新类,因此输入名称 TestPlugin 并按 Enter。

CH13_F07_Hocking3

图 13.7 设置 Android Studio 以构建插件

TestPlugin 目前为空,所以可以在其中编写插件函数。列表 13.10 展示了插件的 Java 代码。

列表 13.10 编译成 JAR 的 TestPlugin.java

package com.testcompany.testplugin;

public class TestPlugin {
   private static int number = 0;

   public static int getNumber() {
      number++;
      return number;
   }

   public static String getString(String message) {
      return message.toLowerCase();
   }
}

好的,现在你可以将此代码打包成一个 JAR(或者更确切地说是一个包含 JAR 的 Android 存档文件)。在顶部菜单中,选择构建 > 生成项目。一旦构建完成,转到你的电脑上的项目,在 <项目位置>/testplugin/build/outputs/aar/ 中找到 testplugin-release.aar。将存档文件拖到 Unity 的 Android 插件文件夹中导入。

Android 的清单和资源文件夹

对于这个简单的测试插件来说,这不是必需的,但 Android 插件通常必须编辑清单文件。所有 Android 应用程序都受一个名为 AndroidManifest.xml 的主要配置文件控制;如果你没有提供,Unity 会创建一个基本的清单文件,但你也可以手动提供一个,将其放在 Plugins/Android/ 与插件一起。

Unity 在运行时会向项目中添加一个 Temp 文件夹,当构建 Android 应用程序时,Unity 会将生成的清单文件放在那里(StagingArea/UnityManifest.xml)。将此文件复制出来进行手动编辑;本章的代码下载包括一个示例清单文件。

同样,有一个名为res的文件夹,你可以将自定义图标等资源放在那里。要用自己的资源替换这个生成的文件夹,你可以在 Android 插件文件夹中创建一个 res 文件夹。

在 Plugins/Android 的存档文件中构建游戏,并将其安装到设备上,每次你点击屏幕时,消息都会改变。同样,像 iOS 插件一样,Android 插件可以使用 UnityPlayer.UnitySendMessage()与场景中的对象通信。Java 代码需要导入 Unity 的 Android Player 库,该库位于 Unity 安装文件夹中(在 Windows 上通常是 C:\Program Files\ Unity\Editor\Data,在 Mac 上通常是/Applications/Unity/Editor),路径为/PlaybackEngines/AndroidPlayer/Variations/mono/Release/Classes/classes.jar。

我知道我在开发 Android 库时省略了很多内容,但这是因为这个过程既复杂又经常变化。如果你足够高级,可以开发 Android 游戏的插件,你将不得不在 Android 的开发者网站上查找文档,并参考 Unity 的文档,请参阅mng.bz/yJKG

13.4 开发 XR(包括 VR 和 AR)

注意:缩写XR代表扩展现实,这个术语包括虚拟现实(VR)和增强现实(AR)。VR 指的是将用户完全沉浸在合成环境中,而 AR 指的是向自然环境添加计算机图形,但两者都属于调节用户周围环境的技术的范畴。

XR 是本章最后讨论的“平台”。“平台”这个词加了引号,因为 XR 在构建应用程序时并不被视为一个独立平台。相反,XR 支持来自可以添加到相关构建平台(如桌面 VR 或移动 AR)的插件包。让我们先了解一下这是如何工作的,首先是 VR,然后是 AR。

13.4.1 支持虚拟现实头戴式设备

目前市场上主要的 VR 设备有 Oculus Quest、HTC VIVE、Valve Index 和 PlayStation VR。忽略 PlayStation VR(因为这本书不涵盖控制台开发),所有其他设备都是通过添加 VR SDK 到 Unity 的 PC 构建目标,或者(在 Oculus Quest 的情况下)到 Android 中支持的。

有多种这样的 SDK 可供选择,通过 Unity 的包管理器进行分发。例如,浏览 Unity 注册表以找到 Oculus XR 或 Windows XR 等选项。同时,Unity 开发者还提供了一个有吸引力的选项,即 XR 交互工具包,但这个包稍微难找一些。因为这个包还不被认为完整(尽管在 AR 支持方面大部分不完整;VR 支持相当稳固),它被视为预览包。默认情况下,标记为预览的包不会显示,但你可以通过调整包管理器窗口的设置来显示预览包(见图 13.8)。

CH13_F08_Hocking3

图 13.8 如何在包管理器中查看预览包

安装 XR 包后,你必须在项目设置中启用它(记住,那是编辑 > 项目设置)在 XR 插件管理下(如图 13.9 所示)。

CH13_F09_Hocking3

图 13.9 在项目设置中管理 XR 插件

注意:XR 插件管理本身是一个包,尽管它应该与你选择的任何其他 XR 包一起安装。如果那些设置没有出现,你可能需要手动安装该包。

我们不会介绍任何特定 VR 设备的代码,因为可供选择的选择太多。相反,我鼓励你访问相关 XR 插件的文档:

然而,我们将实现一个简单的示例来帮助解释 AR。

13.4.2 移动增强现实 AR 基础库

与 VR 不同,增强现实不一定意味着需要头戴式显示器(HMD)。当然,可以涉及 HMD,Unity 支持 HoloLens 和 Magic Leap 等设备。然而,AR 也可以通过手机实现,有时被称为手持增强现实

苹果和谷歌分别为 iOS 和 Android 平台上的手持增强现实提供了 SDK。苹果的 SDK 称为 ARKit,而谷歌提供 ARCore。然而,这些库是特定于这些平台的,因此 Unity 提供了一个名为AR 基础库的跨平台包装器。作为开发者,重要的是要理解你实际上是在 ARKit 或 ARCore 的底层工作,但你编写的代码是针对 AR 基础库的 API。

首先,创建一个新的 Unity 项目。在这个新项目中,进入包管理器并安装 AR 基础库,同时根据你正在为哪个移动平台开发,安装 ARKit XR 或 ARCore XR(或两者都安装!)。然后,在 XR 插件管理中启用 ARKit 或 ARCore(如图 13.9 所示)。

注意:ARKit 的面部跟踪部分有一个与 ARKit 其余部分分开的包。这是因为苹果会拒绝提交包含面部跟踪代码但实际并未进行面部 AR 的应用。因此,如果您不进行面部 AR,请仅安装主要的 ARKit XR 插件包;如果您进行面部 AR,请安装两个包。

ARKit 和 ARCore 在 iOS 和 Android 平台上都有要求,必须在玩家设置中满足(见图 13.10a 和 b)。在 Android 上,首先从图形 API 列表中移除 Vulkan(选择 Vulkan,然后点击减号按钮),然后向下滚动并将最小 API 级别更改为 24。在 iOS 上,将最小 iOS 版本设置为 11,确保架构设置为 ARM64,打开需要 ARKit 设置,并输入相机使用描述(例如,用于 AR 的相机)。

CH13_F10a_Hocking3

图 13.10a 调整 Android 设置以支持 AR

CH13_F10b_Hocking3

图 13.10b 调整 iOS 设置以支持 AR

ARKit 需要这些 iOS 设置才能运行,ARCore 需要这些 Android 设置。在玩家设置中进行了所有必要的调整后,接下来设置场景中需要的各种对象。如图 13.11 所示,步骤如下:

  1. 从 GameObject 菜单中选择 XR > AR Session。

  2. 选择 GameObject > XR > AR Session Origin。

  3. 选择 GameObject > XR > AR 默认平面。

  4. 删除主摄像头(因为会话原点已经包含了一个用于 AR 的摄像头设置)。

  5. 创建一个空的 GameObject 并将其命名为 Controllers。

接下来,创建一个新的 C# 脚本,命名为 PlaneTrackingController,并将列表 13.11 写入其中。

列表 13.11 使用 AR Foundation 的 PlaneTrackingController 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

public class PlaneTrackingController : MonoBehaviour {
    [SerializeField] ARSessionOrigin arOrigin = null;
    [SerializeField] GameObject planePrefab = null;                        ❶

    private ARPlaneManager planeManager;

    void Start() {
        planeManager = arOrigin.gameObject.AddComponent<ARPlaneManager>(); ❷
        planeManager.detectionMode = PlaneDetectionMode.Horizontal;
        planeManager.planePrefab = planePrefab;
    }
}

❶ 这应该是来自 XR 对象的平面预制件,而不仅仅是任何游戏对象。

❷ 也可以在编辑器中添加此组件,但我们将通过代码添加。

此脚本向会话原点添加了一个名为 ARPlaneManager 的组件,并为平面管理器分配了一些设置,包括用于可视化检测到的平面的对象。此组件可以在编辑器中添加,但通过代码添加提供了更多控制 AR 的灵活性。

将此脚本拖到 Controllers 对象上,将其链接为组件。现在(如图 13.11 所示),将 AR Session Origin 和 AR Default Plane 拖到检查器中的组件槽位。

CH13_F11_Hocking3

图 13.11 简单 AR 场景中设置对象

一切准备就绪后,构建移动应用以查看平面跟踪功能。由于 PlaneTrackingController 使用 AR Foundation(而不是 ARKit 或 ARCore),项目应该能在 iOS 和 Android 上运行。一旦应用在您的设备上运行,您应该会在移动摄像头时看到类似于图 13.12 的内容。

CH13_F12_Hocking3

图 13.12 AR 平面检测动作图

太好了,环境中的平面正在被检测到!然而,目前除了计算机检测到表面之外,没有其他动作。也就是说,没有东西被放置在检测到的表面上。AR Foundation 提供了几个有用的功能,不仅仅是平面跟踪,另一个有用的功能是对检测到的 AR 表面进行射线投射。按照列表 13.12 添加进行 AR 射线投射的代码。

列表 13.12 向 PlaneTrackingController 添加射线投射

...
    private ARPlaneManager planeManager;
    private ARRaycastManager raycastManager;                         ❶
    private GameObject prim;
...
    void Start() {
        prim = GameObject.CreatePrimitive(PrimitiveType.Cube);       ❷
        prim.SetActive(false);

        raycastManager = arOrigin.gameObject.AddComponent<ARRaycastManager>();

        planeManager = arOrigin.gameObject.AddComponent<ARPlaneManager>();
        ...
    }

    void Update() {
        if (Input.GetMouseButtonDown(0)) {
            var hits = new List<ARRaycastHit>();
            if (raycastManager.Raycast(Input.mousePosition, hits,    ❸
                  TrackableType.PlaneWithinPolygon)) {
                prim.SetActive(true);
                prim.transform.localScale = new Vector3(.1f, .1f, .1f);

                var pose = hits[0].pose;
                prim.transform.localPosition = pose.position;
                prim.transform.localRotation = pose.rotation;
            }
        }
    }
...

❶ 在现有管理器下方添加新字段。

❷ 创建一个要放置在检测到的表面上的对象。

❸ 在用户输入时调用 Raycast 方法。

再次将应用程序部署到您的移动设备上。这次,点击检测到的平面,应该会出现一个立方体,就像图 13.13 所示。这样,你就可以在你的真实环境中放置虚拟对象。

CH13_F13_Hocking3

图 13.13 放置在跟踪平面上的立方体

这个例子仅涉及 AR Foundation 的基础知识。对于更深入的使用,请参考 Unity 的手册(mng.bz/p9aG)以及 Unity 在 GitHub 上的示例项目(mng.bz/YwpN)。

Unity 作为库

通常,Unity 项目作为自包含的应用程序部署,这种配置对于游戏来说非常合理。然而,Unity 越来越多地被用于非游戏 XR 开发,这些用户可能希望将他们的 Unity 项目与外部应用程序集成。

因此,Unity 现在能够将项目作为库部署,以便在更大的应用程序中使用。Unity 作为库的功能支持 iOS 和 Android,使移动开发者能够将增强现实内容(由 AR Foundation 提供)添加到他们的应用程序中。更多信息,请点击以下链接:

恭喜,你已经到达了终点!

恭喜,你现在知道了在大多数主要平台上部署 Unity 游戏的步骤。所有平台的基本构建过程都很简单(只需一个按钮),但在各种平台上自定义应用程序可能会变得复杂。现在你准备好出去构建自己的游戏了!

摘要

  • Unity 可以为包括桌面计算机、移动设备和网站在内的各种平台构建可执行应用程序。

  • 可以将大量设置应用于构建,包括像应用程序图标和显示名称这样的细节。

  • 网页游戏可以与其嵌入的网页交互,从而允许各种有趣的网页应用程序。

  • Unity 支持自定义插件以扩展其功能。

后记

到目前为止,你已经知道使用 Unity 构建完整游戏所需的一切知识——从编程角度来看;一个顶级的游戏还需要出色的艺术和声音。但作为一个游戏开发者,成功不仅仅涉及技术技能。让我们面对现实——学习 Unity 并不是你的最终目标。你的目标是创建成功的游戏,而 Unity 只是帮助你达到这个目标的工具(当然,是一个非常优秀的工具)。

除了实现游戏中所有内容的技术技能之外,你还需要一个额外的无形属性:毅力。我指的是坚持不懈和自信地继续在一个具有挑战性的项目上工作,并把它完成——我有时称之为“完成能力”。提高你的完成能力只有一种方法,那就是完成许多项目。这似乎是一个悖论(为了获得完成项目的能力,你首先需要完成许多项目),但关键是要认识到,小项目比大项目更容易完成。

因此,前进的道路是首先构建许多小项目——因为那些容易完成——然后逐步过渡到更大的项目。许多新游戏开发者犯了一个错误,就是试图处理一个对他们来说太大的项目,主要有两个原因:他们想要模仿他们最喜欢的(大型)游戏,而且每个人都低估了制作游戏所需的工作量。项目似乎一开始进行得很顺利,但很快就被太多的挑战所拖累,最终开发者感到沮丧并放弃。

相反,对于游戏开发的新手来说,应该从小开始。从那些看似微不足道的小项目开始。这本书中的项目就是那种“小,几乎到了微不足道的程度”的项目,你应该从这些项目开始。如果你已经完成了这本书中的所有项目,你已经完成了许多这些入门级项目。尝试在下一个项目中做一些更大的项目,但要小心不要跳跃太大。你会建立起你的技能和信心,所以你可以每次都稍微更有雄心壮志。

你几乎在任何时候询问如何开始开发游戏时都会听到同样的建议。例如,Unity 要求网络系列Extra Credits(一个关于游戏开发的大好系列)制作一些关于开始游戏开发的视频,你可以在mng.bz/GOjq找到它们。

游戏设计

整个Extra Credits系列远远超出了 Unity 赞助的这些视频的范围。它涵盖了大量的内容,但主要关注游戏设计的学科。

定义 游戏设计是通过创建游戏的目标、规则和挑战来定义游戏的过程。它不应与视觉设计混淆,视觉设计是设计外观而不是功能。这是一个常见的错误,因为普通人最熟悉的是在“图形设计”背景下的“设计”。

定义:游戏设计最核心的部分之一是设计游戏机制——游戏中的单个动作(或动作系统)。游戏中的机制通常由其规则设定,而游戏的挑战通常来自于将机制应用于特定情况。例如,在游戏中四处走动是一种机制;迷宫是基于这种机制的一种挑战。

对于游戏开发的新手来说,思考游戏设计可能会很棘手。最成功(且制作起来最令人满意)的游戏都是通过有趣和创新的机制构建的。相反,过分担心你的第一款游戏的设计可能会让你分心,无法关注游戏开发的其它方面,比如学习如何编程它。你最好从模仿现有游戏的设计开始。记住,我只是在谈论“开始”;克隆现有游戏对于初始实践很有帮助,但最终你会拥有足够的技能和经验来进一步拓展。

话虽如此,任何成功的游戏开发者都应该对游戏设计感到好奇。有很多方法可以了解更多关于游戏设计的信息——你已经知道关于《Extra Credits》视频,但这里还有一些其他网站:

  • www.gamedeveloper.com—提供工作机会、游戏更新、关于游戏的新闻/坏消息;关于制作游戏的艺术和商业的你想知道/需要的所有信息。

  • lostgarden.home.blog/worth-reading/—关于游戏设计理论、艺术和设计业务的易读、有见地的文章。

  • sloperama.com—点击 School-a-rama 获取游戏行业建议页面。

关于这个主题的伟大书籍也很多,例如以下这些:

  • 《游戏设计艺术》,第三版,作者:Jesse Schell(A K Peters/CRC Press,2019)

  • 《游戏设计工作坊》,第四版,作者:Tracy Fullerton(A K Peters/CRC Press,2018)

  • 《游戏设计中的乐趣理论》,第二版,作者:Raph Koster(O’Reilly Media,2013)

游戏营销

在《Extra Credits》视频中,第四个视频是关于游戏营销的。有时游戏开发者会推迟考虑营销问题。他们只想专注于游戏开发,而不是营销,但这种态度可能会导致游戏失败。即使是最优秀的游戏,如果没有人知道它,也不会成功!

词语“营销”常常让人联想到广告,如果你有预算,那么为你的游戏投放广告当然是一种营销方式。但你可以通过许多低成本甚至免费的方式来宣传你的游戏。具体方法会随时间而变化,但视频中提到的总体策略包括在推特上宣传你的游戏(或发布在社交媒体上,而不仅仅是推特)以及创建预告片视频,在 YouTube 上与评论家、博主等分享。保持耐心并发挥创意!

现在去创造一些优秀的游戏吧。Unity 是实现这一目标的绝佳工具,而且你已经学会了如何使用它。祝你在旅途中好运!

附录 A. 场景导航和键盘快捷键

操作 Unity 通过鼠标和键盘完成,但对于新手来说,并不明显 如何 在 Unity 中使用鼠标和键盘。特别是,最基本的鼠标和键盘输入是导航场景和查看 3D 对象。Unity 还为常用操作提供了键盘命令。

我将在这里解释输入控制,但您也可以参考几个网页(这些是 Unity 在线手册中的相关页面):

A.1 使用鼠标进行场景导航

场景导航主要通过三种主要的导航操作来完成:移动、环绕和缩放。这三种操作涉及在按住 Alt(或在 Mac 上为 Option)和 Ctrl(在 Mac 上为 Command)组合键的同时点击和拖动。具体的控制方式因单按钮、双按钮和三按钮鼠标而异;表 A.1 列出了所有控制方式。

表 A.1 不同类型鼠标的场景导航控制

导航操作 三按钮鼠标 双按钮鼠标 单按钮鼠标
移动 中键点击/拖动 Alt-Command + 左键点击/拖动 Alt-Command + 点击/拖动
环绕 按住 Alt + 左键点击/拖动 Alt + 左键点击/拖动 Alt + 点击/拖动
缩放 按住 Alt + 右键点击/拖动 Alt + 右键点击/拖动 Alt-Ctrl + 点击/拖动

注意:尽管 Unity 可以使用单按钮或双按钮鼠标,但我强烈建议您购买一个三按钮鼠标(是的,三按钮鼠标在 Mac 上也能正常工作)。

除了使用鼠标进行的导航操作外,一些视图控制基于键盘。如果您按住鼠标的右键,键盘上的 W、A、S、D 键可以用来以大多数第一人称游戏常见的方式四处走动。在执行任何其他控制时按住 Shift 键可以加快移动速度。

但最重要的是,如果您在选中对象时按 F 键,场景视图将平移和缩放以聚焦于该对象。如果您在导航场景时迷路,一个常见的“逃生口”是在层次结构中选择一个对象,将鼠标移到场景视图中(此快捷键仅在视图中有效),然后按 F 键。

A.2 常用键盘快捷键

Unity 有键盘命令可以快速访问重要功能。最重要的键盘快捷键是 W、E、R 和 T:这些键激活变换工具平移、旋转和缩放(如果您不记得变换工具的作用,请参阅第一章),以及 2D 矩形工具。因为这些键紧挨在一起,所以通常在操作鼠标时,左手会放在这些键上。

除了变换工具外,您还可以使用键盘快捷键。表 A.2 列出了 Unity 中许多有用的键盘快捷键。

表 A.2 有用的键盘快捷键

按键 功能
W 平移(移动所选对象)
E 旋转(旋转所选对象)
R 缩放(调整所选对象的大小)
T 矩形工具(操纵 2D 对象)
F 将视图聚焦于选定的对象
V 锁定到顶点
Ctrl/Command-Shift-N 创建新 GameObject
Ctrl/Command-P 播放游戏
Ctrl/Command-R 刷新项目
Ctrl/Command-1 将当前窗口设置为场景视图
Ctrl/Command-2 设置为游戏视图
Ctrl/Command-3 设置为检查器视图
Ctrl/Command-4 设置为层次结构视图
Ctrl/Command-5 设置为项目视图
Ctrl/Command-6 设置为动画视图

Unity 还响应其他键盘快捷键,但随着列表的向下延伸,它们变得越来越不为人知。

附录 B. 与 Unity 一起使用的工具

使用 Unity 开发游戏需要依赖各种外部软件工具来处理各种任务。在第一章中,我们讨论了一个外部工具:Visual Studio,尽管它捆绑在 Unity 中,但技术上是一个独立的应用程序。以类似的方式,开发者依赖一系列外部工具来完成 Unity 内部的工作。

这并不是说 Unity 缺乏它应该拥有的功能。相反,游戏开发过程非常复杂和多元,任何设计良好的软件,具有明确的焦点和清晰的关注点分离,不可避免地会限制自己在过程的一个有限子集上表现出色。在这种情况下,Unity 专注于成为将游戏的所有内容粘合在一起并使其工作的胶水和引擎。创建所有这些内容是通过其他工具完成的;让我们看看一些可能对你有用的软件类别。

B.1 编程工具

我们已经讨论了与 Unity 一起使用的最重要的编程工具:Visual Studio。但你应该了解其他编程工具,正如你将在本节中看到的。

B.1.1 Rider

如第一章所述,尽管 Unity 随带一个版本的 Visual Studio,但你可以选择使用不同的 IDE。最常见的选择是 Visual Studio Code 或 JetBrains Rider。Rider (www.jetbrains.com/lp/dotnet-unity/) 是一个强大的 C# 编程环境,具有 Unity 集成功能。

B.1.2 Xcode

Xcode 是苹果公司(特别是 IDE,但也包括 Apple 平台的 SDK)提供的编程环境。尽管你仍然会在 Unity 内部完成大部分工作,但你需要使用 Xcode (developer.apple.com/xcode/) 来部署游戏到 iOS。这项工作通常涉及使用 Xcode 中的工具进行调试或分析你的应用程序。

B.1.3 Android SDK

就像你需要安装 Xcode 来部署到 iOS 一样,你需要下载 Android SDK 来部署到 Android。通常,你会在 Unity Hub 中下载 Android 模块的同时下载 SDK。或者,Android SDK 与 Android Studio 一起提供,网址为 developer.android.com/studio。与构建 iOS 游戏不同,你不需要在 Unity 之外启动任何开发工具——你只需在 Unity 中设置指向 Android SDK 的首选项即可。

B.1.4 版本控制(Git,SVN)

任何体量较大的软件开发项目都会涉及对代码文件的大量复杂修订,因此程序员开发了一类称为 版本控制系统(VCS)的软件来处理这个问题。其中一些最受欢迎的免费系统是 Git (git-scm.com) 和 Apache Subversion(也称为 SVN,subversion.apache.org)。

如果你还没有使用版本控制系统(VCS),我强烈建议你开始使用。Unity 会在项目文件夹中填充临时文件和工作区设置,但唯一需要纳入版本控制的文件夹是 Assets(确保你的版本控制系统正在获取 Unity 生成的元文件),Packages 和 ProjectSettings。

B.2 3D 艺术应用程序

虽然 Unity 完全能够处理 2D 图形(第五章和第六章专注于 2D 图形),但它最初是一个 3D 游戏引擎,并且继续拥有强大的 3D 图形功能。许多 3D 艺术家至少使用本节中描述的软件包之一。

B.2.1 Maya

Autodesk Maya (www.autodesk.com/products/maya/overview) 是一个根植于电影制作的 3D 艺术和动画软件包。Maya 的功能集涵盖了几乎 3D 艺术家可能遇到的所有任务,从制作美丽的电影动画到制作高效的适用于游戏的模型。在 Maya 中完成的 3D 动画(如角色行走)可以导出到 Unity 中。

B.2.2 3ds Max

另一个广泛使用的 3D 艺术和动画软件包,Autodesk 3ds Max (www.autodesk.com/products/3ds-max/overview) 提供了几乎相同的功能集,并且在工作流程上与 Maya 相当。3ds Max 仅在 Windows 上运行(而其他工具,包括 Maya,是跨平台的),但在游戏行业中同样被广泛使用。

B.2.3 Blender

虽然在游戏行业中不像 3ds Max 或 Maya 那样常用,但 Blender (www.blender.org) 与那些其他应用程序相当。Blender 还涵盖了几乎所有 3D 艺术任务,而且最好的是,Blender 是开源的!鉴于它在所有平台上都可以免费使用,Blender 是这本书假设可用的唯一 3D 艺术应用程序。

B.2.4 SketchUp

这个简单易用的建模工具非常适合用于建筑和建筑元素。与之前的工具不同,SketchUp (www.sketchup.com) 并不涵盖所有或甚至大多数 3D 艺术任务;相反,它专注于使建模建筑和其他简单形状变得容易。这个工具在游戏开发中用于白盒建模和关卡编辑。

B.3 2D 图像编辑器

2D 图像对于所有游戏都至关重要,无论是直接用于 2D 游戏还是作为 3D 模型表面的纹理。在本节中,你将看到在游戏开发中经常出现的几种 2D 图形工具。

B.3.1 Photoshop

Adobe Photoshop (www.adobe.com/products/photoshop.html) 是最广泛使用的 2D 图像应用程序。Photoshop 中的工具可以用来修饰现有图像,应用图像过滤器,甚至从头开始绘制图片。Photoshop 支持数十种文件格式,包括 Unity 中使用的所有图像格式。

B.3.2 GIMP

GNU Image Manipulation Program(GIMP)的缩写,GIMP (www.gimp.org) 是最著名的开源 2D 图形应用程序。在功能和易用性方面,GIMP 略逊于 Photoshop,但它仍然是一款有用的图像编辑器,而且价格无法匹敌!

B.3.3 TexturePacker

与之前提到的工具不同,这些工具都超出了游戏开发领域,而 TexturePacker 仅适用于游戏开发。但它在设计任务上非常出色:将精灵图集组装起来用于 2D 游戏。如果你正在开发 2D 游戏,你可能想尝试 TexturePacker (www.codeandweb.com/texturepacker)。

B.3.4 Aseprite, Pyxel Edit

像素艺术是 2D 游戏中最具辨识度的艺术风格之一,Aseprite (www.aseprite.org) 和 Pyxel Edit (www.pyxeledit.com) 是优秀的像素艺术工具。虽然技术上 Photoshop 也可以用于像素艺术,但它并不专注于这项任务。此外,Aseprite 和 Pyxel Edit 在动画功能方面更为突出。

B.4 音频软件

可用的音频制作工具琳琅满目,包括音频编辑器(处理原始波形)和序列器(使用音符序列创作音乐)。为了展示可用的音频软件,本节将探讨两种主要的音频编辑工具。除此之外的例子还包括 Logic、Ableton 和 Reason。

B.4.1 Pro Tools

Pro Tools 音频软件(www.avid.com/en/pro-tools)拥有许多实用功能,并被无数音乐制作人和音频工程师视为行业标准。它常用于各种专业音频工作,包括游戏开发。

B.4.2 Audacity

尽管在专业音频工作中并不像 Audacity (www.audacityteam.org)那样有用,但它是一款小巧实用的音频编辑器,适用于小规模音频工作,例如准备短音频文件作为游戏中的音效。这是寻找开源音频编辑软件者的热门选择。

附录 C. 在 Blender 中建模长椅

在第二章和第四章中,我们探讨了创建带有大型平坦墙壁和地板的水平面。但更详细的对象怎么办?如果你想在房间里放置有趣的家具怎么办?你可以通过在外部 3D 艺术应用程序中构建 3D 模型来实现这一点。回想一下第四章引言中的定义:3D 模型是游戏中的网格对象(3D 形状)。在本附录中,我将向你展示如何创建一个简单的长椅网格对象(图 C.1)。

APPC_F01_Hocking3

图 C.1 你将要建模的简单长椅的示意图。

尽管附录 B 列出了几个 3D 艺术工具,但我们将使用 Blender 进行此练习,因为它开源,因此所有读者都可以访问。你将在 Blender 中创建一个网格对象,并将其导出为与 Unity 兼容的艺术资产。

提示:建模是一个很大的主题,但我们将只介绍一些建模功能,这将允许你创建长椅。如果你想在本章之后继续学习更多关于建模的知识,请查看许多关于该主题的书籍和教程(首先,查看www.blender.org的学习资源)。

警告:我使用了 Blender 2.91,因此解释和截图来自该软件版本。Blender 的新版本频繁发布,按钮位置或命令名称可能会有所变化。

C.1 构建网格几何形状

启动 Blender 并点击启动屏幕外的任何位置以关闭它;初始默认屏幕看起来像图 C.2,场景中间有一个立方体。使用鼠标中键来操纵相机视图:点击并拖动以旋转,按住 Shift 并点击拖动以平移,按住 Ctrl 并点击拖动以缩放。左键单击相机以选择它,按住 Shift 同时单击灯光以选择它,然后按 X 键删除两者。

APPC_F02_Hocking3

图 C.2 Blender 中的初始默认屏幕

Blender 从对象模式开始,正如其名称所暗示的,它使你能够操纵整个对象,在场景中移动它们。要详细编辑单个网格对象,你必须选择它并切换到编辑模式;图 C.3 显示了您使用的菜单。

APPC_F03_Hocking3

图 C.3 从对象模式切换到编辑模式的菜单

警告:Blender 界面的许多部分都是上下文相关的,这个菜单就是其中之一。菜单项根据所选对象而变化,无论是网格、相机还是其他对象。

当你第一次切换到编辑模式时,Blender 设置为顶点选择模式,但按钮允许你在顶点、边和面选择模式之间切换(参见图 C.4)。不同的选择模式允许你选择不同的网格元素。

APPC_F04_Hocking3

图 C.4 视口侧面的控件

Blender 中的基本鼠标和键盘快捷键

图 C.4 中还包括了变换工具。与 Unity 一样,变换有移动、旋转和缩放。在视口的右上角有一个按钮可以切换显示 Gizmo(场景中的箭头)的开和关;我建议保持 Gizmo 开启,因为否则你只能通过键盘快捷键来访问变换工具。Blender 的键盘快捷键很难使用,这也是 Blender 的 UI 有坏名声的主要原因。

Blender 过去也经常使用非常非标准的鼠标功能。尽管使用中间鼠标按钮来操纵摄像机总是有道理的,但在场景中选择元素通常使用的是右键鼠标(在大多数应用程序中,左键鼠标用于选择)。现在左键点击是默认的选择方式,但正是这种旧功能使得启动 Blender 时的启动画面显示了此设置(在第一次启动后,在编辑 > 首选项中可以访问此设置):

APPC_UN01_Hocking3

Blender 鼠标设置

类似地,选择框选择和取消选择过去也很奇怪,尽管现在你只需点击并拖动或点击空白区域即可。顺便说一句,如果你已经选择了某些东西,可以按住 Shift 键来添加到选择中,只需按下 A 键(代表全部)即可选择所有内容。

定义 网格元素 是构成网格几何形状的顶点、边和面——换句话说,就是单个角落点、连接点的线条以及填充在连接线条之间的形状。

这些是使用 Blender 的基本控制方法,因此现在我们将看到一些用于编辑模型的函数。首先,将立方体缩放成长条形。选择模型上的每个顶点(务必也选择面向远离物体的侧面的顶点;按 A 键选择全部)然后切换到缩放工具。点击并拖动蓝色臂向下缩放,然后点击并拖动绿色箭头向侧面扩展(见图 C.5)。

APPC_F05_Hocking3

图 C.5 网格拉伸成长条形

切换到面选择模式(使用图 C.4 中指示的按钮)并选择长条的两端。你可以单独点击面,记得在添加到选择时按住 Shift 键。现在点击视口顶部的网格菜单,选择拉伸 > 拉伸单个面(见图 C.6)。当你移动鼠标时,你会看到长条两端的额外部分被添加;稍微移动它们,然后左键点击以确认。使这个额外部分只有长凳腿的宽度,给自己留一点额外的几何形状来工作。

APPC_F06_Hocking3

图 C.6 在网格菜单中,使用拉伸单个面来拉出额外的部分。

定义拉伸通过具有所选面形状的横截面推出新的几何形状。不同的拉伸命令定义了在多个元素被选中时应该做什么:拉伸单个面将每个面视为一个单独的部分进行拉伸,而标准的拉伸面命令将整个选择视为一个单独的部分。

现在看看板子的底部,并选择每端的两个薄面。再次使用“拉伸单个面”命令向下拉出长椅的腿(参见图 C.7)。

APPC_F07_Hocking3

图 C.7 选择长椅下方的薄面并向下拉出腿。

形状已经完成!但在将模型导出到 Unity 之前,你需要注意对模型进行纹理处理。

C.2 纹理映射模型

3D 模型可以在其表面显示 2D 图像(称为纹理)。对于像墙壁这样的大而平的表面,2D 图像如何与 3D 表面相关联是直观的:只需将图像拉伸到平坦的表面上即可。但是,对于像长椅侧面这样的不规则形状表面呢?这就是理解纹理坐标概念变得重要的地方。

纹理坐标定义了纹理的哪些部分与网格的哪些部分相关联。这些坐标将网格元素分配到纹理的各个区域。想象一下包装纸(见图 C.8);3D 模型是被包装的盒子,纹理是包装纸,而纹理坐标代表盒子上的点,包装纸将放在这些点上。纹理坐标定义了 2D 图像上的点和形状;这些形状与网格上的多边形相关联,图像的这一部分就出现在网格的这一部分上。

APPC_F08_Hocking3

图 C.8 包装纸很好地说明了纹理坐标的工作原理。

小贴士:纹理坐标的另一个名称是UV 坐标。这是因为纹理坐标是使用字母 U 和 V 定义的,就像 3D 模型上的坐标是使用 X、Y 和 Z 定义的一样。

将一个事物的一部分与另一个事物的一部分相关联的技术术语是映射——因此,创建纹理坐标的过程被称为纹理映射。从包装纸的类比中,这个过程还有另一个名字,即展开。还有更多术语是通过混合其他术语创建的,例如UV 展开;围绕纹理映射存在许多本质上同义的术语,所以尽量不要混淆。

传统上,纹理映射的过程非常复杂,但幸运的是,Blender 提供了使过程变得相当简单的工具。首先你在模型上定义接缝;如果你进一步思考围绕一个盒子(或者更好的是,考虑相反的方向,展开一个盒子),你会意识到当展开到二维时,3D 形状的每个部分并不都能保持无缝。在 3D 形状中,侧面分开的地方将需要接缝。Blender 允许你选择边缘并将它们声明为接缝。

切换到边缘选择模式(见图 C.4 中的按钮)并选择长椅底部的边缘。现在选择边缘 > 标记接缝(见图 C.9)。这告诉 Blender 为了纹理映射的目的将长椅底部分开。对长椅的侧面也做同样的事情,但不要完全分开侧面。相反,只对接缝沿着长椅腿的边缘进行接缝处理;这样,侧面将保持与长椅相连,同时像翅膀一样展开。

APPC_F09_Hocking3

图 C.9 长椅底部和腿部的接缝边缘

一旦所有接缝都被标记,运行纹理展开命令。首先,选择整个网格(只需按 A 键选择所有内容,或者使用框选,别忘了选择面向远离物体的那一侧)。接下来,选择 UV > 展开以创建纹理坐标。但在这个视图中你无法看到纹理坐标;Blender 默认以场景的 3D 视图显示。切换到 UV 编辑工作区以查看纹理坐标,使用屏幕顶部的工具栏标签(见图 C.10)。

APPC_F10_Hocking3

图 C.10 切换到 UV 编辑,然后导出 UV 布局。

现在,你可以看到纹理坐标了。你可以看到长椅的多边形被平铺、分离和展开,这些都是根据你标记的接缝进行的。要绘制纹理,你必须在你图像编辑程序中看到这些 UV 坐标。再次参考图 C.10,在纹理坐标视图中 UV 菜单下选择导出 UV 布局;将图像保存为 bench.png(这个名称将在稍后导入 Unity 时使用),大小为 256。

在你的图像编辑器中打开这张图片,并为纹理的各个部分绘制颜色。为不同的 UV 绘制不同的颜色将把不同的颜色放在那些面上。例如,图 C.11 显示了在 UV 布局顶部展开的长椅底部较暗的蓝色,以及长椅侧面的红色。现在可以将图像带回到 Blender 中为模型添加纹理;选择图像 > 打开。

APPC_F11_Hocking3

图 C.11 在导出的 UV 上绘制颜色,然后将纹理带入 Blender。

即使在 UV 编辑视图中打开了纹理图像,你仍然无法在 3D 视图中看到模型上的纹理。这需要额外的几个步骤:将图像分配给对象的材质,然后在视图中打开纹理(见图 C.12)。现在你可以看到应用了纹理的完成后的长椅了!

APPC_F12_Hocking3

图 C.12 将图像设置在对象的材质上以在模型上查看纹理。

现在保存模型。Blender 会使用 Blender 的原生文件格式(.blend 扩展名)保存文件。在原生文件格式下工作,以确保 Blender 的所有功能都能正确保留,但稍后你将不得不将模型导出为不同的文件格式(第四章推荐 FBX 格式)以导入到 Unity 中。请注意,纹理图像不会保存在模型文件中;保存的是对图像的引用,但你仍然需要被引用的图像文件。

附录 D. 在线学习资源

本书是 Unity 游戏开发的全面入门,但在此入门之后还有更多东西要学习。您将在网上找到许多有用的资源,您可以在完成本书后进一步使用这些资源。

D.1 其他教程

许多网站提供了关于 Unity 内部各种主题的定向信息。其中一些甚至是由 Unity 背后的公司官方提供的。

Unity 手册

Unity 提供了全面的用户手册。它不仅对查找信息很有用,而且主题列表本身也很有用,可以全面了解 Unity 的功能。您可以在 docs.unity3d.com/Manual/index.html 找到手册。

脚本参考

Unity 程序员最终会阅读脚本参考比其他任何资源都要多(至少我是这样!)。用户手册涵盖了引擎的功能和编辑器的使用,但脚本参考是 Unity 编程 API 的详尽参考。每个命令都在 docs.unity3d.com/ScriptReference/index.html 列出。

Unity 学习教程

Unity 的官方网站在“学习”部分包含了几个全面的教程。最重要的是,这些教程都是视频形式。这可能是好是坏,取决于您的观点;如果您喜欢观看视频教程,learn.unity.com 是一个值得查看的好网站。

Catlike Coding

Catlike Coding 不是通过完整游戏引导学习者,而是提供了一系列有用和有趣的主题。这些主题甚至不一定专门关于游戏开发,但它们是提升 Unity 编程技能的绝佳方式。教程可以在 catlikecoding.com/unity/tutorials/ 找到。

Stack Exchange 上的游戏开发

Stack Exchange 是另一个非常棒的信息网站,其格式与前面列出的不同。它不像一系列自包含的教程那样,Stack Exchange 提供了主要是文本的问答,鼓励搜索。它涵盖了大量主题的章节,gamedev.stackexchange.com 是该网站专注于游戏开发的区域。就其价值而言,我在那里查找 Unity 信息几乎和我在脚本参考中查找的次数一样多。

Maya LT 指南

如附录 B 所述,外部艺术应用是创建视觉上令人惊叹的游戏的关键部分。有许多关于 Maya、3ds Max、Blender 或其他 3D 艺术应用的教程资源。附录 C 提供了关于 Blender 的教程。有关使用 Maya LT(Maya 的游戏开发导向版本,价格较低)的在线指南可在 steamcommunity.com/sharedfiles/filedetails/?id=242847724 找到。

D.2 代码库

虽然之前列出的资源提供了关于 Unity 的教程和/或学习信息,但本节中的网站提供了可以在项目中使用的代码。库和插件是另一种有用的资源,不仅可以直接使用,还可以通过阅读它们的代码来学习。

Unity 社区库

Unity 库是许多开发者的代码贡献的中心数据库,那里托管的脚本涵盖了广泛的功能。该页面的资源部分链接到额外的脚本集合。您可以在github.com/UnityCommunity/UnityLibrary浏览内容。

DOTween 和 LeanTween

如第三章简要提到的,在游戏中常用的一种运动效果被称为tween。在这种类型的运动中,单个代码命令可以在一定时间内设置一个对象移动到目标位置。可以使用像 DOTween (dotween.demigiant.com)或 LeanTween (github.com/dentedpixel/LeanTween)这样的库来添加 tweening 功能。

后处理堆栈

后处理堆栈是向您的游戏添加诸如景深和运动模糊等视觉效果的简单方法。其中许多效果已集成到一个超级组件中。该包的描述可以在mng.bz/9aXl找到。

移动通知包

虽然 Unity 的核心已经覆盖了所有游戏平台的各种功能,但对于移动游戏,您可能想要安装具有额外功能的包。Unity Mobile Notifications 包(mng.bz/jjvx)专注于通知,即手机应用程序生成的小警报。

Firebase Cloud Messaging

虽然前面提到的 Unity 包可以处理 Android 和 iOS 的本地通知,但它仅在 iOS 上支持远程通知(也称为推送通知)。Android 上的推送通知通过名为 Firebase Cloud Messaging 的服务工作,Firebase(mng.bz/WBg0)的开发者页面解释了如何使用其 Unity SDK。

Google 的 Play Games 服务

在 iOS 上,Unity 内置了 GameCenter 集成,因此您的游戏可以拥有平台原生的排行榜和成就。Android 上的等效系统称为 Google Play Games;尽管它没有内置在 Unity 中,但 Google 在mng.bz/80QP维护了一个插件。

FMOD Studio

Unity 内置的音频功能在播放录音方面表现良好,但在高级音效设计工作中可能有限。FMOD Studio 是一个高级音效设计工具,它有一个 Unity 插件。您可以在www.fmod.com/studio找到它。

posted @ 2025-11-20 09:29  绝不原创的飞龙  阅读(73)  评论(0)    收藏  举报