Unity-2018-秘籍-全-

Unity 2018 秘籍(全)

原文:zh.annas-archive.org/md5/efc9a5dc7072dc6b4bde7bb1a41e8e20

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

游戏开发是一项广泛而复杂的任务。它是一个跨学科领域,涵盖了从人工智能、角色动画、数字绘画到声音编辑等各种各样的主题。所有这些知识领域都可以转化为数百(或数千!)多媒体和数据资产的生产。需要一个特殊的软件应用——游戏引擎——来将这些资产整合成一个单一的产品。游戏引擎是专门的软件组件,过去它们属于一个神秘的领域。它们价格昂贵、缺乏灵活性,并且极其复杂。它们只为大型工作室或硬核程序员所使用。然后,Unity 出现了。

Unity 代表了游戏开发的真正民主化。它是一个用户友好且多功能的引擎和多媒体编辑环境。它有免费和 Pro 版本;后者包括更多功能。Unity 支持部署到许多*台,包括以下:

  • 移动:Android、iOS、Windows Phone 和 BlackBerry

  • 网页:WebGL

  • 桌面:PC、Mac 和 Linux *台

  • 控制台:PS4、PS3、Xbox One、XBox 360、PlayStation Mobile、PlayStation Vita 和 Wii U

  • 虚拟现实VR)/增强现实AR):Oculus Rift、Gear VR、Google Daydream 和 Microsoft Hololens

今天,Unity 被全球各地的各种开发者社区所使用。其中一些是学生和爱好者,但许多是商业组织,从车库开发者到国际工作室,他们使用 Unity 制作了大量的游戏——你可能已经在某个*台上玩过一些了。

这本书提供了超过 170 个 Unity 游戏开发食谱。一些食谱展示了 Unity 在多媒体功能方面的应用技术,包括与动画的工作以及使用预安装的包系统。其他食谱使用 C#脚本开发游戏组件,从处理数据结构和数据文件操作到为计算机控制的角色的人工智能算法。

如果你想要以有组织且直接的方式开发高质量的游戏,并且想要学习如何创建有用的游戏组件和解决常见问题,那么 Unity 和这本书都是为你准备的。

这本书面向的对象

这本书是为任何想要探索 Unity 脚本和多媒体功能的广泛范围,并找到许多游戏功能的现成解决方案的人而写的。程序员可以探索多媒体功能,而多媒体开发者可以尝试脚本编写。从中级到高级用户,从艺术家到程序员,这本书是为你,以及你团队中的每个人而写的!它旨在为所有具备 Unity 使用基础和一点点 C#编程知识的人提供帮助。

这本书涵盖的内容

第一章,使用核心 UI 元素显示数据,充满了用户界面UI)配方,帮助你通过显示文本和数据的质量来提高游戏的娱乐性和享受价值。你将学习到一系列 UI 技术,包括显示文本和图像、3D 文本效果,以及使用免费 Fungus 包显示文本和图像对话框的介绍。

第二章,对交互式 UI 响应用户事件,教你如何更新显示(例如基本的定时器),以及检测和响应用户输入动作,如鼠标悬停,而第一章介绍了用于向用户显示值的代码 UI。其中还包括视觉层中的面板、单选按钮和切换组、交互式文本输入、方向雷达、倒计时计时器和自定义鼠标光标的配方。

第三章,库存用户界面,涉及许多涉及玩家收集物品的游戏,例如开门的钥匙、武器的弹药,或者从一系列物品中选择,例如从施法咒语的集合中选择。本章提供的配方提供了一系列文本和图形解决方案,用于向玩家显示库存状态,包括他们是否携带物品,或者他们能够收集的最大物品数量。

第四章,播放和操作声音,建议使用音效和配乐音乐使你的游戏更有趣。本章演示了如何通过脚本、混响区域和音频混音器在运行时操作声音。它还包括播放声音的实时图形可视化的配方,并以创建一个简单的 140 bpm 循环管理器结束,每个播放循环都有可视化效果。

第五章,创建纹理、贴图和材质,包含的配方将帮助你更好地理解如何使用基于物理的着色器使用贴图和材质,无论你是否是游戏艺术家。这是一个练习图像编辑技能的绝佳资源。

第六章,着色器图和视频播放器,涵盖了 Unity 最*添加的两个视觉组件:着色器图和视频播放器。这两个组件都使得在不进行或进行很少编程的情况下向游戏中添加令人印象深刻的视觉效果变得容易。本章为这些功能中的每一个都提供了几个配方。

第七章,使用摄像机,介绍了控制并增强游戏摄像机(s)的技术配方。它提供了处理单摄像机和多摄像机的解决方案,说明了如何应用后期处理效果,如晕影和颗粒状的灰度 CCTV。本章最后介绍了使用 Unity 强大的 Cinemachine 组件的方法。

第八章,灯光与效果,提供了一种动手实践的方法来学习 Unity 的几个照明系统功能,例如饼干纹理、反射贴图、光照贴图、光照和反射探针,以及程序化天空盒。此外,它还演示了投影仪的使用。

第九章,2D 动画,介绍了 Unity 的一些强大的 2D 动画和物理功能。在本章中,我们将提供配方来帮助您理解 Unity 中不同动画元素之间的关系,探讨身体不同部位的移动以及包含一系列精灵帧图片的精灵图集图像文件的使用。本章核心介绍了 Unity 动画概念,包括动画状态图、转换和触发事件。最后,2D 游戏通常使用瓦片和瓦片图(现在是 Unity 的一部分)功能,以及这些功能以及 Unity 3D Gamekit 在本章的配方中都有介绍。

第十章,3D 动画,专注于角色动画,并演示了如何利用 Unity 的动画系统——Mecanim。它涵盖了从基本角色设置到程序化动画和 ragdoll 物理学的广泛主题。它还介绍了 Unity 3D 的一些新功能,如 Probuilder 和 Unity 3D Gamekit。

第十一章,Web 服务器通信在线版本控制,探讨了在设备上运行的游戏如何从与其他网络应用程序的通信中受益。在本章中,提供了一系列配方,展示了如何设置在线数据库驱动的排行榜,如何编写可以与这些在线系统通信的 Unity 游戏,以及如何保护游戏不被在未经授权的服务器上运行(以防止你的 WebGL 游戏被非法复制并在其他人的服务器上发布。此外,这些配方还说明了如何构建项目结构,以便可以使用如 GitHub 之类的在线版本控制系统轻松备份,以及如何从在线网站下载项目以在我们的机器上编辑和运行。

第十二章,控制和选择位置,为 2D 和 3D 用户以及计算机控制的对象和角色提供了一系列食谱,这些食谱可以使游戏拥有更丰富和更令人兴奋的用户体验。这些食谱的例子包括出生点、检查点和基于物理的方法,例如在点击对象时应用力以及将投射物射入场景。

第十三章,导航网格和代理,探讨了 Unity 的导航网格和导航网格代理如何自动化游戏中的对象和角色移动以及路径查找的方法。对象可以遵循预定义的航点序列,或者通过鼠标点击进行点选控制。对象可以根据其群体所有成员的*均位置和运动使它们聚集在一起。额外的食谱展示了如何定义导航区域的“成本”,模拟难以穿越的区域,如泥地和水面。最后,尽管许多导航行为在设计时(“烘焙”过程)就已经预先计算,但本章提供了一个食谱,说明了如何通过使用导航网格障碍组件在运行时通过移动对象影响路径查找。

第十四章,设计模式,阐述了可重用且与计算机语言无关的软件设计模式,以及如何解决常见问题的模板。它教导我们避免重新发明轮子,学习为游戏项目解决常见特性的经过验证的方法。本章介绍了与游戏相关的几个设计模式,包括状态模式、发布者-订阅者模式和模型-视图-控制器模式。

第十五章,编辑器扩展和即时模式 GUI (IMGUI),提供了几个食谱,用于增强 Unity 编辑器中的设计时工作。编辑器扩展是脚本和多媒体组件,允许使用自定义文本、游戏参数的 UI 展示、检查器和场景面板中的数据,以及自定义菜单和菜单项。这些可以促进工作流程的改进,使游戏开发者能够更快、更轻松地实现目标。本章中的一些食谱包括菜单项、具有持久存储的交互式面板、为撤销系统注册操作、禁用菜单项、进度条以及基于预制体的创建新 GameObject 的方法。

为了充分利用这本书

你只需要一份 Unity 2018 的副本,可以从www.unity3d.com免费下载。

如果您希望创建自己的图像文件,例如在 创建地图和材质 的食谱中,您还需要一个图像编辑器,例如 Adobe Photoshop,可以在 www.photoshop.com 找到,或者 GIMP,它是免费的,可以在 www.gimp.org 找到。

下载示例代码文件

您可以在每个章节中找到食谱资产和完成的 Unity 项目,具体位置如下:github.com/PacktPublishing/Unity-2018-Cookbook-Third-Edition.

您可以选择将这些文件作为 Zip 存档下载,或者使用免费的 Git 软件下载(克隆)这些文件。这些 GitHub 仓库将随着任何改进而更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供在 github.com/PacktPublishing/ 上获取。查看它们吧!

下载彩色图像

我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/Unity2018CookbookThirdEdition_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将文件 arrowCursor.png 导入您的 Unity 项目中。”

代码块设置如下:

using UnityEngine; 
using UnityEngine.UI; 

[RequireComponent(typeof(PlayerInventoryTotal))] 
public class PlayerInventoryDisplay : MonoBehaviour { 
   public Text starText; 
   public void OnChangeStarTotal(int numStars) { 
         string starMessage = "total stars = " + numStars; 
         starText.text = starMessage; 
   } 
}

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

总体反馈:如果您对本书的任何方面有任何疑问,请在邮件主题中提及书名,并将邮件发送至 customercare@packtpub.com

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packt.com/submit-errata,选择您的书,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.

评价

请留下您的评价。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packt.com.

第一章:使用核心 UI 元素显示数据

在本章中,我们将涵盖:

  • 显示“Hello World”UI 文本消息

  • 显示数字时钟

  • 显示数字倒计时计时器

  • 创建一个逐渐消失的消息

  • 显示透视 3D 文本网格

  • 使用 TextMeshPro 创建复杂的文本

  • 显示图像

  • 使用 Fungus 开源对话框系统创建 UI

  • 使用图像创建 Fungus 角色对话框

简介

对大多数游戏娱乐和享受贡献关键因素的是视觉体验的质量,而这其中重要的部分是用户界面UI)。UI 元素包括用户与游戏交互的方式(例如按钮、光标和文本框),以及游戏向用户展示最新信息的方式(例如剩余时间、当前生命值、得分、剩余生命或敌人位置)。本章充满了 UI 食谱,为您提供一系列创建游戏 UI 的示例和想法。

整体概念

每个游戏都是不同的,因此本章试图完成两个关键角色。第一个目标是提供逐步指导,说明如何创建一系列 Unity 2018 基本 UI 元素,并在适当的情况下,将它们与代码中的游戏变量关联。第二个目标是提供丰富的示例,说明 UI 元素可以用于各种目的,以便您可以获得关于如何使 Unity UI 控件集为正在开发的游戏提供特定视觉体验和交互的灵感。

基本 UI 元素可以提供静态图像和文本,仅使屏幕看起来更有趣。通过使用脚本,我们可以更改这些图像和文本对象的内容,以便更新玩家的数字得分,或者我们可以显示棍人图像来指示玩家剩余的生命。其他 UI 元素是交互式的,允许用户点击按钮、选择选项、输入文本等。更复杂的 UI 类型可能涉及收集和计算有关游戏的数据(例如剩余时间的百分比或敌人击中伤害;或场景中关键游戏对象的位置和类型,以及它们与玩家位置和方向的关系),然后以自然、图形化的方式显示这些值(例如进度条或雷达屏幕)。

核心游戏对象、组件以及与 Unity UI 开发相关的概念包括:

  • 画布:每个 UI 元素都是画布的子元素。单个场景中可以有多个画布游戏对象。如果一个画布尚未存在,那么在创建新的 UI 游戏对象时,将自动创建一个,该 UI 对象作为新画布游戏对象的子元素。

  • 事件系统:需要一个事件系统GameObject 来管理 UI 控件的交互事件。当第一个 UI 元素被创建时,会自动创建一个。Unity 通常在任何场景中只允许一个事件系统(一些支持多个并发事件系统的代码可以在bitbucket.org/Unity-Technologies/ui/pull-requests/18/support-for-multiple-concurrent-event/diff找到)。

  • 视觉 UI 控件:可见的 UI 控件包括按钮图像文本切换

  • 矩形变换组件:UI GameObject 可以存在于与 2D 和 3D 场景渲染的相机不同的空间中。因此,UI GameObject 都拥有特殊的矩形变换组件,它具有与场景 GameObject 变换组件(具有其直接的 X/Y/Z 位置、旋转和缩放属性)不同的属性。与矩形变换相关的是中心点(缩放、调整大小和旋转的参考点)和锚点。

以下图表显示了 UI 控件的主要四个类别,每个类别都在一个画布GameObject 中,并通过一个事件系统GameObject 进行交互。UI 控件可以有自己的画布,或者几个 UI 控件可以在同一个画布中。这四个类别是:静态(仅显示)和交互式 UI控件、不可见组件(例如用于分组一组互斥的单选按钮的组件),以及通过程序代码中编写的逻辑来管理 UI 控件行为的C#脚本类。请注意,不是 Canvas 的子或后代的 UI 控件将无法正常工作,如果缺少事件系统,交互式 UI 控件也将无法正常工作。一旦将第一个 UI GameObject 添加到场景中,画布事件系统GameObject 就会自动添加到层次结构中:

图片

Rect Transforms 用于 UI 游戏对象表示一个矩形区域,而不是单个点,这与场景 GameObject Transforms 的情况不同。Rect Transforms 描述了 UI 元素相对于其父元素应该如何定位和大小。Rect Transforms 具有可以更改的宽度和高度,而不会影响组件的局部缩放。当更改 UI 元素的 Rect Transform 的缩放时,这也会缩放字体大小和切片图像的边框等。如果所有四个锚点都位于同一点,调整 Canvas 大小不会拉伸 Rect Transform。它只会影响其位置。在这种情况下,我们将看到 Pos X 和 Pos Y 属性,以及矩形的宽度和高度。然而,如果锚点不在同一点,Canvas 调整大小将导致元素矩形的拉伸。因此,我们将看到 Left 和 Right 的值——矩形的水*边相对于 Canvas 边的位置,其中宽度将取决于实际的 Canvas 宽度(同样适用于 Top/Bottom/Height)。

Unity 为枢轴和锚点提供了一套预设值,使得最常见的值可以非常快速和容易地分配给元素的 Rect Transform。以下截图显示了 3 x 3 的网格,它允许您快速选择左、右、上、下、中间、水*方向和垂直方向的值。此外,右侧的额外列提供了水*拉伸预设,底部额外的行提供了垂直拉伸预设。使用 Shift+*Alt *键在点击预设时设置枢轴和锚点:

Unity 手册为 Rect Transform 提供了一个非常好的介绍。此外,Ray Wenderlich 的两篇 Unity UI 网络教程也提供了关于 Rect Transform、枢轴和锚点的有用概述。Wenderlich 教程的两部分都很好地使用了动画 GIF 来说明枢轴和锚点不同值的效应:

有三种 Canvas 渲染模式:

  • 屏幕空间:叠加:在此模式下,UI 元素显示时无需参考任何相机(场景中不需要任何 Camera)。UI 元素显示在场景内容显示的任何类型的相机之前(叠加)。

  • 屏幕空间相机:在此模式下,Canvas 被视为位于 Camera 场景视锥体(观察空间)中的*面——其中此*面始终面向相机。因此,位于此*面之前的所有场景对象都将渲染在 Canvas 上的 UI 元素之前。如果屏幕大小、分辨率或相机设置发生变化,Canvas 将自动调整大小。

  • 世界空间:在此模式下,画布作为摄像机场景视锥体内的一个*面——但该*面并非总是面向摄像机画布的显示方式与场景中任何其他对象一样,相对于(如果有的话)在摄像机的视锥体内画布*面的位置和方向。

在本章中,我们专注于屏幕空间:叠加模式。但所有这些配方也可以与其他两种模式一起使用。

发挥创意!本章旨在作为想法、技术和可重用 C#脚本发射台,用于您的项目。了解 Unity UI 元素的范围,并尝试聪明地工作。通常,一个 UI 元素已经包含了您可能需要的所有组件,但您可能需要对其进行某种调整。一个例子可以在使 UI 滑动条非交互式的配方中看到,它使用它来显示倒计时计时器的红色-绿色进度条。请参阅使用 UI 滑动条图形显示倒计时计时器配方。

许多这些配方都涉及使用 Unity 场景启动事件序列的 C#脚本类,即Awake()对所有游戏对象,Start()对所有 GameObject,然后每帧对每个 GameObject 调用Update()。因此,您将在本章(以及整本书)中看到许多配方,我们在Awake()方法中缓存 GameObject 组件的引用,然后在场景启动并运行后,在Start()和其他方法中使用这些组件。

显示“Hello World”UI 文本消息

使用新计算技术要解决的第一个传统问题是显示 Hello World 消息。在这个配方中,您将学习如何创建一个简单的 UI Text 对象,带有此消息,以选定的字体显示大号白色文本,位于屏幕中央:

图片

准备工作

对于这个配方,我们在01_01文件夹中的Fonts文件夹中准备了您需要的字体。

如何操作...

要显示“Hello World”文本消息,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 导入提供的Fonts文件夹。

  3. 在层次结构面板中,向场景添加一个 UI | Text GameObject——选择菜单:GameObject | UI | Text。将此 GameObject 命名为Text-hello

使用创建菜单:或者,使用位于层次结构标签下的创建菜单,选择菜单:创建 | UI | Text。

  1. 确保在层次结构面板中已选择新的Text-helloGameObject。

    现在,在检查器中,确保以下属性已设置:

    • 文本设置为读取Hello World

    • 字体设置为Xolonium-Bold

    • 字体大小根据您的需求设置(大——这取决于您的屏幕——尝试50100

    • 对齐设置为水*和垂直居中

    • 水*垂直溢出设置为溢出

    • 颜色设置为白色

以下截图显示了具有这些设置的检查器面板:

图片

  1. Rect Transform中点击锚点预设的方形图标,这将导致出现几行几列的预设位置方块。按住  Shift+*Alt *并点击中心的一个(中间行和 中心列)。

简介中的Rect Transform截图突出了本菜谱所需的中间中心预设。

  1. 您的 Hello World 文本现在将出现在游戏面板中,居中显示。

它是如何工作的...

您已将一个新的 Text-hello 游戏对象添加到场景中。同时,也会自动创建一个父级画布和UI EventSystem

您设置了文本内容和展示属性,并使用 Rect Transform 锚点预设来确保无论屏幕如何调整大小,文本都将保持水*和垂直居中。

更多内容...

这里有一些您不想错过的更多细节。

使用富文本样式化子字符串

每个单独的UI Text组件都可以有自己的颜色、大小、粗体样式等。然而,如果您想快速为要显示给用户的字符串的一部分添加一些高亮样式,以下是一些不需要创建单独 UI Text 对象的 HTML 样式标记的示例:

  • 使用“b”标记加粗文本:我是 <b>加粗</b>

  • 使用“i”标记来斜体化文本:我是 <i>斜体</i>

  • 使用十六进制值或颜色名称设置文本颜色:我是 <color=green>绿色</color> 文本 </color>, 但我是 <color=#FF0000>红色</color>

在 Unity 在线手册的富文本页面docs.unity3d.com/Manual/StyledText.html了解更多信息。

显示数字时钟

不论是现实世界的时间,还是游戏中的倒计时时钟,许多游戏都通过某种形式的时钟或计时器显示来增强。最直接的时钟显示类型是由小时、分钟和秒的整数组成的字符串,这正是我们在本菜谱中要创建的。

以下截图显示了我们将在这个菜谱中创建的时钟类型:

准备工作

对于这个菜谱,我们在01_01文件夹中的Fonts文件夹中准备了您需要的字体。

如何操作...

要创建一个数字时钟,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 导入提供的Fonts文件夹。

  3. 层次结构面板中,将一个 UI | Text 游戏对象添加到场景中,命名为 Text-clock。

  4. 确保在层次结构面板中选择了Text-clock GameObject。现在,在检查器中,确保以下属性已设置:

    • 文本设置为随时间读取(此占位文本将在场景运行时被时间替换)

    • 字体类型设置为Xolonium Bold

    • 字体大小设置为20

    • 对齐设置为水*和垂直居中

    • 水*和垂直溢出设置设置为Overflow

    • 颜色设置为白色

  1. Rect Trans****form 中,点击锚点预设的方形图标,这将导致出现几行几列的预设位置方块。按住 Shift+*Alt *并点击顶部和中心的列行。

  2. 创建一个名为 _Scripts 的文件夹,并在该新文件夹中创建一个名为 ClockDigital 的 C# 脚本类:

using UnityEngine; 
using System.Collections; 
using UnityEngine.UI; 
using System; 

public class ClockDigital : MonoBehaviour { 
  private Text textClock; 

  void Awake (){ 
    textClock = GetComponent<Text>(); 
  } 

  void Update (){ 
    DateTime time = DateTime.Now; 
    string hour = LeadingZero( time.Hour ); 
    string minute = LeadingZero( time.Minute ); 
    string second = LeadingZero( time.Second ); 

    textClock.text = hour + ":" + minute + ":" + 
 second; 
  } 

  string LeadingZero (int n){ 
     return n.ToString().PadLeft(2, '0'); 
  } 
} 

下划线前缀使项目按顺序首先出现

由于脚本和场景是最常访问的东西,因此使用下划线字符作为文件夹名称的前缀,即 _as _Scenes_Scripts,意味着它们总是在项目面板的顶部。

尽管前面的代码对于说明如何单独访问 DateTime 对象的时间组件很有用,但 String 类的 Format(...) 方法可以用来在单个语句中格式化 DateTime 对象,例如,前面的代码可以更简洁地写成一个语句:

String.Format("HH:mm:ss", DateTime.Now)

想要查看更多示例,请参阅www.csharp-examples.net/string-format-datetime/.

  1. 确保在层次结构面板中选择 Text-clock GameObject。

  2. 在检查器面板中,通过点击添加组件按钮,选择脚本,然后选择 Clock Digital 脚本类,添加 ClockDigital 脚本类的实例作为一个组件:

通过拖放添加脚本组件

脚本组件也可以通过拖放添加到 GameObject 中。例如,在层次结构面板中选择 Text-clock GameObject,将你的 ClockDigital 脚本拖放到它上面,以将此脚本类的实例作为组件添加到 Text-clock GameObject。

  1. 当你运行场景时,你现在将看到一个数字时钟,显示在屏幕的顶部中央部分的小时、分钟和秒。

它是如何工作的...

你已经将一个 Text GameObject 添加到场景中。你已将 ClockDigital C# 脚本类的实例添加到该 GameObject。

注意,除了默认为每个新脚本编写的标准两个 C# 包(UnityEngineSystem.Collections)之外,你还添加了两个额外的 C# 脚本包的 using 语句,即 UnityEngine.UISystemUI 包是必需的,因为我们的代码使用了 UI 文本对象;而 System 包是必需的,因为它包含我们需要的 DateTime 类,以便访问运行游戏的计算机上的时钟。

有一个变量,textClock,它将是一个对 Text 组件的引用,我们希望在每一帧中用当前的小时、分钟和秒来更新其文本内容。

Awake() 方法(在场景开始时执行)将 textClock 变量设置为对添加了我们的脚本对象的 GameObject 中的 Text 组件的引用。以这种方式存储组件的引用称为缓存——这意味着稍后执行的代码不需要重复搜索 GameObject 层次结构以查找特定类型的组件的计算密集型任务。

注意,另一种方法是将 textClock 设置为公共变量。这将允许我们在检查器面板中通过拖放来分配它。

Update() 方法在每一帧执行。当前时间存储在时间变量中,通过为时间变量的小时、分钟和秒属性添加前导零来创建字符串。

此方法最终将文本属性(即用户看到的字母和数字)更新为字符串,通过冒号连接小时、分钟和秒。

分隔字符。

LeadingZero(...) 方法接受一个整数作为输入,并返回一个字符串,如果该值小于 10,则在左侧添加前导零。

更多内容...

有一些细节您不要错过。

Unity 教程:如何动画模拟时钟

Unity 发布了一个关于如何创建 3D 对象并通过 C# 脚本动画它们以显示模拟时钟的精彩教程,请参阅 unity3d.com/learn/tutorials/modules/beginner/scripting/simple-clock

显示数字倒计时计时器

此配方将向您展示如何显示一个数字倒计时时钟,如下所示:

图片

准备中

此配方修改了之前的配方。因此,请复制之前配方的项目,并在此副本上工作。

对于此配方,我们在 01_03 文件夹中的 _Scripts 文件夹中准备了您需要的脚本。

如何实现...

要创建数字倒计时计时器,请按照以下步骤操作:

  1. 导入提供的相关 _Scripts 文件夹。

  2. 在检查器面板中,从 Text-clock GameObject 中移除脚本组件 ClockDigital

  3. 在检查器面板中,通过点击 添加组件 按钮,选择 脚本,然后选择 CountdownTimer 脚本类,添加 CountdownTimer 脚本类的实例。

  4. 创建一个包含以下代码的 DigitalCountdown C# 脚本类,并将其实例作为脚本组件添加到 Text-clock GameObject 中:

using UnityEngine; 
using UnityEngine.UI; 

public class DigitalCountdown : MonoBehaviour { 
   private Text textClock; 
   private CountdownTimer countdownTimer; 

   void Awake() { 
         textClock = GetComponent<Text>(); 
         countdownTimer = GetComponent<CountdownTimer>(); 
   } 
   void Start() { 
         countdownTimer.ResetTimer( 30 ); 
   }  

   void Update () { 
         int timeRemaining = countdownTimer.GetSecondsRemaining(); 
         string message = TimerMessage(timeRemaining); 
         textClock.text = message; 
   } 

   private string TimerMessage(int secondsLeft) {    
         if (secondsLeft <= 0){ 
             return "countdown has finished"; 
         } else { 
             return "Countdown seconds remaining = " + secondsLeft; 
         } 
   } 
} 
  1. 当您运行 场景 时,您现在将看到一个从 30 开始倒计时的数字时钟。当倒计时达到零时,将显示消息“倒计时完成”。

使用 [RequireComponent(...)] 自动添加组件

DigitalCountdown 脚本类要求同一个游戏对象也要有一个 CountdownTimer 脚本类的实例。而不是手动附加一个需要脚本的实例,您可以在类声明语句之前立即使用 [RequireComponent(...)] C# 属性。这将导致 Unity 自动附加所需的脚本类实例。

例如,通过编写以下代码,Unity 将在 DigitalCountdown 脚本类实例作为游戏对象的组件添加后立即添加 CountdownTimer 实例:

using UnityEngine;   
using UnityEngine.UI;   

[RequireComponent (typeof (CountdownTimer))]   
public class DigitalCountdown : MonoBehaviour {   

在 Unity 文档中了解更多信息,请访问 docs.unity3d.com/ScriptReference/RequireComponent.html

它是如何工作的...

您已将 DigitalCountdownCountdownTimer C# 脚本类实例添加到场景的 UI Text 游戏对象中。

Awake() 方法将 Text 和 CountdownTimer 组件的引用缓存到 countdownTimertextClock 变量中。textClock 变量将是一个指向 UI Text 组件的引用,我们希望在每一帧中用剩余时间消息(或计时器完成消息)更新其文本内容。

Start() 方法调用计时器对象的 CountdownTimerReset(...) 方法,并传递一个初始值为 30 秒的值。

Update() 方法在每一帧中执行。此方法检索剩余的计时器秒数,并将其作为整数(整数)存储在 timeRemaining 变量中。此值作为参数传递给 TimerMessage() 方法,并将结果消息存储在字符串(文本)变量 message 中。此方法最终将 textClock UI Text 游戏对象的文本属性(即用户看到的字母和数字)更新为剩余秒数的字符串消息。

TimerMessage() 方法接受一个整数作为输入,如果值为零或更小,则返回一个表示计时器已完成的消息。否则(如果剩余时间大于零秒),则返回一个表示剩余秒数的消息。

创建一个逐渐消失的消息

有时,我们希望消息只显示一段时间,然后逐渐消失并消失。

准备工作

此配方修改了之前的配方。因此,请复制该项目的副本,并在此副本上工作。

如何实现...

要显示一个逐渐消失的文本消息,请按照以下步骤操作:

  1. 检查器 面板中,从 Text-clock 游戏对象中移除脚本组件 DigitalCountdown

  2. 创建一个包含以下代码的 C# 脚本类 FadeAway,并将其作为一个脚本组件添加到 Text-hello 游戏对象中:

using UnityEngine; 
using UnityEngine.UI; 

[RequireComponent (typeof (CountdownTimer))] 
public class FadeAway : MonoBehaviour { 
   private CountdownTimer countdownTimer; 
   private Text textUI; 

   void Awake () { 
         textUI = GetComponent<Text>();       
         countdownTimer = GetComponent<CountdownTimer>(); 
   } 

   void Start(){ 
         countdownTimer.ResetTimer( 5 ); 
   } 

   void Update () { 
         float alphaRemaining = 
         countdownTimer.GetProportionTimeRemaining(); 
         print (alphaRemaining); 
         Color c = textUI.color; 
         c.a = alphaRemaining; 
         textUI.color = c; 
   } 
} 
  1. 当您运行 场景 时,您现在将看到屏幕上的消息会逐渐消失,在五秒后消失。

它是如何工作的...

您将FadeAway脚本类的一个实例添加到了Text-hello游戏对象中。由于RequireComponent(...)属性,还自动添加了一个CountdownTimer脚本类的实例。

Awake()方法将TextCountdownTimer组件的引用缓存到countdownTimertextUI变量中。

Start()方法将倒计时计时器重置为从五秒开始倒计时。

Update()方法(每帧执行)通过调用GetProportionTimeRemaining()方法检索我们计时器剩余时间的比例。此方法返回一个介于0.01.0之间的值,这恰好也是 UI Text 游戏对象颜色属性 alpha(透明度)属性的值范围。

范围灵活,从0.01.0

通常将比例表示为 0.0 到 1.0 之间的值是个好主意。这将是我们要找的值,或者我们可以将最大值乘以我们的十进制比例,从而得到适当的值。例如,如果我们想要给定0.00.1比例的圆的度数,我们只需将其乘以 360 的最大值,依此类推。

然后Update()方法检索正在显示的文本的当前颜色(通过textUI.color),更新其 alpha 属性,并将文本对象重置为具有此更新的颜色值。结果是,文本对象中每一帧的透明度代表计时器剩余比例的当前值。当计时器达到零时,文本将完全透明。

显示透视 3D 文本网格

Unity 通过 Text Mesh 组件提供了一种在 3D 中显示文本的替代方法。虽然这非常适合场景中的文本(如广告牌、路标以及通常在可能*距离看到的 3D 对象旁边的文字),但它创建起来很快,是创建有趣菜单或指示场景的另一种方式。

在这个配方中,您将学习如何创建一个滚动 3D 文本,模拟电影《星球大战》著名的开场字幕,看起来就像这样:

图片

准备工作

对于这个配方,我们在01_07文件夹中准备了一个名为Fonts的文件夹,以及您需要的文本文件位于名为Text的文件夹中。

如何操作...

要显示透视 3D 文本,请按照以下步骤操作:

  1. 创建一个新的 Unity 3D 项目(这确保了我们从一个透视相机开始,适合我们想要创建的 3D 效果)。

如果需要在项目中混合 2D 和 3D 场景,您始终可以通过检查器面板手动设置任何相机的相机投影属性为透视正交

  1. 在层次结构面板中,选择主相机项目,然后在检查器面板中设置其属性如下:相机清除标志为纯色,视野为150,背景颜色为黑色。

  2. 导入提供的 FontsText 文件夹。

  3. 在 Hierarchy 面板中,将 UI | Text 游戏对象添加到场景中 - 选择菜单:GameObject | UI | Text。将此 GameObject 命名为 Text-star-wars。

  4. 将 UI Text Text-star-wars Text Content 设置为 Star Wars(每个单词占一行)。然后,将其字体设置为 Xolonium Bold,字体大小设置为 50,并将其 颜色 设置为白色。使用 Rect Transform 中的锚点预设将此 UI Text 对象定位在屏幕的顶部中心。设置垂直溢出为 Overflow。设置水*对齐为居中(垂直对齐保留为顶部)。

  5. 在 Hierarchy 面板中,将一个 3D Text 游戏对象添加到场景中 - 选择菜单:GameObject | 3D Object | 3D Text。将此 GameObject 命名为 Text-crawler。

  6. 在 Inspector 面板中,按照以下方式设置 Text-crawler GameObject 的 Transform 属性:位置 (100, -250, 0),旋转 (15, 0, 0)。

  7. 在 Inspector 面板中,按照以下方式设置 Text-crawler GameObject 的 Text Mesh 属性:

    • 将提供的文本文件 star_wars.txt 的内容粘贴到 Text 中。

    • 设置 Offset Z = -20,Line Spacing = 1,和 Anchor = Middle center

    • 设置 Font Size = 200,Font = SourceSansPro-BoldIt

  8. 场景 开始运行时,星球大战的故事文本现在将很好地以 3D 视角显示在屏幕上。

它是如何工作的...

你已经模拟了星球大战的开场屏幕,屏幕顶部有一个*面的 UI Text 对象标题,以及具有看似消失在地*线上的 3D 视角“挤压”设置的 3D Text Mesh。

更多...

有一些细节你不希望错过。

我们必须使这段文本像电影中那样滚动

通过几行代码,我们可以使这段文本在水*方向上滚动,就像在电影中一样。将以下 C# 脚本类 ScrollZ 作为组件添加到 Text-crawler GameObject:

using UnityEngine; using System.Collections; public class ScrollZ : MonoBehaviour { public float scrollSpeed = 20; void Update () { Vector3 pos = transform.position; Vector3 localVectorUp = transform.TransformDirection(0,1,0); pos += localVectorUp * scrollSpeed * Time.deltaTime; transform.position = pos; } } 

在每一帧中,通过 Update() 方法,3D 文本对象的位置会移动到该 GameObject 的局部向上方向。

哪里可以了解更多

在 Unity 在线手册中了解更多关于 3D Text 和 Text Meshes 的信息,请访问 docs.unity3d.com/Manual/class-TextMesh.html

实现类似这种透视文本的另一种方法是将 Canvas 与 World Space 渲染模式一起使用。

使用 TextMeshPro 创建复杂的文本

在 2017 年,Unity 购买了 TextMeshPro Asset Store 产品,目的是将其集成到 Unity 中作为一个免费的核心功能。TextMeshPro 使用 Signed Distance FieldSDF)渲染方法,在任何点大小和分辨率下都能产生清晰且轮廓鲜明的字符。因此,你需要 SDF 字体来使用此资源。

准备工作

在撰写本文时,TextMeshpro是免费的Asset Store下载和Unity Essentials Beta,所以第一步仍然是通过资产商店导入。当你阅读这篇文章时,你可能会发现TextMeshPro作为一个标准的 GameObject 类型,你可以在场景面板中创建,无需下载。所以,如果需要,打开资产商店面板,搜索TextMeshPro,导入这个免费资产包。

对于这个配方,我们在01_08文件夹中的 Fonts & Materials 文件夹中准备了所需的字体。

如何做到这一点...

要显示具有复杂的TextMeshPro视觉样式的文本消息,请按照以下步骤操作:

  1. 创建一个新的 Unity 3D 项目。

  2. 在场景中添加一个新的 UI TextMeshPro Text GameObject – 选择菜单:

    GameObject | UI | TextMeshPro – text. 将此 GameObject 命名为 Text-sophisticated。

TextMeshPro GameObject 不必是 UI Canvas 的一部分。你可以通过选择场景面板菜单创建 | 3D 对象 | TextMeshPro – text直接将TextMeshPro GameObject 添加到场景中。

  1. 确保在层次面板中选中了新的Text-sophisticated GameObject。在 Rect Transform 的检查器中,点击锚点预设的方形图标,按住Shift + Alt,然后点击顶部并拉伸行。

  2. 确保以下属性已设置:

    字体设置:

    • 字体资产设置为Anton SDF

    • 材质预设设置为Anton SDF - Outline

    • 字体大小200

    • 对齐设置为水*居中

  3. 面:

    • 颜色设置为white

    • 扩展设置为0

  4. 轮廓:

    • 颜色设置为Red

    • 厚度设置为0.1

  5. 底纹(阴影):

    • X 偏移量设置为1

    • Y 偏移量设置为-1

    • 扩展设置为1

以下截图显示了具有这些设置的检查器面板:

  1. Text-sophisticated GameObject 现在将显示为非常大,内部为白色,轮廓为红色,右下角有阴影。

它是如何工作的...

你已经向场景中添加了一个新的UI TextMeshPro Text GameObject。你选择了一个 SDF 字体和一个轮廓材质预设。然后调整了面(每个字符的内部部分)、轮廓和下阴影(底纹)的设置。

TextMeshPro组件有数百种设置,因此可能需要大量实验才能达到特定的效果。

更多内容...

这里有一些你不想错过的更多细节。

颜色、效果和精灵的富文本子串

TextMeshPro提供了超过 30 种 HTML 风格的标记来对子串进行标记。以下代码演示了一些,包括以下内容:

<sprite=5> inline sprite graphics 

<smallcaps>...</smallcaps> small-caps and colors 

<#ffa000>...</color> substring colors 

一个强大的标记是<page>标签,这允许一组文本成为交互式,并以一系列页面呈现给用户。

digitalnativestudios.com/textmeshpro/docs/rich-text/的在线手册富文本页面了解更多信息。

显示图像

有许多情况下我们希望在屏幕上显示图像,包括标志、地图、图标和启动图形。在这个食谱中,我们将显示屏幕顶部的图像。

以下截图显示了 Unity 显示的图像:

图片

准备工作

对于这个食谱,我们在01_07文件夹中的Images文件夹中准备了您需要的图像。

如何操作...

要显示图像,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 将游戏面板设置为 400 x 300 的大小。首先显示游戏面板,然后在面板顶部的下拉菜单中创建一个新的分辨率。点击此菜单底部的加号符号,设置标签 = 第二章宽度 = 400高度 = 300。点击确定游戏面板应设置为这个新分辨率:

图片

或者,您可以通过菜单编辑 | 项目设置 | 玩家来设置默认的游戏面板分辨率,然后在检查器中的分辨率和展示宽高(已关闭全屏选项)。

  1. 导入提供的Images文件夹。在检查器选项卡中,确保unity_logo图像的纹理类型设置为默认。如果它有其他类型,则从下拉列表中选择默认,然后点击应用按钮。

  2. 层次面板中,将一个 UI | 原始图像 GameObject 命名为RawImage-logo添加到场景中。

  3. 确保在层次面板中选择RawImage-logoGameObject。在原始图像(脚本)组件的检查器中,点击纹理属性右侧的文件查看器圆形图标,并选择image unity_logo,如图所示:

图片

将此纹理分配的另一种方法是,从您的项目文件夹(图像)中拖动unity_logo图像到原始图像(脚本)公共属性纹理

  1. 点击设置原生大小按钮以调整图像大小,使其不再拉伸和扭曲。

  2. 矩形变换中,点击锚点预设方框图标,这将导致出现几行几列的预设位置方块。按住Shift + Alt并点击顶部行和中心列。

  3. 现在,图像将整齐地定位在游戏面板的顶部,并且将水*居中。

它是如何工作的...

您已确保图像的纹理类型设置为默认。您已向场景中添加了一个UI 原始图像控件。原始图像控件被设置为显示unity_logo图像文件。

图像已定位在游戏面板的顶部中心。

还有更多...

有些细节您不应该错过:

与 2D 精灵和 UI 图像组件一起工作

如果你只想显示非动画图像,那么 Texture 图像和UI RawImage控件是最佳选择。然而,如果你想对图像的显示方式有更多选项(如瓦片和动画),则应使用 UI Image 控件。此控件需要将图像文件导入为 Sprite(2D 和 UI)类型。

一旦图像文件被拖入 UI Image 控制器的Sprite属性中,将会有额外的属性可用,例如图像类型,以及保留宽高比选项。

如果你希望防止 UI Sprite GameObject 的扭曲和拉伸,那么在检查器面板中,检查保留宽高比选项,在其图像(脚本)组件中。

参见

在第三章中,库存 UI通过改变瓦片图像的大小来显示多个对象拾取的图标食谱中可以找到一个瓦片 Sprite 图像的示例。

使用 Fungus 开源对话系统创建 UI

而不是每次都从头开始构建自己的 UI 和交互,Unity 中有很多 UI 和对话系统可供选择。一个强大、免费且开源的对话系统叫做 Fungus,它使用可视流程图方法进行对话设计。

在本食谱中,我们将创建一个非常简单的一句对话,以说明 Fungus 的基本用法。以下截图显示了 Fungus 生成的句子How are you today?的对话:

图片

如何做到...

要使用Fungus创建一句对话,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 打开资源管理器面板,搜索Fungus,并导入这个免费的资源包(搜索 Fungus 和免费)。

  3. 通过选择菜单:工具 | Fungus | 创建 | 流程图来创建一个新的Fungus流程图 GameObject。

  4. 通过选择菜单:工具 | Fungus | 流程图窗口来显示并停靠 Fungus 流程图窗口面板。

  5. 流程图窗口中将有一个块。点击此块以选择它(块周围出现绿色边框以指示已选择)。在检查器面板中,将此块的块名称更改为开始:

图片

  1. 流程图中的每个块都遵循一系列命令。因此,在检查器中,我们现在将创建一系列(说)命令,以便在游戏运行时向用户显示两个句子。

  2. 确保在流程图面板中开始块仍然被选中。点击检查器面板底部的加号(+)按钮以显示命令菜单,并选择叙事| 命令

图片

  1. 由于我们只为这个块有一个命令,因此该命令将自动在检查器的顶部部分被选中(高亮绿色)。检查器的下半部分显示了当前选定的命令的属性,如下面的截图所示。在检查器的下半部分,对于故事文本属性,输入您希望向用户展示的问题文本,即你今天怎么样?

  1. 创建另一个说命令,并为其故事文本属性输入以下内容:非常好,谢谢

  2. 当您运行游戏时,用户首先会看到你今天怎么样?的文本(在屏幕上逐字输入时会听到点击声)。在用户点击对话框窗口右下角的继续三角形按钮后,他们将看到第二句话:非常好,谢谢

它是如何工作的...

您已创建一个新的 Unity 项目,并导入了Fungus资产包,其中包含Fungus Unity菜单、窗口和命令,以及示例项目。

您已将一个名为开始Block添加到场景中,这是一个Fungus 流程图。当游戏开始时(因为第一个块的默认行为是在接收到游戏开始事件时执行),您的块开始执行。

开始块中,您添加了一系列两个说命令。每个命令向用户展示一句话,然后等待点击继续按钮才能继续到下一个命令

如所示,Fungus系统负责为用户创建一个展示良好的面板,显示所需的文本和继续按钮。Fungus提供许多其他功能,包括菜单、动画以及控制和音乐,这些细节可以在下一道菜谱中找到,并通过探索他们提供的示例项目和网站:

创建带有图像的 Fungus 角色对话

在之前的菜谱中引入的Fungus对话系统支持多个角色,其对话可以通过其名称、颜色、音效甚至肖像图像来突出显示。在这个菜谱中,我们将创建一个 Sherlock Holmes 和 Watson 之间的双角色对话来展示该系统:

如何做到...

要使用 Fungus 创建带有肖像图像的角色对话,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 打开资产商店面板,并导入****Fungus对话资产包(这包括Fungus示例,我们将使用这些示例图像的两个角色)。

  3. 通过选择菜单:工具 | Fungus | 创建 | 流程图,创建一个新的Fungus 流程图 GameObject。

  4. 通过选择菜单:工具 | Fungus | 流程图窗口来显示并停靠Fungus 流程图窗口面板。

  5. 流程图中唯一的的名称更改为“失踪小提琴案”。

  6. 通过选择菜单:工具 | Fungus | 创建 | 角色来创建一个新的角色。

  7. 现在,你应该在层次结构中看到一个新角色GameObject。

  8. 项目面板中选择 GameObject Character 1 – Sherlock,然后在检查器中编辑其属性:

    • 将此 GameObject 重命名为Character 1 – Sherlock

    • 在其角色(脚本)组件中,将名称文本设置为Sherlock并将名称颜色设置为绿色。

    • 检查器中,点击添加肖像按钮(加号“+”),以获得一个可以添加肖像图像的“槽”。

    • 将适当的图像拖放到你的新肖像图像槽中(在这个屏幕截图中,我们使用了 Sherlock 示例项目中的“自信”图像:Fungus Examples | Sherlock | Portraits | Sherlock):

  1. 重复上述 6-8 步来创建第二个角色 John,使用名称颜色 = 蓝色和肖像图像 = 恼怒。

  2. 选择你的流程图中的,以便你可以添加一些要执行的命令

  3. 创建一个命令,为Character 1 - Sherlock,说“华生,你看到我的小提琴了吗?”并选择自信的肖像(因为这是我们为角色添加的唯一一个):

  1. 添加第二个命令,这次是为Character 2 – John,说“不,你为什么不自己用你惊人的推理能力去找呢...”并选择恼怒的肖像:

  1. 运行场景,你应该会看到一系列陈述,清楚地显示谁在说(带有颜色的)名称文本以及为每个命令所选的肖像图像(在 Sherlock 的文本出现完毕后,点击框开始 John 的句子)。

它是如何工作的...

你已经创建了一个包含Fungus资产包的新 Unity 项目。

你已经将Fungus 流程图添加到场景中,并且还添加了两个角色(每个角色都有一个文本颜色和肖像图像)。

对于添加到命令中的流程图中的,指定了哪个角色在说每句话,以及使用哪个肖像(如果你添加了更多肖像图像,你可以选择不同的图像来表示说话角色的情绪)。

更多...

有一些细节你不希望错过。

数据驱动对话

Fungus 提供了一种数据驱动的对话方法。角色和肖像(以及面向方向、舞台上的移动与否等)可以通过使用命令的叙事 | 对话选项的简单格式中的文本来定义。此菜谱的带有肖像图像的对话可以用对话中的两行文本来声明:

Sherlock confident: Watson, have you seen my violin?
John annoyed: No, why don't you find it yourself using your amazing powers of deduction...

在他们的文档页面上了解更多关于真菌对话系统的信息:fungusdocs.snozbot.com/conversation_system.html.

第二章:对交互式 UI 的用户事件做出响应

在本章中,我们将涵盖以下内容:

  • 创建 UI 按钮在场景之间移动

  • 在鼠标悬停时动画化按钮属性

  • 通过按钮组织图像面板和更改面板深度

  • 显示交互式 UI 滑块的值

  • 使用 UI 滑块图形化显示倒计时计时器

  • 为 2D 和 3D GameObject 设置自定义鼠标光标

  • 为 UI 控件设置自定义鼠标光标

  • 使用输入字段进行交互式文本输入

  • 通过切换组使用切换和单选按钮

  • 创建文本和图像图标 UI 下拉菜单

  • 显示雷达以指示对象的相对位置

简介

本章中的几乎所有食谱都涉及不同的交互式 UI 控件。尽管有不同类型的交互式 UI 控件,但使用它们的基本方式以及脚本动作响应用户动作都是基于相同的概念:事件触发对象方法函数的执行。

然后,为了娱乐和展示一种非常不同的 UI 类型,最后的食谱演示了如何将一个复杂的实时通信添加到游戏中,该通信显示场景中对象的相对位置(即雷达!)。

整体概念

UI 可以用于三个主要目的:

  1. 要显示静态(不变)值,例如游戏的名称或标志图像,或者如“等级”和“得分”之类的文字标签,这些标签告诉我们它们旁边的数字表示什么(这些食谱可以在第一章,使用核心 UI 元素显示数据中找到)。

  2. 要显示由于我们的脚本而变化的值,例如计时器、得分或玩家角色与其他对象之间的距离(本章末尾的雷达食谱就是一个例子)。

  3. 交互式 UI 控件,其目的是允许玩家通过鼠标或触摸屏与游戏脚本进行通信。我们将在本章中详细探讨这些控件。

在 Unity 中处理交互式 UI 控件的核心概念是将对象的公共方法注册为特定事件发生时的通知。例如,我们可以将一个 UI 下拉菜单添加到名为“DropDown 1”的场景中,然后编写一个包含NewValueAction()公共方法的MyScript脚本类来执行某些操作。但除非我们做两件事,否则什么都不会发生:

  1. 我们需要在场景中的 GameObject(在我们的例子中命名为go1 - 虽然我们也可以选择将脚本实例添加到 UI GameObject 本身)上添加脚本类的实例作为组件。

  2. 在 UI 下拉菜单的属性中,我们需要将脚本组件的 GameObject 的公共方法注册为响应On Value Changed事件消息:

图片

MyScript脚本的NewValueAction()公共方法通常将检索用户在下拉菜单中选择的值并对其进行处理——例如,向用户确认,更改音量,或更改游戏难度。每当 GameObject go1 收到NewValueAction()消息时,NewValueAction()方法将被调用(执行)。在 DropDown 1 的属性中,我们需要将 GameObject go1 的脚本组件 MyScript 的NewValueAction()公共方法注册为 On Value Changed 事件的监听器。我们需要在设计时(即在 Unity 编辑器运行场景之前)完成所有这些操作:

图片

运行时(当构建的应用程序中的场景正在运行时),如果用户更改 UI Dropdown GameObject DropDown 1 的下拉菜单中的值(图中的步骤 1),这将生成一个 On Value Changed 事件。DropDown 1 将更新其屏幕上的显示,以向用户显示新选定的值(步骤 2a)。它还将向所有注册为On Value Changed事件监听器的 GameObject 组件发送消息(步骤 2b)。在我们的例子中,这将导致 GameObject go1 的脚本组件中的NewValueAction()方法被执行(步骤 3)。

注册公共对象方法是处理用户交互或网络通信等事件的一种非常常见的方式,这些事件可能按不同的顺序发生,可能永远不会发生,或者可能在短时间内发生多次。几种软件设计模式描述了如何处理这些事件设置,例如观察者模式发布-订阅模式(我们将在第十六章设计模式中了解更多关于这种模式的信息)。

与交互式 Unity UI 开发相关的核心 GameObject、组件和概念包括:

  • 视觉 UI 控件:可见的 UI 控件包括按钮、图像、文本和切换。这些是用户在屏幕上看到的 UI 控件,并使用鼠标/触摸屏与之交互。这些是维护已订阅用户交互事件的对象方法的列表的 GameObject。

  • 交互 UI 控件:这些是非可见组件,它们被添加到 GameObject 中;例如,包括输入字段和切换组。

  • 面板:UI 对象可以使用 UI Panels 进行分组(逻辑上和物理上)。面板可以扮演多个角色,包括为相关控制组提供 Hierarchy 中的 GameObject 父对象。它们可以提供视觉背景图像,以图形化地关联屏幕上的控件,并且如果需要,还可以添加脚本化的调整大小和拖动交互。

  • 兄弟深度:UI 元素的从下到上的显示顺序(什么出现在什么上面)最初由它们在 Hierarchy 中的顺序决定。在设计时,可以通过将 GameObjects 拖动到 Hierarchy 中所需的顺序来手动设置。在运行时,我们可以向 GameObjects 的 Rect Transforms 发送消息,以动态更改它们的 Hierarchy 位置(因此,显示顺序),以满足游戏或用户交互的需求。这可以在 通过按钮组织面板内的图像和更改面板深度 菜谱中看到。

通常,一个 UI 元素已经包含了你可能需要的游戏中的大多数组件,但你可能需要对其进行某种调整。一个这样的例子可以在 使用 UI 滑块图形显示倒计时计时器状态 菜谱中看到,该菜谱将 UI 滑块变为非交互式,而是用它来显示倒计时计时器的红色-绿色进度条。

创建 UI 按钮在场景之间切换

除了玩家玩游戏时的场景外,大多数游戏还会有菜单屏幕,这些屏幕向用户显示有关说明、高分、他们已经达到的水*等信息。Unity 提供了 UI 按钮,为用户提供了一种简单的方式来表示他们的选择。

在这个菜谱中,我们将创建一个非常简单的游戏,由两个屏幕组成,每个屏幕都有一个按钮来加载另一个屏幕,如图所示:

图片

如何做到这一点...

要创建一个按钮可导航的多场景游戏,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 在新文件夹 _Scenes 中保存当前(空)场景,命名为 scene page1。

  3. 在场景顶部中心位置添加一个 UI 文本 对象,包含显示“主菜单(页面 1)”的大号白色文本。

  4. 在屏幕中间位置添加一个 UI 按钮。在 Hierarchy 中,点击显示子对象三角形以显示此 GameObject 按钮的 Text 子对象。选择 Text GameObject,并在 Inspector 中为 Text(脚本)组件的 Text 属性输入文本“转到页面 2”:

图片

  1. 创建一个名为 page2 的第二个场景,UI 文本为“说明(页面 2)”,并添加一个带有“转到页面 1”文本的 UI 按钮。你可以重复前面的步骤,或者你可以复制 page1 场景文件,将其命名为 page2,并相应地编辑 UI 文本和 UI 按钮文本。

  2. 将两个场景添加到构建中,这是 Unity 构建的实际应用程序中最终将包含的场景集。要将 scene1 添加到构建中,打开 scene page1,然后选择菜单:文件 | 构建设置... 然后点击添加当前按钮,使 page1 场景成为构建中场景列表中的第一个场景。现在打开 scene page2 并重复此过程,这样两个场景都已被添加到构建中。

我们不能告诉 Unity 加载一个尚未添加到构建场景列表中的场景。这很有道理,因为当应用程序构建时,我们永远不应该尝试打开不属于该应用程序的场景。

  1. 确保你已经打开了场景 page1。

  2. 在一个名为_Scripts的新文件夹中创建一个 C#脚本类,名为SceneLoader,包含以下代码,并将其作为一个脚本组件添加到主相机中:

using UnityEngine; 
using UnityEngine.SceneManagement; 

public class SceneLoader : MonoBehaviour { 
    public void LoadOnClick(int sceneIndex) { 
        SceneManager.LoadScene(sceneIndex); 
    } 
} 
  1. 在层级窗口中选择按钮,然后在Inspector视图的Button (Script)组件的底部点击加号(+)按钮,为这个按钮创建一个新的OnClick事件处理程序(即当按钮被点击时执行的动作)。

  2. 将主相机从层级窗口拖动到位于“仅运行时”菜单下方立即的“对象”槽位。这意味着当按钮接收到OnClick事件时,我们可以从主相机内的脚本对象中调用一个公共方法。

  3. 从 SceneLoader 下拉列表中选择LoadOnClick方法(最初显示为“无函数”)。在方法下拉菜单下的文本框中输入 1(我们希望在点击按钮时加载的场景的索引)。这个整数 1 将在按钮接收到OnClick事件消息时传递给方法,如这里所示:

图片

  1. 保存当前场景(page1)。

  2. 打开 page2,并按照相同的步骤来使 page2 按钮加载 page1。也就是说,将SceneLoader脚本类的实例添加到主相机中,然后为按钮添加一个OnClick事件动作,调用LoadOnClick,并传递整数 0,这样就可以加载场景 page1。

  3. 保存场景 page2。

  4. 当你运行 page1 场景时,你会看到主菜单文本和一个按钮,点击这个按钮会使游戏加载 page2 场景。在场景 page2 中,你会有一个按钮可以带你回到 page1。

它是如何工作的...

你已经创建了两个场景,并将这两个场景都添加到了游戏的构建中。你为每个场景添加了一个 UI 按钮和一些 UI Text。

注意,场景的构建顺序实际上是一个脚本数组,从 0 开始计数,然后是 1,依此类推,所以 page1 的索引是 0,page2 的索引是 1。

当一个 UI 按钮被添加到层级面板中时,也会自动创建一个子 UI Text 对象,并且这个 UI Text 子对象的文本属性内容就是用户在按钮上看到的文本。

你创建了一个脚本类,并将其作为一个组件添加到了主相机中。实际上,这个脚本实例添加的位置并不重要,只要它在场景中的某个 GameObject 中即可。这是必要的,因为按钮的OnClick事件动作只能执行场景中 GameObject 组件的方法(函数)。

对于每个场景的按钮,你随后添加了一个新的 OnClick 事件动作,该动作调用(执行)主摄像机中 SceneLoader 脚本组件的 LoadOnClick 方法。此方法输入场景在项目构建设置中的整数索引,因此页面 1 场景上的按钮给出整数 1 作为要加载的场景,而页面 2 的按钮给出整数 0。

还有更多...

有一些细节你不应该错过。

鼠标指针悬停在按钮上时的颜色渐变

有几种方式可以让我们在用户将鼠标光标移至按钮上时,从视觉上告知用户该按钮是交互式的。最简单的是添加一个当鼠标悬停在按钮上时出现的颜色渐变效果——这是默认的 过渡。在层次结构中选择按钮(脚本)组件,在检查器选项卡中为按钮(脚本)组件的 高亮颜色 属性选择一个着色颜色(例如,红色):

另一种通知用户活动按钮的视觉过渡形式是 Sprite Swap。在这种情况下,检查器 面板中提供了针对目标/高亮/按下/禁用不同图像的属性。默认的目标图形是内置的 Unity 按钮(图像)——这是当创建 GameObject 按钮时的灰色圆形矩形默认值。将一个看起来非常不同的图像拖入 高亮 Sprite 是设置颜色渐变的有效替代方案。我们为这个菜谱提供了一个 rainbow.png 图像,可用于按钮鼠标悬停高亮 Sprite。截图显示了具有这种彩虹背景图像的按钮:

在鼠标悬停时动画按钮属性

在上一个菜谱的末尾,我们展示了两种向用户传达按钮的方法。按钮属性的动画可以是一种非常有效且视觉上吸引人的方式,以向用户强化他们当前鼠标悬停的项是一个可点击的、活动的按钮。一个常见的动画效果是当鼠标悬停在按钮上时,按钮变大,然后当鼠标指针移开时,它又恢复到原始大小。动画效果是通过为 Button GameObject 的过渡属性选择动画选项,并创建具有正常、高亮、按下和禁用状态触发器的动画控制器来实现的。

如何做到这一点...

要在鼠标悬停时(高亮状态)使按钮动画放大,请执行以下操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 创建一个 UI 按钮

  3. 检查器按钮(脚本) 组件中,将 过渡 属性设置为 动画

  4. 点击 自动生成动画 按钮(位于 禁用触发器 属性下方)以针对 按钮(脚本) 组件:

  1. 将新的控制器(在新的文件夹Animations中)保存,命名为button-animation-controller

  2. 确保在层次结构中选择按钮GameObject。在动画面板中,从下拉菜单中选择高亮剪辑:

图片

  1. 动画面板中,单击红色记录圆圈按钮,然后单击添加属性按钮,选择记录 Rect Transform | 缩放属性的变化。

  2. 将创建两个关键帧。在1:00删除第二个关键帧(因为我们不希望按钮有“弹跳”效果):

图片

  1. 通过单击一个钻石(选择时两者都会变为蓝色)来选择1:00处的帧,然后按退格键/删除键

  2. 选择0:00处的第一个关键帧(现在只有一个!)!在检查器中,将Rect Transform组件的 X 和 Y 缩放属性设置为1.2, 1.2

  3. 第二次单击红色记录圆圈按钮以结束动画更改的录制。

  4. 保存并运行你的场景,你会看到当鼠标悬停在按钮上时,按钮会*滑地放大,当鼠标移开后,它会*滑地恢复到原始大小。

它是如何工作的...

你已经创建了一个按钮,并将其过渡模式设置为动画。这使得 Unity 需要具有四个状态的动画控制器:正常、高亮、按下和禁用。然后你让 Unity 自动创建具有这四个状态的动画控制器。

然后,你编辑了高亮(鼠标悬停)状态的动画,删除了第二个关键帧,并将唯一的关键帧设置为将按钮放大到 1.2 倍大小的版本。

当鼠标不在按钮上时,它保持不变,并使用正常状态设置。当鼠标移到按钮上时,动画控制器*滑地在按钮的设置之间过渡,变为高亮状态的设置(即变大)。当鼠标从按钮移开时,动画控制器*滑地在按钮的设置之间过渡,变为正常状态的设置(即原始大小)。

以下网页提供了关于 UI 动画的视频和基于网页的教程:

Unity 按钮过渡教程可在unity3d.com/learn/tutorials/modules/beginner/ui/ui-transitions找到。Ray Wenderlich 的出色教程(第二部分),包括按钮动画,可在www.raywenderlich.com/79031/unity-new-gui-tutorial-part-2找到。

通过按钮组织图像面板和更改面板深度

Unity 提供了 UI 面板,允许将 UI 控件分组并一起移动,并且还可以通过图像背景(如果需要)视觉上分组元素。兄弟深度决定了哪些 UI 元素将出现在其他元素之上或之下。我们可以在层次结构中明确看到兄弟深度,因为层次结构中 UI GameObjects 的从上到下的顺序设置了兄弟深度。因此,第一个项目深度为 1,第二个项目深度为 2,依此类推。具有较大兄弟深度(在层次结构中位置更低且绘制较晚)的 UI GameObjects 将出现在具有较小兄弟深度(深度较低)的 UI GameObjects 之上。

在这个食谱中,我们将创建三个 UI 面板,每个面板显示不同的扑克牌图像。我们还将添加四个三角形排列按钮来更改显示顺序(移至底部、移至顶部、向上移动一个和向下移动一个):

图片

准备工作

对于这个食谱,我们在02_03文件夹中的Images文件夹中准备了所需的图像。

如何做到这一点...

要创建可以通过点击按钮更改分层结构的 UI 面板,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 创建一个新的UI 面板GameObject,命名为Panel-jack-diamonds。对此面板执行以下操作:

    • 对于图像(脚本)组件,将项目面板中的**jack_of_diamonds**扑克牌图像资产文件拖动到源图像属性。选择颜色属性,并将透明度值增加到255(这样面板的背景图像就不再部分透明了)。

    • 对于矩形变换属性,将其放置在屏幕的中间中心部分,并使用宽度 = 200高度 = 300进行缩放。

  3. 创建一个名为Button-move-to-frontUI 按钮。在层次结构中,将此按钮作为子项添加到Panel-jack-diamonds。删除此按钮的文本子 GameObject(因为我们将使用图标来指示此按钮的功能)。

  4. 层次结构中选择Button-move-to-frontGameObject 后,在检查器中执行以下操作:

    • 矩形变换中,将按钮放置在玩家卡片图像的顶部中心,以便在扑克牌顶部可见。将图像大小调整为宽度 = 16高度 = 16。将图标图像向下稍微移动,通过设置Y 位置 = -5(以确保我们可以看到三角形上方的水*条)。

    • 对于图像(脚本)组件的源图像属性,选择排列三角形图标图像:icon_move_to_front

    • 通过点击按钮(脚本)组件底部的加号(+)添加一个OnClick事件处理器。

    • Panel-jack-diamonds层次结构拖动到对象槽(位于仅运行时菜单下方)。

    • 从下拉函数列表中选择RectTransform.SetAsLastSibling方法(最初显示为无函数):

图片

  1. 重复步骤 2;创建一个名为Panel-2-diamonds的第二个面板,并为其自己的移动到前面按钮和2_of_diamonds源图像。将这个新面板稍微向Panel-jack-diamonds的右侧移动,以便两个移动到前面按钮都能被看到。

  2. 保存你的场景并运行游戏。你将能够点击任何一张卡片上的移动到前面按钮,将那张卡片的面板移动到前面。如果你以未最大化的游戏面板运行游戏,你实际上会看到层次结构Canvas的子项列表中的面板顺序发生变化。

它是如何工作的...

你已经创建了两个UI 面板,每个面板包含一张玩牌的背景图像和一个UI 按钮,该按钮的动作将使其父面板移动到前面。你将背景图像的颜色Alpha(透明度)设置为255(无透明度)。

你为每个UI 面板的按钮添加了一个OnClick事件动作。该动作向按钮的面板父级发送SetAsLastSibling消息。当接收到OnClick消息时,被点击的面板被移动到 GameObject 序列的底部(末尾),因此这个面板Canvas对象中最后被绘制,因此看起来在所有其他 GameObject 之前。

按钮的动作说明了OnClick函数不必是调用对象脚本组件的公共方法,而是可以向目标 GameObject 的非脚本组件发送消息。在这个菜谱中,我们向按钮所在的面板Rect Transform发送**SetAsLastSibling**消息。

还有更多...

有一些细节你不希望错过。

只通过一个位置上移或下移,使用脚本方法

虽然矩形变换提供了有用的SetAsLastSibling(移动到前面)和SetAsFirstSibling(移动到后面),甚至SetSiblingIndex(如果我们知道确切的位置),但在层次结构中 GameObject 的序列中并没有内置的方法来使一个元素只向上或向下移动一个位置。然而,我们可以用 C#编写两个简单的方法来做这件事,并且我们可以添加按钮来调用这些方法,从而提供对屏幕上 UI 控件从上到下排列的完全控制。要实现四个按钮(移动到前面/移动到后面/上移一个/下移一个),请按照以下步骤操作:

  1. 创建一个名为ArrangeActions的 C#脚本类,包含以下代码,并将其作为脚本组件添加到每个面板中:
using UnityEngine; 

public class ArrangeActions : MonoBehaviour { 
   private RectTransform panelRectTransform; 

   void Awake() { 
         panelRectTransform = GetComponent<RectTransform>(); 
   }  

   public void MoveDownOne() { 
         int currentSiblingIndex = panelRectTransform.GetSiblingIndex(); 
         panelRectTransform.SetSiblingIndex( currentSiblingIndex - 1 ); 
   } 

   public void MoveUpOne() { 
         int currentSiblingIndex = panelRectTransform.GetSiblingIndex(); 
         panelRectTransform.SetSiblingIndex( currentSiblingIndex + 1 );           
   } 
}
  1. 在每个卡片面板上添加第二个UI 按钮,这次使用名为icon_move_to_front的排列三角形图标图像,并将这些按钮的OnClick事件函数设置为SetAsFirstSibling

  2. 在每个卡片面板上添加两个带有向上和向下三角形图标图像的UI 按钮icon_down_oneicon_up_one。将向下按钮的OnClick事件处理函数设置为调用MoveDownOne()方法,并将向上按钮的函数设置为调用MoveUpOne()方法。

  3. 复制一个UI 面板以创建第三个卡片(这次显示红桃 A)。排列三个卡片,以便至少可以看到两张卡片的四个按钮,即使这些卡片位于底部(参见本食谱开头的截图)。

  4. 保存场景并运行你的游戏。现在,你可以完全控制三个卡片面板的分层。

注意,我们应该避免兄弟深度,因此在减去 1 之前,我们应该测试当前兄弟索引值:

if(currentSiblingIndex > 0)       panelRectTransform.SetSiblingIndex( currentSiblingIndex - 1 );

显示交互式 UI 滑块的值

本食谱说明了如何创建一个交互式UI 滑块,并在用户更改UI 滑块值时执行 C#方法:

图片

如何做到这一点...

要创建UI 滑块并在屏幕上显示其值,请按照以下步骤操作:

  1. 创建一个新的 2D 项目。

  2. 在场景中添加一个UI 文本GameObject,字体大小为30,占位文本例如滑块值在此处(当场景开始时,此文本将被滑块值替换)。将水*垂直溢出设置为溢出

  3. 层次结构中添加一个UI 滑块GameObject 到场景中 - 选择菜单:GameObject | UI | Slider。

  4. 检查器中,修改UI 滑块GameObject 的矩形变换位置设置,使其位于屏幕的顶部中间部分。

  5. 检查器中,修改UI 文本的矩形变换位置设置,使其位于滑块下方(顶部,中间,然后Pos Y = -30)。

  6. 检查器中,将UI 滑块的最小值设置为0,将最大值设置为20,并勾选整数复选框:

图片

  1. 创建一个名为SliderValueToText的 C#脚本类,包含以下代码,并将其作为脚本组件添加到文本GameObject 中:
using UnityEngine; 
using UnityEngine.UI; 

public class SliderValueToText : MonoBehaviour { 
   public Slider sliderUI; 
   private Text textSliderValue; 

   void Awake() {
         textSliderValue = GetComponent<Text>(); 
   } 

   void Start() { 
         ShowSliderValue(); 
   } 

   public void ShowSliderValue () { 
         string sliderMessage = "Slider value = " + sliderUI.value; 
         textSliderValue.text = sliderMessage; 
   } 
} 
  1. 确保在层次结构中选择了文本GameObject。然后,在检查器中,将滑块GameObject 拖动到滑块值到文本(脚本)脚本组件的公共滑块 UI变量槽中:

图片

  1. 确保在层次结构中选择了滑块GameObject。然后,在检查器中,将文本GameObject 拖动到滑块(脚本)脚本组件的公共None (Object)槽中,在单次值更改(单次)部分,如图所示:

图片注册对象以接收 UI 事件消息

您现在已告诉 Unity 每次滑块改变时应该向哪个对象发送消息。

  1. 从下拉菜单中选择SliderValueToTextShowSliderValue()方法,如图下截图所示。这意味着每次滑块更新时,脚本对象中的ShowSliderValue()方法,在TextGameObject 中将被执行:

图片

  1. 当您运行场景时,您现在将看到一个UI Slider。在其下方,您将看到一个形式为Slider value = <n>的文本消息。

  2. 每次移动UI Slider时,显示的文本值将(几乎)立即更新。值应从0(滑块的左侧)到20(滑块的右侧)。

它是如何工作的...

您已创建一个UI SliderGameObject,并将其设置为范围在020之间的整数。

您已将SliderValueToText C#脚本类的一个实例添加到UI TextGameObject 中。

Awake()方法将 Text 组件的引用缓存到 textSliderValue 变量中。

Start()方法调用ShowSliderValue()方法,以确保场景开始时显示正确(即,显示初始滑块值)。

ShowSliderValue()方法获取滑块的值,然后更新显示的文本,使其成为形式为Slider value = <n>的消息。

您将SliderValueToText脚本组件的ShowSliderValue()方法添加到SliderGameObject 的On Value Changed事件监听器列表中。因此,每次滑块值改变时,它会发送一个消息调用ShowSliderValue()方法,从而在屏幕上更新新的值。

使用 UI Slider 图形显示倒计时计时器

在许多情况下,我们希望通知玩家剩余时间的比例,或在某个时间点的某些值完成时,例如,一个加载进度条,剩余时间或健康与起始最大值的比较,或者玩家从青春之泉中装满水壶的程度。在这个菜谱中,我们将说明如何移除UI Slider的交互式“手柄”,并更改其组件的大小和颜色,以便我们有一个易于使用的通用进度/比例条。在这个菜谱中,我们将使用修改后的UI Slider来图形化地向用户展示倒计时计时器剩余的时间:

图片

准备工作

对于这个菜谱,我们在02_05文件夹中的_Scripts和 Images 文件夹中准备了您需要的脚本和图像。

如何操作...

要创建一个带图形显示的数字倒计时计时器,请按照以下步骤操作:

  1. 创建一个新的 2D 项目。

  2. **CountdownTimer**脚本以及red_squaregreen_square图像导入到这个项目中。

  3. 在场景中添加一个具有 字体大小30 和占位文本,如 UI S****lider 值在这里(此文本将在场景开始时替换为滑块值)。设置 水*垂直 溢出为 溢出

  4. 层次结构 中,将一个 滑块 游戏对象添加到场景中——选择菜单:GameObject | UI | Slider

  5. 检查器 中,修改 滑块 游戏对象的 矩形变换位置 设置为屏幕的顶部中间部分。

  6. 确保在 层次结构 中选择了 滑块 游戏对象。

  7. 禁用 滑动区域处理 子游戏对象的激活(通过取消选中它)

  8. 你会在 游戏 面板中看到“拖动圆圈”消失(用户不会拖动滑块,因为我们希望这个滑块仅用于显示):

图片

  1. 选择 背景 子项:

    • red_square 图像拖动到 检查器图像(脚本) 组件的 源图像 属性。
  2. 选择填充区域的子项:

    • green_square 图像拖动到 检查器图像(脚本) 组件的 源图像 属性。
  3. 选择 填充区域 子项:

    • 矩形变换 组件中,使用 锚点 预设位置为左中:

    • 宽度 设置为 155高度 设置为 12

图片

  1. 创建一个名为 SliderTimerDisplay 的 C# 脚本类,包含以下代码,并将其作为脚本组件添加到 滑块 游戏对象中:
using UnityEngine; 
using UnityEngine.UI; 

[RequireComponent(typeof(CountdownTimer))] 
public class SliderTimerDisplay : MonoBehaviour { 
   private CountdownTimer countdownTimer; 
   private Slider sliderUI; 

   void Awake() { 
         countdownTimer = GetComponent<CountdownTimer>(); 
         sliderUI = GetComponent<Slider>(); 
   } 

   void Start() { 
         SetupSlider(); 
         countdownTimer.ResetTimer( 30 ); 
   } 

   void Update () { 
         sliderUI.value = countdownTimer.GetProportionTimeRemaining(); 
         print (countdownTimer.GetProportionTimeRemaining()); 
   } 

   private void SetupSlider () { 
         sliderUI.minValue = 0; 
         sliderUI.maxValue = 1; 
         sliderUI.wholeNumbers = false; 
   } 
} 

运行你的游戏,你会看到滑块每秒移动,逐渐显示更多的红色背景,以指示剩余时间。

它是如何工作的...

你隐藏了 滑动区域处理 子游戏对象,这样 UI 滑块 仅用于显示,用户无法与之交互。UI 滑块背景 颜色被设置为红色,因此,随着计数器的下降,越来越多的红色被揭示——警告用户时间正在流逝。

UI 滑块填充 被设置为绿色,这样剩余的比例就显示为绿色——显示的绿色越多,滑块/计时器的值就越大。

自动将提供的 倒计时计时器 脚本类的一个实例添加为滑块的组件,通过 [RequireComponent(...)]

Awake() 方法将 countdownTimersliderUI 变量中 倒计时计时器滑块 组件的引用缓存起来。

Start() 方法调用 SetupSlider() 方法,然后重置倒计时计时器,从 30 秒开始倒计时。

SetupSlider() 方法设置此滑块用于浮点(小数)值,范围在 0.01.0 之间。

在每一帧中,Update() 方法将滑块值设置为从运行中的计时器调用 GetProportionRemaining() 方法返回的浮点数。在运行时,Unity 调整滑块中显示的红色/绿色的比例,以匹配滑块的值。

为 2D 和 3D GameObject 设置自定义鼠标光标

光标图标通常用于指示可以使用鼠标进行的交互性质。例如,缩放可能由放大镜表示;另一方面,射击通常由一个风格化的靶子表示。在这个菜谱中,我们将学习如何实现自定义鼠标光标图标,以更好地说明你的游戏玩法——或者只是逃离 Windows、macOS 和 Linux 的默认 UI:

准备工作

对于这个菜谱,我们在02_06文件夹中准备了你需要用到的文件夹。

如何操作...

要在鼠标悬停在 GameObject 上时显示自定义光标,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 导入提供的名为Images的文件夹。在项目面板中选择unity_logo图像,并在检查器中将纹理类型更改为Sprite (2D and UI)。这是因为我们将使用这个图像作为2D SpriteGameObject,它需要这种纹理类型(使用默认类型将不起作用)。

  3. 向场景添加一个2D Object | SpriteGameObject。如果创建时不是默认名称,请将其命名为New Sprite

    • 检查器中,将Sprite属性设置为Sprite Renderer组件的unity_logo图像。在 GameObject 的Transform组件中,设置缩放为(3,3,3),并在必要时,当场景运行时将Sprite重新定位到游戏面板的中心。

    • SpriteGameObject 添加一个Physics 2D | Box Collider。这是必要的,因为这样这个 GameObject 才能接收OnMouseEnterOnMouseExit事件消息。

  4. 导入提供的名为IconsCursors的文件夹。在项目面板中选择所有三个图像,并在检查器中,将纹理类型更改为Cursor。这将允许我们使用这些图像作为鼠标光标而不会出现任何错误。

  5. 创建一个名为CustomCursorPointer的 C#脚本类,包含以下代码,并将其作为一个脚本组件添加到New SpriteGameObject 中:

using UnityEngine; 
using System.Collections; 

public class CustomCursorPointer : MonoBehaviour { 
  public Texture2D cursorTexture2D; 
  private CursorMode cursorMode = CursorMode.Auto; 
  private Vector2 hotSpot = Vector2.zero; 

  public void OnMouseEnter() { 
    SetCustomCursor(cursorTexture2D); 
  } 

  public void OnMouseExit() { 
    SetCustomCursor(null); 
  } 

  private void SetCustomCursor(Texture2D curText){ 
    Cursor.SetCursor(curText, hotSpot, cursorMode); 
  } 
}

故意将事件方法OnMouseEnter()OnMouseExit()声明为公共。这将允许这些方法在 UI GameObject 接收到OnPointerEnterExit事件时也能被调用。

  1. 层次结构中选择New Sprite项目,将CursorTarget图像拖放到Customer Cursor Pointer (Script)组件的检查器中的公共Cursor Texture 2D变量槽中。

  2. 保存并运行当前场景。当鼠标指针移动到 Unity 标志 sprite 上时,它将更改为你选择的自定义CursorTarget图像。

它是如何工作的...

您创建了一个 Sprite 游戏对象,并将其分配给 Unity 标志图像。您导入了一些光标图像,并将它们的 纹理 类型 设置为 光标,以便它们可以用来更改用户的鼠标指针的图像。您向精灵游戏对象添加了一个 Box Collider,以便它能够接收 OnMouseEnterOnMouseExit 事件消息。

您创建了 **CustomCursorPointer** 脚本类,并将其实例对象添加到精灵游戏对象中 - 此脚本告诉 Unity 在接收到 OnMouseEnter 消息时更改鼠标指针,即当用户的鼠标指针移动到屏幕上正在渲染 Unity 标志精灵图像的部分时。当接收到 OnMouseExit 事件(用户的鼠标指针不再位于屏幕的立方体部分)时,系统被告知返回到操作系统的默认光标。此事件应在用户鼠标退出碰撞器后的几毫秒内接收到。

最后,您选择了图像 CursorTarget 作为用户在鼠标悬停在 Unity 标志图像上时看到的自定义鼠标指针图像。

为 UI 控件设置自定义鼠标光标

之前的菜谱演示了如何更改接收 OnMouseEnterOnMouseExit 事件的 2D 和 3D 游戏对象的鼠标指针。Unity UI 控件不会接收 OnMouseEnterOnMouseExit 事件。相反,如果我们在 UI 游戏对象中添加一个特殊的 事件触发器 组件,UI 控件可以响应 PointerEnterPointerExit 事件。在这个菜谱中,我们将当鼠标移动到 UI 按钮 游戏对象上时,将鼠标指针更改为自定义放大镜光标。

准备工作

对于这个菜谱,我们将使用与上一个菜谱相同的资产文件和 CustomCursorPointer C# 脚本类,所有这些都可以在 02_07 文件夹中找到。

如何操作...

要在鼠标移动到 UI 控件游戏对象上时设置自定义鼠标指针,

执行以下操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 导入提供的 IconsCursors 文件夹。在 项目 面板中选择所有三个图像,然后在 检查器 中将 纹理类型 更改为 光标。这将使我们能够使用这些图像作为鼠标光标而不会出现任何错误。

  3. 导入提供的 _Scripts 文件夹,其中包含 CustomCursorPointer C# 脚本类。

  4. 在场景中添加一个 UI 按钮 游戏对象,保留其名称为 按钮

  5. CustomCursorPointer C# 脚本类的实例添加到 按钮 游戏对象中。

  6. 层次结构 中选择 按钮 游戏对象后,将 CursorZoom 图像拖放到 检查器Customer Cursor Pointer (Script) 组件的公共 Cursor Texture 2D 变量槽中。

  7. 检查器 中,为 按钮 游戏对象添加一个 事件触发器 组件。选择菜单:添加组件 | 事件 | 事件触发器。

  8. 在您的事件触发器组件中添加一个PointerEnter事件,点击加号(+)按钮以添加事件处理程序槽,并将 GameObject 按钮拖入对象槽中。

  9. 函数下拉菜单中选择CustomCursorPointer,然后选择OnMouseEnter方法:

图片

  1. 在您的事件触发器组件中添加一个Pointer Exit事件,并在接收到此事件时调用CustomCursorPointerOnMouseExit()方法。

  2. 保存并运行当前场景。当鼠标指针移至UI 按钮上时,它将变为您选择的自定义**CursorZoom**图像。

它是如何工作的...

您已导入了一些光标图像,并将它们的纹理类型设置为光标,以便它们可以用于更改用户鼠标指针的图像。您还创建了一个UI 按钮GameObject,并为其添加了一个事件触发器组件。

您已将CustomCursorPointer C#脚本类的实例添加到按钮GameObject 中,并选择了放大镜风格的CursorZoom图像。

您创建了一个PointerEnter事件,并将其链接到调用按钮GameObject 中CustomCursorPointer脚本的OnMouseEnter方法(这将鼠标指针图像更改为自定义鼠标光标)。

您创建了一个PointerExit事件,并将其链接到调用CustomCursorPointer C#脚本类的实例的OnMouseExit方法,将其链接到按钮GameObject(这将鼠标光标重置为系统默认值)。

实质上,您已将PointerEnter/Exit事件重定向以调用CustomCursorPointer C#脚本类的OnMouseEnter/Exit方法,这样我们就可以使用相同的脚本方法来管理 2D、3D 和 UI GameObjects 的自定义光标。

使用输入字段的交互式文本输入

虽然我们通常只想向用户显示非交互式文本消息,但在某些情况下(如输入高分时的名字),我们希望用户能够将文本或数字输入到我们的游戏中。Unity 为此提供了 UI 输入字段组件。在此配方中,我们将创建一个输入字段,提示用户输入他们的名字:

图片

除非我们能够检索输入的文本以用于游戏逻辑,否则在屏幕上显示交互式文本并没有多大用处,我们可能还需要知道每次用户更改文本内容时,并据此采取行动。此配方添加了一个事件处理程序 C#脚本,它检测每次用户完成文本编辑,并在屏幕上更新一条额外的消息以确认新输入的内容。

如何实现...

要创建一个交互式文本输入框供用户使用,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 检查器中,将主摄像机的背景更改为纯白色。

  3. 将一个UI 输入字段添加到场景中。将其定位在屏幕的顶部中央。

  4. 在场景中添加一个UI Text游戏对象,命名为Text-prompt。将其放置在Input Field的左侧。将此游戏对象的Text属性更改为 Name:。

  5. 创建一个新的UI Text游戏对象,命名为Text-display。将其放置在Input Text控制的右侧,并将文本颜色设置为红色。

  6. 删除这个新游戏对象Text属性的所有内容(因此最初用户不会看到此游戏对象屏幕上的任何文本)。

  7. DisplayChangedTextContentC#脚本类的一个实例添加到Text-display游戏对象中:

using UnityEngine; 
using UnityEngine.UI; 

public class DisplayChangedTextContent : MonoBehaviour { 
   public InputField inputField; 
   private Text textDisplay; 

   void Awake() { 
         textDisplay = GetComponent<Text>(); 
   } 

   public void DisplayNewValue () { 
         textDisplay.text = "last entry = '" + inputField.text + "'"; 
   } 
} 
  1. Hierarchy中选择Text-display,从Project面板将Input Field游戏对象拖入Display Changed Content (Script)组件的公共**Input Field **变量中:

图片

  1. Hierarchy中选择Input Field,将End Edit (String)事件添加到Input Field (Script)组件的事件处理程序列表中。点击加号(+)按钮添加事件处理程序槽,并将Text-display游戏对象拖入Object槽。

  2. Function下拉菜单中选择DisplayChangedTextContent,然后选择DisplayNewValue方法。

  3. 保存并运行Scene。每次用户输入新文本然后按下 Tab 或 Enter 键时,End Edit 事件都会触发,您将在屏幕上看到一条新的内容文本消息以红色显示。

它是如何工作的...

Unity 中交互式文本输入的核心是Input Field组件的责任。这需要一个对UI Text游戏对象的引用。为了更容易看到文本可以输入的位置,Text Input(就像按钮一样)包括一个默认的圆角矩形图像,背景为白色。

通常有三个Text游戏对象与用户文本输入相关:

  • 静态提示文本,在我们的配方中显示给用户文本 Name:。

  • 淡化的占位文本,提示用户在哪里以及应该输入什么。

  • 实际显示给用户的可编辑文本对象(带有字体和颜色设置),显示用户输入的字符。

您创建了一个Input Field游戏对象,它自动提供了两个子Text游戏对象,分别命名为PlaceholderText。这些代表淡化的占位文本和可编辑文本,您将其重命名为Text-input。然后您添加了第三个Text游戏对象,Text-prompt,其中包含文本 Name:。

Input Field组件内置的脚本为我们做了很多工作。在运行时,会创建一个Text****-Input输入光标游戏对象——显示闪烁的垂直线以告知用户下一个字母将输入的位置。如果没有文本内容,将显示淡化的占位文本。一旦输入了任何字符,占位符将被隐藏,输入的字符将以黑色文本显示。然后,如果所有字符都被删除,占位符将再次出现。

然后,您添加了一个红色的第四个文本GameObject Text-display,以确认用户在输入字段中最后输入的内容。您创建了DisplayChangedTextContentC#脚本类,并将其作为组件添加到Text-displayGameObject 中。您将输入****字段GameObject 链接到脚本组件的输入字段公共变量(因此脚本可以访问用户输入的文本内容)。

您注册了输入字段结束编辑事件处理程序,以便每次用户完成文本编辑(通过按Enter键)时,您的DisplayChangedTextContent脚本对象的DisplayNewValue()方法被调用(执行),并将Text-display的红色文本内容更新,以告诉用户新编辑的文本内容。

还有更多...

有一些细节您不要错过。

限制可以输入的内容类型

输入字段(脚本)内容类型可以设置为(限制)几种特定的文本输入类型,包括电子邮件地址、仅整数或小数数字,或密码文本(每个输入字符都显示为星号)。有关输入字段的更多信息,请参阅 Unity 手册页面:docs.unity3d.com/Manual/script-InputField.html

通过切换组使用切换和单选按钮

用户做出选择,通常,这些选择有两种选项(例如,声音开或关),或者有时有几种可能性(例如,难度级别为简单/中等/困难)。Unity UI 切换允许用户打开和关闭选项;当与切换组结合使用时,它们将选择限制为组中的某个项目。在本示例中,我们将首先探索基本的切换,以及一个响应值变化的脚本。然后,我们将扩展示例以说明切换组,并使用圆形图像进行样式化,使其看起来更像传统的单选按钮。

截图显示了当场景运行时,按钮状态变化在控制台面板中的记录方式:

图片

准备工作

对于本示例,我们在02_09文件夹中的UI Demo Textures文件夹中准备了您需要的图像。

如何操作...

要向用户显示开/关 UI 切换,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 检查器中,将主相机的背景颜色更改为白色。

  3. UI 切换添加到场景中。

  4. 对于切换GameObject 的标签子项,将文本属性设置为“First Class”。

  5. 将名为ToggleChangeManager的 C# 脚本类实例添加到切换GameObject 中:

using UnityEngine; 
using UnityEngine.UI; 

public class ToggleChangeManager : MonoBehaviour { 
   private Toggle toggle; 

   void Awake () { 
         toggle = GetComponent<Toggle>();     
   } 

   public void PrintNewToggleValue() { 
         bool status = toggle.isOn; 
         print ("toggle status = " + status); 
   } 
} 
  1. 选择切换GameObject 后,将On Value Changed事件添加到切换 (脚本)组件的事件处理程序列表中,点击加号(+)按钮添加事件处理程序槽,并将切换拖入对象槽。

  2. Function 下拉菜单中选择 ToggleChangeManager,然后选择 PrintNewToggleValue 方法。

  3. 保存并运行 Scene。每次检查或取消检查 Toggle GameObject 时,On Value Changed 事件都会触发,你将看到我们的脚本在 Console 窗口中打印出新的布尔值(真/假)的文本消息。

它是如何工作的...

当你创建一个 Unity UI Toggle GameObject 时,它会自动包含几个子 GameObject —— BackgroundCheckmark 和文本 Label。除非我们需要以特殊方式设计 Toggle 的外观,否则只需要简单地编辑文本 Label,以便用户知道这个 Toggle 将会开启/关闭哪个选项或功能。

ToggleChangeManager C# 类的 Awake() 方法在脚本实例所在的 GameObject 中缓存了对 Toggle 组件的引用。当游戏运行时,每次用户点击 Toggle 来更改其值时,都会触发一个 On Value Changed 事件。然后我们注册了 PrintNewToggleValue() 方法,该方法将在此类事件发生时执行。此方法检索,然后打印到 Console 面板,Toggle 的新布尔值(真/假)。

更多内容...

有一些细节你不应该错过。

添加更多 Toggles 和 Toggle Group 以实现互斥的单选按钮

Unity UI Toggles 也是实现一组互斥选项的基础组件,这些选项的风格类似于 单选按钮。我们需要将相关的单选按钮 UI Toggles 组合在一起,这样当其中一个开启时,组中的其他所有选项都会关闭。

如果我们想要遵循通常的圆形单选按钮样式,而不是默认的方形 UI Toggle 图像,我们也需要更改视觉外观:

图片

要在创建的项目中创建一组以单选按钮视觉风格相关的切换,请执行以下操作:

  1. UI Demo Textures 文件夹导入到项目中。

  2. Toggle GameObject 中移除 C# 脚本类 ToggleChangeManager 组件。

  3. Toggle GameObject 重命名为 Toggle-easy

  4. 选择 Canvas GameObject,然后在 Inspector 中添加一个 UI | Toggle Group 组件。

  5. 当选择 Toggle-easy GameObject 时,在 Inspector 中将 Canvas GameObject 拖动到 Toggle (Script) 组件的 Toggle Group 属性。

  6. Label 文本更改为 Easy,并给这个 GameObject 添加一个名为 Easy 的新标签。

  7. 选择 Toggle-easyBackground 子 GameObject,并在 Image (Script) 组件中,将 UIToggleBG 图像拖动到 Source Image 属性(一个圆形轮廓)。

  8. 确保检查Toggle(脚本)组件的Is On属性,然后选择Toggle-easyCheckmark子 GameObject。在Image(脚本)组件中,将UIToggleButton图像拖动到Source Image属性(一个实心圆)中。

在我们提供给用户的三个选项(简单、中等和困难)中,我们将简单选项设置为默认选中项。因此,我们需要检查其 Is On 属性,这将导致其勾选图像被显示。

为了使这些切换看起来更像单选按钮,每个切换的背景都设置为UIToggleBG的圆形轮廓图像,而勾选标记(显示处于开启状态的切换)则填充了名为UIToggleButton的圆形图像。

  1. 复制Toggle-easyGameObject,将副本命名为Toggle-medium。将Rect Transform属性的Pos Y设置为-25(因此,这个副本位于简单选项下方),并取消选中Toggle(脚本)组件的Is On属性。给这个副本添加一个新的标签名为Medium

  2. 复制Toggle-mediumGameObject,将副本命名为Toggle-hard。将Rect Transform属性的Pos Y设置为-50(因此,这个副本位于中等选项下方)。给这个副本添加一个新的标签名为Hard

  3. RadioButtonManagerC#脚本类的一个实例添加到画布GameObject 中:

using UnityEngine; 
using System.Collections; 
using UnityEngine.UI; 

public class RadioButtonManager : MonoBehaviour { 
  private string currentDifficulty = "Easy"; 

  public void PrintNewGroupValue(Toggle sender){ 
    // only take notice from Toggle just switched to On 
    if(sender.isOn){ 
      currentDifficulty = sender.tag; 
      print ("option changed to = " + currentDifficulty); 
    } 
  } 
}
  1. 项目面板中选择Toggle-easyGameObject。现在执行以下操作:

    • 由于我们基于第一类切换功能,因此已经有一个值改变时事件添加到了切换(脚本)组件的事件处理器列表中。将画布GameObject 拖动到目标对象槽中(在显示仅运行时的下拉菜单下)。

    • 函数下拉菜单中选择RadioButtonManager,然后选择PrintNewGroupValue方法。

    • Toggle参数槽中,它最初是None (Toggle),拖动Toggle-easyGameObject。您的On Value Changed设置在检查器中应如下所示:

  1. 对于Toggle-mediumToggle-hardGameObject 也执行相同的操作——这样每个切换对象都会调用画布GameObject 中名为RadioButtonManager的 C#脚本组件的PrintNewGroupValue(...)方法,并将自身作为参数传递。

  2. 保存并运行场景。每次您选中三个单选按钮之一时,值改变时事件都会触发,您将看到我们的脚本在控制台窗口中打印出一条新的文本消息,指出刚刚设置为 true(Is On)的Toggle(单选按钮)的标签。

通过向画布添加切换组组件,并让每个切换GameObject 链接到它,三个单选按钮可以告诉切换组它们已被选中,然后组中的其他成员将被取消选中。如果你在同一场景中有多个单选按钮组,一种策略是将切换组组件添加到一个切换上,并让所有其他切换都链接到这个切换。

我们将当前单选按钮的值(最后被切换为开启的那个)存储在currentDifficulty类的属性中。由于在方法外部声明的变量会被记住,例如,我们可以添加一个公共方法,如GetCurrentDifficulty(),它可以将当前值告诉其他脚本对象,无论用户上次更改选项有多久。

创建文本和图像图标 UI 下拉菜单

在上一个配方中,我们使用切换组创建了单选按钮,向用户展示多个选项中的一个。提供一系列选择的一种另一种方式是使用下拉菜单。Unity 提供了UI 下拉菜单控件来实现此类菜单。在这个配方中,我们将为用户提供一个下拉菜单,用于选择一副扑克牌的花色(红心、梅花、方块或黑桃)。

注意,默认创建的UI 下拉菜单包含一个可滚动区域,以防没有足够的空间显示所有选项。我们将学习如何删除 GameObject 和组件,以减少在不需要此类功能时的复杂性。

然后,我们将学习如何为每个菜单选项添加图标图像,如图表所示:

图片

准备工作

对于这个配方,我们在02_10文件夹中的 Images 文件夹中准备了你需要的图像。

如何操作...

要创建一个UI 下拉菜单控制 GameObject,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. UI 下拉菜单添加到场景中。

  3. 检查器中,对于下拉菜单(脚本)组件,将选项列表从“选项 A”、“选项 B”和“选项 C”更改为“红心”、“梅花”、“方块”和“黑桃”。你需要点击加号(+)按钮为第四个选项“黑桃”添加空间。

  4. 将名为DropdownManager的 C#脚本类实例添加到下拉菜单GameObject 中:

using UnityEngine; 
using UnityEngine.UI; 

public class DropdownManager : MonoBehaviour  { 
    private Dropdown dropdown; 

    private void Awake() { 
        dropdown = GetComponent<Dropdown>(); 
    } 

    public void PrintNewValue() { 
        int currentValue = dropdown.value; 
        print ("option changed to = " + currentValue); 
   } 
} 
  1. 在选择下拉菜单GameObject 后,将一个值改变事件添加到下拉菜单(脚本)组件的事件处理程序列表中,点击加号(+)按钮添加事件处理程序槽,并将下拉菜单拖入对象槽中。

  2. 函数下拉菜单中选择DropdownManager,然后选择PrintNewValue方法。

  3. 保存并运行场景。每次更改下拉菜单时,值改变事件都会触发,你将看到我们的脚本在控制台窗口中打印出一条新的文本消息,指出所选下拉菜单值的整数索引(第一个选项为0,第二个选项为1,依此类推):

图片

  1. Project面板中选择DropdownTemplate子 GameObject,并在其Rect Transform中将其高度减少到50。当你运行Scene时,你应该看到一个可滚动区域,因为不是所有选项都适合在Template的高度内:

  1. 删除Template GameObject 的Scrollbar子组件,并移除Template GameObject 的Scroll Rect (Script)组件。当你现在运行Scene时,你将只能看到前两个选项(HeartsClubs),无法访问其他两个选项。当你确定你的Template的高度足以容纳所有选项时,你可以安全地移除这些可滚动选项,以简化场景中的 GameObject。

它是如何工作的...

当你创建一个 Unity UI DropDown GameObject 时,它会自动包含几个组件和子 GameObject,例如 LabelArrowTemplate(以及ViewPortScrollbar等)。Dropdowns通过为Dropdown (Script)组件中列出的每个Options复制的Template GameObject 来工作。每个选项都可以提供TextSprite图像值。Template GameObject 的属性用于控制Dropdown的成千上万种可能的视觉样式和行为。

你首先替换了Dropdown (Script)组件中的默认选项(选项 A、选项 B 等)。然后你创建了一个 C#脚本类,DropdownManager,当它附加到你的Dropdown上,并且其PrintNewValue方法注册了On Value Changed事件,这意味着我们可以在用户更改选择时看到每个选项的Integer索引。项目索引值从零开始计数(如许多计算项目一样),所以第一个选项是0,第二个选项是1,依此类推。

由于默认创建的Dropdown GameObject 包含一个Scroll Rect (Script)组件和一个Scrollbar子 GameObject,当你减小Template的高度时,你仍然可以滚动选项。然后你移除了这些项目,这样你的Dropdown就不再有滚动功能了。

还有更多...

有一些细节你不希望错过。

将图片添加到下拉控制

Unity 使用两对项目来管理TextImages的显示:

  • Caption TextImage GameObjects 用于控制Dropdown当前选中项的显示方式——无论Dropdown是否被交互,我们总是能看到的部分。

  • Item TextImage GameObjects 是Template GameObject 的一部分,它们定义了当Dropdown菜单项被显示时,每个选项如何作为一行显示——当用户积极与Dropdown GameObject 交互时显示的行。

因此,我们必须在两个地方(标题模板项)添加一个图像,以便使下拉菜单能够完全使用图像图标为每个选项工作。

下拉菜单中为每个文本项添加精灵图像,请按照以下步骤操作:

  1. 导入提供的Images文件夹。

  2. 检查器中,对于Dropdown (脚本)组件,对于列表中的每个选项 HeartsClubsDiamondsSpades,从项目面板中的card_suits文件夹(Heartshearts.png等)拖动相关的精灵图像。

  3. 项目面板中添加一个UI 图像,并将此图像作为下拉菜单游戏对象的子对象。

  4. hearts.png图像从项目面板拖动到Image (脚本)组件的源图像属性中,对于图像游戏对象。在矩形变换中将此图像的大小调整为25 x 25,并将其拖动到标签游戏对象的“Hearts”中的“H”字母上。

  5. 标签游戏对象移动到心形图像的右侧。

  6. 项目面板中选择下拉菜单,将图像游戏对象拖动到Dropdown (脚本)组件的标题图像属性中。

  7. 启用模板游戏对象(通常它是禁用的)。

  8. 复制下拉菜单图像游戏对象子对象,并将其命名为项目图像。将此图像放在Dropdown-Template-Content-Item中的项目 背景项目勾选标记游戏对象之间(项目图像需要出现在白色的项目背景图像下方,否则它将被背景覆盖而不可见)。

  9. 由于下拉菜单中的项略小,请将项目图像的大小调整到其在矩形变换中的20 x 20

  10. 项目图像放置在项目文本的“Option A”中的“O”上方,然后将项目文本向右移动,以便图标和文本不在同一位置。

  11. 项目面板中选择下拉菜单,将项目图像游戏对象拖动到Dropdown (脚本)组件的项目图像属性中:

  1. 禁用模板游戏对象,然后运行场景以查看带有图标图像的每个菜单选项的下拉菜单

Unity UI 下拉菜单是强大的界面组件 - 在 Unity 手册中了解更多关于这些控件的信息,请参阅docs.unity3d.com/Manual/script-Dropdown.html.

显示雷达以指示对象的相对位置

雷达显示其他对象相对于玩家的位置,通常基于圆形显示,其中中心代表玩家,每个图形点表示对象距离玩家有多远以及相对方向。复杂的雷达显示将以不同颜色或形状的点图标显示不同类别的对象。

在截图中,我们可以看到两个红色的方块标记,指示玩家附*两个标记为 Cube 的红色立方体 GameObject 的相对位置,以及一个黄色圆圈标记指示标记为 Sphere 的黄色球体 GameObject 的相对位置。绿色的圆形雷达背景图像给人一种飞机控制塔雷达或类似的感觉:

图片

准备工作

对于这个食谱,我们在02_11文件夹中准备了一个名为 Images 的文件夹,其中包含了您需要的图像。

如何操作...

要创建一个雷达以显示对象的相对位置,请按照以下步骤操作:

  1. 创建一个新的 3D 项目,包含一个带有纹理的地形。通过选择菜单:资产 | 导入包 | 环境,导入环境标准资产包的内容。

    1. 通过导航到创建 | 3D 对象 | 地形菜单创建一个地形。

    2. 地形的大小设置为 20 x 20,放置在(-10, 0, -10)的位置——这样其中心就在(0, 0, 0)

    图片

    1. 使用沙色选项纹理绘制你的地形,如图所示。您需要在地形组件中选择画笔工具,然后点击编辑纹理按钮,并从导入的环境资产中选择SandAlbedo纹理:

图片

  1. 导入提供的文件夹Images

  2. 位置(2, 0.5, 2)创建一个 3D 立方体 GameObject。创建一个名为Cube的标签,并将这个 GameObject 标记为这个新标签。使用从项目面板拖拽的红色图像icon32_square_yellow,将这个 GameObject 纹理化为这个图像。

  3. 复制立方体GameObject,并将这个新立方体移动到位置(6, 0.5, 2)

  4. 位置(0, 0.5, 4)创建一个 3D 球体 GameObject。创建一个名为Sphere的标签,并将这个 GameObject 标记为这个新标签。使用名为icon32_square_yellow的红色图像对这个 GameObject 进行纹理化。

  5. 角色标准资产包导入到您的项目中。

  6. 项目面板的Standard Assets文件夹中,将预制件ThirdPersonController拖拽到场景中,并将其放置在(0, 1, 0)的位置。

  7. 第三人称控制器GameObject 标记为Player(选择这个内置标签意味着我们将添加的摄像机将自动跟踪这个玩家对象,而无需我们手动设置摄像机的目标)。

  8. 删除主摄像机GameObject。

  9. 摄像机标准资产包导入到您的项目中。

  10. 项目面板的Standard Assets文件夹中,将预制件Multi-PurposeCameraRig拖拽到场景中。

  11. 层次结构面板中,向场景添加一个 UI | 原始图像 GameObject,命名为RawImage-radar

  12. 确保在 Hierarchy 面板中选择 RawImage-radar GameObject。从 Project 面板中的 Images 文件夹,将 radarBackground 图像拖入 Raw Image (Script) 的公共属性 Texture

  13. Rect Transform 中,使用 Anchor Presets 项将 RawImage-radar 定位于左上角。然后设置宽度和高度均为 200 像素。

  14. 创建一个新的 UI RawImage,命名为 RawImage-blip。将其分配为从 Project 面板中的 yellowCircleBlackBorder 纹理图像文件。在 Project 面板中,创建一个新的空预制资产文件,命名为 blip-sphere,并将 RawImage-blip GameObject 拖入此预制以存储所有其属性。

  15. 将 GameObject RawImage-blip 的纹理设置为从 Project 面板中的 redSquareBlackBorder。在 Project 面板中,创建一个新的空预制资产文件,命名为 blip-cube,并将 RawImage-blip GameObject 拖入此预制以存储所有其属性。

  16. Hierarchy 中删除 RawImage-blip GameObject。

  17. 创建一个名为 Radar 的 C# 脚本类,包含以下代码,并将其实例作为脚本组件添加到 RawImage-radar GameObject 中:

using UnityEngine; 
using UnityEngine.UI; 

public class Radar : MonoBehaviour { 
   public float insideRadarDistance = 20; 
   public float blipSizePercentage = 5; 
   public GameObject rawImageBlipCube; 
   public GameObject rawImageBlipSphere; 
   private RawImage rawImageRadarBackground; 
   private Transform playerTransform; 
   private float radarWidth; 
   private float radarHeight; 
   private float blipHeight; 
   private float blipWidth; 

   void Start() { 
         rawImageRadarBackground = GetComponent<RawImage>(); 
         playerTransform = GameObject.FindGameObjectWithTag("Player").transform; 
         radarWidth = rawImageRadarBackground.rectTransform.rect.width; 
         radarHeight = rawImageRadarBackground.rectTransform.rect.height; 
         blipHeight = radarHeight * blipSizePercentage / 100; 
         blipWidth = radarWidth * blipSizePercentage / 100; 
   } 

   void Update() { 
         RemoveAllBlips(); 
         FindAndDisplayBlipsForTag("Cube", rawImageBlipCube); 
         FindAndDisplayBlipsForTag("Sphere", rawImageBlipSphere); 
   } 

   private void FindAndDisplayBlipsForTag(string tag, GameObject prefabBlip) { 
         Vector3 playerPos = playerTransform.position; 
         GameObject[] targets = GameObject.FindGameObjectsWithTag(tag); 
         foreach (GameObject target in targets) { 
               Vector3 targetPos = target.transform.position; 
               float distanceToTarget = Vector3.Distance(targetPos, playerPos); 
               if ((distanceToTarget <= insideRadarDistance)) 
                CalculateBlipPositionAndDrawBlip (playerPos, targetPos, prefabBlip); 
         } 
   }  

    private void CalculateBlipPositionAndDrawBlip (Vector3 playerPos, Vector3 targetPos, GameObject prefabBlip) { 
         Vector3 normalisedTargetPosition = NormaizedPosition(playerPos, targetPos); 
         Vector2 blipPosition = CalculateBlipPosition(normalisedTargetPosition); 
         DrawBlip(blipPosition, prefabBlip); 
   } 

   private void RemoveAllBlips() { 
         GameObject[] blips = GameObject.FindGameObjectsWithTag("Blip"); 
         foreach (GameObject blip in blips) 
               Destroy(blip); 
   } 

   private Vector3 NormaizedPosition(Vector3 playerPos, Vector3 targetPos) { 
         float normalisedyTargetX = (targetPos.x - playerPos.x) / insideRadarDistance; 
         float normalisedyTargetZ = (targetPos.z - playerPos.z) / insideRadarDistance; 
         return new Vector3(normalisedyTargetX, 0, normalisedyTargetZ); 
   } 

   private Vector2 CalculateBlipPosition(Vector3 targetPos) { 
         float angleToTarget = Mathf.Atan2(targetPos.x, targetPos.z) * Mathf.Rad2Deg; 
         float anglePlayer = playerTransform.eulerAngles.y; 
         float angleRadarDegrees = angleToTarget - anglePlayer - 90; 
         float normalizedDistanceToTarget = targetPos.magnitude; 
         float angleRadians = angleRadarDegrees * Mathf.Deg2Rad; 
         float blipX = normalizedDistanceToTarget * Mathf.Cos(angleRadians); 
         float blipY = normalizedDistanceToTarget * Mathf.Sin(angleRadians); 
         blipX *= radarWidth / 2; 
         blipY *= radarHeight / 2; 
         blipX += radarWidth / 2; 
         blipY += radarHeight / 2; 
         return new Vector2(blipX, blipY); 
   } 

   private void DrawBlip(Vector2 pos, GameObject blipPrefab) { 
         GameObject blipGO = (GameObject)Instantiate(blipPrefab); 
         blipGO.transform.SetParent(transform.parent); 
         RectTransform rt = blipGO.GetComponent<RectTransform>(); 
         rt.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Left, pos.x, blipWidth); 
         rt.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Top, pos.y, blipHeight); 
   } 
} 

运行你的游戏。你将在雷达上看到两个红色方块和一个黄色圆圈,显示红色方块和黄色球体的相对位置。如果你移动得太远,标记点将消失。

它是如何工作的...

屏幕上显示雷达背景。此圆形图像的中心代表玩家角色的位置。你创建了两个预制,一个用于表示雷达范围内找到的每个红色方块的红方形图像,另一个用于表示黄色球体的黄色圆形 GameObject。

Radar C# 脚本类已添加到雷达 UI Image GameObject 中。此类定义了四个公共变量:

  • insideRadarDistance:此值定义了场景中对象可能离玩家多远,仍然可以包含在雷达上(距离超过此距离的对象将不会显示在雷达上)。

  • blipSizePercentage:此公共变量允许开发者决定每个标记点的大小,作为雷达图像的比例。

  • rawImageBlipCuberawImageBlipSphere:这些是对预制 UI RawImages 的引用,用于在雷达上视觉上指示方块和球体的相对距离和位置。

由于此菜谱的代码中有很多内容,每个方法将在自己的部分中进行描述。

Start() 方法

Start()方法首先缓存雷达背景图像的原始图像的引用。然后缓存玩家角色(标记为Player)的变换组件的引用。这使得脚本对象能够知道玩家角色在每个帧中的位置。接下来,缓存雷达图像的宽度和高度,因此可以根据这个背景雷达图像的大小计算雷达标记的相对位置。最后,使用blipSizePercentage公共变量计算每个雷达标记的大小(blipWidthblipHeight)。

Update()方法

Update()方法调用RemoveAllBlips()方法,该方法移除任何可能当前显示的立方体和球体的旧原始图像UI GameObject。如果我们不在创建新标记之前移除旧标记,那么你会在新标记在不同位置创建时看到每个标记后面的“尾巴”——这实际上可能是一个有趣的效果。

接下来,调用了两次FindAndDisplayBlipsForTag(...)方法。首先,为了在雷达上用rawImageBlipCube预制体表示标记为Cube的对象,然后再次为标记为Sphere的对象调用,用rawImageBlipSphere预制体在雷达上表示。正如你所预期的,雷达的大部分工作将由FindAndDisplayBlipsForTag(...)方法来完成。

这段代码是创建雷达的简单方法。从Update()方法中为每一帧重复调用FindGameObjectWithTag("Blip")是非常低效的。在真实游戏中,将所有创建的雷达标记缓存到ListArrayList等数据结构中会更好,然后每次只需简单地遍历这个列表。

调用 FindAndDisplayBlipsForTag(...)方法。

此方法输入两个参数:要搜索的对象的字符串标记,以及要在雷达上显示的任何此类标记对象范围内的RawImage预制体的引用。

首先,从缓存的玩家变换变量中检索玩家角色的当前位置。接下来,构建一个数组,引用场景中具有提供的标记的所有 GameObject。遍历这个 GameObject 数组,并对每个 GameObject 执行以下操作:

  • 获取目标 GameObject 的位置。

  • 计算从目标位置到玩家位置的距离。

  • 如果这个距离在范围内(小于或等于insideRadarDistance),则调用CalculateBlipPositionAndDrawBlip(...)方法。

CalculateBlipPositionAndDrawBlip (...)方法

此方法输入三个参数:玩家的位置,目标的位置,以及要绘制的雷达标记预制体的引用。

现在需要三个步骤来使此对象的雷达标记出现在雷达上:

  1. 目标的归一化位置是通过调用NormalizedPosition(...)来计算的。

  2. 通过调用 CalculateBlipPosition(...) 从这个归一化位置计算出雷达上小图标的位置。

  3. RawImage 小图标通过调用 DrawBlip(...) 并传递小图标位置以及要创建在该位置的 RawImage 预制件的引用来显示。

NormalisedPosition(...) 方法

NormalizedPosition(...) 方法输入玩家的角色位置和目标 GameObject 位置。它的目标是输出目标相对于玩家的相对位置,返回一个包含 X、Y 和 Z 值的三元组的 Vector3 对象。请注意,由于雷达是二维的,我们忽略目标 GameObject 的 Y 值。因此,此方法返回的 Vector3 对象的 Y 值始终为 0。例如,如果目标正好位于玩家所在的位置,返回的 X、Y、Z Vector3 对象将是 (0, 0, 0)

由于我们知道目标 GameObject 不比玩家的角色远于 insideRadarDistance,我们可以通过找到每个轴上目标到玩家的距离,然后除以 insideRadarDistance 来计算 -1 ... 0 ... +1 范围内的 X 和 Z 轴值。X 值为 -1 表示目标完全在玩家左侧(距离等于 insideRadarDistance),+1 表示它完全在右侧。值为 0 表示目标具有与玩家角色相同的 X 位置。同样,对于 Z 轴的 -1 ... 0 ... +1 值(这个轴表示一个对象在我们前方或后方有多远,这将被映射到我们的雷达中的垂直轴)。

最后,此方法构建并返回一个新的 Vector3 对象,包含计算出的 X 和 Z 的归一化值,以及 Y 值为零。

归一化位置

归一化值是指以某种方式简化的值,因此上下文已被抽象化。在这个菜谱中,我们感兴趣的是对象相对于玩家的位置。因此,我们的正常形式是获取目标在 -1+1 范围内的 X 和 Z 位置值,对于每个轴。由于我们只考虑 insideRadarDistance 值内的 GameObject,我们可以直接将这些归一化目标位置映射到我们 UI 中雷达图像的位置。

CalculateBlipPosition(...) 方法

首先,我们计算 angleToTarget:从 (0, 0, 0) 到我们的归一化目标位置的角。

接下来,我们计算 anglePlayer:玩家角色面向的角度。这个菜谱使用了旋转的偏航角,即围绕 Y 轴的旋转,也就是说,角色控制器面向的方向。这可以在 GameObject 的 transform 组件的 eulerAngles 组件的 Y 分量中找到。你可以想象从上方向下看角色控制器,看看它们面向的方向——这就是我们试图通过雷达图形显示的方向。

我们期望的雷达角度(angleRadarDegrees变量)是通过从目标与玩家之间的角度中减去玩家的方向角度来计算的,因为雷达显示的是从玩家面向的方向到目标对象的相对角度。在数学上,零角度表示东方方向。为了纠正这一点,我们还需要从这个角度中减去90度。

然后将角度转换为弧度,因为这是 Unity 三角函数方法所必需的。然后我们将Sin()Cos()的结果乘以我们的归一化距离,分别计算出 X 和 Y 值(见以下图表):

在此图中,alpha 是玩家与目标对象之间的角度,“a”是邻边,“h”是斜边,“o”是与角度相对的边。

我们最终的位置值需要以像素长度表示,相对于雷达的中心。因此,我们将blipXblipY值乘以雷达宽度和高度的一半;注意我们只乘以宽度的一半,因为这些值是相对于雷达中心的。然后我们将雷达图像宽度和高度的一半加到 blipX/Y 值上。因此,这些值现在相对于中心定位。

最后,创建并返回一个新的Vector2对象,返回这些最终计算的 X 和 Y 像素值,用于我们闪烁图标的位置。

DrawBlip()方法

DrawBlip()方法接受闪烁位置(作为一个Vector2 X, Y 对)的输入参数,以及要创建在雷达上该位置的RawImage预制件的引用。

从预制件中创建(实例化)一个新的 GameObject,并将其作为父对象添加到radarGameObject(脚本对象也是其组件)。从新创建的RawImageGameObject 的Rect Transform中检索引用。调用 Unity 的RectTransform方法

SetInsetAndSizeFromParentEdge(...)  导致闪烁 GameObject 被定位在雷达图像提供的水*和垂直位置上,无论背景雷达图像在游戏面板中的位置如何。

还有更多...

有一些细节你不希望错过。

适应对象高度和不透明障碍物

此雷达脚本在玩家周围扫描 360 度,并且只考虑 X-Z *面上的直线距离。因此,此雷达中的距离不受玩家和目标 GameObject 之间任何高度差异的影响。该脚本可以修改为忽略高度与玩家高度差异超过某个阈值的对象。

此外,正如所展示的,这种配方雷达能够穿透一切,即使玩家和目标之间存在障碍。该配方可以通过使用光线投射技术来扩展,以避免显示被遮挡的目标。有关光线投射的更多详细信息,请参阅 Unity 脚本参考:docs.unity3d.com/ScriptReference/Physics.Raycast.html.

第三章:库存 UI

本章将涵盖以下主题:

  • 创建一个简单的 2-D 迷你游戏 - SpaceGirl

  • 显示带有携带和不携带文本的单个物体拾取

  • 显示带有携带和不携带图标的单个物体拾取

  • 以多个状态图标显示相同物体的多个拾取

  • 使用面板在视觉上勾勒库存 UI 区域和单个物品

  • 创建一个 C#库存槽显示 UI 脚本组件

  • 使用 UI 网格布局组(带滚动条!)泛化多个图标显示

  • 通过动态的List<>脚本拾取对象列表显示不同物体的多个拾取

  • 通过动态的Dictionary<>字典和枚举拾取类型,以文本总数的形式显示多个不同物体的拾取

简介

许多游戏涉及玩家收集物品或从物品选择中做出选择。例如,收集钥匙打开门,收集武器弹药,以及从施法咒语集合中选择。

本章中的食谱提供了一系列解决方案,用于向玩家显示他们是否携带了物品,是否允许携带多个相同物品,以及他们有多少个。

整体情况

实现库存的软件设计的两部分与以下内容相关,首先,我们选择如何表示关于库存物品的数据(即存储数据的类型和结构)以及其次,我们选择如何向玩家显示库存物品的信息(UI)。

此外,虽然不是严格意义上的库存物品,但玩家的属性,如剩余生命、健康和时间剩余,也可以围绕我们在本章中提出的相同概念进行设计。

我们首先需要考虑任何特定游戏中不同库存物品的性质:

  • 单个物品:

    • 示例:一个级别的唯一钥匙,我们的魔法盔甲套装

    • 数据类型:bool(布尔值 - true/false

    • UI:无(如果不携带)或显示携带的文本/图像

      • 或者,如果我们希望向玩家突出显示有携带此物品的选项,那么我们可以显示一个文本string没有钥匙/钥匙,或者两个图像,一个显示空钥匙轮廓,另一个显示全色钥匙。
  • 连续物品:

    • 示例:剩余时间,健康,护盾强度

    • 数据类型:float(例如,0.00-1.00)或int(整数)比例

      (例如,0%到 100%)

    • UI:文本数字或图像进度条/饼图

  • 相同项目的两个或更多

    • 示例:剩余生命,或剩余箭头或子弹数量

    • 数据类型:int(整数 - 整数)

    • UI:文本计数或图像

  • 相关物品集合

    • 示例:不同颜色的钥匙打开相应颜色的门,不同强度的药水有不同的标题

    • 数据结构:用于通用物品类型的structclass(例如,Key类(颜色/成本/门打开标签字符串),存储为数组或List<>

    • UI:文本列表或图标列表/网格排列

  • 不同物品集合

    • 示例:钥匙、药水、武器、工具,都在同一个库存系统中

    • 数据结构:List<>Dictionary<>或对象的数组,每个物品类型可以是不同类的实例

本章中的食谱展示了前面提到的所有表示和 UI 显示方法。此外,在本章中,我们将学习如何创建和使用自定义排序层,以便完全控制哪些对象出现在其他对象之上或之下——当场景内容可以包含背景图像、拾取物、玩家角色等时,这一点非常重要。

这些食谱展示了库存物品的 C#数据表示范围以及 Unity UI 界面组件的范围,用于在运行时显示玩家库存的状态和内容。库存 UI 需要高质量的图形资产才能达到高质量的结果。以下是一些你可能希望探索的资产来源网站:

创建一个简单的 2D 迷你游戏 - 太空女孩

这个食谱展示了如何创建 2D 太空女孩迷你游戏,本章的所有食谱都是基于这个游戏。

图片

准备工作

对于这个食谱,我们在03_01文件夹中的 Sprites 文件夹中准备了所需的图像。我们还在这个文件夹中提供了一个名为Simple2DGame_SpaceGirl的 Unity 游戏包,作为完成的游戏。

如何操作...

要创建简单的 2D 太空女孩迷你游戏,请按照以下步骤操作:

  1. 创建一个新的空 2D 项目。

  2. 导入提供的Sprites文件夹到你的项目中。

  3. 由于这是一个 2D 项目,每个精灵图像的类型应该是Sprite (2D and UI)。通过在项目面板中选择精灵,然后在检查器中检查属性纹理类型来检查这一点。如果你需要更改类型,从下拉菜单中更改它,然后点击应用按钮。

  4. 将 Unity 玩家屏幕大小设置为800 x 600:在游戏面板的下拉菜单中选择此分辨率。如果800 x 600不是提供的分辨率,请点击加号+按钮,将其创建为面板的新分辨率。

  5. 显示当前 Unity 项目的标签属性。选择菜单编辑 | 项目设置 | 标签和层。或者,如果你已经在编辑一个 GameObject,你可以从检查器面板顶部的层下拉菜单中选择添加层...菜单,位于静态true/false切换旁边的层。

  6. 使用展开/收缩三角形工具收缩 Tags and Layers,并展开 Sorting Layers。使用加号 + 按钮添加两个新的排序层,如图所示:首先,添加一个名为 Background 的,然后添加一个名为 Foreground 的。顺序很重要,因为 Unity 将在层中按顺序绘制项目

    在列表的上方对项目进行排序,比列表中较早的项目更靠前。您可以通过单击并拖动位置控制:位于每行“Layer”一词左侧的宽等于号(=)图标来重新排列层顺序:

图片

  1. 从 Project 面板(在 Sprites 文件夹中)将 background_blue sprite 拖动到 GameHierarchy 面板中,以创建当前场景的 GameObject。将此 GameObject 的 Position 设置为 (0,0,0)。它应该完全覆盖游戏面板(分辨率为 800 x 600)。

  2. 将 GameObject background-blue 的 Sorting Layer 设置为 Background(在 Sprite Renderer 组件中):

图片

  1. 将 sprite star 从 Project 面板(在 Sprites 文件夹中)拖动到 GameHierarchy 面板中,以创建当前场景的 GameObject:

    • 创建一个新的标签,Star,并将此标签分配给 GameObject star(标签的创建方式与创建排序层相同)。

    • 将 GameObject star 的排序层设置为前景(在 Sprite Renderer 组件中)。

    • 向 GameObject star 添加一个 Box Collider 2D(添加组件 | Physics 2D | Box Collider 2D)并检查 Is Trigger,如图所示:

图片

  1. Project 面板(在 Sprites 文件夹中)将 girl1 sprite 拖动到 GameHierarchy 面板中,以在当前场景中创建玩家的角色 GameObject。将此 GameObject 重命名为 player-girl1

  2. 将 GameObject player-girl1 的排序层设置为前景。

  3. 向 GameObject player-girl1 添加一个 Physics | Box Collider 2D 组件。

  4. 向 GameObject player-girl1 添加一个 Physics 2D | Rigid Body 2D 组件。将其重力比例设置为零(这样它就不会因为模拟重力而掉落屏幕),如图所示:

图片

  1. 为你的脚本创建一个新的文件夹名为 _Scripts

  2. _Scripts 文件夹中创建以下 C# 脚本 PlayerMove,并将其作为组件添加到 Hierarchy 中的 GameObject player-girl1:

using UnityEngine; 
using System.Collections; 

public class PlayerMove : MonoBehaviour { 
  public float speed = 10; 
  private Rigidbody2D rigidBody2D;
  private Vector2 newVelocity;

void Awake(){ 
rigidBody2D = GetComponent<Rigidbody2D>(); 
} 

void Update() {
  float xMove = Input.GetAxis("Horizontal");
  float yMove = Input.GetAxis("Vertical");

  float xSpeed = xMove * speed;
  float ySpeed = yMove * speed;

  newVelocity = new Vector2(xSpeed, ySpeed);
}

void FixedUpdate() {
  rigidBody2D.velocity = newVelocity;
}

}
  1. 保存场景(命名为主场景并将其保存到名为 _Scenes 的新文件夹中)。

它是如何工作的...

你在场景中使用了 girl1 精灵创建了一个玩家角色,并添加了一个PlayerMove类脚本的实例。你还创建了一个带有星号标签的星形 GameObject(一个拾取物),它有一个 2D 盒子碰撞器,当玩家的角色碰到它时会触发碰撞。当你运行游戏时,player-girl1角色应该使用W A S D键、箭头键或摇杆来移动。存在一个newVelocity变量,它在Update()方法中根据输入每帧更新。然后这个Vector2值在FixedUpdate()方法中应用,成为 GameObject 的新速度。

Unity 将用户输入(如按键、箭头键和游戏控制器控制)映射到其Input类。Input类的两个特殊属性是水*轴和垂直轴——通过Input.GetAxis("Horizontal")Input.GetAxis("Vertical")方法访问。

管理你的输入映射:您可以通过菜单管理从不同的用户输入方法(键、鼠标、控制器等)到轴的映射:编辑 | 项目设置 | 输入

目前,如果player-SpaceGirl角色碰到星星,不会发生任何事情,因为这部分还没有被编写脚本。

你已经向场景中添加了一个背景(GameObject background-blue),由于它位于最远的排序层背景,所以它将位于所有内容的后面。您希望出现在背景前面的项目(到目前为止是玩家角色和星星)被放置在前景排序层上。

我们可以在docs.unity3d.com/Manual/class-TagManager.html了解更多关于 Unity 标签和层次的信息。

显示携带和不携带文本的单个对象拾取物

通常,最简单的库存情况是显示文本来告诉玩家他们是否携带了一个物品(或者没有)。我们将向 SpaceGirl 迷你游戏添加检测带有标签的 GameObject 碰撞的能力,并在屏幕上显示一条消息,说明是否收集了星星:

在配方的末尾,在还有更多...部分,我们将学习如何调整此配方以维护收集星星的整数总数,适用于有很多星星可以收集的游戏版本。

准备工作

对于这个配方,我们在03_02文件夹中准备了一个名为 Fonts 的文件夹。

此配方假设您是从本章的第一个配方中设置的Simple2Dgame_SpaceGirl项目开始的。因此,复制该项目,并在副本上工作。

如何做到这一点...

要显示文本以通知用户携带单个拾取物的状态,请按照以下步骤操作:

  1. 从一个新的Simple2Dgame_SpaceGirl迷你游戏副本开始。

  2. 添加一个 UI Text 对象(创建 | UI | 文本)。将其重命名为 Text-carrying-star。将其文本更改为 Carrying star: false。

  3. 将提供的 Fonts 文件夹导入到您的项目中。

  4. 检查器面板中,将 Text-carrying-star 的字体设置为Xolonium-Bold,并将其颜色设置为黄色。水*垂直居中文本,将高度设置为50,并将字体大小设置为32

  5. 编辑其 Rect Transform,在按住Shift + Alt(以设置中心点和位置)的同时,选择顶部拉伸框:

截图

  1. 您的文本现在应位于游戏面板的中间顶部,其宽度应拉伸以匹配整个面板,如本食谱介绍中的截图所示。

  2. _Scripts文件夹中创建以下 C#脚本类PlayerInventory

using UnityEngine; 

public class PlayerInventory : MonoBehaviour { 
   private PlayerInventoryDisplay playerInventoryDisplay; 
   private bool carryingStar = false;  

   void Awake() { 
         playerInventoryDisplay = GetComponent<PlayerInventoryDisplay>(); 
   } 

   void Start() { 
         playerInventoryDisplay.OnChangeCarryingStar( carryingStar); 
   }  

   void OnTriggerEnter2D(Collider2D hit) { 
         if (hit.CompareTag("Star")) { 
               carryingStar = true; 
               playerInventoryDisplay.OnChangeCarryingStar( carryingStar); 
               Destroy(hit.gameObject); 
         } 
   } 
} 
  1. _Scripts文件夹中创建以下 C#脚本类PlayerInventoryDisplay
using UnityEngine; 
using UnityEngine.UI;

[RequireComponent(typeof(PlayerInventory))] 
public class PlayerInventoryDisplay : MonoBehaviour  { 
   public Text starText; 
   public void OnChangeCarryingStar(bool carryingStar) { 
         string starMessage = "no star :-("; 
         if(carryingStar) 
               starMessage = "Carrying star :-)"; 
         starText.text = starMessage; 
   } 
} 
  1. 层次结构中向player-SpaceGirlGameObject 添加脚本类PlayerInventoryDisplay的实例。

注意,由于PlayerInventoryDisplay类包含RequireComponent(),因此会自动将脚本类PlayerInventory的实例添加到 GameObject player-SpaceGirl中。

  1. 层次结构视图中选择player-SpaceGirlGameObject。然后,从检查器中访问玩家库存显示(脚本)组件,并将星星文本公共字段填充为 GameObject Text-carrying-star,如截图所示。

截图

  1. 当您播放场景时,将角色移动到星星后,星星应该消失,屏幕上的UI Text消息应更改为 Carrying star 😃:

截图

它是如何工作的...

您创建了一个UI Text GameObject Text-carrying-star,用于向玩家显示一个文本消息,说明是否携带星星。您创建了两个脚本类,并将每个实例作为组件添加到玩家的player-SpaceGirl角色 GameObject 中:

  • 脚本类PlayerInventory检测玩家与星星的碰撞,更新内部变量以表示是否携带星星,并在检测到每次碰撞时请求更新 UI 显示。

  • 脚本类PlayerInventoryDisplay通过更新 Text-carrying-star UI Text GameObject 显示的文本消息来处理与用户的通信。

一种称为模型-视图-控制器模式MVC)的游戏设计模式(最佳实践方法)将更新 UI 的代码与更改玩家和游戏变量(如分数和库存项目列表)的代码分开。尽管这个食谱只有一个变量和一个更新 UI 的方法,但良好的游戏架构可以扩展以应对更复杂的游戏,因此,即使在这个游戏开始阶段,如果我们要使最终的游戏架构具有良好的结构和可维护性,那么多写一些代码和额外的脚本类通常是值得的。

这种设计模式的另一个优点是,将信息通过 UI 传达给用户的方法可以改变(例如,从文本到图标——见下一道菜谱!),而无需对脚本类PlayerInventory中的代码进行任何更改。

玩家库存脚本类

playerInventoryDisplay变量是对类PlayerInventoryDisplay的实例对象的引用。

布尔变量carryingStar表示玩家在任何时候是否携带星星;它被初始化为 false。

Method Awake()缓存了对兄弟组件playerInventoryDisplay的引用。

当场景开始时,通过Start()方法,我们调用脚本组件playerInventoryDisplayOnChangeCarryingStar(...)方法,传入carryingStar的初始值(为 false)。这确保了我们不是依赖于在设计时输入到UI Text对象 Text-carrying-star 中的文本,因此用户看到的 UI 总是由我们的运行时方法设置的。这避免了在代码中而不是在检查器面板中更改要显示给用户的单词时出现的问题——这会导致场景首次运行时和从脚本更新后屏幕文本不匹配。

在 Unity 游戏设计中,一个黄金法则是在一个以上的地方避免重复内容,因此我们避免需要维护相同内容的两个或多个副本。每个副本都是当某些但不是所有副本的值发生变化时可能出现维护问题的机会。

最大化使用预制件是这一原则在行动中的另一个例子。这也被称为 DRY 原则——不要重复自己。

每当玩家的角色与任何将其触发设置为 true 的对象发生碰撞时,都会向碰撞涉及的双方对象发送OnTriggerEnter2D()事件消息。OnTriggerEnter2D()消息作为参数传递,该参数是刚刚碰撞的对象内部Collider2D组件的引用。

我们玩家的OnTriggerEnter2D()方法测试与对象碰撞的标签字符串,以查看它是否有星星值。由于我们创建的 GameObject 星星设置了触发器,并且有标签星星,这个方法内部的 if 语句将检测与星星的碰撞并完成以下三个动作:

  • 布尔(标志)变量carryingStar被设置为 true

  • 脚本组件playerInventoryDisplayOnChangeCarryingStar(...)方法被调用,传入carryingStar的更新值

  • 刚刚发生碰撞的 GameObject 被销毁——也就是说,星星

布尔变量通常被称为标志。

使用布尔(true/false)变量来表示游戏状态中的某些功能是否为真或假是非常常见的。程序员通常将这些变量称为标志。因此,程序员可能会将 carryingStar 变量称为携带星星标志。

玩家库存显示脚本类

公共Text变量starText是对UI Text对象 Text-carrying-star 的引用。其值已在设计时通过拖放设置。

OnChangeCarryingStar(carryingStar)方法使用字符串变量starMessage的值更新 starText 的文本属性。此方法接受一个输入布尔参数carryingStar。字符串starMessage的默认值告诉用户玩家没有携带星星,但 if 语句测试carryingStar的值,如果为真,则消息将更改为通知玩家他们正在携带星星。

更多内容...

这里有一些细节您可能不想错过。

收集多个物品并显示携带的总数

通常,玩家可以收集多个拾取物品。在这种情况下,我们可以使用整数来表示收集的总数,并使用 UI Text 对象向用户显示此总数。让我们修改配方,让 SpaceGirl 能够收集大量的星星!

图片

要将此配方转换为显示收集到的星星总数的配方,请执行以下操作:

  1. 制作三到四个额外的星形GameObject,并将它们分散在场景中。这样玩家可以收集多个星星,而不仅仅是收集一个。

使用键盘快捷键 Ctrl + D(Windows)或 CMD + D(Mac)快速复制 GameObject。

  1. 将 C#脚本类PlayerInventory的内容更改为以下内容:
using UnityEngine; 
public class PlayerInventory : MonoBehaviour { 
   private PlayerInventoryDisplay playerInventoryDisplay; 
   private int totalStars = 0; 

   void Awake() { 
         playerInventoryDisplay = GetComponent<PlayerInventoryDisplay>(); 
   }  

   void Start() { 
        playerInventoryDisplay.OnChangeStarTotal(totalStars); 
   } 

   void OnTriggerEnter2D(Collider2D hit) { 
         if (hit.CompareTag("Star")) { 
            totalStars++; 
            playerInventoryDisplay.OnChangeCarryingStar(totalStars); 
            Destroy(hit.gameObject); 
         } 
   } 
} 
  1. 将 C#脚本类PlayerInventoryDisplay的内容更改为以下内容:
using UnityEngine; 
using UnityEngine.UI; 

[RequireComponent(typeof(PlayerInventoryTotal))] 
public class PlayerInventoryDisplay : MonoBehaviour { 
   public Text starText; 
   public void OnChangeStarTotal(int numStars) { 
         string starMessage = "total stars = " + numStars; 
         starText.text = starMessage; 
   } 
}

如您所见,在PlayerInventory中,我们现在每次与星星 GameObject 发生碰撞时,将 totalStars 增加 1。在PlayerInventoryDisplay中,我们在屏幕上显示一个简单的文本消息“total stars =”,后面跟着由OnChangeStarTotal(...)方法接收的整数总数。

现在当您运行游戏时,应该看到星星总数从零开始,并且每次玩家的角色击中星星时,总数都会增加 1。

选项 - 将所有责任合并到一个脚本中

玩家库存(他们携带的内容)与如何向用户显示库存的分离是游戏设计模式(最佳实践方法)的一个例子,称为模型-视图-控制器MVC),其中我们将更新 UI 的代码与更改玩家和游戏变量(如分数和库存项目列表)的代码分开。尽管此配方只有一个变量和一个更新 UI 的方法,但良好的游戏架构可以扩展以应对更复杂的游戏,因此,如果我们要使最终游戏架构具有良好的结构和可维护性,那么编写更多代码和额外的脚本类通常是值得的。

然而,对于非常简单的游戏,我们可能会选择在单个脚本类中结合状态和该状态的显示。以本食谱中此方法的示例,移除脚本组件PlayerInventoryPlayerInventoryDisplay,并创建以下 C#脚本类PlayerInventoryCombined,并将其实例添加到 GameObject player-SpaceGirl层次结构中:

using UnityEngine.UI; 
public class PlayerInventoryCombined : MonoBehaviour { 
   public Text starText; 
   private bool carryingStar = false; 

   void Start() { 
         UpdateStarText(); 
   } 

   void OnTriggerEnter2D(Collider2D hit) { 
         if (hit.CompareTag("Star")){ 
               carryingStar = true; 
               UpdateStarText(); 
               Destroy(hit.gameObject); 
         } 
   } 

   private void UpdateStarText() { 
         string starMessage = "no star :-("; 
         if (carryingStar) 
            starMessage = "Carrying star :-)"; 
         starText.text = starMessage; 
   } 
} 

玩家的体验没有区别,变化只是我们游戏代码的架构结构。

显示携带和不携带图标的单个物体拾取

图形图标是通知玩家他们正在携带物品的有效方式。在本食谱中,如果没有携带星星,屏幕左上角将显示一个灰色填充的图标,位于封闭的圆圈中:

图片

然后,在拾取到星星后,将显示一个黄色填充的星星图标。在许多情况下,图标比文本消息更清晰(它们不需要阅读和思考),并且可以在屏幕上比指示玩家状态和库存项目的文本消息更小。

本食谱还说明了前一个食谱中描述的 MVC 设计模式的益处——我们正在改变与用户通信的方式(通过图标而不是文本使用视图),但我们可以在无需更改的情况下使用脚本类 PlayerInventory(模型-控制器),它检测玩家星星碰撞并维护一个布尔标志,告诉我们是否正在携带星星。

准备工作

本食谱假设您是从本章第一食谱中设置的 Simple2Dgame_SpaceGirl 项目开始的。

对于这个食谱,我们在03_03文件夹中准备了一个名为_Scripts的文件夹。

如何操作...

要切换单个物体拾取的携带和不携带图标,请按照以下步骤操作:

  1. Simple2Dgame_SpaceGirl迷你游戏的新副本开始。

  2. 从提供的文件中导入_Scripts文件夹(这包含从上一个食谱中复制的脚本类PlayerInventory,我们可以将其原封不动地用于本食谱)。

  3. 在场景中添加一个UI Image对象(创建 | UI | Image)。将其重命名为Image-star-icon

    • 层次结构中选择 Image-star-icon,将icon_nostar_100精灵(在Sprites文件夹中)从项目面板拖到检查器(在 Image(脚本)组件中的源图像字段)。
  4. 点击图像组件的设置原生大小按钮。这将调整UI Image的大小以适应icon_nostar_100精灵文件的实际像素宽度和高度:

图片

  1. 将图像图标放置在游戏面板的顶部和左侧,在 Rect Transform 中。在按住SHIFTALT的同时选择左上方的框组件(以设置枢轴和位置)。

  2. 创建以下 C#脚本类 PlayerInventoryDisplay,并将其实例添加到 Hierarchy 中的 GameObject player-SpaceGirl:

using UnityEngine; 
using UnityEngine.UI;

[RequireComponent(typeof(PlayerInventory))] 
public class PlayerInventoryDisplay : MonoBehaviour  { 
   public Image imageStarGO; 
   public Sprite iconNoStar; 
   public Sprite iconStar;

   public void OnChangeCarryingStar(bool carryingStar) { 
        if (carryingStar) 
            imageStarGO.sprite = iconStar; 
        else 
            imageStarGO.sprite = iconNoStar; 
    } 
} 
  1. 从层级视图中选择 GameObject player-SpaceGirl。然后,从检查器中访问 PlayerInventoryDisplay(脚本)组件,并将星图像公共字段填充为 UI Image 对象 Image-star-icon。

  2. 从项目面板中将 icon_nostar_100 精灵填充到图标无星公共字段中,然后从项目面板中将 icon_star_100 精灵填充到图标星公共字段中,如图所示:

  1. 播放场景。你应该在左上角看到无星图标(一个在封闭圆圈中的灰色填充图标),直到你拾取星,此时它将变为显示携带星图标(黄色填充星)。

它是如何工作的...

在脚本类 PlayerInventoryDisplay 中,图像变量 imageStarGO 是对 UI Image 对象 Image-star-icon 的引用。精灵变量 iconStariconNoStar 是对项目面板中 Sprite 文件的引用 - 这些精灵用于告诉玩家是否携带了星。

每当 PlayerInventory 对象调用方法 OnChangeCarryingStar(carryingStar) 时,此方法使用 if 语句将 UI Image 设置为与接收到的 bool 参数值对应的精灵。

使用多个状态图标显示同一对象的多个拾取

如果要收集的项目有一个小的、固定的总数而不是文本总数,一个有效的 UI 方法是显示占位符图标(空或灰色图片)来显示用户还有多少个此类项目需要收集,并且每次拾取一个项目时,占位符图标就会被一个全色收集图标所替换。

在这个菜谱中,我们使用灰色填充的星形图标作为占位符,并使用黄色填充的星形图标来表示每个收集到的星,如图所示:

准备工作

本菜谱假设您是从本章第一个菜谱中设置的 Simple2Dgame_SpaceGirl 项目开始的。

如何操作...

要显示多个拾取的同一类型对象的库存图标,请按照以下步骤操作:

  1. 从一个新的 Simple2Dgame_SpaceGirl 小游戏副本开始。

  2. _Scripts 文件夹中创建 C# 脚本类 PlayerInventory

using UnityEngine; 

public class PlayerInventory : MonoBehaviour { 
   private PlayerInventoryDisplay playerInventoryDisplay; 
   private int totalStars = 0; 

   void Awake() { 
         playerInventoryDisplay = GetComponent<PlayerInventoryDisplay>(); 
   }  

   void Start() { 
        playerInventoryDisplay.OnChangeStarTotal(totalStars); 
   }  

   void OnTriggerEnter2D(Collider2D hit) { 
         if (hit.CompareTag("Star")) { 
            totalStars++; 
            playerInventoryDisplay.OnChangeCarryingStar(totalStars); 
            Destroy(hit.gameObject); 
         } 
   } 
} 
  1. 层级 面板中选择 GameObject 星,然后复制此 GameObject 三次。现在场景中有四个星 GameObject。将这些新的星 GameObject 移动到屏幕的不同部分。

  2. 将以下 C# 脚本 PlayerInventoryDisplay 添加到 GameObject player-SpaceGirl 中(层级):

using UnityEngine; 
using System.Collections; 
using UnityEngine.UI; 

public class PlayerInventoryDisplay : MonoBehaviour { 
    public Image[] starPlaceholders; 
    public Sprite iconStarYellow; 
    public Sprite iconStarGrey;

    public void OnChangeStarTotal(int starTotal){ 
       for (int i = 0;i < starPlaceholders.Length; ++i){ 
          if (i < starTotal) 
              starPlaceholders[i].sprite = iconStarYellow; 
          else 
             starPlaceholders[i].sprite = iconStarGrey; 
        } 
    } 
}
  1. 在层级面板中选择画布,并添加一个新的 UI Image 对象(创建 | UI | 图像)。将其重命名为 Image-star0。

  2. 在层级面板中选择 Image-star0。

  3. 从项目面板中,将精灵 icon_star_grey_100(在 Sprites 文件夹中)拖动到图像(脚本)组件的检查器中的源图像字段。

  4. 点击此图像(脚本)组件的“设置原生大小”按钮。这将调整 UI 图像的大小以适应精灵文件 icon_star_grey_100 的物理像素宽度和高度。

  5. 现在,我们将图标放置在游戏面板的左上角。编辑 UI 图像的 Rect Transform 组件,在按住 Shift + Alt(设置枢轴和位置)的同时,选择左上角的框。现在 UI 图像应该位于游戏面板的左上角。

  6. 在层次结构面板中复制 Image-star0 三个更多次,命名为 Image-star1、Image-star2 和 Image-star3。

  7. 在检查器面板中,将 Image-star1 的 Pos X 位置(在 Rect Transform 组件中)更改为 100,将 Image-star2 更改为 200,将 Image-star3 更改为 300

  1. 层次结构 中选择 GameObject player-SpaceGirl。然后,从 检查器 中访问 Player Inventory Display(脚本)组件,并将公共字段 Star Placeholders 的 Size 属性设置为 4

  2. 接下来,用 UI 图像 对象 Image-star0/1/2/3 填充公共字段 Star Placeholders 的 Element 0/1/2/3 数组值。

  3. 现在,从项目面板中用精灵 icon_star_100icon_star_grey_100 填充图标 Star YellowIcon Star Grey 公共字段,如图所示:

  1. 现在,当你播放场景时,你应该首先看到四个灰色的占位星形图标,每次你撞击一个星形时,顶部下一个图标应该变为黄色。

它是如何工作的...

四个UI 图像对象 Image-star0/1/2/3 已在屏幕顶部创建 — 使用灰色占位图标初始化。灰色和黄色图标精灵文件已调整大小为 100 x 100 像素,这使得在设计时它们的水*排列定位更容易,因为它们的坐标是 (0,0),(100,0),(200,0) 和 (300,0)。在一个更复杂的游戏屏幕,或者一个房地产宝贵的场景中,图标的实际大小可能会更小 — 这是一个由游戏图形设计师做出的决定。

在脚本类 PlayerInventory 中,int 变量 totalStars 表示到目前为止收集了多少颗星;它初始化为零。变量 playerInventoryDisplay 是指向管理我们的库存显示的脚本组件的引用 — 这个变量在场景开始之前在 Awake() 方法中缓存。

在场景开始时运行的 Start() 方法调用 PlayerInventoryDisplay 组件的 OnChangeStarTotal(...) 方法,以确保屏幕上的图标显示与 totalStars 的起始值相匹配。

OnTriggerEnter2D() 方法中,每当玩家的角色撞击带有 Star 标签的对象时,totalStars 计数器增加 1。除了销毁被击中的 GameObject,还会调用 PlayerInventoryDisplay 组件的 OnChangeStarTotal(...) 方法,传递新的星形总数整数。

脚本类 PlayerInventoryDisplayOnChangeStarTotal(...) 方法引用了四个 UI 图像,并遍历图像引用数组中的每个项目,将指定数量的图像设置为黄色,其余的设置为灰色。此方法为公共方法,允许从脚本类 PlayerInventory 的实例中调用。

还有更多...

这里有一些细节你不希望错过:

通过改变*铺图像的大小来揭示多个对象拾取的图标

另一种可以采取的方法来显示越来越多的图像是使用*铺图像。通过使用宽度为 400 的*铺灰色星星图像(显示灰色星星图标的四个副本),在*铺黄色星星图像后面,其宽度是收集到的星星数量的 100 倍,也可以达到与之前食谱中相同的视觉效果。

如果黄色星星图像的宽度小于下面的灰色星星图像,那么我们将看到任何剩余位置的灰色星星。例如,如果我们携带 3 个星星,我们将使黄色星星图像的宽度为 3 x 100 = 300 像素宽。这将显示 3 个黄色星星,并从下面的灰色星星图像中露出 100 像素,即 1 个灰色星星。

要使用*铺图像显示多个对象拾取的灰色和黄色星星图标,让我们通过以下步骤调整我们的食谱,通过以下步骤说明这项技术:

  1. 层次 面板中,删除整个 画布 GameObject(因此删除所有四个 UI 图像)。

  2. 在你的场景中添加一个新的 UI 图像 对象(创建 | UI | 图像)。将 GameObject 重命名为 Image-stars-gray。

  3. 确保在 层次 中选择 Image-stars-gray。从 项目 面板,将 Sprites 文件夹中的精灵 icon_star_grey_100 拖动到检查器(在 图像(脚本) 组件中)的 源图像 字段。

  4. 点击此图像(脚本)组件的 设置原生大小 按钮。这将调整 UI 图像 以适应精灵文件 icon_star_grey_100 的物理像素宽度和高度。

  5. 现在将图标放置在屏幕的 顶部左侧。编辑 UI 图像的 矩形 变换 组件,在按住 Shift + Alt(以设置枢轴和位置)的同时,选择左上角的框。现在 UI 图像应该位于 游戏 面板的左上角。

  6. 在检查器面板中,将 Image-stars-grey 的 宽度(在矩形变换组件中)更改为 400。同时,将 图像类型(在 图像(脚本) 组件中)设置为 *铺,如图所示:

对于这样一个简单的游戏,我们选择简洁而不是内存效率。你会看到一个提示,建议使用具有 Wrap 模式重复和清除打包标签的高级纹理。虽然更节省内存,但对于像这个食谱中这样的小而简单的*铺来说,操作会更复杂。

  1. 层级面板中复制 Image-stars-grey,并将其命名为Image-stars-yellow

  2. 在层级面板中选择Image-stars-yellow后,从项目面板中,将精灵icon_star_100(位于 Sprites文件夹中)拖动到检查器(位于图像(脚本)组件中)的源图像字段。

  3. 将 Image-stars-yellow 的宽度设置为 0(在矩形变换组件中)。因此,现在我们有了黄色星星*铺图像在灰色*铺图像之上,但由于其宽度为零,我们目前看不到任何黄色星星。

  4. 用以下代码替换现有的 C#脚本PlayerInventoryDisplay

using UnityEngine; 
using UnityEngine.UI; 

[RequireComponent(typeof(PlayerInventory))] 
public class PlayerInventoryDisplay : MonoBehaviour { 
   public Image iconStarsYellow; 

   public void OnChangeStarTotal(int starTotal) { 
         float newWidth = 100 * starTotal; 
        iconStarsYellow.rectTransform.SetSizeWithCurrentAnchors( RectTransform.Axis.Horizontal, newWidth ); 
   } 
} 
  1. 层级视图中选择 GameObject player-SpaceGirl。然后,从检查器中访问玩家库存显示 (脚本)组件,并将 UI 图像对象 Image-stars-yellow 填充到图标星星黄色公共字段中。

UI 图像 Image-stars-gray 是一个*铺图像,宽度足够(400px)以显示灰色精灵icon_star_grey_100四次。UI 图像 Image-stars-yellow 是一个*铺图像,位于灰色图像之上,初始宽度设置为零,因此看不到任何黄色星星。

每次拾取一颗星星时,都会从PlayerInventory脚本对象调用脚本组件PlayerInventoryDisplayOnChangeStarTotal()方法,传递收集到的新的整数星星数量。通过将这个数字乘以黄色精灵图像的宽度(100 px),我们得到为UI 图像Image-stars-yellow 设置的正确宽度,以便用户现在可以看到相应的黄色星星数量。任何尚未收集的星星仍然会以尚未覆盖的灰色星星的形式出现。

实际上,通过调用SetSizeWithCurrentAnchors(...)方法来完成更改 UI 图像 Image-stars-yellow 宽度的任务。第一个参数是轴,因此我们传递常量RectTransform.Axis.Horizontal,以便它将更改宽度。第二个参数是那个轴的新大小,因此我们传递一个值,它是迄今为止收集的星星数量的 100 倍(变量newWidth)。

使用面板来视觉上勾勒出库存 UI 区域和单个项目。

在玩游戏时,我们会看到四种类型的对象:

  • 具有某些视觉元素的游戏对象,例如 2D 和 3D 对象。

  • 位于世界空间中的 UI 元素,因此它们出现在场景中的 GameObject 旁边。

  • 位于屏幕空间 - 相机中的 UI 元素,因此它们出现在与相机固定距离的位置(但可能被比这些 UI 元素更靠*相机的 GameObject 遮挡)。

  • 位于屏幕空间 - 叠加层中的 UI 元素。这些总是出现在其他三种视觉元素之上,非常适合抬头显示HUD)元素,例如库存。

有时我们希望从视觉上清楚地表明哪些元素是 UI HUD 的一部分,哪些是场景中的视觉对象。Unity 中的UI 面板带有不透明或半透明背景图像,这是一种简单而有效的方法来实现这一点。

面板还可以用于显示带有形状或颜色背景的位置(槽位),指示物品可能放置的位置或可以收集的数量。如图所示,在本食谱中,我们将创建一个带有一些标题文本的面板和三个库存槽位,其中两个槽位将填充星星图标,向玩家传达还有一颗星星可以收集/携带。

准备工作

本食谱假设您正在使用本章第一个食谱中设置的Simple2Dgame_SpaceGirl项目。所需的字体可以在03_02文件夹中找到。

如何操作...

要使用面板从视觉上勾勒出库存区域和单个项目,请按照以下步骤操作:

  1. 从一个新的Simple2Dgame_SpaceGirl迷你游戏副本开始。

  2. 层次结构面板中,创建一个UI 面板创建 | UI | 面板),并将其重命名为 Panel-background。

  3. 现在,将 Panel-background 放置在游戏面板的顶部,拉伸画布的水*宽度。编辑 UI Image 的 Rect Transform组件,在按住Shift + Alt(以设置中心点和位置)的同时,选择顶部拉伸框。

  4. 面板仍然会占据整个游戏窗口。现在,在检查器中,将 Panel-background 的高度(在Rect Transform组件中)更改为 100。

  5. 添加一个 UI Text 对象(创建 | UI | 文本),并将其重命名为 Text-inventory。对于其 Text(脚本)组件,将文本更改为 Inventory。

  6. 层次结构面板中,将子UI Text对象 Text-inventory 添加到 Panel-background 面板中。

  7. 检查器面板中,还将 Text-inventory 的字体设置为 Xolonium-Bold(在字体文件夹中)。水*居中文本,对于对齐选择垂直居中,将其高度设置为 50,并将字体大小设置为 23。

  8. 编辑 Text-inventory 的Rect Transform,在按住Shift + Alt(以设置中心点和位置)的同时,选择顶部拉伸框。现在文本应位于 UI 面板对象 Panel-background 的顶部中心,其宽度应拉伸以匹配整个面板。

  9. 现在文本应位于 UI 面板对象 Panel-background 的顶部中心,其宽度应拉伸以匹配整个面板。

  10. 创建一个新的 UI 面板(创建 | UI | 面板),并将其重命名为 Panel-inventory-slot。

  11. 编辑 Panel-inventory-slot 的 Rect Transform,在按住Shift + Alt(以设置中心点和位置)的同时,选择顶部中心框。将宽度和高度都设置为70,并将 Pos Y 设置为-30。请参见以下截图:

  1. 确保在 Hierarchy 中选择了 GameObject Panel-inventory-slot。在图像(脚本)组件中,将源图像从 UI 面板的默认背景更改为圆形 旋钮图像(这是 Unity UI 系统内置的图像之一)。如图所示,你现在应该在我们的库存 HUD 矩形区域的标题文本下方看到一个圆形。这个圆形从视觉上告诉用户,库存中有一个可以收集物品的空间:

图片

  1. 想象一下玩家已经收集了一个星星。现在让我们在我们的库存槽圆圈面板中添加一个黄色星星图标图像。向场景中添加一个 UI 图像对象(创建 | UI | 图像)。将其重命名为 Image-icon。将子 GameObject Image-icon 添加到面板 Panel-inventory-slot。

子 GameObject 可以通过将其设置为 不活动 来隐藏。通过为我们的星星图标创建一个新的 UI 图像 GameObject,并将其添加为 Panel-inventory-slot GameObject 的子项,我们现在可以在 图像被启用时显示星星图标,通过使其不活动来隐藏它。这是一个通用方法,这意味着只要我们有 图像 GameObject 的引用,我们就不必像在之前的某些食谱中那样进行额外的图像交换工作。这意味着我们可以开始编写更通用的代码,该代码将适用于不同的库存面板,如钥匙、星星、金钱等。

  1. Hierarchy 中选择 Image-icon,将精灵 icon_star_100(在 Sprites 文件夹中)从 项目面板拖动到检查器(在 图像(脚本)组件)中的 源图像字段。

  2. 编辑 Image-icon 的 矩形变换,在按住 Shift + Alt(以设置枢轴和位置)的同时,选择拉伸-拉伸框。现在星星图标应该被拉伸到完美的大小,以适应 70x70 的父面板,所以我们看到圆圈内的星星。

图片

  1. 保存并运行场景并玩游戏。你应该在屏幕顶部看到一个清晰定义的矩形,其中包含标题文本 Inventory。在库存矩形区域内,你可以看到一个圆形槽,目前显示一个星星。

  2. 让我们向玩家显示 3 个槽位。首先,将面板 Panel-inventory-slot 的 Pos X 水*位置更改为 -70。这将使其位于中心左侧,为下一个槽位腾出空间,并在我们完成时允许我们居中三个槽位。

  3. 复制面板 Panel-inventory-slot,如果需要,将副本重命名为 panel Panel-inventory-slot (1)。将此副本的 Pos X 设置为 0。

  4. 再次复制 Panel-inventory-slot 面板,如果需要的话,将副本重命名为 panel Panel-inventory-slot (2)。将这个副本的 Pos X 设置为 70。现在选择这个第三个面板的子 Image-star-icon 并使其不活动(在检查器顶部取消勾选其活动复选框,位于 GameObject 名称的左侧)。这个面板的星形现在应该被隐藏,并且只有槽面板的圆形背景是可见的。

它是如何工作的...

我们创建了一个简单的面板(Panel-background),其中包含一个标题 UI Text 作为游戏画布顶部的子 GameObject,显示了一个灰色背景矩形和标题文本“库存”。这向玩家表明,屏幕的这一部分是库存 HUD 将被显示的地方。

为了说明如何使用它来指示玩家携带星星,我们在库存中添加了一个带有圆形背景图像的小面板,用于一个槽位,并在其中添加了一个星形图标子 GameObject。然后我们再次复制了槽面板两次,将它们定位在 70 像素的距离。然后我们禁用了(使不活动)第三个槽位的星形图标,因此显示了一个空槽的圆形。

我们的场景向用户展示了一个显示,表明可能的三颗星中有两颗正在被携带。这个配方是一个很好的起点,用于在 Unity 中创建更通用的库存 UI,我们将在本章的一些后续配方中基于它进行构建。

我们将在第十二章控制与选择位置中学习如何限制玩家的移动,防止他们的角色移动到像这样的 HUD 物品的矩形内。

创建一个 C#库存槽 UI 显示脚本组件

在上一个配方中,我们开始使用 UI 面板和图像来创建一个更通用的用于显示库存槽的 GameObject,以及图像来指示其中存储的内容。在这个配方中,我们将进一步探讨图形的使用,并创建一个 C#脚本类来处理每个库存槽对象。

图片

正如我们在截图中所见,在这个配方中,我们将创建一个 UI(和脚本)用于有三个星星位置和一个有钥匙的三个位置的库存,使用彩色和灰色图标来指示收集了多少。

准备工作

这个配方是对上一个配方的一个改编。因此,请复制上一个配方的项目,并在这个副本上工作。

对于这个配方,我们在03_06文件夹中准备了一个名为_Scripts的文件夹。

如何做到这一点...

要创建一个 C#库存槽显示脚本组件,请按照以下步骤操作:

  1. 从提供的文件中导入_Scripts文件夹(这包含了一个从之前的配方中复制来的脚本类PlayerInventory,我们可以在这个配方中直接使用它)。

  2. 删除三个库存槽 GameObject 中的两个:Panel-inventory-slot (1) 和 (2)。因此,只剩下 Panel-inventory-slot。

  3. 首先,我们将创建一个用于三个星星槽的面板。在层次结构面板中,创建一个 UI 面板(创建 | UI | 面板),并将其重命名为 Panel-stars。

  4. 现在,我们将 Panel-stars 放置在游戏面板的左上角,并使其适应我们的一般库存矩形的左侧。编辑 UI 图像的矩形变换组件,在按住SHIFTALT(以设置枢轴和位置)的同时,选择左上角的框。现在将高度设置为 60,宽度设置为 300。现在,我们将通过将 Pos X 设置为 10 和 Pos Y 设置为-30 来将此框从左上角移开。

  5. 添加一个UI 文本对象(创建 | UI | 文本),并将其重命名为 Text-title。对于其文本 (脚本)组件,将文本更改为“星星”。将此 UI 文本对象作为子对象添加到面板 Panel-stars 中。

  6. 编辑 Text-title 的矩形变换,在按住Shift + Alt (以设置枢轴和位置)的同时,选择左中部的框。现在文本应位于UI 面板对象 Panel-stars 的左中部。

  7. 检查器面板中,还将 Text-title 的字体设置为Xolonium-Bold(位于“字体”文件夹中)。水*居中文本,垂直居中文本,将其高度设置为50,并将字体大小设置为32。选择黄色文本颜色。将垂直溢出设置为溢出,并将对齐垂直设置为居中。现在,我们将通过将 Pos X 设置为 10 来将此框从非常左侧边缘移开。

  8. 将现有的 GameObject Panel-inventory-slot 作为子对象添加到 Panel-stars。编辑其矩形变换,在按住Shift + Alt (以设置枢轴和位置)的同时,选择左中部的框。

  9. 将 Panel-inventory-slot 的大小调整为50 x 50像素。将其 Pos X 设置为 140。现在它应该看起来在黄色星星文本的右侧:

图片

  1. 将 GameObject Image-icon 重命名为 Image-icon-grey。然后复制此 GameObject,将其命名为 Image-icon-color。这两个都应该成为 Panel-inventory-slot 的子 GameObject。在层次结构中,顺序应该是第一个子对象是 Image-icon-grey,第二个子对象是 Image-icon-color。如果这不是这个顺序,那么交换它们。

  2. 选择“Image-icon-grey”,并将精灵icon_star_grey_100(位于Sprites文件夹中)从项目面板拖动到检查器(位于图像(脚本)组件中的源图像字段)。现在,如果你禁用 GameObject Image-icon-color,你应该在槽面板的圆圈中看到灰色星星图标。

  3. _Scripts文件夹中创建以下 C#脚本PickupUI,并将其作为组件添加到层次结构中 Panel-inventory-slot 的 GameObject:

using UnityEngine; 
using System.Collections; 

public class PickupUI : MonoBehaviour { 
   public GameObject iconColor; 
   public GameObject iconGrey; 

   void Awake() { 
         DisplayEmpty(); 
   }  

   public void DisplayColorIcon() { 
         iconColor.SetActive(true); 
         iconGrey.SetActive(false); 
   }  

   public void DisplayGreyIcon() { 
         iconColor.SetActive(false); 
         iconGrey.SetActive(true); 
   } 

   public void DisplayEmpty() { 
         iconColor.SetActive(false); 
         iconGrey.SetActive(false); 
   } 
} 
  1. Hierarchy中选择 Panel-inventory-slot。在Inspector中,对于 Pickup UI (Script)组件,通过从 Hierarchy 拖动 Image-icon-color 来填充Icon Color公共字段。同样,通过从 Hierarchy 拖动 Image-icon-grey 来填充 Icon Grey 公共字段。现在,Panel-inventory-slot 中的脚本组件PickupUI有对这个库存槽 GameObject 的彩色和灰色图标的引用。

  2. 复制 GameObject Panel-inventory-slot,并将新复制的 GameObject 的 Pos X 设置为 190。

  3. 将 GameObject Panel-inventory-slot 第二次复制,对于新复制的 GameObject,将其 Pos X 设置为 240。你现在应该能看到所有三个星星库存图标整齐地排列在黄色“Stars”标题文本的右侧:

图片

  1. 将以下 C#脚本 PlayerInventoryDisplay 添加到Hierarchy中的 GameObject player-SpaceGirl:
using UnityEngine; 
using System.Collections; 
using UnityEngine.UI; 

[RequireComponent(typeof(PlayerInventory))] 
public class PlayerInventoryDisplay : MonoBehaviour  { 
   public PickupUI[] slots = new PickupUI[1]; 
   public void OnChangeStarTotal(int starTotal) { 
       int numInventorySlots = slots.Length; 
       for(int i = 0; i < numInventorySlots; i++){ 
             PickupUI slot = slots[i]; 
             if(i < starTotal) 
                   slot.DisplayColorIcon(); 
             else 
                   slot.DisplayGreyIcon(); 
       } 
   } 
} 
  1. Hierarchy中选择 GameObject player-SpaceGirl。然后在Inspector中的Player Inventory Display (Script)组件中执行以下操作:

    • 将公共数组槽的大小设置为 3。

    • Element 0公共字段填充为 GameObject Panel-inventory-slot。

    • Element 1公共字段填充为 GameObject Panel-inventory-slot (1)。

    • Element 2公共字段填充为 GameObject Panel-inventory-slot (2):

图片

  1. 最后,在场景中再复制两个 GameObject star,并将它们移动到适当的位置。因此,现在有三个标记为 Star 的 GameObject 供玩家收集。

  2. 当你运行游戏,玩家的角色击中每个星星 GameObject 时,它应该从场景中移除,下一个空闲的库存星星图标应该从灰色变为黄色。

它是如何工作的...

我们创建了一个面板(Panel-stars),用于显示大标题文本“Stars”以及三个库存槽面板,以显示可以收集多少星星,以及在任何游戏时刻已经收集了多少星星。每个星星面板槽是一个带有圆形旋钮背景图像的UI 面板,然后是两个子元素,一个显示灰色图标图像,另一个显示彩色图标图像。当彩色图标图像 GameObject 被禁用时,它将被隐藏,从而揭示灰色图标。当彩色和灰色图像都被禁用时,将显示一个空圆圈,这可能被用来向用户指示库存中的通用位置是空的且可用。

脚本类 PickupUI 有两个公共变量,它们是该 GameObject 相关的灰色和彩色图标的引用。在场景开始之前(方法 Awake()),脚本隐藏灰色和彩色图标并显示一个空圆圈。此脚本类声明了三个公共方法(公开,以便在游戏运行时从另一个脚本对象调用)。这些方法隐藏/显示适当的图标以显示相关的库存面板 UI 对象,无论是空的、灰色的还是彩色的。这些方法被清楚地命名为 DisplayEmpty()DisplayGreyIcon()DisplayColorIcon()

脚本类 PlayerInventory 维护一个整数总计 starTotal,表示收集了多少颗星星(初始化为零)。每次玩家角色与对象碰撞时,如果该对象被标记为星星,则调用 AddStar() 方法。此方法增加总计并发送一条消息,将新总计传递给其兄弟脚本组件 PlayerInventoryDisplayOnChangeStarTotal(...) 方法。

脚本类 PlayerInventoryDisplay 拥有一个指向 PickupUI 对象的公共数组,以及一个单独的公共方法 OnChangeStarTotal(...). 此方法遍历其 PickupUI 脚本对象的数组,在循环计数器小于携带的星星数量时,将这些对象设置为显示彩色图标,之后将它们设置为显示灰色图标。这导致显示的彩色图标与携带的星星数量相匹配。

注意:看起来我们可以通过假设插槽始终显示灰色(没有星星)并每次拾取黄色星星时只更改一个插槽为黄色来简化我们的代码。但如果游戏中发生某些情况(例如,撞击黑洞或被外星人射击)导致我们掉落一个或多个星星,这可能会导致问题。PlayerInventoryDisplay C# 脚本类不对哪些插槽可能或可能没有之前显示为灰色、黄色或空做出任何假设。每次调用时,它都确保显示适当数量的黄色星星,并且所有其他插槽都显示为灰色星星。

为三个星星的 UI 面板 GameObject 插槽添加了一个 PickupUI 脚本组件,并且每个插槽都链接到其灰色和彩色图标。

在场景中添加了几个星星 GameObject(所有标记为星星)。在 GameObject player-SpaceGirl 的 PlayerInventoryDisplay 脚本组件中的 PickupUI 对象引用数组被填充了三个 UI 面板中每个星星的 PickupUI 脚本组件的引用。

还有更多...

这里有一些你不想错过的细节。

修改游戏以添加第二个用于钥匙收集的库存面板

我们为星星对象的集合创建了一个出色的显示面板。现在我们可以重用这项工作,来创建第二个面板以显示游戏中关键对象的集合。

要修改游戏以添加第二个用于钥匙收集的库存面板,请执行以下操作:

  1. 复制 GameObject Panel-stars,将副本命名为 Panel-keys。

  2. 在层次结构中选择 Panel-keys,执行以下操作:

    • 将子 Text-titleText (Script) 从星星改为键。

    • 矩形变换中,选择右上角,将 Pos X 设置为 -10(以远离右侧边缘)并将 Pos Y 设置为 -30(以垂直对齐到面板键)。

    • 对于所有三个面板库存槽的子 Image-icon-grey GameObject,将 Image (Script) 的 Source Image 更改为:icon_key_grey_100。

    • 对于所有三个面板库存槽的子 Image-icon-color GameObject,将 Image (Script) 的 Source Image 更改为:icon_key_green_100。

    • 对于所有三个面板库存槽的子 Image-icon-grey GameObject 和 Image-icon-color GameObject,在 Rect Transform 中将缩放设置为 (0.75, 0.75, 1)。这是为了让键图像完全适合背景面板圆形图像。

  3. 从 GameObject player-SpaceGirl 中移除脚本组件:PlayerInventory 和 PlayerInventoryDisplay。

  4. _Scripts 文件夹中创建以下 C# 脚本 PlayerInventoryKeys:

using UnityEngine; 

public class PlayerInventoryKeys : MonoBehaviour { 
   private int starTotal = 0; 
   private int keyTotal = 0; 
   private PlayerInventoryDisplayKeys playerInventoryDisplay; 

   void Awake() { 
         playerInventoryDisplay = GetComponent<PlayerInventoryDisplayKeys>(); 
   } 

   void Start() { 
         playerInventoryDisplay.OnChangeStarTotal(starTotal); 
         playerInventoryDisplay.OnChangeKeyTotal(keyTotal); 
   } 

   void OnTriggerEnter2D(Collider2D hit) { 
         if(hit.CompareTag("Star")){ 
               AddStar(); 
               Destroy(hit.gameObject); 
         } 

         if(hit.CompareTag("Key")){ 
               AddKey(); 
               Destroy(hit.gameObject); 
         } 
   } 

   private void AddStar() { 
         starTotal++; 
         playerInventoryDisplay.OnChangeStarTotal(starTotal); 
   } 

   private void AddKey() { 
         keyTotal++; 
         playerInventoryDisplay.OnChangeKeyTotal(keyTotal); 
   } 
} 
  1. 将以下 C# 脚本 PlayerInventoryDisplayKeys 添加到 层次结构 中的 GameObject player-SpaceGirl:
using UnityEngine; 

[RequireComponent(typeof(PlayerInventoryKeys))] 
public class PlayerInventoryDisplayKeys : MonoBehaviour  { 
   public PickupUI[] slotsStars = new PickupUI[1]; 
   public PickupUI[] slotsKeys = new PickupUI[1]; 

   public void OnChangeStarTotal(int starTotal) { 
         int numInventorySlots = slotsStars.Length; 
         for(int i = 0; i < numInventorySlots; i++){ 
               PickupUI slot = slotsStars[i]; 
               if(i < starTotal) 
                     slot.DisplayColorIcon(); 
               else 
                     slot.DisplayGreyIcon(); 
         } 
   } 

   public void OnChangeKeyTotal(int keyTotal) { 
         int numInventorySlots = slotsKeys.Length; 
         for(int i = 0; i < numInventorySlots; i++){ 
               PickupUI slot = slotsKeys[i]; 
               if(i < keyTotal) 
                     slot.DisplayColorIcon(); 
               else 
                     slot.DisplayGreyIcon(); 
         } 
   } 
}
  1. 层次结构中选择 GameObject player-SpaceGirl,为其 PlayerInventoryDisplayKeys 脚本组件设置 slotsKeys 和 slotsStars 都为 3(使这些数组的每个大小为 3)。然后从层次结构中拖动相应的库存槽 GameObject 来填充这些数组。

  2. 通过从 项目 面板拖动 sprite 图像 icon-key-green-100 的副本到场景中创建一个新的 GameObject key。然后添加一个 Box Collider 组件(物理 2D)并勾选其 Is Trigger 设置。在其 Sprite Renderer 组件中,将排序层设置为前景。创建一个新的 标签:Key,并将此标签添加到该 GameObject。

  3. 将 GameObject key 复制两次,将它们移动到场景中的不同位置(这样玩家可以看到所有三个星星和所有三个键)。

如您所见,我们已经复制并调整了用于携带星星的库存的视觉 UI 面板和组件,以给我们第二个用于携带键的库存。同样,我们添加了检测带有 Key 标签的对象的代码,并将更新库存显示脚本的代码添加到通知键数量发生变化时更新键的 UI 面板。

使用 UI Grid Layout Groups 自动填充面板

到目前为止,本章中的配方是为每种情况手工制作的。虽然这样做是可以的,但更通用和自动化的库存用户界面方法有时可以节省时间和精力,同时仍然达到相同质量的视觉和可用性效果。

可能会有很多从 Hierarchy 面板拖动槽到数组中,例如在之前的配方中用于脚本组件 PlayerInventoryDisplay。这需要一些工作(在错误顺序或重复拖动同一项时可能会出错)。另外,如果我们更改槽的数量,我们可能需要全部重新做,或者如果我们增加数量,我们可能需要记住拖动更多的槽。更好的方法是,将脚本类 PlayerInventoryDisplay 的第一个任务设置为场景开始时在 Run-Time 创建所需数量的灰色星形(或键或任何)图标 GameObject 作为 Panel-slot-grid 的子项,并同时在同一时间填充显示脚本组件的数组。

图片

在这个配方中,我们将通过利用 Unity 的 Grid Layout Group 组件提供的自动尺寸和布局功能,开始探索一种更工程化的库存 UI 方法。本配方末尾的一些增强功能包括添加一个交互式滚动条,如截图所示。

准备工作

本配方基于之前的配方进行修改。因此,请复制之前配方的项目,并在副本上工作。

如何操作...

要自动使用 UI Grid Layout Groups 填充面板,请按照以下步骤操作:

  1. 在名为 Prefabs 的新文件夹中创建一个名为 panel-inventory-slot 的新空预制体。

  2. Hierarchy 面板中,将 GameObject Panel-inventory-slot 拖动到您新创建的空预制体 panel-inventory-slot 中。现在这个预制体应该变成蓝色,表示它已被填充。

  3. Hierarchy 面板中,删除三个 GameObjects Panel-inventory-slot / (1) / (2)。

  4. 将 Text-title 从 Panel-stars 中移除。设置 Panel-stars 的 Pos-X 位置为 130 - 这样面板现在就在文本 Stars 的右侧。

  5. 在 Hierarchy 面板中选择 Panel-stars 面板,添加一个网格布局组组件(添加组件 | 布局 | 网格布局组)。将 Cell Size 设置为 50 x 50,间距设置为 5 x 5。同时,将 Child Alignment 设置为居中(这样我们的图标在远左和右端将有均匀的间距),如下截图所示:

图片

  1. 将 GameObject player-SpaceGirl 中的 C# 脚本 PlayerInventoryDisplay 替换为以下代码:
using UnityEngine; 
using System.Collections; 
using UnityEngine.UI; 

[RequireComponent(typeof(PlayerInventory))] 
public class PlayerInventoryDisplay : MonoBehaviour  { 
   const int NUM_INVENTORY_SLOTS = 5; 
   public GameObject panelSlotGrid; 
   public GameObject starSlotPrefab; 
   private PickupUI[] slots = new PickupUI[NUM_INVENTORY_SLOTS]; 

   void Awake() {
         float width = 50 + (NUM_INVENTORY_SLOTS * 50); 
         panelSlotGrid.GetComponent<RectTransform>().SetSizeWithCurrentAnchors( RectTransform.Axis.Horizontal, width ); 

         for(int i=0; i < NUM_INVENTORY_SLOTS; i++){ 
               GameObject starSlotGO = (GameObject) 
               Instantiate(starSlotPrefab); 
               starSlotGO.transform.SetParent(panelSlotGrid.transform); 
               starSlotGO.transform.localScale = new Vector3(1,1,1); 
               slots[i] = starSlotGO.GetComponent<PickupUI>(); 
         } 
   } 

   public void OnChangeStarTotal(int starTotal) { 
         for(int i = 0; i < NUM_INVENTORY_SLOTS; i++){ 
               PickupUI slot = slots[i]; 
               if(i < starTotal) 
                     slot.DisplayColorIcon(); 
               else 
                     slot.DisplayGreyIcon(); 
         } 
   } 
} 
  1. 确保在 Hierarchy 中选择 GameObject player-girl1。然后从 Project 面板将 GameObject Panel-stars 拖动到 Inspector 中的 Player Inventory Display (Script) 变量 Panel-slot-grid

  2. Hierarchy 中选择 GameObject player-girl1,从 Project 面板将 prefab panel-inventory-slot 拖动到 Player Inventory Display (Script) 变量 Star Slot Prefab,在检查器中。步骤 7 和 8 如下截图所示:

图片

  1. 编辑脚本类 PlayerInventoryDisplay,将常量 NUM_INVENTORY_SLOTS 设置为 10 或 15 个槽位。这样一些槽位只能在使用水*滚动条时看到。

  2. 保存场景并开始游戏。当你收集星星时,你应该会看到库存显示中更多的灰色星星变为黄色。

它是如何工作的...

我们取了一个包含旋钮圆形背景和灰色和彩色星星图像的子对象的面板,并使用它创建了一个 Prefab 面板-库存槽位。然后我们从场景中移除了星星面板 GameObject,因为我们的脚本类 PlayerInventoryDisplay 在场景开始时会创建所需数量的这些。这种方法节省了大量拖放操作,节省了 设计时 努力并消除了在场景设计更改时可能的一个序列/对象引用错误来源。

C# 脚本类 PlayerInventoryDisplay 有两个属性:

  • 一个定义我们库存槽位数量的常量整数 (NUM_INVENTORY_SLOTS),在这个游戏中我们将其设置为 5。

  • 一个 (slots) 数组,包含对 PickupUI 脚本组件的引用。这些中的每一个将成为我们 Panel-stars 中五个 Panel-inventory-slot GameObject 中的脚本组件的引用。

Awake() 方法用于在 PlayerInventoryDispay 中创建预制实例,这样我们知道这将在 PlayerInventory 中的 Start() 方法之前执行,因为场景中的所有 GameObject 的 Awake() 方法完成之前,场景中不会执行任何 Start() 方法。Awake() 方法首先计算 Panel-stars 的宽度(50 + (50 * 库存槽位数量))。然后,使用 SetSizeWithCurrentAnchors() 方法调整面板大小以具有该宽度。然后,循环运行库存中的槽位数量,每次从预制中创建一个新的星星槽位 GameObject,将其作为子对象添加到 Panel-stars,并在数组槽位中添加对图标槽位 GameObject 的引用。当 OnChangeStarTotal(...) 方法传递我们携带的星星数量时,它遍历每个五个槽位。当当前槽位小于我们的星星总数时,通过调用当前槽位的 DisplayYellow() 方法(PickupUI 脚本组件)显示黄色星星。一旦循环计数器等于或大于我们的星星总数,那么所有剩余的槽位都通过 DisplayGrey() 方法显示为灰色星星。

我们的玩家角色 GameObject,player-girl1,有一个非常简单的 PlayerInventory 脚本。这个脚本只是检测与带有标签 Star 的对象的碰撞,当发生碰撞时,它会移除与之碰撞的星 GameObject,并调用其 playerInventoryModel 脚本组件的 AddStar() 方法。每次调用 AddStar() 方法时,它都会将携带的星星总数增加(加 1),然后调用脚本组件 playerInventoryDisplayOnChangeStarTotal(...) 方法。此外,当场景开始时,会调用 OnChangeStarTotal(...) 方法,以便设置库存的 UI 显示,显示我们最初没有携带任何星星。

公共数组已被改为私有,不再需要通过手动拖放来填充。当你运行游戏时,它将像以前一样运行,现在我们的库存网格面板中的图像数组填充现在是自动化的。Awake() 方法创建新的预制实例(数量由常量 NUM_INVENTORY_SLOTS 定义),并立即将其作为子对象添加到 Panel-slot-grid。由于我们有一个网格布局组组件,它们的放置在我们的面板中自动整齐有序。

当 GameObject 改变其父对象时(为了保持子对象大小相对于父对象的大小),会重置 GameObject 变换组件的缩放属性。因此,在 GameObject 被添加为另一个 GameObject 的子对象后,立即将其局部缩放重置为 (1,1,1) 是一个好主意。我们在 starSlotGO 的 for 循环中紧随 SetParent(...) 语句之后执行此操作。

还有更多...

这里有一些你不想错过的细节。

根据带有标签 Star 的 GameObject 数量自动推断库存槽位数量

而不是手动在脚本类 PlayerInventoryDisplay 中更改整型常量 NUM_INVENTORY_SLOTS 以匹配场景中创建的用于玩家收集的 GameObject 数量,让我们让我们的脚本计算有多少 GameObject 被标记为 Star,并使用这个数量来调整和填充指向库存 UI 槽位引用的数组。

我们只需要将数组大小从常量改为变量,并在我们的 Awake() 方法中将其设置在其他任何内容之前。语句 GameObject.FindGameObjectsWithTag("Star") 获取一个引用数组,指向所有带有标签 Star 的 GameObject,其长度就是我们想要的数组大小:

  1. 将 GameObject player-SpaceGirl 中的 C# 脚本 PlayerInventoryDisplay 替换为以下代码:
using UnityEngine; 
using System.Collections; 
using UnityEngine.UI; 

[RequireComponent(typeof(PlayerInventory))] 
public class PlayerInventoryDisplay : MonoBehaviour  { 
   private int numInventorySlots; 
   private PickupUI[] slots; 
   public GameObject panelSlotGrid; 
   public GameObject starSlotPrefab; 

   void Awake() { 
         GameObject[] gameObjectsTaggedStar = GameObject.FindGameObjectsWithTag("Star"); 
         numInventorySlots = gameObjectsTaggedStar.Length; 
         slots = new PickupUI[numInventorySlots]; 
         float width = 50 + (numInventorySlots * 50); 
         panelSlotGrid.GetComponent<RectTransform>().SetSizeWithCurrentAnchors( RectTransform.Axis.Horizontal, width);  

         for(int i=0; i < numInventorySlots; i++){ 
               GameObject starSlotGO = (GameObject) 
               Instantiate(starSlotPrefab); 
               starSlotGO.transform.SetParent(panelSlotGrid.transform); 
               starSlotGO.transform.localScale = new Vector3(1,1,1); 
               slots[i] = starSlotGO.GetComponent<PickupUI>(); 
         } 
   } 

   public void OnChangeStarTotal(int starTotal) { 
         for(int i = 0; i < numInventorySlots; i++){ 
               PickupUI slot = slots[i]; 
               if(i < starTotal) 
                     slot.DisplayColorIcon(); 
               else 
                     slot.DisplayGreyIcon(); 
         } 
   } 
} 
  1. 添加或删除一些 GameObject 星星重复项,以便总数不再是 5。

  2. 运行场景。你应该会看到当场景开始时,Panel-star 的大小和内容会根据带有标签 Star 的 GameObject 数量进行匹配。

在库存槽位显示中添加水*滚动条

我们如何应对许多库存槽位,多于提供空间的情况?一个解决方案是添加一个滚动条,以便用户可以左右滚动,一次查看五个,例如,如图所示。

让我们在游戏中添加一个水*滚动条。这可以通过不进行任何 C# 代码更改,完全通过 Unity 5 UI 系统来实现。

要为我们的库存显示实现水*滚动条,我们需要做以下几步:

  1. 将 Panel-background 的高度增加到 110 像素。

  2. Inspector 面板中,将 Panel-slot-grid 的组件 Grid Layout Group (Script)Child Alignment 属性设置为 Upper Left。然后,将此面板稍微向右移动,以便库存图标在屏幕上居中。

  3. Canvas 中添加一个 UI Panel,命名为 Panel-scroll-container,并通过将其 Image (Script) 组件的颜色设置为红色来使其着色。

  4. Hierarchy 面板中,将 Panel-slot-grid 拖动,使其成为 Panel-scroll-container 的子项。

  5. 调整 Panel-scroll-container 的大小和位置,使其正好位于 Panel-slot-grid 之后。将它的 Rect Transform 设置为 top-left,Pos X 为 130,Pos Y 为 -30,Width 为 300 和 Height 为 60。因此,你现在应该看到一个红色矩形在 Panel-slot-grid 库存面板后面。

  6. 将一个 UI Mask 添加到 Panel-scroll-container,现在你应该只能看到 Panel-slot-grid 中适合这个红色着色面板矩形的部分。

一种工作流程是将此遮罩组件临时设置为不活动状态,以便在需要时可以看到并处理 Panel-slot-grid 中未看到的部分。

  1. Canvas 中添加一个 UI Scrollbar,命名为 Scrollbar-horizontal。将其移动到红色着色的 Panel-scroll-container 下方,并调整大小以与以下截图中的相同:

  1. 将 UI Scroll Rect 组件添加到 Panel-scroll-container 中。取消选中此 Scroll Rect 组件的 Vertical 属性。

  2. Inspector 面板中,将 Scrolbar-horizontal 拖动到 Panel-scroll-container 的 Scroll Rect 组件的 Horizontal Scrollbar 属性。

  3. Inspector 面板中,将 Panel-slot-grid 拖动到 Panel-scroll-container 的 Scroll Rect 组件的 Content 属性,如图所示:

  1. 现在,确保 Panel-scroll-container 的遮罩组件设置为活动状态,这样我们就不会看到 Panel-slot-grid 的溢出部分,并取消选中此遮罩组件的 Show Mask Graphic 选项(这样我们就不会再看到红色矩形)。

现在,你应该有一个可以工作的可滚动库存系统。

根据库存中的槽位数量自动调整网格单元格大小

考虑一种情况,我们希望更改槽位数量。使用滚动条等方法的替代方案是在 Grid Layout Group 组件中更改单元格大小。我们可以通过代码来自动执行此操作,以确保单元格大小更改以确保 NUM_INVENTORY_SLOTS 将沿画布顶部的面板宽度适应。

要实现本配方中 Grid Layout Group 单元格大小的自动调整,我们需要执行以下操作:

  • Awake() 方法中注释掉第三条语句:
// panelSlotGrid.GetComponent<RectTransform>().SetSizeWithCurrentAnchors( 
// RectTransform.Axis.Horizontal, width);
  • 将以下方法 Start() 添加到 GameObject player-SpaceGirl 中的 C# 脚本 PlayerInventoryDisplay,代码如下:
void Start() { 
  float panelWidth = panelSlotGrid.GetComponent<RectTransform>().rect.width; 
  print ("slotGrid.GetComponent<RectTransform>().rect = " + panelSlotGrid.GetComponent<RectTransform>().rect); 

  GridLayoutGroup gridLayoutGroup = panelSlotGrid.GetComponent<GridLayoutGroup>(); 
  float xCellSize = panelWidth / NUM_INVENTORY_SLOTS; 
  xCellSize -= gridLayoutGroup.spacing.x; 
  gridLayoutGroup.cellSize = new Vector2(xCellSize, xCellSize); 
} 

我们在 Start() 方法中编写代码,而不是在 Awake() 方法中添加代码,以确保 GameObject Panel-slot-grid 的 RectTransform 已完成尺寸调整(在本配方中,它根据游戏面板的宽度进行拉伸)。虽然我们无法知道场景开始时 Hierarchy GameObjects 的创建顺序,但我们可以依赖 Unity 的行为,即每个 GameObject 都会发送 Awake() 消息,并且只有当所有相应的 Awake() 方法执行完毕后,才会发送 Start() 消息。因此,Start() 方法中的任何代码都可以安全地假设每个 GameObject 都已初始化。

上面的截图显示了 NUM_INVENTORY_SLOTS 的值已更改为 15,并且单元格大小相应地更改,以便所有 15 个现在都能水*地适应我们的面板。请注意,单元格之间的间距从计算出的可用宽度中减去,并除以槽位数量 (xCellSize -= gridLayoutGroup.spacing.x),因为每个显示的项目之间也需要这个间距。

通过脚本化的 PickUp 对象的动态 List<> 将不同对象的多个拾取项显示为文本列表

当处理不同类型的拾取项时,一种方法是用 C# List 维护当前库存中项目的灵活长度数据结构。在本配方中,我们将向您展示,每次拾取一个项目时,都会将一个新的对象添加到这样的 List 集合中。通过遍历 List,每次库存更改时都会生成项目文本显示。我们引入一个非常简单的 PickUp 脚本类,演示如何将拾取的信息存储在脚本组件中,在碰撞时提取,并存储在我们的 List 中。

准备工作

此配方假设您是从本章第一道菜谱中设置的 Simple2Dgame_SpaceGirl 项目开始的。您需要的字体可以在 03_02 文件夹中找到。

如何操作...

要显示不同对象类型的多个拾取项的库存总数文本,请按照以下步骤操作:

  1. Simple2Dgame_SpaceGirl 小游戏的全新副本开始。

  2. 编辑标签,将 Star 改为 Pickup。确保星形 GameObject 现在具有 Pickup 标签。

图片

  1. 将以下 C#脚本PickUp添加到层次结构中的 GameObject 星号:
using UnityEngine; 
using System.Collections; 

public class PickUp : MonoBehaviour { 
  public string description; 
}
  1. 在检查器中,将组件拾取(脚本)的 GameObject 星号的描述属性更改为文本star

图片

  1. 在层次结构面板中选择 GameObject 星号,并复制此 GameObject,将其重命名为 heart。

  2. 在检查器中,将组件Pick Up(脚本)的 GameObject heart 的描述属性更改为文本 heart。同时,将健康 heart 图像从项目面板(在精灵文件夹中)拖动到 GameObject heart 的精灵属性中。现在玩家应该能在屏幕上看到这个拾取物品的心形图像。

  3. 层次结构面板中选择 GameObject 星号,并复制此 GameObject,将其重命名为 key。

  4. 在检查器中,将组件拾取(脚本)的 GameObject 键的描述属性更改为文本键。同时,将项目面板(在精灵文件夹中)中的icon_key_green_100图像拖动到 GameObject 键的精灵属性中。现在玩家应该能在屏幕上看到这个拾取物品的钥匙图像。

  5. 将每个拾取 GameObject 复制一个或两个,并将它们排列在屏幕周围,以便星号、heart 和 key 拾取 GameObject 各有两个或三个。

  6. _Scripts文件夹中创建以下 C#脚本PlayerInventory

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

public class PlayerInventory : MonoBehaviour { 
   private PlayerInventoryDisplay playerInventoryDisplay; 
   private List<PickUp> inventory = new List<PickUp>(); 

   void Awake() { 
         playerInventoryDisplay = GetComponent<PlayerInventoryDisplay>(); 
   } 

   void Start() { 
         playerInventoryDisplay.OnChangeInventory(inventory); 
   } 

   void OnTriggerEnter2D(Collider2D hit) { 
         if(hit.CompareTag("Pickup")){ 
               PickUp item = hit.GetComponent<PickUp>(); 
               inventory.Add( item ); 
               playerInventoryDisplay.OnChangeInventory(inventory); 
               Destroy(hit.gameObject); 
         } 
   } 
} 
  1. 添加一个 UI 文本对象(创建 | UI | 文本)。将其重命名为 Text-inventory-list。将文本更改为“the quick brown fox jumped over the lazy dog the quick brown fox jumped over the lazy dog”,或另一个长列表的胡言乱语,以测试你将在下一步更改的溢出设置。

  2. 在文本(脚本)组件中,确保水*溢出设置为 Wrap,并将垂直溢出设置为溢出。这将确保文本将换行到第二行或第三行(如果需要),并且如果有大量拾取物品,文本不会隐藏。

  3. 检查器面板中,将其字体设置为Xolonium-Bold(在字体文件夹中)并将颜色设置为黄色。对于对齐属性,水*居中文本并确保文本垂直居顶,将字体大小设置为28,并选择黄色文本颜色。

  4. 编辑其矩形变换并将其高度设置为 50。然后,按住Shift + Alt(以设置枢轴和位置),选择顶部拉伸框。现在文本应位于游戏面板的中间顶部,其宽度应拉伸以匹配整个面板的宽度。

  5. 您的文本现在应出现在游戏面板的顶部。

  6. 将以下 C#脚本PlayerInventoryDisplay添加到层次结构中的 GameObject player-girl1:

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

[RequireComponent(typeof(PlayerInventory))] 
public class PlayerInventoryDisplay : MonoBehaviour { 
   public Text inventoryText; 

   public void OnChangeInventory(List<PickUp> inventory) { 
         // (1) clear existing display 
         inventoryText.text = ""; 

         // (2) build up new set of items  
         string newInventoryText = "carrying: "; 
         int numItems = inventory.Count; 
         for(int i = 0; i < numItems; i++){ 
               string description = inventory[i].description; 
               newInventoryText += " [" + description+ "]"; 
         } 

         // if no items in List then set string to message saying inventory is empty 
         if(numItems < 1) 
               newInventoryText = "(empty inventory)"; 

         // (3) update screen display 
         inventoryText.text = newInventoryText; 
   } 
} 
  1. 层次结构中选择 GameObject player-girl1。然后,从检查器中访问玩家库存显示(脚本)组件,并将库存文本公共字段填充为 UI 文本对象 Text-inventory-list。

  2. 玩这个游戏。每次你拿起一颗星星、一把钥匙或一颗心时,你携带的更新列表应以携带 [钥匙] [心] 的形式显示。

它是如何工作的...k

在脚本类 PlayerInventory 中,变量库存是一个 C# List<>。这是一个灵活的数据结构,可以进行排序、搜索,并在游戏运行时动态地添加和删除项目。尖括号中的 <PickUp> 表示变量库存将包含 PickUp 对象的列表。在这个菜谱中,我们的 PickUp 类只有一个字段,即一个字符串描述,但我们在后面的菜谱中会添加更复杂的数据项到 PickUp 类中。这个变量库存初始化为一个空的 C# PickUp 对象列表。

在场景开始之前,脚本类 PlayerAwake() 方法会缓存对 PlayerInventoryDisplay 脚本组件的引用。

当场景开始时,Start() 方法会调用 PlayerInventoryDisplay 脚本组件的 OnChangeInventory(...) 方法。这样,场景开始时显示给用户的文本与变量库存的初始值相对应(对于某些游戏,库存可能不为空。例如,玩家可能带着一些钱、一把基础武器或一张地图开始游戏)。

OnTriggerEnter2D(...) 方法检测到与标记为拾取的物品发生碰撞时,被击中的物品的 PickUp 对象组件将被添加到我们的库存列表中。同时,还会调用 playerInventoryDisplayOnChangeInventory(...) 方法来更新玩家的库存显示,并将更新后的库存列表作为参数传递。

脚本类 playerInventoryDisplay 有一个公共变量,与 UI 文本对象 Text-inventory-list 相链接。OnChangeInventory(...) 方法首先将 UI 文本设置为空,然后遍历库存列表,构建每个物品描述的字符串([钥匙],[心],等等)。如果没有物品在列表中,则字符串设置为文本(空库存)。最后,将 UI Text 对象 Text-inventory-list 的文本属性设置为这个字符串表示的变量库存中的内容。

还有更多...

这里有一些你不想错过的细节。

按字母顺序对库存列表中的物品进行排序

将库存列表中的单词按字母顺序排序会很好,这不仅为了整洁和一致性(因此,在游戏中,如果我们捡起一把钥匙和一颗心,无论它们的拾取顺序如何,看起来都一样),而且还因为相同类型的物品将一起列出,这样我们可以轻松地看到我们携带了多少每种物品。

要在库存列表中对物品进行字母排序,我们需要做以下几步:

  1. 将以下 C# 代码添加到脚本类 PlayerInventoryDisplayOnChangeInventory(...) 方法的开头:
public void OnChangeInventory(List<PickUp> inventory){ 
    inventory.Sort( 
        delegate(PickUp p1, PickUp p2){ 
           return p1.description.CompareTo(p2.description); 
        } 
    ); 

   // rest of the method as before ... 
} 
  1. 你现在应该能看到所有物品按字母顺序列出。

这段 C#代码利用了 C# List.Sort(...)方法,这是集合的一个特性,其中每个项目都可以与下一个项目进行比较,如果顺序错误(如果 CompareTo(...)方法返回 false),则进行交换。更多信息请参阅 https://msdn.microsoft.com/en-us/library/3da4abas(v=vs.110).aspx

通过动态字典<>中的 PickUp 对象和枚举拾取类型显示不同对象的多个拾取作为文本总计

虽然之前的菜谱工作得很好,但任何旧文本可能已经被输入到拾取的描述中,或者可能输入错误(星号,Sstar,starr 等)。一个更好的方法是通过使用 C#枚举来限制游戏属性为预定义(枚举)列表中的一个可能值,这样可以大大减少输入字符串错误的机会。此外,这意味着我们可以编写代码来适当地处理预定义的可能值集合。在这个菜谱中,我们将通过引入三种可能的拾取类型(星号,心形,钥匙)来改进我们的通用PickUp类,并编写库存显示代码,计算携带每种类型拾取的数量,并通过屏幕上的UI Text对象显示这些总计。我们还从使用List切换到使用Dictionary,因为 Dictionary 数据结构专门设计用于键值对,非常适合将数值总计与枚举拾取类型关联起来。

图片

在这个菜谱中,我们将通过引入一个脚本类库存管理器,将控制器(用户收集事件)逻辑与存储的库存数据分离,从而管理额外的复杂性。然后,我们的玩家控制器简化为仅两个方法(Awake,获取库存管理器的引用,以及OnTriggerEnter2D,通过与库存管理器通信来响应碰撞)。

准备工作

这个菜谱是对之前的菜谱的改编。因此,复制之前菜谱的项目,并在这个副本上工作。

如何做到这一点...

要通过动态字典显示不同对象的多个拾取作为文本总计,请按照以下步骤操作:

  1. 将脚本类PickUp的内容替换为以下代码:
using UnityEngine; 

public class PickUp : MonoBehaviour { 
   public enum PickUpType { 
         Star, Key, Heart 
   } 

   public PickUpType type; 
} 
  1. 从 GameObject player-SpaceGirl中移除脚本类PlayerInventory的实例。

  2. 创建一个新的 C#脚本类PlayerController,包含以下代码,并将其实例作为组件添加到 GameObject player-girl1中:

using UnityEngine; 

public class PlayerController : MonoBehaviour { 
   private InventoryManager inventoryManager; 

   void Awake() { 
         inventoryManager = GetComponent<InventoryManager>(); 
   } 

   void OnTriggerEnter2D(Collider2D hit) { 
         if(hit.CompareTag("Pickup")){ 

PickUp item = hit.GetComponent<PickUp> ();

               inventoryManager.Add(item); 
               Destroy(hit.gameObject); 
         } 
   } 
} 
  1. 将脚本类PlayerInventoryDisplay的内容替换为以下代码:
using UnityEngine; 
using UnityEngine.UI; 
using System.Collections.Generic; 

[RequireComponent(typeof(PlayerController))] 
[RequireComponent(typeof(InventoryManager))] 
public class PlayerInventoryDisplay : MonoBehaviour { 
   public Text inventoryText; 

   public void OnChangeInventory(Dictionary<PickUp.PickUpType, int> inventory) { 
         inventoryText.text = ""; 
         string newInventoryText = "carrying: "; 

         foreach (var item in inventory) { 
               int itemTotal = item.Value; 
               string description = item.Key.ToString(); 
               newInventoryText += " [ " + description + " " + itemTotal + " ]"; 
         } 

         int numItems = inventory.Count; 
         if (numItems < 1) 
               newInventoryText = "(empty inventory)"; 

         inventoryText.text = newInventoryText; 
   } 
} 
  1. 将以下 C#脚本InventoryManager的实例添加到 Hierarchy 中的 GameObject player-SpaceGirl
using UnityEngine; 
using System.Collections.Generic; 

public class InventoryManager : MonoBehaviour { 
   private PlayerInventoryDisplay playerInventoryDisplay; 
   private Dictionary<PickUp.PickUpType, int> items = new Dictionary<PickUp.PickUpType, int>(); 

   void Awake() { 
         playerInventoryDisplay = GetComponent<PlayerInventoryDisplay>(); 
   } 

   void Start() { 
         playerInventoryDisplay.OnChangeInventory(items); 
   } 

   public void Add(PickUp pickup) { 
         PickUp.PickUpType type = pickup.type; 
         int oldTotal = 0; 

         if(items.TryGetValue(type, out oldTotal)) 
               items[type] = oldTotal + 1; 
         else 
               items.Add (type, 1); 

         playerInventoryDisplay.OnChangeInventory(items); 
   } 
} 
  1. 层次(或 场景)面板中,依次选择每个拾取 GameObject,并在 检查器面板的下拉菜单中选择其对应的 类型。正如你所看到的,公共变量如果是枚举类型,将自动限制为可能的值集合,作为 检查器面板中的组合框下拉菜单。

  1. 玩游戏。首先,你应该在屏幕上看到一个消息,说明库存为空,然后当你拾取每种拾取类型的单个或多个物品时,你会看到你收集到的每种类型的文本总数。

它是如何工作的...

场景中的每个拾取 GameObject 都有一个 PickUp 类的脚本组件。每个 Pickup GameObject 的 PickUp 对象有一个单一属性,即拾取类型,它必须是 StarKeyHeart 的枚举集合中的一个。使用枚举类型意味着值必须是这三个列出的值之一,因此不可能出现像上一个配方中通用文本字符串类型那样的拼写错误或输入错误。

之前,脚本类 PlayerInventory 脚本有两个职责集:

  • 维护所携带物品的内部记录

  • 检测碰撞,更新状态,并要求显示类通知玩家所携带物品的变化

在这个配方中,我们将这两组职责分别分离到独立的脚本类中:

  • 脚本类 InventoryManager 将维护所携带物品的内部记录(并在每次物品携带发生变化时要求显示类通知玩家)。

  • 脚本类 Player 将检测碰撞,并要求 InventoryManager 更新所携带的内容。

添加这个额外的软件层既将玩家碰撞检测行为与库存的内部存储方式分开,也防止任何单个脚本类因为尝试处理过多的不同职责而变得过于复杂。这个配方是 模型-视图-控制器MVC)设计模式低耦合的一个例子。我们设计代码不依赖于或对游戏的其他部分做出太多假设,以降低游戏其他部分的变化破坏我们的库存显示代码的可能性。显示(视图)与我们所携带的逻辑表示(库存管理器模型)分离,模型的变化是通过玩家(控制器)调用的公共方法进行的。

玩家脚本类通过其 Awake() 方法获取对 InventoryManager 组件的引用,并且每次玩家的角色与拾取 GameObject 发生碰撞时,它都会调用库存管理器的 Add(...) 方法,传递与碰撞对象关联的 PickUp 对象。

在脚本类 InventoryManager 中,玩家携带的库存被表示为一个 C# 字典。字典由一系列 键值对 组成,其中键是可能的 PickUp.PickUpType 枚举 之一,值是携带该类型拾取的整数总计。声明字典时,指定将用于键的类型,然后指定将存储为该键值的类型(或脚本类)。以下是声明我们的字典变量 items 的语句:

items = new Dictionary<PickUp.PickUpType, int>() 

C# 字典提供了 TryGetValue(...) 方法,该方法接收一个键的参数,并传递一个与字典值相同数据类型的变量的引用。当调用库存管理器的 Add(...) 方法时,会测试拾取对象的类型,以查看是否已经在字典项中存在该类型的总计。如果在字典中找到了给定类型的项总计,则将字典中该项目的值增加。如果没有找到给定类型的条目,则将在字典中添加一个新元素,其总计为 1。

TryGetValue 引用参数调用 注意在此语句中参数 oldTotal 前的 C# out 关键字的使用:

items.TryGetValue(type, out oldTotal)

表示正在将实际变量 oldTotal 的引用传递给方法 TryGetValue(...),而不仅仅是其值的副本。这意味着该方法可以更改变量的值。

如果在字典中找到了给定类型的条目,则方法返回 true,并且如果找到,则将 oldTotal 的值设置为该键的值。

Add(...) 方法的最后一个动作是调用玩家 GameObject 的脚本组件 PlayerInventoryDisplay 中的 OnChangeInventory(...) 方法,以更新屏幕上显示的文本总计。

脚本类 PlayerInventoryDisplayOnChangeInventory(...) 方法首先初始化字符串变量 newInventoryText,然后遍历字典中的每个项目,将当前项目的类型名称和总计作为字符串附加到 newInventoryText 上。最后,将 UI 文本对象的文本属性更新为 newInventoryText 中的完整文本,向玩家显示拾取总计。

在 Unity 技术教程中了解更多关于在 Unity 中使用 C# 列表和字典的信息,请访问 unity3d.com/learn/tutorials/modules/intermediate/scripting/lists-and-dictionaries

第四章:播放并操作声音

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

  • 使用单个AudioSource播放不同的单次声音效果

  • 播放并控制各自带有自己的AudioSource的不同声音

  • 通过 C#脚本在运行时创建即时AudioSource组件

  • 在播放声音前延迟

  • 防止正在播放的音频剪辑重新开始

  • 在对象自动销毁前等待音频播放完成

  • 通过 dspTime 的精确调度创建节拍器

  • 将音频音调与动画速度匹配

  • 使用混响区域模拟声学环境

  • 使用音频混音器添加音量控制

  • 使用快照制作动态配乐

  • 使用 ducking *衡游戏中的音频

  • 从样本频谱数据中进行音频可视化

  • 同步同时和顺序音乐以创建简单的 140 bpm 音乐循环管理器

简介

声音是游戏体验的重要组成部分。实际上,无法过分强调它对玩家沉浸于虚拟环境中的重要性。想想你最喜欢的赛车游戏中引擎的轰鸣声,模拟游戏中的遥远城市嘈杂声,或者恐怖游戏中的缓慢声音。想想这些声音如何将你带入游戏。

整体概念

在继续介绍食谱之前,让我们首先回顾一下 Unity 中不同的声音功能是如何工作的。需要音频的项目需要一个或多个音频文件,在 Unity 中称为AudioClips,这些文件位于你的项目文件夹中。在撰写本文时,Unity 2017 支持四种音频文件格式:.wav.ogg.mp3.aif。这些类型的文件在 Unity 为目标*台构建时会被重新编码。它还支持四种格式的跟踪模块:.xm、.mod.****it.s3m

一个场景或预制 GameObject 可以拥有一个AudioSource组件——该组件可以在设计时链接到一个AudioClip声音文件,或者在运行时通过脚本进行链接。在任何场景中,都有一个 GameObject 内的活动AudioListener组件。当你创建一个新的场景时,其中一个自动添加到Main Camera中。可以将AudioListener想象成一个模拟的数字“耳朵”,因为 Unity 播放的声音是基于播放的AudioSources和活动AudioListener之间的关系。

简单的声音,如拾取效果和背景配乐音乐,可以定义为2D 声音。然而,Unity 支持3D 声音,这意味着播放的AudioSources和活动AudioListener之间的位置和距离决定了声音在响度和左右*衡方面的感知方式。

您也可以通过AudioSettings.dspTime来设计同步声音播放和调度——这是一个基于音频系统样本的值,因此它比Time.time值要精确得多。此外,dspTime将与场景一起暂停/挂起,因此在使用dspTime时无需重新安排逻辑。本章中的一些食谱展示了这种方法。

*年来,Unity 为游戏音频添加了一个强大的新功能:AudioMixerAudioMixer彻底改变了玩家体验声音元素以及游戏开发者与之交互的方式。它允许我们以与音乐家和制作人他们在数字音频工作站(D.A.W.)中(如GarageBandProTools)相同的方式混音和排列音频。它允许您将AudioSource剪辑路由到特定通道,这些通道可以单独调整音量并经过定制效果和滤波器处理。您可以使用多个AudioMixers,将混音器的输出发送到父混音器,并将混音偏好保存为快照。您还可以从脚本中访问混音器参数。以下图表示了 Unity 音频混音的主要概念及其关系:

上面的图表示了 Unity 音频混音的主要概念及其关系:

利用许多示例项目中新的 AudioMixer 功能,本章充满了希望帮助您为项目实现更好、更高效的声音设计的食谱,增强玩家的沉浸感,将他们带入游戏环境,甚至改善游戏玩法。

未来音频功能

这些食谱展示了在运行时管理音频和引入动态效果的脚本和 Unity 音频系统方法。当效果和音乐的音频环境可以根据游戏中的上下文微妙地改变时,游戏可以变得更加引人入胜——无论您选择的效果是混响区域、降低某些声音的音量以暂时降低其重要性,还是允许用户控制音频音量。

最后,随着 3D VR 游戏播放时引入环绕声音频,特殊音频的可行性现在变得更加有趣——根据声音是在听者上方还是下方,以及它们与音频源的距离,提供丰富的音频体验。环绕声音频的一些参考资源包括:

使用单个AudioSource组件播放不同的单次音效

在 Unity 中播放声音的基本操作非常简单(将AudioSource组件添加到 GameObject 并链接到AudioClip音效文件)。对于简单的音效,如短暂的拾取确认声音,有一个单一的AudioSource组件并重复使用它来播放不同的音效非常有用——这正是本食谱中我们将要做的。

准备工作

尝试使用任何时长小于一秒的短音频片段。我们在04_01文件夹中包含了几个经典的《吃豆人》游戏音效剪辑。

如何做到这一点...

要使用相同的AudioSource组件播放多个声音,请执行以下操作:

  1. 创建一个新的 Unity 2D 项目并导入音效剪辑文件。

  2. 在新文件夹_Scripts中创建一个名为PlaySounds 的 C#脚本类,并添加以下代码,然后将其实例作为脚本组件添加到主相机

using UnityEngine; 

[RequireComponent(typeof(AudioSource))] 
public class PlaySounds : MonoBehaviour  
{ 
    public AudioClip clipEatCherry; 
    public AudioClip clipExtraLife; 

    private AudioSource audioAudioSource; 

    void Awake() { 
        audioAudioSource = GetComponent<AudioSource>(); 
    } 

    void Update() { 
        if (Input.GetKey(KeyCode.UpArrow))
            audioAudioSource.PlayOneShot(clipEatCherry); 

        if (Input.GetKey(KeyCode.DownArrow)) 
            audioAudioSource.PlayOneShot(clipExtraLife); 
    } 
}
  1. 确保在层次结构中选择主相机GameObject。然后,在检查器面板中,将项目面板中的 Pacman Eating Cherry 音效剪辑拖动到PlaySounds (Script)脚本组件中的公共 Pacman Eating Cherry AudioClip变量中。为 Pacman Extra Life 音效剪辑重复此步骤。这些步骤在截图中有说明:

  1. 运行场景,并按箭头键播放不同的音效。

它是如何工作的...

您已创建了一个 C#脚本类PlaySounds。该脚本类包含一个RequireComponent属性,声明任何包含此类脚本对象组件的 GameObject 必须有一个兄弟AudioSource组件(如果脚本组件添加时不存在此类组件,则会自动添加一个)。

PlaySounds脚本类有两个公共AudioClip属性:Pacman Eating CherryPacman Extra Life。在设计时,我们将项目面板中的AudioClip音效文件与这些公共属性关联。

在运行时,Update()方法在每一帧都会执行。此方法检查数组键是否被按下,如果是,则相应地播放吃樱桃或额外生命的声音——向AudioSource组件发送一个带有适当AudioClip音效文件链接的PlayOneShot()消息。

注意:使用PlayOneShot播放的声音无法暂停/查询

虽然PlayOneShot()方法非常适合短时、单次音效,但其局限性在于无法查询播放中的声音状态(是否已结束,播放到什么位置等)。也无法暂停/重新播放使用PlayOneShot()播放的声音。对于这种详细的声音控制,每个声音都需要自己的 AudioSource 组件。

在 Unity 文档中了解更多关于PlayOneShot()方法的信息:docs.unity3d.com/ScriptReference/AudioSource.PlayOneShot.html

还有更多...

有一些细节你不希望错过。

在 3D 世界空间中的静态点播放声音

PlayOneShot()类似的是PlayClipAtPoint() AudioSource方法。这允许你在 3D 世界空间中的特定点播放声音剪辑。请注意,这是一个静态类方法 - 因此你不需要AudioSource组件来使用此方法 - AudioSource组件将在你给出的位置创建,并且只要AudioClip声音在播放,它就会存在。Unity 将在声音播放完毕后自动删除AudioSource组件。你所需要的只是一个Vector3(x,y,z)位置对象,以及要播放的AudioClip文件的引用:

Vector3 location = new Vector3(10, 10, 10); 
AudioSource.PlayClipAtPoint(soundClipToPlay, location); 

播放和控制具有各自 AudioSource 组件的不同声音

虽然在先前的配方(使用单个AudioSourcePlayOneShot(...))中对于单次音效来说是个不错的选择,但当需要进一步控制播放中的声音时,每个声音都需要在它自己的AudioSource组件中播放。在这个配方中,我们将创建两个独立的AudioSource组件,并使用不同的箭头键暂停/恢复每个。

准备工作

尝试使用两个几秒钟长的音频剪辑。我们在文件夹04_02中包含了两个免费的音乐剪辑。

如何做到...

要播放不同的声音,每个声音都有自己的 AudioSource 组件,请执行以下操作:

  1. 创建一个新的 Unity 2D 项目并导入音频剪辑文件。

  2. 在场景中创建一个包含AudioSource组件的 GameObject,该组件链接到 186772__dafawe__medieval AudioClip。这可以通过将音乐剪辑从Project面板拖动到HierarchyScene面板来完成。将这个新 GameObject 重命名为 music1_medieval。

  3. 重复前面的步骤创建另一个名为 music2_arcade 的 GameObject,其中包含一个AudioSource组件,链接到 251461__joshuaempyre__arcade-music-loop。

  4. 对于创建的两个 AudioSources,取消选中 Play Awake 属性 - 因此这些声音不会在场景加载时立即播放。

  5. 创建一个名为 Manager 的空 GameObject。

  6. 在名为_Scripts的新文件夹中创建一个 C#脚本类,名为 MusicManager,包含以下代码,并将其实例作为脚本组件添加到 Manager GameObject 中:

using UnityEngine; 

public class MusicManager : MonoBehaviour  { 
    public AudioSource audioSourceMedieval; 
    public AudioSource audioSourceArcade; 

    void Update() { 
        if (Input.GetKey(KeyCode.RightArrow)){ 
            if (audioSourceMedieval.time > 0) 
                audioSourceMedieval.UnPause(); 
            else 
                audioSourceMedieval.Play(); 
        } 

        if (Input.GetKey(KeyCode.LeftArrow)) 
            audioSourceMedieval.Pause(); 

        if (Input.GetKey(KeyCode.UpArrow)){ 
            if (audioSourceArcade.time > 0) 
                audioSourceArcade.UnPause(); 
            else 
                audioSourceArcade.Play(); 
        } 

        if (Input.GetKey(KeyCode.DownArrow)) 
            audioSourceArcade.Pause(); 
    } 
} 
  1. 确保在层次结构中选中了Manager游戏对象。在检查器面板中,将场景面板中的 music1_medieval 游戏对象拖动到MusicManager (脚本)脚本组件中的公共Audio Source Medieval AudioSource 变量中。重复此步骤,将 GameObject music2_arcade 拖动到公共Audio Source Arcade变量中。

  2. 运行场景,并按箭头键以开始/恢复和暂停中世纪声音片段。按箭头键以开始/恢复和暂停街机声音片段。

它是如何工作的...

您创建了一个 C#脚本类,MusicManager,并将此类的实例作为组件添加到 Manager 游戏对象中。您还在场景中创建了两个游戏对象,分别命名为 music1_medieval 和 music2_arcade,每个都包含一个与不同音乐片段链接的AudioSource组件。

脚本类有两个公共AudioSource属性:Music MedievalMusic Arcade。在设计时,我们将游戏对象music1_medieval 和 music2_arcade 的AudioSource组件与这些公共属性关联。

运行时Update()方法在每一帧执行。此方法检查上/下/右/左数组键是否被按下。如果检测到箭头键,则向中世纪音乐音频源发送Play()UnPause()消息。如果片段尚未播放(其时间属性为零),则发送Play()消息。如果按下箭头键,则向中世纪音乐音频源发送Pause()消息。

通过检测 RIGHT/LEFT 数组键来以相应的方式控制街机音乐片段。

每个与自己的AudioSource组件关联的AudioClip声音文件允许同时播放和管理每个声音。

通过 C#脚本在运行时创建即时 AudioSource 组件

在之前的配方中,对于每个我们想要管理的声音片段,在场景中我们必须在设计时手动创建带有AudioSource组件的游戏对象。然而,使用 C#脚本,我们可以在运行时创建自己的包含AudioSources游戏对象,正好在它们需要的时候。这种方法类似于内置的AudioSource PlayClipAtPoint()方法,但创建的AudioSource组件完全受我们的程序控制——尽管我们随后必须负责在不再需要时销毁此组件。

这段代码受到了 2011 年在在线Unity Answers论坛中由用户 Bunny83 发布的部分代码的启发。Unity 有一个伟大的在线社区,互相帮助并分享添加游戏功能的有意思的方法。了解更多关于这篇帖子,请访问answers.unity3d.com/questions/123772/playoneshot-returns-false-for-isplaying.html

准备工作

这个菜谱是对上一个菜谱的改进。因此,请复制用于上一个菜谱的项目,并在这个副本上工作。

如何做到这一点...

要通过 C# 脚本在运行时创建即时 AudioSource 组件,请执行以下操作:

  1. 从场景中删除 music1_medieval 和 music-loop GameObjects – 在这个菜谱中,我们将在 运行时 创建这些对象!

  2. MusicManager C# 脚本类重构如下(注意,Update() 方法未更改):

using UnityEngine; 

public class MusicManager : MonoBehaviour { 
    public AudioClip clipMedieval; 
    public AudioClip clipArcade; 

    private AudioSource audioSourceMedieval; 
    private AudioSource audioSourceArcade; 

    void Awake() { 
        audioSourceMedieval = CreateAudioSource(clipMedieval, true); 
        audioSourceArcade = CreateAudioSource(clipArcade, false); 
    } 

    private AudioSource CreateAudioSource(AudioClip audioClip, bool startPlayingImmediately) { 
        GameObject audioSourceGO = new GameObject(); 
           audioSourceGO.transform.parent = transform; 
        audioSourceGO.transform.position = transform.position; 
        AudioSource newAudioSource = audioSourceGO.AddComponent<AudioSource>() as AudioSource; 
        newAudioSource.clip = audioClip; 
        if(startPlayingImmediately) 
            newAudioSource.Play(); 

        return newAudioSource; 
    } 

    void Update(){ 
        if (Input.GetKey(KeyCode.RightArrow)){ 
            if (audioSourceMedieval.time > 0) 
                audioSourceMedieval.UnPause(); 
            else 
                audioSourceMedieval.Play(); 
        } 

        if (Input.GetKey(KeyCode.LeftArrow)) 
            audioSourceMedieval.Pause(); 

        if (Input.GetKey(KeyCode.UpArrow)){ 
            if (audioSourceArcade.time > 0) 
                audioSourceArcade.UnPause(); 
            else 
                audioSourceArcade.Play();             
        } 

        if (Input.GetKey(KeyCode.DownArrow)) 
            audioSourceArcade.Pause(); 
   } 
} 
  1. 确保在 Hierarchy 中选择了 MainCamera GameObject。在 Inspector 面板中,将项目面板中的 AudioClip 186772__dafawe__medieval 声音剪辑拖放到 MusicManager (Script) 脚本组件中的公共 Clip Medieval AudioClip 变量中。使用 AudioClip 251461__joshuaempyre__arcade-music-loop 对 Clip Arcade 变量重复此过程。

  2. 运行场景,并按 UPDOWN 箭头键以开始/恢复和暂停中世纪声音剪辑。按 RIGHTLEFT 箭头键以开始/恢复和暂停街机声音剪辑。

它是如何工作的...

这个菜谱的关键特性是新的 CreateAudioSource(...) 方法。该方法接受一个声音剪辑文件的引用和一个布尔值(true/false),表示声音是否应该立即开始播放。该方法执行以下操作:

  • 创建一个新的 GameObject(与创建它的 GameObject 具有相同的父级和位置)

  • 在新的 GameObject 中添加一个新的 AudioSource 组件

  • 将新 AudioSource 组件的音频剪辑设置为提供的 AudioClip 参数

  • 如果布尔参数为 true,则立即向 AudioSource 组件发送 Play() 消息以开始播放声音剪辑

  • 返回 AudioSource 组件的引用

MusicManager 脚本类的其余部分与上一个菜谱中的非常相似。有两个公共 AudioClip 变量,clipMedievalclipArcade,它们在 设计时 通过拖放设置,以链接到 Sounds Project 文件夹中的声音剪辑文件。

audioSourceMedievalaudioSourceArcade AudioSource 变量现在是私有的。这些值在 Awake() 方法中设置,通过调用并存储由 CreateAudioSource(...) 方法返回的值,使用 clipMedievalclipArcade AudioClip 变量。

为了说明布尔参数的工作方式,中世纪音乐的 AudioSource 被创建为立即播放,而街机音乐将在按下 UP 键后才开始播放。播放/恢复/暂停两个音频剪辑与上一个菜谱中的相同 – 通过在(未更改的)Update() 方法中的箭头键检测逻辑。

还有更多...

有一些细节你不希望错过。

将 CreateAudioSource(...) 方法作为对 MonoBehavior 类的扩展

由于CreateAudioSource(...)方法是一个通用方法,可以被许多不同的游戏脚本类使用,它自然不会位于MusicManager类中。此类通用生成方法的最佳位置是将它们作为静态(类)方法添加到它们工作的组件类中 – 在这种情况下,如果能将此方法添加到MonoBehavior类本身中那就太好了 – 这样任何脚本组件都可以在运行时创建AudioSource GameObject。

我们需要做的就是创建一个类(通常命名为ExtensionMethods),其中包含一个静态方法,如下所示:

using UnityEngine; 

public static class ExtensionMethods { 
    public static AudioSource CreateAudioSource(this MonoBehaviour parent, AudioClip audioClip, bool startPlayingImmediately) 
    { 
         GameObject audioSourceGO = new GameObject("music-player"); 
         audioSourceGO.transform.parent = parent.transform; 
         audioSourceGO.transform.position = parent.transform.position; 
         AudioSource newAudioSource = audioSourceGO.AddComponent<AudioSource>() as AudioSource; 
         newAudioSource.clip = audioClip; 

         if (startPlayingImmediately) 
               newAudioSource.Play(); 

         return newAudioSource; 
    } 
} 

如我们所见,我们在扩展方法中添加了一个额外的第一个参数,指定我们要将此方法添加到哪个类。由于我们已经将其添加到MonoBehavior类中,因此现在我们可以在我们的脚本类中使用此方法,就像它是内置的。因此,我们的MusicManager类中的Awake()方法如下所示:

void Awake() { 
   audioSourceMedieval = this.CreateAudioSource(clipMedieval, true); 
   audioSourceArcade = this.CreateAudioSource(clipArcade, false); 
} 

就这样 – 我们现在可以从MusicManager类中删除该方法,并在任何我们的MonoBehavior脚本类中使用此方法。

在播放声音之前延迟

有时我们不想立即播放声音,而是在短暂的延迟后播放。例如,我们可能想要等待一秒钟或两秒钟再播放声音,以表示毒药的效果稍微延迟或玩家进入了一个削弱玩家的法术。对于此类情况,AudioSource提供了PlayDelayed(...)方法。这个配方说明了对于我们不希望立即开始播放声音的情况的一个简单方法。

准备工作

尝试使用两个几秒钟长的音频剪辑。我们在04_04文件夹中包含了两个免费的音频剪辑。

如何做到这一点...

要安排在给定延迟后播放声音,请执行以下操作:

  1. 创建一个新的 Unity 2D 项目并导入音频剪辑文件。

  2. 在场景中创建一个包含AudioSource组件的GameObject,该组件连接到Pacman 开场曲AudioClip。这可以通过一次操作完成,即从项目面板拖动音乐剪辑到HierarchyScene面板中。

  3. 重复前面的步骤创建另一个GameObject,其中包含一个连接到Pacman 死亡剪辑的AudioSource

  4. 对于创建的两个AudioSources,取消选中“Play Awake”属性 – 因此这些声音不会在场景加载时立即播放。

  5. 在屏幕上创建一个名为Button-musicUI Button,将其文本更改为“立即播放音乐”。

  6. 在屏幕上创建一个名为Button-diesUI Button,将其文本更改为“1 秒后播放死亡声音”。

  7. 创建一个名为 SoundManager 的空GameObject

  8. 在新文件夹_Scripts中创建一个名为DelayedSoundManager的 C#脚本类,包含以下代码,并将其作为脚本组件添加到 SoundManager GameObject

using UnityEngine; 

public class DelayedSoundManager : MonoBehaviour { 
    public AudioSource audioSourcePacmandMusic; 
    public AudioSource audioSourceDies; 

    public void ACTION_PlayMusicNow() { 
        audioSourcePacmandMusic.Play(); 
    } 

    public void ACTION_PlayDiesSoundAfterDelay() { 
        float delay = 1.0F; 
        audioSourceDies.PlayDelayed(delay); 
    } 
} 
  1. 在层次结构面板中选择 Button-music GameObject,创建一个新的点击事件处理程序,将 SoundsManager GameObject 拖入对象槽,并选择 ACTION_PlayMusicNow() 方法。

  2. 在层次结构面板中选择 Button-dies GameObject,创建一个新的点击事件处理程序,将 SoundsManager GameObject 拖入对象槽,并选择 ACTION_PlayDiesSoundAfterDelay() 方法。

它是如何工作的...

您向场景中添加了两个 GameObjects,包含连接到音乐和死亡音效剪辑的 AudioSources。您创建了一个 C# 脚本类,DelayedSoundManager,并将其实例添加到一个空 GameObject 中。您将您的脚本组件中的两个公共变量与您的 GameObjects 中的两个 AudioSources 关联起来。

您创建了两个按钮:

  • 按钮音乐,点击动作用于调用 DelayedSoundManager.ACTION_PlayMusicNow() 方法

  • 按钮失效,点击动作用于调用 DelayedSoundManager.PlayDiesSoundAfterDelay() 方法。

DelayedSoundManager.ACTION_PlayMusicNow() 方法立即向连接到 Pacman 开场曲 AudioClip 的音频源发送 Play() 消息。然而,DelayedSoundManager.PlayDiesSoundAfterDelay() 方法向连接到 Pacman 死亡 AudioClip 的音频源发送 PlayDelayed(...) 消息,传递值为 1.0,使 Unity 等待 1 秒后再播放音频片段。

防止音频片段在播放时重新启动

在游戏中,可能有多个不同的事件会导致特定的声音效果开始播放。如果声音已经在播放,那么在几乎所有情况下,我们都不希望重新启动声音。这个配方包括一个测试,以确保只有当音频源组件当前未播放时,才会发送 Play() 消息。

准备工作

尝试使用任何时长为一秒或更长的音频片段。我们已将 engineSound 音频剪辑包含在 04_05 文件夹中。

如何做到这一点...

要防止 AudioClip 重新启动,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目并导入音频剪辑文件。

  2. 在场景中创建一个包含 AudioSource 组件的 GameObject,该组件连接到 AudioClip engineSound。这可以通过从项目面板拖动音乐剪辑到层次结构或场景面板的单一步骤完成。

  3. 取消选中 engineSound GameObject 的 AudioSource 组件的 Play Awake 属性 - 因此,当场景加载时,此声音不会开始播放。

  4. 创建一个名为 Button-play-sound 的 UI 按钮,将其文本更改为播放声音。通过设置其 Rect Transform 属性位置为中间中心,将按钮放置在屏幕中央。

  5. 在一个名为 _Scripts 的新文件夹中创建一个 C# 脚本类 WaitToFinishBeforePlaying,包含以下代码,并将其作为脚本组件添加到 Main Camera GameObject 中:

using UnityEngine; 
using UnityEngine.UI; 

public class WaitToFinishBeforePlaying : MonoBehaviour  { 
   public AudioSource audioSource; 
   public Text buttonText; 

   void Update() { 
         string statusMessage = "Play sound"; 
         if(audioSource.isPlaying ) 
               statusMessage = "(sound playing)"; 

         buttonText.text = statusMessage; 
   } 

   public void ACTION_PlaySoundIfNotPlaying() { 
         if( !audioSource.isPlaying ) 
               audioSource.Play(); 
   } 
} 
  1. 层次结构面板中选择主摄像机,将 engineSound 拖动到检查器面板中的公共 Audio Source 变量,并将 Button-play-sound 的 Text 子组件拖动到公共 ButtonText。

  2. 层次结构面板中选择 Button-play-sound,创建一个新的 on-click 事件处理器,将 Main Camera 拖动到对象槽中,并选择ACTION_PlaySoundIfNotPlaying()函数。

工作原理...

Audio Source组件有一个公共可读属性,isPlaying,它是一个布尔值 true/false 标志,指示声音是否正在播放。在本配方中,当声音未播放时,按钮文本设置为显示播放声音,当正在播放时显示(sound playing)。当按钮被点击时,会调用ACTION_PlaySoundIfNotPlaying()方法。此方法使用if语句,确保只有当 Audio Source 组件的isPlaying为 false 时,才向 Audio Source 组件发送Play()消息,并适当地更新按钮的文本。

参考以下内容

本章中关于在音频播放完成后自动销毁对象的配方。

在对象自动销毁前等待音频播放完成

可能会发生某些事件(例如拾取对象或杀死敌人),我们希望通过播放音频片段并关联一个视觉对象(例如爆炸粒子系统或事件位置的临时对象)来通知玩家。然而,一旦音频片段播放完成,我们希望将视觉对象从场景中移除。本配方提供了一种简单的方法,将播放音频片段的结束与包含对象的自动销毁链接起来。

准备工作

尝试使用任何时长为一秒或更长的音频片段。我们已在04_06文件夹中包含了engineSound音频片段。

如何操作...

要在销毁其父 GameObject 之前等待音频播放完成,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目并导入音频片段文件。

  2. 在场景中创建一个包含AudioSource组件并链接到AudioClip engineSound的 GameObject。这可以通过将音乐片段从项目面板拖动到层次结构场景面板中一步完成。将此 GameObject 重命名为 AudioObject。

  3. 取消勾选engineSound GameObject 的 AudioSource 组件的 Play Awake 属性,这样当场景加载时,这个声音不会立即开始播放。

  4. 在新文件夹_Scripts中创建一个 C#脚本类,名为AudioDestructBehaviour,并包含以下代码,然后将其实例作为脚本组件添加到 AudioObject GameObject 中:

using UnityEngine; 
using UnityEngine; 

public class AudioDestructBehaviour : MonoBehaviour { 
   private AudioSource audioSource; 

   void Awake() { 
         audioSource = GetComponent<AudioSource>(); 
   } 

   private void Update() { 
         if( !audioSource.isPlaying ) 
               Destroy(gameObject); 
   } 
} 
  1. 检查器面板中禁用(取消选中)AudioObject 的AudioDestructBehaviour脚本组件(当需要时,将通过 C#代码重新启用):

图片

  1. _Scripts 文件夹中创建一个名为 ButtonActions 的 C# 脚本类,包含以下代码,并将其实例作为脚本组件添加到主摄像机 GameObject 中:
using UnityEngine; 

public class ButtonActions : MonoBehaviour { 
   public AudioSource audioSource; 

   public AudioDestructBehaviour audioDestructScriptedObject; 

   public void ACTION_PlaySound() { 
         if( !audioSource.isPlaying ) 
               audioSource.Play(); 
   } 

   public void ACTION_DestroyAfterSoundStops(){ 
         audioDestructScriptedObject.enabled = true; 
   } 
} 
  1. Hierarchy 面板中选择主摄像机,将 AudioObject 拖入

    Inspector 面板中的公共音频源变量。

  2. Hierarchy 面板中选择主摄像机,将 AudioObject 拖入

    Inspector 面板中的公共音频销毁脚本对象变量。

  3. 创建一个名为 Button-play-sound 的 UI 按钮,将其文本更改为 Play Sound。通过设置其 Rect Transform 属性为中间中心,将按钮放置在屏幕中央。

  4. Hierarchy 面板中选择 Button-play-sound,创建一个新的点击事件处理器,将主摄像机拖入对象槽中,并选择 ACTION_PlaySound() 函数。

  5. 创建第二个 UI 按钮,命名为 Button-destroy-when-finished-playing,将其文本更改为 Destroy When Sound Finished。通过将按钮的 Rect Transform 属性设置为中间中心,并将按钮向下拖动一点,将按钮放置在屏幕中央(位于另一个按钮下方)。

  6. Hierarchy 面板中选择 Button-destroy-when-finished-playing,创建一个新的点击事件处理器,将主摄像机拖入对象槽中,并选择 ACTION_DestroyAfterSoundStops() 函数。

  7. 运行场景。点击 Play Sound 按钮将每次播放引擎声音。然而,一旦点击了 Destroy When Sound Finished 按钮,一旦引擎声音播放完毕,你将看到 AudioObject GameObject 从 Hierarchy 面板中消失,因为该 GameObject 已经自我销毁。

它是如何工作的...

你创建了一个名为 ButtonActions 的脚本类,并将其实例作为一个组件添加到了主摄像机 GameObject 中。这个类有两个公共变量,一个指向 AudioSource,另一个指向 AudioDestructBehaviour 脚本组件的实例。

命名为 AudioObject 的 GameObject 包含一个 AudioSource 组件,该组件存储和管理音频剪辑的播放。AudioObject 还包含一个脚本组件,它是 AudioDestructBehaviour 类的实例。此脚本最初是禁用的。当启用时,该对象中的每一帧(通过其 Update() 方法)都会测试音频源是否正在播放(!audio.isPlaying)。一旦发现音频未播放,该 GameObject 将被销毁。

创建了两个 UI 按钮。Button-play-sound 按钮会调用 Main Camera 中脚本组件的 ACTION_PlaySound() 方法。此方法将开始播放音频剪辑,如果它尚未播放的话。

第二个按钮,Button-destroy-when-finished-playing,调用 Main Camera 中脚本组件的ACTION_``DestoryAfterSoundStops()方法。此方法启用AudioObject GameObject 中的AudioDestructBehaviour脚本组件 – 因此AudioObject GameObject将在其AudioSource声音播放完毕后销毁。

参见

本章中防止音频剪辑在播放时重新启动的配方。

通过精确安排声音播放时间创建节拍器

在需要精确安排声音播放时间的情况下,我们应该使用AudioSource.PlayScheduled(...)方法。此方法使用AudioSettings.dspTime值,该值基于通过 Unity 音频系统播放的音乐数据,非常精确。dspTime值的另一个优点是它独立于图形渲染帧率:

图片

注意,当游戏暂停或挂起时,dspTime值会自动冻结 – 因此使用此方法安排的音乐将完美地暂停和恢复,与场景游戏玩法同步。在这个配方中,我们将通过精确安排两个不同声音播放的时间来创建节拍器。请注意,这个配方基于 Unity 文档中AudioSource.PlayScheduled(...)方法的某些示例:docs.unity3d.com/ScriptReference/AudioSource.PlayScheduled.html

准备工作

对于这个配方,我们在04_07文件夹中提供了两个节拍器声音剪辑。

如何做到这一点...

要在给定延迟后播放声音,请执行以下操作:

  1. 创建一个新的 Unity 2D 项目并导入提供的声音剪辑文件。

  2. 在场景中创建一个包含与metronome_tick AudioClip链接的AudioSource组件的 GameObject。这可以通过将音乐剪辑从项目面板拖动到HierarchyScene面板的单个步骤来完成。

  3. 重复前面的步骤创建另一个 GameObject,包含一个与 metronome_tick_accent 剪辑链接的AudioSource

  4. 对于创建的两个AudioSources,取消选中 Play Awake 属性 – 因此这些声音不会在场景加载时立即开始播放。

  5. 创建一个名为 MetronomeManager 的空 GameObject。

  6. 在新的文件夹_Scripts中创建一个名为 Metronome 的 C#脚本类,包含以下代码,并将其作为脚本组件添加到 MetronomeManager GameObject 中:

using UnityEngine; 

public class Metronome : MonoBehaviour { 
    public AudioSource audioSourceTickBasic; 
    public AudioSource audioSourceTickAccent; 

    public double bpm = 140.0F; 
    public int beatsPerMeasure = 4; 

    private double nextTickTime = 0.0F; 
    private int beatCount; 
    private double beatDuration; 

    void Start() { 
        beatDuration = 60.0F / bpm; 
        beatCount = beatsPerMeasure; // so about to do a beat 
        double startTick = AudioSettings.dspTime; 
        nextTickTime = startTick; 
    } 

    void Update() { 
        if (IsNearlyTimeForNextTick()) 
            BeatAction(); 
    } 

    private bool IsNearlyTimeForNextTick() { 
        float lookAhead = 0.1F; 
        if ((AudioSettings.dspTime + lookAhead) >= nextTickTime) 
            return true; 
        else 
            return false; 
    } 

    private void BeatAction() { 
        beatCount++; 
        string accentMessage = ""; 

        if (beatCount > beatsPerMeasure) 
            accentMessage = AccentBeatAction(); 
        else 
            audioSourceTickBasic.PlayScheduled(nextTickTime); 

        nextTickTime += beatDuration; 
        print("Tick: " + beatCount + "/" + signatureHi + accentMessage); 
    } 

    private string AccentBeatAction() { 
        audioSourceTickAccent.PlayScheduled(nextTickTime); 
        beatCount = 1; 
        return " -- ACCENT ---"; 
    } 
} 
  1. 在 Hierarchy 中选择 MetronomeManager GameObject。将 metronome_tick 从 Hierarchy 拖动到Inspector中的 Audio Source Tick Basic 公共变量,用于 Metronome (Script)组件。

  2. 将 metronome_tick_accent 从 Hierarchy 拖动到 Inspector 中的 Audio Source Tick Accent 公共变量,用于 Metronome (Script)组件:

图片

  1. 播放场景。你将在(控制台)中看到并听到定时的节拍声,每个计数的第一拍将播放强调(并且更响亮)的声音。

  2. 尝试更改 Bpm(每分钟节拍数)设置,以加快或减慢节拍器的速度。或者更改每个强调节拍之间的节拍数,以计数到 3、4 或 6 个节拍。

它是如何工作的...

你在场景中添加了两个 GameIObjects,包含与基本和强调节拍器的“tick”音乐剪辑链接的 AudioSources。你创建了一个 Metronome C# 脚本类并将其添加到一个空 GameObject 中。你将你的 GameObject 中的两个 AudioSources 与脚本组件中的两个公共变量关联起来。

Start() 方法计算每个节拍的持续时间(基于 bpm),初始化节拍计数(因此第一个节拍是强调节拍),然后设置 nextTickTime,下一个 tick 的时间为当前的 dspTime。

IsNearlyTimeForNextTick() 方法返回一个布尔值 true/false,表示是否接*安排下一个 tick 的时间。返回的值基于当前 dspTime 是否在 nextTickTime 的 1/10^(th) 秒内。如果是,则返回 true。

Update() 方法是一个单行 if 语句。如果接*下一个节拍的时间,则调用 BeatAction() 方法。

BeatAction() 方法执行以下操作:

  • 将节拍数加 1

  • accentMessage 字符串初始化为空(默认值)

  • IF:

    下一个节拍应该是 1(强调),存储 AccentBeatAction() 和返回的字符串在 accentMessage

  • ELSE(如果不是强调下一个节拍):

    安排基本的节拍声音

  • 计算下一个节拍时间(当前节拍时间 + 每个节拍的持续时间)

  • 它在控制台中显示节拍计数信息(包括任何强调信息的字符串)

AccentBeatAction() 方法执行三件事:它安排强调节拍声音,重置节拍计数为 1,并返回一个字符串,与节拍信息一起显示(带有文本表示下一个是强调节拍:-- ACCENT ---)。

还有更多...

有一些细节你不希望错过。

为基本和强调节拍创建即时 AudioSource GameObjects

我们可以通过使用在 Creating just-in-time AudioSource components at runtime through C# scripting 菜谱中提出的扩展方法来减少节拍器的 Design-Time 工作量。

首先,将 ExtensionMethods.cs C# 脚本类复制到你的节拍器项目中。然后,删除场景中包含 AudioSource 组件的两个 GameObjects,而是为每个 AudioClip () 声明两个公共变量。最后,我们只需要编写一个 Awake() 方法,该方法将在场景中创建所需的 GameObjects,包含基于 AudioClip 变量的 AudioSource(并且使基本的节拍比强调的节拍更轻):

void Awake() { 
   audioSourceTickBasic = this.CreateAudioSource(clipTickBasic, false); 
   audioSourceTickBasic.volume = 0.5F; 

   audioSourceTickAccent = this.CreateAudioSource(clipTickAccent, false); 
   audioSourceTickAccent.volume = 1.00F; 
} 

通过数据而不是 AudioClips 创建节拍声音

Unity 文档中关于dspTime的说明提供了一个有趣的创建节拍器基本和重音节的方法——尽管是编辑音频数据样本本身。查看他们的脚本节拍器在docs.unity3d.com/ScriptReference/AudioSettings-dspTime.html

将音频音调与动画速度匹配

许多在加速时音调更高,在减速时音调更低的文物。汽车引擎、风扇冷却器、唱机...等等。如果你想在可以动态改变速度的动画对象中模拟这种音效,请遵循这个食谱。

准备工作

对于这个食谱,你需要一个动画 3D 对象和一个音频剪辑。请使用04_08文件夹中可用的animatedRocket.fbxengineSound.wav文件。

如何做到这一点...

要根据动画对象的速度更改音频剪辑的音调,请按照以下步骤操作:

  1. 创建一个新的 Unity 3D 项目。

  2. 在项目面板中创建一个新的 Models 文件夹,并将提供的 animatedRocket.fbx 文件导入到其中。

  3. 在项目面板中创建一个新的 Sounds 文件夹,并将提供的音频剪辑 engineSound.wav 导入到其中。

  4. 在项目面板中选择 animatedRocket 文件。在 animatedRocket 导入设置的检查器中,单击动画按钮。在动画中选择(唯一的)Take 001 剪辑,并确保选中 Loop Time 选项。单击应用按钮以保存更改。请参阅截图以了解这些设置:

我们不需要检查Loop Pose选项的原因是因为我们的动画已经以无缝的方式循环。如果它没有,我们可以检查该选项以自动创建从动画最后一帧到第一帧的无缝过渡。

  1. 通过从项目面板拖动到场景或层次结构面板,将animatedRocket的实例添加到场景中的 GameObject。

  2. 将一个 AudioSource 组件添加到 engineSound GameObject 中。

  3. 层次结构中选择 engineSound,将 engineSound AudioClip 文件从项目面板拖动到 Audio Source 组件的检查器中的音频剪辑参数。确保选中 Loop 选项,取消选中 Play On Awake 选项:

  1. 为我们的模型创建一个 Animator Controller。在项目面板中选择 Models 文件夹,并使用创建菜单创建一个名为 Rocket Controller 的新Animator Controller 文件。

  2. 双击项目面板中的 Rocket Controller 文件以打开Animator面板。通过选择菜单选项:创建状态 | 空状态(如图所示)来创建一个新状态:

  1. 将这个新状态重命名为 spin(在其检查器属性中),并在运动字段中将 Take 001 设置为它的动作:

  1. 层次结构 面板中选择 animatedRocket。从项目面板中的 Models 文件夹将 Rocket Controller 拖动到 检查器 中 Animator 组件的控制器参数。确保在 检查器 中未选中应用根运动选项:

图片

  1. _Scripts 文件夹中创建一个 C# 脚本类 ChangePitch,包含以下代码,并将其作为脚本组件添加到 animatedRocket GameObject 中:
using UnityEngine; 

public class ChangePitch : MonoBehaviour{ 
   public float acceleration = 0.05f; 
   public float minSpeed = 0.0f; 
   public float maxSpeed = 2.0f; 
   public float animationSoundRatio = 1.0f; 

   private float speed = 0.0f; 
   private Animator animator; 
   private AudioSource audioSource; 

    private void Awake() { 
        animator = GetComponent<Animator>(); 
        audioSource = GetComponent<AudioSource>(); 
    } 

    void Start() { 
         speed = animator.speed; 
        AccelerateRocket (0); 
   }      

   void Update() { 
         if (Input.GetKey (KeyCode.Alpha1)) 
            AccelerateRocket(acceleration); 

         if (Input.GetKey (KeyCode.Alpha2)) 
            AccelerateRocket(-acceleration); 
   } 

   public void AccelerateRocket(float acceleration) { 
         speed += acceleration; 
         speed = Mathf.Clamp(speed,minSpeed,maxSpeed); 
         animator.speed = speed; 
         float soundPitch = animator.speed * animationSoundRatio; 
         audioSource.pitch = Mathf.Abs(soundPitch); 
   } 
} 
  1. 播放场景,并通过按键盘上的数字键 1(加速)和 2(减速)来改变动画速度。音调将相应地改变。

它是如何工作的...

你创建了一个 C# 脚本类,ChangePitch,并将其实例添加到 animatedRocket GameObject 中。它声明了几个变量,其中最重要的是 acceleration

它的 Awake() 方法缓存了对 Animator 和 AudioSource 兄弟组件的引用。Start() 方法从 Animator 设置初始速度,并调用 AccelerateRocket(...) 方法,传递 0 来计算音频源的音调。

在每一帧中,Update() 方法测试键盘键 1 和 2。当检测到时,它们将调用 AccelerateRocket(...) 方法,传递适当的加速度的正值或负值。

AccelerateRocket(...) 方法通过接收到的参数增加变量速度。Mathf.Clamp() 命令将新的速度值限制在最小和最大速度之间。然后,它根据新的速度绝对(正值)值改变 Animator 速度和 Audio Source 音调。该值随后再次被限制以避免出现负数。如果你希望反转动画,请查看此菜谱提供的解决方案中的代码文件。

请注意,将动画速度和因此音调设置为 0 将导致声音停止,这清楚地表明停止对象的动画也会阻止引擎声音播放。

还有更多...

这里有一些关于如何微调和自定义这个菜谱的信息。

改变动画/声音比率

如果你希望音频剪辑的音调更多地或更少地受动画速度的影响,请更改检查器中的公共动画/声音比率参数的值。

从其他脚本访问函数

AccelerateRocket(...) 函数被设置为公共,以便可以从其他脚本访问。例如,我们在 _Scripts 文件夹中包含了 ExternalChangePitch.cs 脚本。为了说明如何从另一个脚本控制 ChangePitch 脚本组件,请执行以下操作:

  1. 将此脚本附加到 主摄像机 GameObject。从层次结构面板中将 animatedRocket GameObject 拖动到公共 Change Pitch Scripted Component 变量中。

  2. 运行场景。

  3. 使用 方向键来控制动画速度(以及音调)。

允许反向动画(负速度!)

在动画器面板中,创建一个新的浮点参数,速度,初始化为 1.0。在动画器面板中选择旋转状态,勾选速度参数乘数选项并选择速度参数。在检查器中,将最小速度设置为-2,以允许动画使用负速度。

ChangePitch C#脚本中,将AccelerateRocket方法替换为以下内容:

public void AccelerateRocket(float acceleration) { 
   speed += acceleration;  
   speed = Mathf.Clamp(speed, minSpeed, maxSpeed); 

   animator.SetFloat("Speed", speed); 
   float soundPitch = speed * animationSoundRatio; 
   audioSource.pitch = Mathf.Abs(soundPitch); 
} 

现在当你使用键 1(加速)和 2(减速)时,你实际上可以将速度减速到零然后继续反向动画。

使用 Reverb Zones 模拟声学环境

一旦你创建了你的级别几何形状并且场景看起来正是你想要的,你可能希望你的声音效果与那种外观相匹配。声音的行为取决于其投影的环境,因此使其相应地产生回声可能是一个好主意。在这个菜谱中,我们将通过使用 Reverb Zones 来处理这种声学效果。

准备工作

对于这个菜谱,我们准备了ReverbZone.unitypackage文件,其中包含一个名为reverbScene的基本级别和一个预制件,Signal。该包位于04_09文件夹中。

如何做到这一点...

按照以下步骤模拟隧道的声学景观:

  1. 创建一个新的 Unity 3D 项目。

  2. 将提供的UnityReverbZone导入到你的项目中。

  3. 从项目面板中,打开reverbScene——它在ReverbZones文件夹中的_Scenes文件夹中。这个场景为你提供了一个隧道,和一个可控制的角色(W A S D键和Shift键用于奔跑)。

  4. 从项目面板中,将 Signal 预制件拖放到层次结构中——它在ReverbZones文件夹中的_Prefabs文件夹中。这将向场景添加一个发声对象。将其放置在隧道的中心:

图片

  1. Signal游戏对象复制五次并分布在整个隧道中(在每个入口外留一个副本):

图片

  1. 在层次结构面板中,使用创建菜单 | 音频 | 音频混响区域来向场景添加一个混响区域。然后将这个新游戏对象放置在隧道的中心。

如果已经有一个 GameObject 位于你想要放置另一个 GameObject 的位置,请使用菜单 GameObject | Move To View。由于我们的 Signal GameObject 位于隧道的中间,我们可以双击该 GameObject,然后单击 Reverb Zone 并移动到视图——将 Reverb Zone 对象移动到相同的位置。

  1. 选择混响区域游戏对象。在检查器面板中,将混响区域组件参数更改为以下值:最小距离 = 6最大距离 = 18,和预设 = StoneCorridor

图片

  1. 播放场景。当你穿过隧道时,你会在混响区域区域内听到音频回声。

它是如何工作的...

一旦定位,音频混响区域将对其半径内的所有音频源应用音频过滤器。

更多内容...

这里有一些更多选项供您尝试。

将音频混响区域组件附加到音频源

您可以不创建音频混响区域 GameObject,而是通过组件 | 音频 | 音频混响区域菜单将其附加到发声对象(在我们的案例中,是 Signal)作为组件。在这种情况下,混响区域将围绕其父 GameObject 单独设置。

创建自己的混响设置

Unity 自带几个混响预设。我们使用了石质走廊,但您的场景可能需要更不强烈(例如房间)或更激进(例如精神错乱)的预设。如果这些预设仍然无法重现您心中的效果,请将其更改为用户预设,并根据您的意愿编辑其参数。

使用音频混音器添加音量控制

声音音量调整可能是一个非常重要的功能,尤其是如果您的游戏是独立游戏。毕竟,玩家不得不访问操作系统音量控制可能会非常令人沮丧。在这个配方中,我们将使用音频混音器功能为音乐和音效创建独立的音量控制。

准备工作

对于这个配方,我们提供了一个名为VolumeControl.unitypackage的 Unity 包,其中包含一个具有配乐音乐和音效的初始场景。文件位于04_10文件夹中。

如何做到这一点...

要将音量控制滑块添加到您的场景中,请按照以下步骤操作:

  1. 创建一个新的 Unity 3D 项目。

  2. 将提供的Unity包,音量,导入到您的项目中。

  3. 项目面板文件夹中打开音量场景,音量控制 | _Scenes

  4. 播放场景,并使用W A S D键(按Shift键跑步)走向隧道中半透明的绿色墙。您将能够听到:

    • 一个循环的音乐配乐

    • 钟声响起

    • 当角色与墙壁碰撞时发出机器人语音

  5. 项目面板中,使用创建菜单添加一个音频混音器文件。将此新文件重命名为主混音器

  6. 双击主混音器以打开音频混音器面板。

  7. 音频混音器面板的分组部分,突出显示,然后单击+(加号)以向组添加子项。将此子项命名为音乐。重复这些操作以添加名为FX的第二个子项:

图片

  1. 音频混音器面板的混音器部分,突出显示主混音器,然后单击+(加号)以向混音器组添加新项。将此重命名为音乐混音器(您可能需要通过项目面板重命名,因为您已通过此过程创建了一个新的音频混音器文件)。

  2. 音乐混音器拖放到主混音器(将其作为子项),并在弹出对话框窗口中选择音乐作为其输出。

  3. 音频混音器面板的混音器部分,突出显示主混音器,然后单击+(加号)以向混音器组添加新项。将此命名为FxMixer

  4. 将(拖动)FxMixer拖放到主混音器上,并在弹出对话框窗口中选择 FX 作为其输出。

  5. 选择 MusicMixer。选择其主组并添加一个名为 Soundtrack 的子项。

  6. 选择FxMixer,并为其组添加两个子项:一个命名为 Speech,另一个命名为 Bells:

图片

  1. 层级面板中选择DialogueTriggerGameObject。在检查器中,将它的音频源组件输出轨道更改为FxMixer | Speech

图片

  1. 层级面板中选择SoundtrackGameObject。在检查器中,将它的音频源组件输出轨道更改为MusicMixer | Soundtrack

  2. 项目面板的预制体文件夹中选择Signal。在检查器中,将它的音频源组件输出轨道更改为 FxMixer | Bells。

  3. 音频混合器面板中选择主混合器,并选择其轨道。在检查器面板中,右键单击衰减组件中的音量。从弹出上下文菜单中选择暴露(主)音量到脚本:

图片

  1. 重复此操作以将音量暴露给音乐和 FX 组的脚本。

  2. 音频混合器面板的右上角,你应该看到暴露参数(三个)。点击下拉图标,并按以下名称重命名:MyExposedParam 为 OverallVolume;MyExposedParam1 为 MusicVolume,MyExposedParam2 为 FxVolume。注意三个参数的顺序可能不匹配你添加它们的顺序,所以请确保右侧灰色条目的名称正确对应:

图片

  1. 层级面板中,使用创建下拉菜单将UI 面板添加到场景(菜单:创建 | UI | 面板)。Unity 将自动为该面板添加一个 Canvas 父级。

  2. 层级面板中,创建一个 UI Slider 到场景(菜单:创建 | UI | Slider)。将其设置为面板对象的子项。将此滑块重命名为Slider-overall。将滑块的 Min Value 设置为0.000025(或2.5e-05)。

  3. 复制它并将其新副本重命名为 Slider-music。在检查器面板中,Rect Transform组件,将它的 Pos Y 参数更改为-40

  4. 复制 Slider-music 并将其新副本重命名为Slider-fx。将它的Pos Y参数更改为-70

图片

  1. _Scripts文件夹中创建一个 C#脚本类,VolumeControl,包含以下代码,并将其作为脚本组件添加到主相机GameObject:
using UnityEngine; 
using UnityEngine.Audio; 

public class VolumeControl : MonoBehaviour { 
    public GameObject panel; 
   public AudioMixer myMixer; 
   private bool isPaused = false; 

   void Start(){ 
        panel.SetActive(false); 

        ON_CHANGE_OverallVol(0.01F); 
        ON_CHANGE_MusicVol(0.01F); 
        ON_CHANGE_FxVol(0.01F); 
   } 

   void Update() { 
         if (Input.GetKeyUp (KeyCode.Escape)) { 
               panel.SetActive(!panel.activeInHierarchy); 

               if(isPaused) 
                     Time.timeScale = 1.0f; 
               else 
                     Time.timeScale = 0.0f; 

               isPaused = !isPaused; 
         }            
   }      

    public void ON_CHANGE_OverallVol(float vol) { 
        myMixer.SetFloat("OverallVolume", Mathf.Log10(vol) * 20f); 
    } 

   public void ON_CHANGE_MusicVol(float vol) { 
         myMixer.SetFloat ("MusicVolume", Mathf.Log10(vol) * 20f); 
   } 

   public void ON_CHANGE_FxVol(float vol) { 
         myMixer.SetFloat ("FxVolume", Mathf.Log10(vol) * 20f); 
   } 
} 
  1. 主相机选项卡中选择层级面板,将面板GameObject 拖入检查器以设置公共面板变量。

  2. 层级面板中选择主相机,将主混合器从项目面板拖入检查器以设置公共 My Mixer 变量。

  3. 选择OverallSlider组件。在On Value Changed列表下方,点击加号以添加一个动作。从Hierarchy面板中,将 Main Camera 拖入对象槽,并使用下拉菜单选择 VolumeControl | ON_CHANGE_OverallVol 选项。  为了测试目的,将适当的选择器从仅运行时更改为编辑器和运行时。

  4. 使用 MusicSlider 和 FxSlider 重复前面的步骤,但这次,从下拉菜单中选择 ON_CHANGE_MusicVol 和 ON_CHANGE_FxVol 选项,分别。

  5. 播放场景。您可以通过按键盘上的ESCAPE键来访问滑块,并从那里调整音量设置。

它是如何工作的...

音频混音器功能的工作方式与数字音频工作站类似,例如LogicSonar。通过音频混音器,您可以通过将它们路由到特定的组来组织和管理工作元素,这些组可以具有可以调整的独立音频轨道,从而允许调整音量级别和音效。

通过将我们的音频剪辑组织并路由到两个组(音乐和音效),我们建立了 MainMixer 作为音量统一的控制器。然后,我们使用音频混音器公开MainMixer中每个轨道的音量级别,使它们可供我们的脚本访问。

此外,我们设置了一个基本的用户界面,包含三个滑块,当使用时,将它们的浮点值(介于 0.000025 和 1 之间)作为参数传递到脚本中的三个特定函数:ON_CHANGE_MusicVol、ON_CHANGE_FxVol 和 ON_CHANGE_OverallVol。这些函数反过来使用 SetFloat 命令在运行时有效地更改音量级别。然而,在传递新的音量级别之前,脚本将线性值(介于 0.000025 和 1 之间)转换为音频混音器使用的分贝级别。这种转换是通过 log(x) * 20 数学函数计算的。

关于将线性值转换为分贝级别以及相反的问题的完整解释,请查看 Aaron Brown 在www.playdotsound.com/portfolio-item/decibel-db-to-float-value-calculator-making-sense-of-linear-values-in-audio-tools/上的优秀文章。

如果其中一个滑块似乎不起作用,请检查SetFloat(...)的第一个参数名称是否与AudioMixer中暴露的参数匹配——任何拼写差异都意味着更改滑块的 on-change 函数将不会在Audio Mixer中更改值。例如,如果命名的暴露参数被错误地命名为“OveralVolume”(缺少一个“l”),则由于拼写不匹配,此响应滑块更改的语句将不起作用:

myMixer.SetFloat ("OverallVolume ", Mathf.Log10(vol) * 20f); 

VolumeControl脚本包含代码,根据玩家是否按ESCAPE键激活/停用音量控制滑块来启用和禁用 UI 和EventSystem

由于我们的 VolumeControl 脚本设置了音乐和效果音轨的最大音量级别,因此在设计时不应手动更改 MainMixer 的任何音轨音量。对于一般调整,请使用辅助的 MusicMixer 和 FxMixer 混音器。

更多内容...

这里有一些关于音频混音器的额外信息。

播放音频制作

对于公开的参数有许多创意用途。例如,我们可以向音频通道添加效果,如失真镶边合唱,使用户能够操作虚拟音表/混音板。

参见

本章中关于使用快照制作动态音轨的配方。

本章中关于在游戏中*衡音频与 ducking

使用快照制作动态音轨

动态音轨是根据游戏中玩家发生的情况而变化的音轨,音乐上反映了角色的冒险地点或时刻。在这个配方中,我们将实现一个音轨,它将改变两次;第一次是在进入隧道时,第二次是在出来时。为了实现这一点,我们将使用音频混音器快照功能。

快照是一种保存您的音频混音器状态的方法,保留音量级别、音频效果等的偏好设置。我们可以通过 C#脚本访问这些状态,创建混音之间的过渡,并为玩家旅程的每个时刻提供所需的音效氛围。

准备工作

对于这个配方,我们准备了一个基本的游戏关卡,包含在名为DynamicSoundtrack的 Unity 包中,以及两个.ogg格式的音轨音频剪辑:Theme01_PercussionTheme01_Synths。所有这些文件都可以在04_11文件夹中找到。

如何操作...

要制作动态音轨,请遵循以下步骤:

  1. 创建一个新的 Unity 3D 项目。

  2. 将提供的 Unity 包 DynamicSoundtrack 以及两个.ogg文件导入到您的项目中。

  3. 项目面板文件夹打开 Dynamic 场景,DynamicSoundtrack | _Scenes。

  4. 在项目面板中,使用创建菜单添加一个音频混音器文件。将此新文件重命名为 Mixer-music。双击它以打开音频混音器面板。

  5. 在音频混音器面板的组部分,突出显示主组并单击+(加号)以向主组添加子项。将此子项命名为 Percussion。重复这些操作以向主组添加第二个子项,命名为 Synths:

图片

  1. 层次结构视图中创建一个新的GameObject。将其命名为 Music。

  2. 在场景中创建一个包含AudioSource组件的 GameObject,该组件链接到 Theme01_Percussion AudioClip。这可以通过将音乐剪辑从项目面板拖动到层次结构或场景面板的单一步骤完成。将此新 GameObject 作为子对象添加到MusicGameObject 的层次结构中。

  3. 确保在层次结构中选择了 Theme01_Percussion 游戏对象。在音频源组件的检查器中,将输出更改为 Percussion(音乐混合器),确保唤醒时播放选项被选中,选中循环选项,并确保其空间混合设置为 2D:

图片

  1. 对 Theme01_Synths 的AudioClip重复前两个步骤——将输出设置为 Synths(音乐混合器)。

  2. 打开音频混合器并播放场景。我们现在将使用混合器为场景的开始设置音轨。在场景播放时,点击音频混合器顶部的编辑在播放模式按钮,如图所示。将合成器轨道的音量降低到-30 dB

图片

  1. 选择打击乐轨道。点击衰减的添加..按钮,并添加一个高通效果。从检查器视图,将高通效果的截止频率更改为544.00 Hz

图片

  1. 到目前为止的每一次更改,都分配给了当前的快照。从快照视图,在当前快照上右键单击并重命名为 Start。点击+(加号)符号以复制当前快照,并将此副本重命名为 Tunnel:

图片

  1. 选择隧道快照,并选择打击乐组的高通效果。在检查器中,将截止频率设置为10.00 Hz:

图片

  1. 隧道开始快照之间切换。你将能够听到差异。

  2. 复制隧道快照,将其重命名为OtherSide,并选择它。

  3. 合成器轨道的音量提升到 0 dB。

  4. 现在我们有了三个快照,是时候创建触发器以在它们之间进行转换了。

  5. 停止运行场景(以便将层次结构中的更改存储到场景中)。

  6. 层次结构中创建一个Cube游戏对象(菜单:创建 | 3D 对象 | 立方体)。将其命名为 Cube-tunnel-trigger。

  7. 在检查器中,访问 Cube-tunnel-trigger 游戏对象的 Box Collider 组件,并检查是触发器选项。取消选中其网格渲染器组件。调整其大小和位置以适应场景隧道内部。你可能发现场景线框视图对立方体碰撞器的定位很有用:

图片

  1. 将立方体Cube-tunnel-trigger复制两次,并将它们重命名为cube Cube-start-triggercube Cube-otherside-trigger。调整它们的大小和位置,使它们占据隧道入口(人物所在的位置)和出口之后的空间:

图片

  1. 在 _Scripts 文件夹中创建一个 C#脚本类,SnapshotTrigger,包含以下代码:
using UnityEngine; 
using UnityEngine.Audio; 

public class SnapshotTrigger : MonoBehaviour { 
   public AudioMixerSnapshot snapshot; 
   public float crossfade; 

   private void OnTriggerEnter(Collider other) { 
         snapshot.TransitionTo (crossfade); 
   } 
} 
  1. SnapshotTrigger实例添加到所有三个触发立方体(隧道触发立方体、起点触发立方体和另一侧触发立方体)。

  2. 选择隧道触发立方体。从快照触发器(脚本)组件的检查器中,将快照设置为隧道,并将交叉淡入设置为 2:

图片

  1. 通过将它们的快照分别设置为开始另一侧来更改起点触发立方体另一侧触发立方体,同时将交叉淡入设置为2

  2. 测试场景。背景音乐将在角色从起点移动到隧道,然后从另一侧出来时改变。

它是如何工作的...

快照功能允许你保存音频混音器状态(包括所有音量级别和每个滤波器设置),这样你就可以在运行时更改这些混音偏好,使音频设计更适合特定的位置或游戏设置。对于这个菜谱,我们为玩家旅程中的不同时刻创建了三个快照

  • 在进入隧道之前

  • 隧道内

  • 隧道外

我们使用了高通滤波器来使初始快照不那么强烈。我们还提高了合成音轨的音量,以强调隧道外的开阔环境。我们的目标是让音频混音的变化帮助为游戏设定正确的氛围。

为了激活我们的快照,我们放置了触发碰撞体,其中包含我们的快照触发器脚本组件,我们设置了所需的快照和过渡(交叉淡入)到下一个快照所需的时间(秒)。实际上,我们脚本中的功能非常直接——snapshot.TransitionTo(crossfade)代码行简单地开始一个持续crossfade秒的过渡到所需的快照。

还有更多...

这里有一些关于如何微调和自定义这个菜谱的信息。

减少对多个音频剪辑的需求

你可能已经注意到,当高通滤波器的截止频率设置为10.00 Hz时,Theme01_Percussion音频剪辑听起来有多么不同。这是因为高通滤波器,正如其名称所暗示的,切断了音频信号的较低频率。在这种情况下,它将低音鼓衰减到不可听见的水*,同时保持摇铃可听。通过低通滤波器可以达到相反的效果。一个主要的好处是

在同一个音频剪辑中拥有两个独立音轨的机会。

处理音频文件格式和压缩率

为了避免音频质量下降,你应该使用适合目标*台的适当文件格式导入你的声音剪辑。如果你不确定使用哪种格式,请查看 Unity 关于此主题的文档,链接为docs.unity3d.com/Manual/AudioFiles.html

将快照应用于背景噪声

尽管我们已经将快照应用到我们的音乐音轨上,背景噪音也可以受益匪浅。如果你的角色穿越了显著不同的地方,从开阔空间过渡到室内环境,你应该考虑将快照应用到你的环境音频混音中。但是,请注意为音乐和环境创建单独的音频混音器——除非你不在乎音乐和环境的混音。

与同一快照绑定的声音

使用效果进行创意

在这个菜谱中,我们提到了高通和低通滤波器。然而,有许多效果可以使音频片段听起来截然不同。实验吧!尝试应用如失真、镶边和合唱等效果。实际上,我们鼓励你尝试每一个效果,玩转它们的设置。这些效果的创意使用可以为单个音频片段带来不同的表现。

参见

本章中的添加音量控制与音频混音器菜谱。

本章中的*衡音轨音量与 Ducking菜谱。

使用 Ducking *衡游戏内音频

背景音乐在建立正确氛围方面可能很重要,但有时其他音频片段应该被强调,音乐音量在片段播放期间降低。这种效果被称为** ducking。你可能需要它来产生戏剧效果(模拟爆炸发生后听力受损),或者你可能想确保玩家听到作为音频速度剪辑呈现的特定信息。在这个菜谱中,我们将学习如何在播放特定声音消息时通过 ducking 来强调一段对话。为了达到这种效果,我们将使用音频混音器**在轨道之间传递信息。

准备工作

对于这个菜谱,我们提供了soundtrack.mp3音频剪辑和一个名为 Ducking.unitypackage 的 Unity 包,其中包含一个初始场景。所有这些文件都位于04_12文件夹内。

如何操作...

要将音频 Ducking 应用到你的音轨上,请按照以下步骤操作:

  1. 创建一个新的Unity 3D项目。

  2. 将提供的UnityDuckingsoundtrack.mp3文件导入到你的项目中。

  3. 项目面板文件夹中打开 Ducking 场景,Ducking | _Scenes

  4. 通过播放场景并使用W、A、SD键(按Shift键跑步)进入运行时,走向隧道中半透明的绿色墙。当角色与绿色墙碰撞时,你会听到机器人 Ducking 语音音频剪辑播放(“这是你的船长在说话...”)。然后停止场景播放以返回设计时。

  5. 在场景中创建一个GameObject,其中包含一个与音轨AudioClip链接的AudioSource组件。这可以通过将音乐剪辑从项目面板拖动到层次结构场景面板来完成。

  6. 确保在层次结构中选择 GameObject 音轨。在检查器中,对于音频源组件,确保已勾选唤醒时播放选项,勾选循环选项,并确保其空间混合设置为 2D(如果需要,请参阅前一个菜谱中关于打击乐器 GameObject 相同操作的截图)。

  7. 再次播放场景。音轨音乐应该正在播放。然后停止场景播放以返回到设计时间

  8. 项目面板中,使用创建菜单添加一个音频混音器文件。将这个新文件重命名为MainMixer

  9. 双击MainMixer以打开音频混音器面板。

  10. 音频混音器面板的部分,高亮显示并点击+(加号)来向组添加一个子项。将此子项命名为音乐。重复这些操作以添加主组的第二个子项,命名为 FX。向组添加第三个子项,命名为输入:

图片

  1. 在音频混音器视图中,通过点击+(加号)来添加一个新的混音器,将一个新的混音器添加到项目中。将其命名为MusicMixer。将其拖入MainMixer(成为其子项)并选择音乐组作为其输出:

图片

  1. 重复上一步,将另一个名为FxMixer的子项添加到项目中,选择 FX 组作为输出。

  2. 选择MusicMixer。选择其组并添加一个名为Soundtrack的子项:

图片

  1. 选择FxMixer并添加一个名为Bells的子项。

  2. 层次结构视图中选择DialogueTrigger GameObject。在检查器中,将输出轨道更改为MainMixer | 输入,对于音频源组件:

图片

  1. 选择Soundtrack GameObject。在检查器中,对于音频 组件,将其输出轨道更改为MusicMixer | Soundtrack

  2. 从项目面板中的降低音量 | 预制件文件夹中选择信号预制件。在检查器中,将其音频源组件输出设置为FxMixer | Bells

  3. 打开音频混音器窗口。选择MainMixer,选择音乐轨道控制器,右键点击衰减,并使用上下文菜单,添加降低音量效果:

图片

  1. 选择输入轨道,右键点击衰减,并使用上下文菜单,添加发送

  2. 在仍然选择输入轨道的情况下,转到检查器视图,并将发送中的接收设置更改为音乐\降低音量,并将其发送级别设置为 0.00 db。

  3. 选择音乐轨道。从检查器视图,按照以下方式更改降低音量的设置:阈值-40.00 db比率300.00%攻击时间100.00 ms释放时间200.00 ms

图片

  1. 再次测试场景。进入触发对象将导致配乐音量显著降低,两秒后恢复到原始音量。

它是如何工作的...

在这个菜谱中,除了音乐和音效之外,我们还创建了一个名为“输入”的组,我们将触发鸭音量效果的音频剪辑路由到我们的音乐轨道。鸭音量效果会在接收到比其阈值设置更响亮的输入时改变轨道的音量。在我们的情况下,我们将输入轨道作为输入,并调整设置,以便在接收到输入后的 0.1 秒内降低音量,在输入停止后的 2 秒后恢复到原始值。音量降低的量由我们的 300.00%比率确定。调整设置值将更好地了解每个参数对最终结果的影响。此外,确保在触发声音播放时可视化图形。您将能够看到输入声音如何通过阈值,触发效果。

轨道组织得很好,其他声音剪辑(除语音外)不会影响音乐的音量——但每个音乐剪辑都会受到发送到输入轨道的音频剪辑的影响。

参见

本章中的使用音频混音器添加音量控制菜谱。

本章中的使用快照制作动态配乐菜谱。

从样本频谱数据中进行的音频可视化

Unity 音频系统允许我们通过AudioSource.GetSpectrumData(...)方法访问音乐数据——这给了我们使用这些数据来展示所听到的整体声音的运行时可视化(来自AudioListener),或者由单个AudioSources播放的个别声音。

截图显示了使用 Unity 提供的示例脚本绘制的线条(docs.unity3d.com/ScriptReference/AudioSource.GetSpectrumData.html):

然而,在那段示例代码中,他们使用Debug.DrawLine()只会在在 Unity 编辑器中运行游戏时(不是最终构建)出现在场景面板中,因此游戏玩家看不到。在这个菜谱中,我们将使用相同的频谱数据,并在游戏面板中创建一个运行时音频频谱可视化。我们将通过创建一排 512 个小立方体来实现这一点,然后根据播放的AudioSource组件的 512 个音频数据样本在每个帧中改变它们的高度。

准备工作

对于这个菜谱,我们在04_13文件夹中提供了几个 140 bmp 采样免费音乐剪辑。

如何做...

要安排在给定延迟后播放声音,请执行以下操作:

  1. 创建一个新的 3D 项目并导入提供的音频剪辑文件。

  2. 在检查器中,将主相机的背景设置为黑色。

  3. 将主相机变换位置设置为(224,50,-200)。

  4. 将主摄像机 Camera 组件的设置设置为:投影 = 透视,视场角 60,裁剪*面 0.3 - 300。

  5. DirectionalLight 添加到场景中。

  6. 在场景中创建一个新的空 GameObject,命名为 visualizer。向此 GameObject 添加 AudioSource 组件,并将其 AudioClip 设置为提供的 140 个 bmp 循环之一。

  7. 在新的文件夹 _Scripts 中创建一个 C# 脚本类,SpectrumCubes,包含以下代码,并将其作为脚本组件添加到 visualizer GameObject 中:

using UnityEngine; 

public class SpectrumCubes : MonoBehaviour  
{ 
    const int NUM_SAMPLES = 512; 
    public Color displayColor; 
    public float multiplier = 5000; 
    public float startY; 
    public float maxHeight = 50; 
    private AudioSource audioSource; 
    private float[] spectrum = new float[NUM_SAMPLES]; 
    private GameObject[] cubes = new GameObject[NUM_SAMPLES]; 

    void Awake() { 
        audioSource = GetComponent<AudioSource>(); 
        CreateCubes(); 
    } 

    void Update() { 
        audioSource.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris); 
        UpdateCubeHeights(); 
    } 

    private void UpdateCubeHeights() { 
        for (int i = 0; i < NUM_SAMPLES; i++) 
        { 
            Vector3 oldScale = cubes[i].transform.localScale; 
            Vector3 scaler = new Vector3(oldScale.x, HeightFromSample(spectrum[i]), oldScale.z); 
            cubes[i].transform.localScale = scaler; 
            Vector3 oldPosition = cubes[i].transform.position; 
            float newY = startY + cubes[i].transform.localScale.y / 2; 
            Vector3 newPosition = new Vector3(oldPosition.x, newY, oldPosition.z); 
            cubes[i].transform.position = newPosition; 
        } 
    } 

    private float HeightFromSample(float sample) { 
        float height = 2 + (sample * multiplier); 
        return Mathf.Clamp(height, 0, maxHeight); 
    } 

    private void CreateCubes() { 
        for (int i = 0; i < NUM_SAMPLES; i++) { 
            GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube); 
            cube.transform.parent = transform; 
            cube.name = "SampleCube" + i; 

            Renderer cubeRenderer = cube.GetComponent<Renderer>(); 
            cubeRenderer.material = new Material(Shader.Find("Specular")); 
            cubeRenderer.sharedMaterial.color = displayColor; 

            float x = 0.9f * i; 
            float y = startY; 
            float z = 0; 
            cube.transform.position = new Vector3(x, y, z); 

            cubes[i] = cube; 
        } 
    } 

} 
  1. Hierarchy 中选择 visualizer GameObject,点击从 Inspector Display Color 公共变量中选择一个可视化颜色用于 SpectrumCubes (脚本) 组件。

  2. 运行场景——您应该看到立方体上下跳动,展示正在播放的声音的音谱的运行时可视化。

它是如何工作的...

您创建了一个 C# 脚本类 SpectrumCubes。您创建了一个具有 AudioSource 组件的 GameObject,以及您脚本类的实例。所有工作都是由 SpectrumCubes C# 脚本类中的方法完成的,所以以下各节将解释这些方法。

void Awake() 方法

此方法缓存了对同级 AudioSource 组件的引用,然后调用 CreateCubes() 方法。

void CreateCubes() 方法

此方法循环样本数量(默认为 512),沿 X 轴创建一个 3D 立方体 GameObject,逐行排列。每个立方体以 "Cube"(其中 "i" 从 0 到 511)命名,然后将其作为父对象添加到 visualizer GameObject(因为脚本方法在此 GameObject 中运行)。然后,每个立方体的渲染器颜色设置为公共 displayColor 参数的值。然后,立方体根据循环次数在 X 轴上定位,位置值为公共 startY 参数(因此多个可视化可以位于屏幕的不同部分),Z = 0。最后,将新立方体 GameObject 的引用存储在 cubes[] 数组中。

void Update() 方法

此方法中的每一帧通过调用 GetSpectrumData(...) 更新 spectrum[] 数组中的值。在我们的示例中,使用了 FFTWindow.BlackmanHarris 频率窗口技术。然后调用 UpdateCubeHeights() 方法。

void UpdateCubeHeights() 方法

此方法循环每个立方体,将其高度设置为对应于 spectrum[] 数组中音频数据值的缩放值。立方体的 Y 值通过 HeightFromSample(spectrum[i]) 方法返回的值进行缩放。然后,立方体从 startY 的值向上移动(其变换位置被设置),移动距离为其高度的一半——这样所有的缩放都会向上(而不是上下)进行——这是为了使我们的立方体光谱的底部保持*坦的线条。

float HeightFromSample(float) 方法

方法 HeightFromSample(float) 进行简单的计算(样本值乘以公共参数乘数),然后加上两个最小值之和。函数返回的值是此结果,限制在maxHeight公共参数内(通过Mathf.Clamp(...)方法)。

更多内容...

有些细节你不希望错过。

向第二个 AudioSource 添加可视化

脚本编写得很容易在场景中添加多个可视化。因此,为了在场景中为第二个AudioClip创建第二个可视化器,请执行以下操作:

  1. 复制可视化 GameObject。

  2. 将不同的 AudioClip 从项目面板拖动到新 GameObject 的音频源组件中。

  3. 检查器中的起始Y公共参数设置为 60(这样新的一行立方体将位于原始行上方)。

  4. 检查器中,为SpectrumCubes (脚本)组件选择不同的显示颜色公共变量:

图片

尝试不同的 FFT(快速傅里叶变换)窗口类型

对于音频数据的频率分析,有几种不同的方法,我们的食谱目前使用的是FFTWindow.BlackmanHarris版本。从 Unity 的FFTWindow文档页面(docs.unity3d.com/ScriptReference/FFTWindow.html)学习(并尝试!)一些其他方法。

同步同时和顺序音乐以创建简单的 140 bpm 音乐循环管理器

通过精确调度声音使用dspTime食谱创建节拍器,展示了如何通过使用AudioSource.PlayScheduled(...)方法和AudioSettings.dspTime值来安排声音播放时间,创建节拍器。我们还需要精确调度音频开始时间的另一种情况是确保从一首音乐轨道*滑过渡到另一首,或者确保同时播放的音乐轨道同步进行。

在这个食谱中,我们将创建一个简单的 4 轨 140 bpm 音乐管理器,在固定时间后开始播放新的声音——结果是轨道完美匹配,重叠的部分同步进行:

图片

准备工作

对于这个食谱,我们在04_14文件夹中提供了几个 140 bmp 采样免费音乐片段。

如何做到这一点...

要创建一个音乐循环管理器,请执行以下操作:

  1. 创建一个新的 Unity 3D 项目并导入提供的音频剪辑文件。

  2. 在场景中创建四个包含AudioSource组件的 GameObject,这些组件连接到 140 bpm 文件提供的不同AudioClip循环。这可以通过从项目面板将音乐剪辑拖动到层次结构或场景面板的单步操作来完成。

  3. 在检查器中,取消选中所有四个AudioSource组件的在唤醒时播放参数(这样它们就不会在我们告诉它们之前开始播放)。

  4. 在场景中添加一个新的空GameObject,命名为musicScheduler

  5. 在一个新文件夹 _Scripts 中创建一个 C#脚本类 LoopScheduler,包含以下代码,并将其作为脚本组件添加到 musicScheduler GameObject 中:

using UnityEngine; 

public class LoopScheduler : MonoBehaviour { 
    public float bpm = 140.0F; 
    public int numBeatsPerSegment = 16; 
    public AudioSource[] audioSources = new AudioSource[4]; 
    private double nextEventTime; 
    private int nextLoopIndex = 0; 
    private int numLoops; 
    private float numSecondsPerMinute = 60F; 
    private float timeBetweenPlays; 

    void Start() { 
        numLoops = audioSources.Length; 
timeBetweenPlays = numSecondsPerMinute / bpm * numBeatsPerSegment; 
        nextEventTime = AudioSettings.dspTime; 
    } 

    void Update() { 
        double lookAhead = AudioSettings.dspTime + 1.0F; 
        if (lookAhead > nextEventTime) 
            StartNextLoop(); 

        PrintLoopPlayingStatus(); 
    } 

    private void StartNextLoop() { 
        audioSources[nextLoopIndex].PlayScheduled(nextEventTime); 
        nextEventTime += timeBetweenPlays; 

        nextLoopIndex++; 
        if (nextLoopIndex >= numLoops) 
            nextLoopIndex = 0; 
    } 

    private void PrintLoopPlayingStatus(){ 
        string statusMessage = "Sounds playing: "; 
        int i = 0; 

        while (i < numLoops) { 
            statusMessage += audioSources[i].isPlaying + " "; 
            i++; 
        } 

        print(statusMessage); 
    } 
} 
  1. Hierarchy 中选择 musicScheduler GameObject,将每个音乐循环 GameObject 拖放到 Loop Scheduler (Script) 组件中 AudioSources 公共数组变量的四个可用槽位中:

  1. 运行场景 - 每个剪辑应该依次在相同的时间延迟后开始。如果你选择了一个或两个较长的剪辑,它们将在下一个剪辑开始时继续播放 - 由于它们都是 140 bpm 的声音剪辑,所以它们会完美重叠。

它是如何工作的...

你在场景中添加了四个包含与 140 bpm 音乐片段链接的 AudioSourcesGameObjects。你创建了一个 C#脚本类 LoopScheduler,并将其实例添加到一个空 GameObject 中。你将你的 GameObjects 中的四个 AudioSources 与脚本组件中公共 AudioSource 数组变量的四个槽位关联起来。

你使用的音乐剪辑的数量可以通过更改公共数组变量的大小来轻松更改。

Start() 方法计算数组的长度以设置 numLoops 变量。然后它计算在开始每个剪辑之前需要延迟的秒数(这是根据每分钟节拍和每小节节拍固定的)。最后,它将当前时间设置为开始第一个循环的时间。

Update() 方法决定是否是时候安排下一个循环,通过测试当前时间加上 1 秒前瞻是否超过了开始下一个循环的时间。如果是这样,就会调用 StartNextLoop() 方法。无论我们是否已经开始下一个循环,都会调用 PrintLoopPlayingStatus() 方法将用户正在播放或未播放的循环显示到控制台。

PrintLoopPlayingStatus() 方法遍历数组中的每个 AudioSource 引用,创建一个包含真和假的字符串,然后将其打印出来。

StartNextLoop() 方法向下一个要播放的 AudioSource 发送 PlayScheduled(...) 消息,传递 nextEventTime 值。然后它为下一个事件时间添加播放之间的时间。然后计算下一个循环索引的下一个值(如果超过了数组的末尾,则加一,然后再次重置为 0)。

还有更多...

有些细节是你不想错过的。

为正在播放的四个循环添加视觉呈现

观看循环声音的视觉呈现非常有趣。要为四个 AudioSources 添加视觉呈现,你只需要做以下几步:

  1. 从上一个菜谱中导入 SpectrumCubes.cs C# 脚本文件到这个项目中。

  2. 将主相机的变换位置设置为 (224, 50, -200)。

  3. 将主相机的相机组件设置为以下设置:投影 = 投影透视,视野 60,裁剪*面 0.3 - 300。

  4. 在场景中添加一个 Directional Light GameObject

  5. 对于包含你的AudioSources的每个四个GameObjects,添加一个 Spectrum Cubes 脚本类的实例。

  6. Spectrum Cubes (脚本)组件的检查器中,更改每个AudioSource GameObject的显示颜色。

  7. 将四个GameObjects组件的 Spectrum Cubes(脚本)的起始 Y 值设置为-50、0、50、100。对于大多数屏幕尺寸,这应该允许你看到所有四个可视化光谱:

第五章:创建纹理、贴图和材料

在本章中,我们将介绍:

  • 使用标准着色器(镜面设置)创建基本材料

  • 将基本材料从镜面设置调整为金属

  • 将法线贴图应用于材料

  • 将透明度和发射贴图添加到材料中

  • 鼠标悬停时突出显示材料

  • 将细节贴图添加到材料中

  • 渐变材料的透明度

简介

纹理材料着色器之间存在密切关系,它们之间的关系很重要:

  • 纹理是二维图像。Unity 游戏中 2D 和 3D 对象的表面由网格定义。纹理图像通过材料映射到网格上——网格上的每个点(顶点)都必须映射到纹理中的某个值。纹理可以指示颜色,但也可以指示凹凸/皱纹或透明度——所有这些都可以有助于确定最终渲染给用户看到的内容。

  • 材料指定应使用哪个着色器将图像渲染到网格上,以及着色器参数的值(例如哪些纹理/纹理图的哪些部分、颜色、其他值)。有关材料的更多信息,请参阅 Unity 文档页面:docs.unity3d.com/Manual/Materials.html

  • 着色器定义了渲染对象的方法。着色器可以使用多个纹理以获得更复杂的结果,并指定在材料检查器中可以自定义哪些参数。最终,着色器是代码和数学,但 Unity 为我们提供了一套着色器。我们还可以使用新的着色器图包,它允许使用可视的、拖放式的图形界面创建复杂的着色器。此外,还可以使用ShaderLab语言编写自定义着色器。

Unity 提供基于物理的着色器。基于物理渲染PBR)是一种技术,它根据光线与材料(更具体地说,该材料由何种物质制成)在现实世界中的相互作用来模拟材料的外观。这种技术可以实现更真实和一致的材料。因此,你在 Unity 中的创作应该比以往任何时候都要好。在 Unity 中创建材料现在也变得更加高效。一旦你选择了可用的工作流程(金属镜面设置;我们稍后会回到这一点),就不再需要浏览下拉菜单以查找特定功能,因为 Unity 会为创建的材料优化着色器,一旦设置好材料并分配了纹理图,就会移除未使用的属性代码。

创建和保存纹理图

材质的可视方面可以通过使用纹理进行修改。为了创建和编辑图像文件,您需要一个图像编辑器,例如 Adobe Photoshop(行业标准,并且其原生格式由 Unity 支持)或 GIMP。为了遵循本章中的配方,强烈建议您能够访问这些软件中的一两件。

当保存纹理贴图,尤其是带有Alpha 通道的贴图时,您可能希望选择一个合适的文件格式。PSD 是 Photoshop 的原生格式,对于保留许多图层的原始艺术品来说很实用。PNG 格式也是一个很好的选择,但请注意,Photoshop 不会独立于透明度处理 PNG 的Alpha 通道,这可能会影响材质的外观。此外,PNG 文件不支持图层。对于本章,我们将经常使用 TIF 格式,主要原因如下:

  • 对不使用 Photoshop 的人来说也是开放的

  • 它使用图层

  • 它保留了Alpha 通道信息

文件大小明显大于 PSDs 和 PNGs,因此请随意将您的作品保存为 PSDs(如果您有 Photoshop)或 PNGs(如果您不需要图层,并且在使用 Photoshop 时,Alpha 通道)。

最后,给出一些建议:虽然我们可以通过使用传统的图像编辑软件手动创建纹理贴图来制作我们的材质,但像 Allegorthmic 的 Substance Painter 和 Bitmap2Material 这样的新工具使这项工作变得更加高效、完整和直观,补充了传统的纹理制作过程,甚至完全取代了它。这些工具以类似于 zBrush 和 Mudbox 对 3D 建模所做的方式提供纹理工作支持。对于设计专业人士,我们强烈建议至少尝试这样的工具。然而,请注意,Allegorithmic 的产品不会使用 Unity 的标准着色器,而是依赖于 substance 文件(这些文件是 Unity 原生支持的)。

整体情况

要理解标准着色器,了解工作流程、它们的属性以及它们如何影响材质的外观是很好的。然而,有许多可能的工作方式与材质一起工作——例如,纹理贴图要求可能因引擎而异,或从一种工具到另一种工具而异。目前,Unity 支持两种不同的工作流程:一种基于镜面反射,另一种基于金属值。尽管两种工作流程具有相似的性质(如法线高度遮挡发射),但它们在设置漫反射颜色和反射特性方面有所不同。

标准着色器(镜面反射工作流程)

Unity 的标准着色器镜面反射设置)使用阿尔贝多镜面/*滑度贴图,将它们结合起来以创建材质的一些特性——主要是其颜色和反射特性。以下显示了阿尔贝多*滑度贴图之间的差异:

  • 反照率: 这是指材料的漫反射颜色。简单来说,这就是你通常描述材料外观的方式(比如,英国的旗帜是红、白、蓝;法拉利的标志是一个黑色马匹在黄色背景上;一些太阳镜的镜片是半透明的渐变)。然而,这种描述可能会误导。纯金属物体(如铝、铬和金)应该有黑色作为它们的漫反射颜色。我们感知到的颜色,实际上来源于它们的镜面通道。另一方面,非金属物体(如塑料、木材,甚至是涂漆或生锈的金属)具有非常明显的漫反射颜色。反照率纹理图用于反照率属性,具有 RGB 通道用于颜色,以及(可选)用于透明度的 Alpha 通道。

  • 镜面/光滑度: 这指的是材料的亮度。纹理图使用 RGB 通道用于镜面颜色(提供色调和强度信息),以及Alpha 通道用于光滑度/光泽(暗值用于较不光滑的表面和模糊的反射;亮/白色值用于光滑、镜面般的外观)。重要的是要注意,非金属物体具有中性、非常暗的镜面颜色(例如,对于塑料,你应该使用大约 59 的灰色值)。另一方面,金属物体具有非常亮的值,并且在色调上略带黄色。

为了说明这些概念,我们创建了一个电池对象,具有磨砂金属盖和塑料外壳。观察每个图如何贡献于最终结果:

图片

标准着色器(金属工作流程)

Unity 的默认标准着色器反照率金属/光泽度图结合以创建材料的颜色和反射特性。以下是一些区别:

  • 反照率: 如同镜面工作流程,这是材料的漫反射颜色;你描述材料的方式。然而,金属工作流程中的反照率图应该以略不同于镜面工作流程的方式配置。这一次,金属材料的感知漫反射颜色(如铁灰色、金色黄色/橙色等)必须存在于反照率图中。再次强调,反照率图具有 RGB 通道用于颜色,以及(可选)用于透明度的 Alpha 通道。

  • 金属/光滑度: 这指的是材料看起来有多金属。金属纹理图使用红色通道用于金属值(非金属为黑色,未涂漆或未生锈的金属为白色)和Alpha 通道用于光滑度(与镜面工作流程类似)。请注意,金属图不包含任何关于色调的信息,在这些情况下,金属光泽的黄色性质应该应用于反照率图

要通过金属工艺流程重现说明镜面反射工作流程的电池,需要按照以下方式重新创建贴图:

您可能已经注意到,我们使用白色来传达金属物体。技术上,由于只有红色通道相关,我们也可以使用红色(R: 255, G: 0, B: 0)、黄色(R: 255, G: 255, B: 0)或任何具有 255 红色值的颜色。

Unity 文档页面提供了两个非常有用的图表,展示了标准着色器金属镜面反射工作流程的常见属性示例(docs.unity3d.com/Manual/StandardShaderMaterialCharts.html):

其他材质属性

还值得一提的是,Unity 的标准着色器支持其他贴图,例如:

  • 法线贴图:法线贴图在材质中添加了详细的凹凸效果,模拟更复杂的几何形状。例如,电池中正(顶部)节点内部的环形结构,在说明着色器工作流程的 3D 对象几何形状中并未建模,而是通过一个简单的法线贴图创建的。

  • 遮挡贴图:使用灰度图来模拟在环境光下物体的暗部区域。通常,它用于强调关节、褶皱和其他几何形状的细节。

  • 高度贴图:这些添加了一个位移效果,给人一种深度感,而不需要复杂的几何形状。

  • 发射贴图:这些为材质添加发射出的颜色,就像自发光一样,例如荧光表面或 LCD。发射贴图的纹理具有 RGB 通道用于颜色。

资源

基于物理的渲染PBR)是一个复杂(且当前)的话题,因此熟悉其背后的工具和概念是很好的学习方式。为了帮助您完成这项任务,我们提供了一份非详尽的资源列表,您应该查看一下。

Unity 样本和文档

在开始之前,阅读 Unity 关于纹理材质着色器的文档可能是个不错的主意。它们可以在网上找到:

本章涵盖了创建纹理贴图的技术,这些贴图通常手动创建,有时也自动创建,能够为材料提供独特的特征。希望您在使用 Unity 的基于物理的着色时能够自信,它能够理解不同工作流程之间的差异,了解每个材料属性的作用,并准备好为您的游戏制作更美观的材料。我们还探讨了通过脚本访问对象的材质来在运行时更改材料属性的方法。

Unity 为那些寻找如何为各种材料设置贴图指南的人准备了一份很好的资源:

参考文献

这里有一份关于基于物理的渲染(包括和不包括 Unity)的有趣、详细的材料列表:

  • 对于对基于物理的渲染有深入理解的读者,我们推荐您查看由 Allegorithmic 的 Wes McDermott 编写的《综合 PBR 指南》。Allegorithmic 的指南深入探讨了 PBR 的实践和理论方面,包括对可能的工作流程的精彩分析。指南以两卷的形式免费提供,可在www.allegorithmic.com/pbr-guide找到。

  • 由 Renaldas Zioma(Unity)、Erland Körner(Unity)和 Wes McDermott(Allegorithmic)合著的《精通 Unity 5 中的基于物理的着色》可在www.slideshare.net/RenaldasZioma/unite2014-mastering-physically-based-shading-in-unity-5找到。这是一份关于在 Unity 中使用 PBS 的详细演示文稿。最初在 Unite 2014 会议上展示,其中包含一些过时的信息,但仍值得一观。

  • Unity 的 Aras Pranckevičius 编写的《Unity 5 中的基于物理的着色》可在aras-p.info/texts/talks.html找到。GDC 上关于该主题的演示文稿的幻灯片和笔记也提供了。

  • 由 Joe "EarthQuake" Wilson 编写的教程《基于物理的渲染,你也可以做到!》可在www.marmoset.co/toolbag/learn/pbr-practice找到。这是 Marmoset Toolbag 和 Skyshop 制作者提供的一个很好的概述。

  • Polycount PBR Wiki,可在 http://wiki.polycount.com/wiki/PBR 找到,是 Polycount 社区汇编的资源列表。

  • Pixar 的 Jeremy Brin 提供的许多关于 3D 图形的一般文章和教程:3drender.com/

工具

这是一款新一代的纹理软件,供您查看,以防您还没有看过:

额外阅读

材质纹理相关的两个新 Unity 功能是着色器图工具和视频播放器组件。更多关于这些内容的信息请参考它们各自的章节:着色器图和*视频播放器**。

使用标准着色器(反射率设置)创建基本材质

在这个菜谱中,我们将学习如何使用新的标准着色器反射率设置)、Albedo 图和反射率/光滑度图来创建一个基本的材质。该材质将具有金属和非金属部分,以及不同的光滑度级别。

准备工作

已准备了两个文件来支持此配方:一个电池的 3D 模型(FBX 格式),以及一个用于创建漫反射纹理贴图的 UVW 模板纹理(PNG 格式)。可以使用 3D 建模软件(如 3DS MAX、Maya 或 Blender)创建 3D 模型和 UVW 模板。所有必要的文件都可在05_01文件夹中找到。

如何操作...

要创建基本材质,请按照以下步骤操作:

  1. 创建一个新的 Unity 3D 项目并将battery.fbxuvw_template.png文件导入到项目中。

  2. 通过从项目面板中的Assets文件夹拖动电池模型到场景面板,将其放置在场景中。在场景面板中选择它,并通过检查器面板上的变换组件确保其位置在 X: 0, Y: 0, Z: 0。

  3. 让我们为我们的对象创建一个光泽度/光滑度贴图。在您的图像编辑器中打开名为uvw_template.png的图像文件(我们将使用 Adobe Photoshop 来说明下一步)。请注意,图像文件只有一个图层,大部分是透明的,包含我们将用作光泽度贴图指南的 UVW 映射模板。

  4. 创建一个新图层并将其放置在带有辅助线的图层下方。用深灰色(R: 56, G: 56, B: 56)填充新图层。辅助线将在实色黑色填充的顶部可见:

图片

  1. 创建一个新图层并选择图像的上部区域(带有圆圈的区域)。将该区域填充为略带颜色的浅灰色(R: 196, G: 199, B: 199):

我们的光滑度贴图的 RGB 值并非任意选择:基于物理的着色从映射过程中去除了大部分猜测,用参考研究取而代之。在我们的例子中,我们使用了基于铁(略带颜色的浅灰色)和塑料(深灰色)的反射率值。查看本章结论部分以获取参考列表。

  1. 使用白色文本元素为电池主体添加品牌、尺寸和正负指示器。然后,隐藏辅助线图层:

图片

  1. 选择所有图层并将它们组织到一个组中(在 Photoshop 中,可以通过点击图层窗口中的下拉菜单并导航到窗口 | 新建组从图层...来完成)。将新组命名为光泽度:

图片

  1. 复制光泽度组(在图层窗口中,右键单击组名并选择复制组...)。将复制的组命名为光滑度。

  2. 隐藏光滑度组。展开光泽度组并隐藏所有文本图层:

图片

  1. 显示光滑度组,并隐藏光泽度组。选择深灰色图层。在电池主体的上部区域周围创建一个区域选择,并用浅灰色(R: 220, G: 220, B: 220)填充。如有需要,重新调整和排列文本图层:

图片

  1. 复制包含图像上部灰色填充的图层(覆盖圆圈的图层)。

  2. 要给这个材质添加刷子效果,向复制的图层添加噪声滤镜(在 Photoshop 中,可以通过导航到滤镜 | 噪声 | 添加噪声...)。使用 50% 作为数量,并将单色设置为 true。然后,使用 30 像素作为距离应用运动模糊滤镜(滤镜 | 模糊 | 运动模糊...)。

  3. 复制 Smoothness 组。选择复制的组并将其合并为单个图层(在图层窗口中,右键单击组名称并选择合并组)。

  4. 选择合并图层,使用 Ctrl + a 键组合选择整个图像,并使用 Ctrl + c 键复制:

图片

  1. 隐藏合并层和 Smoothness 组。显示 Specular 组。

  2. 在您的图像编辑器中,访问图像通道窗口(在 Photoshop 中,可以通过导航到窗口 | 通道来完成)。创建一个新的通道。这将是我们的高光通道。

  3. 将您之前复制的图像(从合并层)粘贴到 Alpha 通道。将所有通道设置为可见:

图片

  1. 将您的图像保存到项目的 Assets 文件夹中,命名为 Battery_specular,可以是 Photoshop 格式(PSD)或 TIF 格式。

  2. 让我们处理 Albedo 贴图。将 Battery_specular 的副本保存为 Battery_albedo。从 通道 窗口删除 Alpha 通道

  3. 图层 窗口隐藏 Smoothness 复制合并层,并显示 Smoothness 组。展开 Smoothness 组,并隐藏应用了噪声滤镜的图层:

图片

  1. 将上方的矩形颜色更改为黑色。将浅灰色区域更改为深红色(R: 204, G: 0, B: 0),并将深灰色更改为红色(R: 255, G: 0, B: 0)。将组重命名为 Albedo 并保存文件:

图片

  1. 返回 Unity 并确保两个文件都已导入。从 项目 面板创建一个新的 材质(菜单:创建 | 材质)。将其命名为 Battery_MAT。

  2. 选择 Battery_MAT。从检查器面板,将着色器更改为标准(高光设置),确保渲染模式设置为不透明,并且*滑度滑块位于最大值 1:

图片

  1. 将 Battery_specular 设置为 Specular 贴图,将 Battery_albedo 设置为 Battery_MAT 的 Albedo 贴图。

  2. 将 Battery_MAT 材质从 项目 面板拖动并放入 Hierarchy 中的 电池 对象。

图片

它是如何工作的...

最终,电池的视觉效果是其材质的三个属性的组合:高光*滑度Albedo

例如,为了制作塑料主体的深红色部分,我们混合了以下内容:

  • 镜面图(RGB):非常暗的灰色光泽(用于非金属外观)

  • 光滑度(镜面图的 Alpha 通道):浅灰色(用于光泽质感)

  • 反照率图:深红色(用于深红色)

另一方面,浅红色部分结合以下内容:

  • 镜面图(RGB):相同的深灰色镜面

  • 光滑度(镜面图的 Alpha 通道):深灰色(用于哑光质感)

  • 反照率图:红色(用于红色)

最后,用于顶部和底部盖子的刷漆金属结合以下内容:

  • 镜面图(RGB):浅灰色(用于金属质感)

  • 光滑度(镜面图的 Alpha 通道):模糊的灰色噪点图案(用于刷漆质感)

  • 反照率图:黑色(用于红色)

关于图像层如何组织,将你的图层组织成与它们相关的属性命名的组是一个好习惯。随着纹理图变得更加多样化,保留一个包含所有图的文件以供快速参考和一致性是一个好主意。

还有更多...

在使用反照率图时,以下是一些你应该考虑的事项。

设置图像文件的纹理类型

由于图像文件可以在 Unity 中用于多个目的(纹理图、UI 纹理、光标等),检查是否为你的文件分配了正确的纹理类型是一个好主意。这可以通过在项目面板中选择图像文件,并在检查器面板中使用下拉菜单选择正确的纹理类型(在这种情况下,纹理)来完成。请注意,可以调整其他设置,例如包裹模式、过滤模式和最大尺寸。最后一个参数非常有用,如果你想在游戏中保持纹理图的小尺寸,同时仍然能够以全尺寸编辑它们。

将图与颜色结合

当编辑材质时,可以在检查器面板上反照率图插槽右侧的颜色选择器中用来选择材质的颜色,如果没有任何纹理图。如果正在使用纹理图,所选颜色将乘以图像,允许在材质的颜色色调上有所变化。

将基本材质从镜面设置转换为金属质感

为了更好地理解金属镜面工作流程之间的差异,我们将修改用于镜面设置材质上的反照率和镜面/光滑度图,以便将它们适应金属工作流程。要生成的材质将具有金属和非金属部分,以及不同的光滑度级别。

准备工作

此配方基于之前的配方,因此请复制该项目并使用副本进行此配方。

如何操作...

要创建使用金属工作流程的基本材质,请按照以下步骤操作:

  1. 项目面板中选择电池预制体元素。从检查器中访问其材质(命名为 Battery_MAT)并将其着色器更改为标准(与当前着色器相反——标准(漫反射设置)):

  1. 项目面板中找到电池高光图并将其重命名为 Battery_metallic。在您的图像编辑器中打开它(以下步骤我们将使用 Adobe Photoshop 进行说明)。

  2. 找到名为 Specular 的图层组并将其重命名为 Metallic。将浅灰色图层(在 Metallic 组中命名为 Layer 2)填充为白色(R: 255, G: 255, B: 255),将深灰色图层(在 Metallic 组中命名为 Layer 1)填充为黑色(R: 0, G: 0, B: 0)。保存文件:

  1. 返回 Unity。从检查器中设置修改后的 Battery_metallic 图作为 Battery_MAT 材质的金属图。同时,将 None 设置为该材质的反照率图。这将给您一个关于材质进展的思路:

  1. 让我们调整反照率纹理图。从项目面板中找到Battery_albedo图并将其在您的图像编辑器中打开。使用油漆桶工具Albedo组中层 2的黑区域填充为浅灰色(R: 196, G: 199, B: 199)。保存文件:

  1. 返回 Unity。从检查器中设置修改后的 Battery_albedo 图作为 Battery_MAT 材质的反照率图。

  2. 您的材质已准备好,结合了基于您编辑和分配的不同图层的视觉属性:

它是如何工作的...

电池的视觉外观是其材质的三个属性的结合:金属度、*滑度和反照率。

例如,要组成塑料体的深红色部分,我们混合了以下内容:

  • 金属图(RGB):黑色(用于非金属质感)

  • *滑度(金属图的 Alpha 通道):浅灰色(用于光泽的外观)

  • 反照率图:深红色(用于深红色)

另一方面,浅红色部分结合了以下内容:

  • 金属图(RGB):黑色

  • *滑度(金属图的 Alpha 通道):深灰色(用于哑光外观)

  • 反照率图:红色(用于红色)

最后,用于顶部和底部盖子的刷洗金属结合了以下内容:

  • 金属图(RGB):白色(用于金属感)

  • *滑度(金属图层的 Alpha 通道):模糊的灰色噪声图案(用于刷洗的质感);

  • 反照率图:浅灰色(用于类似铁的质感)

记得将你的图层组织成以它们相关属性命名的组。

将法线图应用于材质

法线图通常用于模拟在游戏运行时用 3D 多边形实际表示过于昂贵的复杂几何形状。为了简化:法线图在低分辨率 3D 网格上模拟复杂几何形状。这些图可以通过将高分辨率 3D 网格投影到低多边形网格上(通常称为烘焙技术)生成,或者,正如本食谱中所述,从另一个 纹理图中生成:

准备工作

对于这个食谱,我们将准备两个 纹理图:高度图和法线图。前者将使用图像编辑器中的简单形状制作。后者将自动从高度图处理生成。尽管有许多工具可以用来生成 法线图,但我们将使用免费的在线 NormalMap 工具:cpetry.github.io/NormalMap-Online/

为了帮助您完成这个食谱,我们提供了一个电池的 FBX 3D 模型(battery.fbx),以及其 Albedo 和 Specular 纹理(Battery_albedo.tif Battery_specular.tif)。

我们还包含了 UVW 模板纹理(PNG 格式),以指导您创建漫反射 纹理图。所有文件都在 05_03 文件夹中。

如何操作...

要将 法线图应用到 材质上,请按照以下步骤操作:

  1. 将 battery.fbx 及其 Albedo 和 Specular 纹理导入到项目中。

  2. 通过将资产 battery项目面板拖动到 场景(或层次结构)面板中,在场景中添加 3D 模型的实例。

  3. 项目面板中,选择模型资产 battery。在 检查器中,点击 材质按钮,然后点击提取材质...现在您应该在 项目面板中有一个名为 BatteryMaterial 的 材质资产文件。

  4. 选择 BatteryMaterial,并将着色器更改为 Standard(镜面设置)。将 Battery_albedo 纹理项目面板拖动到检查器中的 Albedo 纹理槽中。将 Battery_specular 纹理项目面板拖动到 Specular 纹理槽中。

  5. 在将您项目中的 电池模型与一些参考照片进行比较后,了解法线图应复制的特征:(A)顶部有一个凹凸的环,以及(B)底部有一些圆形的褶皱:

  1. 在图像编辑器中打开 uvw_template.png。创建一个新的图层,将其填充为灰色(RGB:128),并将其放置在现有的图层下方:

  1. 在单独的图层上,画一个以电池顶部为中心的白圆圈。然后,在另一个图层上,画一个以电池底部为中心的黑圆圈:

  1. 如果您已使用矢量形状制作圆圈,则将其图层栅格化(在 Adobe Photoshop 中,可以通过右键单击图层名称并从上下文菜单中选择栅格化图层选项来完成)。

  2. 模糊白色圆圈(在 Photoshop 中,可以通过导航到滤镜 | 模糊 | 高斯模糊...来完成)。使用 4.0 像素作为半径。

  3. 隐藏 UVW 模板层并将图像保存为 Battery_height.png。

  4. 如果您想直接从 Unity 转换 Heightmap,将其导入到您的项目中。从项目面板中选择它,然后从检查器面板中,将其纹理类型更改为正常贴图。检查从灰度创建选项,根据需要调整凹凸度和过滤,然后单击应用以保存更改:

  1. 要在外部转换您的 Heightmap,请访问cpetry.github.io/NormalMap-Online/。将 Battery_height.png 文件拖到相应的图像槽中。您可以自由地调整强度、级别和模糊/锐化参数:

  1. 将生成的正常贴图保存为 Battery_normal.jpg 并添加到您的 Unity 项目中。

  2. 在 Unity 中,从项目面板中选择 Battery_normal。然后,在检查器中,将其纹理类型更改为正常,并取消选中从灰度创建框。单击应用以保存更改。

  3. 项目面板中,选择 BatteryMaterial 资产。在材质组件的检查器中,将 Battery_normal 分配给正常贴图槽。要调整其强度和方向,将其值更改为-0.35:

它是如何工作的...

正常贴图是从 Heightmap 上的灰度值计算得出的,其中较亮的色调被解释为凹槽(应用于电池顶部),较暗的色调被解释为凸起(应用于底部)。由于所需的输出实际上是相反的,因此需要将正常贴图调整为负值(-0.35)。解决此问题的另一个可能方案是重新绘制 Heightmap 并交换白色和黑色圆圈的颜色。

将透明度和发射贴图添加到材质中

发射属性可以用来模拟各种自发光对象,从移动显示器的 LED 到未来派的 Tron 服装。另一方面,透明度可以使材质的漫反射颜色更明显或更不明显。在本配方中,您将学习如何配置这些属性以生成一个具有部分透明塑料外壳、开孔(完全透明)和发光文字的玩具纸箱包装:

准备工作

对于这个配方,我们在05_04文件夹中准备了两个文件:

  • package.fbx:包装的 3D 对象(FBX 格式)

  • card_diffuse_start.png:包装的漫反射纹理贴图(PNG 格式)

此外,还提供了您将在项目中创建的两个最终、经过图像编辑的文件:

  • card_diffuse.png:带有裁剪的包装的 Albedo 纹理图(PNG 格式)

  • card_emission.png:用于发射发光文本的发射纹理图(PNG 格式)

如何做到这一点...

要将透明度和颜色发射图添加到材料中,请按照以下步骤操作:

  1. 导入提供的文件。

  2. 复制 card_diffuse_start 的纹理,将其命名为 card_diffuse。

  3. 项目面板中,将 FBX 模型包拖动到层次结构中,在场景中创建一个 GameObject。

  4. 创建一个名为 m_card 的新材料。选择项目菜单:创建 | 材料。将 card_diffuse 纹理拖动到 m_card 的Albedo属性中。

  5. 层次结构中,选择 PackageCard(包的子项),并分配你的新 m_card 材料。

  6. 创建一个名为 m_plastic 的新材料。选择项目菜单:创建 | 材料。将其渲染模式更改为透明。使用漫反射颜色选择器将颜色的 RGB 值更改为 56/56/56,并将 Alpha 更改为 25。将*滑度级别更改为 0.9:

  1. 层次结构中,选择 PackagePlastic(包的子项),并分配你的新 m_plastic 材料。3D 模型的塑料部分现在应该部分透明,就像是由塑料制成的。

  2. 要在包装和悬挂孔周围创建裁剪,我们首先需要在图像编辑器(如 Photoshop)中准备漫反射图像。在你的图像编辑器中打开 card_diffuse 纹理

  3. 我们将通过删除包装(以及悬挂孔)周围的白色区域来为图像添加透明度。选择这些区域(在 Photoshop 中,可以使用魔棒工具完成此操作)。

  4. 确保通过单击层名左侧的锁定图标解锁背景层:

  1. 删除之前创建的选择(在 Photoshop 中,可以通过按 Delete 键完成)。图像的背景应该是透明的:

  1. 在你的图像编辑器中保存你的文件,并返回 Unity。

  2. 在项目面板中选择 m_card 材料。在检查器中,将渲染模式更改为裁剪,并调整其 Alpha 截止值为 0.9:

选择裁剪意味着你的材料可以是完全不可见或完全可见,不允许半透明。Alpha 截止值用于去除透明边缘周围的不需要的像素。

  1. 让我们为明亮的字母制作发射图。从资产文件夹中,复制 card_diffuse.png 纹理,重命名为 card_emission.png,并在你的图像编辑器中打开它。

  2. 选择 Ms. Laser 铭文中的所有字符和绿色星星(在 Photoshop 中,可以使用魔棒工具完成此操作,在选择多个区域时按住Shift键)。

  3. 将您的选择复制并粘贴到一个新的图层中。然后选择它,并对其应用噪声滤镜(在 Photoshop 中,可以通过导航到滤镜 | 噪声 | 添加噪声...来完成)。使用 50%作为值。

  4. 创建一个新的图层,并使用如画桶工具将其填充为黑色(R: 0, G: 0, B: 0)。将这个黑色图层放置在带有彩色元素的图层下方。

  5. 将您的图像合并(在 Photoshop 中,可以通过导航到图层 | 合并图像来完成):

  1. 在您的图像编辑器中保存您的文件,并返回 Unity。

  2. 项目面板中选择 m_card 材质。检查发射属性 – 应该出现三个新属性:

    • 纹理槽:将其设置为 Texture card_emission(从项目面板拖动资产文件)

    • 颜色槽:将其设置为白色(R: 255; G: 255; B: 255)

    • 全局照明 下拉菜单:确保将其设置为烘焙(这样其光芒就不会添加到光照贴图中或影响实时照明):

它是如何工作的...

Unity 能够读取纹理图的四个通道:R(红色)、G(绿色)、B(蓝色)和 A(Alpha)。当设置为透明或裁剪时,漫反射纹理图的Alpha 通道根据每个像素的亮度级别设置材质的透明度(裁剪模式将不会渲染半透明 – 只渲染完全可见或不可见的像素)。

我们没有添加 Alpha 通道 – 这是因为 Photoshop 根据其透明度导出 PNG 的 Alpha 图。为了帮助您可视化 Alpha 图,提供的文件夹还包含 package_diffuse.tif(TIF 格式)纹理文件;一个图像文件,其 Alpha 图与我们所生成的 PNG 文件完全相同:

关于发射纹理图,Unity 将其 RGB 颜色分配给材质,与适当的颜色选择槽结合,并允许调整该发射的强度。

还有更多...

让我们看看关于透明度和发射的更多信息。

使用透明模式纹理图

请注意,您可以在透明渲染模式中使用位图纹理作为漫反射图。在这种情况下,RGB 值将被解释为漫反射颜色,而 Alpha 将用于确定该像素的透明度(在这种情况下,允许半透明材质)。

避免半透明物体的问题

您可能已经注意到塑料外壳是由两个对象(PackagePlastic 和 innerPlastic)组成的。这样做是为了避免 z 排序问题,即当面应该在后面时,却渲染在前面。使用多个网格而不是一个,可以正确排序这些面以进行渲染。在裁剪模式下材质不受此问题的影响。

在其他物体上发射光线

发射值可以用来计算在使用光照贴图时材质对其他物体的光照投影。

鼠标悬停时突出显示材质

在运行时更改对象的颜色可以是一个非常有效的方法,让玩家知道他们可以与之交互。这在许多游戏类型中非常有用,例如益智游戏和点击冒险游戏,也可以用来创建 3D 用户界面。

如何做到...

要在鼠标悬停时突出显示材质,请按照以下步骤操作:

  1. 创建一个新的 3D 项目。

  2. 在场景中创建3D 立方体(在层次菜单中:创建 | 3D 对象 | 立方体)。

  3. 在项目面板中,创建一个新的材质集合,命名为 m_cube。将其漫反射颜色设置为红色。

  4. 层次结构中,选择立方体GameObject,并分配 m_cube材质(从项目面板拖动资产)。

  5. 创建一个新的 C#脚本类,命名为 MouseOverHighlighter,并将实例对象作为组件添加到立方体中:

    using UnityEngine;

         public class MouseOverHighlighter : MonoBehaviour {
         public Color mouseOverColor = Color.yellow;

         private Material originalMaterial;
         private Material mouseOverMaterial;
         private MeshRenderer meshRenderer;

         void Start() {
             meshRenderer = GetComponent<MeshRenderer>();
             originalMaterial = meshRenderer.material;
             mouseOverMaterial = new 
             Material(meshRenderer.sharedMaterial);
             mouseOverMaterial.color = mouseOverColor;
         }

         void OnMouseOver() {
             meshRenderer.material = mouseOverMaterial;
         }

         void OnMouseExit() {
             meshRenderer.material = originalMaterial;
         }
     } 
  1. 在选择立方体后,在检查器中的 Mouse Over Highlighter(脚本)组件中,你会看到鼠标悬停颜色是黄色。你可能希望更改它。

  2. 测试场景。当鼠标悬停在立方体上时,立方体会突出显示为红色(点击时为绿色)。

它是如何工作的...

Start()方法做了四件事:

  • meshRenderer变量中存储对 MeshRenderer 组件的引用

  • originalMaterial变量中存储对 GameObject 原始材质的引用

  • 创建一个新的材质,命名为 mouseOverMaterial

  • mouseOverMaterial的颜色设置为 mouseOverColor 公共变量中的颜色

当用户将鼠标指针移到屏幕上立方体可见的部分和移开时,立方体会自动接收到鼠标进入/退出事件。我们的代码在检测到这些事件时为立方体添加行为。

当接收到OnMouseOver消息时,将调用具有该名称的方法,并将 GameObject 的材质设置为mouseOverMaterial。当接收到OnMouseExit消息时,将 GameObject 的材质返回到originalMaterial

Renderer 的材质属性是副本

如果一个 GameObject 的材质被多个对象共享,我们在更改材质属性时必须小心,以确保只更改我们想要的那些。如果我们只想更改特定 GameObject 的值,请使用Renderer.material属性——因为如果多个对象使用相同的材质,则会创建一个单独的克隆。如果我们想使使用相同材质的所有 GameObject 都受到更改的影响,请使用Renderer.sharedMaterial属性。由于这个配方中只有一个 GameObject,所以可以使用任一方法。

更多信息请参阅docs.unity3d.com/ScriptReference/Renderer-material.html

还有更多...

这里有一些增强这个配方的方法。

自定义网格所需的碰撞器

我们创建了一个原始的 3D 立方体 – 这会自动具有一个 Box Collider 组件。如果你要使用前面的脚本与自定义 3D 网格对象一起使用,请确保 GameObject 具有 Physics | Collider 组件,以便它能够响应用户事件。

鼠标按下/释放事件 – 用于点击颜色

我们可以扩展我们的代码,以便在对象被点击时显示不同的颜色(鼠标按下/释放事件)。

执行以下操作:

  1. 从 Cube GameObject 中移除脚本化的 MouseOverHighlighter

  2. 创建一个新的名为 MouseOverDownHighlighter 的 C#脚本类,并将实例对象作为组件添加到 Cube 中:

    using UnityEngine;

     public class MouseOverDownHighlighter : MonoBehaviour {
         public Color mouseOverColor = Color.yellow;
         public Color mouseDownColor = Color.green;

         private Material originalMaterial;
         private Material mouseOverMaterial;
         private Material mouseDownMaterial;
         private MeshRenderer meshRenderer;

         private bool mouseOver = false;

         void Start() {
             meshRenderer = GetComponent<MeshRenderer>();
             originalMaterial = meshRenderer.sharedMaterial;
             mouseOverMaterial = NewMaterialWithColor(mouseOverColor);
             mouseDownMaterial = NewMaterialWithColor(mouseDownColor);

         }

         void OnMouseEnter() {
             mouseOver = true;
             meshRenderer.sharedMaterial = mouseOverMaterial;
         }

         void OnMouseDown() {
             meshRenderer.sharedMaterial = mouseDownMaterial;
         }

         void OnMouseUp() {
             if (mouseOver)
                 OnMouseEnter();
             else
                 OnMouseExit();
         }

         void OnMouseExit() {
             mouseOver = false;
             meshRenderer.sharedMaterial = originalMaterial;
         }

         private Material NewMaterialWithColor(Color newColor) {
             Material material = new Material(meshRenderer.sharedMaterial);
             material.color = newColor;

             return material;
         }
     } 
  1. 有两个公共颜色:一个用于鼠标悬停,一个用于鼠标按下(点击)高亮显示。

  2. 运行场景。现在你应该会看到当鼠标指针在 Cube 上时,会看到不同的高亮颜色,当你在鼠标指针在 Cube 上时点击鼠标按钮时。

由于我们创建了两个新的材质,上面的 NewMaterialWithColor(...) C# 方法是可重用的,以简化 Start() 方法的代码内容。引入了一个布尔(true/false)变量,以便在鼠标按钮释放后,根据鼠标指针是否仍然在对象上(mouseOver = true)或已从对象上移开(mouseOver = false),发生正确的行为。

将细节图添加到材质中

当创建一个大型对象时,不仅希望整体纹理化它,还希望添加细节,使其在*距离观看时看起来更好。为了克服需要大、内存占用大、高度详细的纹理图的需求,使用细节图可以真正地做出差异。

在这个配方中,我们将通过应用细节蒙版细节法线图来将细节图添加到火箭玩具中。在我们的案例中,我们想在绿色塑料上添加纹理质量(和条纹图案),除了电池隔室和玩具的标志区域:

准备工作

对于这个配方,我们在 05_06 文件夹中准备了三个文件:

  • rocketToy.fbx: 包装箱的 3D 对象 (FBX 格式)

  • ship_diffuse.png: 火箭船玩具的漫反射纹理图 (PNG 格式)

  • ship_height.png: 用于创建法线图的法线图 (PNG 格式)

还提供了你将在项目中创建的四个最终、图像编辑过的文件:

  • detail_diffuse.png: 带有切口的包装的 Albedo 纹理图 (PNG 格式)

  • detail_height.png: 灰度渐变圆圈 – 用于凹坑表面细节效果 (PNG 格式)

  • ship_mask.tif: 细节蒙版 – 表示文本和电池托架升起的高度(TIF 格式)

  • ship_mask2.tif: 细节蒙版 – 当电池托架的 Alpha 值较大时,从 detail_height (TIF 格式) 不会出现凹坑

如何操作...

要将细节图添加到你的对象中,请按照以下步骤操作:

  1. 导入提供的文件。

  2. 在项目面板中,选择 rocketToy 模型资产。在检查器中,点击 Materials 按钮,然后点击 Extract Materials... 将模型的材料提取到名为 Materials 的新文件夹中。现在你应该有 rocketToy 模型每个部分的五个材料 (MAT_base/end/level1/2/3):

  1. 选择 Mater****ial MAT_rocketLevel1,并将着色器更改为 Standard(镜面设置)。将 ship_diffuse 纹理从项目面板拖动到检查器属性中的 Albedo 纹理槽中 MAT_rocketLevel1。

  2. rocketToy 模型资产从项目面板拖动到场景面板(或层次结构)中,以将模型的实例作为 GameObject 添加到场景中。你应该能看到玩具标志文字的图像(“Rocket”)和电池仓在第一级(在底部上方):

  3. 复制 ship_diffuse 纹理,并将其命名为 ship_mask。

  4. 在您的图像编辑器中打开 ship_mask。选择围绕标志和电池仓周围的实心绿色像素(在 Photoshop 中,这可以通过按下 Shift 键并选择多个区域时使用魔棒工具来完成):

  1. 保持选择活动状态,访问图像通道窗口(在 Photoshop 中,这可以通过导航到窗口 | 通道来完成)。点击新建通道。这将是我们 Alpha 通道:

  1. 隐藏红色、绿色和蓝色通道。选择 Alpha 通道并将选择区域涂成白色。选择电池仓区域并将其涂成灰色(R、G 和 B:100):

  1. 以 TIF 格式保存为 ship_mask.tif,存放在 Assets 文件夹中。确保包含 Alpha 通道:

  1. 现在我们有了遮罩,让我们为我们的细节创建一个漫反射贴图。在您的图像编辑器中,创建一个新图像,其尺寸如下:宽度:64,高度:64:

  1. 用灰色(R、G 和 B:128)填充新图像。使用形状或矩形填充创建一个大约 16 像素高的深灰色(R、G 和 B:100)水*线:

  2. 将图片保存为 detail_diff.png 并存放在 Assets 文件夹中。

  3. 创建一个新的 64 x 64 图像。使用渐变工具创建黑白径向渐变(在 Photoshop 中,这可以通过径向模式的渐变工具来完成):

  1. 将图像保存为 detail_height.png 并存放在 Assets 文件夹中。返回到 Unity 编辑器。

  2. Assets 文件夹中选择 detail_height。在检查器中,将纹理类型更改为 Normal map,勾选从灰度创建选项,将凹凸度调整为 0.25,并将过滤设置为*滑。点击应用以保存更改:

  1. 对 ship_height 纹理也做同样的处理——在检查器中,将其纹理类型更改为法线图,勾选从灰度创建选项,调整凹凸度为0.25,并将过滤设置为*滑。点击应用以保存更改。

  2. 选择MAT_rocketLevel1材质,并在检查器中查看其属性。设置以下属性:

    • 将 ship_height 纹理分配到法线图槽,并将其强度设置为0.3

    • 将 ship_mask 纹理分配到细节遮罩槽。

    • 将 detail_diff 纹理分配到次级图 | 细节 Albedo x 2。

    • 将 detail_height 纹理分配为次级图 | 法线图,并将其强度设置为0.6

  3. 在次级图部分,按照以下方式更改拼接值:

    • 将拼接 X 设置为200,Y 设置为50

    • 将 UV 集设置为 UV1。

在我们将 UV 集设置为 UV1 之前,你可能已经注意到图案并不连续。这是因为我们使用了与漫反射纹理相同的 UV 集。然而,物体已被分配到两个不同的 UV 通道(在建模时)。虽然 UV 通道 1 包含漫反射图的映射,但 UV 通道 2 使用基本的圆柱形映射。我们需要将次级图部分的 UV 集从 UV0 更改为 UV1。

  1. 你的材质的细节图已经准备好了:

它是如何工作的...

当使用时,次级图会混合到材质的初级漫反射和法线图上——这就是为什么即使应用了漫反射细节,我们的物体仍然是绿色的:灰色色调叠加在原始漫反射纹理上。通过使用细节遮罩,艺术家可以定义物体的哪些区域应该受到次级图的影响。这对于定制来说非常好,也可以用于创建细微差别(例如,我们示例中的半凸电池仓)。

另一个有用的功能是使用单独的 UV 通道来处理细节图和拼接。除了为纹理映射增加变化外,这还允许我们通过显著提高物体的视觉质量,绘制出即使在非常*的距离也能感知到的细节。

淡化材质的不透明度

许多游戏的一个特点是物体逐渐淡出至不可见,或从不可见逐渐变为完全可见。Unity 提供了专门的渲染模式“淡出”,正好用于此目的。

在这个配方中,我们将创建一个对象,一旦点击,就会淡出并消失。我们还将探讨如何增强代码,以考虑 GameObject 的初始 alpha 值,在淡出完成后自我销毁等等。

如何做到这一点...

按照以下步骤操作:

  1. 创建一个新的3D 球体,命名为 Sphere-Game。选择菜单:3D 对象 | 球体。

  2. 选择 Sphere-Game 并确保它有一个 Collider(如果你使用的是自定义 3D 对象,你可能需要通过菜单:添加组件 | 物理 | 箱体(或网格)Collider 来添加 Collider)。

  3. 创建一个新的材质,命名为 m_fade。

  4. 在选择 m_fade 材质后,在检查器中将其渲染模式更改为淡入:

淡入渲染模式专门为这种情况设计。其他渲染模式,如透明,将使 Albedo 颜色透明,但不会使高光或反射透明,在这种情况下,对象仍然可见。

  1. 将 m_fade 材质应用到 Sphere-Game 中,通过从项目面板拖动它到 Sphere-Game GameObject。

  2. 创建一个新的 C#脚本类名为 FadeAway,并将其作为组件添加到 Sphere-Game 中:

using UnityEngine;
     public class FadeObject: MonoBehaviour {
         public float fadeDurationSeconds = 1.0f;
         public float alphaStart = 1.0f;
         public float alphaEnd = 0.0f;
         private float startTime;
         private MeshRenderer meshRenderer;
         private Color fadeColor;
         private bool isFading = false;

         void Start () {
             meshRenderer = GetComponent<MeshRenderer>();
             fadeColor = meshRenderer.material.color;
             UpdateMaterialAlpha(alphaStart);
         }

         void Update() {
             if (isFading)
                 FadeAlpha();
         }

         void OnMouseUp() {
             StartFading();
         }

         private void StartFading()
         {
             startTime = Time.time;
             isFading = true;
         }

         private void FadeAlpha()
         {
             float timeFading = Time.time - startTime;
             float fadePercentage = timeFading / 
             fadeDurationSeconds;
             float alpha = Mathf.Lerp(alphaStart, alphaEnd, 
            fadePercentage);
             UpdateMaterialAlpha(alpha);

             if (fadePercentage >= 1)
                 isFading = false;
         }

         private void UpdateMaterialAlpha(float newAlpha) {
             fadeColor.a = newAlpha;
             meshRenderer.material.color = fadeColor;
         }
     } 
  1. 播放你的场景并点击球体以查看它淡出并自我销毁。

它是如何工作的...

使用透明着色器的材料不透明度由其主颜色alpha值决定。这个配方是基于改变MeshRenderer颜色的 Alpha 值。

有三个公共变量:

  • fadeDurationSeconds:我们希望我们的淡入过程持续的时间(以秒为单位)

  • alphaStart:我们希望 GameObject 开始时的初始 alpha(透明度)(1 = 完全可见,0 = 不可见)

  • alphaEnd:我们希望 GameObject 淡入的 alpha 值

UpdateMaterialAlpha(...)方法通过更新fadeColor Color 变量的 alpha 值,然后强制 MeshRenderer 材质更新其颜色值以匹配 fadeColor 中的值,来使用给定的值更新 GameObject 的 Color 对象的 alpha 值。

当场景开始时,Start()方法缓存了 MeshRenderer 组件(meshRenderer变量)的引用,以及 MeshRenderer 材质的 Color 对象(fadeColor变量)。最后,通过调用UpdateMaterialAlpha(...)方法,将 GameObject 的 alpha 值设置为与变量 alphaStart 的值匹配。

当用户用鼠标点击 GameObject 时,会调用OnMouseUp()方法。这会调用StartFading()方法。

开始淡入的动作并没有简单地放在这个方法中,因为我们可能还希望由于某些其他事件(如键盘点击、计时器达到某个值或 NPC 进入某种模式,如死亡)而开始淡入。因此,我们将检测我们感兴趣的事件是否已经发生的那部分逻辑与我们要执行的动作分开,在这种情况下是开始淡入过程。

StartFading()方法记录当前的时间,因为我们需要知道何时结束淡入(开始淡入的时间 + 淡入持续时间)。同时,将 isFading 布尔标志设置为 true,这样其他与淡入相关的逻辑就会知道是时候做事情了。

Update()方法,每帧调用一次,测试isFading标志是否为真。如果是,则每帧调用FadeAlpha()方法。

FadeAlpha()方法是我们大部分 alpha 淡入逻辑的基础:

  • timeFading计算:我们开始淡入以来经过的时间

  • fadePercentage的计算:我们距离开始(0)到结束(1)的淡入距离

  • alpha 值的计算:使用Lerp(...)方法根据0..1百分比选择一个intermedia值,以确定我们淡入百分比适当的 alpha 值

  • 使用新的 alpha 值的UpdateMaterialAlpha(...)方法

  • 如果淡入已完成(fadePercentage >= 1),我们将isFading布尔标志设置为 false 以指示这一点

还有更多...

这里有一些增强我们的淡入功能的方法。

从按键开始并从不可见淡入

上述代码可以从不可见(alphaStart = 0)淡入到完全可见(alphaEnd = 1)。然而,如果我们最初看不到对象,那么要求玩家点击一个不可见的球体就有点过分了!所以让我们在Update()方法中添加代码(每帧检查)来检测当按下F键时,作为启动我们的淡入过程的另一种方式:

void Update()
 {
 if (Input.GetKeyDown(KeyCode.F))
 StartFading();

 if (isFading)
 FadeAlpha();
 }

淡入完成后销毁对象

如果淡入到不可见是 GameObject 与玩家沟通它正在离开场景(完成/死亡)的方式,那么我们可能希望在淡入过程完成后销毁该 GameObject。让我们将此功能添加到我们的代码中。

执行以下操作:

  1. 在我们的脚本中添加一个新的公共布尔变量(默认为 false):
public bool destroyWhenFadingComplete = true; 
  1. 添加一个新的EndFade()方法,将isFading设置为false,然后检查公共变量destroyWhenFadingComplete是否设置为true,如果是,则销毁 GameObject:
private void EndFade() {
         isFading = false;

         if(destroyWhenFadingComplete)
             Destroy (gameObject);
     } 
  1. 重新设计FadeAlpha()方法,使其在淡入完成后调用EndFade()方法(fadeProgress >= fadeDurationSeconds):
    private void FadeAlpha()
     {
         float fadeProgress = Time.time - startTime;
         float alpha = Mathf.Lerp(alphaStart, alphaEnd, fadeProgress 
          / fadeDurationSeconds);
         UpdateMaterialAlpha(alpha);

         if (fadeProgress >= fadeDurationSeconds)
             EndFade();
     } 

使用 GameObect 的 alpha 值作为我们的起始 alpha 值

可能是游戏设计师已经在检查器中将 GameObject 的 alpha 值设置为他们在初始值。所以让我们增强我们的代码,以便通过检查检查器中的公共布尔标志变量来指示这一点,并添加代码来读取和使用 GameObject 的 alpha 值,如果选择了该选项。

执行以下操作:

  1. 检查器中,点击材质的 Albedo 颜色选择器,并将Alpha值设置为除 255 之外的其他值(例如,设置为 32,这几乎是透明的):

  1. 在我们的脚本中添加一个新的公共布尔变量(默认为 false):
public bool useMaterialAlpha = false; 
  1. Start()方法中添加逻辑,以便如果此标志为真,我们使用从 GameObject 的材质中读取的颜色 alpha 值作为场景开始时的 alpha 值(fadeColor.a):
void Start () {
         meshRenderer = GetComponent<MeshRenderer>();

         // set object material's original color as fadeColor
         fadeColor = meshRenderer.material.color;

         // IF using material's original alpha value, THEN use 
             //material's alpha value for alphaStart
         if (useMaterialAlpha)
             alphaStart = fadeColor.a;

         // start object's alpha at our alphaStart value
         UpdateMaterialAlpha(alphaStart);
     } 

使用协程进行我们的淡入循环

在可能的情况下,我们应该避免在Update()方法中添加代码,因为这将每帧被调用,这可能会降低我们游戏的表现,尤其是如果有许多对象具有带有Update()方法的脚本组件,并且每个帧都在测试所有标志。

一个非常有效的解决方案是在我们需要在多个帧上执行某些动作时调用协程,因为协程可以执行一些动作,然后将控制权交还给场景的其余部分,然后从之前离开的地方继续其动作,依此类推,直到其逻辑完成。

执行以下操作:

  1. 删除Update()方法。

  2. 在脚本类的顶部添加一个新的 using 语句,因为协程返回一个IEnumerator值,它是System.Collections包的一部分:

using System.Collections; 
  1. 添加一个新的方法:
private IEnumerator FadeFunction() {
         while (isFading)
         {
             yield return new WaitForEndOfFrame();
             FadeAlpha();
         }
     } 
  1. 重构StartFading()方法,使其开始我们的协程:
private void StartFading() {
         startTime = Time.time;
         isFading = true;
         StartCoroutine(FadeFunction());
     } 

就这样 - 一旦协程开始执行,它将在每一帧中被调用,直到完成其逻辑,每次执行 yield 语句时都会暂时挂起其执行。

第六章:Shader Graphs 和 Video Players

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

  • 通过手动将 VideoPlayer 组件添加到 GameObject 来播放视频

  • 使用脚本控制场景纹理上的视频播放

  • 使用脚本连续播放一系列视频

  • 创建和使用简单的 Shader Graph

  • 使用 Shader Graph 创建颜色发光效果

  • 通过 C#代码切换 Shader Graph 颜色发光效果

简介

Unity 最*增加的两个强大功能是视频播放器组件(和 API)以及 Shader Graph 工具。它们共同提供了更简单、更可配置的方式来处理游戏中的视觉内容。例如,它们有助于在不同可见对象上加载和播放视频,并为非着色器程序员提供了一种使用可视化图形方法构建复杂着色器转换的全面方式。

整体情况

本章讨论的两个核心新 Unity 功能是Shader Graphs视频播放器。每个功能都有其自己的部分。

新的 Shader Graph 工具

2018 年,Unity 发布了关于令人兴奋的新Shader Graph功能的详细信息。Shader Graph是一个工具,允许通过创建和连接节点的输入和输出进行可视化的着色器构建。目前,它仅与轻量级可脚本渲染管线一起工作,但最终应该与许多管线一起工作。

Unity 的可脚本渲染管线允许不同的、可定制的渲染管线,这些管线可以针对特定项目和硬件设置高效地使用(例如,利用功能强大的桌面计算机中的 GPU,或降低性能要求以适应功能较弱的移动设备)。

一些优秀的Shader Graph功能包括以下内容:

  • 图中每个节点的即时、可视预览,以便您可以看到不同的节点是如何贡献给最终的输出节点的。

  • 属性可以在图中公开暴露(通过黑板),因此它们成为使用Shader Graph材质检查器中的可定制值。

  • 公开暴露的属性也可以通过脚本访问和更改。

  • 一个节点的输出可以成为另一个节点的输入之一,因此可以通过许多组合的简单组件节点创建复杂的着色器:

图片

这张截图说明了 Shader Graph 是如何由一个连接节点的图组成,其中一个节点的输出成为另一个节点的输入。节点输入/输出可以是数值、纹理、噪声、布尔真/假值、颜色等。

Shader Graph文件在项目面板中创建,可以在材质Shader属性中选择为图状着色器。

本章中提供了几个配方,以介绍一些强大的Shader Graph功能和工作流程。

使用新的视频播放器播放视频

在 2017 年,Unity 用 VideoPlayer 组件(以及相关的 VideoClip 资产文件类型)替换了旧的 MovieTexture。播放视频就像在设计时在检查器中手动添加一个 VideoPlayer 组件到 GameObject,并从项目面板关联一个 VideoClip 资产文件,或者提供在线资源的 URL。

视频可以在摄像机的远*面(看起来在场景内容之后)或**面(看起来在内容之前——通常具有半透明性)上播放。视频内容也可以指向一个 RenderTexture 资产,然后(通过一个材质)可以在场景中的二维或三维对象上显示。VideoPlayer 使用的内部纹理也可以映射到屏幕上的纹理——例如 UI Raw Image。

可以使用脚本管理单个视频剪辑和视频剪辑数组(序列)的播放。本章介绍了使用 VideoPlayer 的不同方法,并提供了几个示例。

在线参考资料

以下是一些关于本章主题的有用信息来源。

Shader Graph 在线资源

在以下链接中可以找到关于 Shader Graph 的 Unity 文档和第三方文章:

视频播放器在线资源

在以下链接中可以找到关于视频播放器的 Unity 文档和第三方文章:

通过手动将 VideoPlayer 组件添加到 GameObject 中来播放视频

电视、投影仪、显示器……如果你想在你的级别中添加复杂的动画材质,你可以播放视频文件作为纹理图。在这个菜谱中,我们将学习如何在主相机上添加和使用 VideoPlayer 组件。

准备工作

如果你需要一个视频文件以便遵循这个菜谱,请使用包含在 13_01 文件夹中的 videoTexture.mov 文件。

如何做到这一点...

要使用 VideoPlayer 组件手动放置视频,请按照以下步骤操作:

  1. 导入提供的 videoTexture.mov 文件。

  2. 通过选择菜单:GameObject | 3D Object | Cube 添加一个 3D 立方体到场景中。

  3. 选择主相机 GameObject,然后在检查器中,通过点击添加组件,选择 Video | Video Player。Unity 会注意到我们正在将 VideoPlayer 组件添加到相机,因此应该已经为我们正确设置了默认属性:

    • 在唤醒时播放(已勾选)

    • 等待第一帧(已勾选)

    • 渲染模式:相机远*面

    • 相机:主相机(Camera)

  4. 将项目面板中的视频剪辑资产文件 videoTexture 拖动到检查器中的 Video Clip 属性槽中,如下所示:

  1. 测试你的场景。你应该能够看到在场景内容后面的电影正在播放。

  2. 你可以通过在检查器中更改 Aspect Ratio 属性来选择是否拉伸视频内容(例如,你可以将其更改为 Stretch 以填充屏幕的完整背景)。

它是如何工作的...

我们给 Video Player 组件提供了一个指向 Video Clip 资产文件的引用。由于我们已将 Video Player 组件添加到相机(在这个例子中是主相机),它自动选择了与主相机链接的 Camera Far Plane 渲染模式。

默认设置是在唤醒时播放,所以一旦第一帧加载完成(因为 Wait For First Frame 也默认勾选),视频就会开始播放。视频显示在所有主相机内容(远*面)之后。正因为如此,我们在场景中看到了我们的 3D 立方体,视频在背景中播放。

更多内容...

这里有一些使用 VideoPlayer 组件的额外方法。

半透明视频和相机**面

有时,我们可能想播放视频,使其成为用户的主要焦点,但允许他们看到背景中的场景对象。

要使用 VideoPlayer 组件实现这一点,我们只需要进行两个更改:

  1. 将渲染模式更改为*相机*面(这样视频内容就会在场景内容之前播放)。

  2. 要允许用户部分看到视频,我们需要使 Video Player 半透明。将它的 Alpha 属性更改为 0.5。

现在,当你运行场景时,你将看到视频在场景内容之前播放,但现在你将能够看到背景中的 3D 立方体。

音频问题及 AudioSource 解决方案

在撰写这本书的时候,似乎有一些非苹果系统在使用音频输出模式音频播放的 Direct 选项时存在问题。一个解决方案是将 AudioSource 组件添加到具有 VideoPlayer 组件的同一 GameObject 中,并将音频输出模式设置为 AudioSource。

使用脚本控制场景纹理上的视频播放

尽管上一个示例展示了如何使用设计时设置的 Video Player 组件来规划视频,但通过脚本控制视频播放时,还有更多可能。

在这个示例中,我们将使用脚本播放/暂停渲染到 3D 立方体上的视频的播放:

准备工作

如果你需要视频文件来遵循这个示例,请使用包含在13_01文件夹中的videoTexture.mov文件。

如何做到这一点...

要使用脚本控制视频播放,请按照以下步骤操作:

  1. 导入提供的videoTexture.mov文件。

  2. 通过选择菜单:创建 | 3D | 立方体来创建一个 3D 立方体。

  3. 创建一个名为PlayPauseMainTexture的 C#脚本类,并将实例对象作为组件附加到你的 3D 立方体 GameObject 上:

using UnityEngine;
     using UnityEngine.Video;

     [RequireComponent(typeof(VideoPlayer))]
     [RequireComponent(typeof(AudioSource))]

     public class PlayPauseMainTexture : MonoBehaviour {
         public VideoClip videoClip;

         private VideoPlayer videoPlayer;
         private AudioSource audioSource;

         void Start() {
             videoPlayer = GetComponent&lt;VideoPlayer&gt;();
             audioSource = GetComponent&lt;AudioSource&gt;();

             videoPlayer.playOnAwake = false;
             audioSource.playOnAwake = false;

             videoPlayer.source = VideoSource.VideoClip;
             videoPlayer.clip = videoClip;

             videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource;
             videoPlayer.SetTargetAudioSource(0, audioSource);

             videoPlayer.renderMode = VideoRenderMode.MaterialOverride;
             videoPlayer.targetMaterialRenderer = GetComponent&lt;Renderer&gt;();
             videoPlayer.targetMaterialProperty = "_MainTex";
         }

         void Update() {
             if (Input.GetButtonDown("Jump"))
                 PlayPause();
         }

         private void PlayPause() {
             if (videoPlayer.isPlaying)
                     videoPlayer.Pause();
             else
                 videoPlayer.Play();
         }
     } 
  1. 确保在项目面板中选择你的 3D 立方体。然后,将项目面板中的视频剪辑资产文件videoTexture拖放到检查器中PlayPauseMainTexture组件(脚本)的视频剪辑属性槽中。

  2. 运行你的场景。按下空格键应该会在 3D 立方体的表面上播放/暂停视频的播放。你也应该听到视频的蜂鸣声。

它是如何工作的...

我们将我们的脚本类的实例对象添加到 3D 立方体中,并将视频剪辑资产文件的引用拖放到公共插槽中。在我们的代码中,我们正在告诉 VideoPlayer 组件覆盖它所附着的对象(在这种情况下,是 3D 立方体)的材料,以便 Video Player 将在 3D 立方体的主纹理上渲染(显示):

videoPlayer.renderMode = VideoRenderMode.MaterialOverride;
 videoPlayer.targetMaterialRenderer = GetComponent&lt;Renderer&gt;();
 videoPlayer.targetMaterialProperty = "_MainTex";

使用脚本和 VideoPlayer 的基本方法如下。除了定义和设置 Video Player 的渲染位置外,我们还需要每次都执行以下操作:

  1. 创建或获取 VideoPlayer 和 AudioSource 组件的引用(由于我们在类声明之前立即有RequireComponent(...)脚本指令,因此我们将自动拥有这两个组件):
videoPlayer = GetComponent&lt;VideoPlayer&gt;();
 audioSource = GetComponent&lt;AudioSource&gt;();
  1. 设置它们的 Play On Awake 为true/false
videoPlayer.playOnAwake = false;
 audioSource.playOnAwake = false; 
  1. 定义 Video Player 将找到要播放的视频剪辑的引用的位置:
videoPlayer.source = VideoSource.VideoClip;
 videoPlayer.clip = videoClip; 
  1. 定义音频设置(以便你可以输出到 AudioSource 组件):
videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource;
 videoPlayer.SetTargetAudioSource(0, audioSource); 

更多...

这里有一些使用 Video Player 脚本的其他方法。

确保在播放电影之前使用 prepareCompleted 事件准备电影。

在前面的菜谱中,由于游戏等待我们按下 jumo/space 键,电影有准备时间。如果我们使用脚本为视频剪辑设置视频播放器,我们需要在视频准备好播放之前做一些初始工作。Unity 提供了prepareCompleted事件,允许我们注册一个方法,在 VideoPlayer 准备好播放时调用。

执行以下操作:

  1. 通过选择菜单:创建 | UI | 原始图像,将 UI 原始图像添加到场景中。

  2. 创建一个名为 video-object 的新空 GameObject。

  3. 创建一个名为PrepareCompleted的 C#脚本类,并将其作为组件附加到 GameObject 的 video-object 实例上:

    using UnityEngine;
     using UnityEngine.UI;
     using UnityEngine.Video;

     public class PrepareCompleted: MonoBehaviour {
         public RawImage image;
         public VideoClip videoClip;

         private VideoPlayer videoPlayer;
         private AudioSource audioSource;

         void Start() {
             SetupVideoAudioPlayers();
             videoPlayer.prepareCompleted += PlayVideoWhenPrepared;
             videoPlayer.Prepare();
             Debug.Log("A - PREPARING");
         }

         private void SetupVideoAudioPlayers() {
             videoPlayer = gameObject.AddComponent&lt;VideoPlayer&gt;();
             audioSource = gameObject.AddComponent&lt;AudioSource&gt;();

             videoPlayer.playOnAwake = false;
             audioSource.playOnAwake = false;

             videoPlayer.source = VideoSource.VideoClip;
             videoPlayer.clip = videoClip;

             videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource;
             videoPlayer.SetTargetAudioSource(0, audioSource);
         }

         private void PlayVideoWhenPrepared(VideoPlayer theVideoPlayer) {
             Debug.Log("B - IS PREPARED");

             image.texture = theVideoPlayer.texture;

             Debug.Log("C - PLAYING");
             theVideoPlayer.Play();
         }
     } 
  1. 确保在项目面板中选择 video-object。现在,将层次结构中的原始图像拖动到原始图像槽中。然后,将项目面板中的视频剪辑资产文件videoTexture拖动到检查器中PrepareCompleted组件(脚本)的视频剪辑属性槽中。

  2. 测试您的场景。您应该能够在场景内容后面看到正在播放的电影。

您可以看到,在Start()方法中,我们首先将名为PlayVideoWhenPrepared的方法注册到videoPlayer.prepareCompleted事件,然后调用videoPlayer组件的Prepare()方法:

videoPlayer.prepareCompleted += PlayVideoWhenPrepared;
 videoPlayer.Prepare(); 

PlayVideoWhenPrepared(...)方法必须接受一个参数,作为 VideoPlayer 对象的引用。我们首先直接将 VideoPlayer 的纹理属性分配给原始图像的纹理。然后,我们发送Play()消息。

直接使用 VideoPlayer 纹理在这个例子中是可行的,但通常设置一个单独的渲染纹理更可靠、更灵活——请参阅以下小节了解如何操作。

您可以通过控制台面板中的日志消息跟踪剪辑准备进度等。

将视频播放输出到渲染纹理资产

使用视频播放器的一种灵活方法是将其播放输出到渲染纹理资产文件。可以创建一个材质来从渲染纹理获取输入,并使用该材质的 GameObject 将显示视频。此外,一些 GameObject 可以直接将渲染纹理分配为其纹理。

执行以下操作:

  1. 在项目面板中,创建一个名为myRenderTexture的新渲染纹理资产文件(菜单:创建 | 渲染纹理)。

  2. 在层次结构中选择 UI 原始图像,并将其原始图像(脚本)纹理属性分配给myRenderTexture资产文件。

  3. 在项目面板中,创建一个名为m_video的新材质资产文件。对于此材质,在检查器中,将其 Albedo Texture 属性设置为myRenderTexture(从项目面板拖动到检查器中)。

  4. 在场景中创建一个新的 3D 胶囊,并分配给其材质 m_video。

  5. 通过将公共rawImage变量替换为公共renderTexture变量来编辑 C#脚本类PrepareCompleted

  public VideoClip videoClip;
   public RenderTexture renderTexture; 
  1. 编辑 C# 脚本类 PrepareCompleted,在 SetupVideoAudioPlayers() 方法的末尾添加以下语句以将视频输出到 RenderTexture
    videoPlayer.renderMode = VideoRenderMode.RenderTexture;
     videoPlayer.targetTexture = renderTexture; 
  1. 编辑 C# 脚本类 PrepareCompleted 中的 PlayVideoWhenPrepared() 方法。删除直接将 VideoPlayer 的 texture 属性分配给 Raw Image 的 Texture 的语句:
    private void PlayVideoWhenPrepared(VideoPlayer theVideoPlayer) {
         Debug.Log("B - IS PREPARED");

         // Play video
         Debug.Log("C - PLAYING");
         theVideoPlayer.Play();
     } 
  1. 确保在项目面板中选择 GameObject video-object。现在,将项目面板中的 myRenderTexture 资产拖动到检查器中 Prepare Completed (Script) 的 Render Texture 公共属性。

  2. 运行场景。你现在应该看到视频在 UI Raw Image 中播放,并且渲染在 3D 圆柱对象上:

确保在协程中播放电影之前准备就绪

许多 Unity 程序员非常习惯于使用协程,因此我们可以通过使用协程来重写前面的脚本,而不是使用 prepareCompleted 事件。

执行以下操作:

  1. 删除 PlayVideoWhenPrepared() 方法。

  2. 在脚本顶部添加一个新的 using 语句(这样我们就可以引用 IEnumerator 接口):

    using System.Collections; 
  1. 将现有的 Start() 方法替换为以下内容:
    private IEnumerator Start() {
         SetupVideoAudioPlayers();
         videoPlayer.Prepare();

         while (!videoPlayer.isPrepared)
             yield return null;

         videoPlayer.Play();
     } 

如我们所见,我们的 Start() 方法已经变成了一个协程(返回一个 IEnumerator),这意味着它在执行过程中可以交回控制权给 Unity。在下一次帧中,它将在相同的位置恢复执行。

存在一个 while 循环,它将一直运行,直到 VideoPlayer 的 isPrepared 属性为 true。因此,Unity 的每一帧都会返回到这个 while 循环,如果 VideoPlayer 仍然没有准备好,它将再次进入循环并交回执行,直到下一帧。当 VideoPlayer 的 isPrepared 最终为 true 时,循环条件为假,因此循环之后的语句将被执行(videoPLayer.Play()),方法最终完成执行。

对于单个视频,在 isPrepared 事件和前面的协程之间选择余地很小。然而,对于一系列视频,使用 isPreparedloopPointReached 事件可以帮助我们为准备和等待播放序列中的下一个视频创建更简单的逻辑(有关更多信息,请参阅下一道菜谱)。

下载在线视频(而不是剪辑)

而不是将现有的视频剪辑资产文件拖动到指定要播放的视频,视频播放器还可以从在线源下载视频剪辑。我们需要将一个字符串 URL 分配给视频播放器的 URL 属性。

执行以下操作:

  1. 声明一个公共字符串数组,可以在其中定义一个或多个 URL:
    public string[] urls = {
         "http://mirrors.standaloneinstaller.com/video-sample/grb_2.mov",
         "http://mirrors.standaloneinstaller.com/video-sample/lion-sample.mov"
     }; 
  1. 声明一个新的方法,该方法返回一个 URL 字符串,从数组中随机选择:
    public string RandomUrl(string[] urls)
     {
         int index = Random.Range(0, urls.Length);
         return urls[index];
     } 
  1. 最后,在 SetupVideoAudioPlayers() 方法中,我们需要获取随机的 URL 字符串,并将其分配给视频播放器的 url 属性:
    private void SetupVideoAudioPlayers()
     {
         ... as before

         // assign video clip
         string randomUrl = RandomUrl(urls);
         videoPlayer.url = randomUrl;

         ... as before
     } 

使用脚本连续播放一系列视频

脚本的一个优点是它允许我们通过循环和数组等轻松地处理多个项目。在这个食谱中,我们将使用视频剪辑资产数组,并使用脚本连续播放它们(一个在先前的剪辑完成后立即开始),展示了使用 isPrepared 和 loopPointReached 事件来避免复杂的循环和协程的使用。

准备工作

如果你需要视频文件来遵循此食谱,请使用包含在 13_01 文件夹中的 videoTexture.mov 文件。

注意: 独立安装器网站提供了一个很好的在线测试视频资源:http://standaloneinstaller.com/blog/big-list-of-sample-videos-for-testers-124.html。

如何操作...

要使用脚本播放一系列视频,请按照以下步骤操作:

  1. 导入提供的 videoTexture.mov 文件,也许还有一个第二个视频剪辑,以便我们可以测试两个不同视频的序列(尽管如果你愿意,你也可以运行相同的两次)。

  2. 在项目面板中,创建一个名为 myRenderTexture 的新渲染纹理资产文件(菜单:创建 | 渲染纹理)。

  3. 通过选择菜单:创建 | UI | 原始图像将 UI 原始图像添加到场景中。

  4. 在层次结构中选择 UI 原始图像,并将其原始图像(脚本)纹理属性分配给 myRenderTexture 资产文件。

  5. 在项目面板中,创建一个名为 m_video 的新材质资产文件。对于此材质,在检查器中,将其 Albedo 纹理属性设置为 myRenderTexture(从项目面板拖动到检查器)。

  6. 通过选择菜单:创建 | 3D | 立方体创建一个 3D 立方体。将 m_video 材质分配给你的 3D 立方体。

  7. 创建一个名为 video-object 的新空 GameObject。

  8. 创建一个名为 VideoSequenceRenderTexture 的 C# 脚本类,并将一个实例对象作为组件附加到 GameObject 的 video-object 上:

    using UnityEngine;
     using UnityEngine.Video;

     public class VideoSequenceRenderTexture : MonoBehaviour {
         public RenderTexture renderTexture;
         public VideoClip[] videoClips;

         private VideoPlayer[] videoPlayers;
         private int currentVideoIndex;

         void Start() {
             SetupObjectArrays();
             currentVideoIndex = 0;
             videoPlayers[currentVideoIndex].prepareCompleted += PlayNextVideo;
             videoPlayers[currentVideoIndex].Prepare();
             Debug.Log("A - PREPARING video: " + currentVideoIndex);
         }

         private void SetupObjectArrays() {
             videoPlayers = new VideoPlayer[videoClips.Length];
             for (int i = 0; i &lt; videoClips.Length; i++)
                 SetupVideoAudioPlayers(i);
         }

         private void PlayNextVideo(VideoPlayer theVideoPlayer) {
             VideoPlayer currentVideoPlayer = videoPlayers[currentVideoIndex];

             Debug.Log("B - PLAYING Index: " + currentVideoIndex);
             currentVideoPlayer.Play();

             currentVideoIndex++;
             bool someVideosLeft = currentVideoIndex &lt; videoPlayers.Length;

             if (someVideosLeft) {
                 VideoPlayer nextVideoPlayer = videoPlayers[currentVideoIndex];
                 nextVideoPlayer.Prepare();
                 Debug.Log("A - PREPARING video: " + currentVideoIndex);
                 currentVideoPlayer.loopPointReached += PlayNextVideo;
             } else {
                 Debug.Log("(no videos left)");
             }
         }

         private void SetupVideoAudioPlayers(int i) {
             string newGameObjectName = "videoPlayer_" + i;
             GameObject containerGo = new GameObject(newGameObjectName);
             containerGo.transform.SetParent(transform);
             containerGo.transform.SetParent(transform);

             VideoPlayer videoPlayer = containerGo.AddComponent&lt;VideoPlayer&gt;();
             AudioSource audioSource = containerGo.AddComponent&lt;AudioSource&gt;();

             videoPlayers[i] = videoPlayer;

             videoPlayer.playOnAwake = false;
             audioSource.playOnAwake = false;

             videoPlayer.source = VideoSource.VideoClip;
             videoPlayer.clip = videoClips[i];

             videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource;
             videoPlayer.SetTargetAudioSource(0, audioSource);

             videoPlayer.renderMode = VideoRenderMode.RenderTexture;
             videoPlayer.targetTexture = renderTexture;
         }
     } 
  1. 确保在项目面板中选择 GameObject video-object。现在,将 myRenderTexture 资产从项目面板拖动到检查器中 PrepareCompleted(脚本)的渲染纹理公共属性。对于视频剪辑属性,设置大小为 2 – 你现在应该看到两个视频剪辑元素(元素 0 和 1)。从项目面板中拖入一个视频剪辑到每个槽位。

  2. 运行场景。你现在应该看到第一个视频剪辑在 UI 原始图像和 3D 立方体表面上播放。一旦第一个视频剪辑播放完毕,第二个视频剪辑应立即开始播放。

你可以通过控制台面板中的日志消息跟踪剪辑准备进度等。

它是如何工作的...

此脚本类使视频播放器对象将它们的视频输出到渲染纹理的资产文件 myRenderTexture。这被 3D 立方体和 UI 原始图像用于其表面显示。

videoClips 变量是一个公共数组,包含视频 clip 引用。

将 C#脚本类VideoSequenceRenderTexture的实例对象添加为 GameObject 的video-object的组件。此脚本将创建 GameObject video-object的子 GameObject,每个子 GameObject 都包含一个 VideoPlayer 和 AudioSource 组件,准备播放公共数组变量videoClips中分配的每个视频片段。

SetupObjectArrays()方法初始化videoPlayers为数组,其长度与videoClips相同。然后它循环遍历每个项目,通过传递当前整数索引调用SetupVideoAudioPlayers(...)

SetupVideoAudioPlayers(...)方法为 GameObject 的video-object创建一个新的子 GameObject,并将 VideoPlayer 和 AudioSource 组件添加到该 GameObject 中。它将视频播放器的 clip 属性设置为公共videoClips数组变量中的相应元素。它还在videoPlayers数组中的适当位置添加了对新 VideoPlayer 组件的引用。然后它设置视频播放器将音频输出到新的 AudioSource 组件,并将视频输出到公共renderTexture变量。

Start()方法执行以下操作:

  • 它调用SetupObjectArrays()

  • 它将currentVideoIndex变量设置为 0(用于数组的第一个项目)

  • 它为第一个videoPlayers对象的prepareCompleted事件注册PlayNextVideo方法(当前currentVideoIndex = 0)

  • 它为videoPlayers对象调用Prepare()方法(当前currentVideoIndex = 0)

  • 最后,它记录一条调试信息,说明项目正在准备中

PlayNextVideo(...)方法执行以下操作:

  • 它获取了与currentVideoIndex变量对应的videoPlayers数组中的视频播放器元素

此方法忽略它接收到的视频播放器引用参数——此参数在方法声明中是必需的,因为它是允许此方法注册prepareCompletedloopPointReached事件的必需签名。

  • 它向当前视频播放器发送Play()消息

  • 然后它增加currentVideoIndex的值,并检查数组中是否还有剩余的视频片段

  • 如果还有剩余的片段,则获取下一个片段的引用,并发送它一个Prepare()消息;同时,当前正在播放的视频播放器的loopPointReached事件注册为PlayNextVideo方法

    (如果没有剩余的视频,则打印一条简单的调试日志消息并结束方法)

智巧之处在于当前播放的 Video Player 已为 PlayNextVideo 方法注册了 loopPointReached 事件。当视频剪辑播放完毕时,loopPointReached 事件发生,并开始再次播放(如果其 loop 属性为 true)。我们在这个脚本中所做的是,当当前 Video Player 的视频剪辑播放完毕时,再次调用PlayNextVideo(...)方法——再次使用 currentVideoIndex 的值向下一个 Video Player 发送Play()消息,然后检查是否有剩余的 Video Player,以此类推,直到达到数组的末尾。

这是一个使用事件(if 语句)而不是协程 while 循环来使用条件的良好示例。只要你对方法如何与 C#事件注册感到满意,那么这种方法就可以通过避免循环和协程 yield null 语句来简化我们的代码。

在下面的屏幕截图中,我们可以看到我们的视频对象 GameObject 在运行时最终变成了 videoPlayer_<n>子 GameObject,每个元素一个。这允许一个 VideoPlayer 播放时,下一个正在准备,以此类推:

图片

创建和使用简单的 Shader Graph

Unity 2018 中的新 Shader Graph 功能是一个强大且令人兴奋的功能,它将着色器的创建和编辑向每个人开放,无需任何复杂的数学或编码技能。在这个菜谱中,我们将创建一个简单的 Shader Graph 来生成棋盘图案,并创建一个使用该着色器的材料,并将其应用于 3D 立方体。最终结果如下:

图片

如何操作...

要创建和使用简单的 Shader Graph,请按照以下步骤操作:

  1. 首先,我们需要设置轻量级渲染管线。使用包管理器导入轻量级渲染管线包。

  2. 在项目面板中,创建一个新的轻量级管线资产文件,命名为 myLightweightAsset。选择菜单:创建 | 渲染 | 轻量级管线资产。

  3. 在检查器中,通过选择菜单:编辑 | 项目设置 | 图形来显示项目的图形设置。然后,将 myLightweightAsset 从项目面板拖到可脚本渲染管线设置属性:

图片

  1. 使用包管理器导入 Shader Graph 包。

  2. 在项目面板中,创建一个新的基于物理渲染(PBR)Shader Graph,命名为 myShaderGraph。选择菜单:创建 | 着色器 | PBR 图形。

  3. 在项目面板中,创建一个新的名为 m_cube 的材料。选择菜单:创建 | 材料。

  4. 选择 m_cube 后,在检查器中,将其 Shader 属性设置为 myShaderGraph。对于材料的 Shader 属性,选择菜单:图形 | myShaderGraph:

图片

  1. 将 3D 立方体添加到场景中(菜单:游戏对象 | 3D 对象 | 立方体)。将这个 3D 立方体的材料设置为 m_Cube。

  2. 在项目面板中,双击 myShaderGraph 以打开 Shader Graph 编辑面板。一个新的 PRB Shader Graph 将打开,包含三个组件:(1)黑板(用于公开暴露参数);(2)主 PRB 节点;(3)输出预览器节点:

在编辑 Shader Graph 时,最大化 Shader Graph 面板是最容易的。

图片

  1. 右键单击输出预览器,并选择立方体:

您可以缩放和旋转预览网格。您还可以从您的项目中选择自定义网格,以便在目标 3D 对象上预览 Shader Graph。

图片

  1. 让我们在着色器中填充红色。从颜色选择器中选择红色作为 PRB Master 节点 Albedo 属性的最高属性。

  2. 通过右键单击鼠标并选择菜单:创建节点 | 程序 | 棋盘格来创建一个新的图节点。您将在该节点的预览中看到棋盘格图案。将 X 属性设置为 2,Y 属性设置为 3。

  3. 现在,从棋盘格节点输出 Out(3)拖动一个链接到 PRB Master 节点的 Emission (3)输入。现在,您应该在 PRB Master 节点预览中看到一个红色/粉红色的棋盘格图案,您也会在输出预览器节点中看到以下输出应用于立方体网格:

图片

  1. 在您能够看到它们应用于场景之前,您必须保存对 Shader Graph 的更改。在 Shader Graph 面板的左上角点击保存资产按钮。

  2. 保存并运行您的场景。您应该看到一个红色/粉红色的棋盘格 3D 立方体正在显示。

它是如何工作的...

通过安装包、创建资产并选择该资产作为项目的可脚本渲染管线图形属性,您启用了轻量级渲染管线。

您随后创建了一个新的着色器图资产文件,以及一个使用您着色器的新的材质。

您的 Shader Graph 将一个程序生成的棋盘格图案输入到 PBR Master 输出节点的 Emission 属性中,并且通过为 Albedo 属性选择红色颜色值来着色输出。您已保存对 Shader Graph 资产的更改,以便在场景运行时可用。

使用 Shader Graph 创建发光效果

在上一个配方中,通过使用原始 3D 立方体网格的材质创建了一个简单的 Shader Graph。在这个配方中,我们将更进一步,创建一个将参数化发光效果应用于 3D 对象的 Shader Graph。最终结果将如下所示:

图片

准备工作

此配方基于之前的配方,因此请复制该项目并使用副本为此配方。

如何操作...

要使用着色器图创建发光效果,请按照以下步骤操作:

  1. 在项目面板中,创建一个名为 glowShaderGraph 的新基于物理渲染(PBR)Shader Graph。选择菜单:创建 | 着色器 | PBR 图。

  2. 在项目面板中,创建一个名为m_glow的新材质。选择菜单:创建 | 材质。

    1. 选择 m_glow,在检查器中设置其 Shader 属性为 glowShaderGraph。对于材质的 Shader 属性,选择菜单:graphs | glowShaderGraph。
  3. 我们现在需要在场景中添加一个使用m_glow的 3D 网格对象。虽然我们可以再次使用 3D 立方体,但添加一个低多边形纹理角色更有趣。为此配方,我们使用了来自 AmusedArt 的免费 Unity Asset Store 角色包 Fantasy Mushroom Mon(ster)。一旦添加了包,从项目面板文件夹:amusedART | Mushroom Monster | Prefab 中将蘑菇怪物 Prefab 拖入场景。

图片

  1. 在项目面板中,双击 glowShaderGraph 以打开 Shader Graph 编辑面板。

  2. 右键单击输出预览器,选择自定义网格,并在选择对话框中选择 MushroomMon。

  3. 通过在Shader Graph黑板上创建一个新的属性纹理,向你的Shader Graph添加一个新的纹理公开属性。点击加号+按钮,选择属性类型纹理。

  4. 在黑板上,将属性纹理的默认值从 None 更改为 Mushroom Green。

  5. 要在我们的Shader Graph中使用公开的 Blackboard 属性,我们需要将属性的引用从 Blackboard 拖到图区域。将 Blackboard 属性纹理拖到图区域。你应该会看到一个标题为 Property,值为 Texture (T)的新节点:

图片

  1. 主 PDB 节点没有纹理输入,因此我们需要添加一个转换节点,可以从2D 纹理图像中获取(样本)数据,并将其转换为可以发送到PBR 主节点的 Albedo 输入的 RGB 值。通过在Shader Graph中鼠标右键单击,然后选择菜单:创建节点 | 输入 | 纹理 | Sample Texture 2D 来在你的Shader Graph中创建一个新的 Sample Texture 2D 节点。

  2. 现在,让我们通过 Sample Texture 2D 转换节点将纹理蘑菇绿发送到主 PRB 节点。将属性节点输出的纹理(T)连接到 Sample Texture 2D 节点的纹理(T)输入。你现在应该能在 Sample Texture 2D 节点底部的 2D 矩形预览中看到蘑菇绿纹理图像。

  3. 接下来,将 Sample Texture 2D 节点的 RGBA(4)输出连接到主 PRB 节点的 Albedo(3)输入(Unity 将智能地忽略第 4 个 Alpha(A)值)。你现在应该能在主 PRB 节点底部的预览中看到蘑菇绿纹理图像。你也应该能在Shader Graph输出预览器节点中看到蘑菇绿纹理被应用到 3D 蘑菇怪物网格上:

图片

  1. 创建发光效果的一种方法是通过应用菲涅耳效应。在我们的着色器图中创建一个新的菲涅耳效应节点。将菲涅耳效应节点的输出(3)连接到PRB 主节点的发射(3)输入。现在你应该在着色器图输出预览器节点中看到一个更亮的发光轮廓效果。

奥古斯丁-让·菲涅耳(1788-1827)研究了并记录了物体的反射如何依赖于观察角度——例如,直接向下看静止的水,很少有阳光反射,我们可以清楚地看到水。但如果我们眼睛靠*水面(例如,如果我们正在游泳),那么水会反射更多的光。在数字着色器中模拟这种效果是一种使物体边缘变亮的方法,因为光线是沿着物体的边缘掠过的,并反射到我们的游戏摄像机上。

  1. 让我们通过结合公开的着色属性来着色我们的菲涅耳效应,该属性可以通过检查器或 C#代码由游戏设计师设置。

  2. 首先,从菲涅耳效应节点到 PBR 主节点发射(3)输入的链接中删除链接。

  3. 通过在着色器图黑板中创建一个新的属性颜色来向你的着色器图添加一个新的公开颜色属性。点击加号+按钮并选择颜色属性类型。

  4. 在黑板中,将颜色属性的默认值设置为红色(使用颜色选择器)。

  5. 将黑板上的颜色属性拖动到图区域。你应该会看到一个标题为属性且值为颜色(4)的新节点。

  6. 在你的着色器图中通过右键单击鼠标并选择菜单:创建节点 | 数学 | 基本操作 | 乘法来创建一个新的乘法节点。

数学乘法节点是一种简单的方法,可以将两个节点的值组合起来,然后将这些值传递给第三个节点的单个输入。

  1. 让我们将颜色和菲涅耳效应结合起来,使乘法节点的两个输入都有效。将属性(颜色)节点的颜色(4)输出连接到乘法节点的 A(4)输入。接下来,将菲涅耳效应节点的输出(3)连接到乘法节点的 B(4)输入。最后,将菲涅耳效应节点的输出(3)连接到 PRB 主节点的发射(3)输入。现在你应该在着色器图输出预览器节点中看到一个带有红色着色的发光轮廓效果:下面的概述截图显示了为我们的着色器图完成这些节点连接。

图片

  1. 通过点击着色器图面板右上角的保存资产按钮来保存你的更新后的着色器图

  2. 保存并运行你的场景。你应该会在角色周围看到红色的发光效果。

  3. 在项目面板中,找到您的 3D GameObject 使用的材质(对于绿蘑菇怪物,它是文件夹:项目 | amusedART | Mushroom_Monster | Materials | MusroomMonGreen)。Shader Graph黑板上的公开暴露属性应作为可自定义属性出现在检查器中。将颜色属性更改为蓝色。再次运行场景。现在 3D GameObject 周围的发光效果应该是蓝色:

图片

它是如何工作的...

您创建了一个新的Shader Graph,其中包含几个连接的节点。一个节点的输出成为另一个节点的输入。

您使用Shader Graph黑板创建了公开暴露的颜色和纹理属性,并将这些属性作为图中的输入。

您使用了一个 Sample Texture 2D 节点将2D 纹理图像转换为适合PBR Master节点的 Albedo 输入的 RGB 值。

您创建了一个 Fresnel Effect 节点,并通过乘法节点将其与公开暴露的颜色属性结合,将输出发送到 PRB Master 节点的 Emission 输入。

您已经学习了如何在检查器中通过材质的属性更改公开暴露的 Color 属性。

通过 C#代码切换 Shader Graph 颜色发光效果

如前一个配方中的发光效果之类的效果通常是我们在不同情况下希望切换开启和关闭的功能。在游戏中,效果可以开启或关闭,以视觉上传达 GameObject 的状态——例如,一个愤怒的角色可能会发光红色,而一个快乐的怪物可能会发光绿色,等等。

我们将在之前的配方基础上添加新的公开暴露的Shader Graph黑板属性,命名为 Power,并编写代码来设置此值为零或五,以便开启和关闭发光效果。我们还将访问颜色属性,以便能够设置发光效果显示的颜色。

准备工作

此配方基于之前的配方,因此请复制该项目并使用副本进行此配方。

如何操作...

要在 Shader Graph 中切换发光效果,请按照以下步骤操作:

  1. 首先,删除从乘法节点的 Out (3)输出到 PBR Master 节点的 Emission (3)输入的链接。我们这样做是因为这个乘法节点的输出将成为我们即将创建的第二个乘法节点的输入。

  2. Shader Graph中通过右键单击鼠标并选择菜单:创建节点 | 数学 | 基本操作 | 乘法来创建一个新的乘法节点。

  3. 将原始乘法节点的 Out (4)输出链接到您新乘法节点的 A (4)输入。同时,将新乘法节点的 Out (3)输出链接到 PBR Master 节点的 Emission (3)输入。

  4. 通过在 Shader Graph 黑板中创建一个新的属性来将一个新的 float(十进制数)添加到你的 Shader Graph,使其公开于功率属性。点击加号 + 按钮并选择属性类型 Vector 1,并将此重命名为 Power

  5. 在黑板上,将功率属性的默认值设置为 5。同时,将显示模式设置为带有值 Min 0 和 Max 5 的滑块。

  6. 将黑板的功率属性拖动到图形区域。你应该会看到一个标题为属性的新节点,值为 Power(1)。

  7. 最后,将属性节点(功率)的 Power(1) 输出链接到新乘法节点的 B (4) 输入:

图片

  1. 通过点击 Shader Graph 面板右上角的保存资产按钮来保存你的更新后的 Shader Graph

  2. 创建一个新的名为 GlowManager 的 C# 脚本类,包含以下内容:

    using UnityEngine;

     public class GlowManager : MonoBehaviour {
         private string powerId = "Vector1_AA07C639";
         private string colorId = "Color_466BE55E";

         void Update () {
             if (Input.GetKeyDown("0"))
                 GetComponent&lt;Renderer&gt;().material.SetFloat(powerId, 0);

             if (Input.GetKeyDown("1"))
                 SetGlowColor(Color.red);

             if (Input.GetKeyDown("2"))
                 SetGlowColor(Color.blue);
         }

         private void SetGlowColor(Color c) {
             GetComponent&lt;Renderer&gt;().material.SetFloat(powerId, 5);
             GetComponent&lt;Renderer&gt;().material.SetColor(colorId, c);
         }
     } 
  1. 在项目面板中选择 Shader Graph glowShaderGraph,并在检查器中查看其属性:

图片

  1. 查找公开暴露的属性 Power 和 Color 的内部 ID – 它们可能是类似 Vector1_AA07C639 和 Color_466BE55E 的内容。将这些 ID 复制到 C# 脚本语句中,通过设置 ID 字符串:
    private string powerId = "Vector1_AA07C639";
    private string colorId = "Color_466BE55E"; 

在撰写本书时,当前版本的 Shader Graph 没有提供一种方便的方法来使用在 Shader Graph 黑板上选择的名称访问公开属性,因此需要查找用于 material.SetFloat(powerId, power) 语句所需的内部 ID。Unity 很可能很快就会更新 Shader Graph 脚本 API,使这一操作更加直接。

  1. 在层次结构中,找到包含网格渲染器组件的 3D GameObject 的组件(对于我们的蘑菇怪物示例,这是蘑菇怪物 GameObject 的蘑菇怪物子项)。将 GlowManager 脚本类的实例对象作为组件添加到这个 GameObject

  2. 保存并运行你的场景。按下 1 键应该开启红色发光效果,按下 2 键应该开启蓝色发光效果,按下 0 键应该关闭发光效果。

它是如何工作的...

你为你的 Shader Graph 创建了一个新的 Power 公开属性,它与菲涅耳颜色效果结合,使得零值会关闭效果。你查看了 Power 和 Color 公开属性的内部 ID,并更新了 C# 脚本,使其能够更新这些属性。

脚本类会检查 0/1/2 键,并相应地关闭效果/变为红色发光/变为蓝色发光。

通过结合公开暴露的属性和代码,你能够在运行时通过代码检测到的事件来更改 Shader Graph 的值。

还有更多...

这里有一些方法可以将你的 Shader Graph 功能进一步扩展。

使用正弦时间创建脉冲发光效果

你可以通过创建一个时间节点,并将正弦时间(1)输出链接到菲涅耳效果输入功率(1),使发光效果脉冲。当正弦时间值在-1/0/+1 之间变化时,它将影响菲涅耳效果的强度,从而改变发光效果的亮度。

使用“编译并显示代码”按钮作为查找公开属性 ID 的另一种方式

当你在检查器中查看着色器图资产属性时,你会看到一个名为“编译并显示代码”的按钮。如果你点击这个按钮,你将在脚本编辑器中看到一个生成的 ShaderLab 代码文件。

这并不是 Unity 实际使用的代码,但它为你从着色器图生成的代码提供了一个很好的概念。你公开暴露的黑板属性内部 ID 列在属性部分,这是生成代码的开头:

Shader "graphs/glowShaderGraph" {
    Properties { 

     [NoScaleOffset]  Texture_C5AA766B ("Texture", 2D) = "white" { }
      Vector1_AA07C639 ("Power", Range(0.000,5.000)) = 5.000 

     Color_466BE55E ("Color", Color) = (1.000,0.000,0.038368,0.000)
    } 

   etc. 

第七章:使用相机

在本章中,我们将介绍以下食谱:

  • 创建本章的基本场景

  • 创建画中画效果

  • 在多个相机之间切换

  • 从屏幕内容制作纹理

  • 调整望远镜相机的缩放

  • 显示小地图

  • 创建游戏中的监控相机

  • 与 Unity 的多用途相机装置协同工作

  • 使用 Cinemachine ClearShot 切换相机以保持玩家在画面中

  • 允许玩家切换到 Cinemachine FreeLook 相机

简介

作为开发者,我们永远不应该忘记关注相机。毕竟,它们是我们玩家看到我们游戏窗口。在本章中,我们将探讨一些有趣的使用相机的方法,这些方法可以增强玩家的体验。

整体情况

场景可以包含多个相机。通常,我们有一个主相机(新场景默认提供)。对于第一人称视角的游戏,我们直接控制相机的位置和旋转,因为它充当我们的眼睛。在第三人称视角的游戏中,我们的主要相机跟随一个动画 3D 角色(通常是从上方/后方/肩上),并且可能缓慢而*滑地改变其位置和旋转,就像有人拿着相机移动以保持我们的视线一样。

透视相机前方有一个三角形金字塔形状的空间体积,称为视锥体。空间内的物体被投影到一个*面上,这决定了我们从相机看到的内容。我们可以通过指定裁剪*面和视野来控制这个空间体积。裁剪*面定义了物体之间的最小和最大距离,以确定它们是否可见。视野由金字塔形状的宽窄决定:

相机可以通过多种方式定制:

  • 它们可以排除特定层上的对象以进行渲染

  • 它们可以被设置为正交模式(即,没有透视)进行渲染

  • 它们的视野FOV)可以被调整以模拟广角或窄角镜头

  • 它们可以被渲染在其他相机之上或屏幕的特定区域(视口)内

  • 它们可以被渲染成纹理文件

列表还在继续。以下截图展示了这些相机功能中的几个。相同的场景有一个透视相机,输出到占据整个游戏屏幕(从 0,0 到 1,1)的视口。在其上方是一个用于正交相机的第二个视口,显示相同场景内容的 2D 俯视图。这个视口只是屏幕的右上四分之一 (0, 0.5)(0.5, 1.0)。"幽灵"角色在一个被第二正交相机忽略(裁剪)的层上:

相机 有一个深度属性。Unity 使用这个属性来确定相机的渲染顺序。Unity 从最低的深度数字开始渲染相机,然后逐步增加到最高的。这是为了确保那些没有渲染到整个屏幕的相机在渲染到整个屏幕的相机之后被渲染。你将在包括画中画食谱在内的几个食谱中看到这一点。

Cinemachine

由 Adam Myhill 开发,现在作为 Unity 包免费提供,Cinemachine 是一个强大的自动相机控制系统。它为 Unity 开发者提供了很多帮助,包括游戏运行时的相机控制和电影场景或完整动画电影的制作。我们以如何使用 Cinemachine 添加一些运行时相机控制到你的游戏中的示例结束本章。

Cinemachine 的核心概念是在场景中设置一组虚拟相机和一个 Cinemachine Brain 组件,该组件决定应该使用哪个虚拟相机的属性来控制场景的 主相机

www.cinemachineimagery.com/ 了解更多关于 Cinemachine 的历史和发展。

创建本章的基本场景

本章中的所有食谱都以相同的基本场景开始,包括一个 3D 迷宫、一些对象和一个可由键盘控制的 3D 角色。在这个食谱中,你将创建一个包含此类场景的项目,该场景可以复制并适应后续的每个食谱。

图片

准备工作

对于这个食谱,我们准备了一个名为 CamerasChapter.unity 的 Unity 包,其中包含本章所需的所有资源。该包位于 06_01 文件夹中。

如何做到这一点...

要创建本章的基本场景,只需按照以下步骤操作:

  1. 创建一个新的 3D 项目。

  2. 将 CamerasChapter 包导入到你的 Unity 项目中。

  3. 在项目面板中,你会在 Prefabs 文件夹中找到三个预制体(maze-floor-wallsmaze-objectscharacter-MsLazer)。通过将预制体从项目面板拖动到层次或场景面板中,为这三个预制体创建 GameObject。

  4. 你现在应该有一个带有地板、一些墙壁、一些 球体 对象、一个绿色重生点和可由键盘控制的 character-MsLazer 3D 角色的迷宫。

  5. 让我们将场景的 主相机 附接到角色上,这样你就可以在迷宫中移动角色时始终看到这个第三人称控制器角色。将主相机作为 character-MsLazer 的子对象。

  6. 在检查器中,将主相机位置设置为(0,3,-5),并将旋转设置为(5,0,0)。

  7. 现在,当你使用箭头键在迷宫中移动角色时,主相机应自动跟随角色移动和旋转,并且你应该能够始终看到角色的背面。

  8. 保存并运行场景。当你移动角色时,主相机 应该随着角色移动。

它是如何工作的...

通过克隆预制体,你将迷宫和一些对象添加到了一个空场景中。你还将一个键盘控制器角色添加到了场景中。

通过将主相机作为子对象附加到角色 GameObject,主相机始终相对于角色保持相同的位置和旋转。因此,随着角色的移动,主相机也会移动,为游戏动作提供了一个简单、肩上视角

创建画中画效果

在许多情况下,显示多个视口可能很有用。例如,你可能想显示在不同地点同时发生的事件,或者你可能想为热座多人游戏有一个单独的窗口:

准备工作

这个配方是在本章第一个配方创建的场景的基础上添加的,所以请复制那个项目文件夹,并使用该副本进行这个配方的操作。

如何操作...

要创建画中画显示,只需按照以下步骤操作:

  1. 在场景中添加一个名为Camera-pic-in-pic的新相机。选择菜单:创建 | 相机。

  2. 在检查器中,对于相机组件,将深度属性设置为1

  3. 取消选中或删除相机的音频监听器组件,因为场景中应该只有一个活动的音频监听器。

  4. 创建一个新的 C#脚本类名为PictureInPicture,并将实例对象作为组件添加到Camera-pic-in-picGameObject:

    using UnityEngine;
     public class PictureInPicture : MonoBehaviour {
         public enum HorizontalAlignment {
             Left, Center, Right
         };

         public enum VerticalAlignment {
             Top, Center, Bottom
         };

         public HorizontalAlignment horizontalAlignment = HorizontalAlignment.Left;
         public VerticalAlignment verticalAlignment = VerticalAlignment.Top;
         public float widthPercentage = 0.5f;
         public float heightPercentage = 0.5f;
         private Camera camera;

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

         void Update() {
             Vector2 origin = CalcOrigin();
             Vector2 size = new Vector2(widthPercentage, heightPercentage);
             Rect newCameraRect = new Rect(origin, size);
             camera.rect = newCameraRect;
         }

         private Vector2 CalcOrigin() {
             float originX = 0;
             float originY = 0;

             switch (horizontalAlignment) {
                 case HorizontalAlignment.Right:
                     originX = 1 - widthPercentage;
                     break;

                 case HorizontalAlignment.Center:
                     originX = 0.5f - (0.5f * widthPercentage);
                     break;

                 case HorizontalAlignment.Left:
                 default:
                     originX = 0;
                     break;
             }

             switch (verticalAlignment) {
                 case VerticalAlignment.Top:
                     originY = 1 - heightPercentage;
                     break;

                 case VerticalAlignment.Center:
                     originY = 0.5f - (0.5f * heightPercentage);
                     break;

                 case VerticalAlignment.Bottom:
                 default:
                     originY = 0;
                     break;
             }

             return  new Vector2(originX, originY);
         }
     }
  1. 在检查器中,更改一些画中画(脚本)参数:选择顶部和右侧作为垂直和水*对齐。选择 0.25 作为垂直和水*百分比。

  2. 播放你的场景。在游戏面板中,你的画中画相机的视口应该在屏幕的左上角可见,占据屏幕的四分之一(25%)。

它是如何工作的...

在这个例子中,你添加了一个第二个相机,以便从不同的视角显示场景。

默认主相机的默认深度0。你将我们的Camera-pic-in-pic的深度设置为1,因此主相机首先渲染,覆盖整个游戏窗口,然后我们的第二个相机(画中画)最后渲染,位于主相机渲染之上。

画中画脚本更改相机的标准化视口矩形,从而根据用户的偏好调整视口的尺寸和位置。垂直和水*对齐的四个值,加上宽度高度百分比,用于在游戏面板的(0,0)-(1.0, 1.0)标准化坐标中创建一个矩形。相机rect属性被设置为计算出的新矩形。

还有更多...

以下是你可能更改的画中画的一些方面。

在屏幕上更改画中画视口的尺寸和位置

你可以通过设置水*和垂直百分比值来更改画中画矩形的大小

垂直对齐和水*对齐选项可以用来改变视口的垂直和水*对齐。使用它们将其放置在所需的位置,例如左上角、右下角、中心中心等。

添加对景深和宽高比的进一步控制

你可以向代码中添加额外的公共变量,并对应地进行视野宽高比相机调整:

    [Range(20f, 150f)]
     public float verticalFieldOfView = 90f;

     [Range(0.25f, 2f)]
     public float ascectRatio = 1f;

     void Update()
     {
         Vector2 origin = CalcOrigin();
         Vector2 size = new Vector2(widthPercentage, heightPercentage);
         Rect newCameraRect = new Rect(origin, size);
         camera.rect = newCameraRect;
         camera.fieldOfView = verticalFieldOfView;
         camera.aspect = ascectRatio;
      }

相机视野是指相机捕捉到的世界范围;通常,我们将其视为视野的宽窄。更多信息请参阅en.wikipedia.org/wiki/Field_of_viewdocs.unity3d.com/ScriptReference/Camera-fieldOfView.html

相机宽高比是矩形宽度与高度的关系。它是通过宽度除以高度来计算的。更多信息请参阅docs.unity3d.com/ScriptReference/Camera-aspect.htmlen.wikipedia.org/wiki/Aspect_ratio_(image)

在检查器中手动更改相机视口属性

一旦你熟悉了使用(0,0)到(1.0, 1.0)的标准化视口坐标系,你就可以直接在检查器中手动编辑相机视口设置,而不需要使用任何 C#脚本类:

图片

参见

本章中的显示迷你图配方。

在多个相机之间切换

在许多游戏类型中,从多种相机中选择是一个常见功能:赛车、体育、大亨/策略等。在本配方中,你将学习如何通过使用键盘让玩家能够从许多相机中进行选择。

准备工作

本配方是在本章第一配方创建的场景基础上进行的,因此请复制那个项目文件夹,并使用该副本来完成本配方的操作。

如何操作...

要实现可切换的相机,请按照以下步骤操作:

  1. 在场景中创建一个新的相机,命名为 Camera-1(创建 | 相机)。将其位置设置为(0,0,0)。将此相机的标签设置为主相机

  2. 复制 Camera-1,将副本命名为 Camera-2。将其位置设置为(0, 0, -15),并将其旋转设置为(20, 0, 0)。

  3. 禁用 Camera-1 和 Camera-2 的相机和 AudioListener 组件。

  4. 创建一个名为 switchboard 的空 GameObject(创建 | 创建空对象)。

  5. 创建一个名为CameraSwitch的新 C#脚本类,包含以下内容,并将其作为组件添加到 switchboard GameObject 中:

  using UnityEngine;    
  public class CameraSwitch : MonoBehaviour  {
         public Camera[] cameras = new Camera[3];
         public bool changeAudioListener = true;

         void  Update() {
             if (Input.GetKeyDown("0")) {
                 EnableCamera(cameras[0], true);
                 EnableCamera(cameras[1], false);
                 EnableCamera(cameras[2], false);
             }

             if (Input.GetKeyDown("1")) {
                 EnableCamera(cameras[0], false);
                 EnableCamera(cameras[1], true);
                 EnableCamera(cameras[2], false);
             }

             if (Input.GetKeyDown("2")) {
                 EnableCamera(cameras[0], false);
                 EnableCamera(cameras[1], false);
                 EnableCamera(cameras[2], true);
             }
         }

         private void EnableCamera(Camera cam, bool enabledStatus) {
             cam.enabled = enabledStatus;

             if(changeAudioListener)
                 cam.GetComponent<AudioListener>().enabled = enabledStatus;
         }
     }
  1. 确保在层次结构中选择开关板GameObject。在相机切换(脚本)组件的检查器中,将相机数组的大小设置为3。然后,将场景中的相机拖动并填充到相机槽中,包括主相机和 MsLazer 角色的子对象:

  1. 保存并播放场景。按下键012应在三个相机之间切换。

它是如何工作的...

每帧,都会对三个快捷键(0、1 或 2)中的每一个进行测试。如果按下了这些键中的任何一个,则相应的相机被启用,而其他两个相机被禁用。

如果公共布尔属性changeAudioListener已被勾选,则所选相机内的AudioListener被启用,而其他两个相机中的AudioListeners被禁用。

更多内容...

这里有一些关于如何尝试调整这个食谱的想法。

使用单个启用的相机

解决这个问题的另一种方法是将所有辅助相机禁用,并通过脚本类将它们的位置和旋转分配给主相机(如果你想要保存其变换设置,你需要复制主相机并将其添加到列表中)。这与Cinemachine包实现的将虚拟相机属性应用于主相机的方法类似(参见本章最后两个食谱,了解更多关于Cinemachine的信息)。

参见

本章中创建游戏内监控相机食谱。

从屏幕内容制作纹理

如果你想要你的游戏或玩家在游戏中捕捉快照并将其作为纹理应用,这个食谱将向你展示如何操作。如果你计划实现游戏内照片库或在关卡结束时显示关键时刻的快照,这将非常有用(赛车游戏和特技模拟大量使用此功能)。对于这个特定的例子,我们将捕捉屏幕的一个框架区域并将其打印在显示器的右上角:

准备工作

这个食谱增加了本章第一个食谱中创建的场景,所以请复制那个项目文件夹,并使用该副本来完成这个食谱的工作。

如何操作...

要从屏幕内容创建纹理,请按照以下步骤操作:

  1. 在层次结构中,通过选择以下内容从层次结构面板菜单创建一个新的UI Image,命名为Image-frame:创建 | UI | Image。由于这是在此场景中创建的第一个 UI GameObject,应自动创建新的 Canvas 和EventSystemGameObject,Image-frame UI 应成为 Canvas GameObject 的子对象。

  2. 从检查器面板中,找到 frame GameObject 的 Image(脚本)组件,并将其源图像设置为InputFieldBackground。同时,取消选中填充中心选项。

Sprite InputFieldBackground是 Unity 附带的一部分,并且已经为了调整大小而切片。

  1. 为图像框架设置锚点:最小(0.25),(0.25);最大(0.75),(0.750)。

  2. 将位置(左:0,上:0,位置 Z:0)和大小(宽度:0,高度:0)归零。

  3. 图像框架GameObject 应出现在屏幕中央,占据屏幕的一半:

图片

  1. 在层次结构中,通过从层次结构面板菜单中选择以下内容创建一个新的UI 原始图像名为原始图像-照片创建 | UI | 原始图像。确保其纹理对于其原始图像(脚本)组件为

  2. 检查器中,现在禁用整个原始图像-照片GameObject。

  3. 宽度高度设置为1。为原始图像-照片设置锚点:最小(0),(1);最大(0),(1)。

  4. 原点设置为(0, 1)。并通过将设置为0设置为0Z设置为0来归零位置。

  5. 创建一个新的 C#脚本类名为TextureFromCamera,并将实例对象作为组件添加到主相机GameObject(MsLazer的子对象)中:

    using UnityEngine;
     using UnityEngine.UI;
     using System.Collections;

     public class TextureFromCamera : MonoBehaviour {
         public GameObject imageFrame;
         public GameObject rawImagePhoto;
         public float ratio = 0.25f;

         void  LateUpdate ()  {
             if (Input.GetKeyUp(KeyCode.Mouse0))
             {
                 rawImagePhoto.SetActive (false);
                 StartCoroutine(CaptureScreen());
                 rawImagePhoto.SetActive (true);
             }
         }

         IEnumerator CaptureScreen () {
             RectTransform frameTransform = imageFrame.GetComponent<RectTransform> ();
             Rect framing = frameTransform.rect;
             Vector2 pivot = frameTransform.pivot;
             Vector2 origin = frameTransform.anchorMin;
             origin.x *= Screen.width;
             origin.y *= Screen.height;
             float xOffset = pivot.x * framing.width;
             origin.x += xOffset;
             float yOffset = pivot.y * framing.height;
             origin.y += yOffset;
             framing.x += origin.x;
             framing.y += origin.y;
             Texture2D texture = new Texture2D((int)framing.width, (int)framing.height);

             yield return new WaitForEndOfFrame();
             texture.ReadPixels(framing, 0, 0);
             texture.Apply();
             Vector3 photoScale = new Vector3 (framing.width * ratio, framing.height * ratio, 1);
             rawImagePhoto.GetComponent<RectTransform> ().localScale = photoScale;
             rawImagePhoto.GetComponent<RawImage>().texture = texture;
         }
     }
  1. 检查器中,找到屏幕纹理组件,并将原始图像照片字段填充为GameObject 原始图像-照片;并将图像框架字段设置为图像框架GameObject。

  2. 播放场景。每次您点击鼠标,您都会在矩形框架内捕获屏幕的快照,并且快照应该显示在屏幕的左上角。

它是如何工作的...

首先,我们创建了一个用于捕获快照的 UI 框架,以及一个位于屏幕左上角的 UI 原始图像,用于应用快照纹理。

每帧,C#脚本类TextureFromCameraLateUpdate()方法都会执行。每次发生这种情况时,都会进行测试以查看是否点击了鼠标按钮。如果是,则禁用 UI 原始图像(这样以前的快照就不会出现在新的快照中),并调用CaptureScreen()协程方法。

我们在这里使用LateUpdate()来确保在捕获图像之前所有渲染都已经完成。

协程CaptureScreen()计算一个Rect区域,从该区域复制屏幕像素,并将它们应用于要由UI 原始图像元素显示的纹理,该元素也被调整大小以适应纹理。

Rect的大小是根据屏幕尺寸和框架的 Rect Transform 设置计算的,特别是其原点,锚点,宽度和高度。然后,通过ReadPixels()命令捕获该区域的屏幕像素,并将其应用于纹理,然后将纹理应用于原始图像照片,该照片本身被调整大小以适应照片大小和原始像素之间的所需比例。

CaptureScreen()方法是一个协程,它允许它等待直到帧的结束(yield return new WaitForEndOfFrame()),然后再尝试从相机捕获图像的副本。

还有更多...

除了将纹理显示为 UI 元素外,你还可以以其他方式使用它。

将你的纹理应用到材质上

你可以通过在CaptureScreen方法的末尾添加类似GameObject.Find("MyObject").renderer.material.mainTexture = texture的行,将你的纹理应用到现有对象的材质上。

使用你的纹理作为截图

你可以将你的纹理编码为 PNG 图像文件并保存。这可以在 Unity 文档页面中关于编码 PNG 图像的部分找到:docs.unity3d.com/ScriptReference/ImageConversion.EncodeToPNG.html

相关内容

在第十章“与外部资源文件和设备一起工作”,保存和加载数据文件中,有关从游戏中保存截图的菜谱。

缩放望远镜相机

在这个菜谱中,我们将创建一个望远镜相机,每当按下左鼠标按钮时,它就会放大。

准备工作...

这个菜谱增加了本章第一个菜谱中创建的场景,所以请复制那个项目文件夹,并使用该副本来完成这个菜谱的工作。

如何操作...

要创建一个望远镜相机,请按照以下步骤操作:

  1. 创建一个新的 C#脚本类名为TelescopicView,并将一个实例对象作为组件添加到主相机MsLazer的子对象):
  using UnityEngine;

     public class TelescopicView : MonoBehaviour {
         public float zoom = 2.0f;
         public float speedIn = 100.0f;
         public float speedOut = 100.0f;
         private float initFov;
         private float currFov;
         private float minFov;
         private float addFov;

         void Start() {
             initFov = Camera.main.fieldOfView;
             minFov = initFov / zoom;
         }

         void Update() {
             if (Input.GetKey(KeyCode.Mouse0))
                 ZoomView();
             else
                 ZoomOut();
         }

         void ZoomView(){
             currFov = Camera.main.fieldOfView;
             addFov = speedIn * Time.deltaTime;

             if (Mathf.Abs(currFov - minFov) < 0.5f)
                 currFov = minFov;
             else if (currFov - addFov >= minFov)
                 currFov -= addFov;

             Camera.main.fieldOfView = currFov;
         }

         void ZoomOut() {
             currFov = Camera.main.fieldOfView;
             addFov = speedOut * Time.deltaTime;

             if (Mathf.Abs(currFov - initFov) < 0.5f)
                 currFov = initFov;
             else if (currFov + addFov <= initFov)
                 currFov += addFov;

             Camera.main.fieldOfView = currFov;
         }
     }
  1. 播放关卡。当你点击并按住右鼠标按钮时,你应该会看到一个动画缩放效果。

它是如何工作的...

缩放效果实际上是由相机视野FOV)属性值的改变引起的;较小的值会导致较小区域的更*距离视图,而较大的值会扩大 FOV。

TelescopicView脚本类通过在按下左鼠标按钮时减少视野(FOV)来改变相机的视野。当不按住右鼠标按钮时,它还会增加 FOV 值,直到达到原始值。

FOV 的缩放限制可以从minFov = initFov / zoom代码中推断出来。这意味着 FOV 的最小值等于其原始值除以缩放量。例如,如果我们的相机原始 FOV 为 60,我们将望远镜视图缩放设置为2.0,则允许的最小 FOV 将是 60/2 = 30。

还有更多...

有一些细节你不希望错过。

在缩放时添加晕影效果

在游戏中,通常在相机缩放的同时应用视觉晕影效果。晕影是指图像边缘变得不那么明亮或更加模糊(通常是椭圆形或圆形)。这曾经是旧相机和镜头的一个意外(并且通常是希望避免的)效果,但可以在游戏中有意应用,以帮助玩家专注于屏幕中央的内容,并在游戏中的某个特定点增加更强烈的氛围:

要添加晕影效果,请执行以下操作:

  1. 打开资产商店面板,然后下载并导入 Unity Technologies 发布的免费后处理堆栈资产。

  2. 在检查器中,选择主摄像机 GameObject(MsLazer的子对象)。然后,添加一个后处理行为组件。转到添加组件 | 效果 | 后处理行为。

  3. 在项目面板中,通过选择项目面板菜单中的创建 | 后处理配置文件,创建一个名为my-vignette的新后处理配置文件。

  4. 在项目面板中选择my-vignette文件,在检查器中勾选 my-vignette 效果并单击一次以显示其属性。设置其属性如下:

    • 中心:(X: 0.5, Y: 0.5)

    • 强度:0.75

    • *滑度:0.5

    • 圆度:1

  5. 检查器中选择主摄像机,从项目面板中将my-vignette文件拖到检查器中,以填充后处理行为组件的配置文件属性。

  6. 你现在应该看到游戏面板摄像机视图边缘的暗模糊晕影圆形效果。

  7. 检查器中,选择主摄像机并禁用其后处理行为组件(在检查器中取消勾选此组件)。我们将在脚本中仅当缩放效果开启时启用此效果

  8. TelescopicView脚本类顶部添加一个新的 using 语句:

using UnityEngine.PostProcessing;
  1. TelescopicView脚本类中添加一个新的私有变量:
private PostProcessingBehaviour postProcessingBehaviour;
  1. TelescopicView脚本类的Start()方法末尾添加一个语句以获取并存储对后处理行为组件的引用:
    void Start() {
         initFov = Camera.main.fieldOfView;
         minFov = initFov / zoom;
         postProcessingBehaviour = GetComponent<PostProcessingBehaviour>();
     }
  1. 在脚本类TelescopicViewUpdate()方法中添加语句,以在鼠标按键按下时启用后处理行为,在未按下时禁用它:
    void Update() {
         if (Input.GetKey(KeyCode.Mouse0)) {
             postProcessingBehaviour.enabled = true;
             ZoomView();
         }
         else {
             ZoomOut();
             postProcessingBehaviour.enabled = false;
         }
     }
  1. 播放场景。当鼠标按钮被点击并保持时,你应该会看到除了缩放效果外,还有一个动画晕影效果。

在 Unity 文档中了解更多关于晕影效果和后处理堆栈的信息:

进一步了解 Unity 后处理堆栈的版本 2

在打印时,Unity 发布了他们后处理堆栈的实验性版本 2。

要添加晕影效果,请执行以下操作:

  1. 检查器中选择主摄像机MsLazer的子对象)。在检查器中创建一个名为PostProcessing的新,并将主摄像机设置为PostProcessing

  2. 从 Unity GitHub 账户github.com/Unity-Technologies/PostProcessing下载 ZIP 文件。

  3. 然后,将文件夹解压到项目 Assets 文件夹中。

  4. 将后处理层组件添加到 主相机。在 检查器 中通过点击 添加组件 | 渲染 | 后处理层 来完成此操作。将此组件的 属性设置为 后处理

  5. 现在,通过点击 添加组件 | 渲染 | 后处理体积 来添加一个 后处理体积组件。检查 Is Global 属性。创建一个新的配置文件(点击新建按钮)。然后,点击添加效果...按钮,从下拉菜单中选择 Vignette。

  6. TelescopicView 脚本类顶部添加一个 using 语句:

using UnityEngine.Rendering.PostProcessing;
  1. TelescopicView 脚本类添加两个额外的属性:
private Vignette vignetteEffect;
 public float vMax = 1f;
  1. 将以下语句添加到脚本类 TelescopicViewStart() 方法末尾,以便在 Post-Processing Volume 组件中获取 Vignette 效果的引用:
    void Start() {
         initFov = Camera.main.fieldOfView;
         minFov = initFov / zoom;

         PostProcessVolume volume = GetComponent<PostProcessVolume>();
         volume.profile.TryGetSettings<Vignette>(out vignetteEffect);
     }
  1. 将以下语句添加到脚本类 TelescopicViewUpdate() 方法末尾,以便更新 Vignette 设置:
   void Update()
     {
         if (Input.GetKey(KeyCode.Mouse0))
             ZoomView();
         else
             ZoomOut();

         float currDistance = currFov - initFov;
         float totalDistance = minFov - initFov;
         float vMultiplier = currDistance / totalDistance;

         float vAmount = vMax * vMultiplier;
         vAmount = Mathf.Clamp(vAmount, 0, vMax);
         vignetteEffect.intensity.Override(vAmount);
     }
  1. 播放场景。当鼠标按钮被点击并按住时,你应该会看到一个动画的 Vignette 效果,除了缩放效果。

在以下位置了解更多关于这个新版本的信息:

显示小地图

在许多游戏中,更宽的视野对于导航和信息非常有价值。小地图对于在第一人称或第三人称模式下提供玩家可能需要的额外视角非常有用。在这个配方中,我们首先创建一个简单的方形小地图,它出现在屏幕的右上角;然后,你将学习如何将其变为圆形并添加旋转罗盘效果:

图片

准备中...

这个配方是在本章第一个配方创建的场景基础上添加的,所以请复制那个项目文件夹,并使用该副本来完成这个配方的制作。

如何做到这一点...

要创建小地图,请按照以下步骤操作:

  1. 层次结构 面板中创建一个新的 UI Panel 对象(创建 | UI | Panel),命名为 Panel-miniMap。由于这是在这个场景中创建的第一个 UI GameObject,应该会自动创建新的 Canvas 和 EventSystem GameObject,UI Panel 应该是 Canvas GameObject 的子对象。

  2. 检查器 中选择 Panel-miniMap GameObject,执行以下操作:

    • 在 Rect Transform 中,设置对齐方式为右上角(在按住 Shift 和 Alt 键的同时点击右上角框)

    • 在矩形变换中,将宽度设置为128,高度设置为128

  3. 我们将创建一个渲染纹理文件,我们的缩略图相机将复制其视图。在项目面板中,创建一个新的渲染纹理并将其命名为RenderTextureMap。在检查器中,确保其大小设置为 256 x 256。

  4. 检查器中,选择 GameObject 面板-minimap 并添加一个新的子 UI 原始图像,命名为RawImage-TextureMap(创建 | UI | 原始图像)。

  5. 对于 UI RawImage-TextureMap GameObject,将源图像字段填充为RenderTextureMap图像。这意味着当我们的缩略图相机更新其视图到渲染纹理时,相机所看到的内容将自动显示在这个 UI 原始图像中。

  6. 在确保 UI 原始Image-RenderTextureMapPanel-minimap的子项后,通过在矩形变换中选择拉伸来使它填充整个面板,同时按住 Shift 和 Alt 键:

图片

  1. 层次面板中,创建一个新的相机(创建 | 相机,并将其重命名为Camera-minimap。取消选中(或移除)相机的音频监听器组件,因为在场景中应该只有一个活动的音频监听器**。

  2. 在层次结构子项Camera-minimapcharacter-MsLazer角色。然后,在检查器中,设置其属性如下:

    • 位置:(0, 10, 0)

    • 旋转:(90, 0, 0)

    • 清除标志:仅深度

    • 投影:正交

    • 相机:大小:5(默认)

    • 深度:1(或更高)

    • 目标纹理:RenderTextureMap

  3. 播放场景。你应该能够在屏幕右上角看到正方形缩略图的功能:

图片

它是如何工作的...

缩略图的主要元素是一个 UI 原始图像元素(RawImage-TextureMap),显示名为RenderTextureMap的渲染纹理文件的内容。

你在场景中创建了一个第二个相机(Camera-minimap),并将其目标纹理设置为RenderTextureMap文件;这意味着相机的视图在每一帧更新RenderTextureMap的内容,然后这些内容再通过 UI RawImage-TextureMap显示给用户。

Camera-minimap是一个正交相机,从俯视角度跟随玩家的角色。你在这个新相机中移除/禁用了AudioListener组件,因为场景中应该只有一个活动的AudioListener,而默认的主相机GameObject 中已经有一个了。

你将这个新相机子类化到character-MsLazer角色上,因此它随着角色移动。你将其定位在角色上方 10 个单位(Y = 10),并使其向下指向角色(X 旋转 90 度)。

更多内容...

如果你想对你的缩略图进行更多实验,请继续阅读。

使用 UI 遮罩使缩略图呈圆形

要使缩略图在 UI 中突出,一种方法是将它做成圆形。我们可以通过添加基于圆形的 UI 遮罩来轻松实现这一点:

使缩略图在 UI 中突出显示的一个好方法是将其呈现为圆形形状,请执行以下操作:

  1. 项目 面板中,选择 Textures 文件夹中的 circleMask 文件,并在 检查器 中确保纹理类型为 精灵 (2D 和 UI)

要将纹理类型更改为 精灵(2D 和 UI),在 项目 面板中选择文件,然后在 检查器 中将其 纹理类型 更改为 精灵(2D 和 UI) 并点击 应用 以确认更改。

  1. 确保在 层次结构 中选择 Panel-miniMap GameObject。在 检查器 中为 Image (Script) 组件,将源图像字段填充为 circleMask 纹理,点击颜色属性,并将 Alpha 值设置为 255

  1. 现在,在 检查器 中为 Panel-miniMap 添加一个遮罩组件,通过选择菜单:添加组件 | UI | 遮罩。取消选中显示遮罩图形属性(它将变为不可见)。

圆形图像作为缩略图的遮罩,因此只有 circleMask 区域内的图像将被显示,从而形成一个圆形缩略图。

隐藏缩略图中心玩家角色图像并显示三角形标记

在大多数缩略图中,缩略图中心是玩家角色的位置,因此我们不需要在缩略图中显示玩家角色。让我们创建一个名为 Player 的层,并将 character-MsLazer 放置在该 上。然后我们可以通过创建一个忽略 Player 剔除遮罩 来提高效率并减少视觉杂乱。我们可以在我们的缩略图中心显示一个简单的 2D 精灵,向上指,以表明我们的玩家相对于缩略图显示始终向上:

  1. 创建一个新的用户层名为 Player

  2. 在层次结构中选择 character-MsLaser GameObject(玩家角色),并将 属性设置为 Player(在 更改子项 弹出对话框中点击是)。

  3. 层次结构 中选择 Camera-minimap,在 检查器 中为 Camera剔除遮罩 属性取消选择 Layer Player。此属性现在应显示为 混合...,这意味着 Player 上的 GameObject 将被我们的 Camera-minimap 忽略。

  4. 让我们在 面板 的中心添加一个三角形标记 2D 图像。创建一个新的 UI Image 作为 Panel-minimap 的子项;将此重命名为 Image-marker。在 检查器 中,将源图像属性设置为纹理资产文件 triangleMarker。点击设置原生大小按钮。

  5. 运行场景。而不是以玩家角色的自上而下视角,你现在应该看到在缩略图中心的一个三角形(始终向上指):

而不是只在中心有一个代表玩家的三角形图像,你可以通过使用Layers进一步创建,例如,彩色的 3D 对象,在正交投影中看起来像圆形、正方形等。这涉及到为要在小地图中显示的对象创建一个Layer,以及另一个(例如Player)将被小地图相机忽略的Layer。Unity 的骑士们发布了一个简短的教程,解释了如何做到这一点:blog.theknightsofunity.com/implementing-minimap-unity/

旋转罗盘风格的图像

有时我们想在我们的最小地图周围有一个罗盘风格的图像,这样我们就可以看到玩家角色的当前航向(前进方向)和原始面向之间的任何差异。

要在我们的最小地图周围添加一个旋转的罗盘风格图像,请执行以下操作:

  1. 让我们在面板的中心添加一个罗盘风格的圆形图像(字母 N 向上指)。为此,创建一个新的 UI Image 作为Panel-minimap的子对象;将此重命名为Image-compass。在Inspector中,将Source Image属性设置为 Texture compass。

  2. 确保在Panel-minimap游戏对象的子对象层级中,Image-compassImage-marker都位于RawImage-TextureMap下方;这确保了三角形标记和罗盘圆圈图像在相机纹理之后(即在相机视图图像之上)被绘制:

图片

  1. 创建一个 C#脚本类 MiniMap,并将一个instance-object作为组件添加到Camera-minimap:
    using UnityEngine;

     public class MiniMap : MonoBehaviour {
         public GameObject mapUI;
         private Transform target;

         void Start() {
             target = GameObject.FindGameObjectWithTag("Player").transform;
         }

         void Update() {
             Vector3 compassAngle = new Vector3();
             compassAngle.z = target.transform.eulerAngles.y;
             mapUI.transform.eulerAngles = compassAngle;
         }
     }
  1. 播放场景。你应该能够在屏幕的右上角看到小地图正在工作。当你旋转玩家角色的方向时,你会看到围绕小地图的“N”北指示器也在旋转(但方向相反)。

每一帧,罗盘 UI Image 都会旋转以匹配场景中玩家 3D 角色的旋转。

调整地图的范围更大或更小

由于我们的 Camera-minimap 是正交的,改变位于角色上方的Camera的高度将不会产生影响(因为距离不会改变对象在正交相机上的投影)。然而,在Inspector中更改Camera组件的 Size 属性将控制世界投影到Camera上的区域大小。

尝试将大小增加到 20,你的小地图将显示你角色周围更多的迷宫区域:

图片

当增加小地图的范围时,你可能希望将小地图中心的三角标记图像缩小。你可以通过将其 Rect Transform 居中,然后设置较小的宽度和高度(例如 16 x 16)来实现这一点。

将你的小地图适应到其他风格

您可以轻松修改这个菜谱,使其成为赛车游戏电路图的俯视或等距视图。在检查器中,取消Camera-minimap GameObject 的子对象;这将防止它跟随场景中的任何角色

创建游戏中的监控摄像头

在上一个菜谱中,我们将最小地图摄像头的输出渲染到渲染纹理中,并在UI 原始图像中显示该图像的内容。我们可能希望捕获和输出摄像头运行时视图的另一个例子是模拟游戏中的监控摄像头,例如闭路电视CCTV)系统。在这个菜谱中,我们将使用渲染纹理创建一个在场景中其他地方传输视频到 3D 监控器的游戏内监控摄像头:

图片

准备工作

这个菜谱增加了本章第一个菜谱中创建的场景,所以请复制那个项目文件夹,并使用该副本来完成这个菜谱的工作。

对于这个菜谱,我们还准备了两个 3D 模型(FBX 文件),用于监控器和cctv-camera对象。这些 3D 模型文件可以在06_07文件夹中找到。

如何操作...

要创建游戏中的监控摄像头,请按照以下步骤操作:

  1. 将监控器和cctv-camera模型导入到您的 Unity 项目中。

  2. 通过从项目面板拖动它们到层次结构面板来创建监控器和cctv-camera模型的克隆。

  3. 检查器中,为 monitor GameObject 设置以下属性:

    • 位置:(-3, 0, 6)

    • 旋转:(0, 180, 0)

    • 缩放:(1,1,1)

  4. 检查器中,为cctv-camera GameObject 设置以下属性:

    • 位置:(-6, 0, 1)

    • 旋转:(0, 90, 0)

  5. 项目面板创建一个新的渲染纹理文件,并将其重命名为screenRenderTexture。在检查器中,将其大小更改为512 x 512

  6. 在场景中添加一个新的摄像头(菜单:创建 | 摄像头),命名为Camera-surveillance。将这个新的 GameObject 作为cctv-camera的子对象。取消选中(或移除)摄像头的音频监听器组件,因为场景中应该只有一个活动的音频监听器

  7. 检查器中,为Camera-surveillance GameObject 设置以下属性:

    • 位置:(0, 2, 0)

    • 旋转:(0, 0, 0)

    • 剪裁*面:*:0.6

    • 目标纹理:screenRenderTexture

  8. 创建一个新的材质,命名为m_renderTexture,并将其漫反射纹理设置为screenRenderTexture

  9. 层次结构中,找到 monitor GameObject 的屏幕子对象,并将其网格渲染器材质设置为m_renderTexture

  10. 播放您的场景。您应该能够在监控器的屏幕上实时看到cctc-camera前的动作:

图片

它是如何工作的...

我们通过将监控相机作为应用到屏幕上的渲染纹理的来源,实现了最终结果。为了便于重新定位,将相机设置为 3D 模型的子项。此外,重新调整了其*裁剪*面,以避免显示相机 3D 模型几何形状的一部分,并禁用了其音频源组件,以免与主相机的组件冲突。

最后,我们的渲染纹理被应用到监视器 GameObject 的材料上。

还有更多...

如果你想对你的小地图进行更多实验,请继续阅读。

使用后处理添加颗粒、灰度效果到 CCTV

为游戏中的电视系统添加一个很好的效果是带有颗粒(视觉噪声)的灰度后处理。这增加了廉价、老式 CCTV 系统的感觉,例如可能用于安全系统,并添加一种威胁性的电影黑帮效果:

图片

要添加晕影效果,请执行以下操作:

  1. 打开资产商店面板,然后下载并导入 Unity Technologies 发布的免费后处理堆栈资产。

  2. 项目面板中,通过转到创建 | 后处理配置文件创建一个新的后处理配置文件文件。将此新配置文件命名为film-noir.

  3. 层次结构中,选择cctv-cameraGameObject 的Camera-surveillance子项。通过选择菜单:添加组件 | 效果 | 后处理行为,为此相机添加一个后处理行为组件。

  4. film-noir文件从项目面板拖动到检查器中,以填充后处理行为组件的配置文件属性。

  5. 检查器中,为后处理配置文件film-noir文件设置以下属性:

    • 选择色彩分级选项,并将基本:饱和度设置为零

    • 选择颗粒选项,取消选择彩色,并设置强度(1)发光贡献(1)大小(3)的最大值。

  6. 运行场景。

通过处理从相机生成的图像,监视器中的图像现在应该是来自cctv-camera.的老式颗粒、灰度视频流。图像被转换为灰度,因为颜色饱和度为零,并且还应用了颗粒效果。

使用 Unity 的多功能相机装置

Unity 提供了一些相机装置,可以使设置场景更快,并有助于测试想法。在这个菜谱中,你将使用一个第三人称角色和默认 Unity 资产包中的多功能相机装置,快速创建一个带有相机的场景,该相机在角色移动时自动跟随角色,并在角色改变方向时*滑旋转:

图片

如何做到这一点...

要使用 Unity 的多功能相机装置,只需遵循以下步骤:

  1. 创建一个新的 Unity 3D 场景。

  2. 导入CharactersCameras Asset Packages:使用菜单:Assets | Import Package ... | Cameras & Characters

  3. 现在,你应该在你的项目面板中有一个Standard Assets文件夹,其中包含CamerasCharacters文件夹(以及可能的一些其他文件夹,如CrossPlatformInputEditor等)。

  4. 在你的场景中创建一个 3D *面。

  5. ThirdPersonController Prefab的副本添加到你的场景中。通过将ThirdPersonController PrefabStandard Assets | Characters | ThirdPersonController | Prefabs文件夹拖动到场景中完成此操作。

  6. 层次结构中选择 GameObject ThirdPersonController,在检查器中使用Player标签标记此 GameObject。

  7. MultipurposeCameraRig Prefab的副本添加到你的场景中。通过将MultipurposeCameraRig Prefab 从Standard Assets | Cameras | Prefabs文件夹拖动到场景中完成此操作。

  8. 禁用Main Camera GameObject。

  9. 运行场景。当你移动角色在场景中时,相机应该*滑地跟随后面。

它是如何工作的...

你已将ThirdPersonController添加到场景并标记为Player。你已将MultipurposeCameraRig添加到场景中。相机架上的代码会自动寻找标记为 Player 的目标 GameObject,并从上方和后方定位以跟随此 GameObject。

你可以通过更改MultipurposeCameraRig检查器组件Auto Cam (Script)中的公共属性来调整相机跟随和转向的速度。

使用 Cinemachine ClearShot 切换相机以保持玩家在画面中

Unity 的新特性是Cinemachine组件集:

图片

准备工作

此配方添加到本章第一个配方创建的场景中,因此复制该项目文件夹,并使用该副本完成此配方的操作。

如何操作...

要使用Cinemachine ClearShot切换相机以保持玩家在画面中,只需遵循以下步骤:

  1. 打开提供的场景,其中包含一个 3D 迷宫和character-MsLazer

  2. Main Cameracharacter-MsLazer中解除关联,因为我们需要这个相机空闲,以便Cinemachine可以控制它。

  3. 使用 Unity Package Manager 安装Cinemachine包(以获取最新版本)。

  4. 在场景中添加一个Cinemachine ClearShot相机 GameObject(菜单:Cinemachine | Create ClearShot Camera)。你应该在层次结构中看到一个名为 CM Clearshot 1 的新 GameObject。将这个新 GameObject 的位置设置为(0,0,0)。

  5. CM Clearshot 1 应该有一个子 GameObject,Cinemachine Virtual Camera CM vcam 1。将这个虚拟相机,CM vcam 1 的位置设置为(10, 4, -10)。

  6. 你还会看到已经添加了一个到Main Camera的 Cinemachine Brain 组件,在层次结构中,你会在Main Camera名称旁边看到 Cinemacine Brain 图标(一半灰色齿轮,一半红色相机):

图片

  1. character-MsLazer的层级中定位mixamorig:neck GameObject。我们将使用character-MsLazer的这一部分作为我们的Cinemachine相机将用来定位的部分。

  2. 选择CM Clearshot 1,然后在检查器中,将Cinemachine ClearShot组件的Look At属性填充为对mixamorig:neck GameObject 的引用(将 GameObject 从层级拖动到检查器中的属性):**

图片

  1. 运行场景。当您在场景中移动character-MsLazer时,主相机(由Cinemachein大脑控制)应该旋转以始终面向角色。然而,有时墙壁会遮挡视线。

  2. 通过在层级中选择 CM Clearshot 1,创建第二个子虚拟相机,然后在检查器中点击Cinemachine Clear Shot 组件的虚拟相机子代属性的+按钮。您应该看到一个名为 CM vcam 2 的新虚拟相机子代被创建。将 CM vcam 2 的位置设置为(27, 4, -18)。

  3. 运行场景。最初,CM vcam 1 拥有最佳的拍摄角度,因此将使用这个相机的位置来引导主相机。但是,如果您将character-MsLazer沿着走廊移动到 CM vcam 2,Cinemachine将切换控制到 CM vcam 2。

它是如何工作的...

场景中添加了一个 Cinemachine Brain 组件。它控制主相机并使用一个或多个虚拟 Cinemachine 相机的属性来决定应用于主相机的属性。您添加了一个Cinemachine ClearShot GameObject,其目的是告诉Cinemachine Brain哪个其虚拟相机子代拥有最佳的拍摄角度。

您将ClearShot组件的Look At属性设置为character-MsLazer的颈部组件;此 GameObject 的位置被ClearShot组件用于对每个虚拟相机的拍摄质量进行排名。

还有更多...

我们只是刚刚触及了 Cinemachine 所能提供的表面。以下是一些学习更多知识的建议。

Unity Cinemachine 教程

在 Unity 网站的“学习”部分,您可以找到许多介绍 Unity 不同动画功能的视频教程。有一个专门的类别是Cinemachine教程,它提供了 Cinemachine 功能和用途的全面概述:unity3d.com/learn/tutorials/s/animation

威尔·戈尔茨坦(Will Goldstone)的 ClearShot 教程

这个配方受到了 YouTube 上威尔·戈尔茨坦(Will Goldstone)的 ClearShot 教程的启发:www.youtube.com/watch?v=kLcdrDljakA

亚当·迈希尔(Adam Myhill)的 Cinemachine 博客文章

Adam Myhill 的博客文章(他是Cinemachine的创造者)中包含大量关于许多不同 Cinemachine 功能的信息和视频链接:blogs.unity3d.com/2017/08/25/community-stories-cinemachine-and-timeline/

阅读已安装的 Cinemachine 文档

Cinemachine的后续版本(2.1+)包含与包一起安装的文档。显示Cinemachine关于面板(菜单:Cinemachine | About),然后点击Documentation按钮:

图片

您也可以在网上找到文档,链接为docs.unity3d.com/Packages/com.unity.cinemachine@2.1/manual/index.html

允许玩家切换到 Cinemachine FreeLook 相机

总是给玩家提供选择和控制他们的游戏体验是件好事。在这个配方中,我们将设置一个鼠标可控制的Cinemachine FreeLook相机,并允许玩家切换到它。

准备工作

此配方在先前的配方基础上添加内容,因此请复制那个项目文件夹,并使用该副本进行此配方的操作。

如何操作...

要探索 Cinemachine,只需遵循以下步骤:

  1. 确保在Main Camera中的Cinemachine Brain组件的Default Blend属性设置为 Ease In Out。这意味着我们在切换相机之间将会有*滑的过渡。

  2. 将一个Cinemachine FreeLook相机 GameObject 添加到场景中(菜单:Cinemachine | Create FreeLook Camera)。您应该会在“Hierarchy”中看到一个名为CM FreeLook 1的新 GameObject。将Cinemachine** Free Look (Script)组件的Priority属性设置为零。

  3. MsLazer中的Hierarchy内找到mixamorig:neck GameObject。我们将使用MsLazer角色的这部分作为我们的Cinemachine相机将用来定位和以恒定距离跟随的部分。

  4. 选择CM FreeLook 1,并在Inspector中填充Cinemachine Free Look (Script)组件的Look AtFollow属性,以引用mixamorig:neck GameObject(从Hierarchy拖动 GameObject 到Inspector中的属性)。**

  5. 创建一个新的FreeLookSwitcher C# Script 类,包含以下代码,并将一个实例对象作为组件添加到CM FreeLook 1 GameObject 中:

    using UnityEngine;
     using Cinemachine;

     public class FreeLookSwitcher : MonoBehaviour {
         private CinemachineFreeLook cinemachineFreeLook;

         private void Start() {
             cinemachineFreeLook = GetComponent<CinemachineFreeLook>();
         }

         void Update ()  {
             if (Input.GetKeyDown("1"))
                 cinemachineFreeLook.Priority = 99;

             if (Input.GetKeyDown("2"))
                 cinemachineFreeLook.Priority = 0;
         }
     }
  1. 运行Scene。当在迷宫中移动时,最初Cinemachine ClearShot相机将由Cinemachine Brain 选择。但是,按下1键将使其切换到跟随玩家角色的FreeLook相机。按下2键将切换回ClearShot相机。

它是如何工作的...

你添加了一个FreeLook Cinemachine游戏对象,但优先级为零,因此最初会被忽略。当按下1键时,脚本将Priority提升到99(比ClearShot相机的默认值 10 高得多),因此Cinemachine脑部将使FreeLook虚拟相机控制Main Camera。按下2键将FreeLook组件的Priority降低回zero,因此将再次使用ClearShot相机。

FreeLookClearShot以及返回的过渡应该是*滑的,因为你已经将Main Camera中的Cinemachine Brain组件的Default Blend属性设置为Ease In Out

第八章:灯光与效果

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

  • 使用 cookie 纹理模拟多云天气的方向光

  • 为聚光灯创建并应用 cookie 纹理

  • 向场景添加自定义反射贴图

  • 使用投影仪创建激光瞄准

  • 使用线渲染器增强激光瞄准

  • 使用程序化 Skybox 和方向光设置环境

  • 使用反射探针反射周围物体

  • 使用材质发射烘焙来自发光灯的光线到场景物体上

  • 使用光照贴图和光照探针照亮场景

简介

无论您是试图制作外观更好的游戏还是想添加有趣的功能,灯光和效果都可以提升您的项目并帮助您交付更高品质的产品。现代游戏引擎,包括 Unity,使用复杂的数学和物理建模来模拟光源与场景中物体之间的光线交互。

对于视觉上逼真的虚拟游戏场景,游戏引擎必须模拟光源、光线如何直接从这些光源照射到表面上,以及光线随后如何间接地从这些表面反弹到场景中的其他物体上,然后再次反弹到其他物体上,依此类推。对于包含许多物体和光源的丰富、复杂场景,每帧从头开始计算一切是不可能的,因此需要预先计算来模拟这些光源和表面之间的相互作用。

在本章中,我们将探讨使用灯光和效果的创意方式,并查看 Unity 的一些关键光照功能,例如程序化 Skyboxes、反射发射材质、探针、光照探针、自定义反射源和全局光照GI)。

整体概念

在 Unity 中创建光源有许多方法。以下是对最常见方法的快速概述。

灯光

灯光作为具有灯光组件的 GameObject 放置到场景中。它们可以在实时、烘焙或混合模式下工作。在其他属性中,用户可以设置它们的范围、颜色、强度和阴影类型。有四种类型的灯光:

  • 方向光:这通常用于模拟阳光

  • 聚光灯:这就像一个圆锥形聚光灯

  • 点光:这是一种类似灯泡的全向光

  • 区域光:这是一种仅烘焙的灯光类型,从矩形实体向所有方向发射,允许实现*滑、逼真的着色

以下截图展示了不同类型的灯光及其场景面板图标:

图片

环境光照

Unity 的环境光照通常是通过结合 Skybox材质和场景中定义的方向光的阳光来实现的。这种组合创建了一种环境光,它被集成到场景的环境中,并且可以设置为实时或烘焙到光照贴图中。

环境光照并不来自任何特定位置,因为它在整个场景中均匀分布。环境光可以用来影响场景的整体亮度:

图片

发光材料

当应用于静态物体时,具有发射颜色或图的材料将在实时烘焙模式下在附*的表面上投射光线,如下面的截图所示:

图片

投影仪

如其名所示,投影仪可以通过将材料及其纹理图投影到其他物体上来模拟投影光和阴影:

图片

光照贴图

光照贴图基本上是从场景的光照信息生成的纹理图,并将其应用于场景中的静态物体,以避免使用处理密集型的实时光照。

场景中预计算光照被称为光照贴图烘焙。静态的(不可移动的)场景部分(灯光和其他物体)可以在游戏运行之前将它们的照明“烘焙”(预计算)。然后,在运行时,由于可以使用预计算的光照贴图而不是在运行时每帧重新计算,因此游戏性能得到提高(尽管这需要更多的内存来存储预计算)。

Unity 提供了两个光照贴图器,Enlighten 和较新的 Progressive 光照贴图器。Enlighten 可以很好地用于预计算的实时全局光照(环境光照)。Progressive 光照贴图器建议用于烘焙光照贴图。

光探针

光探针是一种在场景的特定点采样光照的方法,以便在没有使用实时光照的情况下将其应用于动态物体。移动(动态)的物体可以使用光探针,以便它们的照明随烘焙光源在场景中的位置变化而变化。

灯光设置窗口

灯光窗口,(菜单:窗口 | 渲染 | 灯光设置),是设置和调整场景照明功能(如光照贴图、全局光照、雾等)的中心:

图片

光照探索器面板

当在 Unity 中处理灯光和光照时,一个有用的工具是光照探索器面板,它允许编辑和查看当前场景中所有灯光的属性。光照探索器面板列出了单个面板中的所有灯光,这使得单独处理每个灯光或同时更改多个灯光的设置变得容易。当处理涉及大量灯光游戏对象的场景时,它是一个节省时间的强大工具。

要显示光照探索器面板,请选择以下菜单:窗口 | 渲染 | 光照探索器:

图片

棕色纸板

灯光可以应用 cookie 纹理。cookie 是用于在场景中投射阴影或轮廓的纹理。它们是通过使用 cookie 纹理作为光源和被渲染表面之间的遮罩来产生的。它们的名称和用途来源于在剧院和电影制作中使用的物理设备 cucoloris(昵称 cookie),用于产生暗示环境效果的阴影,如移动的云彩、监狱窗户的栅栏或被丛林树叶遮挡的阳光。

色彩空间(伽玛和线性)

Unity 现在提供了两种色彩空间的选择:伽玛(默认)和线性。您可以通过以下菜单选择您想要的色彩空间:编辑 | 项目设置 | 玩家。虽然线性空间具有显著的优势,但并非所有硬件(尤其是移动系统)都支持它,因此您选择哪种将取决于您部署的*台。

进一步的资源

本章旨在向您介绍 Unity 的一些照明功能,并提供一些关于灯光和效果的技巧。在您学习本章中的食谱时,您可能希望从以下这些来源中了解更多关于本章主题的信息:

使用 cookie 纹理模拟多云天气的方向光

正如许多第一人称射击游戏和生存恐怖游戏中可以看到的那样,灯光和阴影可以为场景增添许多真实感,极大地帮助创造适合游戏的氛围。在这个菜谱中,我们将使用 cookie 纹理创建一个多云的户外环境。Cookie 纹理充当灯光的遮罩。它通过调整灯光投影到 cookie 纹理的 alpha 通道的强度来工作。这可以实现轮廓效果(想想蝙蝠信号),或者在这个特定案例中,细微的变化,给灯光带来过滤后的质感。

准备工作

如果您无法访问图像编辑器,或者希望跳过纹理映射的详细说明,以便专注于实现,我们已经在07_01文件夹中提供了准备好的 cookie 图像文件 cloudCookie.tga。

如何做到这一点...

要模拟多云的户外环境,请按照以下步骤操作:

  1. 在您的图像编辑器中创建一个新的 512 x 512 像素图像。

  2. 使用黑色作为前景色和白色作为背景色,应用云彩滤镜 - 在 Photoshop 中,选择以下菜单:滤镜 | 渲染 | 云彩:

图片

了解 alpha 通道很有用,但您可以在没有它的情况下得到相同的结果。跳过步骤 3 到 7,将图像保存为 cloudCookie.png,并在步骤 9 更改纹理类型时,保留灰度中的 Alpha 选中。

  1. 选择您的整个图像并复制它。

  2. 打开通道窗口(在 Photoshop 中,可以通过以下菜单操作:窗口 | 通道。

  3. 应该有三个通道:红色、绿色和蓝色。创建一个新的通道。这将是一个 alpha 通道。

  4. 在通道窗口中,选择 Alpha 1 通道并将您的图像粘贴进去:

图片

  1. 将您的图像文件保存为 cloudCookie.PSD 或 TGA。

  2. 将您的图像文件导入 Unity 并在项目面板中选择它。

  3. 检查器中,将其纹理类型更改为 Cookie,其灯光类型更改为方向。然后,按照以下方式点击应用:

图片

  1. 让我们在场景中添加一束光。由于我们想要模拟阳光,最佳选项是创建一个方向光。选择层次菜单:创建 | 光 | 方向光。

  2. 我们需要一个表面来真正看到灯光效果。您可以在场景中添加一个 3D *面(菜单:游戏对象 | 3D 对象 | *面),或者创建一个 3D 地形(菜单:游戏对象 | 3D 对象 | 地形)。

  3. 在检查器中,将灯光的变换位置重置为(0, 0, 0)及其旋转重置为(90, 0, 0)。

  4. 在 Cookie 字段中,选择您之前导入的 cloudCookie 纹理。将 Cookie Size 字段更改为 15,或您认为更适合场景尺寸的值。将阴影类型设置为无阴影:

图片

  1. 创建一个新的 C#脚本类名为 ShadowMover,并将实例对象作为组件添加到方向光
 public class ShadowMover : MonoBehaviour {
         public float windSpeedX = 2;
         public float windSpeedZ = 2;

         private float lightCookieSize;
         private Vector3 startPosition;
         private float limitX;
         private float limitZ;
         private Vector3 windMovement;

         void Start() {
             startPosition = transform.position;
             lightCookieSize = GetComponent<Light>().cookieSize;
             limitX = Mathf.Abs(startPosition.x) + lightCookieSize;
             limitZ = Mathf.Abs(startPosition.z) + lightCookieSize;
             windMovement = new Vector3(windSpeedX, 0, windSpeedZ);
         }

         void Update() {
             Vector3 position = transform.position + (Time.deltaTime 
             * windMovement);
             position.x = WrapValue(position.x, limitX,     
             startPosition.x);
             position.z = WrapValue(position.z, limitZ,         
             startPosition.z);
             transform.position = position;
         }

         private float WrapValue(float n, float limit, float  
         startValue) {
             float absoluteValue = Mathf.Abs(n);
             if (absoluteValue > limit)
                 return startValue;
             else
                 return n;
         }
     }
  1. 选择方向光。在检查器中,将参数风速 X 和风速 Z 更改为不同的值。

  2. 播放你的场景。现在阴影将会移动。

它是如何工作的...

脚本类提供了两个公共值,用于 X 和 Z 方向的速度(模拟风速)。

当场景开始时,首先,存储方向光的初始位置。然后,从同级光组件中读取饼干的大小,并用于计算最大 X 和 Z 值。最后,创建一个 Vector3,用于在秒内移动我们的光,基于 X 和 Z 窗口速度(Y 为零,因为我们不需要在 Y 轴上移动方向光)。

WrapValue(...) 方法被定义,它返回一个值。如果第一个参数(正数)的值超过第二个参数(限制),则返回第三个参数(初始值)。否则,返回第一个参数的值。这允许我们确保如果一个值(例如,我们的 X 或 Z 坐标)超出了限制,我们可以将其“包裹”回起始值。

Update() 方法在每一帧执行。计算光的下一个位置(当前位置加上当前帧每秒风速向量的比例)。使用我们的 WrapValue(...) 方法设置这个新位置的 X 和 Z 值,这样我们知道它们的限制值不会超过。最后,将光的位置设置为这个新位置,Vector3

我们没有启用阴影的原因是因为 X 轴的光角度必须是 90 度(否则当光重置到原始位置时会有明显的间隙)。如果你想在场景中实现动态阴影,请添加第二个 方向光

创建并应用饼干纹理到聚光灯上

饼干纹理可以很好地与 Unity 聚光灯一起工作,以模拟来自投影仪、窗户等处的阴影。一个例子是监狱窗户的栅栏。

在这个菜谱中,我们将创建并应用一个适合与 Unity 聚光灯一起使用的饼干纹理

准备工作

如果你没有访问图像编辑器的权限,或者希望跳过纹理映射的详细说明,以便专注于实现,我们在 07_02 文件夹中提供了名为 spotCookie.tif 的准备好的饼干图像文件。

如何做...

要创建并应用饼干纹理到聚光灯上,请按照以下步骤操作:

  1. 在你的图像编辑器中,创建一个 512 x 512 灰度像素图像。

  2. 通过将画笔工具颜色设置为黑色并在图像的四周边缘绘制,确保边界完全为黑色。然后,绘制一些交叉的线条。保存你的图像,命名为 spotCookie:

  1. 将你的图像文件导入 Unity 并在项目面板中选择它。

  2. 在检查器中,将其纹理类型更改为 Cookie,并将光类型更改为聚光灯,然后将 Alpha 源设置为从灰度。然后,按照以下方式点击应用:

图片

  1. 创建一个包含以下内容的场景:

    • 一个*坦的 3D 地形或*面作为我们的地面

    • 一个 3D 立方体或*面,拉伸以充当墙壁

    • 墙壁前面的两到三个其他 3D 对象

  2. 主摄像机定位为显示“墙壁”前面的 3D 对象。

  3. 现在,通过选择以下菜单将聚光灯添加到场景中:创建 | 光 | 聚光灯。

  4. 将聚光灯定位为主摄像机指向的方向——你可能需要将 Y 值旋转 180 度。

  5. 将阴影类型设置为无阴影,并将你的 spotCookie 纹理从项目面板拖动到 Cookie 槽中。

  6. 播放你的场景。你现在应该看到聚光灯投射出阴影,就像光线穿过木条或金属板的网格一样。

它是如何工作的...

我们创建了一个用于 Unity 聚光灯的灰度纹理——边缘完全为黑色——这样光线就不会“溢出”到我们的聚光灯发射边缘。纹理中的黑色线条被 Unity 用来在从聚光灯发出的光中创建阴影,从而产生一些直的木棍或金属杆的光束效果。

你可以在 Unity 教程页面了解更多关于创建聚光灯 cookie 的信息:docs.unity3d.com/Manual/HOWTO-LightCookie.html

向场景添加自定义反射图

Unity 的标准着色器从场景的反射源获取反射,这是在照明窗口的场景部分中配置的。每种材质的反射程度由其金属值或光泽值定义,具体取决于正在使用的着色器。这种方法可以节省大量时间,允许你快速将相同的反射图分配给场景中的每个对象。它还有助于保持场景的整体外观协调一致。在本菜谱中,我们将学习如何利用反射源功能:

图片

准备工作

对于这个菜谱,我们将准备一个反射立方体贴图,这基本上是将环境投影到材质上的反射。它可以由六个或,如本菜谱所示,一个单独的图像文件制作。

为了帮助我们完成这个菜谱,我们提供了一个 Unity 包(batteryPrefab.unitypackage),其中包含一个由 3D 对象和基本材质(使用 TIFF 作为漫反射图)组成的预制件,以及一个用于反射图的 JPG 文件。所有这些文件都在07_03文件夹中。

如何做到这一点...

要给材质添加反射性和光泽度,请按照以下步骤操作:

  1. 将 batteryPrefab.unitypackage 包导入新项目。然后,从项目面板中选择 Assets 文件夹中的 battery_prefab 对象。

  2. 从检查器中展开材质组件,观察资产预览窗口。多亏了高光贴图,材质已经具有反射外观。然而,它看起来像是在反射场景的默认天空盒,如下面的截图所示:

图片

  1. 导入 CustomReflection.jpg 图像文件。确保在项目面板中选中此资产。

  2. 在其导入设置检查器中,设置以下属性:

    • 纹理类型: 默认

    • 纹理形状: 立方体

    • 映射: 纬度-经度布局(圆柱形)

    • 卷积类型: 无

    • 修复边缘接缝: 已勾选

    • 过滤模式: 三线性

    • 现在,点击应用按钮,如下所示:

图片

  1. 让我们将场景的天空盒替换为我们新创建的立方体贴图,作为场景的反射贴图(菜单:窗口 | 渲染 | 灯光设置)。

  2. 选择场景部分,并使用下拉菜单将反射源更改为自定义。最后,将新创建的 CustomReflection 纹理作为立方体贴图分配,如下所示:

图片

  1. 查看电池 _prefab 对象上的新反射。

它是如何工作的...

材质的高光贴图提供了反射外观,包括反射的强度和光滑度。然而,反射中看到的图像是由我们创建的立方体贴图提供的。

更多...

反射立方体贴图可以通过多种方式实现,并且具有不同的映射属性。

映射坐标

我们应用的圆柱形映射非常适合我们使用的照片。然而,根据反射图像的生成方式,基于立方体或球体贴图的映射可能更合适。此外,请注意,修复边缘接缝选项将尝试使图像无缝。

锐利反射

你可能已经注意到,与原始图像相比,反射有些模糊;这是因为我们勾选了光泽反射框。为了获得更清晰的反射效果,取消选中此选项;在这种情况下,你还可以将过滤模式选项保留为默认(双线性)。

最大尺寸

在 512 x 512 像素时,我们的反射贴图可能在低端机器上运行良好。然而,如果你的游戏中反射贴图的质量不是很重要,并且原始图像的尺寸很大(比如说,4096 x 4096),你可能想将导入设置菜单中的纹理最大尺寸从高数值降低。

使用投影仪创建激光瞄准

虽然使用 UI 元素,如准星,是允许玩家瞄准的有效方法,但用投影激光点替换(或结合)它可能是一个更有趣的方法。在这个菜谱中,我们将使用灯光投影仪来实现这个概念:

准备工作

为了帮助您完成这个食谱,在 07_04 文件夹中,我们提供了一个 Unity 包(laserAssets.unitypackage),其中包含一个包含一个手持激光指示器的角色示例场景,以及一个名为 LineTexture 的纹理图。

如何操作...

要使用 Projector 创建激光点瞄准,请按照以下步骤操作:

  1. 开始一个新的 3D 项目。

  2. 我们将导入 Unity 标准资产 中的 Projectors 组件。如果您在安装 Unity 时没有安装 标准资产,请访问 资产商店 并现在安装免费的 标准资产

  3. laserAssets.unitypackage 导入到一个新项目中。然后,打开名为 basic_scene_MsLaser 的场景。这是一个基本场景,包含一个在迷宫中的玩家角色,可以使用标准箭头键或 WASD 进行移动。

  4. 从 Effects Unity 标准资产包文件夹导入 Projectors 内容:

  1. 检查器 中,找到 ProjectorLight 着色器(在 Assets | Standard Assets | Effects | Projectors | Shaders 文件夹内部)。复制文件,并将新副本命名为 ProjectorLaser。

  2. 打开 ProjectorLaser。从代码的第一行开始,将 Shader "Projector/Light" 更改为 Shader "Projector/Laser"。然后,找到 Blend DstColor One 并将其更改为 Blend One One。保存并关闭文件:

编辑激光着色器的目的是通过将其混合类型更改为叠加来增强其效果。着色器编程是一个复杂的话题,超出了本书的范围。然而,如果您想了解更多,请查看 Unity 关于该主题的文档,可在 docs.unity3d.com/Manual/SL-Reference.html 找到,以及由 Packt 出版的名为 Unity Shaders and Effects Cookbook 的书籍。

  1. 创建一个名为 m_laser 的新材质。在检查器中,将其着色器更改为 Projector/Laser。

  2. 项目 面板中找到 Falloff Texture(在 Effects | Projectors | Textures 内部)。

  3. 在您的图像编辑器中打开它,除了第一列和最后一列像素应该是黑色之外,将其他所有内容涂成白色。将更改后的图像文件保存为 Falloff_laser 并返回 Unity:

  1. 项目 面板中选择 m_laser 资产。在检查器中,将主颜色设置为红色(RGB:255, 0, 0)。然后,从纹理槽中拖动 Texture Light 到 Cookie 槽中,并将 Texture Falloff_laser 拖动到 Falloff 槽中(这些 纹理 在您导入的文件夹中,Effects | Projectors | Textures 内部):

  1. 层次结构中找到并选择 pointerPrefab 对象(MsLaser | mixamorig:Hips | mixamorig:Spine | mixamorig:Spine1 | mixamorig:Spine2 | mixamorig:RightShoulder | mixamorig:RightArm | mixamorig:RightForeArm | mixamorig:RightHand | pointerPrefab)。然后,创建一个新的子 GameObject(菜单:创建 | 创建空子对象)。将这个新子对象重命名为 laserProjector:

  1. 选择激光投影仪对象。然后,从检查器中点击添加组件按钮,导航到效果 | 投影仪。接着,从新的投影仪组件中,将正交选项设置为 true,并将正交大小设置为0.1。最后,从材质槽中选择 m_laser。

  2. 运行场景。您将能够看到激光瞄准点。

它是如何工作的...

在这个食谱中,瞄准点的尺寸已经被夸大了。如果您需要激光指针更真实的厚度,请将投影仪组件的正交大小更改为更小的值,如 0.025。

激光瞄准效果是通过使用投影仪实现的。投影仪可以用来模拟光线、阴影等,它是一个将材质(及其纹理)投影到其他游戏对象的组件。通过将投影仪附加到激光指针对象上,我们确保它始终面向正确的方向。为了获得所需的鲜艳外观,我们编辑了投影仪材质的 Shader 代码,使其更亮。

更多内容...

这里有一些增强这个食谱的方法。

通过 Raycast 击中限制激光范围以限制远裁剪*面

我们的目标激光应该突出显示它击中的第一个对象——它不应该穿过前面的所有对象。项目的远裁剪*面定义了投影仪停止的距离。我们可以使用一个简单的脚本来发射 Raycast,并使用击中第一个对象的距离作为设置此远裁剪*面的指南:

using UnityEngine;

     public class LaserAim : MonoBehaviour  {
         private Projector projector;
         private float margin = 0.5f;

         void Start () {
             projector = GetComponent<Projector> ();
         }

         void Update ()  {
             RaycastHit hit;
             Vector3 forward =         
             transform.TransformDirection(Vector3.forward);

             if (Physics.Raycast(transform.position, forward, out hit))
                 projector.farClipPlane = hit.distance + margin;
         }
     }

如果 Raycast 击中一个对象,我们将投影仪的远裁剪*面设置为该距离,再加上 0.5 个 Unity 单位的小边距(例如,它可能是一个曲面)。

我们通过设置其远裁剪*面在接收投影的第一个对象的大致同一水*上,编写了一个脚本,以防止投影穿过对象。负责此操作的代码行如下:

projector.farClipPlane = hit.distance + margin 

进一步阅读

在 Unity 手册页面了解有关投影仪的更多信息:docs.unity3d.com/Manual/class-Projector.html

使用 Line Renderer 增强激光瞄准

让我们通过显示从角色的激光枪到投影激光目标的光束来改进之前的食谱。我们将通过编写一个每帧重绘的 Line Renderer 脚本来实现激光束:

准备工作

此配方基于之前的配方,因此复制该项目并使用其副本进行工作。您还需要一个用于光束颜色的纹理;在07_05文件夹中提供了一个名为 beam.psd 的纹理。

如何做到这一点...

要使用 Line Renderer 增强激光瞄准,请按照以下步骤操作:

  1. 我们的 Line Renderer 需要一个材质来工作。创建一个新的材质,命名为 m_beam。

  2. 检查器中,将 m_beam 的着色器设置为 Particles/Additive。同时,将其色调颜色设置为红色(RGB255;0;0)。

  3. 导入光束图像文件。然后,将其设置为 m_beam 的粒子纹理,如下所示:

图片

  1. 创建一个新的 C#脚本类名为 LaserBeam,并将实例对象作为组件添加到游戏对象的激光投影器中:
public class LaserBeam : MonoBehaviour  {
     public float lineWidth = 0.2f;
     public Color regularColor = new Color (0.15f, 0, 0, 1);
     public Material beamMaterial;

     private Vector3 lineEnd;
     private LineRenderer line;

     void Start () {
         line = gameObject.AddComponent<LineRenderer>();
         line.material = beamMaterial;
         line.material.SetColor("_TintColor", regularColor);
         line.SetVertexCount(2);
         line.SetWidth(lineWidth, lineWidth);
     }

     void Update () {
         RaycastHit hit;
         Vector3 forward = 
         transform.TransformDirection(Vector3.forward);

         if (Physics.Raycast (transform.position, forward, out hit))
             lineEnd =  hit.point;
         else
             lineEnd = transform.position + forward * 10f;

         line.SetPosition(0, transform.position);
         line.SetPosition(1, lineEnd);
     }
 }
  1. 选择激光投影器游戏对象。从检查器中,找到激光束(脚本)组件并将 m_beam 材质从项目面板拖动到光束材质中。

  2. 播放场景。红色激光束应该从激光枪到被光束击中的第一个物体形成一条线。

它是如何工作的...

激光瞄准效果是通过使用动态 Line Renderer 实现的,它通过代码在每一帧创建和更新。

在本配方中,激光束的宽度被夸大了。如果您需要更逼真的厚度,请将激光束(脚本)组件的线宽字段更改为 0.05。同时,记得通过设置激光束组件的常规颜色更亮来使光束更不透明。您可能还想匹配激光瞄准投影器的大小,因此将投影器组件的正交大小设置为更小的值,例如 0.025。

关于 Line Renderer,我们选择通过代码动态创建它,而不是手动将组件添加到游戏对象中。

还有更多...

这里有一些增强此配方的方法。

当按下火焰键时更改光束颜色

当玩家执行某些操作时,提供音频或视觉反馈总是好的。所以,当玩家按下火焰按钮(例如,鼠标按钮)时,让我们改变光束的颜色。请执行以下操作:

  1. 为火焰光束颜色添加一个新的公共变量:
public Color firingColor = new Color (0.31f, 0, 0, 1);
  1. 添加一个新的方法来设置颜色变化(使用正弦波值):
private void SetupColor() {
         float lerpSpeed = Mathf.Sin (Time.time * 10f);
         lerpSpeed = Mathf.Abs(lerpSpeed);
         Color lerpColor = Color.Lerp(regularColor, firingColor, 
         lerpSpeed);
         line.material.SetColor("_TintColor", lerpColor);
     }
  1. Update()方法的末尾添加语句以检测当按下/释放火焰按钮时触发颜色变化:
void Update ()  {
         // … (as before)

         if(Input.GetButton("Fire1"))
             SetupColor();

         if(Input.GetButtonUp("Fire1"))
             line.material.SetColor("_TintColor", regularColor);
     }

设置程序化 Skybox 和方向光的环境

除了传统的六边形和 Cubemap Skyboxes 之外,Unity 还提供了一种第三种类型的 skybox:程序化 Skybox。易于创建和设置,程序化 Skybox 可以与方向光结合使用,为场景提供环境光照。在本配方中,我们将了解程序化 Skybox 的不同参数:

图片

如何做到这一点...

要使用程序化天空盒和 方向光 设置环境照明,请按照以下步骤操作:

  1. 在 Unity 项目内创建一个新的 场景。观察到一个新的场景已经包括两个对象:主摄像机和一个方向光。

  2. 在场景中创建一个名为 Plane-ground 的 3D *面;定位在 (0, 0, 0) 并缩放到 (20, 20, 20)。

  3. 向你的场景添加一些 3D 立方体,如下所示:

图片

  1. 创建一个名为 m_skybox 的新 材质 资产文件。在检查器中,将着色器从标准更改为 Skybox/程序化。

  2. 打开照明窗口(窗口 | 渲染 | 照明设置),并访问场景部分。在环境照明子部分,将 m_skybox 材质填充到天空盒槽中,并将场景的默认方向光填充到太阳槽中。确保实时全局照明选项被勾选(来自实时照明),并且环境环境模式设置为实时。

  3. 项目 面板中选择 m_skybox。然后,从检查器中设置太阳大小为 0.05 和大气厚度为 1.4。通过改变天空色调颜色到 RGB:148;128;128,并将地面颜色设置为类似于场景立方体地板颜色的值(例如 RGB:202;202;202)进行实验。如果你觉得场景太亮,尝试将曝光级别降低到 0.85,如下所示:

图片

  1. 选择方向光并将其旋转设置为 5, 170, 0。同时确保其光模式设置为实时(不是烘焙或混合)。

  2. 运行场景 – 它应该类似于黎明环境。

它是如何工作的...

最终,Unity 本地程序化天空盒的外观取决于构成它们的五个参数:

  • 太阳大小:绘制到天空盒上的明亮黄色太阳的大小是根据方向光在 X 和 Y 轴上的旋转定位的。

  • 大气厚度:这模拟了此天空盒的大气密度。较低的值(小于 1.0)适合模拟外太空设置。中等值(大约 1.0)适合地球环境。略高于 1.0 的值在模拟空气污染和其他戏剧性设置时可能很有用。夸张的值(例如,大于 2.0)可以帮助说明极端条件或甚至外星环境。

  • 天空色调:这是用于着色天空盒的颜色。它对于微调或创建风格化环境很有用。

  • 地面:这是地面的颜色。它真的会影响场景的全局照明。因此,选择一个接*关卡地形和/或几何形状(或中性)的值。

  • 曝光:这决定了进入天空盒的光量。较高的级别模拟过曝,而较低的值模拟欠曝。

需要注意的是,天空盒的外观将响应场景的方向光,扮演太阳的角色。在这种情况下,围绕 X 轴旋转光线可以创建黎明和日落场景,而围绕 Y 轴旋转则会改变太阳的位置,改变场景的方位点。

关于环境照明,也请注意,尽管我们使用了天空盒作为环境光源,但我们也可以选择渐变或单色,在这种情况下,场景的照明就不会附着于天空盒的外观。

最后,关于环境照明,请注意,我们已经将环境光漫反射设置为实时。这样做的原因是为了允许由旋转的方向光促进的实时光漫反射变化。如果我们不需要在运行时进行这些变化,我们可以选择烘焙的替代方案。

还有更多...

这里有一些增强这道菜谱的方法。

通过脚本旋转方向光设置和升起太阳

通过使用代码更改方向光的旋转,让事情变得更加有趣。这将给我们的场景带来动态的日出/日落效果。

创建一个新的 C#脚本类名为 RotateLight,并将一个实例对象作为组件添加到方向光游戏对象:

using UnityEngine;
     using System.Collections;
     public class RotateLight : MonoBehaviour {
       public float speed = -1.0f;
       void Update () {
         transform.Rotate(Vector3.right * speed * Time.deltaTime);
       }
     }

现在,当你运行场景时,你会看到太阳升起/落下,照明颜色相应地改变。

添加光晕

让我们在场景中添加一个光晕效果。

对于这一步,你需要导入 Unity 的标准资产效果包,你应该在安装 Unity 时安装了它,但你也可以通过 Unity 资产商店将其添加到单个项目中。

  1. 从效果 Unity 标准资产包文件夹导入光晕内容。

  2. 选择方向光。在检查器中,对于灯光组件,将光晕槽填充为太阳光晕(从项目面板,效果 | 灯光光晕 | 光晕文件夹)。

  3. 在照明窗口的场景部分,找到其他设置子部分。然后,将光晕淡入速度设置为 1,光晕强度设置为 0.46,如下所示:

图片

  1. 播放场景。应该已经将光晕效果应用于场景的照明。

使用反射探针反射周围对象

如果你想让你的场景环境通过具有反射材料(如具有高金属或高镜面级别的材料)的 Game Objects 反射,那么你可以通过使用反射探头来实现这种效果。它们允许通过使用立方体贴图实现实时、烘焙或自定义反射。

实时反射在处理方面可能会很昂贵;在这种情况下,你应该优先考虑烘焙反射,除非确实有必要显示动态对象被反射(例如,类似镜子的对象)。尽管如此,还有一些方法可以优化实时反射。在这个食谱中,我们将测试反射探针的三个不同配置:

  • 实时反射(持续更新)

  • 实时反射(通过脚本按需更新)

  • 烘焙反射(来自编辑器)

准备工作

对于这个食谱,我们准备了一个基本场景,其中包括三组反射对象:一组是持续移动的,一组是静态的,还有一组在交互时移动。包含场景的reflectionProbes.unitypackage包可以在07_07文件夹中找到。

如何操作...

要使用反射探针反射周围的对象,请按照以下步骤操作:

  1. 导入 Unity 包reflectionProbes.unitypackage。然后,打开名为 reflective_objects 的场景。这是一个包含三组反射对象的基本场景。

  2. 确保项目的质量设置已启用实时反射探针。通过选择菜单:编辑 | 项目设置 | 质量,并确保要使用的质量设置中已选中实时反射探针选项。

  3. 播放场景。观察到一个系统是动态的,一个是静态的,还有一个在按下键时随机旋转。

  4. 停止场景。

  5. 首先,让我们为场景创建一个持续更新的实时反射探针(菜单:创建 | 光 | 反射探针)。将其命名为 ReflectionProbe-real-time。

  6. 将 ReflectionProbe-real-time 设置为游戏对象系统 1 实时 | MainSphere 的子对象。然后,在检查器中,将其变换位置设置为(0, 0, 0):

图片

  1. 在检查器中,找到反射探针组件。将类型设置为实时,刷新模式设置为每帧,时间切片设置为无时间切片,如下所示:

图片

  1. 播放场景。现在,系统 1 实时上的反射将实时更新。停止场景。

  2. 注意,唯一显示实时反射的对象是系统 1 实时 | MainSphere。原因是反射探针的盒子大小。从反射探针组件中,将其大小更改为(25, 10, 25)。请注意,现在小红色球体也会受到影响。然而,重要的是要注意所有对象都显示相同的反射。由于我们的反射探针的起点与 MainSphere 的位置相同,所有反射对象都将从这个视角显示反射:

图片

  1. 如果你想要消除 Reflection Probe 内反射对象的反射,例如小红色球,选择这些对象,从 Mesh Renderer 组件中,将 Reflection Probes 设置为关闭,如下所示:

图片

  1. 向场景中添加一个新的 Reflection Probe。这次,将其命名为 ReflectionProbe-onDemand,并使其成为 System 2 On Demand | MainSphere GameObject 的子对象。然后,在检查器中,将变换位置更改为(0,0,0)。

  2. 现在,前往 Reflection Probe 组件。将类型设置为实时,刷新模式设置为通过脚本,时间切片设置为单个面,如下所示:

图片

  1. 创建一个新的 C#脚本类名为 UpdateProbe,并将实例对象作为组件添加到 ReflectionProbe-onDemand GameObject:
 using UnityEngine;
 using System.Collections;

 public class UpdateProbe : MonoBehaviour {
    private ReflectionProbe probe;

    void Awake () {
       probe = GetComponent<ReflectionProbe> ();
       probe.RenderProbe();
    }

    public void RefreshProbe(){
       probe.RenderProbe();
    }
 }
  1. 现在,找到名为 RandomRotation 的脚本类,它附加到 System 2 On Demand | Spheres 对象上,并将其替换为以下内容:
using UnityEngine;

public class RandomRotation : MonoBehaviour {
   private GameObject probe;
   private UpdateProbe updateProbe;

   void Awake() {
      probe = GameObject.Find("ReflectionProbe-onDemand");
      updateProbe = probe.GetComponent<UpdateProbe>();
   }

   void Update () {
      if (Input.anyKeyDown) {
          Vector3 newRotation = transform.eulerAngles;
          newRotation.y = Random.Range(0F, 360F);
          transform.eulerAngles = newRotation;
          updateProbe.RefreshProbe();
      }
   }
}
  1. 保存脚本并测试你的场景。观察每当按下键时 Reflection Probe 是如何更新的。

  2. 停止场景。向场景中添加第三个 Reflection Probe。将其命名为 ReflectionProbe-custom,并使其成为 System 3 On Custom | MainSphere GameObject 的子对象。然后,在检查器中,将变换位置更改为(0,0,0)。

  3. 前往 Reflection Probe 组件。将类型设置为 Custom,然后点击 Bake 按钮,如下所示:

图片

  1. 将会弹出一个保存文件对话框。将文件保存为 ReflectionProbe-custom-reflectionHDR.exr。

  2. 注意,反射贴图不包括红色球在其上的反射。要更改这一点,你有两种选择:将 System 3 On Custom | Spheres GameObject(及其所有子对象)设置为 Reflection Probe Static,如下所示:

图片

  1. 或者检查 ReflectionProbe-custom GameObject 的 Reflection Probe 组件中的 Dynamic Objects 选项。注意,使用此选项时,你也会在 System 3 Custom 的 MainSphere 上的反射中看到其他两个大球及其红色球带的反射。

  2. 选择 ReflectionProbe-custom GameObject,然后再次点击 Bake 按钮。你现在应该能看到红色球在其上的反射。

  3. 如果你希望在编辑场景时动态烘焙你的反射立方体贴图,可以将 Reflection Probe 类型设置为 baked,打开照明窗口(菜单:窗口 | 渲染 | 照明设置),访问场景部分,并检查自动生成选项,如下所示:

图片

此模式不会包括动态对象在反射中,所以请确保将 System 3 Custom | Spheres 和 System 3 Custom | MainSphere 设置为 Reflection Probe Static。

它是如何工作的...

反射探针元素类似于全向摄像机,它渲染 Cubemaps 并将它们应用到其约束内的对象上。在创建反射探针时,了解不同类型的工作方式很重要:

  • 实时反射探针Cubemaps在运行时更新。实时反射探针有三种不同的刷新模式:在唤醒时(Cubemap 在场景开始前烘焙一次);每帧(Cubemap 不断更新);通过脚本(每当使用 RenderProbe 函数时更新 Cubemap)。由于 Cubemaps 有六个面,反射探针具有时间切片功能,因此每个面可以独立更新。有三种不同类型的时间切片:一次性更新所有面(一次渲染所有面,并在 6 帧内计算米普图。在 9 帧内更新探针);单独更新面(每个面在多帧内渲染。在 14 帧内更新探针。结果可能有点不准确,但这是在帧率影响方面最经济的解决方案);无时间切片(探针在一帧内渲染,米普图在一帧内计算。它提供高精度,但也是帧率影响中最昂贵的)。

  • 烘焙:当编辑屏幕时,Cubemaps 会被烘焙。Cubemaps 可以是手动或自动更新,这取决于是否勾选了自动生成选项(可以在光照设置窗口的场景部分找到)。

  • 自定义:自定义反射探针可以是手动从场景烘焙(甚至包括动态对象),或者从预制的 Cubemap 创建。

还有更多...

有许多其他设置可以调整,例如重要性、强度、盒子投影、分辨率、HDR(高动态范围)等。为了全面了解每个设置,我们强烈建议您阅读 Unity 关于该主题的文档,该文档可在docs.unity3d.com/Manual/class-ReflectionProbe.html找到。

使用材质发射将发光灯的光线烘焙到场景对象上

除了灯光之外,其他对象如果它们的材质具有发射属性(例如纹理和/或着色颜色),也可以发光。在这个配方中,我们将创建一个通过其发射纹理发出绿色光的灯。场景中的灯和其他 3D 对象将被烘焙,以创建场景的预计算光照图:

准备工作

对于这个配方,我们在07_08文件夹中的lamp.unitypackage Unity 包中提供了一个 3D 灯模型(lamp),以及一个绿色纹理(lamp_emission)。

如何操作...

要使用材质发射创建发光灯,请按照以下步骤操作:

  1. 创建一个新的 3D 项目。你应该从一个包含主摄像机和方向光的场景开始。

  2. 导入包含 3D 灯模型(灯)以及绿色纹理(lamp_emission)的 Unity 包lamp.unitypackage

  3. 在项目面板中选择了 3D 模型资产灯后,在检查器中勾选其生成光照图 UV 选项,并点击应用按钮以确认更改:

图片

  1. 在项目面板中,选择材质 m_lamp。勾选发射选项,然后将纹理 lamp_emission 分配给其发射颜色属性。将全局光照下拉菜单设置为烘焙。这将使灯对象发出绿色光,并将其烘焙到光照图中:

图片

  1. 此外,对于材质m_lamp,点击 HDR 颜色框,并增加这个发光材质的强度到 1 或 2(这是一个你可能需要调整以获得场景所需设置的值):

图片

  1. 添加一些 3D 游戏对象以创建一个简单的 3D 场景,包含一个 3D *面(地面)和三个 3D 立方体。调整 3D 立方体的位置和大小,使有一个大立方体位于 3D *面的后方,一个中等大小的立方体位于中间,一个小立方体位于前方。

  2. 现在,将 3D 灯模型的一个实例从项目面板拖动到场景中,放置在最*的 3D 立方体前方:

图片

  1. 你可能需要调整主相机的位置和旋转,以便可以看到灯和位于 3D *面上方的三个 3D 立方体。

  2. 烘焙光照仅适用于静态对象,因此,除了主摄像机外,选择层级中的所有内容,并在检查器面板右上角勾选静态选项:

图片

  1. 现在,在层级中选择方向光,并将其光模式下拉菜单属性更改为烘焙:

图片

  1. 我们现在可以将方向光分配为环境光照太阳光源。打开光照设置窗口(选择菜单:窗口 | 渲染 | 光照设置),并将方向光层级拖动到场景环境属性中的太阳槽中。同时,将环境光照环境模式下拉菜单设置为烘焙。在调试设置中,取消自动生成,并点击生成光照按钮以“烘焙”环境光和绿色灯发射光到场景中:

图片

  1. 几秒钟内(取决于你电脑的速度和场景的复杂度),你将在 Unity 编辑器应用程序窗口的右下角看到光照烘焙过程的进度条:

图片

  1. 播放你的场景。你应该能看到场景对象既被方向光照亮,也被来自灯的绿色纹理发出的光照亮。

  2. 改变方向光的旋转,并尝试将其光强度和间接乘数设置为 0.5。同时,调整材质 m_lamp 的 HDR 强度,并重新烘焙光照贴图,以使灯的发射更加突出(并且方向光的作用更小)。

它是如何工作的...

你已经为一个游戏对象(灯)添加了一个发射材质,并根据场景中的静态对象(包括灯、方向光、3D *面和立方体)烘焙了光照贴图。环境的全局光照环境光照来自方向光的设置。

光照贴图基本上是包含场景灯光/阴影、全局光照、间接光照以及具有发射材质的物体的纹理贴图。它们可以由 Unity 的光照引擎自动生成或在需要时生成。然而,有一些需要注意的点,如下所示:

  • 将所有非移动对象和灯光设置为烘焙为静态

  • 将游戏灯光设置为烘焙

  • 将场景的环境全局光照设置为烘焙

  • 将发射材质的全局光照选项设置为烘焙

  • 为所有 3D 网格(特别是导入的网格)生成光照 UV

  • 可以从光照设置窗口手动构建光照贴图,或者检查自动生成选项

使用光照贴图和光探针照明简单场景

光照贴图是实时照明的优秀替代品,因为它们可以在不占用处理器资源的情况下为环境提供所需的外观。然而,有一个缺点——由于无法将光照贴图烘焙到动态对象上,游戏中的重要元素(如玩家角色本身)的照明可能看起来很假,无法与周围区域的强度相匹配。解决方案?光探针。

光探针通过采样它们放置位置的光强度来工作。一旦启用光探针,动态对象将根据它们周围最*的探针进行插值来照明:

准备工作

对于这个配方,我们准备了一个基本的 Unity 包(rollerballLevel.unitypackage),包括一个包含发射灯(来自上一个配方!)的游戏环境,以及游戏对象,使其成为一个适合 RollerBall 游戏的合适关卡。游戏对象是静态的,方向光和发射材质被设置为烘焙,因此场景已经被设置为烘焙光照贴图场景。

包含场景的 rollerBallLevel.unitypackage 包位于07_09文件夹中。你还可以找到创建 RollerBall 材质所需的两个 PNG 图像(RollerBallAlbedo.png 和 RollerBallSpecularGloss.png)。

该场景的几何形状是使用 ProBuilder 创建的,这是由 ProCore 开发的扩展,现在是 Unity 2018 的一部分免费提供。ProBuilder 是一个出色的关卡设计工具,可以显著加快简单和复杂关卡设计的设计过程。你可以在www.procore3d.comblogs.unity3d.com/2018/02/15/probuilder-joins-unity-offering-integrated-in-editor-advanced-level-design/了解更多信息。

如何操作...

要使用 Reflection Probes 反射周围对象,请按照以下步骤操作:

  1. 将 rollerBallLevel.unitypackage 导入到一个新项目中。然后,打开名为 scene0_level_baked 的场景。该场景具有一个基本环境,包括一个方向光和一些绿色的发射灯。

  2. 将 Standard Assets 导入到你的项目中。我们需要三个包,如下所示:

    • 相机

    • 角色(我们需要 RollerBall,所以在导入时可以取消选中 FirstPersonCharacter 和 ThirdPersonCharacter)

    • 效果(我们只需要 Projectors 资产,所以在导入时可以取消选中除该文件夹外的所有其他文件夹)

  3. 将 RollerBall prefab 从Project面板(Standard Assets | Characters | RollerBall | Prefabs)拖入场景。

  4. 将 FreeLookCameraRig prefab 从Project面板(Standard Assets | Cameras | Prefabs)拖入场景。如果这没有自动针对 RollerBall 角色,那么将 RollerBall GameObject 从Hierarchy拖入Inspector中的 Free Look Cam (Script) Target 槽位。

  5. 让我们在 RollerBall 上添加一点颜色,通过为这个 GameObject 创建并应用一个新的材质。创建一个新的名为 m_rollerballColor 的材质,并设置 Specular 设置。将 Albedo Texture 设置为 RollerBallAlbedo,其色调为 127/127/127。将 Specular Texture 设置为 RollerBallSpecularGloss。

  6. Hierarchy中选中 GameObject RollerBall。将 m_rollerballColor 材质应用到 RollerBall GameObject 上。现在应该是一个双色球。

  7. 现在我们需要确保 RollerBall GameObject 将动态地受到 Light Probes 的影响,因此在 Hierarchy 中仍然选中 GameObject RollerBall,对于 Light Probes 选项的 Mesh Renderer 组件,从下拉菜单中选择 Blend Probes 选项:

图片

  1. 现在,我们需要为场景创建 Light Probes。选择 Hierarchy 菜单:Create | Light | Light Probe Group。这将为你提供一个基本的包含八个 Light Probes 的组,成对排列以形成一个立方体体积。

重要提示:即使你正在处理一个*坦的水*面,你也不应该将所有探针放置在同一水*面上,因为 Light Probe Groups 将形成一个体积,以便正确计算插值。

  1. 要便于操作探针,在层次面板的搜索字段中输入探针。这将隔离新创建的光探针组,使其成为场景中唯一的可编辑对象:

图片

  1. 通过选择窗口 | 布局 | 4 分割来将视口布局更改为 4 分割。然后,设置视口为顶部、前面、右侧和透视。可选地,将顶部、前面和右侧视图更改为线框模式。最后,确保它们设置为正交视图,如下面的截图所示。这将使您更容易定位光探针:

图片

  1. 将初始光探针放置在关卡顶部房间的角落。要移动探针,只需点击并拖动它们,如下所示:

图片

  1. 选择隧道入口左侧的四探针。然后,复制它们(使用Ctrl/Cmd + D键)。最后,将新探针稍微向右拖动,直到它们不再位于墙投射的阴影上方,如下所示:

图片

  1. 重复最后一步,这次复制隧道入口旁边的探针,并将它们向内移动到组中。要删除选定的探针,可以使用光探针组组件上的相应按钮,或者使用Ctrl/Cmd + Backspace键:

图片

  1. 复制并重新定位距离隧道最*的前四个探针,重复此操作五次,并使每个复制的探针组与隧道投射的阴影相吻合:

图片

  1. 使用添加探针按钮将三个探针放置在场景中光照良好的区域:

图片

  1. 现在,在 L 形墙投射的阴影内添加光探针:

图片

  1. 由于滚球能够跳跃,将较高的探针放置得更高,以便它们能够采样场景中阴影区域的上方光线:

图片

  1. 在场景上放置过多的光探针可能会占用大量内存。尝试通过从玩家无法访问的区域移除探针来优化光探针组。此外,通过移除与同一光照条件下其他探针距离过*的探针,避免在连续光照条件区域过度拥挤:

图片

  1. 要检查哪些光探针在任何位置影响滚球,请将滚球游戏对象在场景中移动。一个多面体会指示在该位置正在插值的探针,如下所示:

图片

  1. 从灯光设置窗口的底部,点击生成灯光按钮,等待灯光图烘焙完成。

  2. 测试场景。滚球的光照将根据光照探头进行:

图片

  1. 持续添加探头,直到完全覆盖水*面。

它是如何工作的...

光探头通过在它们放置的点采样场景的照明来工作。启用了使用光探头功能的动态对象,其光照由四个光探头之间的插值确定,定义了它周围的体积(或者,如果没有适合定义动态对象周围体积的探头,则使用最*探头的三角剖分)。

更多关于这个主题的信息可以在 Unity 的文档中找到,请参阅docs.unity3d.com/Manual/LightProbes.html

还有更多...

如果您能腾出一些处理能力,可以将光探头的使用与混合光照交换。

执行以下操作:

  1. 从您的场景中删除光照探头组。

  2. 选择方向光,从光照组件中,将烘焙更改为混合。

  3. 将阴影类型设置为软阴影,强度设置为 0.5,如下截图所示:

图片

  1. 最后,点击生成光照按钮,等待光照图烘焙完成。实时光照/阴影将被投射到/来自动态对象,例如滚球。

第九章:2D 动画

在本章中,我们将涵盖:

  • 水*翻转精灵——DIY 方法

  • 水*翻转精灵——使用动画状态图和转换

  • 为角色移动事件动画身体部位

  • 创建一个 3 帧的动画剪辑,使*台持续动画

  • 使用触发器使*台一旦被踩上就开始下落,从而将动画从一个状态转换到另一个状态

  • 从精灵序列创建动画剪辑

  • 使用图块和地图创建*台游戏

  • 使用 2D Gamekit 创建场景

简介

自从 2014 年的 Unity 4.6 以来,Unity 就提供了专门的 2D 功能,Unity 2018 继续在此基础上发展。在本章中,我们提供了一系列食谱,介绍 Unity 2018 中 2D 动画的基础知识,并帮助您了解不同动画元素之间的关系。

整体概念

在 Unity 2D 中,可以通过几种不同的方式创建动画——一种方式是创建许多略有不同的图像,逐帧给出运动的外观。创建动画的第二种方式是为对象的各个部分定义关键帧位置(例如,手臂、腿部、脚、头部和眼睛),并在游戏运行时让 Unity 计算所有中间位置:

动画的两类来源在动画面板中变为动画剪辑。每个动画剪辑随后成为动画控制器状态机中的一个状态。我们还可以根据动画剪辑复制状态,或者创建新的状态,并添加脚本行为。

我们还可以定义复杂的条件,在满足这些条件的情况下,GameObject 将从一种动画状态转换到另一种状态。

网格、地图和图块调色板

Unity 引入了一套地图功能,使得创建基于地图的场景变得快速且简单。地图网格 GameObject 充当地图的父对象。地图是将图块绘制到其上的 GameObject,来自图块调色板。精灵可以被制作成图块资产,并将一组图块添加到调色板中,用于绘制场景。

它还提供了强大的脚本规则图块,增强了图块画笔工具,自动添加顶部、左侧、右侧和底部边缘的图块,当用图块绘制更多网格元素时。规则图块甚至可以在定义的条件下从一组图块中随机选择。更多信息请访问 unity3d.com/learn/tutorials/topics/2d-game-creation/using-rule-tiles-tilemap

2D GameKit – 将 2D 工具整合在一起

Unity 引入的最令人兴奋的 2D 功能可能是 2D GameKit。它将构建 2D 游戏所需的几个强大的 Unity 功能汇集在一起,包括:

  • 地图和规则图块

  • 2D 角色控制器(以及输入映射器和玩家角色组件)

  • Cinemachine 智能相机控制

  • 统一事件系统

  • 许多预制好的常见 2D 游戏组件,包括门、传送门、对话框面板、开关、库存、*战、可收集物品和库存、可伤害物体以及敌人等等

本章的最后一个食谱介绍了 2D GameKit,而本章的其他食谱和一些其他食谱则分别介绍了某些组件,这样您就会知道足够的信息来开始使用 2D GameKit 并学习如何构建紧密组合核心 2D 游戏功能的场景。

资源

在本章中,我们介绍了展示 2D 游戏元素动画系统的食谱。PotatoMan2D 角色来自 Unity 2D *台游戏,您可以从 Unity 资产商店自行下载。该项目是查看更多 2D 游戏和动画技术示例的好地方:

这里有一些链接,用于探索这些主题的更多有用资源和信息来源:

水*翻转精灵 - DIY 方法

可能最简单的 2D 动画就是简单的翻转,从面向左到面向右,或者从面向上到面向下,等等。在这个菜谱中,我们将向场景添加一个可爱的虫子精灵,并编写一个简短的脚本,当按下 方向键时翻转其水*方向:

准备工作

对于这个菜谱,我们在名为 Sprites 的文件夹中名为 08_01 的文件夹中准备了所需的图像。

如何实现...

要通过箭头键按下水*翻转对象,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

如果你在一个最初创建为 3D 的项目中工作,你可以通过菜单更改默认项目行为(例如,新的 Sprite Textures 和 场景 模式)到 2D,方法是:编辑 | 项目设置 | 编辑器,然后在检查器中选择 2D 作为默认行为模式。

  1. 导入提供的图像:EnemyBug.png。

  2. 从项目 | Sprites 文件夹中将红色 Enemy Bug 图像的实例拖到 场景 中。将此 GameObject 定位在 (0, 0, 0) 并缩放到 (2, 2, 2)。

  3. 创建一个名为 BugFlip 的 C# 脚本类,并将实例对象作为组件添加到 Enemy Bug:

     using UnityEngine;
     using System.Collections;

     public class BugFlip : MonoBehaviour {
       private bool facingRight = true;

       void Update() {
         if (Input.GetKeyDown(KeyCode.LeftArrow) && facingRight)
           Flip ();
         if (Input.GetKeyDown(KeyCode.RightArrow) && !facingRight)
           Flip();
       }

       void Flip (){
         // Switch the way the player is labelled as facing.
         facingRight = !facingRight;

         // Multiply the player's x local scale by -1.
         Vector3 theScale = transform.localScale;
         theScale.x *= -1;
         transform.localScale = theScale;
       }
     } 
  1. 当你运行场景时,按下 方向键应该使虫子面向左或右。

它是如何工作的...

C# 类定义了一个 布尔 变量 facingRight,它存储一个真/假值,对应于虫子是否面向右侧。由于我们的虫子精灵最初面向右侧,我们将 facingRight 的初始值设置为 true 以匹配这一点。

每一帧,Update() 方法都会检查是否按下了 方向键。如果按下 方向键且虫子面向右,则调用 Flip() 方法,同样,如果按下 方向键且虫子面向左(即面向右为假),则再次调用 Flip() 方法。

Flip() 方法执行两个操作;第一个操作简单地反转变量 facingRight 中的真/假值。第二个操作改变变换的 localScale 属性的 X 值的正负号。反转 localScale 的符号将导致我们想要的 2D 翻转。在下一个菜谱中,查看 PlayerControl 脚本中的 PotatoMan 角色内部,你会看到相同的 Flip() 方法被使用。

水*翻转精灵 - 使用动画状态图和转换

在这个配方中,我们将(以简单的方式)使用 Unity 动画系统创建两个状态,对应两个动画剪辑,以及一个根据哪个动画状态活动而改变 localScale 的脚本。我们将使用第二个脚本,它将箭头键的按下水*输入轴值映射到状态图中的参数,并驱动从一个状态到另一个状态的过渡。

虽然这可能看起来工作量很大,但与之前的配方相比,这种方法说明了我们如何将输入事件(如按键或触摸输入)映射到状态图中的参数和触发器。

准备工作

对于这个配方,我们在名为 Sprites 的文件夹中名为 08_02 的文件夹中准备了所需的图像。

如何操作...

要使用动画状态图和过渡水*翻转对象,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 导入提供的图像:EnemyBug.png。

  3. 从项目 | Sprites 文件夹中将红色敌人虫子图像的实例拖动到场景中。将此 GameObject 定位在 (0, 0, 0) 并缩放到 (2, 2, 2)。

  4. 层次结构中选择 Enemy Bug GameObject,打开动画面板(菜单:窗口 | 动画 | 动画),然后单击创建按钮以创建一个新的动画剪辑资产。将新的动画剪辑资产保存为 beetle-right。你还会看到已经添加了 Animator 组件到 Enemy Bug GameObject:

图片

  1. 如果你查看项目面板,你会看到创建了两个新的资产文件:beetle-right 动画剪辑和一个名为 Enemy Bug 的动画控制器:

图片

  1. 关闭动画面板,双击敌人虫子动画控制器以开始编辑它——它应该出现在一个新的动画器面板中。你应该看到四个状态,任何状态和退出都是未链接的,状态进入有一个过渡箭头连接到动画剪辑 beetle-right。这意味着一旦动画控制器开始播放,它将进入 beetle-right 状态。状态 beetle-right 被着色为橙色,以表示它是默认状态。

图片

如果只有一个动画剪辑状态,它将自动成为默认状态。一旦你在状态图中添加了其他状态,你可以右键单击不同的状态,并使用上下文菜单更改首先进入的状态。

  1. 选择 beetle-right 状态并复制它,将副本重命名为 beetle-left(可以使用右键菜单或 Ctrl + C/V 键盘快捷键)。将 beetle-left 定位在 beetle-right 的左侧是有意义的:

图片

  1. 将鼠标指针移至 beetle-right 状态,然后在鼠标右键上下文菜单中选择创建过渡,并将出现的白色箭头拖动到 beetle-left 状态:

图片

  1. 使用 beetle-left 重复此步骤,以创建从 beetle-left 到 beetle-right 的转换回:

图片

  1. 我们需要一个左右面向的实例转换。因此,对于每个转换,取消选中“有退出时间”选项。点击转换箭头以选择它(它应该变成蓝色),然后在检查器中取消选中此选项:

图片

要删除转换,首先选择它,然后使用删除键(Windows)或按Fn + 退格键macOS)。

  1. 为了决定何时更改活动状态,我们需要创建一个参数来指示左/右箭头键是否被点击。左/右键的按下由 Unity 输入系统的水*轴值表示。通过在动画师面板的左上角选择参数(而不是层),点击加号符号"+"按钮,并选择浮点数,创建一个名为 axisHorizontal 的状态图浮点参数。将你的新参数命名为 axisHorizontal:

图片

  1. 使用我们的参数,我们可以定义在左右面向状态之间切换的条件。当按下左箭头键时,Unity 输入系统的水*轴值是负值,因此选择从 beetle-right 到 beetle-left 的转换,并在检查器中点击转换属性条件部分的加号符号。由于只有一个参数,这会自动建议,默认为大于零。将大于改为小于,我们就得到了所需的条件:

图片

  1. 现在,选择从 beetle-left 到 beetle-right 的转换,并添加一个条件。在这种情况下,默认的 axisHorizontal 大于零正好是我们想要的(因为当按下右箭头键时,Unity 输入系统的水*轴返回正值)。

  2. 我们需要一个方法来将 Unity 输入系统的水*轴值(来自左/右数组键)映射到我们的动画师状态图参数 axisHorizontal。我们可以通过创建一个简短的脚本类来实现这一点,我们将在下一步中创建它。

  3. 创建一个名为 InputMapper 的 C#脚本类,并将实例对象作为组件添加到 Enemy Bug 游戏对象中:

    using UnityEngine;

    public class InputMapper : MonoBehaviour {
         Animator animator;

         void Start() {
             animator = GetComponent<Animator>();
         }

         void Update() {
             animator.SetFloat("axisHorizontal", Input.GetAxisRaw("Horizontal"));
         }
     } 
  1. 现在我们需要实际更改 GameObject 在切换到左右面向状态时的本地缩放属性。创建一个名为 LocalScaleSetter 的 C#脚本类:
    using UnityEngine;

     public class LocalScaleSetter : StateMachineBehaviour  {
         public Vector3 scale = Vector3.one;

         override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
             animator.transform.localScale = scale;
         }

     } 
  1. 在动画师面板中,选择 beetle-right 状态。在检查器中,点击添加行为按钮,并选择 LocalScaleSetter。对于此状态,默认的公共 Vector three 缩放值(1,1,1)是合适的。

  2. 在动画师面板中,选择 beetle-left 状态。在检查器中,点击添加行为按钮,并选择 LocalScaleSetter。将公共 Vector three 缩放值更改为(-1,1,1) – 即,我们需要交换 X 缩放以使我们的 Sprite 面向左侧:

将 C# 脚本类的实例对象添加到动画器状态中是将进入/退出状态时的逻辑与动画器状态本身链接起来的好方法。

  1. 在动画器面板中,选择 beetle-right 状态。在检查器中,点击添加行为按钮,并选择 InputMapper。

  2. 当你运行 场景 时,按下 方向键应该使虫子面向左边或右边。

它是如何工作的...

每一帧,InputMapper C# 脚本类的 Update() 方法都会读取 Unity 输入系统的水*轴值,并将动画器状态图表参数 axisHorizontal 设置为该值。如果该值小于(左箭头)或大于(右箭头)零,如果适当,动画器状态系统将切换到另一个状态。

实际上改变 LocalScaleSetter C# 脚本类的 localScale 属性(初始值 1,1,1,或水*翻转使其面向左边 -1,1,1)。对于每个状态,公共 Vector3 变量都可以自定义到适当的值。

每次进入此 C# 类的实例附加到的状态时,都会涉及 OnStateEnter(...) 方法。您可以在 docs.unity3d.com/ScriptReference/StateMachineBehaviour.html 阅读有关 StateMachineBehaviour 类的各种事件消息。

当我们按下 方向键时,Unity 输入系统的水*轴值是负数,并将其映射到动画器状态图表,即 Parameter axisHorizontal,导致系统过渡到 beetle-left 状态,并执行 LocalScaleSetter 脚本类实例的 OnStateEnter(...),将局部缩放设置为 (-1, 1, 1),使 纹理 水*翻转,因此甲虫面向左边。

还有更多...

这里有一些增强此配方的建议。

瞬时交换

你可能已经注意到,即使我们设置了退出时间为零,仍然存在延迟。这是因为从一个状态过渡到另一个状态时存在默认混合。但是,这可以设置为 0,以便状态机可以瞬时从一个状态切换到下一个状态。

执行以下操作:

  1. Animator 面板中选择每个过渡。

  2. 展开设置属性。

  3. 将过渡持续时间和过渡偏移量都设置为 0:

现在当你运行 场景 时,当你按下相应的箭头键时,虫子应该立即左右切换。

为角色移动事件动画化身体部位

在这个配方中,我们将学习如何根据跳跃事件动画化 Unity 土豆人角色的帽子。

准备工作

对于这个配方,我们在文件夹 08_03 中准备了所需的文件。

如何做到这一点...

要为角色移动事件动画化身体部位,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 将提供的 PotatoManAssets 包导入到你的项目中。

  3. 主摄像机 的尺寸增加到 10。

  4. 为此项目设置 2D 重力设置 - 我们将使用与 Unity 2D *台教程相同的设置,Y= -30。通过选择菜单:编辑 | 项目设置 | 物理效果 2D,将 2D 重力设置为该值,然后在顶部将 Y 值更改为 -30:

图片

  1. 将 PotatoMan hero character2D 的实例从 Project | 预制体文件夹拖动到 场景 中。将此 GameObject 定位于 (0, 3, 0)。

  2. 将 Project | Sprites 文件夹中的 sprite platformWallBlocks 的实例拖动到 场景 中。将此 GameObject 定位于 (0, -4, 0)。

  3. 通过选择菜单:添加组件 | 物理效果 2D | 矩形碰撞器 2D,将矩形碰撞器 2D 组件添加到 platformWallBlocks GameObject。

  4. 现在我们有一个静止的*台,玩家可以着陆并左右行走。创建一个名为 Ground 的新层,并将 platformWallBlocks GameObject 分配到这个新层,如下截图所示。当角色在*台上时,按下 空格 键现在会使他跳跃:

图片

  1. 目前,当我们将 PotatoMan 英雄角色设置为跳跃时,他会被动画化(手臂和腿部移动)。让我们删除动画剪辑和动画控制器,从头开始创建自己的。如以下所示,从项目 | 资产 | PotatoMan2DAssets | Character2D | 动画中删除 Clips 和 Controllers 文件夹:

图片

  1. 让我们为我们的英雄角色创建一个动画剪辑(及其关联的动画控制器)。在层次结构中选中 GameObject hero。确保在层次结构中选中 GameObject hero character2D,打开动画面板,并确保它处于 Dope Sheet 视图(这是默认视图)。

  2. 点击 动画 面板的创建按钮,并将新剪辑保存到 Character2D | 动画文件夹中,命名为 character-potatoman-idle。你现在已为空闲角色状态(未动画)创建了一个动画剪辑:

图片

你的最终游戏可能包含数十个,甚至数百个动画剪辑。通过在剪辑名称前缀中添加对象类型、名称以及动画剪辑的描述,使搜索变得容易。

  1. 项目面板中查看 Character2D | 动画文件夹,你现在应该看到你刚刚创建的动画剪辑(character-potatoman-idle)和一个新的动画控制器,该控制器默认为你的 hero character2D GameObject 的名称:

图片

  1. 确保在层次结构中选中了英雄 GameObject,打开动画面板,你会看到控制我们角色动画的状态机。由于我们只有一个动画剪辑(character-potatoman-idle),进入时,状态机立即进入此状态:

图片

  1. 运行你的场景。由于角色始终处于“空闲”状态,当我们让它跳跃时,我们还没有看到任何动画。

  2. 创建一个跳跃动画剪辑,使帽子动画。确保在层次结构中仍然选中了英雄GameObject。点击动画面板(“样本”一词旁边的)中的空下拉菜单,并在你的动画文件夹中创建一个新的剪辑,命名为 character-potatoman-jump:

图片

  1. 点击“添加属性”按钮,通过点击其+(加号)按钮选择帽子子对象的 Transform | Position。我们现在可以记录在这个动画剪辑中帽子 GameObject 的(X, Y, Z)位置的变化:

图片

  1. 你现在应该看到在 0.0 和 1.0 处有两个关键帧。这些在动画面板右侧部分的时间轴区域用菱形表示。

  2. 点击选择第一个关键帧(在时间 0.0 处)——菱形应该变成蓝色以表示它已被选中。

  3. 让我们为这个第一帧记录一个新的帽子位置。在动画面板中点击一次红色记录圆圈按钮开始记录。现在在场景面板中,将帽子向上和向左移动一点,远离头部。你应该会看到在检查器中所有三个 X, Y, Z 值都有红色背景——这是为了通知你 Transform 组件的值正在被记录在动画剪辑中:

图片

  1. 再次点击红色记录圆圈按钮,在动画面板中停止记录。

  2. 由于 1 秒可能对于我们跳跃动画来说太长了,将第二个关键帧的菱形向左拖动到 0.5 的时间:

图片

  1. 我们需要定义角色应该从空闲状态过渡到跳跃状态的时间。在动画器面板中,选择 character-potatoman-idle 状态,通过右键单击并选择 Make Transition 菜单,然后拖动过渡箭头到 character-potatoman-jump 状态,如图所示:

图片

  1. 让我们在动画器面板的左上角点击添加参数加号“+”按钮,选择触发器,并输入名称“Jump”来添加一个名为“Jump”的触发参数:

图片

  1. 我们现在可以定义当我们的角色应该从空闲状态过渡到跳跃状态时的属性。点击过渡箭头选择它,设置以下两个属性,并在检查器面板中添加一个条件:

    • 已有退出时间:取消选中此选项

    • 过渡持续时间(秒):设置为 0.01

    • 条件:添加跳跃(点击底部的+按钮):

图片

  1. 保存并运行你的场景。一旦角色在*台上着陆并按下空格键跳跃,你会看到角色的帽子从头上跳开,并慢慢移动回来。由于我们没有添加离开跳跃状态的过渡,这个动画剪辑将循环,所以即使跳跃完成,帽子也会继续移动。

  2. 动画器面板中,选择character-potatoman-jump状态并添加一个新的过渡回到character-potatoman-idle状态。选择这个过渡箭头,并在检查器面板中设置其属性如下:

    • 有退出时间:(保持选中)

    • 退出时间:0.5(这需要与我们的跳跃动画剪辑的第二个关键帧相同的时值):

图片

  1. 保存并运行你的场景。现在当你跳跃时,帽子应该动一次,然后角色立即回到空闲状态。

它是如何工作的...

你已经将动画控制器状态机添加到了英雄游戏对象中。你创建的两个动画剪辑(空闲和跳跃)在动画器面板中显示为状态。当状态机接收到跳跃触发参数时,你创建了一个从空闲到跳跃的过渡。你创建了第二个过渡,在等待 0.5 秒后(与我们的跳跃动画剪辑中的两个关键帧之间的相同持续时间)返回到空闲状态。

玩家通过按下空格键使角色跳跃。这会导致英雄游戏对象的PlayerControl C#脚本组件中的代码被调用,使精灵在屏幕上向上移动,并给动画控制器组件发送一个名为跳跃的SetTrigger(...)消息。

布尔参数和触发器的区别在于,触发器被临时设置为 True,一旦SetTrigger(...)事件被状态转换消耗,它将自动返回到 False。因此,触发器适用于我们希望执行一次然后恢复到之前状态的行动。布尔参数是一个变量,可以在游戏的不同时间将其值设置为 True 或 False,因此可以创建不同的过渡,根据变量在任何时间的值来触发。请注意,布尔参数必须使用SetBool(...)显式地将值设置回 False。

以下截图突出了发送SetTrigger(...)消息的代码行:

图片

用于一系列动作(跑步/行走/跳跃/坠落/死亡)的动画状态机将具有更多状态和过渡。Unity 提供的土豆人英雄角色具有更复杂的状态机,以及更复杂的动画(每个动画剪辑的手和脚、眼睛和帽子等),这可能对你探索这些内容很有用。

在 Unity 手册网页上了解更多关于动画视图的信息:docs.unity3d.com/Manual/AnimationEditorGuide.html.

创建一个三帧动画剪辑以使*台持续动画

在此配方中,我们将制作一个看起来像木头的*台持续动画,上下移动。这可以通过一个三帧的动画剪辑(从顶部开始,位置到底部,然后再次回到顶部位置)来实现:

图片

准备工作

此配方基于上一个配方,因此复制该项目,并在此配方中对其副本进行操作。

如何操作...

要创建一个持续移动的动画*台,请按照以下步骤操作:

  1. 将*台木块精灵实例从“项目 | 精灵”文件夹拖动到场景中。将此 GameObject 定位在(-4, -5, 0),这样这些木块就整齐地位于墙块*台的左侧,并且略微低于*台。

  2. *台木块GameObject 添加一个 Box Collider 2D 组件,以便玩家角色也能站在这个*台上。选择菜单:添加组件 | 物理 2D | Box Collider 2D。

  3. 创建一个名为“动画”的新文件夹,用于存储我们将创建的动画剪辑和控制器。

  4. 确保*台木块GameObject 仍然被选中在层次结构中,打开一个动画面板,并确保它处于 Dope Sheet 视图(这是默认视图)。

  5. 点击动画面板的创建按钮,并将新剪辑保存在您的新动画文件夹中,命名为 platform-wood-moving-up-down。

  6. 点击添加属性按钮,选择 Transform,然后点击位置旁边的加号。我们现在准备好记录在此动画剪辑中对*台木块GameObject 的(X, Y, Z)位置所做的更改:

图片

  1. 您现在应该看到在 0.0 和 1.0 处有两个关键帧。这些在动画面板右侧部分的“时间轴”区域由菱形表示。

  2. 我们需要 3 个关键帧,新关键帧在2:00秒处。在动画面板顶部的 2:00 处单击,以便当前播放头时间的红色线位于 2:00 处。然后单击菱形+按钮在当前播放头时间创建一个新的关键帧:

图片

  1. 第一个和第三个关键帧是好的——它们记录了木*台在 Y= -5 处的当前高度。我们需要使中间关键帧记录*台在运动顶部的位置,Unity 将为我们完成所有其余的动画工作。

  2. 通过单击时间 1:00 处的菱形来选择中间关键帧(在时间 1:00 处),它们都应该变成蓝色,并且红色播放头垂直线应移动到 1:00,以指示正在编辑中间关键帧。

  3. 点击红色录音圆按钮开始记录更改。

  4. 检查器中,将*台的 Y 位置更改为 0。你应该会看到所有三个 X、Y 和 Z 值在检查器中都有红色背景——这是为了通知你 Transform 组件的值正在记录在动画剪辑中。

  5. 再次点击红色记录圆圈按钮,以完成更改的记录。

  6. 保存并运行您的场景。现在,木*台应该正在连续动画,*滑地上下移动到我们设置的位置。

如果你想让 potatoman 角色能够在移动的木块上跳跃,你需要选择该块 GameObject 并将其层设置为地面。

它是如何工作的...

您已将动画添加到 platformWoodBlocks GameObject。此动画包含三个关键帧。关键帧表示对象在某一时间点的属性值。第一个关键帧存储 Y 值为-4,第二个关键帧 Y 值为 0,最后一个关键帧再次为-4。Unity 为我们计算所有中间值,结果是*台 Y 位置的*滑动画。

更多...

这里有一些增强此配方的建议。

将动画相对于新的父 GameObject 进行复制

如果我们想要复制移动*台,简单地复制 platformWoodBlocks GameObject 在层次结构中并移动副本将不起作用——因为当你运行场景时,每个副本都会被动画回原始动画帧的位置(即,所有副本都会定位并移动到原始位置)。

解决方案是首先创建一个新的空 GameObject(命名为 movingBlockParent),然后创建一个 platformWoodBlocks 父 GameObject。现在我们可以复制 movingBlockParent GameObject(及其 platformWoodBlocks 子 GameObject)来创建更多移动的方块,这些方块在场景中相对于父 GameObject 的位置设计时移动。

使用触发器将动画从一个状态移动到另一个状态,使*台一旦被踩到就开始下落

在许多情况下,我们不想在满足某些条件或发生某些事件之前开始动画。在这些情况下,组织 Animator Controller 的一个好方法是在两个动画状态(剪辑)之间设置一个触发器。我们使用代码检测何时想要动画开始播放,并在那时向动画控制器发送触发器消息,从而开始过渡。

在此配方中,我们将在我们的 2D *台游戏中创建一个水*台方块;这些方块一旦被踩到,就会开始慢慢向下屏幕落下,因此玩家必须继续移动,否则他们也会随着方块一起掉落屏幕!它看起来如下:

准备工作

此配方基于之前的配方,因此复制该项目,并在副本上工作以进行此配方。

如何操作...

要构建一个只有在接收到触发器后才会播放的动画,请遵循以下步骤:

  1. 层次结构中创建一个名为 water-block-container 的空 GameObject,位置在(2.5, -4, 0)。这个空 GameObject 将允许我们制作动画 Water Block 的副本,这些副本将相对于其父 GameObject 的位置进行动画。

  2. 将 Project | Sprites 文件夹中的 Water Block 精灵实例拖动到场景中,并将其子对象设置为 water-block-container GameObject。确保你的新子 Water Block GameObject 的位置是(0, 0, 0),这样它就会整齐地出现在墙块*台右侧:

  1. 向 child Water Block GameObject 添加一个 Box Collider 2D 组件,并将此 GameObject 的层设置为 Ground,以便玩家的角色可以站在这个水块*台上跳跃。

  2. 确保在层次结构中选择 child Water Block GameObject,打开动画面板,然后创建一个名为 platform-water-up 的新剪辑,并将其保存在你的动画文件夹中。

  3. 创建第二个动画剪辑,命名为 platform-water-down。再次点击“添加属性”按钮,选择变换和位置,并删除 1:00 处的第二个关键帧。

  4. 选择 0:00 的第一个关键帧,点击一次红色记录按钮开始记录更改,并将 GameObject 的变换位置 Y 值设置为-5。再次按下红色记录按钮停止记录更改。你现在已经完成了 water-block-down 动画剪辑的创建,因此可以点击红色记录按钮停止记录。

  5. 你可能已经注意到,除了你创建的上下动画剪辑外,在动画文件夹中还创建了一个名为 Water Block 的动画控制器文件。选择此文件并打开动画器面板,以查看和编辑状态机图:

  1. 目前,尽管我们创建了 2 个动画剪辑(状态),但只有 Up 状态始终处于活动状态。这是因为当场景开始时(入口),对象将立即进入 platform-water-up 状态,但由于没有从这个状态到 platform-water-down 的过渡箭头,目前 Water Block GameObject 将始终处于其 Up 状态。

  2. 确保选择 platform-water-up 状态(它周围将有一个蓝色边框),然后通过鼠标右键点击菜单中的 Make Transition 选项创建一个到 platform-water-down 状态的过渡(箭头)。

  3. 如果你现在运行场景,默认的过渡设置是在 0.75 秒(默认退出时间)后,Water Block 将过渡到其下状态。我们不想这样——我们只想在玩家走上它们之后让它们向下动画。

  4. 通过在动画器面板中选择参数选项卡,点击+按钮并选择触发器,然后选择 Fall 来创建一个名为 Fall 的触发器。

  5. 按以下步骤创建等待触发器的过渡:

    • 动画面板中,选择过渡

    • 检查器面板中,取消选中 Has Exit Time 选项

    • 将过渡持续时间设置为 3.0(这样水块将在 2 秒内缓慢过渡到其下状态)

    • 检查器面板中,点击+按钮添加一个条件,它应该自动建议唯一的可能条件参数,即我们的触发下落:

图片

设置过渡持续时间的另一种方法是,在检查器中过渡设置提供的动画时间轴上拖动过渡结束时间到 3:00 秒。

  1. 我们需要在水块上方添加一个 Collider 触发器,并为玩家进入 Collider 时发送 Animator Controller 触发器消息添加一个 C#脚本类行为。

  2. 确保选中子 Water Block GameObject,添加一个(第二个)2D Box Collider,Y 偏移量为 1,并勾选其 Is Trigger 复选框:

图片

  1. 创建一个名为 WaterBlock 的 C#脚本类,并将实例对象作为组件添加到子 Water Block GameObject:
    using UnityEngine;
     using System.Collections;

     public class WaterBlock : MonoBehaviour {
       private Animator animatorController;

       void Start(){
         animatorController = GetComponent<Animator>();
       }

       void OnTriggerEnter2D(Collider2D hit){
         if(hit.CompareTag("Player")){
           animatorController.SetTrigger("Fall");
         }
       }
     } 
  1. 将 water-block-container GameObject 复制 6 次,每次 X 位置增加 1,即 3.5、4.5、5.5 等等。

  2. 运行场景,当玩家的角色跑过每个水块时,它们将开始下落,所以他最好继续跑!

它是如何工作的...

您创建了一个两状态 Animator Controller 状态机。每个状态都是一个动画剪辑。您创建了一个从水块上状态到其下状态的过渡,当 Animator Controller 接收到下落触发器消息时将发生此过渡。您创建了一个带有触发器的 Box Collider 2D,以便当玩家(标记为 Player)进入其 Collider 时,可以检测到脚本 WaterBlock 组件,并在此点发送下落触发器消息,使水块 GameObject 开始逐渐过渡到屏幕下方的下状态。

在 Unity Manual 网页上了解更多关于 Animator Controllers 的信息:docs.unity3d.com/Manual/class-AnimatorController.html

从精灵图集序列创建动画剪辑

传统的动画方法涉及手工绘制许多略有不同的图像,这些图像快速逐帧显示,以产生运动的外观。对于计算机游戏动画,Sprite Sheet 这个术语用于包含一个或多个精灵帧序列的图像文件。Unity 提供工具将单个精灵图像拆分成大型的 Sprite Sheet 文件,以便可以使用单个帧或帧的子序列来创建动画剪辑,这些动画剪辑可以成为 Animator Controller 状态机中的状态。在这个菜谱中,我们将导入并拆分一个开源怪物精灵图集到三个动画剪辑中,用于空闲、攻击和死亡,看起来如下所示:

图片

准备中

对于本章中的所有食谱,我们已经在文件夹 08_04 中准备了所需的精灵图像。感谢 Rosswet Mobile 使这些精灵以 开源 的形式在 www.rosswet.com/wp/?p=156 可用。

如何做到这一点...

要从逐帧动画图像的精灵图中创建动画,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 导入提供的图像:monster1。

  3. 项目 面板中选中 monster1 图像,在 检查器 中将其 Sprite 模式更改为 Multiple,然后点击面板底部的 Apply 按钮:

  1. 检查器 中,通过点击 Sprite Editor 按钮打开 Sprite 编辑器面板。

  2. 在 Sprite 编辑器中,打开 Slice 下拉对话框,将类型设置为 Grid,将网格像素大小设置为 64x64,然后点击 Slice 按钮。对于类型,选择 Grid by CellSize,并将 X 和 Y 设置为 64。点击 Slice 按钮,然后在 Sprite 编辑器面板右上方的栏中点击 Apply 按钮:

  1. 项目 面板中,你现在可以点击精灵右侧的展开三角形按钮,你会看到这个精灵的所有不同子帧(如下面的截图所示):

  1. 创建一个名为 Animations 的文件夹。

  2. 在你的新文件夹中,创建一个名为 monster-animator 的动画控制器资产文件,选择 项目 面板菜单:创建 | 动画控制器。

  3. 在场景中,创建一个名为 monster1(位置为 0, 0, 0)的新空 GameObject,并将你的怪物动画器拖拽到这个 GameObject 上。

  4. 层次结构 中选中 monster1 GameObject,打开 动画 面板,创建一个名为 monster1-idle 的新动画剪辑。

  5. 项目 面板中选中 monster1 图像(在其展开视图中),选择并拖动前 5 帧(monster1_0 到 monster1_4)到 动画 面板。将样本率更改为 12(因为此动画是为每秒 12 帧而创建的):

  1. 如果你查看 monster-animator 的状态图,你会看到它有一个默认状态(剪辑)名为 monster-idle。

  2. 当你运行你的 场景 时,你现在应该看到 monster1 GameObject 在其 monster-idle 状态中动画。你可能希望将 主摄像机 的大小缩小一点(大小 1),因为这些精灵相当小:

它是如何工作的...

Unity 的 Sprite 编辑器了解 Sprite Sheets,一旦输入了正确的网格大小,它就会将 Sprite Sheet 图像中每个网格方格内的项目视为单个图像或动画的帧。您选择了精灵动画帧的子序列,并将它们添加到几个动画剪辑中。您为您的人物对象添加了动画控制器,因此每个动画剪辑都作为动画控制器状态机中的一个状态出现。

您现在可以重复此过程,创建一个动画 Clipmonster-attack,包含帧 8-12,以及第三个动画 clip monster-death,包含帧 15-21。然后,您将创建触发器和转换,使怪物 GameObject 在游戏进行时过渡到适当的状态。

在 Unity 视频教程中了解更多关于 Unity Sprite 编辑器的信息:unity3d.com/learn/tutorials/modules/beginner/2d/sprite-editor

在 John Horton 在 GameCodeOldSchool.com 上的一篇文章中了解更多关于使用 Sprite Sheets 进行 2D 动画的信息:gamecodeschool.com/unity/simple-2d-sprite-sheet-animations-in-unity/

使用 Tile 和 Tilemaps 创建*台游戏

Unity 引入的强大 2D 工具之一是 Tilemapper。在这个菜谱中,我们将创建一个简单的 2D *台游戏,使用一些免费的 Tile Sprite图像构建基于网格的场景

图片

准备工作

对于这个菜谱,我们在文件夹08_07中准备了所需的 Unity 包和图像。

特别感谢 GameArt2D.com 发布带有 Creative Commons Zero 许可的 Desert 图像 Sprites:www.gameart2d.com/free-desert-platformer-tileset.html

如何做到这一点...

要使用 Tile 和 Tilemaps 创建*台游戏,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 导入提供的图像。

  3. 我们在这个菜谱中使用的 Tile Sprites 大小为 128 x 128 像素。确保我们将每单位像素设置为 128,这样我们的 Sprite 图像就会映射到 1 x 1 Unity 单位的网格。选择 Project | DesertTilePack | Tile 文件夹中的所有 Sprites,并在检查器中将每单位像素设置为 128:

图片

  1. 显示Til****e Palette面板,通过选择菜单:窗口 | 2D | Tile Palette。

  2. 项目面板中,创建一个名为 Palettes 的新文件夹(这是保存你的 TilePalette 资产的地方)。

  3. 在 Tile Palette 面板中点击创建新调色板按钮,并创建一个名为 DesertPalette 的新 Tile Palette:

图片

  1. 项目面板中,创建一个名为 Tiles 的新文件夹(这是保存你的 Tile 资产的地方)。

  2. 确保在 Tile Palette 面板中选择了 Tile Palette DesertPalette,选择 Project | DesertTilePack | Tile 文件夹中的所有 Sprite,并将它们拖动到 Tile Palette 面板中。当被问及保存这些新的 Tile 资产文件的位置时,选择你的新 Assets | Tiles 文件夹。现在你应该在你的 Tiles 文件夹中有 16 个 Tile 资产,并且这些 Tile 应该在Tile Palette面板中的 DesertPalette 中可用:

图片

  1. 从项目面板拖动 Sprite BG 到场景中,DesertTilePack,然后调整主摄像机的大小(由于这是一个 2D 项目,它应该是正交的),以便沙漠背景填满整个游戏面板。

  2. 场景中添加一个 Tilemap GameObject,选择创建菜单:2D Object | Tilemap。你会看到一个 Grid GameObject 被添加,并且作为它的子对象,你会看到一个 Tilemap GameObject。将 Tilemap GameObject 重命名为 Tilemap-platforms:

图片

正如 UI GameObjects 是 Canvas 的子对象一样,Tilemap GameObjects 是 Grid 的子对象。

  1. 我们现在可以开始在 Tilemap 上绘制Tile 了。确保在层次结构中选择 Tilemap-platforms,并且你可以看到 Tile Palette 面板。在 Tile Palette 面板中,选择使用活动刷子工具(画笔图标)。现在点击 Tile Palette 面板中的一个 Tile,然后在场景面板中,每次点击鼠标按钮你都会向 Tilemap-platforms 添加一个 Tile,并自动与网格对齐:

图片

  1. 如果你想要删除一个 Tile,使用 Shift-Click 在该网格位置上。

  2. 使用 Tile Palette 刷子绘制两个或三个*台。

  3. 向 Tilemap-platforms GameObject 添加一个合适的 Collider。在层次结构中选择 Tilemap-platforms GameObject,在检查器中添加一个 Tilemap Collider 2D。点击添加组件,然后选择 Tilemap | Tilemap Collider 2D。

  4. 创建一个名为 Ground 的新层,并将 Tilemap-platforms GameObject 设置在这个层上(这将允许角色在*台上站立时跳跃)。

  5. 让我们用二维角色来测试我们的场景*台——我们可以重用 Unity 免费教程中的 potatoman 角色。将提供的 PotatoManAssets 包导入到你的项目中。

  6. 由于 potatoman 角色的尺寸相对于*台较大,我们需要为这个项目设置 2D 重力设置。我们将通过设置 Y= -60 的重力设置来使角色移动缓慢。通过选择菜单:编辑 | 项目设置 | 物理设置 2D,然后在顶部将 Y 值更改为-60 来设置 2D 重力。

  7. 从项目 | 预制体文件夹中将 potatoman 英雄角色 2D 的一个实例拖动到场景中。将他定位在你其中一个*台上方。

  8. 播放场景。2D 英雄角色应该会掉落并落在*台上。你应该能够左右移动角色,并使用空格键使他跳跃。

  9. 您可能希望通过将一些对象精灵拖放到场景(在项目面板文件夹中,项目 | DesertTilePack | 对象)来装饰场景。

它是如何工作的...

通过拥有一组所有都是常规大小(128 x 128)的*台精灵,可以直接从这些精灵中创建一个 Tile 调色板,然后添加一个网格和 Tilemap 到场景中,使得 Tile 调色板笔可以绘制 Tile 到场景中。

您需要将精灵的像素每单位设置为 128,这样每个 Tile 就映射到 1 x 1 的 Unity 网格单位。

您已将 Tilemap Collider 2D 添加到 Tilemap GameObject 中,以便角色(如 potatoman)可以与*台交互。通过添加一个 Layer Ground,并将 Tilemap GameObject 设置为这个层,potatoman 角色控制器脚本中的跳跃代码可以测试被站立在上面的对象的层,这样跳跃动作只有在站在*台 Tile 上时才可能发生。

更多内容...

这里有一些增强这个食谱的建议。

对象和墙壁的 Tile 调色板

在沙漠免费包中的对象精灵大小各不相同,并且肯定与*台 Tile 的 128 x 128 精灵大小不一致。

然而,如果您的游戏中的对象和墙壁精灵与*台精灵大小相同,您可以为您的对象创建一个 Tile 调色板,并使用 Tile 调色板笔将它们绘制到场景中。

智能 Tile 选择规则 Tile

如果您探索 2D Extras Pack 或 2D GameKit(见下一个食谱),您将了解规则 Tile。这些允许您根据 Tile 的邻居定义关于 Tile 选择的规定。例如,您不会立即在另一个*台 Tile 的顶部放置一个*台顶 Tile,因此规则 Tile 会在*台顶 Tile 下方放置某种地面 Tile。规则可以确保组中最左端和最右端的 Tile 选择边缘的艺术品 Tile,等等。

在这个 Unity 现场培训视频会议中可以找到关于 Rule Times 的良好介绍:unity3d.com/learn/tutorials/topics/2d-game-creation/using-rule-tiles-tilemap

学习更多

这里有一些关于 Tilemapping 的学习资源:

使用 2D Gamekit 创建游戏

将一系列 Unity 2D 工具组合在一起,成为 Unity 2D GameKit。在本食谱中,我们将创建一个简单的 2D *台游戏,以探索 2D GameKit 提供的一些功能,包括压力板、门和掉落物体伤害敌人:

准备工作

本食谱使用免费的 Unity Asset Store 和包管理器包。

如何操作...

要使用2D GameKit创建游戏,请按照以下步骤操作:

  1. 创建一个新的 Unity 2D 项目。

  2. 使用包管理器安装 Cinemachine 和后处理包(如果已安装,下载 2D GameKit 时会出现错误)。

  3. 从资产商店导入 2D GameKit(免费来自 Unity Technologies)。

  4. 关闭并重新打开 Unity 编辑器。

  5. 通过选择菜单:工具套件 | 创建新场景 创建一个新的 2D GameKit场景。然后您将被要求命名场景,并在您的项目 | 资产文件夹中创建一个新的场景资产文件。您会看到在您新场景层次结构中有很多特殊游戏对象:

  1. 如您所见,新的场景一开始包含一个动画 2D 角色(艾伦),以及一个小*台。

  2. 检查器中,选择 TilemapGrid 游戏对象的 Tilemap 子对象——我们正在为这个 Tilemap 游戏对象绘制一些瓦片。

  3. 显示瓦片调色板,选择菜单:窗口 | 2D | 瓦片调色板。选择 TilesetGameKit,然后点击绿色顶部的草地*台瓦片。选择使用活动画笔工具(画笔图标)进行绘制。

  4. 开始在场景上绘制草地顶部的*台。这是一个规则瓦片,因此它巧妙地确保只有接触组中顶部的瓦片被绘制为草地顶部的瓦片。其他接触的瓦片(左/右/下)被绘制为棕色、土质的瓦片。

  5. 创建一个宽阔*坦的区域,然后在艾伦开始的地方右边,创建一个非常高的土墙,对于艾伦来说太高了,跳不过去。

  6. 在艾伦和地球墙壁之间添加四个尖刺,这样她尝试跳过它们时会受伤。从 2DGameKit | 预制件 | 环境 项目文件夹中拖动尖刺预制件的实例。

  7. 为了使事情更加困难,在尖刺和土墙之间添加一个 Chomper 敌人!从 2DGameKit | 预制件 | 敌人 项目文件夹中拖动 Chomper 预制件的实例:

  1. 我们必须给艾伦一些绕过土墙的方法,避免尖刺和 Chomper 障碍物。让我们在艾伦开始的地方左边添加一个传送门。从 2DGameKit | 预制件 | 交互式 项目文件夹中拖动传送门预制件的实例。

  2. 让我们使用自定义 Sprite 创建一个传送器的目的地点。将敌人虫子 Sprite 导入此项目,并将实例从项目面板拖入场景——在地球墙的右侧某个位置。

  3. 传送器需要在目标 GameObject 中有一个转换点组件。向敌人虫子添加一个 Collider 2D,选择添加组件 | 物理引擎 2D | 矩形碰撞器 2D。检查其触发器选项。

  4. 向敌人虫子添加一个转换点组件,选择添加组件,搜索转换,然后添加转换点。

  5. 我们现在可以设置传送器。在层次结构中选择传送器,在检查器的转换点(脚本)组件中,执行以下操作:

    • 转换游戏对象:将艾伦拖入此槽位

    • 转换类型:从下拉菜单中选择同一场景

    • 目的地变换:将敌人虫子拖入此转换点槽位

    • 转换时机:从下拉菜单中选择触发器进入时

图片

  1. 运行场景。艾伦可以通过使用传送器安全地避开尖刺和咬合者。

  2. 让我们让它更有趣一些——让传送器 GameObject 最初处于非活动状态(不可见或无法交互),并添加一个艾伦必须按下的开关来激活传送器。

  3. 层次结构中选择传送器 GameObject,并在检查器的左上角取消其活动框——GameObject 应该是不可见的,并在层次结构中显示为灰色。

  4. 在游戏中添加一个单次使用的开关,位于艾伦开始的地方左侧。从 2DGameKit | Prefabs | 交互式项目文件夹中拖动单次使用开关的一个实例。

  5. 层次结构中选择单次使用开关,在检查器中设置以下内容:

    • 层:将玩家层添加到可交互层(这样开关可以通过玩家碰撞或射击子弹来启用)

    • 进入时:将传送器拖入一个仅运行时 GameObject 槽位,并将动作下拉菜单从无功能更改为 GameObject | 设置活动(布尔值),然后检查出现的复选框。

  6. 运行场景。艾伦现在必须走到开关那里,以揭示传送器,然后它将她安全地运输到敌人虫子位置,越过地球墙,远离危险。

它是如何工作的...

我们已经涉猎了 2D GameKit 广泛的功能范围。希望这个配方能给你一个如何使用提供的 Prefab 以及如何探索如何使用适当添加的组件来创建自己的 GameObject 使用 2D GameKit 功能的想法。

如果你看看艾伦 2D 角色,你会看到一些脚本组件,它们管理着角色与 2D GameKit 的交互。这些包括:

  • 2D 角色控制器:移动和物理交互

  • 玩家输入:键盘/输入控制映射,以便您可以更改哪些键/控制器按钮控制移动、跳跃等

  • 玩家角色:角色如何与 2D 游戏工具包交互,包括战斗(*战)、伤害和子弹池

在参考指南中了解更多关于艾伦及其组件的信息:unity3d.com/learn/tutorials/projects/2d-game-kit/ellen?playlist=49633.

还有更多...

这里有一些关于 Unity 2D 游戏工具包的学习资源:

第十章:3D 动画

在本章中,我们将涵盖以下内容:

  • 配置角色的 Avatar 和空闲动画

  • 使用根运动和混合树移动你的角色

  • 使用层和遮罩混合动画

  • 将状态组织到子状态机中

  • 通过脚本转换角色控制器

  • 向动画角色添加刚体道具

  • 使用动画事件抛出对象

  • 将布娃娃物理应用到角色上

  • 将角色的身体旋转以瞄准武器

  • 使用 Probuilder 创建几何体

  • 使用 3D Gamekit 创建游戏

  • 从 Mixamo 导入第三方 3D 模型和动画

简介

Mecanim 动画系统彻底改变了在 Unity 中对角色进行动画和控制的模式。在本章中,我们将学习如何利用其灵活性、强大功能和友好且高度可视化的界面。

整体情况

使用 Mecanim 系统控制可玩角色可能看起来是一个复杂任务,但实际上非常直接:

到本章结束时,你将获得对 Mecanim 系统的基本理解。为了更全面地了解这个主题,可以考虑阅读 Jamie Dean 的 Unity Character Animation with Mecanim,由 Packt Publishing 出版。

所有食谱都将使用 Mixamo 动作包。Mixamo 是一个完整的角色制作、绑定和动画解决方案。实际上,所使用的角色是用 Mixamo 的角色创建软件 Fuse 设计的,并使用 Mixamo Auto-Rigger 进行绑定。你可以在 Unity 的 Asset Store 或 Mixamo 网站上了解更多关于 Mixamo 和他们的产品:

assetstore.unity.com/packages/3d/animations/melee-axe-pack-35320

www.mixamo.com/

请注意,尽管 Mixamo 提供了 Mecanim-兼容的角色和动画剪辑,但对于本章中的食谱,我们将使用未准备好的动画剪辑。这样做的原因是为了让你在处理通过其他方法和来源获得的资产时更有信心。

配置角色的 Avatar 和空闲动画

使 Mecanim 非常灵活和强大的一个特性是能够快速将动画剪辑从一个角色重新分配到另一个角色。这是通过使用 Avatar 实现的,它基本上是在你的角色的原始绑定和 Unity 的 Animator 系统之间的一层。

在这个食谱中,我们将学习如何在绑定的角色上配置 Avatar 骨架。

准备工作

对于这个食谱,你需要 MsLaser@T-Pose.fbx 和 Swat@rifle_aiming_idle.fbx 文件,这些文件位于 09_03 文件夹中。

如何做到这一点...

要配置一个 Avatar 骨架,请按照以下步骤操作:

  1. 将 MsLaser@T-Pose.fbx 和 Swat@rifle_aiming_idle.fbx 文件导入到你的项目中。

  2. 从项目面板中选择 MsLaser@T-Pose 模型。

  3. 在检查器中,在 MsLaser@T-Pose 导入设置下,激活“机械臂”部分。将动画类型更改为“类人”。然后,将“头像定义”保留为“从该模型创建”。现在,点击“应用”以应用这些设置。最后,点击“配置...”按钮:

图片

  1. 检查器将显示新创建的头像。观察 Unity 如何正确地将我们角色的骨骼映射到其结构中,例如,将 mixamoRig:LeftForeArm 骨分配为头像的下臂。当然,如果需要,我们可以重新分配骨骼。现在,只需点击“完成”按钮来关闭视图:

图片

  1. 现在我们已经准备好了头像,让我们为空闲状态配置动画。从项目面板中选择 Swat@rifle_aiming_idle 文件。

  2. 选择“机械臂”部分,将动画类型更改为“类人”,并将“头像定义”保留为“从该模型创建”。通过点击“应用”来确认。

  3. 选择“动画”部分(位于“机械臂”右侧)。应选择 rifle_aiming_idle 剪辑。将 MsLaser@T-Pose 拖动到检查器底部的预览区域:

图片

  1. 在剪辑列表中选择 rifle_aiming_idle,检查“循环时间”和“循环姿态”选项。然后,点击“限制范围”按钮以调整时间轴到动画剪辑的实际时间。接着,在“根变换旋转”下,检查“烘焙到姿态”并选择“基于 | 原始”。在“根变换位置(Y)”下,检查“烘焙到姿态”并选择“基于原始”。在“根变换位置(XZ)”下,不检查“烘焙到姿态”并选择“基于(在开始时) | 重心”。最后,点击“应用”以确认更改:

图片

  1. 为了访问动画剪辑并播放它们,我们需要创建一个控制器。通过选择项目面板菜单:创建 | 动画控制器。将其命名为 MainCharacter。

  2. 双击“动画控制器”以打开“动画器”面板。

  3. 动画器面板中,右键单击网格以打开上下文菜单。然后,选择“创建状态 | 空白”选项。将出现一个名为“新状态”的新框,它将呈橙色,表示它是默认状态:

图片

  1. 选择“新状态”,在检查器中将其名称更改为“空闲”。此外,在“动作”字段中,通过从列表中选择或从项目面板拖动来选择 rifle_aiming_idle:

图片

  1. 将 MsLaser@T-Pose 模型从项目面板拖动到层次结构中,并将其放置在场景中。

  2. 从层次结构中选择 MsLaser@T-Pose 并观察检查器中的动画器组件。然后,将新创建的 MainCharacter 控制器分配到其控制器字段:

图片

  1. 播放您的场景以查看角色是否正确动画化。

它是如何工作的...

为动画准备我们的角色需要许多步骤。首先,我们根据角色模型的原始骨骼结构创建了其 Avatar。然后,我们使用其自己的 Avatar 设置了动画片段(与角色网格一样,存储在.fbx 文件中)。

之后,我们调整了动画片段,限制了其大小并使其循环。我们还烘焙了其根变换旋转以跟随原始文件的朝向。最后,创建了一个动画控制器,并将编辑后的动画片段设置为默认动画状态。

Avatar 的概念使得 Mecanim 非常灵活。一旦你有了动画控制器,你就可以将其应用于其他具有 Avatar 身体蒙版的类人角色。

还有更多...

这里有一些方法可以进一步扩展这个配方。

使用控制器与另一个 3D 角色 Avatar

让我们用 Mascot 3D 角色替换 MsLaser。执行以下操作:

  1. 导入提供的模型,mascot.fbx。

  2. 为此角色应用步骤 3 和 4 以创建其 Avatar。

  3. 将模型实例拖入场景中。

  4. 在其动画组件的检查器中,将控制器设置为你在本配方中创建的 MainCharacter Animator Controller。

  5. 运行场景;你应该看到吉祥物正在播放 rifle_aiming_idle 动画片段。

参见

要了解更多关于动画控制器(Animator Controller)的信息,请查看 Unity 的文档:docs.unity3d.com/Manual/class-AnimatorController.html

使用根运动和混合树(Blend Trees)移动你的角色

Mecanim 动画系统能够将根运动(Root Motion)应用于角色。换句话说,它实际上根据动画片段移动角色,而不是在播放原地动画循环时任意*移角色模型。这使得 Mixamo 的大多数动画片段非常适合与 Mecanim 一起使用。

动画系统的另一个特性是混合树,它可以*滑且容易地混合动画片段。在这个配方中,我们将利用这些特性使我们的角色能够向前和向后行走/奔跑,以及在不同速度下向右和向左横移。

准备工作

对于这个配方,我们准备了一个名为 Character_02 的 Unity 包,其中包含一个角色和一个基本的动画控制器。该包位于09_02文件夹中,同时包含了所需动画片段的 FBX 文件。

在 Unity 中导入动画有两种方法。一种方法是将每个动画作为一个单独的文件导入,文件名采用modelName@animation的形式,例如MsLazer@idleMsLazer@jumping等。另一种方法是一个模型包含多个动画,所有动画都在一个动作中,在这种情况下,您可以在 Unity 编辑器中将动作分解成单独的命名动画剪辑,为每个剪辑指定起始和结束帧。在本章中,我们将使用第一种方法,因为它更直接。更多内容请参阅 Unity 文档docs.unity3d.com/Manual/Splittinganimations.html

如何操作...

要使用 Blend Trees 将根运动应用到角色上,请按照以下步骤操作:

  1. 将 Character_02.unityPackage 导入到新项目中。同时,导入以下 FBX 文件:

    • Swat@rifle_run

    • Swat@run_backward

    • Swat@strafe

    • Swat@strafe_2

    • Swat@strafe_left

    • Swat@strafe_right

    • Swat@walking

    • Swat@walking_backward

  2. 我们需要配置我们的动画剪辑。从项目面板中选择 Swat@rifle_run。

  3. 激活“机械”部分。将动画类型更改为“人类”并将“头像定义”更改为“从该模型创建”。通过点击“应用”进行确认:

  1. 现在,激活“动画”部分(位于“机械”右侧)。选择“clip_rifle_run”。在检查器底部的预览区域将显示消息“没有模型可供预览”。将 MsLaser@T-Pose 拖动到预览区域。

  2. 在项目面板中仍然选择 Swat@rifle_run 资产,在检查器中勾选 Loop Time 和 Loop Pose 选项。然后,点击“限制范围”按钮调整时间轴到动画剪辑的实际时间。点击“应用”以确认这些更改:

  1. 然后,在“根变换旋转”下,勾选“烘焙到姿态”并选择“基于 | 原始”。在“根变换位置(Y)”下,勾选“烘焙到姿态”并选择“基于(在开始时) | 原始”。在“根变换位置(XZ)”下,不勾选“烘焙到姿态”并选择“基于(在开始时) | 重心”。最后,点击“应用”以确认更改:

  1. 对以下每个动画剪辑重复步骤 3 到 6:Swat@run_backward、Swat@strafe、Swat@strafe_2、Swat@strafe_left、Swat@strafe_right、Swat@walking 和 Swat@walking_backward。

  2. 场景中添加一个 3D *面,选择菜单:创建 | 3D 对象 | *面。

  3. 将 MsLaser 预制体的实例拖动到场景中,并将其放置在 3D *面上。

  4. 从层次结构面板中选择 MsLaser GameObject。在检查器中,向其添加一个 Character Controller 组件(点击添加组件并选择组件 | 物理 | Character Controller)。然后,将其 Skin Width 设置为 0.0001,将其 Center 设置为(0,0.9,0);也将其 Radius 设置为 0.34,将其 Height 设置为 1.79:

  1. 在项目面板中,双击 MainCharacter 控制器资产文件;它应该在动画器面板中打开。

  2. 动画器面板的右上角,选择参数部分,并使用加号创建三个新的参数(浮点数)命名为 xSpeed、zSpeed 和 Speed:

图片

  1. 我们确实为我们的角色有一个空闲状态,但我们还需要新的状态。右键单击网格区域,从上下文菜单中导航到创建状态 | 从新混合树。在检查器中,将名称从默认混合树更改为移动:

图片

  1. 双击移动状态。您将看到您创建的空混合树。选择它,在检查器中将其重命名为移动。然后,将其混合类型更改为 2D 自由形式方向,也在参数选项卡中设置 xSpeed 和 zSpeed。最后,使用运动列表底部的加号,添加九个新的运动字段:

图片

  1. 现在,使用以下动作剪辑和相应的 Pos X 和 Pos Y 值填充运动列表:Run_backwards, 0, -1;Walking_backwards, 0,-0.5;Rifle_aiming_idle, 0, 0;Walking, 0, 0.5;Rifle_run, 0, 1;Strafe, -1, 0;Strafe_left, -0.5, 0;Strafe_right, 0.5, 0;Strafe_2, 1, 0。您可以通过从列表中选择它来填充运动列表,或者如果有多个具有相同名称的剪辑,您可以通过展开相应的模型图标,将其从项目面板拖动到槽中:

图片

  1. 要返回到基础层,要么双击动画器面板的网格背景,要么单击面板顶部的信息栏中的基础层按钮:

图片

  1. 由于我们在移动混合树中有 rifle_aiming_idle 动作剪辑,我们可以删除原始的空闲状态。右键单击空闲状态框,从菜单中选择删除。移动混合状态将变为新的默认状态,变为橙色。

  2. 现在,我们必须创建一个脚本类,它将实际将玩家的输入转换为创建来控制动画的变量。创建一个新的 C#脚本类名为 BasicController,并将实例对象作为 MsLazer 游戏对象的组件添加:

    using UnityEngine;
     using System.Collections;

     public class BasicController: MonoBehaviour {
       private Animator anim;
       private CharacterController controller;
       public float transitionTime = .25f;
       private float speedLimit = 1.0f;
       public bool moveDiagonally = true;
       public bool mouseRotate = true;
       public bool keyboardRotate = false;

       void Start () {
         controller = GetComponent<CharacterController>();
         anim = GetComponent<Animator>();
       }

       void Update () {
         if(controller.isGrounded){
           if (Input.GetKey (KeyCode.RightShift) ||Input.GetKey (KeyCode.LeftShift))
             speedLimit = 0.5f;
           else
             speedLimit = 1.0f;

           float h = Input.GetAxis("Horizontal");
           float v = Input.GetAxis("Vertical");
           float xSpeed = h * speedLimit;
           float zSpeed = v * speedLimit;
           float speed = Mathf.Sqrt(h*h+v*v);

           if(v!=0 && !moveDiagonally)
             xSpeed = 0;

           if(v!=0 && keyboardRotate)
             this.transform.Rotate(Vector3.up * h, Space.World);

           if(mouseRotate)
             this.transform.Rotate(Vector3.up * (Input.GetAxis("Mouse X")) * Mathf.Sign(v), 
                                                                             Space.World);

           anim.SetFloat("zSpeed", zSpeed, transitionTime, Time.deltaTime);
           anim.SetFloat("xSpeed", xSpeed, transitionTime, Time.deltaTime);
           anim.SetFloat("Speed", speed, transitionTime, Time.deltaTime);
         }
       }
     }
  1. 播放您的场景并测试游戏。您应该能够使用箭头键(或WASD键)控制您的角色。按住Shift键会减慢速度。

它是如何工作的...

当基本控制器(BasicController)脚本检测到任何方向键被使用时,它会将动画器状态(Animator state)的 Speed 变量设置为大于 0 的值,将动画器状态从 Idle 变为 Move。Move 状态反过来会根据 xSpeed(从水*轴输入获得,通常是AD键)和 zSpeed(从垂直轴输入获得,通常是WS键)的输入值混合它所填充的运动剪辑。由于Mecanim能够将根运动应用于角色,我们的角色实际上会沿着结果方向移动。

例如,如果按下WD键,xSpeed 和 zSpeed 的值将上升到 1.0。从检查器中可以看到,这种组合将导致名为 rifle_run 和 strafe_2 的运动剪辑之间的混合,使角色以对角线方向(向前和向右)奔跑:

图片

我们的基本控制器(BasicController)包括三个复选框以提供更多选项:对角移动(Move Diagonally),默认设置为 true,允许在前后和左右剪辑之间混合;鼠标旋转(Mouse Rotate),默认设置为 true,允许使用鼠标旋转角色,在移动时改变其方向;以及键盘旋转(Keyboard Rotate),默认设置为 false,允许通过同时使用左右和前后方向键来旋转角色。

还有更多...

这里是关于这些主题的更多信息来源。

我们使用的混合树使用了 2D 自由形式方向混合类型。然而,如果我们只有四个动画剪辑(前进、后退、左转和右转),2D 简单方向混合将是一个更好的选择。

从 Unity 的文档中了解更多关于混合树和 2D 混合的信息:

此外,如果您想了解更多关于Mecanim动画系统的信息,有一些链接您可能想查看,例如 Unity 的文档:

Mecanim示例场景可以从 Unity 资源商店获取:

Mecanim视频教程在此处提供:

使用层和遮罩混合动画

混合动画是增加动画角色复杂性的好方法,而不需要大量动画剪辑。使用层和遮罩,我们可以通过播放角色的特定身体部分的特定剪辑来组合不同的动画。

在这个菜谱中,我们将应用这项技术到我们的动画角色上,通过角色的上半身触发开枪和投掷手榴弹的动画片段。我们将根据玩家的输入,在保持下半身移动或空闲的同时完成这些操作。

准备工作

对于这个菜谱,我们准备了一个名为 Mixing 的 Unity 包,其中包含一个具有动画角色的基本场景。我们还提供了 FBX 动画片段 Swat@firing_rifle.fbx 和 Swat@toss_grenade.fbx。这些文件可以在09_03文件夹中找到。

如何操作...

要使用层和遮罩混合动画,请按照以下步骤操作:

  1. 创建一个新的 3D 项目并导入 Mixing Unity 包,以及 FBX 文件 Swat@firing_rifle.fbx 和 Swat@toss_grenade.fbx。

  2. 然后,从项目面板中打开 mechanimPlayground 级别。

  3. 我们需要配置动画片段。在项目面板中选择 Swat@firing_rifle 资产。

  4. 选择绑定部分。将动画类型更改为 Humanoid,并将头像定义更改为从此模型创建。通过点击应用确认这一更改:

图片

  1. 现在,激活动画部分。选择firing_rifle片段;点击限制范围按钮调整时间轴并勾选循环时间循环姿态选项。在根变换旋转下,勾选烘焙到姿态并选择基于 | 原始。在根变换位置Y)下,勾选烘焙到姿态并选择基于(在开始时)| 原始。在根变换位置XZ)下,不勾选烘焙到姿态。点击应用以确认更改:

图片

  1. 选择Swat@toss_grenade动画片段。选择绑定部分。然后,将动画类型更改为 Humanoid 并将头像定义更改为此模型创建。通过点击应用确认。

    1. 现在,激活动画部分。选择toss_grenade动画片段。点击限制范围按钮调整时间轴,并取消选中循环时间循环姿态选项。在根变换旋转下,勾选烘焙到姿态并选择基于 | 原始。在根变换位置Y)下,勾选烘焙到姿态并选择基于(在开始时)| 原始。在根变换位置(XZ)下,不勾选烘焙到姿态。点击应用以确认更改。
  2. 让我们创建一个遮罩。从项目面板中,点击创建按钮并向项目添加一个 Avatar Mask。将其命名为 BodyMask。

  3. 选择BodyMask标签,并在检查器中展开 Humanoid 部分。绿色身体部分和 IK 点被选中(默认全部选中),红色未被选中。取消选中角色的两条腿、脚下的圆形底座以及两个脚的 IK 点(它们应该变成红色):

图片

  1. 项目面板中,双击MainCharacter控制器资产文件;它应该在动画器面板中打开。

  2. 动画器面板中,通过点击左上角的标签中的+号创建一个新层,位于基础层之上。

  3. 将新层命名为layerUpperBody并点击设置图标。然后,将其权重设置为 1 并在遮罩槽中选择BodyMask。同时,将混合模式更改为叠加:

图片

  1. 现在,在动画器面板中,选择UpperBody层,通过右键点击网格区域并从菜单中选择创建状态|空创建三个新的空状态。将默认(橙色)状态命名为 null,其他两个命名为 Fire 和 Grenade。

  2. 现在,访问参数选项卡并添加两个类型为 Bool 的新参数,FireGrenade

图片

  1. 选择 Fire 状态,并在检查器中,将 firing_rifle 动画剪辑添加到运动字段:

图片

  1. 现在,选择手榴弹状态,并在检查器中,将toss_grenade动画剪辑添加到运动字段。

  2. 右键点击 null 状态框,从菜单中选择创建过渡。然后,将白色箭头拖到 Fire 框上。

  3. 选择箭头(它将变为蓝色)。从检查器中,取消勾选有退出时间选项。然后,访问条件列表,点击+号添加一个新条件,并将其设置为触发和为真:

图片

  1. 现在,从 null 状态到手榴弹状态创建一个过渡。选择箭头(它将变为蓝色)。从检查器中,取消勾选有退出时间选项。然后,访问条件列表,点击+号添加一个新条件,并将其设置为手榴弹和为真。

  2. 现在,从 Fire 到 null 和从手榴弹到 null 创建过渡。然后,选择从 Fire 到 null 的箭头,并在条件框中选择 Fire 和false选项。保留有退出时间选项的勾选。

  3. 最后,选择从手榴弹到 null 的过渡箭头。在条件框中,选择手榴弹false选项。保留有退出时间选项的勾选。参见截图以了解这些设置选项:

图片

  1. 在代码编辑器(在项目文件夹Scripts中)中打开BasicController C#脚本类。在Update()函数的末尾之前立即添加以下代码:
if(Input.GetKeyDown(KeyCode.F)){
    anim.SetBool("Grenade", true);
} else {
    anim.SetBool("Grenade", false);
}

if(Input.GetButtonDown("Fire1")){
    anim.SetBool("Fire", true);
}

if(Input.GetButtonUp("Fire1")){
    anim.SetBool("Fire", false);
}
  1. 保存脚本并播放场景。您可以通过点击火按钮并按F键来触发firing_rifletoss_grenade动画。观察角色的腿部仍然响应移动动画状态,这样角色在投掷手榴弹时可以继续向后走。

它是如何工作的...

一旦创建了 Avatar 遮罩,它就可以用作过滤实际播放特定层动画状态的身体部分的方式。在我们的例子中,我们将fire_rifletoss_grenade动画剪辑限制在角色的上半身,使下半身可以自由播放与移动相关的动画剪辑,如行走、跑步和侧滑。

还有更多...

这里有一些进一步使用这个配方的方法。

覆盖与加法混合

你可能已经注意到,UpperBody层有一个名为 Blending 的参数,我们将其设置为 Additive。这意味着此层中的动画状态将添加到来自下层的状态。

如果更改为覆盖,当播放时,此动画将覆盖下层的动画状态。在我们的例子中,Additive 有助于在跑步时保持瞄准稳定。

有关动画层和 Avatar 身体遮罩的更多信息,请查看 Unity 的文档:

将状态组织到子状态机中

动画器面板中的内容过于杂乱时,你总是可以考虑将你的动画状态组织到子状态机中。在这个配方中,我们将使用这种技术来组织旋转角色的动画状态。此外,由于提供的动画剪辑不包括 Root Motion,我们将利用这个机会说明如何通过脚本克服 Root Motion 的不足,使用它将角色向左和向右旋转 45 度:

准备工作

对于这个配方,我们准备了一个名为Turning的 Unity 包,其中包含一个具有动画角色的基本场景。我们还提供了 FBX 动画剪辑Swat@turn_right_45_degrees.fbxSwat@turn_left.fbx.这些文件可以在09_04文件夹中找到。

如何操作...

要将状态组织到子状态机中,请按照以下步骤操作:

  1. 创建一个新的 3D 项目,并导入 Mixing Unity 包,以及 FBX 文件Swat@turn_right_45_degrees.fbxSwat@turn_left.fbx.

  2. 然后,从项目面板中打开mecanimPlayground级别。

  3. 我们需要配置动画剪辑。在项目面板中选择Swat@turn_left资产。

  4. 选择 Rig 部分。将动画类型更改为Humanoid,将Avatar 定义更改为从该模型创建。通过点击应用来确认。

  5. 现在,选择动画部分。选择turn_left剪辑。点击限制范围按钮调整时间轴并检查循环时间选项。在根变换旋转下,检查烘焙到姿态并导航到基于 | 原始。在根变换位置Y)下,检查烘焙到姿态并选择基于(开始) | 原始。在根变换位置(XZ)下,不检查烘焙到姿态。点击应用以确认更改:

图片

  1. Swat@turning_right_45_degrees重复步骤 4 和 5。

  2. 项目面板中,双击MainCharacter控制器资产文件;它应该在动画器面板中打开。

  3. 动画器面板的左上角,选择参数部分,并使用+符号创建两个新的参数(布尔值)命名为TurnLeftTurnRight

  4. 在网格区域右键单击。从上下文菜单中选择创建子状态机。在检查器中,将其重命名为转。

图片

  1. 双击转子状态机。在网格区域右键单击,选择创建状态 | ,并添加一个新状态。将其重命名为向左转。然后,添加另一个名为向右转的状态。

  2. 检查器中,将turn_left动作剪辑填充到向左转。然后,将向右转填充为turning_right_45_degrees

图片

  1. 动画器面板中,返回到基础层(点击此面板顶部的信息栏中的基础层)。

  2. 状态移动创建两个转换,一个进入向左转子状态,另一个进入向右转子状态:

图片

  1. 创建两个返回转换,从向左转返回到移动子状态,以及从向右转子状态返回到移动。通过进入子状态,将转换箭头从向左转向右转拖动到(向上)基础层,并选择状态移动

  2. 选择从向右转到(向上)基础层的转换箭头。它将变为蓝色。在检查器中,取消选中有退出时间选项。然后,访问条件列表,点击+符号添加一个新条件,并将其设置为TurnRightfalse

图片

  1. 选择从(向上)基础层到向右转的箭头。在检查器中,取消选中有退出时间选项。然后,访问条件列表,点击+符号添加一个新条件,并将其设置为TurnRighttrue

  2. 使用向左转作为条件,重复步骤 14 和 15,使用(向上)基础层和向左转之间的箭头。

  3. 在您的代码编辑器中(文件夹项目 | 脚本)打开BasicController C# 脚本类。在if(controller.isGrounded)行之后立即添加以下内容:

if(Input.GetKey(KeyCode.Q)){
    anim.SetBool("TurnLeft", true);
    transform.Rotate(Vector3.up * (Time.deltaTime * -45.0f), 
Space.World);
} else {
    anim.SetBool("TurnLeft", false);
}

if(Input.GetKey(KeyCode.E)){
    anim.SetBool("TurnRight", true);
    transform.Rotate(Vector3.up * (Time.deltaTime * 45.0f), Space.World);
} else {
    anim.SetBool("TurnRight", false);
}
  1. 保存您的脚本类。然后,选择MsLaser角色,并在检查器中,选择基本控制器组件。不检查移动对角线和鼠标旋转选项。同时,检查键盘旋转选项。

  2. 播放场景。您可以通过使用QE键分别向左和向右转动。

它是如何工作的...

从菜谱中可以看出,子状态机的工作方式与组或文件夹类似,允许您将一系列状态机封装成一个单一实体,以便更容易引用。子状态机的状态可以从外部状态转换,在我们的例子中是移动状态,甚至可以从不同的子状态机转换。

关于角色旋转,我们通过使用transform.Rotate(Vector3.up * (Time.deltaTime * -45.0f), Space.World)`命令克服了根运动的不足,使角色在按下 Q 和 E 键时实际上可以转身。

这个命令与animator.SetBool("TurnLeft", true)一起使用,触发了右侧的动画剪辑:

图片

通过脚本转换角色控制器

将根运动应用于您的角色可能是一种非常实用和精确的动画方式。然而,时不时地,您可能需要手动控制角色的一个或两个方面。也许您只有原地动画可以工作,或者可能希望角色的移动受到其他变量的影响。在这些情况下,您将需要通过脚本覆盖根运动

为了说明这个问题,这个菜谱使用了一个跳跃的动画剪辑,它最初只将角色在 Y 轴上移动。为了使她在跳跃时向前或向后移动,我们将学习如何通过脚本访问角色的速度来通知跳跃方向。

准备工作

对于这个菜谱,我们准备了一个名为 Jumping 的 Unity 包,其中包含一个具有动画角色的基本场景。我们还提供了 FBX 动画剪辑Swat@rifle_jump。这些文件可以在09_05文件夹中找到。

如何做到这一点...

要通过脚本应用根运动,请按照以下步骤操作:

  1. 创建一个新的 3D 项目,并导入 Jumping Unity 包以及 FBX 文件Swat@rifle_jump.fbx

  2. 然后,从项目面板中打开mecanimPlayground级别。

  3. 我们需要配置动画剪辑。在项目面板中选择Swat@rifle_jump资产。

  4. 选择 Rig 部分。将动画类型更改为 Humanoid,并将 Avatar Definition 更改为 Create From this Model。通过点击应用来确认这一点。

  5. 现在,激活动画部分。选择rifle_jump剪辑。点击“限制范围”按钮调整时间轴,并勾选“循环时间”和“循环姿态”选项。在根变换旋转下,勾选“烘焙到姿态”并选择基于“原始”。在根变换位置(Y)下,不勾选“烘焙到姿态”并选择基于(在开始时)|原始。在根变换位置(XZ)下,不勾选“烘焙到姿态”。点击应用以确认更改:

图片

  1. 项目面板中,双击MainCharacter控制器资产文件;它应该在动画面板中打开。

  2. 从动画面板的左上角选择“参数”部分,并使用+符号创建一个名为“跳跃”的新触发参数:

图片

  1. 添加一个名为“跳跃”的新状态。通过在网格区域右键单击并选择“创建状态 | 空白”,然后在检查器中更改其名称来完成此操作。

  2. 选择“跳跃”状态。然后,从检查器中填充它,使用rifle_jump动作剪辑:

图片

  1. 从任何状态到“跳跃”创建一个转换(使用“创建转换”右键单击菜单)。选择转换,取消勾选“具有退出时间”,并添加一个触发器条件“跳跃”:

图片

  1. 现在,从“跳跃”到“移动”创建一个转换。确保勾选“具有退出时间”选项:

图片

  1. 在代码编辑器(文件夹项目 | 脚本)中打开BasicControllerC#脚本类。

  2. Start()函数之前,添加以下代码:

public float jumpHeight = 3f;
private float verticalSpeed = 0f;
private float xVelocity = 0f;
private float zVelocity = 0f;
  1. if(controller.isGrounded)行之后立即添加以下内容:
if (Input.GetKey (KeyCode.Space)) {
   anim.SetTrigger("Jump");
   verticalSpeed = jumpHeight;
}
  1. 最后,在脚本类的代码末尾添加一个新的函数。因此,它被插入到代码的最后一个大括号(})之前:
      void OnAnimatorMove(){
         Vector3 deltaPosition = anim.deltaPosition;
         if (controller.isGrounded) {
           xVelocity = controller.velocity.x;
           zVelocity = controller.velocity.z;
         } else {
           deltaPosition.x = xVelocity * Time.deltaTime;
           deltaPosition.z = zVelocity * Time.deltaTime;
           anim.SetBool ("Jump", false);
         }
         deltaPosition.y = verticalSpeed * Time.deltaTime;
         controller.Move (deltaPosition);
         verticalSpeed += Physics.gravity.y * Time.deltaTime;
         if ((controller.collisionFlags & CollisionFlags.Below) 
!= 0) {
           verticalSpeed = 0;
         }
       }
  1. 保存你的脚本并播放场景。你可以使用空格键进行跳跃。观察角色的速度如何影响跳跃的方向。

它是如何工作的...

注意到一旦这个函数被添加到脚本中,动画组件中的“应用根运动”字段就会从勾选框变为“由脚本处理”:

图片

原因在于,为了覆盖动画剪辑的原始运动,我们在 Unity 的OnAnimatorMove()函数内放置了一系列命令来移动我们的角色控制器,使其在跳跃时移动。代码中的controller.Move (deltaPosition)行基本上用 deltaPosition 3D 向量替换了跳跃的方向,这个向量由跳跃瞬间的角色速度(x 和 z 轴)以及跳跃高度变量随时间与重力的计算(y 轴)组成。

从任何状态到动画状态的转换跳转的条件是跳转转换已触发(变为真)。在代码中,我们使用SetTrigger("Jump")语句在动画控制器中激活 Trigger Jump。触发器就像布尔参数一样,但设置后变为真,然后自动返回假。这意味着不需要编写额外的代码来将触发器设置为假。

触发器非常适合发生事件,然后你想让事情恢复正常。从跳转动画状态回到移动状态的转换不需要任何条件,所以跳转动画播放后,角色返回到移动状态。

将刚体属性添加到动画角色中

如果你在建模和动画角色时没有包含足够数量的属性,你可能想给她在运行时收集新属性的机会。在这个食谱中,我们将学习如何在尊重动画层次结构的同时实例化 GameObject 并将其分配给角色。

准备工作

对于这个食谱,我们准备了一个名为 Props 的 Unity 包,其中包含一个具有动画角色和名为徽章的预制件的基场景。还有一个名为texture_pickupBadge.png的纹理。这些文件可以在09_06文件夹中找到。

如何操作...

要在运行时将刚体属性添加到动画角色中,请按照以下步骤操作:

  1. 创建一个新的 3D 项目,并导入 Props Unity 包和texture_pickupBadge.png纹理。

  2. 然后,从项目面板打开mecanimPlayground级别。

  3. 项目面板,通过拖动到层次结构中,将徽章属性添加到场景中。

  4. 将徽章设置为mixamorig:Spine2变换的子对象(使用层次结构树导航到 MsLaser | mixamorig:Hips | mixamorig:Spine | mixamorig:Spine1 | `mixamorig:Spine2``)。然后,通过将变换位置更改为(-0.08, 0, 0.15)和旋转更改为(0.29, 0.14, -13.29),使徽章对象在角色的胸部上方可见:

图片

  1. 记录位置旋转值,并从场景中删除徽章对象。

  2. 通过选择创建 | 3D 对象 | 立方体,将名为 Cube-pickup 的 3D 立方体添加到场景中。在检查器中,将它的变换位置设置为(0, 0.5, 2),并检查 Box Collider 组件的 Is Trigger 选项。

  3. texture_pickupBadge.png纹理从项目面板拖动到 Cube-pickup 游戏对象上。你应该能看到拾取徽章文本写在立方体的所有面上。

  4. 项目面板中,创建一个名为AddProp的新 C#脚本类,包含以下代码,并将其作为组件添加到 Cube-pickup 游戏对象中:

    using UnityEngine;
     using System.Collections;

     public class PropManager : MonoBehaviour  {
         public GameObject prop;
         public Transform targetBone;
         public Vector3 positionOffset;
         public Vector3 rotationOffset;
         public bool destroyTrigger = true;

         void OnTriggerEnter(Collider collision) {
             bool addPropCondition = targetBone.IsChildOf(collision.transform) & !AlreadyHalreadyHasChildObject();

             if (addPropCondition)
                 AddProp();
         }

         private void AddProp() {
             GameObject newprop;
             newprop = Instantiate(prop, targetBone.position,  
             targetBone.rotation) as GameObject;
             newprop.name = prop.name;
             newprop.transform.parent = targetBone;
             newprop.transform.localPosition += positionOffset;
             newprop.transform.localEulerAngles += rotationOffset;

             if(destroyTrigger)
                 Destroy(gameObject);
         }

         private bool AlreadyHalreadyHasChildObject() {
             string propName = prop.name;

             foreach(Transform child in targetBone){
                 if (child.name == propName)
                     return true;
             }

             return false;
         }
     }
  1. 选择 Cube-pickup 游戏对象,并查看检查器中属性管理器(脚本)组件的属性。按照以下方式填充公共变量:

    • 属性:徽章预制件

    • 目标骨骼:MsLaser 层次结构 GameObject 内的 mixamorig:Spine2 变换

    • 位置偏移量:(-0.08, 0, 0.15)

    • 旋转偏移量:(0.29, 0.14, -13.29)

    • 销毁触发器:勾选(true)

  1. 运行 场景。使用 WASD 键盘控制方案,将角色引导到 Cube-pickup GameObject。第一次碰撞将向角色添加徽章。如果公共变量 销毁触发器 被勾选,那么 Cube-pickup GameObject 应在第一次碰撞后从场景中移除:

它是如何工作的...

一旦被角色触发,附加到 Cube-pickup GameObject 的脚本实例对象将实例化指定的预制体,使其成为它们“放置”的骨骼的子对象。位置偏移量旋转偏移量 可以用来微调道具相对于其父变换的确切位置。随着道具成为动画角色的骨骼的父对象,它们将跟随并尊重父角色 GameObject 的层次结构和动画。

AlreadyHalreadyHasChildObject() 方法在实例化新对象之前检查是否存在同名的前置道具,因此我们不会尝试将道具作为目标骨骼的新子对象实例化超过一次。

还有更多...

这里有一些方法可以进一步扩展这个菜谱。

使用脚本移除道具

你可以编写一个类似的脚本来移除道具。在这种情况下,OnTriggerEnter(...) 方法将调用以下 RemoveProp() 方法:

    private void RemoveProp() {
         string propName = prop.name;

         foreach(Transform child in targetBone){
             if (child.name == propName)
                 Destroy (child.gameObject);
         }
     }

如果只有一个类型的道具,则设置活动状态

如果只有一个道具,那么而不是让代码实例化一个新的 GameObject,你可以让道具始终在层次结构中,但最初不活动,然后在拾取触发器对象被击中时,你将道具 GameObject 设置为活动状态。

虽然不太灵活,但这是一个更简单的脚本。执行以下操作:

  1. 拖动徽章 预制体 并使其成为 mixamorig:Spine2 变换的子对象,然后设置 位置旋转,就像在上一道菜的第 4 步中做的那样。

  2. 检查器 中,取消勾选顶部整个徽章 GameObject 的活动复选框。

  3. 将 C# 脚本管理器的内容替换为以下代码:

using UnityEngine;

 public class PropManager : MonoBehaviour {
      public GameObject propObject;

     void OnTriggerEnter(Collider hit) {
         if (hit.CompareTag("Player")) {
             propObject.SetActive(true);
             Destroy(gameObject);
         }
     }
 }
  1. 层次结构 中选择 Cube-pickup GameObject,在 检查器 中将 mixamorig:Spine2 的徽章子对象拖入公共槽位,用于道具管理器(脚本)组件的 Prop Object 变量。

使用动画事件抛出对象

现在您的动画角色已经准备好了,您可能想要协调她的一些动作与她的动画状态。在这个菜谱中,我们将通过让角色在适当的动画剪辑达到动画中的特定时间点时抛出物体来展示这一点。为此,我们将利用 动画 事件,它基本上会从动画剪辑的时间线中触发一个函数。这个在机械系统引入的功能,对于那些熟悉经典 动画 面板中添加事件功能的用户来说应该很熟悉:

准备中

对于这个菜谱,我们准备了一个名为 Throwing 的 Unity 包,其中包含一个基本场景,包含一个动画角色和一个名为 EasterEgg预制件。文件可以在 09_07 文件夹中找到。

如何操作...

要使动画角色抛出物体,请按照以下步骤操作:

  1. 创建一个新的 3D 项目并导入道具 Unity 包和 EasterEgg 纹理。

  2. 打开 mecanimPlayground 级别。

  3. 播放级别并按键盘上的 F 键。角色将像是在用右手抛东西一样移动。

  4. 创建一个新的 C# 脚本类名为 ThrowObject,并将实例对象作为组件添加到角色的 MsLaser GameObject:

    using UnityEngine;
     using System.Collections;

     public class ThrowObject : MonoBehaviour {
       public GameObject prop;
       private GameObject proj;
       public Vector3 posOffset;
       public Vector3 force;
       public Transform hand;
       public float compensationYAngle = 0f;

       public void Prepare () {

         proj = Instantiate(prop, hand.position, hand.rotation) as GameObject;
         if(proj.GetComponent<Rigidbody>())
           Destroy(proj.GetComponent<Rigidbody>());
         proj.GetComponent<SphereCollider>().enabled = false;
         proj.name = "projectile";
         proj.transform.parent = hand;
         proj.transform.localPosition = posOffset;
         proj.transform.localEulerAngles = Vector3.zero;
       }

       public void Throw () {

         Vector3 dir = transform.rotation.eulerAngles;
         dir.y += compensationYAngle;
         proj.transform.rotation = Quaternion.Euler(dir);
         proj.transform.parent = null;
         proj.GetComponent<SphereCollider>().enabled = true;
         Rigidbody rig = proj.AddComponent<Rigidbody>();
         Collider projCollider = proj.GetComponent<Collider> ();
         Collider col = GetComponent<Collider> ();
         Physics.IgnoreCollision(projCollider, col);
         rig.AddRelativeForce(force);
       }
     }
  1. 层次结构 中,确保选择 MsLaser GameObject。在 检查器 中,检查其 抛物对象(脚本) 组件。填写以下内容:

    • 道具:EasterEgg 预制件

    • 手:mixamorig:RightHand

    • 位置偏移:设置为 (0; 0.07, 0.04)

    • 力量:设置为 (0; 200, 500)

  1. 项目 面板中选择 Swat@toss_grenade 资产文件。在 检查器 中,选择 动画 部分,向下滚动到 事件 部分,并展开它。

  2. 探索 动画 预览面板,看看当您拖动预览播放头沿着预览 时间线 时,播放头也会相应地在 事件 时间线中移动。在 预览 面板的底部,显示了时间、百分比和帧属性。当您点击添加事件按钮时,将在当前播放头位置在 事件时间线 中添加一个新的 动画事件,因此请确保在创建新的 动画 事件之前将播放头放在正确的帧上:

您可以通过在移动播放头时关注角色动画本身来查看此面板的内容,并在角色处于事件所需位置时选择合适的帧。或者,您可能已经知道希望动画事件创建的时间/百分比/帧号,因此可以关注预览面板底部的数值。

  1. 在事件部分创建两个 动画事件,如下所示:

    • 017.9%:设置函数为准备

    • 057.1%:设置函数为抛出

    • 然后,点击应用按钮:

  1. 播放你的 场景。当你按下 F 键时,你的角色现在将能够投掷一个 复活节彩蛋

工作原理...

Once the toss_grenade animation reaches the points to which we have set our Events, the Prepare() and Throw() methods are invoked.

The Prepare() method instantiates a prefab, now named projectile, in the character's hand (Projectile Offset values are used to fine-tune its position), also making it respect the character's hierarchy. Also, it disables the prefab's collider and destroys its Rigidbody component, provided it has one.

The Throw() method enables the projectile's collider and adds a Rigidbody component to it, making it independent from the character's hand. Finally, it adds a relative force to the projectile's Rigidbody component, so it will behave as if thrown by the character. The Compensation YAngle can be used to adjust the direction of the grenade, if necessary.

Applying Ragdoll physics to a character

动作游戏通常利用 Ragdoll 物理来模拟角色身体对受到打击或爆炸影响的反应。在这个配方中,我们将学习如何设置并激活角色在接触带刺物体时触发的 Ragdoll 物理效果。我们还将利用这个机会在事件发生后几秒钟重置角色的位置和动画:

准备工作

对于这个配方,我们准备了一个名为 Ragdoll 的 Unity 包,其中包含一个具有动画角色的基本场景,以及已经放置在场景中的名为 Spawnpoint 的预制件。文件可以在 09_08 文件夹中找到。

如何操作...

要将 Ragdoll 物理应用于你的角色,请按照以下步骤操作:

  1. 创建一个新的 3D 项目并导入 Ragdoll Unity 包。

  2. 打开 mecanimPlayground 级别。

  3. 你将看到动画化的 MsLaser 角色和一个圆盘,Spawnpoint。

  4. 首先,让我们创建并设置我们的 Ragdoll。通过选择 创建 | 3D 对象 | Ragdoll.... 创建一个新的 3D Ragdoll。应该会弹出 Ragdoll 向导。

  5. Assign the transforms as follows:

    • 骨盆: mixamorig:Hips

    • 左髋关节: mixamorig:LeftUpLeg

    • 左膝: mixamorig:LeftLeg

    • 左脚: mixamorig:LeftFoot

    • 右髋关节: mixamorig:RightUpLeg

    • 右膝: mixamorig:RightLeg

    • 右脚: mixamorig:RightFoot

    • 左臂: mixamorig:LeftArm

    • 左肘: mixamorig:LeftForeArm

    • 右臂: mixamorig:RightArm

    • 右肘: mixamorig:RightForeArm

    • 中间脊柱: mixamorig:Spine1

    • 头部: mixamorig:Head

    • 总质量: 20

    • 力度: 50

  6. See the screenshot for these settings:

  1. 在层次结构中选择 MsLaser GameObject。在 检查器 中,将其 标签 设置为 Player

  2. Create a new C# script-class named RagdollCharacter and add an instance-object as a component to the MsLaser GameObject:

    using UnityEngine;
     using System.Collections;

     public class RagdollCharacter : MonoBehaviour {
         private Transform spawnPoint;

         void Start () {
             spawnPoint = GameObject.Find("Spawnpoint").transform;
             DeactivateRagdoll();
         }

         public void ActivateRagdoll() {
             gameObject.GetComponent<CharacterController> ().enabled = false;
             SetActiveRagdoll(true);
             StartCoroutine (Restore ());
         }

         public void DeactivateRagdoll() {
             SetActiveRagdoll(false);
             RespawnPlayer();
             gameObject.GetComponent<CharacterController>().enabled = true;
         }

         IEnumerator Restore() {
             yield return new WaitForSeconds(5);
             DeactivateRagdoll();
         }

         public void SetActiveRagdoll(bool isActive) {
             gameObject.GetComponent<CharacterController> ().enabled = !isActive;
             gameObject.GetComponent<BasicController> ().enabled = !isActive;
             gameObject.GetComponent<Animator> ().enabled = !isActive;

             foreach (Rigidbody bone in GetComponentsInChildren<Rigidbody>()) {
                 bone.isKinematic = !isActive;
                 bone.detectCollisions = isActive;
             }

             foreach (CharacterJoint joint in GetComponentsInChildren<CharacterJoint>()) {
                 joint.enableProjection = isActive;
             }

             foreach (Collider col in GetComponentsInChildren<Collider>()) {
                 col.enabled = isActive;
             }
         }

         private void RespawnPlayer() {
             transform.position = spawnPoint.position;
             transform.rotation = spawnPoint.rotation;
         }
     }
  1. 这个配方需要与某物发生碰撞;创建一个名为 death-object 的 GameObject。你可以创建一个简单的 3D 立方体(菜单:创建 | 3D 对象 | 立方体****)。然而,任何具有物理碰撞器的 3D 对象都适合玩家角色与之交互。

与一个视觉上有趣的 3D 模型交互更有趣。在为这个配方创建截图时,我们使用了来自 Unity Asset Store 中的 LowlyPoly 的高质量、低多边形、免费的 Stilized Crystal 资产作为示例对象,你可能会使用这些对象在游戏中引起小丑娃娃碰撞:

assetstore.unity.com/packages/3d/props/stylized-crystal-77275

  1. 创建一个新的 C# 脚本类名为 DeadlyObject,并将实例对象作为组件附加到 death-object GameObject 上:
    using UnityEngine;

     public class DeadlyObject : MonoBehaviour {
         public float range = 2f;
         public float force = 2f;
         public float up = 4f;

         void OnTriggerEnter(Collider hit) {
             if (hit.CompareTag("Player")) {
                 RagdollCharacter ragdollCharacter = hit.gameObject.GetComponent<RagdollCharacter>();
                 ExplodePlayer(ragdollCharacter);
                 Destroy(gameObject);
             }
         }

         private void ExplodePlayer(RagdollCharacter ragdollCharacter) {
             ragdollCharacter.ActivateRagdoll();
             Vector3 explosionPos = transform.position;
             Collider[] colliders = Physics.OverlapSphere(explosionPos, range);

             foreach (Collider collider in colliders) {
                 if (collider.GetComponent<Rigidbody>())
                     collider.GetComponent<Rigidbody>().AddExplosionForce(force, explosionPos, range, up);
             }
         }
     }
  1. 播放 场景。使用 WASD 键盘控制方案,将角色引导到 death-object GameObject。与之碰撞将激活角色的 Ragdoll 物理并对其应用爆炸。结果,角色将被抛出相当远的距离,并且将不再控制其身体的运动,类似于小丑娃娃。

它是如何工作的...

Unity 的 Ragdoll 工具将 Collider、Rigidbody 和 Character Joint 组件分配给选定的变换。结合这些组件,使得 Ragdoll 物理成为可能。然而,每当我们要让我们的角色被动画化和由玩家控制时,它们必须被禁用。在我们的例子中,我们使用 RagdollCharacter 脚本及其两个函数 ActivateRagdoll()DeactivateRagdoll() 来开关这些组件。后者包括将我们的角色在适当位置重新生成的指令。

为了测试目的,我们还创建了名为 DeadlyObject 的脚本,该脚本调用 RagdollCharacter 脚本中名为 ActivateRagdoll() 的函数。它还对我们的小丑娃娃角色应用了爆炸,将其抛出爆炸范围之外。

还有更多...

这里有一些进一步改进这个配方的方法。

使用新的玩家 GameObject 而不是禁用并移动到重生点

而不是重置角色的变换设置,你可以销毁它的 GameObject,并在重生点使用标签实例化一个新的。例如,如何做到这一点,请参阅 Unity 的文档:docs.unity3d.com/ScriptReference/GameObject.FindGameObjectsWithTag.html

将角色的身体旋转以瞄准武器

当播放第三人称角色时,你可能希望她瞄准的目标不是直接在她面前,而无需改变她的方向。在这种情况下,你需要应用所谓的程序动画,它不依赖于预制的动画片段,而是依赖于其他数据的处理,例如玩家输入,以动画化角色。在这个菜谱中,我们将使用这种技术通过移动鼠标来旋转角色的脊柱,从而允许调整角色的瞄准。我们还将利用这个机会从角色的武器发射一条射线,并在最*的目标上显示准星。请注意,这种方法仅适用于位于第三人称控制角色背后的相机

准备工作

对于这个菜谱,我们准备了一个名为AimPointer的 Unity 包,其中包含一个具有动画角色的基本场景。该包还包括用作瞄准准星的crossAim精灵,可以在09_09文件夹中找到。

如何操作...

要旋转角色的躯干以瞄准武器,请执行以下操作:

  1. 创建一个新的项目并导入AimPointer Unity 包。

  2. 打开mecanimPlayground级别。在检查器场景面板中,你会看到一个名为MsLaser的动画角色,手持pointerPrefab对象。

  3. 创建一个新的 C# 脚本类名为MouseAim,并将实例对象作为组件添加到MsLaser游戏对象:

    using UnityEngine;
     using System.Collections;

     public class MouseAim: MonoBehaviour  {
         public Transform spine;
         public Transform weapon;
         public GameObject crosshairImage;
         public Vector2 xLimit = new Vector2(-30f, 30f);
         public Vector2 yLimit= new Vector2(-30f, 30f);
         private float xAxis = 0f;
         private float yAxis = 0f;

         public void LateUpdate() {
             RotateSpine();
             ShowCrosshairIfRaycastHit();
         }

         private void RotateSpine() {
             yAxis += Input.GetAxis("Mouse X");
             yAxis = Mathf.Clamp(yAxis, yLimit.x, yLimit.y);
             xAxis -= Input.GetAxis("Mouse Y");
             xAxis = Mathf.Clamp(xAxis, xLimit.x, xLimit.y);
             Vector3 newSpineRotation = new Vector3(xAxis, yAxis, spine.localEulerAngles.z);
             spine.localEulerAngles = newSpineRotation;
         }

         private void ShowCrosshairIfRaycastHit() {
             Vector3 weaponForwardDirection = weapon.TransformDirection(Vector3.forward);
             RaycastHit hit;
             Vector3 fromPosition = weapon.position + Vector3.one;
             if (Physics.Raycast (fromPosition, weaponForwardDirection, out hit)) {
                 Vector3 hitLocation =  Camera.main.WorldToScreenPoint(hit.point);
                 DisplayPointerImage(hitLocation);
             } else
                 crosshairImage.SetActive(false);
         }

         private void DisplayPointerImage(Vector3 hitLocation) {
             crosshairImage.transform.position = hitLocation;
             crosshairImage.SetActive(true);
         }
     }
  1. 层次结构中,通过选择创建 | UI | 图像来创建一个新的 UI 图像,命名为 Image-crosshair。

  2. 在矩形变换组件的检查器中,将其宽度和高度设置为 16,并在源图像字段中填充crossAim精灵:

  1. 层次结构中选择MsLaser游戏对象,并在鼠标瞄准组件的检查器中填充以下内容:

    • 脊柱:mixamorig:Spine(在MsLaser | mixamorigHips

    • 武器:pointerPrefab(在MsLaser|Hips|Spine|Spine1|Spine2|RightShoulder|Arm|ForeArm|Hand

    • 准星:Image-crosshair 游戏对象

  1. 播放场景。现在,你可以通过移动鼠标来旋转角色的躯干。更好的是,Image-crosshair UI 图像将显示在指针所指向的对象的顶部。

它是如何工作的...

你可能已经注意到,所有用于旋转角色脊柱的代码都位于LateUpdate()方法内部,而不是更常见的Update()方法中。这样做的原因是为了确保所有的变换操作都会在原始动画片段播放之后执行,从而覆盖它。

关于脊柱旋转,我们的脚本将鼠标的水*速度和垂直速度添加到 xAxis 和 yAxis 浮点变量中。然后,这些变量被限制在指定的范围内,避免对角色模型的扭曲。最后,将脊柱对象沿 x 轴和 y 轴的变换旋转设置为 xAxis 和 yAxis。z 轴保留原始动画剪辑中的原始值。

此外,我们的脚本使用射线投射命令来检测武器瞄准方向上是否存在物体的碰撞器,在这种情况下,屏幕上会显示一个准星。

还有更多...

这里有一些方法可以进一步改进这个菜谱。

针对除主摄像机之外的其他摄像机的通用解决方案

由于这个菜谱的脚本是为站在第三人称控制角色背后的摄像机定制的,因此我们包括了一个更通用的解决方案来解决这个问题——实际上,与Unity 4.x Cookbook, Packt Publishing中提出的方法类似。

一个名为MouseAimLookAt的备用脚本,可以在09_09文件夹中找到,它首先将我们的二维鼠标光标屏幕坐标转换为三维世界空间坐标(存储在点变量中)。然后,它使用LookAt()命令将角色的身体旋转到点位置。此外,它确保脊柱不会外推 minY 和 maxY 角度,否则会导致角色模型的扭曲。

此外,我们还包括了一个补偿YAngle变量,这使得我们可以微调角色与鼠标光标的对齐。另一个新增功能是冻结 X 轴旋转,以防你只想让角色横向旋转身体,而不想向上或向下看。同样,这个脚本使用射线投射命令来检测武器前方是否存在物体,当它们存在时,在屏幕上绘制一个准星。

使用 Probuilder 创建几何形状

3D Unity 工具的最新增项是Probuilder,它允许你在 Unity 编辑器内部创建和操作几何形状。比现有的地形编辑器更强大,Probuilder允许你创建 3D 原语,然后对其进行操作,例如通过拉伸或移动顶点、边缘或面积,然后使用颜色或材质进行着色或纹理化。

在这个菜谱中,我们将创建一些可能对原创游戏或添加到 3D Gamekit 场景(如以下菜谱中使用的场景)有用的几何形状。

如果你之前没有使用过 3D 建模软件(如 Blender、3D Studio Max 或 Maya),那么探索Probuilder的不同功能是非常值得的。你将学习到关键概念,包括以下内容:

  • 顶点:线条接触的点——边缘接触的角落

  • 边缘:两个顶点之间的直线

  • 面积:*面的二维表面,通常是矩形或三角形

准备工作

这个菜谱使用了免费的 Unity Asset Store 和包管理器包。

如何做到...

要使用 Probuilder 创建几何体,请按照以下步骤操作:

  1. 创建一个新的 Unity 3D 项目。

  2. 使用包管理器安装Probuilder包。

  3. 通过工具 | Probuilder | 窗口显示Probuilder面板。

  4. 将面板(在Hierarchy旁边)停靠。通过右键鼠标的上下文菜单选择文本模式图标模式,根据你的喜好。

  5. 通过点击新建形状并从形状工具窗口中选择*面来创建一个新的Probuilder*面。接受默认选项并点击绿色的构建*面按钮。

  6. 在层次结构中选择新的Probuilder*面,你将在场景面板中看到选中的对象及其属性。在检查器中,我们可以看到,除了其变换、网格和网格渲染器组件外,还有两个特殊的 Probuilder 组件,Pb_mesh_nnnnnPb_Object(脚本)。

Pb_mesh_nnnnn是一个特殊组件,用于存储该 GameObject 的 3D 对象网格数据;这些数据可以在设计时的场景面板中编辑。在运行时,基于这些数据创建一个 Unity 网格。

图片

  1. 注意,当选择Probuilder GameObject 时,场景面板中会显示一个小Probuilder工具图标栏,允许 Object、Vertex、Edge 和 Face 检测模式:

图片

  1. 让我们在*面的中间做一个凹陷。选择面选择(四个Probuilder部分图标中最右边的一个),并使用 Shift 键进行多部分选择,选择四个内部面(选中的面变为黄色)。然后,使用 Y 轴箭头将这些四个选中的面向下移动:

图片

  1. 让我们在物体上用 Vertex Paint 添加一些颜色。当物体有更多面时,这样做会更仔细。首先,在 Probuilder 面板中,点击 Subdivide 工具。

  2. 现在,点击 Probuilder Vertex Colors +。应该会显示Probuilder Vertex Painter 弹出面板。点击红色颜色并选择一个更深的红色。然后,点击你颜色上方的白色方块来选择这个深红色画笔颜色。将画笔大小调大(2 或 3),并将整个*面涂成深红色。现在,点击 Vertex Painter 面板中的白色方块来选择黄色画笔,并将画笔大小调小(比如 1.5)。现在,点击*面下陷区域中间的九个顶点。现在,你应该有一个深红色的*面,其下部分为黄色:

图片

  1. 保存你的场景。由于Probuilder网格数据存储在场景数据中,如果你忘记保存场景,你将丢失所有的 Probuilder 工作。

它是如何工作的...

您已将 Probuilder 包添加到新的 3D 项目中,并使用Probuilder工具面板将Probuilder网格对象添加到场景中。您已使用面选择工具来允许您选择并移动一些面以创建一个凹陷。然后您细分了对象,以便您有更多的面来为最终详细工作使用。最后,您学习了使用不同颜色和画笔大小的 Vertex Paint。

Probuilder 提供了许多更多功能,包括通过绘制线状多边形来创建对象以及纹理表面,而不仅仅是简单的 Vertex Painting。在这里了解更多关于Probuilder的信息:

使用 3D Gamekit 创建游戏

将一系列 Unity 3D 工具组合在一起成为 Unity 3D GameKit。在这个菜谱中,我们将创建一个新的场景并使用一些套件的预制件和脚本来说明角色如何与门和拾取等对象交互:

准备工作

这个菜谱使用了免费的Unity Asset Store包管理器包。

如何做到这一点...

要使用 3D Gamekit 创建游戏,请按照以下步骤操作:

  1. 创建一个新的 Unity 3D 项目。

  2. 使用包管理器安装以下包(3D GameKit 所需):

    • Cinemachine

    • 后处理(同意质量设置弹出对话框)

    • Probuilder

  3. 从资产商店导入3D GameKit(来自 Unity Technologies,免费):

  1. 同意质量设置弹出对话框。经过几分钟(在此期间,它正在设置一个包含大量资产的项目),您将在项目面板中看到一个名为 3DGamekit 的新文件夹。

  2. 关闭并重新打开 Unity 编辑器。

  3. 首先,打开提供的示例场景,通过控制 3D Ellen 角色来探索 3D 世界。

移动是标准的WASD-SPACE/箭头键。通过鼠标指针控制摄像机。点击左鼠标按钮使用武器。

  1. 通过选择“工具”|“创建新场景”来创建一个新的 3D GameKit 场景您将被要求命名场景,并在您的项目|资产文件夹中创建一个新的场景资产文件。您会看到在您新场景的层次结构中有很多特殊的 GameObject:

  1. 如您所见,新的场景一开始就包含一个动画 3D 角色(Ellen)在一个 ProBuilder 3D *面上,这个*面是她站立的地方。

  2. 在场景中添加一个小门。从项目面板(Assets | 3DGamekit | Prefabs | Interactables)拖动 DoorSmall Prefab 的副本到 3D Plane 场景的中间。

  3. 场景中添加一个 Crystal,位于门对面,Ellen 角色开始的地方。从项目面板(Assets | 3DGamekit | Prefabs | Interactables)拖动 Crystal Prefab 的副本到门后面的场景中。

  4. 现在,在门的两侧添加一些墙壁,这样 Ellen 才能打开门才能到达 Crystal。从项目面板(Assets | 3DGamekit | Prefabs | Interactables)拖动两个 Prefab Wall2x 的副本到场景中:

图片

  1. 我们现在需要将压力垫连接到门,这样当 Ellen 踏上压力垫时,它会发送一个打开门的信号。这非常直接,因为门有一个 GameCommandReceiver 组件,可以链接到压力垫的 Send on Trigger Enter (Script)组件。在层次结构中选择 PressurePad GameObject,并将其拖动到其 Send on Trigger Enter (Script)组件的公共 Interactive Object 槽位中:

图片

  1. 运行场景。当 Ellen 踏上压力垫时,门应该打开。

  2. 我们现在需要通过添加一个 Box Collider 来使 Crystal 可碰撞。将 Box Collider 组件添加到 GameObject Crystal 上,并检查其 On Trigger 选项。

  3. 3D Gamekit具有库存功能。让我们通过添加一个 Inventory Item (Script)组件来使 Crystal 可收集。在检查器中,点击添加组件,然后输入 inven 并选择 Inventory Item 脚本组件。对于该组件,输入 Crystal 作为库存键:

图片

库存对象和库存槽位之间的库存键名称必须匹配。

  1. 现在,我们可以向 Ellen 添加一个 Inventory Controller (Script)组件,并为 Crystal 设置一个槽位。在层次结构中,选择 Ellen GameObject。在检查器中,点击添加组件,然后输入 inven 并选择 Inventory Item 脚本组件。

  2. 检查器中,我们现在需要按照以下方式配置 Inventory Controller (Script)组件的属性:

    • 将大小从0改为1

    • 对于其键,输入 Crystal

    • 对于 On Add()事件,点击加号,+,以创建一个新事件。

    • 将 Ellen 拖入新事件的对象槽位(下方为 Runtime Only)。

    • 将函数从无功能更改为 InventoryController Add Item。

    • 最后,在库存中输入此物品的名称为 Crystal:

图片

  1. 运行场景。Ellen 现在可以通过压力垫打开门并走进 Crystal,Crystal 被添加到她的库存中。

它是如何工作的...

我们已经涉猎了 3D GameKit 的广泛功能。希望这个菜谱能给你一个如何使用提供的 预制件,以及如何将 3D Gamekit 组件添加到自定义 GameObject 的想法。

查看以下链接以获取更多信息:

从 Mixamo 导入第三方 3D 模型和动画

虽然 Unity 资产商店中有许多现成的 3D 模型和动画可供使用,但来自第三方组织的 3D 资产来源还有很多。Mixamo(现在是 Adobe 的一部分)通过其基于网络的系统提供了一系列令人惊叹的角色和动画。

在这个菜谱中,我们将选择并下载一个角色和一些动画,为使用 Unity 进行格式化,并使用动画控制器状态图来控制动画:

准备工作

这个菜谱使用了免费的 Adobe Mixamo 系统,所以如果你还没有账户,你需要注册一个账户。

如何操作...

要从 Mixamo 导入第三方 3D 模型 和动画,请按照以下步骤操作:

  1. 打开网页浏览器并访问 Mixamo.com

  2. 使用你的 Mixamo/Adobe 账户注册/登录。

  3. 选择左侧导航栏顶部的角色部分

  4. 选择你的角色,例如 Lola B Styperek。你将在右侧预览面板中看到这个角色。

  5. 下载你的角色,选择 FBX For Unity (.fbx) 和 T-pose:

  1. 创建一个新的 3D Unity 项目,并在项目面板中创建一个名为 Models 的文件夹。

  2. 将下载的 FBX 文件导入到 Models 文件夹。

  3. 在项目面板中选择资产文件,然后在检查器中选择材质部分。

  4. 点击 Extract Textures... 按钮,并将模型的纹理提取到你的 Models 文件夹。如果需要修复使用纹理作为法线图的材质问题,请选择 Fix Now:

  1. 将角色克隆从项目面板拖动到场景

  1. 我们需要一个动画师控制器来管理动画。在项目面板中创建一个新的动画师控制器文件,命名为 Lola-Animator-Controller。

  2. 在层级中选择 Lola B Styperek。在检查器中,你会看到一个动画师组件的控制器槽位。将 Project 面板中的 Lola-Animator-Controller 文件拖动到动画师 | 控制器槽位中。

  3. 现在,让我们为这个模型制作动画。回到Mixamo.com网页并选择一个动画,例如高尔夫挥杆。点击下载按钮并选择以下选项:

    • 格式:Unity 的 FBX (.fbx)

    • 每秒帧数:30

    • 皮肤:无皮肤

    • 关键帧减少:无

图片

  1. 将动画剪辑 FBX 文件(在本例中为 lola_b_styperek@Golf Drive.fbx)导入到 Unity 项目的动画文件夹中。

  2. 双击 Lola-Animator-Controller 文件以打开动画师(状态机)编辑面板。

  3. 将高尔夫挥杆动画剪辑拖动到动画师面板中;它应该显示为橙色状态,并从进入状态转换到它(即,这个状态成为默认状态):

图片

  1. 运行场景。你现在应该看到 Lola 在练习她的高尔夫挥杆。如果你在层级中选择了角色并且可以查看动画师面板,你会看到高尔夫挥杆动画剪辑(状态)正在播放:

图片

它是如何工作的...

Mixamo 导出 FBX 格式的 3D 绑定角色模型和动画剪辑。模型的材质嵌入在 FBX 文件中,因此我们在将模型导入 Unity 后必须提取它们。

Unity 使用动画师控制器控制模型的动画,因此我们必须为我们的人物模型创建一个,然后拖入我们希望用于动画模型的动画剪辑。

更多内容...

这里有一些进一步使用这个食谱的方法。

动画循环

在项目面板中选择动画剪辑,然后在检查器中检查其循环时间选项,然后点击应用按钮以将更改应用到这个资产文件。当你运行场景时,Lola 现在将无限期地重复动画。

编写脚本事件以控制动画剪辑何时播放

可以在角色的动画师控制器中的状态图中添加额外的动画剪辑。然后你可以定义变量和触发器,以定义动画何时从一个剪辑(状态)转换到另一个剪辑。本章中的许多食谱说明了允许脚本影响一个动画剪辑(状态)到另一个动画剪辑(状态)转换的方法。

关于将模型和动画导入 Unity 的信息来源

从以下内容了解更多关于模型和动画导入的信息:

第十一章:Web 服务器通信和在线版本控制

在本章中,我们将涵盖:

  • 使用 PHP 和数据库设置排行榜

  • Unity 游戏与 Web 服务器排行榜的通信

  • 创建和克隆 GitHub 仓库

  • 将 Unity 项目添加到 Git 仓库,并将其推送到 GitHub

  • 使用 GitHub for Unity 进行 Unity 项目版本控制

  • 防止游戏在未知服务器上运行

简介

服务器等待接收请求客户端的消息,当收到消息时,它尝试解释并采取行动,然后向客户端发送适当的响应。客户端是一种可以与其他客户端和/或服务器通信的计算机程序。客户端发送请求,并接收响应

在考虑和与客户端-服务器架构工作时,记住这些客户端/服务器/请求/响应的概念是有用的。

整体概念

世界是网络化的,涉及许多不同的客户端相互通信,以及与服务器通信。

每个 Unity 部署*台都展示了客户端的一个示例:

  • WebGL(在浏览器中运行)

  • Windows 和 Mac 应用程序

  • 任天堂 Switch

  • 微软 Xbox

  • 索尼 PlayStation

这些游戏可以与之通信的服务器包括专用多人游戏服务器、常规 Web 服务器和在线数据库服务器。多人游戏开发是整本书的主题。

Web 和数据库服务器在游戏开发和运行时交互中可以扮演许多角色。Unity 游戏与 Web 服务器交互的一种形式是游戏与在线服务器通信以获取数据,例如高分、库存、玩家资料和聊天论坛。

另一种客户端-服务器关系是针对分布式版本控制系统DVCS),其中本地计算机(笔记本电脑或台式机)上的内容可以与在线服务器同步,用于备份和历史变更目的,同时也允许与他人共享和协作编写代码项目。商业游戏公司内部使用私有仓库,而公共仓库用于开源项目,允许任何人访问内容。

本章中的食谱探讨了与 Unity 游戏开发、在线运行时通信以及云代码版本控制和共享相关的各种客户端-服务器通信场景。

使用 PHP 和数据库设置排行榜

当有玩家达到的高分排行榜时,游戏更有趣。即使是单人游戏也可以与共享的基于 Web 的排行榜通信。本食谱创建设置和从 SQL 数据库获取玩家分数的 Web 服务器端(PHP)脚本。接下来的食谱将创建一个可以与这个 Web 排行榜服务器通信的 Unity 游戏客户端

准备工作

这个食谱假设你或者拥有自己的网络托管,或者正在运行一个本地网络服务器。你可以使用内置的 PHP 网络服务器,或者像 Apache 或 Nginx 这样的网络服务器。对于数据库,你可以使用像 MySQL 或 MariaDB 这样的 SQL 数据库服务器,然而,我们尽量使用 SQLite——一个基于文件的数据库系统来简化事情。所以你实际上在电脑上需要的只是 PHP 7,因为它内置了网络服务器,并且可以与 SQLite 数据库通信,这正是这个食谱测试的设置。

这个食谱的所有 PHP 脚本和 SQLite 数据库文件都可以在12_01文件夹中找到。

如果你确实想安装一个网络服务器和数据库服务器应用程序,XAMPP 是一个很好的选择。它是一个免费的、跨*台的集合,包含了你在本地计算机上设置数据库和网络服务器所需的一切。下载页面还包含了 Windows、Mac 和 Linux 的常见问题解答和安装说明:www.apachefriends.org/download.html

如何做到这一点...

要使用 PHP 和数据库设置排行榜,请执行以下操作:

  1. 将提供的 PHP 项目复制到你将运行你的网络服务器的位置:

    • 实时网站托管:将文件复制到服务器上的实时网络文件夹(通常是wwwhttdocs

    • 在本地机器上运行:在注释行,你可以使用 Composer 脚本快捷方式通过输入composer run:来运行内置的 PHP 网络服务器。

图片

  1. 使用网络浏览器打开你的网站位置:

    • 实时网站托管:访问你的托管域名的 URL

    • 在本地机器上运行:访问localhost:8000 URL:

图片

  1. 通过点击最后一个项目符号链接:重置数据库来创建/重置数据库。你应该会看到一个带有消息“数据库已重置”和返回主页的链接(点击该链接)的页面。

  2. 要在浏览器中以网页的形式查看排行榜分数,请点击第二个链接:列出玩家(HTML):

图片

  1. 尝试第五个链接——列出玩家(TXT)——以获取排行榜数据作为文本文件。注意它在浏览器中查看时的外观(浏览器忽略换行符),与在服务器返回的实际源文件中查看时的外观不同:

图片

  1. 使用 JSON 和 XML 选项做同样的事情——看看我们的服务器如何将数据库内容包装成 HTML、纯文本(TXT)、XML 或 JSON 返回。

  2. 点击第六个链接——创建(用户名 = mattilda,分数=800)。当你下次检索内容时,你会看到有一个新的数据库记录,玩家 mattilda 的分数为 800。这表明我们的服务器可以接收数据并更改数据库的内容,而不仅仅是返回它。

它是如何工作的...

玩家的分数存储在 SQLLite 数据库中。通过提供的 PHP 脚本来方便地访问数据库。在我们的例子中,所有 PHP 脚本都放置在我们本地机器上的一个文件夹中,我们将从这个文件夹中运行服务器(使用 PHP 内置服务器时,它可以在任何地方)。因此,脚本通过 http://localhost:8000 访问。

所有访问都通过名为index.php的 PHP 文件进行。这被称为前端控制器,就像大楼里的接待员一样,解释请求并调用适当的函数执行一些操作,并以响应的形式返回给请求

实现了五个操作,每个操作都通过在 URL 末尾添加操作名称来指示(这是 GET HTTP 方法,有时用于网页表单。例如,下次您在 Google 上搜索时,请查看浏览器的地址栏)。操作及其参数(如果有)如下:

  • action = list & format = HTML / TXT / XML / JSON:此操作请求返回所有玩家分数的列表。根据第二个变量格式(html/txt/xml/json)的值,用户和他们的分数以不同的文本文件格式返回。

  • action = reset:此操作请求一组默认玩家名称和分数值来替换数据库表中的当前内容。此操作不需要参数。它返回一些 HTML 声明数据库已重置,以及一个链接到主页。

  • action = get & username = & format = HTML / TXT:此操作请求找到指定玩家的整数分数。它返回分数整数。有两种格式:HTML,用于提供玩家分数的网页,TXT,其中 HTTP 消息返回的内容只有数值。

  • action = update & username = & score = :此操作请求将提供的分数存储在数据库中(但只有当这个新分数大于当前存储的分数时)。如果数据库更新成功,它返回单词 success,否则返回-1(表示没有更新发生)。

还有更多...

这里有一些方法可以进一步使用这个菜谱。

SQLite、PHP 和数据库服务器

本菜谱中使用的 PHP 代码使用了 PDO 数据对象函数来与 SQLite 本地文件数据库进行通信。更多关于 PHP 和 SQLite 的信息请访问 www.sqlitetutorial.net/sqlite-php/

当 SQLite 不是解决方案(不被网络托管包支持)时,您可能需要在本地使用 SQL Server 进行开发,例如 MySQL 社区版或 MariaDB,然后使用托管公司的实时数据库服务器进行部署。

在您的本地机器上尝试事物的良好解决方案可以是 XAMP/WAMP/MAMP 等组合网络应用程序集合。您的 Web 服务器需要支持 PHP,并且您还需要能够创建 MySQL 数据库。

PHPLiteAdmin

当编写与数据库文件和数据库 服务器 通信的代码时,如果事情没有按预期工作,无法查看数据库内部,这可能会很令人沮丧。因此,数据库 客户端 存在,允许你与数据库 服务器 交互,而无需使用代码。

当使用 PHP 和 SQLite 时,PHPLiteAdmin 是一个轻量级(单文件!)解决方案,它是免费使用的(尽管如果你经常使用它,你可能考虑捐赠)。它包含在这个食谱的 PHP 脚本中的 phpLiteAdmin 文件夹中。可以使用 Composer 脚本快捷命令——composer dbadmin——来运行,它将在本地运行在 localhost:8001。一旦运行,只需点击玩家表格的链接,就可以看到数据库文件中每个玩家的分数数据:

在项目的 GitHub 仓库和网站上了解更多关于 PHPLiteAdmin 的信息:

Unity 游戏与 web 服务器排行榜的通信

在这个食谱中,我们创建了一个 Unity 游戏 客户端,可以通过 UI 按钮与之前食谱中的 web 服务器排行榜通信:

准备工作

由于场景包含多个 UI 元素,而食谱的代码是与 PHP 脚本和 SQL 数据库的通信,在 12_02 文件夹中,我们提供了一个名为 UnityLeaderboardClient 的 Unity 包,其中包含一个为 Unity 项目设置好的场景。

如何做到这一点...

要创建一个可以与 web 服务器排行榜通信的 Unity 游戏,请执行以下操作:

  1. 导入提供的 Unity 包,UnityLeaderboardClient

  2. 运行提供的 场景

  3. 确保你的 PHP 排行榜正在运行。

  4. 如果你没有在本地运行(localhost:8000),你需要通过在 Hierarchy 中选择 主摄像机,然后在 Inspector 中编辑 Web 排行榜(脚本) 组件的 排行榜 URL 文本来更新 URL:

  1. 点击 UI 按钮,使 Unity 与可以访问高分数据库的 PHP 脚本通信。

它是如何工作的...

玩家的分数存储在 SQL 数据库中。通过之前设置的 web 服务器项目提供的 PHP 脚本来方便地访问数据库。

在我们的例子中,所有的 PHP 脚本都被放置在一个本地 web 服务器上的 服务器 文件夹中。因此,脚本通过 http://localhost:8000/ 来访问。然而,由于 URL 是一个公共字符串变量,这可以在运行之前设置到你的服务器和网站代码的位置。

Unity 场景中有按钮(对应于网络排行榜理解的操作),这些按钮设置了相应的动作和要添加到 URL 中的参数,以便通过 LoadWWW() 方法对下一个网络 Server 调用进行调用。每个按钮的 OnClick() 动作已被设置为调用 Main CameraWebLeaderBoard C# 脚本的相应方法。

此外,还有几个 UI Text 对象。一个显示发送到服务器的最新 URL 字符串。另一个显示从服务器接收到的响应消息中提取的整数值(如果收到其他数据,则显示为 not an integer 消息)。

第三个 UI Text 对象位于 UI Panel 内,并且已经足够大,可以显示从服务器接收到的完整多行文本字符串(该字符串存储在 textFileContents 变量中)。

我们可以看到,当为玩家 Matt 设置随机分数时,HTTP 文本响应消息的内容只是一个整数;当点击“获取玩家 'matt' 的分数(TXT)”按钮时,返回包含 505 的文本文件:

图片

UI Text 对象已被分配给主相机的 WebLeaderBoard C# 脚本的公共变量。当任何 UI 按钮被点击时,会调用 WebLeaderBoard 方法的相应方法,该方法构建带有参数的 URL 字符串,然后调用 LoadWWW() 方法。此方法向 URL 发送请求,并通过协程的特性等待接收响应。然后,它将接收到的内容存储在 textFileContents 变量中,并调用 UpdateUI() 方法。对接收到的文本进行了美化,插入换行符以使 JSON、HTML 和 XML 更易于阅读。

还有更多...

这里有一些方法可以进一步使用这个食谱。

提取完整的排行榜数据以在 Unity 中显示

从 PHP 网络服务器检索到的 XML/JSON 文本为 Unity 游戏提供了一种有用的方法,允许游戏从数据库中检索完整的排行榜数据。然后,排行榜可以在 Unity 游戏中显示给用户(可能以美观的 3D 风格,或通过与游戏一致的 UI 显示)。

请参阅本食谱中的第 处理纯文本、XML 和 JSON 文本文件 章节以了解如何处理 XML 和 JSON 格式的数据。

使用秘密游戏代码来保护排行榜脚本

所展示的 Unity 和 PHP 代码演示了一个简单、未受保护的基于网络的排行榜。为了防止玩家使用虚假分数破解排行榜,我们应该将某种形式的秘密游戏代码(或密钥)编码到通信中。只有包含正确代码的更新请求才会实际导致数据库发生变化。

Unity 代码将秘密密钥(在本例中为harrypotter字符串)与通信相关的内容结合在一起——例如,同一个 MySQL/PHP 排行榜可能为具有不同游戏 ID 的不同游戏有不同的数据库记录:

    // Unity Csharp code
     string key = "harrypotter"
     string gameId = 21;
     string gameCode = Utility.Md5Sum(key + gameId); 

服务器端 PHP 代码将接收加密的游戏代码以及用于创建该加密代码的游戏数据片段。在本例中,是游戏 ID 和 MD5 散列函数,这两个函数在 Unity 和 PHP 中都是可用的。您可以在维基百科上了解更多关于 MD5 散列的信息:en.wikipedia.org/wiki/MD5

秘密密钥(harrypotter)与游戏 ID 结合使用,生成一个加密代码,该代码可以与从 Unity 游戏(或任何尝试与排行榜服务器脚本通信的用户代理或浏览器)接收到的代码进行比较。只有当在服务器上创建的游戏代码与随数据库操作请求发送的代码匹配时,数据库操作才会执行:

    // PHP - security code
     $key = "harrypotter"
     $game_id =  $_GET['game_id'];
     $provided_game_code =  $_GET['game_code'];
     $server_game_code = md5($key.$game_id);

     if( $server_game_code == $provided_game_code ) {
       // codes match - do processing here
     } 

创建和克隆 GitHub 仓库

分布式版本控制系统DVCS)正成为软件开发人员日常生活中的必备工具。Unity 项目的问题可能是每个项目中都有许多二进制文件。在本地系统的 Unity 项目目录中,也有许多文件不需要存档/共享,例如特定操作系统的缩略图文件和垃圾文件。最后,一些 Unity 项目文件夹本身不需要存档,例如 Temp 和 Library。

虽然 Unity 提供了自己的Unity Teams在线协作工具,但许多小型游戏开发者选择不为此额外功能付费。此外,Git 和 Mercurial(最常见的 DVCS)是免费的,并且可以与任何需要维护的文档集(任何编程语言的程序、文本文件等)一起使用。因此,学习如何使用第三方、行业标准 DVCS 来处理 Unity 项目是有意义的。实际上,本书的文档都是使用私有GitHub仓库存档和版本控制的!

在本项目中,我们将使用免费的GitHub服务器创建一个新的在线项目仓库,然后将副本克隆(复制)到本地计算机上。接下来的食谱将把 Unity 项目转移到本地项目仓库,并使用克隆存储的链接将更改后的文件推送到GitHub在线服务器。

注意:所有来自本食谱的项目都已存档在GitHub的公共仓库中,供您阅读、下载、编辑和在您的计算机上运行。尽管在编写本书期间发生了硬盘故障,但由于遵循了本食谱的步骤,因此没有代码丢失。

注意:Git是一个版本控制系统,GitHub是几个在线系统之一,用于托管以Git格式归档的项目。GitHub的一个流行替代品是BitBucket,它可以托管GitMercurial版本控制项目格式。

准备工作

由于这个菜谱展示了如何在GitHub上托管代码,如果你还没有,你需要在 GitHub.com 上创建一个(免费)的GitHub账户。

如果尚未安装,你需要在本地计算机上安装Git作为此菜谱的一部分。了解如何操作,并从以下链接下载客户端:git-scm.com/book/en/Getting-Started-Installing-Git

本菜谱的截图是在 Mac 上创建的。在 Windows 上,你会使用 Git BASH(见gitforwindows.org/)或 Powershell(见docs.microsoft.com/en-us/powershell/)终端窗口进行命令行 Git 操作。

如何操作...

要创建和克隆一个GitHub仓库,请按照以下步骤操作:

  1. 在你的计算机上安装Git的命令行版本。像往常一样,在安装任何新应用程序之前进行系统备份是一个好习惯:git-scm.com/book/en/v2/Getting-Started-Installing-Git

  2. 通过在终端窗口的命令行中输入git来测试你是否已安装Git。你应该会看到显示可能命令选项的文本帮助:

截图

  1. 打开一个网页浏览器并导航到你的GitHub仓库页面:

截图

  1. 点击绿色按钮开始创建一个新的仓库(例如my-github-demo):

    • 为新仓库输入一个名称,例如my-github-demo

    • 点击创建README(重要,这样你就可以将文件克隆到本地计算机)

    • 添加一个.gitignore文件 - 选择 Unity 版本:

截图

.gitignore文件是一个特殊的文件;它告诉版本控制系统哪些文件不需要归档。例如,我们不需要记录 Windows 或 Mac 的图像缩略图文件(DS_STOREThumbs.db)。

  1. 选择好选项后,点击绿色的创建仓库按钮。

  2. 你现在应该被带到仓库内容页面。点击名为“克隆或下载”的绿色下拉菜单,然后点击 URL 复制到剪贴板的工具按钮。这已经复制了连接到 GitHub 并将文件复制到本地计算机所需的特殊GitHub URL:

截图

  1. 打开一个命令行终端窗口,并导航到你希望克隆你的GitHub项目仓库的位置(例如桌面或 Unity-projects)。

  2. 在命令行界面(CLI)中,键入 **git clone**,然后粘贴您的剪贴板中的 URL,它将类似于 github.com/dr-matt-smith/my-unity-github-demo.git

  1. 切换到您的克隆目录,例如 cd my-unity-github-demo

  2. 列出文件。您应该看到您的 README.md 文件,如果您有查看隐藏文件夹和文件的选项,您还会看到 .git.gitignore

  1. 使用 git remote -v 命令查看从您计算机上的项目文件副本存储的链接,然后将其备份到 GitHub 在线仓库:
 $ git remote -v
 origin  https://github.com/dr-matt-smith/my-unity-github-demo.git (fetch)
 origin  https://github.com/dr-matt-smith/my-unity-github-demo.git (push) 

它是如何工作的...

您学习了如何在 GitHub 网络服务器上创建一个新的空仓库。然后您将其克隆到您的本地计算机上。

您还检查了此克隆是否有一个指向其远程源的反向链接。

如果你只是下载并解压了 ZIP 文件,你将没有 .git 文件夹,也不会有指向其 GitHub 源的远程链接。

.git 文件实际上包含了项目仓库文件变更的整个历史记录——并且使用不同的 Git 命令,您可以更新文件夹以重新实例化项目仓库内容中提交的任何快照。

被称为 .gitignore 的特殊文件列出了所有不应存档的文件和目录。在撰写本文时,以下是无需存档的文件内容(它们要么是不必要的,要么在将项目加载到 Unity 时可以重新生成):

         [Ll]ibrary/
     [Tt]emp/
     [Oo]bj/
     [Bb]uild/
     [Bb]uilds/
     Assets/AssetStoreTools*

     # Visual Studio cache directory
     .vs/

     # Autogenerated VS/MD/Consulo solution and project files
     ExportedObj/
     .consulo/
     *.csproj
     *.unityproj
     *.sln
     *.suo
     *.tmp
     *.user
     *.userprefs
     *.pidb
     *.booproj
     *.svd
     *.pdb
     *.opendb

     # Unity3D generated meta files
     *.pidb.meta
     *.pdb.meta

     # Unity3D Generated File On Crash Reports
     sysinfo.txt

     # Builds
     *.apk
     *.unitypackage  

如我们所见,LibraryTemp 等文件夹不应存档。请注意,如果您有一个资源丰富的项目(例如使用 2D 或 3D Gamekits 的项目),根据您计算机的速度,重新构建 Library 可能需要几分钟。

注意,推荐的忽略文件对于 Git 随着 Unity 项目文件夹结构的更改而不断变化。GitHub 为 Unity 提供了一个主推荐的 .gitignore 文件,建议您定期查看它,尤其是在升级到 Unity 编辑器的新版本时:github.com/github/gitignore/blob/master/Unity.gitignore

如果您使用的是较旧(2018 年之前)的 Unity 版本,您可能需要查找适当的 .gitignore 内容。本食谱中给出的详细信息适用于 Unity 编辑器的 2018.2 版本。

还有更多...

这里有一些使用此食谱进一步发展的方法。

了解更多关于分布式版本控制系统(DVCS)的信息

这里是一个关于 DVCS 的简短视频介绍:youtu.be/1BbK9o5fQD4

注意,Fogcreek Kiln 的“和谐”功能现在允许使用相同的 Kiln 仓库在GitMercurial之间无缝工作:blog.fogcreek.com/kiln-harmony-internals-the-basics/

在命令行中了解更多关于 Git 的信息

如果你刚开始使用CLI,跟踪一些在线资源来提高你的技能是非常值得的。任何严肃的软件开发都可能需要在某个时候在命令行中进行一些工作。

由于GitMercurial都是开源的,因此有很多优秀的免费在线资源可用。以下是一些好的起点资源:

  • 了解 Git 的所有信息,下载免费的图形用户界面客户端,甚至可以通过创意共享许可免费在线访问The Pro Git书籍(由 Scott Chacon 编写):git-scm.com/book

  • 你将找到一个在线交互式 Git 命令行来练习:try.github.io/levels/1/challenges/1

使用 Bitbucket 和 SourceTree 可视化应用程序

Unity 提供了一个很好的教程,介绍了使用 Bitbucket 网站和 SourceTree 应用程序进行版本控制:

SourceTree 是一个免费的 Mercurial 和 Git 图形用户界面客户端,可在以下地址获取:

了解 Mercurial 而不是 Git

主 Mercurial 网站,包括对Mercurial的免费在线访问The Definitive Guide(由 Bryan O'Sullivan 编写)可通过开放出版许可在mercurial.selenic.com/获取。

将 Unity 项目添加到本地 Git 仓库,并将文件推送到 GitHub

在上一个菜谱中,你使用免费的GitHub Server创建了一个新的在线项目仓库,然后将其克隆(复制)到本地计算机上。

在这个菜谱中,我们将把 Unity 项目转移到本地项目仓库,并使用克隆时存储的链接将更改后的文件推回 Guthub 在线服务器

准备工作

这个菜谱是基于上一个菜谱的,所以确保你在开始这个菜谱之前已经完成了那个菜谱。

如何做到这一点...

要将 Unity 项目添加到Git仓库,并将其推送到GitHub,请执行以下操作:

  1. 创建一个新的 Unity 项目(或者使用一个旧项目),保存场景,并退出 Unity。例如,我们创建了一个名为project-for-version-control的项目,其中包含默认的SampleScene和一个名为m_red的材质。项目面板中的资产文件是存储在磁盘上的文件,也是你将使用GitGitHub进行版本控制的文件。

在您存档 Unity 项目时,非常重要的一点是所有工作都已保存,Unity 应用程序没有运行,因为如果 Unity 是打开的,可能会有未保存的更改,这些更改将无法正确记录。

  1. 在您的计算机上,将以下文件夹复制到您克隆的GitHub仓库的文件夹中:
     /Assets
     /Plugins (if this folder exists - it may not)
     /ProjectSettings    
     /Packages 
  1. 复制这些内容后的文件夹如下所示:

图片

  1. CLI中,输入git status以查看已更改并需要提交到项目内容下一个快照的文件夹/文件列表。

  2. 通过输入git add添加所有这些文件。

  3. 使用git commit -m "files added to project"命令提交我们的新快照:

 matt$ git commit -m "files added to project"

 [master 1f415a3] files added to project
 23 files changed, 1932 insertions(+)
 create mode 100644 Assets/Scenes.meta
 create mode 100644 Assets/Scenes/SampleScene.unity
      ...         
  1. 我们已经创建了新文件和文件夹的快照,因此现在我们可以将这个新提交的快照推送到 GitHub 云服务器。输入git push origin master
 matt$ git push origin master

 Counting objects: 29, done.
 Delta compression using up to 4 threads.
 Compressing objects: 100% (27/27), done.
 Writing objects: 100% (29/29), 15.37 KiB | 0 bytes/s, done.
 Total 29 (delta 0), reused 0 (delta 0)
 To https://github.com/dr-matt-smith/my-unity-github-demo.git
 1b27686..1f415a3  master -> master
 matt$ 

注意:第一次这样做时,您将被要求输入您的GitHub用户名和密码。

  1. 访问GitHub,您应该会看到有一个新的提交,并且您的 Unity 文件和文件夹已上传到GitHub在线仓库:

图片

它是如何工作的...

在上一个菜谱中,您在GitHub服务器上创建了一个项目仓库,然后将其克隆到您的本地机器上。然后,您将来自(关闭的)Unity 应用程序的文件添加到克隆的项目仓库文件夹中。这些新文件被添加并提交到一个新的文件夹快照中,并将更新的内容推送到GitHub 服务器

由于 Unity 的工作方式,当创建新项目时,它会创建一个新的文件夹。因此,我们必须将 Unity 项目文件夹的内容转换为Git仓库。有两种方法可以做到这一点:

  1. 将 Unity 项目的文件复制到克隆的GitHub仓库中(以便设置远程链接以推送到GitHub服务器)。

  2. 将 Unity 项目文件夹转换为Git仓库,然后将其链接到远程GitHub仓库,或者将文件夹内容推送到GitHub服务器,并在该点创建一个新的在线仓库

对于 Git 和 GitHub 的新手来说,我们在这道菜谱中遵循的第一个步骤可能是最容易理解的。此外,如果出现问题,它也最容易修复——因为可以创建一个新的 GitHub 仓库,克隆到本地机器上,并将 Unity 项目文件复制到那里,然后推送到 GitHub(并删除旧仓库),基本上遵循相同的步骤集。

有趣的是,当使用开源 GitHub for Unity包时,建议使用第二种方法——这将在下一个菜谱中探讨。

使用 GitHub for Unity 进行 Unity 项目版本控制

GitHub发布了一个将GitGitHub集成到 Unity 中的开源工具,我们将在本菜谱中探讨。

准备工作

您需要在您的计算机的命令行上安装Git

您可能需要安装 Git LFS(大型文件存储)以使 GitHub for Unity 包正常工作:

您可能希望创建一个.gitattributes 文件来指定哪些文件应该使用 Git 大型文件存储 (LFS)。为了获得一些指导,请查看 Rob Reilly 的有用文章,如何使用 Git 与 Unityrobots.thoughtbot.com/how-to-git-with-unity

如何操作...

要使用 GitHUb for Unity 管理 Unity 项目的版本控制,请按照以下步骤操作:

  1. 开始一个新的 Unity 项目。

  2. 打开 Asset Store 面板,选择菜单:窗口 | 通用 | Asset Store。

  3. 在 Asset Store 中搜索 GitHub for Unity,下载并将其导入到您的项目中:

图片

  1. 如果出现有关新版本的弹出窗口,请接受它并下载新版本。这将作为一个 Unity 包(可能位于您的下载文件夹中)下载,然后您可以将其导入到您的 Unity 项目中。

  2. 导入后,您应该在项目面板中看到一个新的 Plugins | GitHub 文件夹。您现在也会在窗口菜单中看到两个新的项目,分别是 GitHub 和 GitHub 命令行:

图片

  1. 选择窗口 | GitHub 命令行允许您使用前两个菜谱中列出的 Git 命令(它将在您的 Unity 项目目录中打开)。

  2. 选择窗口 | GitHub 菜单项将导致显示 GitHub 面板。最初此项目不是Git仓库,因此需要将其初始化为新的Git项目,这可以通过点击“为此项目初始化为 git 仓库”按钮来完成:

图片

  1. 您将能够看到有一个提交,这是为此项目的 Git 版本控制跟踪初始化的快照:

图片

  1. 使用您的GitHub用户名和密码登录:

图片

  1. 打开网页浏览器,登录您的GitHub账户,并创建一个新的空仓库(没有额外文件,即没有README.gitignoreLicence):

图片

  1. 将新仓库的 URL 复制到您的剪贴板:

图片

  1. 在 Unity 中,对于 GitHub 面板,点击设置按钮,粘贴远程:origin属性的 URL,然后点击“保存仓库”按钮以保存此更改。您的 Unity 项目现在已链接到远程GitHub云仓库:

图片

  1. 您现在可以从 Unity 项目提交并推送更改到GitHub

  2. 添加一些新的资源(例如一个新的 C#脚本和一个名为m_redMaterial)。在 GitHub 面板中点击“更改”标签页,确保完整的Assets文件夹被选中(及其所有内容),简要描述更改,然后点击“提交到[master]”按钮:

  1. 你现在已经在你的电脑上有一个提交的新 Unity 项目内容快照。通过点击“推送到(1)”按钮,将这个新的提交快照推送到GitHub服务器。这里的(1)表示本地有一个尚未推送的新提交快照,也就是说,本地机器比GitHub 服务器上的 master 领先 1 个提交:

  1. 在你的网络浏览器中访问GitHub上的仓库,你会看到 Unity 项目内容的新提交快照已经从你的电脑推送到GitHub服务器

工作原理...

GitHub for Unity包添加了一个具有以下Git/GitHub操作功能的特殊面板:

  • 为当前 Unity 项目初始化一个新的Git项目仓库

  • 使用你的GitHub用户名和密码凭证登录

  • 将 Unity 项目的Git历史记录链接到远程GitHub在线仓库

  • 将 Unity 项目中你希望记录的更改快照提交

  • 将提交的更改推送到远程GitHub在线仓库

还有更多...

这里有一些使用此食谱进一步发展的方法。

关于 GitHub for Unity 的进一步阅读

查看以下链接以获取更多信息:

从其他开发者那里拉取更新

GitHub插件还提供了从远程GitHub仓库拉取更改到你的电脑的功能(如果你在多台电脑上工作,或者有其他游戏开发者帮助你添加游戏功能,这很有用)。

如果你与其他游戏开发者一起工作,了解 Git 分支非常有用。在开始新功能的工作之前,执行一次 pull 操作,以确保你正在使用项目最新版本的更新。

Unity Collaborate 由 Unity Technologies 提供

虽然许多 Unity 项目使用GitGitHub,但它们是通用的版本控制技术。Unity Technologies 为开发者和团队提供了一个自己的在线系统,以便他们可以协作工作在同一 Unity 项目上,然而,这个功能已不再包含在免费的 Unity 许可证计划中。

在 Unity 网站上了解更多关于Unity CollaborateUnity Teams的信息:

防止你的游戏在未知服务器上运行

在完成你的网页游戏项目所必须经历的艰辛工作之后,如果它最终在别人的网站上产生流量和收入,那就太不公*了。在这个配方中,我们将创建一个脚本,以防止除非由授权 服务器 托管,否则主游戏菜单显示。

准备工作

对于这个配方,你需要访问一个可以托管游戏的网络空间提供商。然而,你可以使用本地主机网络 服务器 进行测试,例如内置的 PHP 服务器,或者 AMP Apache 或 Nginx 网络服务器。

你还需要安装 Unity WebGL 构建目标。

注意:在撰写本文时(2018 年夏季),对于 macOS 计算机,如果在通过 Unity Hub 进行原始安装时未包含 WebGL 包,则仍然存在添加 WebGL 包的问题。macOS WebGL 包需要 Unity 应用程序位于 Applications | Unity 文件夹中。通过 Unity Hub 安装时,它实际上位于 Applications | Unity | Hub | Editor | 2018.2.2f1。如果你有多个版本,它们将各自位于 Editor 文件夹中。为特定 Unity 版本安装 WebGL 的解决方案是将 Applications | Unity | Hub 文件夹临时移动到其他位置(例如桌面),然后将你的 Unity 版本文件夹(对我来说是 2018.2.2f1)的内容复制(或临时移动)到 Applications | Unity。然后你可以成功运行 WebGL 包安装程序,它将在 Applications | Unity 的 PlaybackEngines 文件夹中添加。最后,你可以将 Unity 应用程序移回它们原来的位置,以便 Unity Hub 继续工作。

如何操作...

要防止你的网页游戏被盗版,请按照以下步骤操作:

  1. 从层次结构中创建一个名为 Text-loading-warning 的 UI Text GameObject,选择菜单:创建 | UI | 文本。

  2. Text 组件的检查器中输入 Loading...

  3. 检查器 中设置 Text (Script) 组件的属性,将 水*垂直溢出 设置为 溢出,并使用 Rect Transform 将对象对齐到 场景 的中心。使字体大小适中(50)。

  4. 创建一个新的 C# 脚本类名为 BlockAccess,并将实例对象作为组件添加到 Text-loading-warning GameObject:

    using UnityEngine;
     using System.Collections;
     using UnityEngine.UI;
     using UnityEngine.SceneManagement;

     public class BlockAccess : MonoBehaviour {
         public Text textUI;
         public string warning;
         public bool fullURL = true;
         public string[] domainList;

         private void Start() {
             Text scoreText = GetComponent<Text>();
             if (Application.platform == RuntimePlatform.WebGLPlayer)
             {
                 string url = Application.absoluteURL;
                 if (LegalCopy(url))
                     LoadNextScene();
                 else
                     textUI.text = warning;
             }
         }

         private bool LegalCopy(string Url) {
            if (Application.isEditor)
             return true;

             for (int i = 0; i < domainList.Length; i++){
                 if (Application.absoluteURL == domainList[i])
                     return true;

                 if (Application.absoluteURL.Contains(domainList[i]) && !fullURL)
                     return true;
             }

             return false;
         }

         private void LoadNextScene() {
             int currentSceneIndex = SceneManager.GetActiveScene().buildIndex;
             int nextSceneIndex = currentSceneIndex + 1;
             SceneManager.LoadScene(nextSceneIndex);
         }
    }
  1. 在检查器中,保留 Full URL 选项选中,将域名列表的大小增加到 1,并在元素 0 中填写你游戏的完整 URL。在警告字段中,输入 "这不是一个有效的游戏副本":

  1. 将你的场景保存为 scene0-loading。将此场景添加到构建(菜单:文件 | 构建设置...)。它应该是第一个,索引为 0。

  2. 创建一个新的场景,将主相机的背景颜色更改为黑色,并添加一个 UI Text 消息,说明游戏现在将播放。

  3. 将这个场景保存为 scene1-gamePlaying。将这个场景添加到构建中——它应该是第二个,索引为 1。

  4. 让我们构建我们的 WebGL 文件。再次打开构建设置面板,这次确保部署*台是 WebGL。点击构建按钮,并选择构建文件的名称和文件夹位置:

图片

  1. 现在,你应该有一个包含 HTML 文件(index.html)以及 BuildTemplateData 文件夹的文件夹:

图片

  1. 将这些文件夹内容复制到你的网络 服务器 的公共文件夹中,并使用浏览器通过网络 服务器 访问网页。

  2. 如果你的 URL 在列表中,你会看到游戏播放(可能伴随着 加载... 场景 消息的短暂显示)。如果你的 URL 不在列表中,你会看到 这不是有效的游戏副本 消息,并且游戏将无法开始播放。

如果你尝试使用你的网络浏览器打开文件,你可能会遇到 WebGL 无法工作错误。你必须通过网络服务器(例如 localhost:8000 或从你的公开托管页面)访问 HTML 页面。

它是如何工作的...

一旦 场景 开始,脚本会将运行中的 Unity 生成的 WebGL 网页的实际 URL 与 BlockAccess 脚本组件中列出的 URL 进行比较。如果不匹配,构建的下一级将不会加载,并在屏幕上显示消息。如果匹配,构建列表中的下一个场景将被加载。

更多内容...

这里有一些方法可以进一步使用这个食谱。

在 Google Chrome 中启用 WebGL

对于这个食谱,你需要在你的网络浏览器中启用 WebGL。目前,我们的测试浏览器(Google Chrome)默认禁用了 WebGL。要在 Google Chrome 中启用 WebGL,请执行以下操作:

  1. 打开 Google Chrome 并输入 chrome://flags URL。

  2. 定位到 Web GL Draft Extensions(搜索 WebGL)。

  3. 使用下拉菜单将状态从 禁用 切换到 启用

  4. 点击 立即重新启动 按钮以使用新设置重新启动应用程序:

图片

  1. 现在,你应该能够使用嵌入 WebGL 内容的网页。

通过在域名列表中使用完整 URL 提高安全性

如果你在域名列表中填写完整的 URL,例如 www.myDomain.com/unitygame/index.html,而不是仅仅填写主域名,例如 www.myDomain.com,那么你的游戏将更加安全。实际上,建议你选择保留完整 URL 选项,这样你的游戏就不会被盗用并在类似 www.stolenGames.com/yourgame.html?www.myDomain.com 这样的 URL 下发布。

第十二章:控制和选择位置

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

  • 玩家控制 2D 游戏对象(并在矩形内限制移动)

  • 玩家控制 3D 游戏对象(并在矩形内限制移动)

  • 选择目的地 – 寻找随机生成点

  • 选择目的地 – 寻找最*的生成点

  • 选择目的地 – 回到最*通过的检查点重生

  • 通过点击移动对象

  • 向运动方向发射弹体

简介

游戏中的许多游戏对象(GameObjects)都在移动!移动可以由玩家控制,由环境中的(模拟的)物理定律控制,或者由非玩家角色(NPC)逻辑控制;例如,跟随路径的航点对象,或者寻找(朝向)或逃离(远离)角色的当前位置。Unity 为第一人称和第三人称角色以及汽车和飞机等车辆提供了几个控制器。游戏对象(GameObject)的移动也可以通过 Unity Mecanim 动画系统的状态机来控制。

然而,可能会有时候你想调整 Unity 中的玩家角色控制器,或者编写自己的。你可能希望编写方向逻辑——简单或复杂的人工智能(Artificial Intelligence)来控制游戏中的 NPC 和敌人角色。这种 AI 可能涉及你的计算机程序使对象朝向或远离角色或其他游戏对象。

本章(以及随后的章节)介绍了一系列这样的方向性食谱,许多游戏可以从更丰富和更令人兴奋的用户体验中受益。

Unity 提供了复杂的类和组件,包括Vector3类和刚体物理,用于在游戏中建模现实运动、力和碰撞。我们利用这些游戏引擎功能来实现本章食谱中的一些复杂的 NPC 和敌人角色移动。

整体概念

对于 3D 游戏(以及在一定程度上,2D 游戏),一个基本的对象类别是存储和操作表示 3D 空间中位置的(x, y, z)值的Vector3类对象。如果我们从一个原点(0, 0, 0)到空间中的一个点画一个想象中的箭头,那么这个箭头的方向和长度(向量)可以表示速度或力(即,在某个方向上的一定量的量度)。

如果我们忽略 Unity 中的所有角色控制器组件、碰撞体和物理系统,我们可以编写代码将对象直接传送到场景中的特定(x, y, z)位置。有时,这正是我们想要的;例如,我们可能希望在一个位置生成一个对象。然而,在大多数情况下,如果我们想让对象以更物理现实的方式移动,那么我们要么对对象的刚体(RigidBody)施加力,要么改变其速度分量。或者,如果它有一个角色控制器(Character Controller)组件,那么我们可以发送一个Move()消息给它。

NPC 对象移动和创建(实例化)的一些重要概念包括以下内容:

  • 生成点:场景中创建或移动对象的具体位置

  • 检查点:一旦通过,就会改变游戏后期发生的事情的位置(或碰撞体)(例如,额外时间,或者如果玩家的角色被杀死,他们将重生到最后一个通过的检查点,等等)

  • 航标点:定义 NPC 或玩家角色跟随的路径的一系列位置

在本章中,我们将介绍一些配方,并展示关于角色控制、生成点和检查点的几种方法。在下一章中,我们将探讨 AI 控制角色的航标点。

你可以在unity3d.com/learn/tutorials/modules/beginner/2d/2d-controllers了解更多关于 Unity 2D 角色控制器的信息。

你可以在docs.unity3d.com/Manual/class-CharacterController.htmlunity3d.com/learn/tutorials/projects/survival-shooter/player-character了解 Unity 3D 角色组件和控制。

每个游戏都需要纹理。以下是一些适合许多游戏的免费纹理来源:

玩家对 2D GameObject 的控制(以及限制其在矩形内的移动)

虽然本章中的其余配方都是在 3D 项目中演示的,但基本的 2D 角色移动以及限制移动到边界矩形是许多 2D 游戏的核心技能,因此这个第一个配方说明了如何为 2D 游戏实现这些功能。

由于在第三章,库存 UI中,我们已经创建了一个基本的 2D 游戏,我们将适应这个游戏以限制移动到边界矩形内:

图片

准备工作

这个配方基于第三章(c6ad221f-b476-4471-8259-9ad448749a32.xhtml)库存 UI的第一个配方中的简单 2D 游戏 Simple2DGame_SpaceGirl 迷你游戏。从这个游戏的副本开始,或者使用提供的完成配方项目作为这个配方的基础。你可以从github.com/dr-matt-smith/unity-cookbook-2018-ch03下载完成的项目。

如何做到这一点...

要创建一个用户控制的 2D 精灵,其移动限制在矩形内,请按照以下步骤操作:

  1. 创建一个新的空 游戏对象,命名为 corner_max,并将其放置在名为 player_spaceGirl游戏对象上方和右侧。在层次结构视图中选择此 游戏对象后,选择检查器面板中突出显示的大黄色椭圆形图标:

图片

  1. 复制 corner_max 游戏对象,将副本命名为 corner_min,并将其放置在玩家-spaceGirl 游戏对象下方和左侧。这两个 游戏对象的坐标将确定玩家角色的最大和最小移动范围。

  2. 修改名为 PlayerMove 的 C# 脚本,在类开始处声明一些新的变量:

     public Transform corner_max;
     public Transform corner_min;
     private float x_min;
     private float y_min;
     private float x_max;
     private float y_max; 
  1. 修改名为 PlayerMove 的 C# 脚本,以便 Awake() 方法现在获取 SpriteRenderer 的引用,并使用此对象来帮助设置最大和最小 X 和 Y 移动限制:
    void Awake(){
       rigidBody2D = GetComponent<Rigidbody2D>();
       x_max = corner_max.position.x;
       x_min = corner_min.position.x;
       y_max = corner_max.position.y;
       y_min = corner_min.position.y;
     } 
  1. 修改名为 PlayerMove 的 C# 脚本,声明一个名为 KeepWithinMinMaxRectangle() 的新方法:
  private void KeepWithinMinMaxRectangle(){
     float x = transform.position.x;
     float y = transform.position.y;
     float z = transform.position.z;
     float clampedX = Mathf.Clamp(x, x_min, x_max);
     float clampedY = Mathf.Clamp(y, y_min, y_max);
     transform.position = new Vector3(clampedX, clampedY, z);
   } 
  1. 修改名为 PlayerMove 的 C# 脚本,以便在 FixedUpdate() 方法更新速度后,调用 KeepWithinMinMaxRectangle() 方法:
  void FixedUpdate(){
     rigidBody2D.velocity = newVelocity;

     // restrict player movement
     KeepWithinMinMaxRectangle();
   } 
  1. 层次结构 视图中选择玩家-spaceGirl 游戏对象,将 corner_max 和 corner_min 游戏对象拖动到检查器中名为 corner_max 和 corner_min 的公共变量上。

场景 面板中运行场景之前,尝试重新定位 corner_max 和 corner_min 游戏对象。当你运行场景时,这两个 游戏对象(最大和最小,以及 X 和 Y)的位置将被用作玩家玩家空间女孩角色的移动限制。

它是如何工作的...

您已将名为 corner_max 和 corner_min 的空 游戏对象添加到场景中。这些 游戏对象的 X 和 Y 坐标将用于确定我们允许名为 player-spaceGirl 的角色进行的移动范围。由于这些是空 游戏对象,因此在游戏模式下玩家将看不到它们。然而,我们可以在 场景 面板中看到并移动它们,并且添加了黄色椭圆形图标后,我们可以很容易地看到它们的位置和名称。

PlayerMoveWithLimits 对象上使用 Awake() 方法时,在玩家空间女孩 GameObject 内部,记录了名为 corner_max 和 corner_min 的 GameObjects 的最大和最小 X 和 Y 值。每次通过 FixedUpdate() 方法调用物理系统时,玩家空间女孩角色的速度都会更新到 Update() 方法中设置的值,该值基于水*和垂直键盘/摇杆输入。然而,FixedUpdate() 方法的最终操作是调用 KeepWithinMinMaxRectangle() 方法,该方法使用 Math.Clamp(...) 函数将角色移动回 X 和 Y 限制内。这样,玩家的角色不允许移动到由 corner_max 和 corner_min GameObjects 定义的区域内。

我们一直遵循一个很好的经验法则:

“始终在 Update() 中监听 输入。”

总是在 FixedUpdate() 中应用 物理

在 Unity Answers 线程中了解更多关于为什么我们不应该在 FixedUpdate() 中检查输入的原因(该线程也是用户 Tanoshimi 之前引用的来源),请参阅 answers.unity.com/questions/1279847/getaxis-being-missed-in-fixedupdate-work-around.html

还有更多...

有些细节你不希望错过。

绘制辅助黄色矩形以视觉上显示边界矩形

作为开发者,在运行测试我们的游戏时,看到像边界矩形这样的元素是有用的。让我们通过在场景面板中绘制一个黄色的“辅助”矩形,使运动的矩形边界在黄色线条中视觉上明确。将以下方法添加到名为 PlayerMove 的 C# 脚本类中:

    void OnDrawGizmos(){
       Vector3 top_right = Vector3.zero;
       Vector3 bottom_right = Vector3.zero;
       Vector3 bottom_left = Vector3.zero;
       Vector3 top_left = Vector3.zero;

       if(corner_max && corner_min){
         top_right = corner_max.position;
         bottom_left = corner_min.position;

         bottom_right = top_right;
         bottom_right.y = bottom_left.y;

         top_left = top_right;
         top_left.x = bottom_left.x;
       }

       //Set the following gizmo colors to YELLOW
       Gizmos.color = Color.yellow;

       //Draw 4 lines making a rectangle
       Gizmos.DrawLine(top_right, bottom_right);
       Gizmos.DrawLine(bottom_right, bottom_left);
       Gizmos.DrawLine(bottom_left, top_left);
       Gizmos.DrawLine(top_left, top_right);
     } 

OnDrawGizmos() 方法检查对 corner_max 和 corner_min GameObjects 的引用是否不为空,然后设置代表由矩形定义的四个角的四个 Vector3 对象的位置,其中 corner_max 和 corner_min 位于对角。然后设置 Gizmo 颜色为黄色,并在 场景 面板中绘制线条,连接四个角。

参见

  • 参考以下菜谱以获取有关限制玩家控制角色移动的更多信息。

3D GameObject 的玩家控制(并在矩形内限制移动)

本章中的许多 3D 菜单都是基于这个基本项目构建的,该项目构建了一个带有纹理地形、主摄像机和可以由用户使用四个方向箭头移动的红色立方体的场景。使用与上一个 2D 菜单中相同的技术约束立方体的运动范围:

如何做到这一点...

要创建一个基本的 3D 立方体控制游戏,请按照以下步骤操作:

  1. 创建一个新的空 3D 项目。

  2. 一旦项目创建完成,通过选择菜单:资产 | 导入包 | 环境,导入名为 SandAlbedo 的单个地形纹理。取消选择所有内容,然后转到Assets/Environment/TerrainAssets/SurfaceTextures/ SandAlbedo.psd找到并勾选该资产。

你本可以在创建项目时添加环境资产包,但这会导入数百个文件,而我们只需要这一个。如果你想要保持项目资产文件夹的大小尽可能小,那么在 Unity 中开始一个项目然后只选择性地导入所需内容是最好的方法。

  1. 通过选择菜单:创建 | 3D 对象 | 地形来创建一个新的地形。在层次结构中选择这个新的地形GameObject,在其检查器属性中,将大小设置为 30 x 20,并将位置设置为(-15, 0, -10):

地形的变换位置与其角落相关,而不是与其中心相关。

由于地形的变换位置与对象的角落相关,我们通过将 X 坐标设置为(-1width/2),Z 坐标设置为(-1length/2)来将此类对象居中在(0, 0, 0)。换句话说,我们通过对象宽度的一半和高度的一半滑动对象,以确保其中心正好在我们想要的位置。

在这种情况下,宽度是 30,长度是 20,因此我们得到 X 坐标为-15(-1 * 30/2),Z 坐标为-10(-1 * 20/2)。

图片

  1. 使用你的纹理 SandAlbedo 绘制此地形。

  2. 对主摄像机进行以下更改:

    • 位置:(0, 20, -15)

    • 旋转:(60, 0, 0)

  3. 游戏面板的纵横比从自由纵横比更改为 4:3。现在你将在游戏面板中看到整个地形。

  4. 创建一个新的空GameObject,命名为 corner_max,并将其定位在(14, 0, 9)。在层次结构中选择此GameObject,选择检查器面板中突出显示的大、黄色椭圆形图标。

  5. 复制角落最大值 GameObject,将副本命名为 corner_min,并将此副本定位在(-14, 0, -9)。这两个GameObject的坐标将决定玩家角色允许移动的最大和最小边界。

  6. 通过选择菜单:创建 | 3D 对象 | 立方体创建一个新的立方体GameObject。将其命名为Cube-player,并将位置设置为(0, 0.5, 0),大小为(1, 1, 1)。

  7. 向 Cube-player GameObject添加刚体组件(物理 | 刚体),并取消勾选刚体属性使用重力。

  8. 创建一个名为m_red的红色材质,并将其应用到 Cube-player 上。

  9. 创建一个名为PlayerControl的 C#脚本类,并将实例对象作为组件添加到GameObject Cube-player:

using UnityEngine;

public class PlayerControl : MonoBehaviour {
   public Transform corner_max;
         public Transform corner_min;
         public float speed = 40;
         private Rigidbody rigidBody;
         private float x_min;
         private float x_max;
         private float z_min;
         private float z_max;
         private Vector3 newVelocity;

    void Awake() {
       rigidBody = GetComponent<Rigidbody>();
       x_max = corner_max.position.x;
       x_min = corner_min.position.x;
       z_max = corner_max.position.z;
       z_min = corner_min.position.z;
    }

private void Update() {
    float xMove = Input.GetAxis("Horizontal") * speed * Time.deltaTime;
    float zMove = Input.GetAxis("Vertical") * speed * Time.deltaTime;
    float xSpeed = xMove * speed;
    float zSpeed = zMove * speed;
    newVelocity = new Vector3(xSpeed, 0, zSpeed);
}

void FixedUpdate() {
    rigidBody.velocity = newVelocity;
    KeepWithinMinMaxRectangle();
}

 private void KeepWithinMinMaxRectangle() {
   float x = transform.position.x;
   float y = transform.position.y;
   float z = transform.position.z;
   float clampedX = Mathf.Clamp(x, x_min, x_max);
   float clampedZ = Mathf.Clamp(z, z_min, z_max);
   transform.position = new Vector3(clampedX, y, clampedZ);
 }
 }
  1. 在层次结构中选择 Cube-player GameObject,将名为 corner_max 和 corner_min 的GameObject拖到检查器面板中的公共变量 corner_max 和 corner_min 上。

当您运行场景时,corner_max 和 corner_min GameObjects 的位置将定义玩家立方体-玩家角色的移动范围。

它是如何工作的...

场景包含一个定位的地形,使其中心位于 (0, 0, 0)。红色立方体通过 PlayerControl 脚本由用户的箭头键控制。

就像之前的 2D 配方一样,当 Awake() 方法执行时,会存储对 (3D) RigidBody 组件的引用,并从两个角 GameObjects 中检索最大和最小 X 和 Z 值,并将它们存储在 x_minx_maxz_minz_max 变量中。请注意,对于这个基本的 3D 游戏,我们不会允许任何 Y 方向的运动,尽管可以通过扩展此配方中的代码轻松添加此类运动(以及通过添加第三个最大高度角 GameObject 的边界限制)。

KeyboardMovement() 方法读取水*和垂直输入值(Unity 默认设置从四个方向箭头键读取)。根据这些左右和上下值,更新立方体的速度。它将移动的量取决于速度变量。

KeepWithinMinMaxRectangle() 方法使用 Math.Clamp(...) 函数将角色移动回 X 和 Z 的限制范围内,以便玩家的角色不允许移动到由 corner_max 和 corner_min GameObjects 定义的区域内。

还有更多...

有一些细节您不想错过。

绘制一个黄色的 gizmo 矩形以直观地显示边界矩形

作为开发者,在测试运行我们的游戏时,看到像边界矩形这样的元素是有用的。让我们通过在 场景 面板中绘制一个黄色的“gizmo”矩形,使移动的矩形边界在黄色线条中直观地显示出来。将以下方法添加到名为 PlayerMove 的 C# 脚本类中:

void OnDrawGizmos (){
         Vector3 top_right = Vector3.zero;
         Vector3 bottom_right = Vector3.zero;
         Vector3 bottom_left = Vector3.zero;
         Vector3 top_left = Vector3.zero;

         if(corner_max && corner_min){
           top_right = corner_max.position;
           bottom_left = corner_min.position;

           bottom_right = top_right;
           bottom_right.z = bottom_left.z;

           top_left = bottom_left;
           top_left.z = top_right.z;
         }

         //Set the following gizmo colors to YELLOW
         Gizmos.color = Color.yellow;

         //Draw 4 lines making a rectangle
         Gizmos.DrawLine(top_right, bottom_right);
         Gizmos.DrawLine(bottom_right, bottom_left);
         Gizmos.DrawLine(bottom_left, top_left);
         Gizmos.DrawLine(top_left, top_right);
       } 

OnDrawGizmos() 方法检查对 corner_max 和 corner_min GameObjects 的引用是否不为空,然后设置代表矩形四个角的四个 Vector3 对象的位置,这些对象位于相对的角上。然后,它将 Gizmo 颜色设置为黄色,并在 场景 面板中绘制线条,连接四个角。

选择目的地 – 寻找一个随机出生点

许多游戏都使用出生点和航点。此配方演示了选择一个随机出生点,然后在所选点实例化一个对象。

准备工作

此配方基于之前的配方。因此,复制此项目,打开它,然后按照下一节中的步骤进行操作。

如何操作...

要找到一个随机出生点,请按照以下步骤操作:

  1. 在场景面板中,创建一个大小为 (1, 1, 1) 的球体(通过导航到 Create | 3D Object | Sphere),位置在 (2, 2, 2),并应用 m_red 材质

  2. 在项目面板中,创建一个新的 Prefab(通过转到 Create | Prefab),命名为 Prefab-ball,并将你的球体拖入其中(然后从层次结构面板中删除球体)。

  3. 在场景面板中,创建一个新的胶囊(通过导航到 Create | 3D Object | Capsule),命名为 Capsule-spawnPoint,位置在 (3, 0.5, 3),并给它一个标记为 Respawn(这是 Unity 提供的默认标记之一):

为了测试,我们将保留这些重生点的可见性。在最终游戏中,我们将取消选中每个重生 GameObject 的“网格渲染”选项,以便它们对玩家不可见。

图片

  1. 制作几个你的胶囊重生点的副本,将它们移动到地形上的不同位置。

  2. 创建一个名为 SpawnBall 的 C# 脚本类,并将其作为组件添加到 Cube-player GameObject

    using UnityEngine;

     public class BallSpawner : MonoBehaviour {
         public GameObject prefabBall;
         private SpawnPointManager spawnPointManager;
         private float timeBetweenSpawns = 1;

         void Start () {
             spawnPointManager = GetComponent<SpawnPointManager> ();
             InvokeRepeating("CreateSphere", 0, timeBetweenSpawns);
         }

         private void CreateSphere() {
             GameObject spawnPoint = 
             spawnPointManager.RandomSpawnPoint();

             GameObject newBall = (GameObject)Instantiate(
                 prefabBall, spawnPoint.transform.position, 
                 Quaternion.identity);
             Destroy(newBall, timeBetweenSpawns/2);
         }
     } 
  1. 创建一个名为 SpawnPointManager 的 C# 脚本类,并将其作为组件添加到 Cube-player GameObject
    using UnityEngine;

     public class SpawnPointManager : MonoBehaviour {
         private GameObject[] spawnPoints;

         void Start() {
             spawnPoints = GameObject.FindGameObjectsWithTag("Respawn");
         }

         public GameObject RandomSpawnPoint() {
             int r = Random.Range(0, spawnPoints.Length);
             return spawnPoints[r];
         }
     } 
  1. 确保在检查器中选择 Cube-player 以用于 SpawnBall 脚本组件。然后,将 Prefab-ball 拖动到名为 public variable projectile 的公共变量“Prefab Ball”上。

  2. 现在,运行你的游戏。每秒应该生成一个红色球体,并在半秒后消失。每个球体生成的位置应该是随机的。

它是如何工作的...

Capsule-spawnPoint 对象代表可能创建我们的球体 Prefab 实例的候选位置。当我们的 SpawnPointManager 对象在 Cube-player GameObject 内接收到 Start() 消息时,重生后的 GameObject 数组被设置为从 FindGameObjectsWithTag("Respawn") 调用返回的数组。这创建了一个具有 Respawn 标记的所有场景对象的数组,即我们的所有 Capsule-spawnPoint 对象。

当我们的 SpawnBall 对象 GameObject Cube-player 接收到 Start() 消息时,它将 spawnPointManager 变量设置为对其兄弟 SpawnPointManager 脚本组件的引用。接下来,我们使用 InvokeRepeating(...) 方法安排每秒调用一次 CreateSphere() 方法。

SpawnBall 方法 CreateSphere()spawnPoint 变量赋值给由我们 spawnPointManagerRandomSpawnpoint(...) 方法返回的 GameObject。然后,它通过公共变量在与 spawnPoint GameObject 相同的位置创建一个新的 prefab_ball 实例。

参见

相同的技术和代码可以用于选择重生点或航点。有关航点的更多信息,请参考下一章中的“NPC NavMeshAgent 控制以顺序跟随航点”配方(导航网格和代理)。

选择目的地 – 寻找最*的重生点

而不是仅仅选择一个随机的出生点或航点,有时我们希望选择离某个对象(如玩家的 GameObject)最*的那个。在这个配方中,我们将修改之前的配方以找到离玩家立方体最*的出生点,并使用该位置生成一个新的红色球体预制体。

准备就绪

此配方基于之前的配方。因此,复制此项目,打开它,然后按照下一节中的步骤操作。

如何操作...

要找到最*的出生点,请按照以下步骤操作:

  1. 将以下方法添加到名为 SpawnPointManager 的 C# 脚本类中:
    public GameObject NearestSpawnpoint (Vector3 source){
       GameObject nearestSpawnPoint = spawnPoints[0];
       Vector3 spawnPointPos = spawnPoints[0].transform.position;
       float shortestDistance = Vector3.Distance(source, spawnPointPos);

       for (int i = 1; i < spawnPoints.Length; i++){
         spawnPointPos = spawnPoints[i].transform.position;
         float newDist = Vector3.Distance(source, spawnPointPos);
         if (newDist < shortestDistance){
           shortestDistance = newDist;
           nearestSpawnPoint = spawnPoints[i];
         }
       }

       return nearestSpawnPoint;
     } 
  1. 现在,我们需要更改名为 SpawnBall 的 C# 类中的第一行,以便通过调用我们新方法 NearestSpawnpoint(...) 来设置 spawnPoint 变量:
    private void CreateSphere(){
       GameObject spawnPoint = 
       spawnPointManager.NearestSpawnpoint(transform.position);

       GameObject newBall = (GameObject)Instantiate (prefabBall, 
       spawnPoint.transform.position, Quaternion.identity);
       Destroy(newBall, timeBetweenSpawns/2);
     } 
  1. 现在,运行你的游戏。每秒应该生成一个红色球体,半秒后消失。使用箭头键移动玩家的红色立方体在场景中。每次生成新的球体时,它应该位于离玩家最*的出生点。

它是如何工作的...

NearestSpawnpoint(...) 方法中,我们将 nearestSpawnpoint 设置为数组中的第一个(数组索引 0GameObject 作为默认值。然后我们遍历数组的其余部分(数组索引 1spawnPoints.Length)。对于数组中的每个 GameObject,我们测试其距离是否小于迄今为止的最短距离,如果是,则更新最短距离,并将 nearestSpawnpoint 设置为当前元素。当数组搜索完毕后,我们返回 nearestSpawnpoint 变量所引用的 GameObject

更多内容...

有一些细节你不希望错过。

避免因数组为空而导致的错误

让我们使我们的代码更加健壮,以便它可以处理空 spawnPoints 数组的问题,即场景中没有标记为 Respawn 的对象。

为了处理没有标记为 Respawn 的对象,我们需要执行以下操作:

  1. 改进名为 SpawnPointManager 的 C# 脚本类中的 Start() 方法,以便如果标记为 Respawn 的对象数组为空,则记录一个 错误
    public GameObject NearestSpawnpoint (Vector3 source){
     void Start() {
       spawnPoints = GameObject.FindGameObjectsWithTag("Respawn");

       // logError if array empty
       if(spawnPoints.Length < 1)
         Debug.LogError ("SpawnPointManagaer - cannot find any objects 
         tagged 'Respawn'!");
     } 
  1. 改进名为 SpawnPointManager 的 C# 脚本类中的 RandomSpawnPoint()NearestSpawnpoint() 方法,以确保即使数组为空,它们仍然返回一个 GameObject
    public GameObject RandomSpawnPoint (){
       // return current GameObject if array empty
       if(spawnPoints.Length < 1)
         return null;

     // the rest as before ... 
  1. 改进名为 SpawnBall 的 C# 类中的 CreateSphere() 方法,以便只有在 RandomSpawnPoint()NearestSpawnpoint() 方法返回非空对象引用时,才尝试实例化新的 GameObject
    private void CreateSphere(){
       GameObject spawnPoint = spawnPointManager.RandomSpawnPoint ();

       if(spawnPoint){
         GameObject newBall = (GameObject)Instantiate (prefabBall, 
         spawnPoint.transform.position, Quaternion.identity);
         Destroy(newBall, destroyAfterDelay);
       }
     } 

相关内容

  • 同样的技术和代码可以用于选择出生点或航点。请参考下一章中关于 NPC NavMeshAgent 控制按顺序跟随航点 的配方(导航网格和代理)。

选择目的地 – 回到最*通过的检查点

检查点通常表示游戏中(或可能是一条赛道)的某个距离,其中代理(用户或 NPC)成功到达。达到(或通过)检查点通常会导致奖励,例如额外时间、分数、弹药等。此外,如果玩家有多个生命,那么玩家通常只会被重生到最*通过的检查点,而不是直接回到关卡开始处。

本食谱演示了检查点的简单方法,即一旦玩家的角色通过检查点,如果他们死亡,他们将被移回最*通过的检查点:

图片

准备工作

本食谱基于您在本章开头创建的玩家控制的 3D 立方体 Unity 项目。因此,复制此项目,打开它,然后按照本食谱的步骤进行操作。

如何操作...

要根据通过的检查点在失去生命时更改重生位置,请按照以下步骤操作:

  1. 将 Cube-player GameObject 移动到 (12, 0.5, 0) 位置。

  2. 在检查器面板中选择 Cube-player,通过点击添加组件 | 物理 | Character Controller 添加一个 Character Controller 组件(这是为了接收 OnTriggerEnter 碰撞消息)。

  3. 在 (5, 0, 0) 位置创建一个名为 Cube-checkpoint-1 的立方体,缩放比例为 (1, 1, 20)。

  4. 在选择 Cube-checkpoint-1 时,检查其 Box Collider 组件在检查器面板中的 Is Trigger 属性。

  5. 创建一个 CheckPoint 标签,并将此标签分配给 Cube-checkpoint-1。

  6. 复制 Cube-checkpoint-1 并将其副本命名为 Cube-checkpoint-2,并将其放置在 (-5, 0, 0) 位置。

  7. 在 (7, 0.5, 0) 位置创建一个名为 Sphere-Death 的球体。将 m_red 材质分配给此球体以使其变红。

  8. 在选择 Sphere-Death 时,检查其 Sphere Collider 组件在检查器面板中的 Is Trigger 属性。

  9. 创建一个 Death 标签,并将此标签分配给 Sphere-Death。

  10. 复制 Sphere-Death,并将这个副本放置在 (0, 0.5, 0) 位置。

  11. 再次复制 Sphere-Death,并将这个第二个副本放置在 (-10, 0.5, 0) 位置。

  12. 将名为 CheckPoints 的以下 C# 脚本类实例添加到 Cube-player GameObject 中:

    using UnityEngine;

     public class CheckPoint : MonoBehaviour {
         private Vector3 respawnPosition;
         void Start () {
             respawnPosition = transform.position;
         }

         void OnTriggerEnter (Collider hit) {
             if(hit.CompareTag("Checkpoint"))
                 respawnPosition = transform.position;

             if(hit.CompareTag("Death"))
                 transform.position = respawnPosition;
         }
     } 

运行场景。如果立方体在通过检查点之前撞到红色球体,它将被重新生成到其起始位置。一旦红色立方体通过检查点,如果撞到红色球体,则立方体将被移回到它最*通过的检查点位置。

它是如何工作的...

CheckPoint C# 脚本类有一个名为 respawnPosition 的变量,它是一个 Vector3,指代玩家立方体将被移动到(重新生成)的位置,如果它与一个带有 Death 标签的对象发生碰撞。此默认设置是场景开始时玩家立方体的位置,因此我们在 Start() 方法中将其设置为玩家的位置。

每当与标记为Checkpoint的对象发生碰撞时,respawnPosition的值将更新为玩家红色立方体在此时此刻的位置(即它接触标记为 CheckPoint 的拉伸对象时的位置)。下一次当标记为Death的对象被击中时,立方体将重新生成到它上次接触标记为 CheckPoint 的对象的位置。

通过点击移动对象

有时,我们希望允许用户通过鼠标指针点击与对象交互。在本食谱中,我们将允许用户通过点击来使对象向随机方向移动。

准备工作

本食谱基于你在本章开头创建的玩家控制的 3D 立方体 Unity 项目。因此,请复制此项目,打开它,然后按照本食谱的步骤进行操作。按照此食谱操作的结果应如下所示:

图片

如何操作...

通过点击来移动对象,请按照以下步骤操作:

  1. 删除立方体-玩家(Cube-player游戏对象

  2. 将主相机的位置设置为(0,3,-5),并将它的旋转设置为(25,0,0)。

  3. 创建一个名为ClickMove的 C#脚本类:

    using UnityEngine;

     [RequireComponent(typeof(Rigidbody))]
     public class ClickMove : MonoBehaviour {
         public float multiplier = 500f;
         private Rigidbody rigidBody;

         private void Awake() {
             rigidBody = GetComponent<Rigidbody>();
         }

         void OnMouseDown() {
             float x = RandomDirectionComponent();
             float y = RandomDirectionComponent();
             float z = RandomDirectionComponent();
             Vector3 randomDirection = new Vector3(x,y,z);
             rigidBody.AddForce(randomDirection);
         }

         private float RandomDirectionComponent() {
             return (Random.value - 0.5f) * multiplier;
         }
     } 
  1. 创建一个立方体(Cube游戏对象(GameObject),并将脚本类ClickMove的实例对象作为组件添加。

你应该会看到,由于脚本类中包含了指令 RequireComponent(typeof(Rigidbody)),新创建的立方体自动添加了一个刚体(RigidBody)组件。这仅在指令位于将脚本类添加到游戏对象(GameObject)之前时才有效。

  1. 制作四个更多立方体的副本,并将六个对象按照以下位置排列成一个金字塔:
                       (0, 2.5, 0)
    (-0.75, 1.5, 0),   (0.75, 1.5, 0)
 (-1, 0.5, 0),   (0, 0.5, 0),   (1.5, 0.5, 0)
  1. 运行场景。每次使用鼠标指针点击立方体时,点击的立方体将受到一个随机方向上的力。因此,通过几次点击,你可以将金字塔推倒!

工作原理...

公共的float变量multiplier允许你通过更改每个立方体的ClickMove脚本组件中的值来改变力的最大大小。

ClickMove脚本类中有一个名为rigidBody的私有变量,在Awake()方法中将它设置为对 RigidBody 组件的引用。

每次立方体接收到MouseDown()消息(例如,当用户用鼠标指针点击它时),此方法创建一个随机的方向Vector3,并将其作为力应用到对象的rigidBody引用上。

RandomDirectionComponent()方法返回一个介于-multiplier+multiplier之间的随机值。

向移动方向发射弹丸

力的另一种常见用途是将力应用到新实例化的对象上,使其成为朝向玩家游戏对象(GameObject)面向的方向移动的弹丸。这就是本食谱中我们将要创建的内容。按照此食谱操作的结果应如下所示:

图片

准备工作

这个配方基于你在本章开头创建的玩家控制的 3D 立方体 Unity 项目。因此,复制此项目,打开它,然后按照此配方的步骤进行操作。

如何做到这一点...

要在移动方向上发射弹体,请按照以下步骤操作:

  1. 创建一个新的球体 GameObject(通过导航到 创建 | 3D 对象 | 球体)。将其大小设置为 (0.5, 0.5, 0.5)。

  2. 将 RigidBody 组件添加到球体上(转到 物理 | RigidBody)。

  3. 在项目面板中,创建一个新的蓝色 材质 命名为 m_blue(转到 创建 | 材质)。

  4. m_blue 材质 应用到你的球体上。

  5. 在项目面板中,创建一个新的 Prefab 命名为 prefab_projectile

  6. 将球体从层级面板拖动到你的 prefab_projectile 上(它应该变成蓝色)。

  7. 现在你可以从层级中删除球体。

  8. 确保 Cube-player 位于 (0, 0.5, 0)。

  9. 创建一个新的立方体名为 Cube-launcher。禁用其 Box Collider 组件,并设置其变换如下:

    • 位置 (0, 1, 0.3)

    • 旋转 (330, 0, 0)

    • 缩放 (0.1, 0.1, 0.5)

  10. 在层级中,通过将 Cube-launcher 拖动到 Cube-player 上,使 Cube-launcher 成为 Cube-player 的子对象。这意味着当用户按下箭头键时,两个对象将一起移动。

  11. 创建一个名为 FireProjectile 的 C# 脚本类,并将其作为组件添加到 Cube-launcher 实例对象中:

    using UnityEngine;

     public class FireProjectile : MonoBehaviour {
         const float FIRE_DELAY = 0.25f;
         const float PROJECTILE_LIFE = 1.5f;

         public Rigidbody projectilePrefab;
         public float projectileSpeed = 500f;

         private float nextFireTime = 0;

         void Update() {
             if (Time.time > nextFireTime)
                 CheckFireKey();
         }

         private void CheckFireKey() {
             if(Input.GetButton("Fire1")) {
                 CreateProjectile();
                 nextFireTime = Time.time + FIRE_DELAY;
             }
         }

         private void CreateProjectile() {
             Vector3 position = transform.position;
             Quaternion rotation = transform.rotation;

             Rigidbody projectileRigidBody =
                 Instantiate(projectilePrefab, position, rotation);
             Vector3 projectileVelocity = transform.TransformDirection(
                 Vector3.forward * projectileSpeed);

             projectileRigidBody.AddForce(projectileVelocity);

             GameObject projectileGO = projectileRigidBody.gameObject;
             Destroy(projectileGO, PROJECTILE_LIFE);
         }
     } 
  1. 在检查器中选择 Cube-launcher,从项目面板中,将 prefab_projectile 拖动到检查器中 Fire Projectile(脚本)组件的公共变量 Projectile Prefab。

  2. 运行场景。你可以使用箭头键在场景中移动,每次点击鼠标按钮,你应该看到从玩家立方体面向的方向发射出一个蓝色的球体弹体。

它是如何工作的...

你创建了一个蓝色的球体作为 Prefab(包含 RigidBody)。然后你创建了一个缩放和旋转的立方体作为弹体发射器 Cube-launcher,并将此对象作为子对象添加到 Cube-player 中。

FireProjectile 脚本类包含一个常量 FIRE_DELAY——这是发射新弹体之间的最小时间,设置为 0.25 秒。还有一个名为 PROJECTILE_LIFE 的第二个常量——这是每个弹体“存活”的时间,直到它被自动销毁,否则,场景和内存会很快被大量旧弹体填满!

此外,还有两个公共变量。一个是球体预制件的引用,另一个是新实例化的预制件的初始速度。

还有一个名为 nextFireTime 的私有变量——这个变量用于判断是否已经过去了足够的时间,以便可以发射新的弹体。

Update() 方法将当前时间与 nextFireTime 的值进行比较。如果已经过去了足够的时间,那么它将调用 CheckFireKey() 方法。

CheckFireKey() 方法用于检测是否点击了 Fire1 按钮。这通常映射到左鼠标按钮,但可以通过项目设置(导航到 Edit | 项目设置 | 输入)映射到其他输入事件。如果检测到 Fire1 事件,则下一次射击时间将被重置为未来的 FIRE_DELAY 秒,并通过调用 CreateProjectile() 方法创建一个新的弹体。

CreateProjectile() 方法获取父 GameObject 的当前位置和旋转。请记住,这个类的实例对象已经被添加到 Cube-launcher 中,因此我们的脚本对象可以使用这个发射器的位置和旋转作为每个新弹体的初始设置。使用这些位置和旋转设置创建 projectilePrefab 的新实例。

接下来,通过将 projectileSpeed 变量与标准前进向量(0, 0, 1)相乘,创建了一个名为 projectileVelocity 的 Vector3。在 Unity 中,对于 3D 对象,Z 轴通常是对象面向的方向。

使用特殊的 TransformDirection(...) 方法将局部空间的前进方向转换为世界空间方向,这样我们就有一个表示相对于 Cube-launcher 对象的前进运动的 Vector。

然后,使用这个世界空间方向向量给弹体的 RigidBody 添加力。

最后,引用了弹体的父 GameObject,并使用 Destroy(...) 方法,以便弹体在 1.5 秒后——即 PROJECTILE_LIFE 的值——被销毁。

你可以在 docs.unity3d.com/ScriptReference/Transform.TransformDirection.html 了解更多关于 Transform.TransformDirection() 的信息。

第十三章:导航网格和代理

在本章中,我们将涵盖以下内容:

  • NPC 在避开障碍物的同时前往目的地

  • NPC 寻找或逃离移动物体

  • 指点并点击移动到对象

  • 指点并点击移动到瓦片

  • 使用用户定义的高成本导航区域的点按射线投射

  • NPC 按顺序跟随航点

  • 通过集群控制对象群体移动

  • 创建可移动的 NavMesh 障碍物

简介

Unity 提供了导航网格和人工智能(AI)代理,它们可以规划路径并沿着这些计算出的路径移动对象。路径查找是经典的人工智能任务,Unity 为游戏开发者提供了快速高效的路径查找组件,无需额外配置即可使用。

拥有能够自动绘制并跟随从当前位置到目标位置点(或移动对象)路径的对象,为许多不同类型的交互式游戏角色和机制提供了组件。例如,我们可以通过点击位置或对象来创建点按游戏,指向我们希望一个或多个角色前往的位置。或者,我们可以让敌人在我们玩家的角色附*“醒来”,并移动向我们的玩家,一旦他们进入玩家角色短距离范围内,可能就会进入战斗或对话模式。

或者,对象可以集体集群在一起,作为一个群体向共同的目的地移动。

本章探讨了利用 Unity 的基于导航的 AI 组件来控制游戏角色的路径查找和移动的方法。

整体图景

Unity 导航系统的核心是两个概念/组件:

  • 导航网格

  • 导航网格代理

导航网格定义了世界中可导航的区域。它通常表示为一组多边形(二维形状),因此到目的地的路径是通过绘制最有效的相邻多边形序列来计算的,同时考虑到需要避开不可导航的障碍物。

代理是需要计算(绘制)从当前位置到目标位置路径的对象。NavMesh 代理具有诸如停止距离等属性,因此它们的目标是在目标坐标一定距离的点到达,并且自动制动,因此当它们接*目的地时,会逐渐减速。

导航网格可以由具有不同“成本”的区域组成。区域的默认成本是 1。然而,为了通过由 AI 代理控制的角色进行更现实的路径计算,我们可能想要模拟穿越水、泥或陡峭斜坡所需的额外努力。因此,Unity 允许我们定义自定义区域,我们可以选择名称(例如水或泥),并关联成本,例如 2(即水穿越起来是两倍费力)。

通过NavMesh 链接连接不同的可导航区域:

运行时 Nav Mesh 障碍物

游戏与导航网格最有效的工作方式是在游戏世界中预先计算多边形的成本;这被称为烘焙,在设计时执行,在我们运行游戏之前。

然而,有时游戏中会有一些我们希望在不同时间影响导航决策和路线规划的功能,即在游戏的不同时间动态运行时导航障碍。Unity 提供了一个NavMesh 障碍物组件,可以添加到 GameObject 中,具有“雕刻”(临时移除)NavMesh区域的功能,迫使 AI-Agent 重新计算避开带有NavMesh 障碍物组件的 GameObject 的路径。

关于 Unity 和 AI 导航的更多信息来源

一些NavMesh功能(例如NavMesh 链接运行时的动态网格烘焙)不是标准 Unity 安装的一部分,需要额外安装。在此处了解更多关于这些组件、它们的 API 以及如何安装它们的信息:

在 Unity Technologies 教程中了解更多关于 Unity NavMeshes的信息,该教程可在以下链接找到:

从克雷格·W·雷诺兹的经典论文《自主角色的驾驶行为》中学习大量关于计算机控制移动 GameObject 的知识,该论文在 GDC-99(游戏开发者大会)上展出:

尽管 Unity 开发社区已经呼吁几年了,但 2D NavMeshes尚未作为核心功能发布。关于如何编写自己的 2D路径查找系统的在线信息很多。在TIGForums上可以找到一个很好的帖子,其中包含大量链接:

在本章中,您将学习如何添加NavMesh 代理来控制角色,以及如何与您的游戏环境一起工作以指定和烘焙场景的导航网格。一些食谱探讨了如何创建点选式游戏,您可以通过在游戏世界中点击对象或点来指示角色想要导航到的位置。

你将创建“群体”对象,这些对象会移动并一起集群,你还将学习如何向移动的游戏对象添加NavMesh Obstacle组件,迫使 AI 代理在运行时动态重新计算路径,因为这些对象正在它们的方式中移动。

NPC 在避开障碍物的同时前往目的地

Unity 的NavMeshAgent的引入极大地简化了 NPC(非玩家角色)和敌人代理行为的编码。在这个菜谱中,我们将添加一些墙壁障碍物(缩放立方体),并生成一个NavMesh,这样 Unity 就知道不要试图穿过墙壁。然后我们将向我们的 NPC 游戏对象添加一个NavMeshAgent组件,并告诉它通过智能规划和遵循路径,避开墙壁障碍物,前往指定的目的地位置。

当导航面板可见时,场景面板将显示蓝色阴影的可行走区域,以及地形边缘和两个墙壁对象周围的未阴影、不可行走区域:

图片

准备工作

所需的 Terrain TextureSandAlbedo 可以在 15_01 文件夹中找到。或者,你可以转到 Assets |导入包 | 环境,取消选择所有内容,然后找到并勾选此资产:Assets/Environment/TerrainAssets/SurfaceTextures/SandAlbedo.psd

如何操作...

要使 NPC 在避开障碍物的同时前往目的地,请按照以下步骤操作:

  1. 创建一个新的空 3D 项目。

  2. 创建一个新的 3D 地形,选择菜单:创建 | 3D 对象 | 地形。在层次结构中选择这个新的地形游戏对象,在其检查器属性中设置其大小为 30 x 20,位置为(-15, 0, -10),这样我们就有一个以(0,0,0)为中心的游戏对象。

  3. 使用 SandAlbedo 纹理绘制地形。

  4. 在(-12, 0, 8)处创建一个名为 Capsule-destination 的 3D 胶囊。这将是我们的 NPC 自主导航游戏对象的靶目标。

  5. 创建一个名为 Sphere-arrow 的球体,其位置在(2, 0.5, 2)。将其缩放为(1,1,1)。

  6. 创建第二个名为 Sphere-small 的球体。将其缩放为(0.5, 0.5, 0.5)。

  7. 在层次结构中,将 Sphere-small 子组件移动到 Sphere-arrow,并定位在(0, 0, 0.5):

图片

  1. 在检查器面板中,向 Sphere-arrow 添加一个新的 NavMeshAgent。通过选择菜单:添加组件 | 导航 | Nav Mesh Agent 来完成此操作。

  2. 将 NavMeshAgent 组件的停止距离属性设置为 2:

图片

  1. 创建ArrowNPCMovementC#脚本类,并将其实例对象添加到 Sphere-arrow 游戏对象中:
using UnityEngine;
 using UnityEngine.AI;

public class ArrowNPCMovement : MonoBehaviour {
 public GameObject targetGo;
 private NavMeshAgent navMeshAgent;

void Start() {
 navMeshAgent = GetComponent<NavMeshAgent>();
 HeadForDestintation();
 }

private void HeadForDestintation () {
 Vector3 destination = targetGo.transform.position;
 navMeshAgent.SetDestination (destination);
 }
 }
  1. 确保在检查器面板中选择 Sphere-arrow。对于 ArrowNPCMovement 脚本组件,将 Capsule-destination 拖到 Target Go 变量上。

  2. 在(-6, 0, 0)处创建一个名为 Cube-wall 的 3D 立方体,并将其缩放为(1, 2, 10)。

  3. 在(-2, 0, 6)处创建另一个名为 Cube-wall2 的 3D 立方体,并将其缩放为(1, 2, 7)。

  4. 通过选择菜单:窗口 | 导航来显示导航面板。

将导航面板停靠在检查器面板旁边是一个好地方,因为你永远不会同时使用检查器和导航面板。

  1. 在层级面板中,选择两个立方体墙面对象(我们选择那些不应该成为场景可通行部分的对象),然后在导航面板中,点击对象按钮并勾选导航静态复选框:

图片

  1. 在检查器中,点击顶部的烘焙按钮以获取烘焙选项。然后,点击右下角的烘焙按钮以创建你的导航网格资产:

图片

  1. 当导航面板显示时,你会在场景的部分看到蓝色色调,这些部分是 NavMeshAgent 考虑其导航路径的区域。

  2. 现在,运行你的游戏。你会看到球体箭头 GameObject 自动移动到胶囊目标 GameObject,沿着避开两个墙面对象的路径移动。

它是如何工作的...

我们添加到球体箭头 GameObject 中的 NavMeshAgent 组件为我们做了大部分工作。NavMeshAgents 需要两样东西:

  • 一个要前往的目的地位置

  • 地形上的可通行/不可通行区域的 NavMesh 组件,以便它可以通过避开障碍物来规划路径

我们创建了两个障碍物(立方体墙面对象),并在导航面板中为这个场景创建 NavMesh 时选择了它们。当导航面板显示时,同时场景面板(以及启用 Gizmos 的游戏面板)中,我们看到可通行区域形成一个蓝色导航网格。

注意:蓝色区域是默认的 NavMesh 区域。请看,在本章后面,有一个不同、自定义命名、有成本、颜色编码的 NavMesh 区域的配方。

我们 NPC 对象要前往的位置是胶囊目标 GameObject 在(-12, 0, 8)的位置;当然,我们也可以在场景面板中设计时移动这个对象,其新的位置将在游戏运行时成为目的地。

ArrowNPCMovement C#脚本类有两个变量:一个是目标 GameObject 的引用,另一个是 GameObject 的 NavMeshAgent 组件的引用,其中我们的ArrowNPCMovement类实例也是一个组件。当场景开始时,通过Start()方法找到 NavMeshAgent 兄弟组件,并调用HeadForDestination()方法,将 NavMeshAgent 的目的地设置为目的地 GameObject 的位置。

一旦 NavMeshAgent 有一个目标要前往,它将规划一条路径并持续移动,直到到达(或者如果该参数被设置为大于零的距离,则在其停止距离内)。

在场景面板中,如果您选择包含 NavMeshAgent 的 GameObject 并选择显示避免工具,那么您可以看到代理正在考虑的候选局部目标位置。方块越亮,位置排名越好。

方块的颜色越深,位置就越不理想;因此,深红色方块表示要避免的位置,因为它们可能会造成代理与 NavMesh 静态障碍物碰撞:

图片

确保在运行时选择具有 NavMeshAgent 组件的对象在层次结构面板中,以便能够在场景面板中看到这些导航数据。

NPC 寻找或逃离移动对象

而不是在场景开始时固定的目标,让我们允许 Capsule-destination 对象在场景运行时被玩家移动。在每一帧,我们将使 NPC 箭头重置 NavMeshAgent 的目标为 Capsule-destination 被移动到的任何位置。

准备工作

这个配方是在之前的配方基础上增加的,所以请复制那个项目文件夹,并使用该副本来完成这个配方的任务。

如何做到这一点...

要使 NPC 寻找或逃离移动对象,请按照以下步骤操作:

  1. 在检查器中,为 Capsule-destination GameObject 添加一个刚体物理组件。

  2. 在检查器中,对于 Capsule-destination GameObject,在刚体组件的约束选项中检查 Y 轴的冻结位置约束。这将防止由于移动时的碰撞而导致对象在 Y 轴上移动。

  3. 创建SimplePlayerControlC#脚本类,并将其作为组件添加到 Capsule-destination GameObject:

using UnityEngine;

 public class SimplePlayerControl : MonoBehaviour {
 public float speed = 1000;
 private Rigidbody rigidBody;
 private Vector3 newVelocity;

 private void Start() {
 rigidBody = GetComponent<Rigidbody>();
 }

 void Update() {
   float xMove = Input.GetAxis("Horizontal") * speed * Time.deltaTime;
   float zMove = Input.GetAxis("Vertical") * speed * Time.deltaTime;
   newVelocity = new Vector3(xMove, 0, zMove);
 }

 void FixedUpdate() {
   rigidBody.velocity = newVelocity;
 }
}
  1. 更新ArrowNPCMovementC#脚本类,以便我们每帧调用HeadForDestintation()方法,即从Update()而不是仅在Start()中调用一次:
void Start() {
     navMeshAgent = GetComponent<NavMeshAgent>();
 }

 private void Update() {
     HeadForDestintation();
 } 

它是如何工作的...

SimplePlayerControl脚本类检测箭头键的按下,并将它们转换为应用于移动 Capsule-destination GameObject 的力的方向。

ArrowNPCMovement脚本类的Update()方法使 NavMeshAgent 根据 Capsule-destination GameObject 的当前位置每帧更新其路径。当用户移动 Capsule-destination 时,NavMeshAgent 会计算到该对象的新路径。

还有更多

这里有一些你不想错过的细节。

使用调试射线显示源到目标线

使用可视调试射线显示 NPC 与 NavMeshAgent 之间的直线,以及它正在尝试导航到的当前目标非常有用。由于这是我们可能希望为许多游戏做的事情,因此在一个通用类中创建一个静态方法很有用,然后射线可以用一个单独的语句绘制。

要使用调试射线绘制源到目标线,请按照以下步骤操作:

  1. 创建一个UsefulFunctions.csC#脚本类,包含以下内容:
using UnityEngine;

public class UsefulFunctions : MonoBehaviour {
 public static void DebugRay(Vector3 origin, Vector3 destination, Color c) {
 Vector3 direction = destination - origin;
 Debug.DrawRay(origin, direction, c);
 }
 }
  1. 现在,在NPCMovementC#脚本类中的HeadForDestination()方法末尾添加一个语句:
private void HeadForDestintation () {
Vector3 destination = targetGo.transform.position;
navMeshAgent.SetDestination (destination);
// show yellow line from source to target
 UsefulFunctions.DebugRay(transform.position, destination, Color.yellow);
 }

当场景运行时,我们可以在场景面板中看到一个黄色的线。如果选择游戏面板中的 Gizmos 选项(游戏面板标题栏的右上角),我们也可以在游戏面板中看到这个:

持续更新 NavMeshAgent 目的地以避开玩家的当前位置

有时候,我们希望由 AI 控制的 NPC 角色远离另一个角色,而不是朝它移动。例如,一个生命值非常低的敌人可能会逃跑,从而在再次战斗之前获得恢复生命值的时间。或者,一只野生动物可能会逃离任何靠*它的其他角色。

要让我们的 NavMeshAgent 避开玩家的位置,我们需要将ArrowNPCMovementC#脚本类替换为以下内容:

 using UnityEngine;
 using UnityEngine.AI;

 public class ArrowNPCMovement : MonoBehaviour {
 public float runAwayDistance = 10;
 public GameObject targetGO;
 private NavMeshAgent navMeshAgent;

void Start() {
 navMeshAgent = GetComponent<NavMeshAgent>();
 }

void Update() {
 Vector3 targetPosition = targetGO.transform.position;
 float distanceToTarget = Vector3.Distance(transform.position, targetPosition);
 if (distanceToTarget < runAwayDistance)
 FleeFromTarget(targetPosition);
 }

private void FleeFromTarget(Vector3 targetPosition) {
 Vector3 destination = PositionToFleeTowards(targetPosition);
 HeadForDestintation(destination);
 }

private void HeadForDestintation (Vector3 destinationPosition) {
 navMeshAgent.SetDestination (destinationPosition);
 }

private Vector3 PositionToFleeTowards(Vector3 targetPosition) {
 transform.rotation = Quaternion.LookRotation(transform.position - targetPosition);
 Vector3 runToPosition = targetPosition + (transform.forward * runAwayDistance);
 return runToPosition;
 }
 }

有一个公共变量runAwayDistance。当敌人距离小于这个runAwayDistance变量的值时,我们将指示计算机控制的对象向相反方向逃跑。

Start()方法缓存了 NavMeshAgent 组件的引用。

Update()方法计算敌人距离是否在runAwayDistance范围内,如果是,则调用FleeFromTarget(...)方法,并将敌人位置作为参数传递。

FleeFromTarget(...)方法计算一个点,该点在 Unity 单位中距离玩家立方体runAwayDistance,方向是直接远离计算机控制的对象。这是通过从当前变换的位置减去敌人位置向量来实现的。

最后,调用HeadForDestintation(...)方法,传递逃跑到的位置,这将导致 NavMeshAgent 被指示将位置设置为新的目的地。

Unity 单位是任意的,因为它们只是计算机上的数字。然而,在大多数情况下,将距离视为米(1 Unity 单位 = 1 米),质量视为千克(1 Unity 单位 = 1 千克)会使事情变得简单。当然,如果你的游戏基于微观世界或跨银河系太空旅行,那么你需要决定每个 Unity 单位在你的游戏上下文中对应什么。有关 Unity 中单位的进一步讨论,请查看这篇关于 Unity 测量的帖子:forum.unity3d.com/threads/best-units-of-measurement-in-unity.284133/#post-1875487

Debug Ray 显示了 NPC 瞄准的点,无论是为了避开玩家的角色,还是为了赶上并保持与它的恒定距离:

保持与目标恒定的距离(“潜伏”模式!)

将之前的代码修改为 NPC 尝试与目标对象保持恒定距离很简单。这涉及到始终朝向距离目标runAwayDistance的点移动,无论这个点是否朝向或远离目标。

只需从Update()方法中移除If语句:

void Update() {
 Vector3 targetPosition = targetGO.transform.position;
 float distanceToTarget = Vector3.Distance(transform.position, targetPosition);
 FleeFromTarget(targetPosition);
 }

然而,使用这种变化,可能最好将方法命名为MoveTowardsConstantDistancePoint()而不是FleeFromTarget(),因为我们的 NPC 有时在逃跑,有时在跟随。

点击式移动到对象

另一种为我们的 Sphere-arrow GameObject 选择目的地的方法是通过用户点击屏幕上的对象,然后 Sphere-arrow GameObject 移动到被点击对象的当前位置:

图片

准备工作

这个配方增加了本章的第一个配方,所以请复制那个项目文件夹,并使用该副本来完成这个配方的任务。

如何做到这一点...

要创建基于对象的点击式迷你游戏,请执行以下操作:

  1. 在检查器中,将 Player Tag 添加到 Sphere-arrow GameObject。

  2. 从场景中删除两个 3D 立方体和 3D 胶囊目标。

  3. 创建包含以下内容的ClickMeToSetDestination C#脚本类:

using UnityEngine;

public class ClickMeToSetDestination : MonoBehaviour
 {
 private UnityEngine.AI.NavMeshAgent playerNavMeshAgent;

void Start() {
 GameObject playerGO = GameObject.FindGameObjectWithTag("Player");
 playerNavMeshAgent = playerGO.GetComponent<UnityEngine.AI.NavMeshAgent>();
 }

private void OnMouseDown() {
 playerNavMeshAgent.SetDestination(transform.position);
 }
 }
  1. ClickMeToSetDestination C#脚本类的实例对象作为组件添加到您的 3D 立方体、球体和圆柱体中。

  2. 运行场景。当你点击其中一个 3D 对象时,Sphere-arrow GameObject 应该导航到被点击的对象。

它是如何工作的...

ClickMeToSetDestination C#脚本类的OnMouseDown()方法将 Sphere-arrow GameObject 中的 NavMeshAgent 的目标位置更改为被点击的 3D 对象的位置。

ClickMeToSetDestination C#脚本类的Start()方法获取标记为 Player 的 GameObject(即 Sphere-arrow GameObject)的 NavMeshAgent 组件的引用。

每次点击不同的对象时,Sphere-arrow GameObject 内部的 NavMeshAgent 都会更新,使 GameObject 移动到被点击对象的当前位置。

更多内容

有一些细节你不应该错过。

创建鼠标悬停黄色高亮

一个好的 UX(用户体验)反馈技术是在用户可以通过鼠标与对象交互时,通过视觉方式向用户指示。一种常见的方法是在鼠标移到可交互对象上时,呈现音频或视觉效果。

我们可以创建一个黄色的 Material,当鼠标悬停在对象上时,可以使对象看起来是黄色的,然后当鼠标移开时,对象恢复到原始材质。

创建包含以下内容的MouseOverHighlighter C#脚本类。然后,将实例对象作为组件添加到每个三个 3D GameObject 中:

using UnityEngine;

public class MouseOverHighlighter : MonoBehaviour
 {
 private MeshRenderer meshRenderer;
 private Material originalMaterial;

void Start() {
 meshRenderer = GetComponent<MeshRenderer>();
 originalMaterial = meshRenderer.sharedMaterial;
 }

void OnMouseOver() {
 meshRenderer.sharedMaterial = NewMaterialWithColor(Color.yellow);
 }

void OnMouseExit() {
 meshRenderer.sharedMaterial = originalMaterial;
 }

private Material NewMaterialWithColor(Color newColor) {
 Shader shaderSpecular = Shader.Find("Specular");
 Material material = new Material(shaderSpecular);
 material.color = newColor;

return material;
 }
 }

现在,当运行游戏时,当鼠标悬停在三个对象之一上时,该对象将被突出显示为黄色。如果你在对象突出显示时点击鼠标按钮,Sphere-arrow GameObject 将移动到(但停止在点击对象之前)。

点选移动到瓦片

与通过点击特定对象来指示我们的 AI 控制代理的目标相比,我们可以创建一个由 3D *面(瓦片)对象组成的网格,允许玩家点击任何瓦片来指示 AI 控制角色的目的地。因此,任何位置都可以点击,而不仅仅是少数几个特定对象:

图片

准备工作

此配方在先前的配方基础上进行扩展,因此请复制那个项目文件夹,并使用该副本来完成此配方的操作。

对于此配方,我们在15_04文件夹中的 Textures 文件夹中准备了一个名为 square_outline.png 的红色轮廓黑色方块 Texture 图像。

如何实现...

要创建一个点选游戏,将 GameObject 移动到选定的瓦片,请执行以下操作:

  1. 从场景中删除你的 3D 立方体、球体和圆柱体 GameObject。

  2. 创建一个新的 3D *面对象,缩放为(0.1,0.1,0.1)。

  3. 创建一个新的材质,使用提供的 Texture 图像 square_outline.png(黑色带有红色轮廓的方块)。将此材质应用到你的 3D *面上。

  4. ClickMeToSetDestination脚本类的实例对象作为组件添加到 3D *面。

  5. 在项目面板中,创建一个名为 tile 的新空 Prefab。

  6. 通过将*面 GameObject 拖动到 Prefab 瓦片上,将 Prefab 瓦片填充为你的 3D *面 GameObject 的属性(它应该从白色变为蓝色,以指示 Prefab 现在具有你的 GameObject 的属性)。

  7. 从场景中删除你的 3D *面 GameObject。

  8. 创建一个新的TileManagerC#脚本类,包含以下内容,并将其作为组件添加到主摄像机 GameObject:

using UnityEngine;

public class TileManager : MonoBehaviour {
 public int rows = 50;
 public int cols = 50;
 public GameObject prefabClickableTile;

void Start () {
 for (int r = 0; r < rows; r++) {
 for (int c = 0; c < cols; c++) {
 float y = 0.01f;
 Vector3 pos = new Vector3(r - rows/2, y, c - cols/2);
 Instantiate(prefabClickableTile, pos, Quaternion.identity);
 }
 }
 }
 }
  1. 在层次结构中选择主摄像机,并在 Tile Manager(脚本)组件的检查器中,将“Prefab Clickable Tile”公共属性填充为项目面板中的 Prefab 瓦片。

  2. 运行场景。现在,你应该能够点击任何小方块瓦片来设置 NavMeshAgent 控制的 Sphere-arrow GameObject 的目标。

工作原理...

你创建了一个 Prefab,其中包含名为 tile 的 3D *面的属性,该 Prefab 包含ClickMeToSetDestinationC#脚本类的组件实例对象。

TileManager脚本类循环创建场景中此瓦片 GameObject 的 50 x 50 个实例。

当你运行游戏时,如果你在鼠标指针悬停在瓦片上时点击鼠标按钮,Sphere-arrow GameObject 内部的 NavMeshAgent 将被设置为该瓦片的位置。因此,Sphere-arrow GameObject 将移动到,但在到达点击的瓦片位置之前停止。

Y 值为 0.01 表示*面将刚好位于地形之上,因此我们避免了由于同一位置网格造成的任何类型的摩尔纹干扰模式。通过从 XZ 位置减去 rows/2cols/2,我们将瓦片网格的中心定位在 (0, Y, 0)。

更多内容

有些细节是你不想错过的。

黄色调试射线显示 AI 代理的目的地

我们可以通过创建具有以下内容的 MouseOverHighlighter C# 脚本类来显示移动对象到其目的地瓦片的调试射线。然后,我们将实例对象作为组件添加到 NavMeshAgent 控制的 Sphere-arrow 游戏对象中:

using UnityEngine;
 using UnityEngine.AI;

public class DebugRaySourceDestination : MonoBehaviour {
 void Update() {
 Vector3 origin = transform.position;
 Vector3 destination = GetComponent<NavMeshAgent>().destination;
 Vector3 direction = destination - origin;
 Debug.DrawRay(origin, direction, Color.yellow);
 }
 }

使用用户定义的更高成本导航区域的点按射线投射

而不是通过点击对象或瓦片来指示期望的目的地,我们可以使用 Unity 内置的 Physics.Raycast(...) 方法来识别与游戏中的对象表面相关的哪个 Vector3 (x,y,z) 位置。

这涉及到从 2D (x,y) 屏幕位置转换到一个想象中的从用户视角出发的 3D “射线”,穿过屏幕,进入游戏世界,并识别它首先击中的对象(多边形)。

此配方使用 Physics.Raycast 将点击位置的位置设置为 NavMeshAgent 控制对象的新目的地。实际遵循的路线可以通过定义不同成本的导航网格区域来影响。例如,穿过泥地或游泳穿越水可能具有更高的成本,因为它们会花费更长的时间,所以 AI NavMeshAgent 可以计算出最低成本的路线,这可能是场景中最短距离的路线:

图片

准备工作

此配方在先前的配方上进行了扩展,因此请复制那个项目文件夹,并使用该副本为此配方进行工作。

如何操作...

要使用射线投射创建一个点按游戏,请执行以下操作:

  1. 从主相机游戏对象中移除 Tile Manager (脚本) 组件。

  2. 创建一个新的 3D 球体,命名为 Sphere-destination,缩放为 (0.5, 0.5, 0.5)。

  3. 创建一个新的红色材质,并将其分配给 Sphere-destination 游戏对象。

  4. 创建一个新的MoveToClickPoint C# 脚本类,包含以下内容,并将实例对象作为组件添加到 Sphere-arrow 游戏对象中:

using UnityEngine;
 using UnityEngine.AI;

public class MoveToClickPoint : MonoBehaviour {
 public GameObject sphereDestination;
 private NavMeshAgent navMeshAgent;
 private RaycastHit hit;

void Start() {
 navMeshAgent = GetComponent<NavMeshAgent>();
 sphereDestination.transform.position = transform.position;
 }

void Update() {
 Ray rayFromMouseClick = Camera.main.ScreenPointToRay(Input.mousePosition);

if (FireRayCast(rayFromMouseClick)){
 Vector3 rayPoint = hit.point;
 ProcessRayHit(rayPoint);
 }
 }

private void ProcessRayHit(Vector3 rayPoint) {
 if(Input.GetMouseButtonDown(0)) {
 navMeshAgent.destination = rayPoint;
 sphereDestination.transform.position = rayPoint;
 }
 }

private bool FireRayCast(Ray rayFromMouseClick) {
 return Physics.Raycast(rayFromMouseClick, out hit, 100);
 }
 }
  1. 在 Hierarchy 中选择 Sphere-arrow 游戏对象,并在 MoveToClickPoint (脚本) 组件的 Inspector 中,将 Sphere Destination 公共属性填充为你的红色 Sphere-destination 游戏对象。

  2. 运行场景。现在你应该能够点击地形上的任何位置来设置 NavMeshAgent 控制的 Sphere-arrow 游戏对象的目的地。当你点击时,红色 Sphere-destination 游戏对象应该位于这个新的目的地点,Sphere-arrow 游戏对象将朝这个方向导航。

它是如何工作的...

你创建了一个名为 Sphere-destination 的小红色 3D 球体。

Sphere-arrow GameObject 的MoveToClickPoint脚本组件有一个公共变量。这个公共的sphereDestination变量已经与场景中的红色 Sphere-destination GameObject 链接。

有两个私有变量:

  • navMeshAgent:这将设置为指向 Sphere-arrow GameObject 的 NavMeshAgent 组件,以便在适当的时候重置其目的地。

  • hit:这是一个RaycastHit对象,作为Physics.Raycast(...)要设置的参数传入。在创建射线后,设置了该对象的多个属性,包括射线击中物体表面的场景中的位置。

Start()方法缓存了 Sphere-arrow GameObject 的 NavMesh 组件的引用,并将 Sphere-destination GameObject 移动到当前对象的位置。

Update()方法中,每一帧都会根据主相机和屏幕上点击的(2,y)点创建一个射线。这个射线作为参数传递给FireRayCast(...)方法。如果该方法返回 true,则提取被击中物体的位置并传递给ProcessRayHit(...)方法。

FireRayCast(...)方法接收一个射线对象。它使用Physics.Raycast(...)来确定射线是否与场景中某个物体的部分发生碰撞。如果射线击中某个物体,则RaycastHit hit对象的属性将被更新。该方法返回一个布尔值,表示Physics.Raycast(...)是否击中了表面。

每当用户点击屏幕时,场景中的相应对象都会通过射线识别,红色球体被移动到那里,并且 NavMeshAgent 开始向该位置导航。

docs.unity3d.com/ScriptReference/RaycastHit.html了解更多关于 Unity 射线 C#脚本类的信息。

还有更多

这里有一些细节,你不想错过。

通过为自定义定义的导航区域(如泥地和水面)设置不同的成本来实现更智能的路径查找

我们可以创建网格定义为 NavMeshAgents 旅行成本更高的对象,这有助于 AI 代理行为在避免水、泥地等时选择更快的路径,从而在现实世界中更加真实。

要创建一个具有更高旅行成本的定制 NavMesh 区域(我们假设它是泥地),请执行以下操作:

  1. 在导航面板中,通过点击区域按钮来揭示区域。然后,定义一个名为泥地的新区域,成本为 2:

图片

  1. 创建一个新的 3D 圆柱体,命名为 Cylinder-mud,位置在(0, -4.9, 0),缩放为(5,5,5)。

  2. 确保在层次结构中选择 Cylinder-mud GameObject,并且导航面板是显示的。

  3. 在导航面板中,点击对象按钮,勾选导航静态,并从导航区域下拉列表中选择泥地:

图片

  1. 现在,点击烘焙按钮以显示导航烘焙子面板,然后在此子面板中点击烘焙按钮以使用新对象重新生成导航网格。

现在,如果你点击移动 Sphere-arrow GameObject 接*圆柱-泥地区域的边缘,然后,比如说,点击对面,你会看到 NavMeshAgent 让 Sphere-arrow GameObject 沿着圆柱-泥地边缘的半圆形(最低成本)路径移动,而不是直接穿过成本更高的泥地路径:

图片

通过每帧更新“注视”光标来提高用户体验

在点击鼠标之前知道我们的目的地将被设置在哪里是很好的。所以,让我们添加一个黄色球体来显示我们的射线击中表面的“候选”目的地,这个目的地会随着我们移动鼠标而每帧更新。

因此,我们需要创建第二个黄色球体。我们还需要创建一个忽略层;否则,如果我们把黄色球体移动到射线击中表面的位置,那么在下一次帧中,我们的射线将会击中黄色球体的表面——每次帧都会让它离我们越来越*!

为了通过每帧更新“注视”光标来提高用户体验,请执行以下操作:

  1. 创建一个新的名为 m_yellow 的黄色材质。

  2. 创建第二个 3D 球体,命名为 Sphere-destination-candidate,并使用 m_yellow 纹理。

  3. 创建一个新的层,UISpheres。

  4. 将 Sphere-destination 和 Sphere-destination-candidate GameObject 的层设置为 LayerUISpheres。

  5. 修改 MoveToClickPoint C# 脚本类,添加一个新的公共变量 sphereDestinationCandidate

public class MoveToClickPoint : MonoBehaviour {
public GameObject sphereDestination;
public GameObject sphereDestinationCandidate;
  1. 修改 MoveToClickPoint C# 脚本类,在 ProcessRayHit(...) 方法的逻辑中添加一个 Else 子句,以便如果鼠标没有点击,则将黄色 sphereDestinationCandidate 对象移动到射线击中表面的位置:
private void ProcessRayHit(Vector3 rayPoint) {
 if(Input.GetMouseButtonDown(0)) {
 navMeshAgent.destination = rayPoint;
 sphereDestination.transform.position = rayPoint;
 } else {
 sphereDestinationCandidate.transform.position = rayPoint;
 }
 }
  1. 修改 MoveToClickPoint C# 脚本类,以便创建一个忽略层 UISpheres 的 LayerMask,并在调用 Physics.Raycast(...) 时将其作为参数传递:
private bool FireRayCast(Ray rayFromMouseClick) {
 LayerMask layerMask = ~LayerMask.GetMask("UISpheres");
 return Physics.Raycast(rayFromMouseClick, out hit, 100, layerMask.value);
 }
  1. 在层次结构中选择 Sphere-arrow GameObject,然后在 MoveToClickPoint (Script) 组件的检查器中,将 Sphere Destination Candidate 公共属性填充为你的黄色 Sphere-destination-candidate GameObject。

  2. 运行场景。现在,你应该能够点击地形上的任何地方来设置 NavMeshAgent 控制的 Sphere-arrow GameObject 的目的地。当你点击时,红色的 Sphere-destination GameObject 应该位于这个新的目的地点,Sphere-arrow GameObject 将朝这个方向导航。

我们使用 ~LayerMask.GetMask("UISpheres") 语句设置了一个 LayerMask,这意味着除了命名层之外的所有层。这个层被传递给 Raycast(...) 方法,这样我们的红色和黄色球体在发射射线和查看射线首先击中哪个表面时会被忽略。

NPC NavMeshAgent 按顺序跟随航点

航点通常用作指南,使自主移动的 NPC 和敌人以一般方式遵循路径,但能够在附*检测到朋友/捕食者/猎物时做出其他方向行为,如逃跑或寻找。航点按顺序排列,因此当角色到达或接*航点时,它将选择序列中的下一个航点作为移动的目标位置。这个配方演示了一个箭头对象移动到航点,然后当它足够接*时,它将选择序列中的下一个航点作为新的目标目的地。当到达最后一个航点后,它再次开始朝向第一个航点前进。

由于 Unity 的 NavMeshAgent 简化了 NPC 行为的编码,我们在这个配方中的工作基本上变成了找到下一个航点的位置,然后告诉 NavMeshAgent 这个航点是其新的目的地:

图片

准备中

这个配方增加了本章的第一个配方,所以复制那个项目文件夹,并使用那个副本来完成这个配方的任务。

对于这个配方,我们在1362_08_06文件夹中的 Textures 文件夹中准备了一张你需要使用的黄色砖块纹理图像。

如何操作...

要指示一个对象遵循一系列航点,请按照以下步骤操作:

  1. ArrowNPCMovementC#脚本类的内容替换为以下内容:
using UnityEngine;
 using UnityEngine.AI;

public class ArrowNPCMovement : MonoBehaviour {
 private GameObject targetGo = null;
 private WaypointManager waypointManager;
 private NavMeshAgent navMeshAgent;

void Start () {
 navMeshAgent = GetComponent<NavMeshAgent>();
 waypointManager = GetComponent<WaypointManager>();
 HeadForNextWayPoint();
 }

void Update () {
 float closeToDestinaton = navMeshAgent.stoppingDistance * 2;
 if (navMeshAgent.remainingDistance < closeToDestinaton) {
 HeadForNextWayPoint ();
 }
 }

private void HeadForNextWayPoint () {
 targetGo = waypointManager.NextWaypoint (targetGo);
 navMeshAgent.SetDestination (targetGo.transform.position);
 }
 }
  1. 在(-12, 0, 8)的位置创建一个新的 3D 胶囊对象,命名为 Capsule-waypoint-0。

  2. 复制 Capsule-waypoint-0,将其命名为 Capsule-waypoint-3,并将此副本放置在(8, 0, -8)的位置。

我们稍后将要添加一些中间的航点,编号为 1 和 2。这就是为什么这里的第二个航点编号为 3,以防你有所疑问。

  1. 创建以下内容的WaypointManagerC#脚本类,并将其作为组件添加到 Sphere-arrow 游戏对象中:
using UnityEngine;

 public class WaypointManager : MonoBehaviour {
 public GameObject wayPoint0;
 public GameObject wayPoint3;

public GameObject NextWaypoint(GameObject current) {
 if(current == wayPoint0)
 return wayPoint3;

return wayPoint0;
 }
 }
  1. 确保在检查器中选择了 Sphere-arrow 的WaypointManager脚本组件。将 Capsule-waypoint-0 和 Capsule-waypoint-3 拖动到名为 Way Point 0 和 Way Point 3 的公共变量 projectiles 上,分别对应。

  2. 现在,运行你的游戏。箭头对象将首先移动到其中一个航点胶囊,然后当它接*时,它会减速,掉头,朝向另一个航点胶囊前进,并持续这样做。

它是如何工作的...

我们添加到 Sphere-arrow 游戏对象的 NavMeshAgent 组件为我们做了大部分工作。NavMeshAgent 需要两样东西:

  • 一个要前往的目的地位置

  • 一个 NavMesh,以便它可以规划路径并避开障碍物

我们创建了两个可能的航点作为 NPC 移动的位置:Capsule-waypoint-0 和 Capsule-waypoint-3。

被称为 WaypointManager 的 C# 脚本类有一个任务:返回我们的 NPC 应该前往的下一个路点的引用。有两个变量,wayPoint0wayPoint3,它们引用场景中的两个路点 GameObject。NextWaypoint(...) 方法接受一个名为 current 的单个参数,该参数是对象正在移动向其的当前路点的引用(或 null)。此方法的任务是返回 NPC 应该前往的下一个路点的引用。此方法的逻辑很简单:如果 current 指向 waypoint0,则我们将返回 waypoint3;否则,我们将返回 waypoint0。注意,如果我们向此方法传递 null,则我们将返回 waypoint0(因此,它是我们的默认第一个路点)。

ArrowNPCMovement C# 脚本类有三个变量。一个是名为 targetGo 的目标 GameObject 的引用。第二个是引用 GameObject 的 NavMeshAgent 组件,其中我们的 ArrowNPCMovement 类实例也是一个组件。第三个变量,称为 waypointManager,是引用兄弟脚本组件,即我们的 WaypointManager 脚本类的实例。

当场景通过 Start() 方法开始时,找到 NavMeshAgent 和 WaypointManager 兄弟组件,并调用 HeadForDestination() 方法。

HeadForDestination() 方法首先将名为 targetGO 的变量设置为指向通过调用名为 WaypointManager 的脚本组件的 NextWaypoint(...) 方法返回的 GameObject(即,targetGo 被设置为指向 Capsule-waypoint-0 或 Capsule-waypoint-3)。接下来,它指示 NavMeshAgent 将其目的地设置为 targetGO GameObject 的位置。

每帧都会调用名为 Update() 的方法。进行测试以查看 NPC 箭头对象与目的地路点的距离是否接*。如果距离小于我们在 NavMeshAgent 中设置的停止距离的两倍,则调用 WaypointManager.NextWaypoint(...) 来更新我们的目标目的地为序列中的下一个路点。

还有更多...

这里有一些你不想错过的细节。

与路点数组一起工作

拥有一个独立的WaypointManagerC#脚本类,简单地在这几个胶囊位置(Capsule-waypoint-0 和 Capsule-waypoint-3)之间切换,可能看起来有点过于复杂,像是过度设计,但实际上这是一个非常好的举措。WaypointManager脚本类的实例对象负责返回下一个位置点。现在,在不修改ArrowNPCMovementC#脚本类中的任何代码的情况下,添加一个更复杂的具有位置点数组的实现方法变得非常简单。我们可以选择一个随机的位置点作为下一个目的地;例如,参见第十四章中的选择目的地 - 查找最*的(或随机的)出生点配方,选择和控制位置。或者,我们可以有一个位置点数组,并按顺序选择下一个位置点。

为了改进我们的游戏,使其能够按顺序跟随一系列位置点,我们需要做以下几步:

  1. 复制 Capsule-waypoint-0,将其命名为 Capsule-waypoint-1,并将此副本放置在(0,0,8)的位置。

  2. 再复制四个(命名为 Capsule-waypoint-1, 2, 4, 5),并按照以下方式定位它们:

    • Capsule-waypoint-1: 位置 = (-2, 0, 8)

    • Capsule-waypoint-2: 位置 = (8, 0, 8)

    • Capsule-waypoint-4: 位置 = (-2, 0, -8)

    • Capsule-waypoint-5: 位置 = (-12, 0, -8)

  3. WaypointManagerC#脚本类替换为以下代码:

using UnityEngine;
 using System;

public class WaypointManager : MonoBehaviour {
 public GameObject[] waypoints;

public GameObject NextWaypoint (GameObject current) {
 if( waypoints.Length < 1)
 Debug.LogError ("WaypointManager:: ERROR - no waypoints have been added to array!");

int currentIndex = Array.IndexOf(waypoints, current);
 int nextIndex = (currentIndex + 1) % waypoints.Length;

return waypoints[nextIndex];
 }
 }
  1. 确保 Sphere-arrow 被选中。在WaypointManager脚本组件的检查器面板中,将 Waypoints 数组的大小设置为 6。现在,拖入所有六个名为 Capsule-waypoint-0/1/2/3/4/5 的胶囊位置点对象。

  2. 运行游戏。现在,Sphere-arrow GameObject 将首先移动到位置点 0(左上角),然后跟随地形周围的序列。

  3. 最后,你可以让它看起来像 Sphere 正在跟随一条黄色的砖路。导入提供的黄色砖纹理,将其添加到地形中,并绘制纹理以在位置点之间创建一个椭圆形路径。你也可以取消选中每个位置点胶囊的 Mesh Renderer 组件,这样用户就看不到任何位置点,只能看到跟随黄色砖路的箭头对象。

NextWaypoint(...)方法中,首先检查数组是否为空,如果是,则记录错误。接下来,找到当前waypointGameObject 在数组中的索引(如果存在)。最后,使用模运算符计算下一个位置点的数组索引,以支持循环序列,在访问到最后一个元素后返回数组的开始。

使用 WayPoint 类提高灵活性

而不是强制一个 GameObject 遵循单一的刚体位置序列,我们可以通过定义一个WayPoint类来使事情更加灵活,其中每个位置点 GameObject 都有一个可能的目的地数组,每个目的地也有自己的数组。这样,就可以实现一个有向图(digraph),其中线性序列只是可能的一个实例。

为了改进我们的游戏并使其与航点有向图一起工作,请执行以下操作:

  1. 从球体箭头 GameObject 中移除脚本WayPointManager组件。

  2. ArrowNPCMovementC#脚本类替换为以下代码:

 using UnityEngine;
 using System.Collections;

 public class ArrowNPCMovement : MonoBehaviour {
 public Waypoint waypoint;
 private bool firstWayPoint = true;
 private NavMeshAgent navMeshAgent;

 void Start (){
 navMeshAgent = GetComponent<NavMeshAgent>();
 HeadForNextWayPoint();
 }

 void Update () {
 float closeToDestinaton = navMeshAgent.stoppingDistance * 2;
 if (navMeshAgent.remainingDistance < closeToDestinaton){
 HeadForNextWayPoint ();
 }
 }

 private void HeadForNextWayPoint (){
 if(firstWayPoint)
 firstWayPoint = false;
 else
 waypoint = waypoint.GetNextWaypoint();

 Vector3 target = waypoint.transform.position;
 navMeshAgent.SetDestination (target);
 }
 }
  1. 创建一个新的WayPointC#脚本类,包含以下代码:
using UnityEngine;
using System.Collections;

public class Waypoint: MonoBehaviour {
public Waypoint[] waypoints;

public Waypoint GetNextWaypoint () {
return waypoints[ Random.Range(0, waypoints.Length) ];
 }
 }
  1. 选择所有六个名为胶囊-航点-0/1/2/3/4/5 的 GameObject,并给它们添加一个WayPointC#类的实例对象组件。

  2. 选择球体箭头 GameObject,并添加一个WayPointC#类的实例对象组件。

  3. 确保选择了球体箭头 GameObject。在箭头 NPCMovement 脚本组件的检查器面板中,将胶囊-航点-0 拖入Waypoint公共变量槽中。

  4. 现在,我们需要将胶囊-航点-0 连接到胶囊-航点-1,胶囊-航点-1 连接到胶囊-航点-2,依此类推。选择胶囊-航点-0,将其航点数组大小设置为 1,并将胶囊-航点-1 拖入。接下来,选择胶囊-航点-1,将其航点数组大小设置为 1,并将胶囊-航点-2 拖入。以此类推,直到最终将胶囊-航点-5 连接回胶囊-航点-0。

您现在拥有一个更加灵活的游戏架构,允许 GameObject 在到达每个航点时随机选择几条不同的路径。在这个配方变体中,我们已经实现了一个航点序列,因为每个航点只有一个链接航点的数组。然而,如果您将数组大小更改为 2 或更多,那么您将创建一个链接航点的图,为计算机控制的字符在游戏运行中的任何给定运行添加随机变化。

通过鸟群控制对象组移动

通过创建具有以下四个简单规则的对象集合,可以创建一个逼真的、自然外观的鸟群行为(例如鸟类、羚羊或蝙蝠):

  • 分离:避免与邻居过于接*

  • 避免障碍物:立即转向远离前方障碍物

  • 对齐:向鸟群前进的一般方向移动

  • 内聚性:向鸟群中间的位置移动

鸟群中的每个成员都独立行动,但需要了解其鸟群成员的当前航向和位置。这个配方向您展示了如何创建一个包含两个鸟群立方体的场景:一个绿色立方体的鸟群和一个黄色立方体的鸟群。

为了简化,我们不会在我们的配方中考虑分离:

图片

准备工作

这个配方基于您在第一个配方中创建的玩家控制的 3D 立方体 Unity 项目。因此,复制此项目,打开它,然后按照此配方的步骤进行操作。

控制红色立方体(玩家控制)移动所需的脚本(PlayerControl.cs)在15_07文件夹中提供。

如何操作...

要使一组对象聚集在一起,请按照以下步骤操作:

  1. 在 Project 面板中创建一个材质,命名为 m_green,并将主颜色设置为绿色。

  2. 在 Project 面板中创建一个材质,命名为 m_yellow,并将主颜色设置为黄色。

  3. 在 (0,0,0) 位置创建一个名为 Cube-drone 的 3D 立方体 GameObject,并将 m_yellow 材质拖入此对象。

  4. 向 Cube-drone 添加一个 Navigation | NavMeshAgent 组件。将 NavMeshAgent 组件的 Stopping Distance 属性设置为 2。

  5. 向 Cube-drone 添加一个具有以下属性的 Physics RigidBody 组件:

    • 质量:1

    • 拖动为 0

    • 角度阻力为 0.05

    • 使用重力和不使用动力学都未勾选

    • 约束冻结位置:勾选 Y 轴

  6. 创建以下 Drone C# 脚本类,并将一个实例对象作为组件添加到 Cube-drone GameObject:

 using UnityEngine;
 using UnityEngine.AI;

 public class Drone : MonoBehaviour {
 private NavMeshAgent navMeshAgent;

 void Start() {
 navMeshAgent = GetComponent<NavMeshAgent>();
 }

 public void SetTargetPosition(Vector3 swarmCenterAverage, Vector3 swarmMovementAverage) {
 Vector3 destination = swarmCenterAverage + swarmMovementAverage;
 navMeshAgent.SetDestination(destination);
 }
 }
  1. 创建一个新的空 Prefab,命名为 dronePrefabYellow,然后从 Hierarchy 面板中,将你的 Cube-boid GameObject 拖入此 Prefab。

  2. 现在,将 m_green 材质拖入 Cube-boid GameObject。

  3. 创建一个新的空 Prefab,命名为 dronePrefabGreen,然后从 Hierarchy 面板中,将你的 Cube-drone GameObject 拖入此 Prefab。

  4. 从 Scene 面板中删除 Cube-drone GameObject。

  5. 创建以下 Swarm C# 脚本类,并将一个实例对象作为组件添加到 Main Camera:

using UnityEngine;
 using System.Collections.Generic;

public class Swarm : MonoBehaviour {
 public int droneCount = 20;
 public GameObject dronePrefab;

private List<Drone> drones = new List<Drone>();

void Awake() {
 for (int i = 0; i < droneCount; i++)
 AddDrone();
 }

void FixedUpdate() {
 Vector3 swarmCenter = SwarmCenterAverage();
 Vector3 swarmMovement = SwarmMovementAverage();

foreach(Drone drone in drones )
 drone.SetTargetPosition(swarmCenter, swarmMovement);
 }

private void AddDrone()
 {
 GameObject newDroneGo = Instantiate(dronePrefab);
 Drone newDrone = newDroneGo.GetComponent<Drone>();
 drones.Add(newDrone);
 }

private Vector3 SwarmCenterAverage() {
 Vector3 locationTotal = Vector3.zero;
 foreach(Drone drone in drones )
 locationTotal += drone.transform.position;

return (locationTotal / drones.Count);
 }

private Vector3 SwarmMovementAverage() {
 Vector3 velocityTotal = Vector3.zero;
 foreach(Drone drone in drones )
 velocityTotal += drone.GetComponent<Rigidbody>().velocity;

return (velocityTotal / drones.Count);
 }
 }
  1. 在 Hierarchy 面板中选择 Main Camera,从 Project 面板中将 dronePrefabYellow 拖到 Drone Prefab 公共变量上。

  2. 在 Hierarchy 面板中选择 Main Camera,向此 GameObject 添加 Swarm 脚本类的一个第二个实例对象,然后从 Project 面板中将 dronePrefabGreen 拖到 Drone Prefab 公共变量上。

  3. 创建一个新的 3D 立方体,命名为 wall-left,具有以下属性:

    • 位置: (-15, 0.5, 0)

    • 缩放: (1, 1, 20)

  4. 通过命名新对象 wall-right 复制 wall-left 对象,并将 wall-right 的位置更改为 (15, 0.5, 0)。

  5. 创建一个新的 3D 立方体,命名为 wall-top,具有以下属性:

    • 位置: (0, 0.5, 10)

    • 缩放: (31, 1, 1)

  6. 通过命名新对象 wall-bottom 复制 wall-top 对象,并将 wall-bottom 的位置更改为 (0, 0.5, -10)。

  7. 创建一个新的 3D 球体,命名为 Sphere-obstacle,具有以下属性:

    • 位置: (5, 0, 3)

    • 缩放: (10, 3, 3)

  8. 在 Hierarchy 面板中选择 Sphere-obstacle GameObject。然后在 Navigation 面板中勾选 Navigation Static 复选框。然后,点击 Navigation 面板底部的 Bake 按钮。

  9. 最后,为玩家创建一个红色的 3D 立方体,通过添加 Materialm_red 使其变红,并通过将其缩放设置为 (3,3,3) 使其变大。现在,将 PlayerControl C# 脚本类的一个实例对象作为组件添加到这个 GameObject。

如何工作...

Swarm 类包含三个变量:

  1. droneCount:它是一个整数,引用了创建的 Swarm 类成员的数量

  2. dronePrefab:它引用了要克隆以创建群体成员的 Prefab

  3. drones:一个引用无人机的对象列表;一个列表,包含所有已创建的Swarm对象中的所有脚本Drone组件

在创建时,随着场景的开始,Swarm 脚本类的Awake()方法通过反复调用AddDrone()方法来循环创建droneCount个 swarm 成员。此方法从预制体实例化一个新的 GameObject,然后将newDrone变量设置为对新的Swarm类成员中 Drone 脚本对象的引用。在每一帧中,FixedUpdate()方法通过调用它们的SetTargetPosition(...)方法来遍历Drone对象列表,并传入Swarm中心位置和所有 swarm 成员速度的*均值。

这个Swarm类的其余部分由两个方法组成:一个(SwarmCenterAverage)返回一个表示所有Drone对象*均位置的Vector3对象,另一个(SwarmMovementAverage)返回一个表示所有Drone对象*均速度(运动力)的Vector3对象:

  1. SwarmMovementAverage():

    1. 群体正在移动的一般方向是什么?

    2. 这被称为对齐:一个 swarm 成员试图移动到与群体*均方向相同

  2. SwarmCenterAverage():

    1. 群体的中心位置在哪里?

    2. 这被称为凝聚力:一个 swarm 成员试图移动到群体的中心

核心工作由Drone类承担。每个无人机的Start(...)方法找到并缓存其 NavMeshAgent 组件的引用。

每个无人机的UpdateVelocity(...)方法接受两个Vector3参数:swarmCenterAverageswarmMovementAverage。然后此方法通过简单地将两个向量相加来计算这个Drone所需的新速度,并使用结果(一个Vector3位置)来更新 NavMeshAgent 的目标位置。

现代计算中的大多数群聚模型都归功于 20 世纪 80 年代克雷格·雷诺斯的工作。了解更多关于克雷格和他的 boids 程序的信息,请访问en.wikipedia.org/wiki/Craig_Reynolds_(computer_graphics)

创建可移动的 NavMesh 障碍物

有时,我们希望移动对象减速或防止 AI NavMeshAgent 控制的角色穿过游戏中的某个区域。或者,也许我们希望像门或吊桥这样的东西有时允许通行,而有时不允许。我们无法在 Design-Time 将这些对象“烘焙”到 NavMesh 中,因为我们希望在 Run-Time 更改它们。

虽然在计算上更昂贵(也就是说,它们会减慢你的游戏速度,比静态不可导航的对象更多),但 NavMesh 障碍物是可以添加到 GameObject 的组件,并且这些组件可以像任何其他组件一样启用和禁用。

NavMesh 障碍物的一个特殊属性是它们可以被设置为“雕刻”NavMesh 的区域,导致 NavMeshAgents 重新计算避免这些雕刻出的网格部分的路线。

在这个菜谱中,你将创建一个玩家控制的红色立方体,你可以移动它来阻碍 AI NavMeshAgent 控制的角色。此外,如果你的立方体在一个地方停留半秒或更长时间,它将雕刻出其周围的 NavMesh 的一部分,因此导致 NavMeshAgent 停止撞击障碍物,并计算并遵循一条避开它的路径:

准备工作

这个菜谱增加了本章的第一个菜谱,所以请复制那个项目文件夹,并使用该副本来完成这个菜谱的工作。

控制红色立方体移动所需的脚本 (PlayerControl.cs) 已在 15_08 文件夹中提供。

如何操作...

要创建一个可移动的 NavMesh Obstacle,请按照以下步骤操作:

  1. 在项目面板中创建一个 Material,命名为 m_green,并将主颜色调整为绿色。

  2. 为玩家控制创建一个红色 3D 立方体,命名为 Cube-player,通过添加 Material m_red 使其变红,并通过将其比例设置为 (3,3,3) 使其变大。

  3. 将提供的 PlayerControl C# 脚本类的一个实例作为组件添加到这个 GameObject 上。

  4. 在检查器中,向 Cube-player 添加一个 Navigation | NavMesh Obstacle 组件并检查其 Carve 属性。

  5. 运行游戏。你可以移动玩家控制的红色立方体来阻碍移动的 Sphere-arrow GameObject。在 NavMesh Obstacles Time-to-stationary 时间为半秒后,如果你显示了 Gizmos,你会看到 NavMesh 的雕刻,这样 Cube-player 占据的区域以及其周围的一小部分就会被从 NavMesh 中移除,然后 Sphere-arrow GameObject 将重新计算一条新的路线,避开 Cube-player 所在的雕刻区域。

它是如何工作的...

在运行时,AI NavMeshAgent 控制的 Sphere-arrow GameObject 会朝向目标点移动,但当玩家控制的红色立方体挡在路中间时,它会停止。一旦立方体静止超过 0.5 秒,NavMesh 就会被雕刻出来,这样 AI NavMeshAgent 控制的 Sphere-arrow GameObject 就不再尝试规划通过立方体占据的空间的路径,而是重新计算一条完全避开障碍物的新路径,即使这意味着要回头并偏离目标的一部分路径。

第十四章:设计模式

在本章中,我们将涵盖:

  • 由状态驱动的行为 DIY 状态

  • 由状态驱动的行为 状态设计模式类

  • 使用 Unity 可脚本化对象的状态驱动行为

  • 发布-订阅模式 C#委托和事件

  • 模型-视图-控制器(MVC)模式

简介

在计算机编程的一般情况下,某些类型的功能和需求经常出现。对于计算机游戏编程,新游戏通常具有与现有游戏共同的特征。软件设计模式是解决常见问题的可重用、计算机语言无关的模板。

并非所有设计模式对所有语言都是必需的(例如,某些计算机语言可能已经提供了解决常见问题的简单方法)。在本章中,我们将探讨在 C#编程语言背景下 Unity 游戏编程中的几个常见设计模式。

整体情况

没有必要重新发明轮子,并且对于游戏程序员来说,采用经过验证的方法来解决游戏项目中常见的功能有许多优势。设计模式是通过经验设计的,并经过提炼,以鼓励良好的编程实践和精心设计的软件架构解决方案。设计模式中的常见主题是代码组件的独立性,以及当组件需要相互了解时,明确定义的接口及其交互协议。

在本章中,食谱主要关注三个主要设计模式,这些模式不仅众所周知,而且不仅存在于游戏软件设计中,还存在于许多交互式软件系统的设计中,如 Web 应用和手机编程。本章探讨的设计模式包括:

  • 状态模式:状态及其转换

  • 发布-订阅模式:观察者订阅事件发布对象

  • 模型-视图-控制器(MVC)模式:将内部工作与 UI 组件和显示表示分离

在本章中,示例以通用面向对象设计的形式呈现,同时也有 C#特定的功能(例如委托事件),以及一些 Unity 特定的功能(例如可脚本化对象)。虽然通用方法具有优势,即来自其他语言或领域的程序员会立即熟悉他们的 Unity 游戏实现,但通过利用语言和引擎特定的功能,可以找到最显著的内存速度提升。

C# 的 Delegate 变量就像一个用于函数(或函数集合)的容器,这些函数可以被传递和调用。它们被分配了值,并且可以在运行时更改。Delegates 可以通过使用 += 操作符进行多播,多个方法可以被分配给单个委托,并且当委托被调用时,所有方法都会被调用。C# 的 events 是一种特殊且更安全的委托类型。通过定义公共静态事件变量,我们限制其他脚本类只能允许:

  • 订阅其中一个方法

  • 取消其中一个方法的订阅

事件确保了我们的代码逻辑的良好分离,并且意味着发布事件的脚本类不需要了解或关心有多少其他脚本类正在订阅发布的事件。

这里有一些你可以学习更多关于 设计模式 和 Unity 的资源:

驱动行为的状态 DIY 状态

总体而言,游戏以及单个对象或角色,常常可以被视为(或建模为)通过不同的状态或模式。建模状态和状态变化(由于事件或游戏条件)是管理游戏和游戏组件复杂性的非常常见方式。在本食谱中,我们使用 GameManager 类创建了一个简单的三状态游戏(游戏进行中/游戏胜利/游戏失败)。提供了按钮和计时器来模拟允许玩家赢得或输掉游戏的事件:

图片

如何做到...

要使用状态来管理对象行为,请按照以下步骤操作:

  1. 在屏幕顶部中间创建两个 UI 按钮。将一个命名为Button-win,并编辑其文本为Win Game。将第二个命名为Button-lose,并编辑其文本为Lose Game

  2. 在屏幕左上角创建一个 UI Text 对象。将其命名为Text-state-messages,并将其 Rect Transform 高度属性设置为300,其 Text(脚本)Paragraph Vertical Overflow 属性设置为Overflow

  3. 创建一个新的 C#脚本类,GameStates.cs

    public class GameStates
     {
         public enum GameStateType
         {
             GamePlaying,
             GameWon,
             GameLost,
         }
     } 
  1. 创建MyGameManager.csC#脚本类,并将实例对象作为组件添加到主摄像机:
    using UnityEngine;
     using System;
     using UnityEngine.UI;

     public class MyGameManager : MonoBehaviour
     {
         public Text textStateMessages;
         public Button buttonWinGame;
         public Button buttonLoseGame;

         private GameStates.GameStateType currentState;
         private float timeGamePlayingStarted;
         private float timeToPressAButton = 5;

         void Start()
         {
              currentState = GameStates.GameStateType.GamePlaying;
         }

         //--------- Update[ S ] - state specific actions
         void Update()
         {
             switch (currentState)
             {
                 case GameStates.GameStateType.GamePlaying:
                     UpdateStateGamePlaying();
                     break;
                 case GameStates.GameStateType.GameWon:
                     // do nothing
                     break;
                 case GameStates.GameStateType.GameLost:
                     // do nothing
                     break;
             }
         }

         public void NewGameState(GameStates.GameStateType newState)
         {
             // (1) state EXIT actions
             OnMyStateExit(currentState);

             // (2) change current state
             currentState = newState;

             // (3) state ENTER actions
             OnMyStateEnter(currentState);

             PostMessageDivider();
         }

         public void PostMessageDivider()
         {
             string newLine = "\n";
             string divider = "--------------------------------";
             textStateMessages.text += newLine + divider;
         }

         public void PostMessage(string message)
         {
             string newLine = "\n";
             string timeTo2DecimalPlaces =
     String.Format("{0:0.00}", Time.time);
             textStateMessages.text += newLine +
     timeTo2DecimalPlaces + " :: " + message;
         }

         private void DestroyButtons()
         {
             Destroy(buttonWinGame.gameObject);
             Destroy(buttonLoseGame.gameObject);
         }

         //--------- OnMyStateEnter[ S ] - state specific actions
         private void OnMyStateEnter(GameStates.GameStateType state)
         {
             string enterMessage = "ENTER state: " +
             state.ToString();
             PostMessage(enterMessage);

             switch (state)
             {
                 case GameStates.GameStateType.GamePlaying:
                     OnMyStateEnterGamePlaying();
                     break;
                 case GameStates.GameStateType.GameWon:
                     // do nothing
                     break;
                 case GameStates.GameStateType.GameLost:
                     // do nothing
                     break;
             }
         }

         private void OnMyStateEnterGamePlaying()
         {
             // record time we enter state
             timeGamePlayingStarted = Time.time;
         }

         //--------- OnMyStateExit[ S ] - state specific actions
         private void OnMyStateExit(GameStates.GameStateType state)
         {
             string exitMessage = "EXIT state: " + state.ToString();
             PostMessage(exitMessage);

             switch (state)
             {
                 case GameStates.GameStateType.GamePlaying:
                     OnMyStateExitGamePlaying();
                     break;
                 case GameStates.GameStateType.GameWon:
                     // do nothing
                     break;
                 case GameStates.GameStateType.GameLost:
                     // do nothing
                     break;
             }
         }

         private void OnMyStateExitGamePlaying()
         {
             // if leaving gamePlaying state then destroy the 2 buttons
             DestroyButtons();
         }

         private void UpdateStateGamePlaying()
         {
             float timeSinceGamePlayingStarted =
             Time.time - timeGamePlayingStarted;
             if (timeSinceGamePlayingStarted > timeToPressAButton)
             {
                 string message = "User waited too long - automatically   
                 going to Game LOST state";
                   PostMessage(message);
                 NewGameState(GameStates.GameStateType.GameLost);
             }
         }
     } 
  1. 创建ButtonActions.csC#脚本类,并将实例对象作为组件添加到主摄像机:
    using UnityEngine;

     public class ButtonActions : MonoBehaviour
     {
         private MyGameManager myGameManager;

         private void Start()
         {
             myGameManager = GetComponent<MyGameManager>();
         }

         public void BUTTON_CLICK_ACTION_WIN_GAME()
         {
             string message = "Win Game BUTTON clicked";
             myGameManager.PostMessage(message);
             myGameManager.NewGameState
             (GameStates.GameStateType.GameWon);
         }

         public void BUTTON_CLICK_ACTION_LOSE_GAME()
         {
             string message = "Lose Game BUTTON clicked";
             myGameManager.PostMessage(message);
             myGameManager.NewGameState
             (GameStates.GameStateType.GameLost);
         }

     } 
  1. 在层次结构中选择“Button-win”按钮,并为它的按钮(脚本)组件添加一个 OnClick 动作,以从主摄像机 GameObject 中的 ButtonsActions 组件调用BUTTON_CLICK_ACTION_WIN_GAME()方法。

  2. 在层次结构中选择“Button-lose”按钮,并为它的按钮(脚本)组件添加一个 OnClick 动作,以从主摄像机 GameObject 中的 ButtonActions 组件调用BUTTON_CLICK_ACTION_LOSE_GAME()方法。

  3. 在层次结构中选择主摄像机 GameObject。将此 GameObject 拖入检查器以确保所有三个 GameManager(脚本)公共变量(Text State Messages、Button Win Game 和 Button Lose Game)都有相应的 Canvas GameObject 拖入其中(两个按钮和 UI Text GameObject)。

它是如何工作的...

如以下状态图所示,这个配方模拟了一个简单的游戏,它从GamePlaying状态开始;然后,根据用户点击的按钮,游戏将移动到GameWon状态或GameLost状态。另外,如果用户等待太长时间(五秒)才点击按钮,游戏将移动到GameLost状态。

系统的可能状态是通过在GameStates类中使用枚举的GameStateType类型来定义的,并且系统在任何时间点的当前状态都存储在GameManagercurrentState变量中:

图片

初始状态GamePlaying是在MyGameManagerStart()方法中设置的。

GameManager在 Unity 中似乎是一个特殊的资产名称,因此,我们已将我们的游戏管理脚本类命名为MyGameManager,以避免任何问题。

MyGameManager对象接收到消息(例如,对于Update(),每帧一次)时,其行为必须适合当前状态。因此,我们在Update()方法中看到了一个Switch语句,它调用特定状态的方法。例如,如果当前状态是GamePlaying,当接收到Update()消息时,将调用UpdateStateGamePlaying()方法。

NewGameState(...)方法首先调用带有当前状态的OnMyStateExit(...)方法,因为当退出特定状态时可能需要执行某些操作;例如,当退出GamePlaying状态时,它会销毁两个按钮。接下来,NewGameState(...)方法将currentState变量设置为分配新状态。然后,调用OnMyStateEnter(...)方法,因为当进入新状态时可能需要立即执行某些操作。最后,通过调用PostMessageDivider()方法,在 UI 文本框中发布一个消息分隔符。

当对应的按钮被点击时,会执行BUTTON_CLICK_ACTION_WIN_GAME()BUTTON_CLICK_ACTION_LOSE_GAME()方法。它们将游戏移动到相应的GameWonGameLost状态。

逻辑已经编写在UpdateStateGamePlaying()方法中,因此一旦MyGameManager处于GamePlaying状态超过一定时间(由timeToPressAButton变量定义),游戏将自动变为GameLost状态。

因此,对于每个状态,我们可能需要编写状态退出、状态进入和更新事件的相应方法,以及每个事件的主方法,其中包含一个 Switch 语句来确定应该调用哪个状态方法(或不应调用)。正如可以想象的那样,随着更多状态和更复杂的游戏逻辑的需求增加,我们的方法和MyGameManager类中的方法数量将显著增加。

参见

  • 下一个菜谱采用了一种更复杂的状态驱动游戏方法,其中每个状态都有自己的类。本章节的下一个菜谱展示了如何通过类继承和状态设计模式来管理状态的复杂性。

使用状态设计模式实现状态驱动行为

之前展示的模式不仅说明了建模游戏状态的有用性,而且还说明了游戏管理类如何增长并变得难以管理。为了管理许多状态和状态的复杂行为,软件开发社区提出了状态模式。设计模式是一般用途的软件组件架构,经过尝试和测试,被证明是解决常见软件系统特征的优秀解决方案。状态模式的关键特性是每个状态都由其自己的类来建模,并且所有状态都继承(子类化)自单个父状态类。状态需要相互了解,以便告诉游戏管理器更改当前状态。这是为了将整体游戏行为的复杂性分解为单独的状态类而付出的微小代价。

注意:非常感谢 Bryan Griffiths 的贡献,这有助于改进这个菜谱。

准备工作

这个菜谱基于之前的菜谱。因此,请复制那个项目,打开它,然后按照这个菜谱的步骤进行操作。

如何操作...

要使用状态模式架构管理对象的行为,请执行以下步骤:

  1. 创建一个新的 C#脚本类,名为GameState:
    public class GameState
     {
         public enum EventType
         {
             ButtonWinGame,
             ButtonLoseGame
         }

         protected MyGameManager gameManager;
         public GameState(MyGameManager manager)
         {
             gameManager = manager;
         }

         public virtual void OnMyStateEntered() {}
         public virtual void OnMyStateExit() {}
         public virtual void StateUpdate() {}
         public virtual void OnEventReceived(EventType eventType) {}
     } 
  1. 创建一个新的 C#脚本类,名为StateGamePlaying:
    using UnityEngine;

     public class StateGamePlaying : GameState
     {
         public StateGamePlaying(MyGameManager manager) : 
         base(manager) { }

         public override void OnMyStateEntered()
         {
             string stateEnteredMessage = "ENTER state: 
             StateGamePlaying";
             gameManager.DisplayStateEnteredMessage
             (stateEnteredMessage);
             Debug.Log(stateEnteredMessage);
         }

         public override void OnEventReceived(EventType eventType)
         {
             switch(eventType){
                 case (EventType.ButtonWinGame):
                     gameManager.NewGameState(gameManager.stateGameWon);
                     break;
                 case (EventType.ButtonLoseGame):
                 case (EventType.TimerFinished):
                     gameManager.NewGameState
                     (gameManager.stateGameLost);
                     break;
             }
         }
     } 
  1. 创建一个新的 C#脚本类,名为StateGameWon:
    using UnityEngine;

     public class StateGameWon : GameState
     {
         public StateGameWon(MyGameManager manager) : base(manager) { }

         public override void OnMyStateEntered()
         {
             string stateEnteredMessage = "ENTER state: StateGameWon";
             gameManager.DisplayStateEnteredMessage
             (stateEnteredMessage);
             Debug.Log(stateEnteredMessage);
         }
     } 
  1. 创建一个新的 C#脚本类,名为StateGameLost:
    using UnityEngine;

     public class StateGameLost : GameState
     {
         public StateGameLost(MyGameManager manager) : base(manager) { }

         public override void OnMyStateEntered()
         {
             string stateEnteredMessage = "ENTER state: StateGameLost";
             gameManager.DisplayStateEnteredMessage
             (stateEnteredMessage);
             Debug.Log(stateEnteredMessage);
         }
     } 
  1. 用以下内容替换MyGameManagerC#脚本类的现有内容:
    using UnityEngine;
     using UnityEngine.UI;

     public class MyGameManager : MonoBehaviour
     {
         public Text textGameStateName;
         public Button buttonWinGame;
         public Button buttonLoseGame;

         [HideInInspector]
         public StateGamePlaying stateGamePlaying;

         [HideInInspector]
         public StateGameWon stateGameWon;

         [HideInInspector]
         public StateGameLost stateGameLost;

         private GameState currentState;

         private void Awake()
         {
             stateGamePlaying = new StateGamePlaying(this);
             stateGameWon = new StateGameWon(this);
             stateGameLost = new StateGameLost(this);
         }

         private void Start()
         {
             NewGameState(stateGamePlaying);
         }

         private void Update()
         {
             if (currentState != null)
                 currentState.StateUpdate();
         }

         public void NewGameState(GameState newState)
         {
             if (null != currentState)
                 currentState.OnMyStateExit();

             currentState = newState;
             currentState.OnMyStateEntered();
         }

         public void DisplayStateEnteredMessage(string  
         stateEnteredMessage)
         {
             textGameStateName.text = stateEnteredMessage;
         }

         public void PublishEventToCurrentState(GameState.EventType 
         eventType)
         {
             currentState.OnEventReceived(eventType);
             DestroyButtons();
         }

         private void DestroyButtons()
         {
             Destroy(buttonWinGame.gameObject);
             Destroy(buttonLoseGame.gameObject);
         }
     } 
  1. 在层次结构中,选择“Button-win”按钮,为其“Button (Script)”组件添加一个 OnClick 动作,以调用主摄像机 GameObject 中 GameManager 组件的BUTTON_CLICK_ACTION_WIN_GAME()方法。

  2. 在层次结构中,选择“Button-lose”按钮,为其“Button (Script)”组件添加一个 OnClick 动作,以调用主摄像机 GameObject 中 GameManager 组件的BUTTON_CLICK_ACTION_LOSE_GAME()方法。

  3. 在层次结构中,选择主摄像机 GameObject。将其拖动到检查器中,以确保所有三个 GameManager (Script)公共变量(文本状态消息、按钮赢游戏和按钮输游戏)都有相应的 Canvas GameObject 拖动到它们中(两个按钮和UI TextGameObject)。

它是如何工作的...

对于这个配方,场景非常简单。有一个单独的主摄像机 GameObject,它附有MyGameManager脚本对象组件。

为游戏需要管理的每个状态定义一个 C#脚本类——对于这个例子,StateGamePlayingStateGameWonStateGameLost。这些状态类都是GameState的子类。GameState定义了所有子类状态将拥有的属性和方法:

  • 一个枚举类型EventType,它定义了游戏可能生成的两种可能的按钮点击事件:ButtonWinGameButtonLoseGame

  • gameManager变量,以便每个状态对象都有一个指向游戏管理器的链接。

  • 接受对MyGameManager引用的构造函数方法,这会自动使gameManager变量指向传入的MyGameManager对象。

  • 四个具有空体的方法:OnMyStateEntered()OnMyStateExit()OnEventRecieved(...)StateUpdate()。请注意,这些方法被声明为虚拟的,以便在必要时可以被子类覆盖,如果没有覆盖,则不会执行任何操作。

MyGameManager类的Awake()方法执行时,将创建三个状态对象,每个对象对应于一个玩/赢/输类。这些状态对象存储在其相应的变量中:stateGamePlayingstateGameWonstateGameLost

MyGameManager类中有一个名为currentState的变量,它在游戏运行期间始终指向当前状态对象(最初,它将是 null)。由于它是GameState类(所有状态类的父类)的引用,它可以指向任何不同的状态对象。

Awake() 之后,GameManager 将接收一个 Start() 消息。此方法将 currentState 初始化为 stateGamePlaying 对象。

对于每一帧,GameManager 将接收 Update() 消息。在接收到这些消息后,GameManager 会向 currentState 对象发送 StateUpdate() 消息。因此,对于每一帧,当前游戏状态的对象将执行这些方法。例如,当 currentState 设置为游戏进行时,对于每一帧,gamePlayingObject 将调用其(在这种情况下,为空的)StateUpdate() 方法。

StateGamePlaying 类在其 OnEventReceived() 方法中实现了语句,以便当用户点击按钮时,gamePlayingObject 将调用 GameManager 实例的 NewState(...) 方法,并传递对应新状态的对象。例如,如果用户点击按钮-win,则 NewState(...) 方法传递给 gameManager.stateGameWon

还有更多...

有些细节你不希望错过。

在游戏开始后五秒添加计时器事件

状态模式解决方案使得添加新功能变得更加简单和清晰。例如,要将五秒计时器功能添加到这个配方中,请执行以下操作:

  1. 创建一个新的 C# 脚本类名为 SimpleTimer,并将实例对象作为组件添加到主摄像机 GameObject:
    using UnityEngine;

     public class SimpleTimer : MonoBehaviour
     {
         private float timeGamePlayingStarted;
         private float timeToPressAButton = 5;

         private MyGameManager myGameManager;

         private void Start()
         {
             myGameManager = GetComponent<MyGameManager>();
             timeGamePlayingStarted = Time.time;
         }

         void Update ()
         {
             float timeSinceGamePlayingStarted = Time.time - 
             timeGamePlayingStarted;

             if (timeSinceGamePlayingStarted > timeToPressAButton)
             {
                 myGameManager.PublishEventToCurrentState
                 (GameState.EventType.TimerFinished);
             }
         }
     } 
  1. GameState 脚本类中添加一个新的 TimerFinished 事件类型:
    public enum EventType
     {
         ButtonWinGame,
         ButtonLoseGame,
         TimerFinished
     } 
  1. StateGamePlaying 中的 switch 语句中添加一个新的情况,以便这个 TimerFinished 事件也能使游戏进入 GameLost 状态:
    public override void OnEventReceived(EventType eventType)
     {
         switch(eventType){
             case (EventType.ButtonWinGame):
                 gameManager.NewGameState(gameManager.stateGameWon);
                 break;
             case (EventType.ButtonLoseGame):
             case (EventType.TimerFinished):
                 gameManager.NewGameState(gameManager.stateGameLost);
                 break;
         }
     } 

参见

  • 在这个配方中,通过其他脚本对象(如主摄像机内部的脚本对象组件 ButtonActionsSimpleTimer)调用 PublishEventToCurrentState(...) 公共方法实现了导致 MyGameManager 改变其当前状态的复杂事件类型的一些实现。在本章后面的 发布-订阅模式 C# 委托和事件 配方中提供了更好的实现游戏事件的方法。

使用 Unity Scriptable Objects 的状态驱动行为

Unity 有一个名为 Scriptable Objects 的功能。Scriptable Objects 是存储在 Assets 文件夹中的资产文件,就像其他任何资产(如材质或纹理)一样。在某种程度上,Scriptable Objects 类似于 Monobehaviours,但它们不附加到 GameObjects 上。逻辑(代码)和数据都可以以 Scriptable Objects 的形式作为资产文件存储。

在这个配方中,我们实现了一个基于状态的游戏,游戏从游戏进行状态开始,当计时器耗尽时进入游戏失败状态。如果在计时器耗尽之前收集到两个星星,游戏将进入游戏胜利状态。提供了一个 UI 按钮 给用户,每次点击都会收集一个星星。

正如你将看到的,Scriptable Object 资产文件用于表示哪些决策会导致哪些状态变化发生。

如何实现...

要使用 Unity 可脚本对象管理游戏的状态驱动行为,请执行以下步骤:

  1. 在屏幕中间创建一个 UI Button。将其命名为 Button-collect-star 并编辑其文本,使其显示为 Collect a star

  2. 在屏幕的右上角创建一个 UI Text 对象。将其命名为 Text-current-state

  3. 在屏幕的右上角创建一个 UI Text 对象。将其命名为 Text-stars-collected

  4. 在屏幕的左上角创建第二个 UI Text 对象,位于 Text-stars-collected 下方。将其命名为 Text-seconds-left

  5. 创建一个新的 C# 脚本类,命名为 Decision

    using UnityEngine;

     public abstract class Decision : ScriptableObject
     {
         public abstract bool Decide(StateController controller);
     }
  1. 创建一个新的 C# 脚本类,命名为 Transition
    [System.Serializable]
     public class Transition
     {
         public Decision decision;
         public State trueState;
     } 
  1. 创建一个新的 C# 脚本类,命名为 State
    using UnityEngine;

     [CreateAssetMenu(menuName = "MyGame/State")]
     public class State : ScriptableObject
     {         public Transition[] transitions;

         public void UpdateState(StateController controller)
         {
             CheckTransitions(controller);
         }

         private void CheckTransitions(StateController controller)
         {
             for (int i = 0; i < transitions.Length; i++)
             {
                 bool decisionSucceeded = 
                 transitions[i].decision.Decide(controller);

                 if (decisionSucceeded)
                 {
                     controller.TransitionToState
                     (transitions[i].trueState);
                 }
             }
         }
     } 
  1. 创建一个新的 C# 脚本类,命名为 StateController
    using UnityEngine;

     public class StateController : MonoBehaviour
     {
         public State currentState;
         [HideInInspector] public MyGameManager gameManager;

         void Awake()
         {
             gameManager = GetComponent<MyGameManager>();
         }

         private void Update()
         {
             currentState.UpdateState(this);
             gameManager.DisplayCurrentState(currentState);
         }

         public void TransitionToState(State nextState)
         {
             currentState = nextState;
         }
     } 
  1. 创建一个新的 C# 脚本类,命名为 GameWonDecision
    using UnityEngine;

     [CreateAssetMenu(menuName = "MyGame/Decisions/GameWonDecision")]
     public class GameWonDecision : Decision
     {

         public override bool Decide(StateController controller)
         {
             return GameWonActionDetected(controller.gameManager);
         }

         private bool GameWonActionDetected(MyGameManager gameManager)
         {
             return gameManager.HasCollectedAllStars();
         }
     } 
  1. 创建一个新的 C# 脚本类,命名为 GameLostDecision
    using UnityEngine;

     [CreateAssetMenu(menuName = "MyGame/Decisions/GameLostDecision")]
     public class GameLostDecision : Decision
     {

         public override bool Decide(StateController controller)
         {
             return GameLostActionDetected(controller.gameManager);
         }

         private bool GameLostActionDetected(MyGameManager gameManager)
         {
             return gameManager.GetTimeRemaining() <= 0;
         }
     } 
  1. 创建一个新的 C# 脚本类,命名为 MyGameManager,并将其实例对象作为主摄像机的组件添加:
    using UnityEngine;
     using UnityEngine.UI;

     public class MyGameManager : MonoBehaviour
     {
         public Text textCurrentState;
         public Text textStarsCollected;
         public Text textSecondsLeft;

         public float secondsLeft = 10;
         public int totalStarsToBeCollected = 2;
         private int starsColleted = 0;

         void Update()
         {
             secondsLeft -= Time.deltaTime;
             UpdateDisplays();
         }

         public void DisplayCurrentState(State currentState)
         {
             textCurrentState.text = currentState.name;
         }

         public bool HasCollectedAllStars()
         {
             return (starsColleted == totalStarsToBeCollected);
         }

         public float GetTimeRemaining()
         {
             return secondsLeft;
         }

         public void BUTTON_ACTION_PickupOneStar()
         {
             starsColleted++;
         }

         private void UpdateDisplays()
         {
             textStarsCollected.text = "stars = " + starsColleted;
             textSecondsLeft.text = "time left = " + secondsLeft;
         }
     } 
  1. 在层次结构中,选择 Button-collect-star 按钮,并为它的按钮(脚本)组件添加一个 OnClick 动作,调用主摄像机 GameObject 中的 MyGameManager 组件的 BUTTON_ACTION_PickupOneStar() 方法。

  2. 在项目面板中创建两个新的文件夹,分别命名为 _Decisions_States。这些文件夹将包含此项目的可脚本对象资产文件。

  3. _Decisions 文件夹中,创建两个新的决策可脚本对象,分别命名为 GameWonDecisionGameLostDecision。通过选择菜单:资产 | 创建 | MyGame | 决策来完成此操作。

  4. _States 文件夹中,创建三个新的状态可脚本对象,分别命名为 StateGamePlayingStateGameWonStateGameLost。通过选择菜单:资产 | 创建 | MyGame | 状态来完成此操作。

  5. 在项目面板文件夹 _States 中,选择 StateGamePlaying 可脚本对象,并在检查器中将其转换属性的大小设置为 2

    • 对于元素 0,将决策设置为 GameWonDecision,并将真实状态设置为 StateGameWon

    • 对于元素 1,将决策设置为 GameLostDecision,并将真实状态设置为 StateGameLost:

图片

  1. 在层次结构中,选择主摄像机 GameObject。将其拖动到检查器中,以确保所有三个 GameManager(脚本)公共变量(当前状态文本、收集的星星文本和剩余秒数文本)都有相应的 Canvas UI Text GameObject 拖动到它们中。

  2. 运行游戏。游戏应从 StateGamePlaying 状态开始。如果用户不采取任何操作,当计时器到达零时,游戏应进入 StateGameLost 状态。如果用户在计时器到达零之前点击按钮两次(模拟收集两个星星),游戏应进入 StateGameWon 状态。

它是如何工作的...

GameManager 对游戏中基于状态的决策一无所知。它有一个公共方法提供计时器剩余的秒数:GetTimeRemaining()。它还有一个公共方法返回一个布尔值(true/false),表示是否所有星星都已收集:HasCollectedAllStars()。它还引用了三个UI Text GameObject,因此它可以保持显示更新,显示剩余时间和收集的星星。还有一个公共的 DispayCurrentState(...) 方法也更新当前游戏状态的名称(即传递给此方法的任何内容)。

核心游戏行为由 StateController 脚本类驱动。它维护当前状态,并引用 MyGameManager 类的实例对象,这是它在主相机中的同级组件。它的 Awake() 方法获取 MyGameManager 对象的引用。它的 Update() 方法调用当前状态的 UpdateState(...) 方法。它将自己传递给当前状态。它还有一个第三个公共方法,TransitionToState(...),允许状态指示控制器更改当前状态。

Transition 类没有方法,它仅仅存储两个对象的引用:一个决策和系统在决策为真时将改变到的状态。

Decision 类是抽象的,这意味着从这个类中永远不会创建任何对象,但可以定义子类。它声明任何子类都必须实现一个名为 Decide(...) 的方法,该方法返回一个布尔值(true/false)。

State 类有一个 Transition 对象数组,并且它的 UpdateState() 方法简单地调用其 CheckTransitions(...) 方法。CheckTransitions(...) 遍历每个转换,测试其 Decision。如果决策为真,它告诉 StateController 将当前状态设置为转换的真正状态。

在这个项目中,我们声明了 Decision 类的两个子类:

  • GameWonDecision: 这个类的 Decide(...) 方法返回其 GameWonActionDetected(...) 方法的值。GameWonActionDetected(...) 返回游戏管理器的 HasCollectedAllStars() 方法的值,即当游戏管理器表示所有星星都已收集时,游戏胜利的决策为真。

  • GameWonDecision: 这个类的 Decide(...) 方法返回其 GameLostActionDetected(...) 方法的值。GameLostActionDetected(...) 返回真,如果游戏管理器的计时器为零或更少(时间已用完)。

我们创建了五个可脚本化对象资源:

  • 三个状态StateGamePlayingStateGameWonStateGameLost

  • 两个决策GameWonDecisionGameLostDecision

我们能够通过创建菜单,通过在DecisionState脚本类声明之前的语句中声明应该有一个名为 MyGame 的新子菜单来创建这些可脚本化对象资产:

[CreateAssetMenu(menuName = "MyGame/Decisions/GameLostDecision")] 

在这些可脚本化对象中,唯一需要定制的是StateGamePlaying。在这个对象中定义了两个转换:

  • 如果GameWonDecision变为 true,状态控制器应将当前状态设置为StateGameWon

  • 如果GameLostDecision变为 true,状态控制器应将当前状态设置为StateGameLost

虽然这个简单的游戏可能需要做很多工作,但这个配方展示的是如何创建一些通用的状态机类(StateDecisionTransitionStateController),并且实际的游戏行为是通过一系列可脚本化对象资产以及两个类来实现的,这两个类用于实现特殊游戏事件的决策(GameWonDecisionGameLostDecision)。

由于可脚本化对象是资产文件,因此只有一个。有时,当游戏运行时,我们可能希望不同的 GameObject 使用相同的可脚本化对象——例如,许多不同敌人角色的 AI 状态转换可能共享使用同一个可脚本化对象。因此,当在StateDecision可脚本化对象上调用方法时,会传递一个StateController对象实例的引用,这样可脚本化对象的方法中的逻辑就可以在提供的任何运行时控制器对象上工作。这是一个被称为委托设计模式(不要与 C#的委托混淆)的另一个设计模式的例子。

还有更多...

有些细节你不希望错过。

将游戏扩展到模拟玩家健康

为了进一步了解这种基于状态的游戏数据驱动方法的力量,让我们向游戏中添加以下行为:

  • 玩家有一个健康值,起始值为 100%(浮点值为 1.0)

  • 在每一帧,都会随机增加或减少一定量的健康值

  • 如果玩家的健康值下降到零,则游戏失败

要实现这个功能,我们只需要做以下事情:

  1. GameLostDecision脚本类中添加一个测试,如果时间或健康值为零则返回 true:
    private bool GameLostActionDetected(MyGameManager gameManager)
     {
         return (gameManager.GetTimeRemaining() <= 0) || 
         (gameManager.GetHealth() <= 0);
     } 
  1. health功能添加到GameManager脚本类中:
    private float health = 1;
     public float healthPlusMaximum = 0.03f;
     public float healthMinusMaximum = -0.03f;

     void Update()
     {
         // extra feature
         RandomlyChangeHealth();

         secondsLeft -= Time.deltaTime;
         UpdateDisplays();
     }

     // extra freature
     public float GetHealth()
     {
         return health;
     }

     // health can't go below 0 or above 1
     private void RandomlyChangeHealth()
     {
         float healthChange = Random.Range(healthMinusMaximum,  
         healthPlusMaximum);
         health += healthChange;
         health = Mathf.Clamp(health, 0, 1);
     } 

我们还可以在屏幕上添加另一个UI Text来查看当前的健康值。如果公共healthPlusMaximum变量设置小于减去最大值(例如,0.02),则健康值将下降,因为随机*均值将小于零。

要添加另一个状态,例如StatePauseGame,所需的步骤如下:

  1. 创建一个新的GamePausedDecision脚本类,包含检测游戏是否暂停的逻辑(例如,游戏管理器可以有一个isPaused布尔变量,当用户按下P键时设置为 true)

  2. 创建一个新的StateGamePaused脚本对象状态

  3. 创建一个新的GamePausedDecision脚本对象状态

  4. StateGamePlaying脚本对象状态(元素 2)中添加第三个过渡,使用GamePausedDecisionStateGamePaused的真实状态

参见

发布者-订阅者模式 C#委托和事件

当事件可以根据可见性、距离或碰撞来触发时,我们可以使用像OnTriggerExitOnBecomeInvisible这样的事件。当事件可以根据时间段来触发时,我们可以使用协程。然而,某些事件是每个游戏情况独有的,C#提供了几种方法来向脚本对象广播用户定义的事件消息。一种方法是SendMessage(...)方法,当发送到 GameObject 时,将检查每个 Monobehaviour 脚本组件,如果其参数匹配,则执行命名方法。然而,这涉及到一种称为反射的低效技术。C#提供了另一种事件消息方法,称为委托和事件,我们在本食谱中描述并实现它。

委托和事件的工作方式与SendMessage(...)类似,但更高效,因为 Unity 维护一个定义了哪些对象正在监听广播事件的列表。如果性能很重要,应避免使用SendMessage(...),因为这意味着 Unity 必须分析每个脚本对象(对对象进行反射)以查看是否有与发送的消息相对应的公共方法;这比使用委托和事件慢得多。

委托将声明委托的代码与使用委托的任何代码(类)分开。声明公共委托的脚本类不需要了解任何使用其委托功能的对象或对象。

委托和事件实现了 发布者-订阅者设计模式pubsub)。这也被称为 观察者 设计模式。对象可以将它们的方法之一订阅,以从特定的发布者接收特定类型的事件消息。在这个菜谱中,我们将有一个管理类,当 UI 按钮 被点击时,它会发布新的事件。我们将创建一些 UI 对象,其中一些对象订阅了颜色更改事件。每次发布颜色更改事件时,订阅的 UI 对象都会接收到事件消息并相应地更改它们的颜色。

我们还将添加一个控制台事件记录器,以监听并记录有关颜色更改事件的日志。

C# 发布者对象不必担心在任何时刻有多少对象订阅它们(可能是没有或 1,000 个!)这被称为松耦合,因为它允许不同的代码组件独立编写(和维护),并且是面向对象代码的一个理想特性:

图片

准备工作

对于这个菜谱,在 17_04 文件夹中提供了一个 Unity 包(colorChangeScene.unitypackage)。这个包包含一个场景,其中包含 UI 按钮 和其他用于菜谱的对象。

如何操作...

要使用 C# 委托和事件实现发布者-订阅者模式,请按照以下步骤操作:

  1. 创建一个新的 Unity 项目,并删除默认的 Scenes 文件夹。

  2. 导入提供的 Unity 包(colorChangeScene.unitypackage)。

  3. 将以下 ColorManager C# 脚本类添加到主摄像机:

    using UnityEngine;

     public class ColorManager : MonoBehaviour
     {
         private ColorModel colorModel;
         private ColorChangeListenerConsole colorChangeListenerConsole;

         void Awake()
         {
             colorModel = new ColorModel();
             colorChangeListenerConsole = new 
             ColorChangeListenerConsole();
         }

         public void BUTTON_ACTION_make_green()
         {
             colorModel.SetColor(Color.green);
         }

         public void BUTTON_ACTION_make_blue()
         {
             colorModel.SetColor(Color.blue);
         }

         public void BUTTON_ACTION_make_red()
         {
             colorModel.SetColor(Color.red);
         }
     }
  1. 创建 ColorChangeListenerImage C# 脚本类,并将实例对象作为组件添加到 Image-listeningSlider-listening GameObject(都是 Canvas | listening-game-objects 的子对象):
    using UnityEngine;
     using UnityEngine.UI;

     public class ColorChangeListenerImage : MonoBehaviour
     {
         void OnEnable() {
             ColorModel.OnChangeColor += ChangeColorEvent;
         }

         void OnDisable(){
             ColorModel.OnChangeColor -= ChangeColorEvent;
         }

         void ChangeColorEvent(Color newColor)
         {
             GetComponent<Image>().color = newColor;
         }
     } 
  1. 创建 ColorChangeListenerText C# 脚本类,并将实例对象作为组件添加到 Text-listening GameObject(Canvas | listening-game-objects 的子对象):
    using UnityEngine;
     using UnityEngine.UI;

     public class ColorChangeListenerText : MonoBehaviour
     {
         void OnEnable() {
             ColorModel.OnChangeColor += ChangeColorEvent;
         }

         void OnDisable(){
             ColorModel.OnChangeColor -= ChangeColorEvent;
         }

         void ChangeColorEvent(Color newColor)
         {
             GetComponent<Text>().color = newColor;
         }
     }
  1. 创建 ColorChangeListenerConsole C# 脚本类:
    using UnityEngine;

     public class ColorChangeListenerConsole
     {
         public ColorChangeListenerConsole()
         {
             ColorModel.OnChangeColor += ChangeColorEvent;
         }

         ~ColorChangeListenerConsole()
         {
             ColorModel.OnChangeColor -= ChangeColorEvent;
         }

         void ChangeColorEvent(Color newColor){
             Debug.Log("new color = " + newColor);
         }
     } 
  1. 创建 ColorModel C# 脚本类:
    using UnityEngine;

     public class ColorModel
     {
         private Color color;

         public delegate void ColorChangeHandler(Color newColor);
         public static event ColorChangeHandler OnChangeColor;

         private void PublishColorEvent()
         {
             // if there is at least one listener to this delegate
             if (OnChangeColor != null)
                 // broadcast change colour event
                 OnChangeColor(this.color);
         }

         public void SetColor(Color newColor)
         {
             this.color = newColor;
             PublishColorEvent();
         }
     } 
  1. 在 Hierarchy 中选择按钮-GREEN,为该按钮添加一个新的 OnClick() 事件,将主摄像机作为目标 GameObject,并选择 BUTTON_ACTION_make_green() 公共函数。对按钮-BLUE 和按钮-RED 按钮分别执行相同的操作,使用 BUTTON_ACTION_make_blue() 和 BUTTON_ACTION_make_red() 函数。

  2. 运行游戏。当你点击更改颜色按钮时,屏幕右侧的三个 UI 对象会显示所有对应的颜色更改,而屏幕左下角的两个 UI 对象将保持默认的白色。你还应该在控制台面板中看到 Debug.Log 消息,显示被点击按钮对应的 RBG 颜色。

它是如何工作的...

我们已经将 ColorManager 的实例对象添加到主摄像机。这个类主要做三件事:

  • 创建ColorChangeListenerConsole脚本类的实例对象

  • 创建ColorModel脚本类的实例对象

  • 提供了三个可以通过点击红色/绿色/蓝色 UI 按钮调用的公共方法

每次点击按钮时,都会告诉colorModel对象将颜色设置为相应的颜色。

ColorModel脚本类有一个表示当前颜色的私有变量。这个值可以通过调用其SetColor(...)方法(在点击按钮时从ColorManager调用)来改变。除了改变颜色值之外,SetColor(...)方法还会调用PublishColorEvent()方法。PublishColorEvent()方法发布OnChangeColor(this.color)事件,这样所有注册监听此事件的监听器都会被调用,并使用新的颜色值。

在屏幕的右侧,我们有三个 GameObject:Image-listening、Slider-listening 和 Text-listening。这些对象各自都有一个ColorChangeListenerImageColorChangeListenerText的脚本组件。这些组件将它们各自的OnChangeColor(...)方法注册为监听OnChangeColor(this.color)事件。ColorChangeListenerImageColorChangeListenerText脚本类都将它们的ChangeColor(...)方法注册到ColorModel脚本类的OnChangeColor(...)事件。

ColorChangeListenerConsole脚本类也将它的ChangeColor(...)方法注册到ColorModel脚本类的OnChangeColor(...)事件。

由于我们的脚本对象可能在不同的时间被启用和禁用,每次脚本ColorChangeListener对象被启用(例如,当其 GameObject 父对象被实例化时),其OnChangeColor()方法将被添加到订阅颜色更改事件的列表中(+=),同样,每次ColorChangeListenerImageText对象被禁用,那些方法将从事件订阅者列表中移除(-=)。

当不再需要时,从事件的订阅者列表中删除方法非常重要。未能这样做可能会导致重大问题,例如内存泄漏。

ColorChangeListenerImageText对象接收到颜色更改消息时,其订阅的OnChangeColor()方法将被执行,并且适当的组件颜色将更改为接收到的颜色值(绿色/红色/蓝色)。

ColorManager 类声明了一个名为 ColorChangeHandler委托。委托定义了可以委托(订阅)到事件的方法的返回类型(在这种情况下,void)和参数签名。在这种情况下,方法必须具有单个 Color 类型参数的参数签名。我们的 OnChangeColor() 方法在 ColorChangeListenerImageTextConsole 类中与这个参数签名相匹配,因此可以订阅 ColorManager 类中的 changeColorEvent

你可能会注意到 ColorChangeListenerConsole 没有 OnEnableOnDisable 方法。这是因为它不是一个 MonoBehaviour,所以不会接收到 Unity 运行时事件,如 Awake()Update()OnEnable()。然而,作为一个简单的类,它可以在使用 new 关键字创建新的对象实例时调用构造方法,并在对象不再被引用时调用析构方法。因此,在这些方法中,这些对象注册和注销以监听 ColorModel.OnChangeColor 事件。

模型-视图-控制器 (MVC) 模式

模型-视图-控制器 (MVC) 模式是一种软件架构,试图将数据(模型)与显示(视图)以及改变数据的操作(控制器)分离。

在这个菜谱中,我们使用 MVC 模式来实现许多游戏的一个功能——一个表示玩家数值生命值的视觉生命条(在这种情况下,一个从 0.0 - 1.0 的浮点数)。当用户按下上/下箭头键(模拟治疗和伤害)时,玩家的生命值会发生变化。随着生命值变化事件的发生,视觉显示和 控制台 日志都会更新,以向用户展示新的生命值:

图片

准备工作

对于这个菜谱,17_05 文件夹中提供了两个图像:

  • health_bar_outline.png:一个红色心形图像和填充轮廓

  • health_bar_fill_blue_to_green.png:一个渐变(从左边的蓝色到右边的绿色)填充图像,表示剩余的健康量

感谢 Pixel Art Maker 提供的生命条图像:

如何操作...

在 Unity 中实现 MVC 模式,请按照以下步骤操作:

  1. 导入提供的两个图像。

  2. 创建一个名为 Image-health-bar-outlineUI Image,并用 health_bar_outline 图像资源填充它。

  3. 创建一个名为 Image-health-bar-fillerUI Image,并用 health_bar_fill_blue_to_green 图像资源填充它。在 Image (Script) 组件的检查器中,将图像类型设置为 Filled,并将填充类型设置为水*。

  4. 将你的 画布 安排得使 Image-health-bar-filler 在层次结构中位于 Image-health-bar 之前(填充物位于轮廓之后)。

  5. 创建一个新的 C# 脚本类,Player

   public class Player
    {
        public delegate void HealthChangeAction(float health);
        public static event HealthChangeAction OnHealthChange;

        private float health;
        const float MIN_HEALTH = 0;
        const float MAX_HEALTH = 1;

        public Player(float health = 1)
        {
            this.health = health;

            // ensure initial value published
            PublishHealthChangeEvent();
        } 

        public float GetHealth()
        {
            return this.health;
        }

        public void AddHealth(float amount)
        {
            this.health += amount;
            if (this.health > MAX_HEALTH)
            {
                this.health = MAX_HEALTH;
            }
            PublishHealthChangeEvent();
        }

        public void ReduceHealth(float amount)
        {
            this.health -= amount;
            if (this.health < MIN_HEALTH)
            {
                this.health = MIN_HEALTH;
            }
            PublishHealthChangeEvent();
        }

        // event
        private void PublishHealthChangeEvent()
        {
            if (null != OnHealthChange)
                OnHealthChange(this.health);
        }
    } 
  1. 创建一个新的 C# 脚本类,PlayerController
    using UnityEngine;

     public class PlayerController
     {
         private Player player;

         public PlayerController()
         {
             player = new Player();
         }

         public void AddToHealth()
         {
             player.AddHealth(0.5f);
         }

         public void ReduceHealth()
         {
             player.ReduceHealth(0.1f);
         }
     } 
  1. 创建一个新的 C#脚本类,HealthBarDisplay,并将实例对象作为组件添加到Image-health-bar-filler
    using UnityEngine;
     using UnityEngine.UI;

     public class HealthBarDisplay : MonoBehaviour
     {
         private Image healthMeterFiller;

         private void Start()
         {
             healthMeterFiller = GetComponent<Image>();
         }

         private void OnEnable()
         {
             Player.OnHealthChange += UpdateHealthBar;
         }

         private void OnDisable()
         {
             Player.OnHealthChange -= UpdateHealthBar;
         }

         public void UpdateHealthBar(float health)
         {
             healthMeterFiller.fillAmount = health;
         }
     } 
  1. 创建一个新的 C#脚本类,HealthChangeLogger
    using UnityEngine;

     public class HealthChangeLogger
     {
         public HealthChangeLogger()
         {
             Player.OnHealthChange += LogNewHealth;
         }

         ~HealthChangeLogger()
         {
             Player.OnHealthChange -= LogNewHealth;
         }

         public void LogNewHealth(float health)
         {
             // 1 decimal place
             string healthAsString = health.ToString("0.0");
             Debug.Log("health = " + healthAsString);
         }
     } 
  1. 将以下PlayerManager C#脚本类添加到主相机:
    using UnityEngine;

     public class PlayerManager : MonoBehaviour
     {
         private PlayerController playerController;
         private HealthChangeLogger healthChangeLogger;

         void Start()
         {
             playerController = new PlayerController();
             healthChangeLogger = new HealthChangeLogger();
         }

         void Update()
         {
             if (Input.GetKeyDown("up"))
                 playerController.AddToHealth();

             if (Input.GetKeyDown("down"))
                 playerController.ReduceHealth();
         }
     } 
  1. 运行游戏。按下上箭头键和下箭头键应该提高/降低玩家的健康值,这应该通过填充图像和控制台中的Debug.Log()消息得到确认。

它是如何工作的...

您提供了两个 UI Images:一个是健康条轮廓(红色心形和黑色轮廓),另一个是填充图像——用于显示从深蓝到浅蓝再到绿色,表示从弱到强的健康值。

您将 Image-health-bar-filler 的图像类型设置为填充,并设置为水*填充(从左到右)。因此,UI Image 的 fillAmount 属性决定了向用户显示的填充图像的量(从0.01.0)。

PlayerManager 脚本类是一个管理脚本,它初始化PlayerControllerHealthChangeLogger对象,并允许用户通过按上箭头键和下箭头键来改变玩家的健康值(在游戏中模拟治疗/伤害)。通过Update()方法,每次按下上箭头键或下箭头键都会调用PlayerController的方法:AddToHealth()ReducedHealth()

PlayerController 脚本类在其构造函数中创建一个PlayerModel对象。它还有两个其他方法,AddToHealth()ReducedHealth(),分别通过+0.5-0.1来增加/减少PlayerModel对象的健康值。

PlayerModel 脚本类管理玩家的健康值,并使用代理和事件将健康变化发布给任何监听视图类。当创建一个新的对象时,健康属性被初始化,并将健康变化事件发布给所有监听对象。同样,当调用AddHealth(...)ReduceHealth(...)方法并传入一个值时,健康值会改变,并将健康变化事件发布给所有监听对象。OnHealthChange 事件作为一个静态公共事件对其他对象可见。PublishHealthChangeEvent() 方法通过调用所有监听的OnHealthChange(...)方法来发布健康的新值。

有两个视图类注册监听Player.OnHealthChange(...)事件:

  • HealthBarDisplay:为每个接收到的新的健康值更新 UI Image 的fillAmount

  • HealthChangeLogger:将接收到的新的Player health值的消息打印到 Debug.Log 文件

如所见,每个 Model/View/Controller 类都相当小且简单,并且每个都有自己的、定义良好的职责。很容易添加另一种健康变化事件监听器,比如播放一个声音,而不需要更改这些现有类中的任何代码。同样,添加一个新的属性到我们的PlayerModel(可能是一个得分或收集到的星星的库存值)也很简单,然后添加另一个公共静态事件,以便将得分/库存更改发布给订阅的监听视图。

参见

  • 只有PlayerManager是一个 MonoBehaviour。这种 MVC 架构的一个优点是,这些组件中的每一个都变得更容易在隔离状态下进行单元测试。这个食谱的版本在第十九章“自动化测试”中的状态设计模式 PlayMode 和单元测试带有事件、日志和异常的健康条食谱中被用作示例。

第十五章:编辑器扩展和即时模式 GUI (IMGUI)

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

  • 记录消息和清除控制台的菜单项

  • 显示包含文本数据的面板

  • 一个交互式面板和持久存储

  • 创建 GameObject、设置父级和注册撤销操作

  • 与选定的对象一起工作并禁用菜单项

  • 创建 100 个随机位置预制克隆的菜单项

  • 显示编辑器扩展处理完成比例的进度条

  • 一个编辑器扩展,允许在设计时通过自定义检查器 UI 更改拾取类型(和参数)

  • 一个编辑器扩展,具有一个对象创建 GameObject,带有按钮在场景中十字准星对象的位置实例化不同的拾取物品

  • 可扩展的基于类的代码架构,用于管理复杂的 IMGUI

简介

游戏开发的一般方面(以及本章中我们特定的例子——库存)的一个方面是关于我们何时进行活动的区分。运行时是游戏运行时(以及我们所有的软件和 UI 选择生效时)。然而,设计时是我们游戏设计团队的不同成员在构建各种游戏组件时工作的时间,包括脚本、音频和视觉资产,以及构建每个游戏关卡(或 Unity 所说的场景)的过程。

Unity 的编辑器扩展是脚本和多媒体组件,它使游戏软件工程师能够使设计时工作更简单,并减少引入错误的可能性。编辑器扩展允许工作流程改进,从而允许设计师更快、更轻松地实现目标;例如,通过菜单选择或在场景中不同位置手动放置拾取物品时,无需任何脚本知识即可生成许多随机位置的库存拾取。

整体概念

除了纯文本外,以下四个部分将为您提供一个关于本章内容的概览。

Unity 即时模式 GUI (IMGUI)

在 Unity 的早期版本中,所有UI组件都是通过代码创建的——没有CanvasRect变换或拖放UI控制布局等。几年前(Unity 4.6),Unity 引入了我们现在所知道的UI系统。新的(播放模式)UI 系统是保留模式 UI的一个例子;我们创建的UI项目被帧帧记住,并且不需要我们作为开发者重新创建/显示。然而,基于代码的 GUI 系统在编辑器扩展中仍然扮演着重要的角色。IMGUI系统被称为即时模式,因为它的代码在每一帧执行一次。因此,没有必要清除之前的 GUI 显示,因为这是自动的。

识别和保存更改

在编辑器扩展菜谱中提出了序列化的概念,我们需要记住,当我们正在检查器中编辑项目属性时,每次更改都需要保存到磁盘,以便下次使用或编辑该项目时更新的属性是正确的。这是通过在OnInspectorGUI()方法中首先调用serializedObject.Update()方法,然后在检查器中完成所有更改后,最终调用serializedObject.ApplyModifiedProperties()方法来实现的。

我们可以通过查询 Unity 的特殊公共布尔值GUI.changed来检测用户是否已更改 GUI 控件。如果 GUI 控件已更改输入数据(例如,如果用户正在输入或点击 GUI 控件),则将其设置为 true。以下是一个使用GUI.changed记录消息的简单示例,当文本输入字段的内文更新时:

stringToEdit = GUILayout.TextField(stringToEdit, 25);

 if (GUI.changed)
     Debug.Log("new contents of 'stringToEdit' = " + stringToEdit); 

Unity 的 EditorGUI 类提供了Begin-End ChangeCheck()方法,如果用户在由这些方法分隔的语句块中更改了一个或多个交互式 GUI 组件,则将 GUI.changed 设置为 true:

EditorGUI.BeginChangeCheck();
 stringToEdit = GUILayout.TextField(stringToEdit, 25);
 ... other interactive GUI statements here
 EditorGUI.EndChangeCheck();

 // logic if any have changed
 if(GUI.changed)
     ... do actions since user has changed at least one GUI control 

如果他们已经更改了,我们可以保存一个语句,因为EditorGUI.EndChangeCheck(),以及声明 GUI.change 的语句块的结束。这也返回 GUI.changed 的布尔值。因此,我们实际上可以在我们的 if 语句中使用这个方法调用而不是 GUI.changed。本质上:

EditorGUI.BeginChangeCheck();
 stringToEdit = GUILayout.TextField(stringToEdit, 25);
 ... other interactive GUI statements here

 // logic if any have changed
 if(EditorGUI.EndChangeCheck())
     ... do actions since user has changed at least one GUI control 

内存 - EditorPrefs 持久存储

立即模式系统的一个问题是一切都是临时的并且会被遗忘。Unity 提供了EditorPrefs,类似于PlayerPrefs,这是一个用于存储在面板关闭和重新打开之间会被记住的数据的功能。正如与PlayerPrefs一样,可以使用Get<>()Set<>()方法存储和检索不同类型的值,包括:

  • SetString(<key>, <value>)

  • GetString(<key>)

  • 等等

还提供了删除所有存储的 EditorPrefs 数据的方法(DeleteAll()),删除给定键的一个项目(DeleteKey(<key>)),以及检查给定键是否存在的方法(HasKey(<key>))。

当然,DeleteAll()不是应该不加考虑就使用的东西。Unity 文档中有一个DeleteAll()的示例,确保游戏开发者首先被询问是否真的想要删除所有存储的值:docs.unity3d.com/ScriptReference/EditorPrefs.DeleteAll.html

通常,值是在每次面板获得焦点时从EditorPrefs加载的(如果存在<key>),例如:

private void OnFocus()  
 {
     if (EditorPrefs.HasKey("PlayerName"))
         playerName = EditorPrefs.GetString("PlayerName");
 } 

同样,当面板失去焦点(OnLostFocus())或关闭(OnDestroy())时,我们可能希望自动使用设置器将任何值保存到 EditorPrefs 中。

结论和进一步资源

虽然编辑器扩展是一个相当高级的话题,但如果你团队中有能够编写自定义编辑器组件的人,例如我们展示的那些,这将大大提高只有一两个成员且对脚本编写有信心的小型团队的效率。

在本章中,我们介绍了展示一些 Unity 编辑器扩展脚本的食谱,说明了我们如何通过限制和控制对象的属性以及它们如何通过检查器进行选择或更改,使事情变得更简单,减少脚本依赖,并降低出错的可能性。

与编辑器扩展和 IMGUI 一起工作有很多内容。以下是一些资源,可以帮助您了解更多关于这些主题的信息:

用于记录消息和清除控制台的菜单项

自定义菜单是向游戏开发者提供轻松访问你的编辑器扩展功能的好方法。记录操作是显示和记录操作性能以及已更改的对象属性的好方法。在本菜谱中,我们将为 Unity 编辑器应用程序创建一个新菜单和一个菜单项,当选择该菜单项时,会记录一条简单消息:

图片

如何操作...

要创建一个带有记录到控制台菜单项的菜单,请按照以下步骤操作:

  1. 在项目面板中,创建一个名为 Editor 的新文件夹。

  2. 在你的新编辑器文件夹中,创建一个名为 ConsoleUtilities.cs 的新 C# 脚本类,其中包含以下内容:

using UnityEditor;
 using UnityEngine;
 using System.Reflection;

 public class ConsoleUtilities : EditorWindow
 {
     [MenuItem("My Utilities/Clear Console")]
     public static void ClearLogConsole()
     {
         var assembly = Assembly.GetAssembly(typeof(SceneView));
         var type = assembly.GetType("UnityEditor.LogEntries");
         var method = type.GetMethod("Clear");
         method.Invoke(new object(), null);
     }

     [MenuItem("My Utilities/Log a message")]
     public static void LogHello()
     { 
         Debug.Log("Hello from my console utilties");
     }
 } 
  1. 几秒钟后,你现在应该看到一个名为我的工具的菜单出现,其中包含两个项目:清除控制台和记录消息。

  2. 现在,你应该能够使用这些菜单项清除控制台并生成日志消息。

如何工作...

你在 Editor 文件夹中创建了一个扩展 EditorWindow 类的编辑器扩展脚本类。你定义了两个方法;每个方法紧接一个属性,用于将菜单项添加到名为我的工具的菜单中。

ClearLogConsole() 方法紧接一个属性声明的新菜单,我的工具,以及其单个菜单项 Clear Console

    [MenuItem("My Utilities/Clear Console")] 

MenuItem 属性紧接在实现用户选择菜单项时要执行的动作的 静态 方法之前。菜单路径的形式为:

  • MenuName/MenuItemName 或

  • MenuName/SubMenuName/MenuItemName 以及子菜单的后续内容

LogHello() 方法在每次被调用时创建一个新的日志消息。

ClearLogConsole() 方法获取 Unity 日志的引用并清除它们。

你可以在 Unity 关于此主题的教程中了解更多关于菜单的编辑器扩展的信息:unity3d.com/learn/tutorials/topics/interface-essentials/unity-editor-extensions-menu-items

应该注意的是,使用反射非常慢,因此通常仅用于编辑器脚本或只执行一次且短暂延迟不会影响用户或游戏开发者的体验。

更多内容

有一些细节你不希望错过。

键盘快捷键

菜单项字符串中的特殊字符可以用来指定菜单项的键盘快捷键:

  • % 表示 CTRL 键(Windows)或 CMD 键(Mac)

  • 表示 SHIFT 键

  • 然后是(小写)字母或字符快捷键(例如,'k' 用于 K 键)

当定义了键盘快捷键后,Unity 也会在菜单项的右侧指示这一点:

图片

那么,让我们添加快捷键,以便 CTRL/CMD-L 记录消息,CTRL/CMD-K 清除日志(键 LK 在键盘上相邻):

[MenuItem("My Utilities/Log a message %l")] // CMD + L
 public static void LogHello()
 {
     Debug.Log("Hello from my console utilties");
 }

 [MenuItem("My Utilities/Clear Console %k")] // CMD + K
 public static void ClearLogConsole()
 {
     var assembly = Assembly.GetAssembly(typeof(SceneView));
     var type = assembly.GetType("UnityEditor.LogEntries");
     var method = type.GetMethod("Clear");
     method.Invoke(new object(), null);
 } 

https://docs.unity3d.com/ScriptReference/MenuItem.html 了解更多关于 Unity 菜单项键盘快捷键的信息。

子菜单

你可以通过在正斜杠之间添加第三个文本项来创建子菜单,形式如下:

Menu Name/Sub-menu name/menu item 

因此,为了有一个名为“实用工具”的菜单,一个名为“控制台”的子菜单以及该子菜单的两个项目,你可以编写以下内容:

[MenuItem("Utilities/Console/Clear Console")]
 public static void ClearLogConsole() {
     // code here
 }

 [MenuItem("Utilities/Console/Log a message")]
 public static void LogHello() {
     // code here
 } 

显示包含文本数据的面板

有时,我们希望在编辑器扩展中创建并显示一个新的面板。在这个菜谱中,我们创建了一个菜单项,用于创建并显示一个新的面板,显示一些文本信息:

图片

如何做到这一点...

要显示包含文本数据的面板,请按照以下步骤操作:

  1. 在项目面板中,创建一个新的文件夹,名为“编辑器”。

  2. 在你的新编辑器文件夹中,创建一个新的 C#脚本类名为InformationPanel.cs,包含以下内容:

using UnityEditor;
 using UnityEngine;

 public class InformationPanel : EditorWindow
 {
     [MenuItem("My Game/Info Panel")]
     public static void ShowWindow()
     {
         GetWindow<InformationPanel>("My Game", true);
     }

     private void OnGUI()
     {
         GUILayout.Label("Hello editor world");
         GUILayout.FlexibleSpace();
         GUILayout.Label("Here is some important information");
     }
 } 
  1. 几秒钟后,你现在应该看到一个名为“我的游戏”的菜单出现,包含菜单项“信息面板”。

  2. 选择菜单项“信息面板”- 你现在应该看到一个新面板出现,标题为“我的游戏”,包含两个文本消息。

它是如何工作的...

你在ShowWindow()方法之前添加了一个属性,将名为“信息面板”的菜单项添加到名为“我的游戏”的菜单中。GetWindow()语句获取一个 InformationPanel 对象的引用 - 如果不存在这样的窗口面板,它将创建一个。它搜索类型为(即,对于这个脚本类)的窗口面板。第一个参数是面板标题“我的游戏”。第二个参数的 true 告诉 Unity 使窗口面板获得焦点(如果已经存在窗口面板)。

如果已创建新的窗口面板,它将始终获得焦点。

OnGUI()方法,它至少每帧执行一次,使用GUILayout显示两个文本标签。由于GUILayout从左上角开始添加项目,所以第一条消息“Hello editor world”出现在面板的左上角。然后是一个FlexibleSpace()语句。这告诉 GUI 布局管理器尽可能填充(默认为垂直)空间,同时为面板中的任何其他内容留出空间。第三个语句显示第二个文本标签。结果是第二个文本标签被FlexibleSpace()推到面板的底部。

尝试调整面板大小;你会看到第二个文本标签始终在底部。

更多内容

这里有一些你不想错过的细节。

垂直居中

如果我们想要垂直居中文本,我们可以在内容前后都添加一个FlexibleSpace()语句。例如,以下代码将垂直居中文本Here is some important information

GUILayout.Label("Hello editor world");

 GUILayout.FlexibleSpace();
 GUILayout.Label("Here is some important information");
 GUILayout.FlexibleSpace(); 

垂直和水*居中(区域中间)

要水*居中,我们需要通过开始(并结束)水*布局来改变默认的垂直布局,形式如下:

GUILayout.BeginHorizontal();
 // content here is laid out horiztonally
 GUILayout.EndHorizontal(); 

通过在水*布局中的内容前后添加FlexibleSpace(),我们可以水*居中内容。

为了水*和垂直居中,我们在水*布局之前和之后以及水*布局内部的内容之前和之后使用 FlexibleSpace()。例如:

private void OnGUI() {
     GUILayout.Label("Hello editor world");
     GUILayout.FlexibleSpace();

     GUILayout.BeginHorizontal();
     GUILayout.FlexibleSpace();

         GUILayout.Label("I am in the center !!!");

     GUILayout.FlexibleSpace();
     GUILayout.EndHorizontal();

     GUILayout.FlexibleSpace();
 } 

这种灵活间距的使用在以下屏幕截图中有说明:

图片

具有持久存储的交互式面板

在即时模式中,我们必须在显示它们的时候存储交互式控件(如按钮和文本输入)的值。同时,我们需要决定何时以及如何持久化存储要记住的值,当面板失去焦点或关闭时。

在这个菜谱中,我们显示一个文本标签,向玩家问候,并使用在编辑器 Prefs 存储中找到的名称。我们还提供了一个文本输入框和一个按钮,当按钮被点击时,我们更新被问候的名称:

图片

如何实现...

要提供一个具有持久存储的交互式面板,请按照以下步骤操作:

  1. 在项目面板中,创建一个新的编辑器文件夹。

  2. 在您的新编辑器文件夹中,创建一个名为 Welcome.cs 的新 C# 脚本类,包含以下内容:

using UnityEditor;
 using UnityEngine;

 public class Welcome : EditorWindow
 {
     private string playerName = "";
     private string tempName = "";

     [MenuItem("Welcome/Hello Player")]
     public static void ShowWindow()
     {
         GetWindow<Welcome>("Welcome", true);
     }

     private void OnGUI()
     {
         // hello
         string helloMessage = "Hello (no name)";
         if (playerName.Length > 0){
             helloMessage = "Hello " + playerName;
         }

         GUILayout.Label(helloMessage);
         GUILayout.FlexibleSpace();

         // text input
         tempName = EditorGUILayout.TextField("Player name:", tempName);

         // button
         if (GUILayout.Button("Update")){
             playerName = tempName;
         }
     }
 } 
  1. 几秒钟后,您现在应该看到一个名为“欢迎”的菜单出现,其中包含菜单项“Hello Player”。

  2. 选择菜单项“Hello Player”。现在您应该看到一个新面板出现,标题为“欢迎”,显示问候信息、文本输入框和一个标签为“Update”的按钮。

  3. 在文本框中输入您的名字,然后按按钮,您应该看到一个按您的名字问候您的消息。

工作原理...

您在 ShowWindow() 方法之前使用了一个属性来添加一个名为“Hello Player”的菜单项到名为“欢迎”的菜单中。GetWindow() 语句获取一个类型为 EditorWindowEditorWindow 对象的引用(即,对于这个脚本类),如果找不到现有窗口面板,则创建一个新的。

OnGUI() 方法,它每帧执行一次,使用 GUILayout 显示以下内容:

  • 形式的文本标签“Hello”

  • 带有提示“玩家名称”的文本输入

  • 一个更新按钮

在第一个项目(问候标签)和输入框以及按钮之间有一些 FlexibleSpace(),因此输入框和按钮出现在面板的底部。

有两个私有字符串变量:

  • playerName

  • tempName

playerName 变量用于决定显示什么问候语。如果这个字符串的长度大于零(本质上,它不是一个空字符串),那么问候信息将是“Hello”。如果 playerName 为空,则信息将是“Hello (no name)”。

第二个变量 tempName 被设置为文本框中的值。这必须在每一帧(在 OnGUI() 中)重新分配,因为文本框每帧都会重新显示。每次用户在文本框中输入不同的文本时,新的文本会立即存储在 tempName 变量中。

最后,有一个if语句会显示更新按钮。如果在某个帧中用户点击了该按钮,那么if-语句将被执行,它将从文本框中复制tempNameplayerName变量中。在下一个帧,我们会看到问候语改变以反映playerName中的新值。

我们已经使用了EditorGUILayout.TextField(...)进行文本字段输入,而其他方法是 GUILayout 方法。EditorGUILayout方法使交互式控件更容易,而 GUILayout 方法使布局更容易。对于这样的窗口-面板,你可以混合使用这些 GUI 方法。

更多内容

有一些细节你不应该错过。

使用 EditorPrefs 的持久化存储

目前,如果面板被关闭(例如,由于调用了新的面板布局),那么任何正在显示的名称都将丢失。然而,我们可以添加一些代码,使用 EditorPrefs 系统在每次点击更新按钮时存储新名称。然后,额外的代码可以在面板新显示时检查 EditorPrefs 值,并将playerName初始化为存储的值。

首先,当面板被创建/获得焦点时,让我们尝试使用键"PlayerName"读取一个 EditorPrefs 项。如果找到,我们将检索该字符串并将playerName变量分配给存储的值:

private void OnFocus() {
 if (EditorPrefs.HasKey("PlayerName"))
 playerName = EditorPrefs.GetString("PlayerName");
 }

现在,让我们创建一个方法,将playerName中的值保存到 EditorPrefs 中,再次使用键"PlayerName"

private void SavePrefs() {
     EditorPrefs.SetString("PlayerName", playerName);
 } 

有两种情况我们可能希望确保值被保存,当面板失去焦点和当它被关闭(销毁)时。因此,对于这两个事件,我们将编写调用我们的SavePrefs()方法的方法:

// automatic save when panel loses focus
 private void OnLostFocus() {
     SavePrefs();
 }

 // automatic save when panel closed/destroyed
 private void OnDestroy() {
     SavePrefs();
 } 

GUILayout 与 EditorGUILayout 的比较

你可能已经注意到在这个配方中有两个不同的GUILayout方法调用:

GUILayout.Label(helloMessage);
 GUILayout.FlexibleSpace();
 tempName = EditorGUILayout.TextField("Player name:", tempName);

GUILayoutEditorGUILayout在提供带有一些自动化布局的 UI 控件方面执行非常相似的角色——也就是说,我们不需要指定每个窗口-面板大小和位置的精确(x,y)值或矩形。GUILayout提供了一些更灵活的布局选项,如FlexibleSpace和水*和垂直组,其中FlexibleSpace可以垂直和水*对齐项目。然而,EditorGUILayout提供了更简单、更强大的数字和文本输入字段,以及颜色选择小部件、折叠组等等。

DM Gregory 在 2017 年 StackExchange 帖子中列出了使用EditorGUILayout时可用的一些额外方法:gamedev.stackexchange.com/questions/139192/difference-between-guilayout-and-editorguilayout

创建 GameObject、设置父级和注册撤销操作

不论是从菜单项还是从检查器视图中,有时我们希望从编辑器扩展中在场景中创建一个新的 GameObject。在这个菜谱中,我们将创建一个新的 GameObject 并随机设置其位置和颜色:

图片

如何做到这一点...

要创建一个对象并更改其值,请按照以下步骤操作:

  1. 在项目面板中创建一个名为 Editor 的新文件夹。

  2. 在你的新 Editor 文件夹中创建一个名为ObjectManager.cs的新 C#脚本类,包含以下内容:

using UnityEditor;
 using UnityEngine;

 public class ObjectManager : EditorWindow
 {
     [MenuItem("GameObject/MyObjectManager/Create New Empty Game Object")]
     static void CreateCustomEmptyGameObject(MenuCommand menuCommand)
     {
         GameObject go = new GameObject("GameObject - custom - Empty");
         go.transform.position = RandomPosition(5);

         // Ensure it gets reparented if this was a context click (otherwise does nothing)
         GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject);

         // Register the creation in the undo system
         Undo.RegisterCreatedObjectUndo(go, "Create " + go.name);
         Selection.activeObject = go;
     }

     private static Vector3 RandomPosition(float limit)
     {
         float x = Random.Range(-limit, limit);
         float y = Random.Range(-limit, limit);
         float z = Random.Range(-limit, limit);
         return new Vector3(x,y,z);
     }
 } 
  1. 几秒钟后,你会在 GameObject 菜单中看到一个名为 MyObjectManager 的新子菜单出现,其中包含菜单项创建新空 GameObject。

  2. 选择菜单:GameObject | MyObjectManager | 创建新空 GameObject。

  3. 你现在应该在 Hierarchy 面板中看到一个名为 GameObject - custom - Empty 的新 GameObject 被创建。如果你选择此对象,它的位置(x,y,z)值应该是随机的,在-0.5 ... 0.5 的范围内。

  4. 你可以从编辑菜单(或CTRL/CMD-Z)撤销创建 GameObject 的操作。

图片

  1. 在 Hierarchy 中选择新的空 GameObject,右键单击以获取此对象的上下文菜单。现在,选择菜单:创建 | MyObjectManager | 创建新空 GameObject。

  2. 你现在应该看到一个作为第一个空 GameObject 子对象的第二个空 GameObject 被创建;

图片

它是如何工作的...

你在CreateCustomEmptyGameObject(...)方法之前添加了一个属性,以向现有的 GameObject 菜单添加一个名为 MyObjectManager 的子菜单,其中包含创建新空 GameObject 的菜单项。

CreateCustomEmptyGameObject(...)方法创建一个名为 GameObject - custom - Empty 的新空 GameObject。然后,它将位置属性设置为从方法RandomPosition(...)返回的 Vector3 随机位置。

CreateCustomEmptyGameObject(...)方法随后使用GameObjectUtility.SetParentAndAlign(...)方法将新 GameObject 作为子对象添加到 Hierarchy 中选定的对象,如果菜单是在选定的 GameObject 的内容中调用的。否则,新 GameObject 在 Hierarchy 中将没有父对象。

RandomPosition(...)方法接受一个 float 参数作为输入,并生成三个值(x,y,z)在从负到正的随机范围内。然后,它创建并返回一个包含这三个值的新 Vector3 对象。

因为我们选择将我们的操作添加到特殊的 GameObject 菜单中,所以我们的子菜单项出现在 Hierarchy 面板的内容创建菜单中,如下所示:创建 | MyObjectManager | 创建新空 GameObject。

更多内容

有些细节你不希望错过。

注册对象更改以允许撤销操作

当我们在 Unity 编辑器中执行对象创建/删除/更改操作时,我们应该给用户提供撤销操作的机会。Unity 通过注册已更改的对象并提供一个 Undo 类,使我们能够非常容易地实现这一点。

我们可以向CreateCustomEmptyGameObject(...)方法添加对Undo.RegisterCreatedObjectUndo(...)方法的调用。这将注册 GameObject 的创建到 Unity 系统的撤销注册中,以便如果用户希望这样做,可以撤销操作:

[MenuItem("GameObject/MyObjectManager/Create New Empty Game Object")]
 static void CreateCustomEmptyGameObject(MenuCommand menuCommand)
 {
     GameObject go = new GameObject("GameObject - custom - Empty");
     go.transform.position = RandomPosition(5);

     // Ensure it gets reparented if this was a context click (otherwise does nothing)
     GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject);

     // Register the creation in the undo system
     Undo.RegisterCreatedObjectUndo(go, "Create " + go.name);
     Selection.activeObject = go;
 } 

在 Unity 文档页面中了解更多关于撤销功能的信息:docs.unity3d.com/ScriptReference/Undo.html

使用随机颜色创建原始 3D GameObject

与创建空 GameObject 相比,我们可以创建新的 GameObject,这些 GameObject 是 3D 原语,如立方体和球体等。我们可以使用GameObject.CreatePrimitive(...)方法来完成此操作。通过添加以下三个方法,我们将能够从我们的 MyObjectManager 子菜单的第二个菜单项中创建随机位置、随机颜色和随机的 3D GameObject:

图片

让我们编写一个方法来添加第二个菜单项,该菜单项提供创建具有 3D 原语的随机 GameObject 的功能:

[MenuItem("GameObject/MyObjectManager/Create New RandomShape GameObject")]
 static void CreateCustomPrimitiveGameObject(MenuCommand menuCommand){
     // Create a custom game object
     GameObject go = BuildGameObjectRandomPrimitive();
     go.transform.position = RandomPosition(5);
     go.GetComponent<Renderer>().sharedMaterial = RandomMaterialColor();
 } 

我们可以从 0..3 中选择一个随机整数,以在 Cube/Sphere/Capsule/Cylinder 3D 原语之间选择我们的新 GameObject:

private static GameObject BuildGameObjectRandomPrimitive() {
     GameObject go;
     PrimitiveType primitiveType = PrimitiveType.Cube;
     int type = Random.Range(0, 4);

     switch (type) {
         case 0:
             primitiveType = PrimitiveType.Sphere;
             break;

         case 1:
             primitiveType = PrimitiveType.Capsule;
             break;

         case 2:
             primitiveType = PrimitiveType.Cylinder;
             break;
     }

     go = GameObject.CreatePrimitive(primitiveType);
     go.name = "GameObject - custom - " + primitiveType.ToString();
     return go;
 } 

这里是我们需要创建具有随机颜色的新材质的最终方法,该材质可以分配给新原语共享 Material 属性:

private static Material RandomMaterialColor() {
     Shader shaderSpecular = Shader.Find("Specular");
     Material material = new Material(shaderSpecular);
     material.color = Random.ColorHSV();

     return material;
 } 

与选定的对象一起工作并禁用菜单项

有时,我们只想在对象当前被选中时执行一些语句,这与那些动作相关。在这个菜谱中,我们学习如何在没有选择任何内容时禁用菜单项。如果选择了 GameObject,我们将获取对该对象的引用并将其移回原点(0,0,0):

图片

如何操作...

要与选定的对象一起工作并禁用菜单项,请按照以下步骤操作:

  1. 在项目面板中,创建一个新的文件夹,Editor。

  2. 在你的新 Editor 文件夹中创建一个名为SelectedObjectManager.cs的新 C#脚本类,包含以下内容:

using UnityEditor;
 using UnityEngine;

 public class SelectedObjectManager : EditorWindow
 {
     [MenuItem("MyMenu/Move To Origin")]
     static void ZeroPosition()
     {
         GameObject selectedGameObject = Selection.activeTransform.gameObject;

         Undo.RecordObject (selectedGameObject.transform, "Zero Transform Position");
         selectedGameObject.transform.position = Vector3.zero;
     }

     [MenuItem("MyMenu/Move To Origin", true)]
     static bool ValidateZeroPosition()
     {
         // Return false if no transform is selected.
         return Selection.activeTransform != null;
     }
 }
  1. 几秒钟后,你会看到一个新创建的菜单 MyMenu,其中包含菜单项“移动到原点”。

  2. 如果在层级中没有选择任何 GameObject,那么“移动到原点”菜单项应该变灰(不活跃)。

  3. 创建一个新的空 GameObject,并将其变换位置设置为(5,6,7)。

  4. 在层级中选择这个新的 GameObject 后,选择菜单:MyMenu | 移动到原点。菜单项应该是活跃的,一旦选择,GameObject 的位置应该被设置为(0,0,0)。

它是如何工作的...

你在ZeroPosition()方法前添加了 MenuItem 属性来创建一个新的菜单 MyMenu,其中包含菜单项“移动到原点”。此方法使用Selection.activeTransform.gameObject属性获取当前选中的 GameObject 的引用。对象被注册到撤销系统的属性更改记录中,然后将其变换位置设置为(0,0,0)。

有第二种方法,ValidateZeroPosition()。此方法前面有一个与 ZeroPosition() 方法相同的 菜单路径 的 MenuItem 属性。MenuItem 属性传递 true 以指示这是一个验证方法:

[MenuItem("MyMenu/MoveToOrigin", true)] 

验证方法必须是静态方法,标记与它们验证的项相同的 MenuItem 属性,并将 true 传递给验证参数。该方法必须返回一个布尔值 true/false,以指示是否满足条件,使菜单项处于活动状态。

ValidateZeroPosition() 返回表达式 Selection.activeTransform != null 的布尔值 true/false。换句话说,如果有选中的 GameObject,它返回 true,如果没有,则返回 false。

在 Unity 教程中了解更多关于编辑器扩展菜单项的信息:unity3d.com/learn/tutorials/topics/interface-essentials/unity-editor-extensions-menu-items

创建 100 个随机位置预制件副本的菜单项

有时候我们想在场景中随机创建 很多 拾取物。而不是手动这样做,我们可以在 Unity 编辑器中添加一个自定义菜单和项,当选择它时,将执行一个脚本。在这个食谱中,我们创建了一个调用脚本的菜单项,以在场景中创建 100 个随机位置的星星拾取物预制件:

准备工作

本食谱假设您是从 第三章,库存 UI 中的第一个食谱开始设置项目 Simple2Dgame_SpaceGirl

如何做到这一点...

要创建一个编辑器扩展,通过一个菜单点击添加 100 个随机位置的预制件副本,请按照以下步骤操作:

  1. 从新的副本开始,使用 mini-game Simple2Dgame_SpaceGirl

  2. 在项目面板中,创建一个名为 Prefabs 的新文件夹。在这个新文件夹内,创建一个名为 prefab_star 的新空预制件。通过将 Hierarchy 面板中的 GameObject 星星拖动到项目面板中的 prefab_star 上,填充这个预制件。现在预制件应该变成蓝色,并具有 GameObject 星星的全部属性和组件的副本。

  3. 从 Hierarchy 中删除 GameObject 星星。

  4. 在项目面板中,创建一个名为 Editor 的新文件夹。在这个新文件夹内,创建一个名为 MyGreatGameEditor 的新 C# 脚本类,代码如下:

using UnityEngine;
 using UnityEditor;

 public class MyGreatGameEditor : MonoBehaviour {
     const float X_MAX = 10f;
     const float Y_MAX = 10f;

     static GameObject starPrefab;

     [MenuItem("My-Great-Game/Make 100 stars")]
     static void PlacePrefabs() {
         string assetPath = "Assets/Prefabs/prefab_star.prefab";
         starPrefab = (GameObject)AssetDatabase.LoadMainAssetAtPath(assetPath);

         int total = 100;
         for(int i = 0; i < total; i++){
             CreateRandomInstance();
         }
     }

     static void CreateRandomInstance() {
         Vector3 randomPosition = RandomPosition();
         Instantiate(starPrefab, randomPosition, Quaternion.identity);
     }

     private static Vector3 RandomPosition() {
         float x = Random.Range(-X_MAX, X_MAX);
         float y = Random.Range(-Y_MAX, Y_MAX);
         float z = 0;
         return new Vector3(x,y,z);
     }
 }
  1. 几秒钟后,根据您电脑的速度,您应该会看到一个新菜单出现,My Great Game,其中只有一个菜单项,制作 100 颗星星。

  2. 选择这个菜单项,就像魔法一样,你现在应该会看到场景中出现 100 个新的 prefab_star(Clone) GameObjects!

它是如何工作的...

本食谱的核心目标是添加一个新的菜单,包含一个将执行我们所需操作的菜单项。C#属性[MenuItem("<menuName>/<menuItemName>")]声明了菜单名称和菜单项名称,并且每次用户选择菜单项时,Unity 都会执行代码列表中跟随的静态方法。

在这个食谱中,[MenuItem("My-Great-Game/Make 100 stars")]语句声明了菜单名称为 My-Great-Game,菜单项为 Make 100 stars。紧随此属性之后的方法是PlacePrefabs()方法。当此方法执行时,它使starPrefab变量成为通过Assets/Prefabs/prefab_star.prefab路径找到的预制件的引用。然后,执行一个循环 100 次,每次调用CreateRandomInstance()方法。

RandomPosition()方法返回一个 Vector3 变量,它是一个随机位置,使用了X_MAXY_MAX常量(z 始终为零)。

CreateRandomInstance()方法通过调用RandomPosition()方法获取一个 Vector3 随机位置。然后使用内置方法Instantiate(...)在场景中创建一个新的 GameObject,制作预制件的克隆并将其定位在由 randomPosition 定义的位置。

还有更多...

一些你不希望错过的细节:

将每个新的 GameObject 分配给单个父对象,以避免在层次结构中填充 100 多个新对象

而不是让数百个新对象克隆填充我们的层次结构面板,保持事物整洁的好方法是有一个新的“父”GameObject,并将相关的一组 GameObject 作为子对象添加到它。让我们在层次结构中有一个名为 Star-container 的 GameObject,并将所有新的星星作为子对象添加到这个对象:

我们需要一个变量,它将是我们的容器对象 starContainerGO 的引用。我们还需要一个新的方法CreateStarContainerGO(),该方法将找到 GameObject star-container 的引用,如果该对象已存在,则将其删除,然后该方法将创建一个新的空 GameObject 并给它这个名称。将以下变量和方法添加到我们的脚本类中:

static GameObject starContainerGo;

 static void CreateStarContainerGo() {
     string containerName = "Star-container";
     starContainerGo = GameObject.Find(containerName);
     if (null != starContainerGO)
         DestroyImmediate(starContainerGO);

     starContainerGo = new GameObject(containerName);
 } 

在我们创建预制件克隆之前,我们需要首先确保我们已经创建了我们的星星容器 GameObject。因此,我们需要在执行PlacePrefabs()方法时作为第一件事调用我们的新方法,所以在PlacePrefabs()方法的开始处添加一个调用此方法的语句:

static void PlacePrefabs(){
     CreateStarContainerGo();

     // rest of method as before ...
 } 

现在,我们需要修改CreateRandomInstance()方法,使其获取它刚刚创建的新 GameObject 的引用,然后可以将这个新对象作为子对象添加到我们的 star-container GameObject 变量 starContainerGO。修改CreateRandomInstance()方法,使其看起来如下:

static void CreateRandomInstance() {
     float x = UnityEngine.Random.Range(-X_MAX, X_MAX);
     float y = UnityEngine.Random.Range(-Y_MAX, Y_MAX);
     float z = 0;
     Vector3 randomPosition = new Vector3(x,y,z);

     GameObject newStarGo = (GameObject)Instantiate(starPrefab, 
randomPosition, Quaternion.identity);
     newStarGo.transform.parent = starContainerGO.transform;
 } 

一个进度条来显示编辑器扩展处理的完成比例

如果一个编辑器任务需要超过半秒或更长时间,那么我们应该通过进度条向用户显示进度完成/剩余情况,这样他们就能理解实际上有事情在进行,应用程序并没有崩溃和冻结:

准备工作

这个配方在先前的配方基础上增加了内容,所以请复制那个项目文件夹,并使用该副本来完成这个配方的任务。

如何操作...

要在循环期间添加进度条(然后在循环完成后移除它),将PlacePrefabs()方法替换为以下代码:

static void PlacePrefabs(){ 

   string assetPath = "Assets/Prefabs/prefab_star.prefab"; 

   starPrefab = (GameObject)AssetDatabase.LoadMainAssetAtPath(assetPath); 

   int total = 100; 

      for(int i = 0; i < total; i++){ 

         CreateRandomInstance(); 

         EditorUtility.DisplayProgressBar("Creating your starfield", 
i + "%", i/100f); 

      } 

      EditorUtility.ClearProgressBar(); 

   } 

它是如何工作的...

如所见,在 for 循环内部,我们调用EditorUtility.DisplayProgressBar(...)方法,传递三个参数。第一个是一个进度条对话框窗口的字符串标题,第二个是在进度条本身下方显示的字符串(通常百分比就足够了),最后一个参数是一个介于 0.0 和 1.0 之间的值,表示要显示的完成百分比。

由于我们有一个从 1 到 100 的循环变量 i,我们可以显示这个整数,然后跟一个百分号作为第二个参数,只需将这个数字除以 100,就可以得到指定进度条应该显示完成的百分比所需的十进制值。如果循环运行的是其他数字,我们只需将循环计数器除以循环总数,就可以得到我们的十进制进度值。

最后,在循环完成后,我们使用EditorUtility.ClearProgressBar()语句移除进度条。如果我们没有这一步,进度条窗口面板将漂浮在周围——这会令用户感到烦恼!

一个编辑器扩展,允许在设计时通过自定义检查器 UI 更改拾取类型(和参数)

在检查器面板中使用枚举和相应的下拉菜单来限制更改到有限集中的一个,通常效果很好(例如,拾取对象的拾取类型)。然而,这种方法的麻烦在于,当两个或多个属性相关并需要一起更改时,可能会忘记更改相应的属性;例如,从心形改为钥匙的拾取类型,但忘记更改相应的属性;例如,Sprite Renderer 组件仍然显示心形精灵。这种不匹配既会搞乱预期的关卡设计,当然,当玩家与显示一个拾取图像但拾取类型不同的物品相撞时,也会让玩家感到沮丧!

如果一个 GameObject 类有几个相关的属性或组件,所有这些都需要一起更改,那么一个很好的策略是使用 Unity 编辑器扩展,在从显示定义的枚举选择集的下拉菜单中选择不同的选项时,对所有的相关更改进行操作。

在这个配方中,我们介绍了一个用于 GameObject 的PickUp组件的编辑器扩展:

准备中

此配方假设你从第三章,库存 UI中的第一个配方开始,使用Simple2Dgame_SpaceGirl项目设置。

如何操作...

要创建一个编辑器扩展,允许在设计时间通过自定义检查器 UI 更改拾取类型(和参数),请按照以下步骤操作:

  1. 从 mini 游戏Simple2Dgame_SpaceGirl的新副本开始。

  2. 在项目面板中,创建一个名为 EditorSprites 的新文件夹。将以下图片从 Sprites 文件夹移动到这个新文件夹:star、healthheart、icon_key_green_100、icon_key_green_32、icon_star_32 和 icon_heart_32:

图片

  1. 在层次结构面板中,将 GameObject star 重命名为 pickup。

  2. 编辑标签,将标签 Star 更改为 Pickup。确保拾取 GameObject 现在具有 Pickup 标签。

  3. 创建 C#脚本类PickUp并将实例对象作为组件添加到层次结构中的 GameObject 拾取:

using UnityEngine;
 using System;
 using System.Collections;

 public class PickUp : MonoBehaviour {
   public enum PickUpType {
     Star, Health, Key
   }

   [SerializeField]
   public PickUpType type;

   public void SetSprite(Sprite newSprite){
     SpriteRenderer spriteRenderer = GetComponent<SpriteRenderer>();
     spriteRenderer.sprite = newSprite;
   }
 } 
  1. 在项目面板中,创建一个名为 Editor 的新文件夹。

  2. 在这个新的 Editor 文件夹内,创建一个名为PickUpEditor的新 C#脚本类,代码如下:

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

 [CanEditMultipleObjects]
 [CustomEditor(typeof(PickUp))]
 public class PickUpEditor : Editor
 {
   public Texture iconHealth;
   public Texture iconKey;
   public Texture iconStar;

   public Sprite spriteHealth100;
   public Sprite spriteKey100;
   public Sprite spriteStar100;

   UnityEditor.SerializedProperty pickUpType;

   private Sprite sprite;
   private PickUp pickupObject;

   void OnEnable () {
     iconHealth = AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/icon_heart_32.png", typeof(Texture)) as Texture;
     iconKey = AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/icon_key_32.png", typeof(Texture)) as Texture;
     iconStar = 
AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/
icon_star_32.png", typeof(Texture)) as Texture;

     spriteHealth100 = 
AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/
healthheart.png", typeof(Sprite)) as Sprite;
     spriteKey100 = 
AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/
icon_key_100.png", typeof(Sprite)) as Sprite;
     spriteStar100 = 
AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/
star.png", typeof(Sprite)) as Sprite;

     pickupObject = (PickUp)target;
     pickUpType = serializedObject.FindProperty ("type");
   }

   public override void OnInspectorGUI()
   {
     serializedObject.Update ();

     string[] pickUpCategories = TypesToStringArray();
     pickUpType.enumValueIndex = 
EditorGUILayout.Popup("PickUp TYPE: ", 
pickUpType.enumValueIndex, pickUpCategories);

     PickUp.PickUpType type = 
(PickUp.PickUpType)pickUpType.enumValueIndex;
     switch(type)
     {
     case PickUp.PickUpType.Health:
       InspectorGUI_HEALTH();
       break;

     case PickUp.PickUpType.Key:
       InspectorGUI_KEY();
       break;

     case PickUp.PickUpType.Star:
     default:
       InspectorGUI_STAR();
       break;
     }

     serializedObject.ApplyModifiedProperties ();
   }

   private void InspectorGUI_HEALTH()
   {
     GUILayout.BeginHorizontal();
     GUILayout.FlexibleSpace();
     GUILayout.Label(iconHealth);
     GUILayout.Label("HEALTH");
     GUILayout.Label(iconHealth);
     GUILayout.Label("HEALTH");
     GUILayout.Label(iconHealth);
     GUILayout.FlexibleSpace();
     GUILayout.EndHorizontal();

     pickupObject.SetSprite(spriteHealth100);
   }

   private void InspectorGUI_KEY()
   {
     GUILayout.BeginHorizontal();
     GUILayout.FlexibleSpace();
     GUILayout.Label(iconKey);
     GUILayout.Label("KEY");
     GUILayout.Label(iconKey);
     GUILayout.Label("KEY");
     GUILayout.Label(iconKey);
     GUILayout.FlexibleSpace();
     GUILayout.EndHorizontal();

     pickupObject.SetSprite(spriteKey100);
   }

   private void InspectorGUI_STAR()
   {
     GUILayout.BeginHorizontal();
     GUILayout.FlexibleSpace();
     GUILayout.Label(iconStar);
     GUILayout.Label("STAR");
     GUILayout.Label(iconStar);
     GUILayout.Label("STAR");
     GUILayout.Label(iconStar);
     GUILayout.FlexibleSpace();
     GUILayout.EndHorizontal();

     pickupObject.SetSprite(spriteStar100);
   }
   private string[] TypesToStringArray(){
     var pickupValues = 
(PickUp.PickUpType[])Enum.GetValues(typeof
(PickUp.PickUpType));

     List<string> stringList = new List<string>();

     foreach(PickUp.PickUpType pickupValue in pickupValues){
       string stringName = pickupValue.ToString();
       stringList.Add(stringName);
     }

     return stringList.ToArray();
   }
 } 
  1. 在检查器面板中,选择 GameObject 拾取并选择下拉菜单拾取类型的不同值。你应该会在检查器中的拾取(脚本)组件(三个带有类型名称的图标)的图像和图标中看到相应的变化。这个 GameObject 的 Sprite Renderer 组件的 Sprite 属性应该会改变。此外,在场景面板中,你会看到场景中的图像变为你选择的拾取类型的适当图像。

图片

工作原理...

我们的脚本类PickUp有一个枚举PickUpType,包含三个值:StarHealthKey。还有一个变量类型,用于存储父 GameObject 的类型。最后,有一个SetSprite(...)方法,该方法将父 GameObject 的 Sprite Renderer 组件设置为提供的 Sprite 参数。每次从下拉菜单更改拾取类型时(传递对应新类型的相应 sprite),都会调用此方法。

此配方的绝大多数工作由脚本类PickUpEditor负责。虽然这个脚本中有很多内容,但其工作相对简单:对于每一帧,通过OnInspectorGUI()方法,向用户展示一个包含 PickUpType 值的下拉列表。根据从下拉列表中选择的值,执行三个方法之一:InspectorGUI_HEALTH()InspectorGUI_KEY()InspectorGUI_STAR()。每个方法都在下拉菜单下方显示三个图标和类型的名称,并在最后调用被编辑的 GameObject 的SetSprite(...)方法,以更新父 GameObject 的 Sprite Renderer 组件。

在我们的类声明之前出现的 C#属性[CustomEditor(typeof(PickUp))]告诉 Unity 使用这个特殊的编辑器脚本来在检查器面板中显示 GameObject 的拾取(脚本)组件的属性,而不是 Unity 的默认检查器,该默认检查器显示此类脚本组件的公共变量。

OnInspectorGUI()方法的主要工作之前和之后,该方法首先确保与在检查器中编辑的对象相关的任何变量都已更新 - serializedObject.Update()。该方法中的最后一条语句相应地确保编辑器脚本中变量的任何更改都已复制回正在编辑的 GameObject - serializedObject.ApplyModifiedProperties()

PickUpEditor脚本类的OnEnable()方法加载三个小图标(用于在检查器中显示)和三个较大的精灵图像(用于更新场景/游戏面板中的 Sprite Renderer)。pickupObject变量被设置为对拾取脚本组件的引用,允许我们调用SetSprite(...)方法。pickUpType变量被设置为与拾取脚本组件的类型变量链接,该组件的特殊检查器编辑器视图使此脚本成为可能 - serializedObject.FindProperty ("type")

还有更多...

这里有一些你不想错过的细节。

通过检查器提供拾取参数的自定义编辑

许多拾取项具有附加属性,而不仅仅是携带的物品。例如,一个健康拾取项可能为玩家的角色添加健康点,一个硬币拾取项可能为角色的银行余额添加金钱点,等等。因此,让我们在我们的拾取类中添加一个整数点变量,并允许用户通过我们的自定义检查器编辑器中的 GUI 滑块轻松编辑此点值:

图片

要向我们的PickUp对象添加可编辑的点属性,请按照以下步骤操作:

  1. 将以下额外行添加到 C#脚本PickUp中,以创建我们的新整数点变量:
public int points; 
  1. 将以下额外行添加到 C#脚本PickUpEditor中,以便与我们的新整数点变量一起工作:
UnityEditor.SerializedProperty points; 
  1. 将以下额外行添加到 C#脚本PickUpEditor中的OnEnable()方法中,以将我们的新点变量与其在 GameObject 的PickUp脚本组件中的对应值关联起来:
csharp void OnEnable () {    

points = serializedObject.FindProperty ("points");    

pickUpType = serializedObject.FindProperty ("type");    

// rest of method as before...
  1. 现在,我们可以在每个不同PickUp类型的 GUI 方法中添加一个额外行。例如,我们可以添加一个语句来向用户显示一个IntSlider,以便能够查看和修改健康拾取对象的点值。我们在 C#脚本PickUpEditor中的InspectorGUI_HEALTH()方法末尾添加一个新语句,以显示一个可修改的IntSlider,如下所示:
private void InspectorGUI_HEALTH(){
   // beginning of method just as before...

   pickupObject.SetSprite(spriteHealth100);

 // now display Int Slider for points
   points.intValue = EditorGUILayout.IntSlider 
("Health points", points.intValue, 0, 100);
 } 

我们向 IntSlider(...) 方法提供了四个参数。第一个是用户将在滑块旁边看到的文本标签。第二个是滑块显示的初始值。最后两个是最大值和最小值。在我们的例子中,我们允许从 0 到 100 的值,但如果健康恢复物品只提供一、二或三个健康点,那么我们只需调用 EditorGUILayout.IntSlider ("Health points, points.intValue, 1, 5")。此方法返回一个基于滑块位置的新整数值,并将此新值存储回我们的 SerializedProperty 变量的整数值部分。

注意,从 GameObject 中的脚本组件加载和保存值以及我们编辑器脚本中的所有工作,都是通过在 OnInspectorGUI() 方法中对序列化对象调用 Update() 方法和 ApplyModifiedProperties() 方法来完成的。

注意,由于某些拾取物品(例如钥匙)的点可能没有任何意义,那么我们简单地在用户编辑该类型的 PickUp 对象时不会在 GUI Inspector 编辑器中显示任何滑块。

通过 Inspector 提供适合钥匙拾取的标签下拉列表

虽然对于钥匙拾取物品,点的概念可能没有意义,但给定钥匙可以适配的锁的类型确实是我们可能希望在游戏中实现的概念。由于 Unity 为我们提供了任何 GameObject 的定义(并可编辑)的字符串标签列表,通常通过标签来表示与钥匙对应的锁或门类型是足够简单直接的。例如,绿色钥匙可能适合所有标记为 LockGreen 的对象,依此类推:

图片

因此,能够为可以打开锁的钥匙的字符串属性提供一个自定义的 Inspector 编辑器是非常有用的。这项任务结合了多个动作,包括使用 C# 从 Unity 编辑器检索标签数组,然后构建并提供一个包含这些标签的下拉列表给用户,当前值已在此列表中选中。

要为钥匙可以锁定的标签添加一个可选择的字符串列表,请按照以下步骤操作:

  1. 将以下额外行添加到 C# 脚本 PickUp 中以创建我们新的整数 fitsLockTag 变量:
public string fitsLockTag; 
  1. 将以下额外行添加到 C# 脚本 PickUpEditor 中以处理我们新的整数 fitsLockTag 变量:
UnityEditor.SerializedProperty fitsLockTag; 
  1. 将以下额外行添加到 C# 脚本 PickUpEditor 中的 OnEnable() 方法中,以将我们的新 fitsLockTag 变量与其对应的 GameObject 中 PickUp 脚本组件的值关联起来:
csharp void OnEnable () {      

fitsLockTag = 
serializedObject.FindProperty ("fitsLockTag");     points = serializedObject.FindProperty ("points");      

pickUpType = serializedObject.FindProperty ("type");      

// rest of method as before... 
  1. 现在,我们需要在 GUI 方法中添加一些额外的代码行以处理钥匙拾取。我们需要在 C#脚本PickUpEditor中的方法InspectorGUI_KEY()的末尾添加几个语句,以设置和显示一个可选择的弹出下拉列表,表示我们新的fitsLockTag变量,如下所示。用以下代码替换InspectorGUI_KEY()方法:
private void InspectorGUI_KEY() {
     GUILayout.BeginHorizontal();
     GUILayout.FlexibleSpace();
     GUILayout.Label(iconKey);
     GUILayout.Label("KEY");
     GUILayout.Label(iconKey);
     GUILayout.Label("KEY");
     GUILayout.Label(iconKey);
     GUILayout.FlexibleSpace();
     GUILayout.EndHorizontal();

     pickupObject.SetSprite(spriteKey100);

     string[] tags = 
UnityEditorInternal.InternalEditorUtility.tags;
     Array.Sort(tags);
     int selectedTagIndex = 
Array.BinarySearch(tags, fitsLockTag.stringValue);
     if(selectedTagIndex < 0)
         selectedTagIndex = 0;

     selectedTagIndex = 
EditorGUILayout.Popup("Tag of door key fits: ", 
selectedTagIndex, tags);

     fitsLockTag.stringValue = tags[selectedTagIndex];
 } 

我们已经将几个语句添加到这个方法的末尾。首先创建(并排序)一个字符串数组tags,其中包含当前游戏中在 Unity 编辑器中可用的标签列表。然后我们尝试找到数组中fitsLockTag当前值的所在位置——由于我们已经按字母顺序排序了数组(这也使得用户导航更容易),我们可以使用内置脚本类ArrayBinarySearch(...)方法。如果fitsLockTag中的字符串在数组标签中找不到,则默认选择第一个项目(索引 0)。

然后用户通过GUILayout方法的EditorGUILayout.Popup(...)显示下拉列表,此方法返回所选项目的索引。所选索引存储到selectedTagIndex中,方法中的最后一个语句提取相应的字符串,并将该字符串存储到fitsLockTag变量中。

注意:为了不显示所有可能的标签,进一步的改进可能需要从数组标签中移除所有没有前缀 Lock 的项。因此,用户只会看到如 LockBlue 和 LockGreen 等标签,等等。

基于 fitsLockTag 打开门的逻辑

在我们的玩家碰撞逻辑中,我们现在可以搜索我们的库存,看看是否有任何关键物品适合我们与之碰撞的锁。例如,如果与一扇绿色门发生碰撞,并且玩家携带一把可以打开这种门的钥匙,那么该物品应该从库存List<>中移除,并且门应该被打开。

为了实现这一点,你需要在OnTriggerEnter()方法中添加一个 if 测试,以检测与标记为Door的物品的碰撞,然后尝试打开门,如果失败,则执行适当的操作(例如,播放声音)以通知玩家他们目前还不能打开门(我们假设我们已经编写了一个门动画控制器,当门要打开时,它会播放适当的动画和声音):

if("Door" == hitCollider.tag){
     if(!OpenDoor(hitCollider.gameObject))
         DoorNotOpenedAction();
 } 

OpenDoor()方法需要识别库存中哪些物品(如果有)可以打开这样的门,如果找到,则应该从List<>中移除该物品,并且应该通过适当的方法打开门:

private bool OpenDoor(GameObject doorGO){
     // search for key to open the tag of doorGO
     int colorKeyIndex = FindItemIndex(doorGO.tag);
     if( colorKeyIndex > -1 ){
         // remove key item from inventory List<>
         inventory.RemoveAt( colorKeyIndex );

         // now open the door...
         DoorAnimationController doorAnimationController = 
doorGO.GetComponent<>(DoorAnimationController);
         doorAnimationController.OpenDoor();

         return true;
     }

     return false;
 } 

以下是一个方法,用于找到与门标签匹配的库存列表中的关键物品的代码:

private int FindItemIndex(string doorTag){
     for (int i = 0; i < inventory.Count; i++){
         PickUp item = inventory[i];
         if( (PickUp.PickUpType.Key == item.type) && 
 (item.fitsLockTag == doorTag))
             return i;
     }

     // not found
     return -1;
 } 

需要为私有属性添加[Serializable]标记

注意,如果我们希望创建用于处理私有变量的编辑器扩展,那么我们需要在变量被编辑器脚本更改的行之前显式添加 [SerializeField]。在 Unity 中,公共变量默认是序列化的,因此对于脚本类 PickUp 中的公共类型变量,这并不是必需的,尽管将所有通过编辑器扩展可更改的变量以这种方式标记为良好实践。

从 Unity 编辑器脚本文档页面了解更多信息:docs.unity3d.com/ScriptReference/Editor.html

一个编辑器扩展,具有对象创建器 GameObject,带有按钮,可以在场景中十字准线对象位置实例化不同的拾取物品

如果关卡设计师希望手动“逐个”放置拾取物品,我们仍然可以使这个过程比从项目面板手动拖动预制体副本更容易。在这个配方中,我们提供了一个“十字准线”GameObject,在检查器中有按钮,允许游戏设计师通过在十字准线的中心位于所需位置时点击相应的按钮,在精确位置创建三种不同类型的预制体的实例。

Unity 编辑器扩展是这个配方的核心,展示了此类扩展如何允许游戏开发团队中不太技术的人员在 Unity 编辑器中积极参与关卡创建。

准备工作

本配方假设您是从 第三章,库存 UI 中的第一个配方开始,使用 Simple2Dgame_SpaceGirl 项目设置。

对于这个配方,我们在 18_09 文件夹下的 Sprites 文件夹中准备了所需的十字准线图像。

如何操作...

要创建一个对象创建器 GameObject,请按照以下步骤操作:

  1. 从新的 Simple2Dgame_SpaceGirl 游戏开始。

  2. 在项目面板中,将 GameObject star 重命名为 pickup。

  3. 在项目面板中,创建一个名为 Prefabs 的新文件夹。在这个新文件夹内,创建三个新的空预制体,分别命名为 star、heart 和 key。

  4. 通过将 Hierarchy 面板中的 GameObject pickup 拖动到 Project 面板中的星形上,来填充星形预制体。现在,预制体应该变成蓝色,并具有星形 GameObject 的所有属性和组件的副本。

  5. 在检查器中添加一个新的标签 Heart。在 Hierarchy 面板中选择 GameObject pickup 并将其分配给 Heart 标签。此外,从项目面板(Sprites 文件夹)中将 healthheart 图像拖动到 GameObject pickup 的 Sprite 属性中,以便玩家在屏幕上看到这个拾取物品的心形图像。

  6. 通过将 Hierarchy 面板中的 GameObject pickup 拖动到 Project 面板中 Prefabs 文件夹中的心形上,来填充心形预制体。现在,预制体应该变成蓝色,并具有拾取 GameObject 的所有属性和组件的副本。

  7. 在检查器中添加一个新的标签 Key。在层次结构面板中选择 GameObject 的拾取,并分配这个标签 Key。此外,从项目面板(文件夹 Sprites)拖动图像图标 _key_green_100 到 GameObject 拾取的 Sprite 属性中,以便玩家在屏幕上看到这个拾取物品的钥匙图像。

  8. 通过将 GameObject 拾取从层次结构面板拖动到项目面板中的 Prefabs 文件夹中的 key 上,来填充关键预制件。现在,预制件应该变成蓝色,并具有拾取 GameObject 的所有属性和组件的副本。

  9. 从层次结构中删除 GameObject 拾取。

  10. 在项目面板中,创建一个名为 Editor 的新文件夹。在这个新文件夹内,创建一个名为ObjectBuilderEditor的新 C#脚本类,代码如下:

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(ObjectBuilderScript))]
public class ObjectBuilderEditor : Editor{
   private Texture iconStar;
   private Texture iconHeart;
   private Texture iconKey;

   private GameObject prefabHeart;
   private GameObject prefabStar;
   private GameObject prefabKey;

   void OnEnable () {
      iconStar = AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/icon_star_32.png", typeof(Texture)) as Texture;
      iconHeart = AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/icon_heart_32.png", typeof(Texture)) as Texture;
      iconKey = AssetDatabase.LoadAssetAtPath("Assets/EditorSprites/icon_key_green_32.png", typeof(Texture)) as Texture;

      prefabStar = AssetDatabase.LoadAssetAtPath("Assets/Prefabs/star.prefab", typeof(GameObject)) as GameObject;
      prefabHeart = AssetDatabase.LoadAssetAtPath("Assets/Prefabs/heart.prefab", typeof(GameObject)) as GameObject;
      prefabKey = AssetDatabase.LoadAssetAtPath("Assets/Prefabs/key.prefab", typeof(GameObject)) as GameObject;
   }

   public override void OnInspectorGUI() {
      GUILayout.Label("");
      GUILayout.BeginHorizontal();
      GUILayout.FlexibleSpace();
      GUILayout.Label("Click button to create instance of prefab");
      GUILayout.FlexibleSpace();
      GUILayout.EndHorizontal();
      GUILayout.Label("");

      GUILayout.BeginHorizontal();
      GUILayout.FlexibleSpace();
      if(GUILayout.Button(iconStar)) AddObjectToScene(prefabStar);
      GUILayout.FlexibleSpace();
      if(GUILayout.Button(iconHeart)) AddObjectToScene(prefabHeart);
      GUILayout.FlexibleSpace();
      if(GUILayout.Button(iconKey)) AddObjectToScene(prefabKey);
      GUILayout.FlexibleSpace();
      GUILayout.EndHorizontal();
   }

   private void AddObjectToScene(GameObject prefabToCreateInScene) {
      ObjectBuilderScript myScript = (ObjectBuilderScript)target;
      GameObject newGo = Instantiate(prefabToCreateInScene, myScript.gameObject.transform.position, Quaternion.identity);
      newGo.name = prefabToCreateInScene.name;
   }
}
  1. 我们的编辑器脚本期望在名为 EditorSprites 的文件夹中找到三个图标,所以让我们这样做。首先,创建一个名为 EditorSprites 的新文件夹。接下来,将三个 32 x 32 像素的图标从 Sprites 文件夹拖动到这个新的 EditorSprites 文件夹中。现在,我们的编辑器脚本应该能够加载这些图标,用于在检查器中绘制的基于图像的按钮,用户可以通过它选择要克隆到场景中的拾取预制对象。

  1. 从项目面板中,将sprite cross_hairs.fw拖动到场景中。将这个 GameObject 对象-creator-cross-hairs 重命名为 object-creator-cross-hairs,并在检查器中的 Sprite Renderer 组件中,将排序层设置为前景。

  2. 将以下 C#脚本附加到 GameObject 对象-creator-cross-hairs:

    using UnityEngine;

     public class ObjectBuilderScript : MonoBehaviour {
       void Awake(){
         gameObject.SetActive(false);
       }
     } 
  1. 选择矩形工具(快捷键 T),当你拖动 gameObject 对象-creator-cross-hairs 并在检查器中点击所需的图标时,新的拾取 GameObject 将被添加到场景的层次结构中。

它是如何工作的...

脚本类ObjectBuilderScript有两个方法,其中一个只有一个语句 - Awake()方法简单地使这个 GameObject 在游戏运行时变为非活动状态(因为我们不希望用户在游戏过程中看到我们创建的十字准星工具)。AddObjectToScene(...)方法接收一个预制件的引用作为参数,并在该点在场景中实例化一个新的预制件副本,位置与 GameObject 对象-creator-cross-hairs 相同。

脚本类ObjectBuilderEditor在类声明之前有一个 C#属性[CustomEditor(typeof(ObjectBuilderScript))],告诉 Unity 使用这个类来控制ObjectBuilderScript GameObject 属性和组件在检查器中如何显示给用户。

有六个变量,三个用于在检查器中形成按钮的图标纹理,以及三个 GameObject 引用,这些引用将创建预制件的实例。OnEnable()方法使用内置方法AssetDatabase.LoadAssetAtPath()将这些六个变量赋值,从项目文件夹 EditorSprites 中检索图标,并获取项目文件夹 Prefabs 中预制件的引用。

OnInspectorGUI() 方法有一个名为 myScript 的变量,该变量被设置为对 GameObject 对象创建器十字准线中脚本组件 ObjectBuilderScript 实例的引用(因此我们可以在选择预制件时调用其方法)。该方法随后显示一系列空文本 Labels(以获取一些垂直间距)和 FlexibleSpace(以获取一些水*间距和居中对齐),并向用户显示三个按钮,按钮图标分别为星星、心形和钥匙。Unity 自定义 Inspector GUI 的脚本 GUI 技术将每个按钮周围包裹一个 if 语句,当用户点击按钮时,该 if 语句的语句块将被执行。当点击三个按钮中的任何一个时,都会调用脚本组件 ObjectBuilderScriptAddObjectToScene(...) 方法,传递与被点击按钮对应的预制件。

用于管理复杂 IMGUI 的可扩展基于类的代码架构

对于复杂对象和编辑器交互,您可能会发现 GUI 语句的数量很高,并且代码可能会因为非常长的 OnGUI() 方法而快速变得难以管理。组织复杂 GUI 的一种方法涉及一个项目列表,其中每个项目都是一个 GUI 控制对象包装类的对象实例。每个包装类将实现它自己的 OnGUI() 方法。

在这个菜谱中,我们将使用这种方法来创建一个复杂且代码组织良好的 GUI。这个菜谱是从 2013 年在 answers.unity.com 上发布的关于不同 IMGUI 库的问题的示例中改编的:answers.unity.com/questions/601131/editorgui-editorguilayout-gui-guilayout-pshhh-when.html

我们将创建 IMGUI 静态标签、交互式文本框和按钮,并使用 BeginHorizontal()EndHorizontal() 来展示一些灵活的空间和居中对齐,其中繁琐的 GUILayout 语句被放入它们自己的类中。

图片

如何实现...

要创建一个可扩展的基于类的代码架构来管理复杂的 IMGUI,请按照以下步骤操作:

  1. 首先,让我们创建一个 接口,即一个模板脚本类,它定义了所有实现类必须拥有的方法。创建一个名为 Editor 的文件夹。在该文件夹内创建一个名为 MyGUI 的文件夹。在该文件夹内创建一个新的 C# 脚本类,命名为 IMyGUI,包含以下内容:
public interface IMyGUI
 {
     void OnGUI();
 } 
  1. 现在,让我们为我们的 GUI 库定义一个 FlexibleSpace 类。创建一个名为 MyGUIFlexibleSpace 的 C# 脚本类,包含以下内容:
using UnityEngine;

 public class MyGUIFlexibleSpace : IMyGUI
 {
     public void OnGUI()
     {
         GUILayout.FlexibleSpace();
     }
 }
  1. 现在,我们将创建一个按钮类。创建一个名为 MyGUIButton 的 C# 脚本类,包含以下内容:
using UnityEngine;

 public class MyGUIButton : IMyGUI
 {
     public GUIContent label = new GUIContent();
     public event System.Action OnClick;

     public void OnGUI() {
         // if button clicked, invoke methods registed with 'OnClick' event
         if (GUILayout.Button (label) && OnClick != null)
             OnClick ();
     }
 } 
  1. 现在,我们将创建一个输入文本字段类。创建一个名为 MyGUITextField 的 C# 脚本类,包含以下内容:
using UnityEngine;
 using UnityEditor;

 public class MyGUITextField : IMyGUI
 {
     public string text = "";
     public GUIContent label = new GUIContent();

     public void OnGUI() {
         text = EditorGUILayout.TextField (label, text);
     }
 } 
  1. 现在,我们将创建一个非交互式文本标签类。创建一个名为 MyGUILabel 的 C# 脚本类,包含以下内容:
using UnityEngine;

 public class MyGUILabel : IMyGUI {
     private string text;
     private bool centerFully;

     public MyGUILabel(string text, bool centerFully = false) {
         this.text = text;
         this.centerFully = centerFully;
     }

     public void OnGUI() {
         if (centerFully) {
             GUILayout.BeginVertical();
             GUILayout.FlexibleSpace();
             GUILayout.BeginHorizontal();
             GUILayout.FlexibleSpace();
         }

         GUILayout.Label(text);
         if (centerFully) {
             GUILayout.FlexibleSpace();
             GUILayout.EndHorizontal();
             GUILayout.FlexibleSpace();
             GUILayout.EndVertical();
         }
     }
 } 
  1. 在编辑器文件夹中,我们现在将创建一个类来显示一个交互式自定义面板,利用我们上面的 MyGUI 类。创建包含以下内容的 C# script-class MyEditorWindow 以开始:
using UnityEngine;
 using UnityEditor;
 using System.Collections.Generic;

 // adapted from answers.unity.com sample code posted by 'Statememt' (Dec 2013)
 // https://answers.unity.com/questions/601131/editorgui-editorguilayout-gui-guilayout-pshhh-when.html
 public class MyEditorWindow : EditorWindow
  {
      MyGUITextField username;
      MyGUITextField realname;
      MyGUIButton registerButton;
      MyGUIFlexibleSpace flexibleSpace;

      // Optional, but may be convenient.
      List<IMyGUI> gui = new List<IMyGUI>();

      [MenuItem("Example/Show Window")]
      public static void ShowWindow () {
          GetWindow<MyEditorWindow>("My Reg Panel", true);
      }
 }
  1. 我们现在将添加一个方法来显示一个菜单项以打开我们的窗口面板。将以下内容添加到 C# script-class MyEditorWindow 中:
[MenuItem("Example/Show Window")]
 public static void ShowWindow () {
  GetWindow<MyEditorWindow>("My Reg Panel", true);
 } 
  1. 我们现在将添加一个方法来设置我们的 MyGUI 对象并将它们添加到我们的 GUI 对象列表中。将以下内容添加到 C# 脚本类 MyEditorWindow 中:
    void OnEnable()
     {
         username = new MyGUITextField ();
         username.label.text = "Username";
         username.text = "JDoe";

         realname = new MyGUITextField ();
         realname.label.text = "Real name";
         realname.text = "John Doe";

         registerButton = new MyGUIButton ();
         registerButton.label.text = "Register";
          // add RegisterUser() to button's OnClick event broadcaster
         registerButton.OnClick += LogUser;

         bool centerFully = true;
         gui.Add(new MyGUILabel("Unity 2018 is great", centerFully));

         gui.Add (username);
         gui.Add (realname);
         gui.Add(new MyGUIFlexibleSpace());
         gui.Add (registerButton);
     } 
  1. 我们现在将添加一个方法来遍历并显示我们所有的 GUI 对象,每帧。将以下内容添加到 C# 脚本类 MyEditorWindow 中:
    void OnGUI() {
      foreach (var item in gui)
          item.OnGUI();
     }
  1. 最后,我们需要添加一个方法来响应用户点击(LogUser)。还需要一个方法来确保当窗口被禁用时重新注册此方法(以避免内存泄漏)。将这两个方法添加到 C# 脚本类 MyEditorWindow 中:
    private void OnDisable()
     {
      registerButton.OnClick -= LogUser;
     }

     void LogUser()
     {
      var msg = "Registering " + realname.text + " as " + username.text;
      Debug.Log (msg);
     } 
  1. 几秒钟后,你现在应该看到一个名为 Example 的菜单出现,其中有一个 Show Window 项。

  2. 你现在应该能够通过选择此菜单项来显示我们的自定义注册面板。

它是如何工作的...

由于有多个 C# 脚本类,以下将分别描述每个。

脚本类 MyEditorWindow

在 C# 脚本类 MyEditorWindow 中,你使用属性在名为 Example 的菜单中添加了一个名为 Show Window 的菜单项,在 ShowWindow() 方法之前。GetWindow() 语句获取一个 MyEditorWindow 对象的引用 - 如果不存在这样的窗口面板,它将创建一个。第一个参数是面板的标题 My Reg Panel。第二个参数的 true 告诉 Unity 使窗口面板获得焦点(如果已经存在窗口面板)。

OnEnable() 方法在窗口面板首次启用(激活)时执行。它创建了两个 MyGUITextField 对象用于用户名和真实姓名,以及一个注册 MyGUIButton。这些对象都基于文件夹 MyGUI 中的 MyGUI 组件脚本类。然后,按照我们希望的顺序,将这些 GUI 对象添加到列表变量 guiComponents 中。列表中第一个添加的 GUI 组件是一个新的非交互式 MyGUILabel 对象实例(传递文本字符串 Unity 2018 是伟大的,并使用 true 进行完全居中)。然后,我们添加两个文本输入组件(用户名和真实姓名),接着是一个新的 MyGUIFlexible 空间对象实例,最后是一个带有标签 Register 的 MyGUIButton,其 OnClick 事件将触发 LogUser() 方法的调用。

LogUser() 方法将两个文本字段中的名称记录到 Debug.Log。

OnDisable() 方法确保当窗口面板被禁用/关闭时,我们从按钮对象的 OnClick 事件中注销 LogUser() 方法。

OnGUI() 方法,每帧执行一次,简单地遍历列表 guiComponents 中的每个 GUI 组件,调用组件的 OnGUI() 方法。因此,每帧,我们的 GUI 都会重新显示。

脚本类 IMyGUI

这个 C#脚本类声明了一个名为IMyGUI的接口。接口是一个模板脚本类,它定义了所有实现类必须实现的方法。我们的接口类非常简单,它只要求所有实现类必须定义一个OnGUI()方法。有一个命名约定建议所有接口类在首字母大写的类名前都有一个大写的 I。

通过声明这个接口,我们现在可以实现许多不同的IMyGUI类,它们都可以以相同的方式处理——也就是说,它们可以在每一帧调用它们的OnGUI()方法。

脚本类 MyGUIFlexibleSpace

这个简单的脚本类在其OnGUI()方法被调用时,向 IMGUI 系统添加一个GUILayout.FlexibleSpace()

脚本类 MyGUITextField

这个类声明了两个公共项:一个公共字符串(用于用户可以看到和编辑的文本),和一个公共标签。它的OnGUI()方法显示标记的文本字段,并将它的值存储回变量 text。

因此,我们的自定义编辑类可以设置初始文本值和标签,并且可以从这个类的对象实例中读取任何新的文本值。

脚本类 MyGUILabel

这个类有两个私有值:要显示的文本和一个布尔值,定义是否完全居中文本。它的OnGUI()方法将添加一个GUILayout.Label()到 GUI 中,如果布尔值为真,它将在标签前后添加其他 GUILayout 组件,以确保标签在显示时垂直和水*居中。

这是一些接口复杂性的示例,如何将其委托给其自己的类,就像这样。通过设置一个布尔值设置为 true,添加了几个 Begin/End/Vertical/Horizontal 语句和 FlexibleSpace 语句到输出的 GUI 中。

截图说明了灵活空间和垂直/水*分组如何导致用户在窗口面板中看到所需的对齐和间距:

图片

脚本类 MyGUIButton

这个类声明了一个公共标签,以及一个公共OnClick事件。在其OnGUI()方法的执行过程中,如果按钮被点击,任何已注册监听OnClick事件的函数将被调用。

OnGUI()方法中有一个条件,以确保如果没有注册方法来监听OnClick事件,则不执行任何语句。

我们可以在MyEditorWindow的窗口面板对象中看到LogUser方法被注册为registerButton对象的OnClick事件,在其OnEnable()方法中:

registerButton = new MyGUIButton ();
 registerButton.label.text = "Register";
 // add RegisterUser() to button's OnClick event broadcaster
 registerButton.OnClick += LogUser; 

虽然对于这个简单的窗口面板来说可能有些过度,但这个配方说明了使用接口和 GUI 组件对象列表的使用,如何创建一个可扩展的自定义 GUI 组件类系统,同时保持EditorWindow类的复杂性。

注意:C#事件的替代方案可以是使用 Unity 事件,在某些情况下使用 lambda 表达式。您可以在以下在线文章中阅读关于这些主题的讨论:

  • http://www.blockypixel.com/2012/09/c-in-unity3d-dynamic-methods-with-lambda-expressions/

  • https://forum.unity.com/threads/how-to-use-an-action.339952/

第十六章:与外部资源文件和设备一起工作

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

  • 加载外部资源文件——使用 Unity 默认资源

  • 加载外部资源文件——从互联网下载文件

  • 加载外部资源文件——手动将文件存储在 Unity 的“资源”或StreamingAssets文件夹中

  • 将项目文件保存到 Unity Asset Bundles 中

  • 从 Unity Asset Bundles 加载资源

简介

对于某些项目,使用检查器窗口手动将导入的资产分配到组件槽位,然后构建并播放游戏,无需进一步更改,效果很好。然而,也有很多时候,某些类型的外部数据可以为游戏增加灵活性和功能。例如,它可能添加可更新或用户可编辑的内容;它可以使用户偏好和成就之间的场景,甚至游戏会话的记忆。在运行时使用代码读取本地或互联网文件内容可以帮助文件组织和游戏程序员与内容设计师之间的任务分离。拥有不同类型的资产和长期游戏记忆技术意味着为玩家和开发者提供广泛的机会,以提供丰富的体验。

整体情况

在继续介绍食谱之前,让我们退后一步,快速回顾一下资产文件在 Unity 游戏构建和运行过程中的作用。与资产最直接的工作方式是将它们导入到 Unity 项目中,使用检查器窗口将资产分配给检查器中的组件,然后构建并播放游戏:

独立的可执行文件提供另一种可能的流程,即在游戏构建后将其文件添加到游戏的Resources文件夹中。这将支持游戏媒体资产开发者能够在开发和构建完成后提供资产的最终版本。然而,另一个选项是使用WWW类在运行时动态地从网络读取资产,或者,也许是为了与高分或多人游戏服务器通信,发送和接收信息和文件。

当在本地或通过网络界面加载/保存数据时,重要的是要记住可以使用的数据类型。当编写 C#代码时,我们的变量可以是语言允许的任何类型,但当通过网络界面通信或使用 Unity 的PlayerPrefs类与本地存储通信时,我们在可以处理的数据类型上受到限制。Unity 的WWW类允许三种文件类型(文本文件、二进制音频剪辑和二进制图像纹理),但例如,对于 2D UI,我们有时需要精灵图像而不是纹理,因此我们在本章提供了一个 C#方法,可以从纹理创建精灵

当使用PlayerPrefs类时,我们限于保存和加载整数、浮点数和字符串:

图片

WWW 是一个小类,使用起来非常简单。*年来,Unity 在其网络库中引入了Unity Web Request 系统,用于创建和处理 HTTP 消息(请求和响应)。虽然WWW类足以处理本章中的资源保存和加载菜谱,但如果你可能需要以更复杂的方式处理 HTTP 消息,那么建议学习 Unity Web Requests,例如以下链接:

加载外部资源文件 - 使用 Unity 默认资源

在这个菜谱中,我们将加载一个外部图像文件,并使用 Unity 默认资源文件(在游戏编译时创建的库)将其显示在屏幕上。

这种方法可能是存储和读取外部资源文件的最简单方式。然而,只有在资源文件的内容在编译后不会改变的情况下才适用,因为这些文件的内容被合并并编译到resources.assets文件中。

编译后的游戏在Data文件夹中可以找到resources.assets文件:

图片

准备工作

10_01文件夹中,我们为这个菜谱提供了一个图像文件、一个文本文件以及一个.ogg格式的音频文件:

  • externalTexture.jpg

  • cities.txt

  • soundtrack.ogg

如何操作...

要从 Unity 默认资源加载外部资源,请执行以下操作:

  1. 创建一个新的 3D Unity 项目。

  2. 项目窗口中,创建一个新的文件夹并将其重命名为Resources

  3. externalTexture.jpg文件导入并放置在Resources文件夹中。

  4. 创建一个 3D 立方体,并将其命名为Cube-1

  5. 创建一个 C# ReadDefaultResources脚本类,并将其作为组件添加到Cube-1

using UnityEngine;

 public class ReadDefaultResources : MonoBehaviour {
   public string fileName = "externalTexture";
   private Texture2D externalImage;

   void Start () {
     externalImage = (Texture2D)Resources.Load(fileName);
     Renderer myRenderer = GetComponent<Renderer>();
     myRenderer.material.mainTexture = externalImage;
 }
 }
  1. 播放场景。纹理将被加载并在屏幕上显示。

  2. 如果你还有其他图像文件,将其副本放入Resources文件夹。然后,在检查器窗口中,将公共文件名更改为你的图像文件名,再次播放场景。现在将显示新的图像。

它是如何工作的...

Resources.Load (fileName) 语句使 Unity 在其编译的项目数据文件 resources.assets 中查找名为 externalTexture 的文件的内容。内容作为纹理图像返回,并存储在 externalImage 变量中。Start() 方法中的最后一个语句将脚本附加到的 GameObject 的纹理设置为我们的外部 externalImage 变量。

字符串变量 fileName 是公共属性,因此您可以在层次结构中选择 GameObject Cube-1,并在检查器中编辑 Read Default Resources (Script) 组件中的文件名字符串。

传递给 Resources.Load() 的文件名字符串不包括文件扩展名(例如 .jpg.txt)。

更多内容...

这里有一些您不想错过的细节。

使用此方法加载文本文件

您可以使用相同的方法加载外部文本文件。私有变量需要是字符串(用于存储文本文件内容)。Start() 方法使用一个临时的 TextAsset 对象来接收文本文件内容,该对象的文本属性包含要存储在私有 textFileContents 变量中的字符串内容:

using UnityEngine;

 public class ReadDefaultResourcesText : MonoBehaviour {
   public string fileName = "textFileName"; // e.g.: cities.txt
   private string textFileContents;

   void Start () {
     TextAsset textAsset = (TextAsset)Resources.Load(fileName);
     textFileContents = textAsset.text;
     Debug.Log(textFileContents);
   }
 } 

最后,此字符串将在控制台上显示:

图片

使用此方法加载和播放音频文件

您可以使用相同的方法加载外部音频文件。私有变量需要是 AudioClip

using UnityEngine;

[RequireComponent (typeof (AudioSource))]
public class ReadDefaultResourcesAudio : MonoBehaviour {
   public string fileName = "soundtrack";

   void Start () {
     AudioSource audioSource = GetComponent<AudioSource>();
     audioSource.clip = (AudioClip)Resources.Load(fileName);
     if(!audioSource.isPlaying && audioSource.clip.loadState == AudioDataLoadState.Loaded)
           audioSource.Play();
 }
 }

我们不会尝试播放 AudioClip 直到加载完成,这可以通过音频剪辑的 loadState 属性来测试。在 Unity 脚本参考页面中了解更多关于 Audio Load State 的信息:docs.unity3d.com/ScriptReference/AudioDataLoadState.html

参见

参考本章中的以下菜谱以获取更多信息:

  • 通过手动将文件存储在 Unity Resources 文件夹中来加载外部资源文件

  • 通过从互联网下载文件来加载外部资源文件

通过从互联网下载文件来加载外部资源文件

存储和读取文本文件数据的一种方法是将文本文件存储在网络上。在这个菜谱中,下载、读取给定 URL 的文本文件内容,然后显示。

准备工作

对于这个菜谱,您需要能够访问网络服务器上的文件。如果您运行本地网络服务器,例如 Apache,或者有自己的网络托管,那么您可以使用 10_02 文件夹中的文件和相应的 URL。

否则,您可能会发现以下 URL 有用,因为它们是图像文件(Packt 出版物的标志)和文本文件(ASCII 艺术獾图片)的网络位置:

  • packt logo

  • ASCII 艺术獾

如何操作...

通过从互联网下载来加载外部资源,请按照以下步骤操作:

  1. 在 3D 项目中,创建一个新的 RawImage UI 游戏对象。

  2. 创建一个 C# ReadImageFromWeb 脚本类,并将其实例对象作为组件添加到 RawImage 游戏对象中:

using UnityEngine;
 using UnityEngine.UI;
 using System.Collections;

 public class ReadImageFromWeb : MonoBehaviour {
     public string url = "http://www.packtpub.com/sites/default/files/packt_logo.png";

     IEnumerator Start() {
         WWW www = new WWW(url);
         yield return www;

         Texture2D texture = www.texture;
         GetComponent<RawImage>().texture = texture;
     }
 } 
  1. 播放场景。一旦下载完成,图像文件的内容将被显示:

图片

它是如何工作的...

注意需要使用UnityEngine.UI包来实现此食谱。

当游戏开始时,我们的Start()方法启动名为LoadWWW()的协程方法。协程是一种可以在后台持续运行而不会停止或减慢游戏其他部分和帧率的函数。yield语句表示一旦imageFile可以返回值,方法的其他部分就可以执行——也就是说,直到文件下载完成,不应尝试提取WWW对象变量的纹理属性。

一旦图像数据被加载,执行将超过yield语句。最后,将脚本附加到的 RawImage 游戏对象的纹理属性更改为从网络下载的图像数据(在 WWW 对象的纹理变量中)。

还有更多...

有些细节是你不想错过的。

从纹理转换为精灵

在食谱中,我们使用了 UI RawImage,因此我们可以直接使用下载的纹理;然而,有时我们可能希望使用精灵而不是纹理。要从一个纹理创建精灵对象,请创建以下脚本类:

using UnityEngine;
 using UnityEngine.UI;
 using System.Collections;

 public class ImageFromWebTextureToSprite : MonoBehaviour {
     public string url = "http://www.packtpub.com/sites/default/files/packt_logo.png";

     IEnumerator Start() {
         WWW www = new WWW(url);
         yield return www;

         Texture2D texture = www.texture;
         GetComponent<Image>().sprite = TextureToSprite(texture);
     }

     private Sprite TextureToSprite(Texture2D texture) {
         Rect rect = new Rect(0, 0, texture.width, texture.height);
         Vector2 pivot = new Vector2(0.5f, 0.5f);
         Sprite sprite = Sprite.Create(texture, rect, pivot);
         return sprite;
     }
 } 

从网络下载文本文件

使用此技术下载文本文件(将脚本类的实例对象附加到 UI Text 对象):

using UnityEngine;
 using System.Collections;
 using UnityEngine.UI;

 public class ReadTextFromWeb : MonoBehaviour {
     public string url = "http://www.ascii-art.de/ascii/ab/badger.txt";

     IEnumerator Start() {
         Text textUI = GetComponent<Text>();
         textUI.text = "(loading file ...)";
         WWW www = new WWW(url);
         yield return www;

         string textFileContents = www.text;
         Debug.Log(textFileContents);
         textUI.text = textFileContents;
     }
 } 

WWW 类和资源内容

WWW 类定义了多个不同的属性和方法,以便将下载的媒体资源文件数据提取到适当的变量中,以便在游戏中使用。其中最有用的包括以下内容:

  • .text: 一个只读属性,返回作为字符串的网页数据

  • .texture: 一个只读属性,返回作为Texture2D图像的网页数据

  • .GetAudioClip(): 返回作为AudioClip的网页数据的方法

更多信息请访问docs.unity3d.com/ScriptReference/WWW.html

使用 UnityWebRequest 的一个示例

而不是使用WWW类,我们还可以使用UnityWebRequest库来下载纹理。只需将ReadImageFromWeb脚本类的以下内容替换掉:

using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using UnityEngine.Networking;
public class ReadImageFromWebUnityWebRequest : MonoBehaviour {
 public string url = "http://www.packtpub.com/sites/default/files/packt_logo.png";
IEnumerator Start() {
 using (UnityWebRequest uwr = UnityWebRequestTexture.GetTexture(url)) {
 yield return uwr.SendWebRequest();
if (uwr.isNetworkError || uwr.isHttpError)
 Debug.Log(uwr.error);
 else {
 Texture2D texture = DownloadHandlerTexture.GetContent(uwr);
 UpdateUIRawImage(texture);
 }
 }
 }

 private void UpdateUIRawImage(Texture2D texture) {
 GetComponent<RawImage>().texture = texture; 
 }
}

参见

参考本章中的以下食谱以获取更多信息:

  • 通过Unity 默认资源加载外部资源文件

  • 通过手动将文件存储在 Unity 的Resources文件夹中加载外部资源文件

通过手动将文件存储在 Unity 的ResourcesStreamingAssets文件夹中加载外部资源文件

有时,在游戏编译后,可能需要更改外部资源文件的内容。在网络上托管资源文件可能不是一个选项。有一种手动存储和从编译后的游戏Resources文件夹中读取文件的方法,这使得这些文件在游戏编译后可以更改。

Resources文件夹技术在编译为 Windows 或 Mac 独立可执行文件时有效。StreamingAssets文件夹技术也适用于这些,以及 iOS 和 Android 设备。下一个菜谱将说明Resources文件夹技术,然后我们在最后讨论如何使用StreamingAssets方法。

准备工作

10_01文件夹提供了可用于此菜谱的纹理图像:

  • externalTexture.jpg

如何操作...

要通过手动将文件存储在资源文件夹中来加载外部资源,请执行以下操作:

  1. 创建一个新的 3D 项目。

  2. 创建一个新的 UI Image GameObject。使其占据屏幕的大部分区域。

  3. 创建一个新的 UI Text GameObject。将其定位在屏幕底部,以拉伸整个屏幕宽度。

  4. 创建一个 C# ReadManualResourceImageFile 脚本类,并将实例对象作为组件添加到 UI Image GameObject:

using System.Collections;
 using UnityEngine;
 using UnityEngine.UI;
 using System.IO;

 public class ResourceFileLoader : MonoBehaviour
 {
     public Text textUrl;

     private string fileName = "externalTexture.jpg";
     private string urlPrefixMac = "file://";
     private string urlPrefixWindows = "file:///";

     IEnumerator Start()
     {
         //        string url = urlPrefixWindows + Application.dataPath;
         string url = urlPrefixMac + Application.dataPath;
         url = Path.Combine(url, "Resources");
         url = Path.Combine(url, fileName);

         textUrl.text = url;

         WWW www = new WWW(url);
         yield return www;

         Texture2D texture = www.texture;
         GetComponent<Image>().sprite = TextureToSprite(texture);
     }

     private Sprite TextureToSprite(Texture2D texture)
     {
         Rect rect = new Rect(0, 0, texture.width, texture.height);
         Vector2 pivot = new Vector2(0.5f, 0.5f);
         Sprite sprite = Sprite.Create(texture, rect, pivot);
         return sprite;
     }
 } 
  1. 在层次结构中选择 UI Image GameObject 后,将 UI Text 对象拖动到检查器中的 ResourceFileLoader(脚本)组件的公共 Text URL 属性中。

  2. 保存当前场景,并将其添加到构建设置中。

  3. 构建您的(Windows 或 Mac)独立可执行文件。

  4. 将 externalTexture.jpg 图像复制到您的独立应用程序的Resources文件夹:

图片

  1. 运行您的独立游戏应用程序,图像将在运行时从资源文件夹中读取并显示(并且该资源的路径将在应用程序窗口底部的 UI Text 中显示):

图片

它是如何工作的...

URL 路径被定义,说明了 Unity 如何在独立应用程序构建的资源文件夹中找到所需的图像。此路径通过将 UI Text GameObject 的文本属性设置为该路径的字符串值,在屏幕上显示。

WWW对象发现 URL 以文件协议开头,因此 Unity 尝试在其资源文件夹中找到外部资源文件(等待它加载完成),然后加载其内容。

Start()方法被声明为IEnumerator,允许它作为协程运行,并因此等待直到 WWW 类对象完成图像文件的加载:

您需要在每次编译后手动将文件放置在资源文件夹中。

当您创建 Windows 或 Linux 独立可执行文件时,还有一个名为 _Data 的文件夹,与可执行应用程序文件一起创建。资源文件夹位于此数据文件夹内部。

Mac 独立应用程序的可执行文件看起来像一个单独的文件,但实际上是一个 macOS 包文件夹。右键单击可执行文件并选择 显示包内容。然后你将在 Contents 文件夹内找到独立版本的 Resources 文件夹。

我们使用文件协议作为 URL,它必须以以下形式开始:file:///:

对于 OSX 独立版本,Unity Application.dataPath 返回的路径形式为 /user/location-to-Contents,因此我们在这个路径前加上 file:// 以获取有效的文件协议 URL,形式为 file:///user/location-to-Contents

对于 Windows,Unity Application.dataPath 返回的路径形式为 C:Projects/MyUnityProject/location-to-``Data,因此我们在这个路径前加上 file:/// 以获取有效的文件协议 URL,形式为 file:///C:Projects/MyUnityProject/location-to-Data

从 Unity 文档和 Unity 答案页面了解更多信息:

还有更多...

有些细节你不希望错过。

避免使用 Path.Combine() 而不是 / 或 \ 来解决跨*台问题

Windows 和 Mac 文件系统中的文件路径分隔符字符不同(Windows 使用反斜杠 \,Mac 使用正斜杠 /)。然而,Unity 知道你正在将项目编译成哪种类型的独立版本;因此,Path.Combine() 方法会插入所需的文件 URL 中的适当分隔符斜杠字符。

StreamingAssets 文件夹

如果部署到 iOS 或 Android,Resources 文件夹技术将不起作用,你应该在名为 StreamingAssets. 的文件夹中创建和存储资源文件。例如,如果你有一个名为 MyTextFile.txt 的文本文件,你可以在 项目 面板中创建一个名为 StreamingAssets 的文件夹,将文件 MyTextFile.txt 存储在该文件夹中,并使用以下代码在运行时使用你的应用程序加载该文件的内容:

string filePath = System.IO.Path.Combine(Application.streamingAssetsPath, "MyTextFile.txt");
string contents = System.IO.File.ReadAllText(filePath);

在以下链接中了解更多关于 Unity 中 StreamingAsset 文件夹的信息:docs.unity3d.com/Manual/StreamingAssets.htmldocs.unity3d.com/ScriptReference/Application-streamingAssetsPath.html

参见

参考本章中的以下食谱以获取更多信息:

  • 使用 Unity 默认资源加载外部资源文件

  • 通过从互联网下载文件来加载外部资源文件

将项目文件保存到 Unity Asset Bundles

Unity 提供了 Asset Bundle 机制作为另一种在运行时管理资源加载的方法。Asset Bundles 可以存储在本地或互联网上。

在本教程中,我们将创建一个 Prefab(3D 立方体 GameObject 的 Prefab),并将其保存到 Asset Bundle 中。

准备工作

10_06文件夹中,我们提供了externalTexture.jpg图像文件。

如何操作...

要保存 Unity Asset Bundles,请执行以下操作:

  1. 创建一个新的 3D Unity 项目。

  2. 导入提供的图像文件(到名为 Textures 的新文件夹中)。

  3. 在场景中创建一个新的立方体(命名为Cube-1),并应用导入的纹理图像。

  4. 在项目窗口中,创建一个新文件夹并将其重命名为 Prefabs。

  5. 在项目文件夹 Prefabs 中,创建一个名为 cube 的新空 Prefab。

  6. 从场景面板,将 GameObject Cube-1 拖到项目文件夹 Prefabs 中的 Prefab cube 上。Prefab 应该变成蓝色,现在它是一个存储 GameObject Cube-1 属性的文件。

  7. 在项目面板中选中文件 cube,转到检查器面板底部创建一个新的 AssetBundle,命名为 chapter11。请参阅说明截图:

  1. 在项目面板中,创建一个名为EditorAssetBundles的新文件夹。

  2. Editor文件夹中,创建一个新的 C# CreateAssetBundles 脚本类,包含以下代码:

using UnityEditor;

 public class CreateAssetBundles
 {
     [MenuItem("Assets/Build AssetBundles/Mac")]
     static void BuildAllAssetBundlesMac()
     {
         string assetBundleDirectory = "Assets/AssetBundles";
         BuildPipeline.BuildAssetBundles(assetBundleDirectory, BuildAssetBundleOptions.None, BuildTarget.StandaloneOSX);
     }

     [MenuItem("Assets/Build AssetBundles/Windows")]
     static void BuildAllAssetBundlesWindows()
     {
         string assetBundleDirectory = "Assets/AssetBundles";
         BuildPipeline.BuildAssetBundles(assetBundleDirectory, BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows);
     }
 } 
  1. 你现在应该能在资产菜单底部看到两个新菜单项:

  1. 选择适合你使用的操作系统(Mac 或 Windows)的创建包操作。

  2. 你现在应该能在文件夹AssetBundles中看到创建的文件:AssetBundleschapter11

它是如何工作的...

Resources.Load(fileName)语句使 Unity 在其编译的项目数据文件resources.assets中查找名为externalTexture的文件的内容。内容作为纹理图像返回,并存储到externalImage变量中。Start()方法中的最后一个语句将 GameObject 的纹理设置为附加脚本的externalImage变量。

字符串变量 fileName 是一个公共属性,因此你可以选择层次结构中的 GameObject Cube-1,并在检查器中的 Read Default Resources(Script)组件中编辑文件名字符串。

在以下地址了解更多关于 Asset Bundles 和 Unity 推荐的工作流程:

从 Unity Asset Bundles 加载资源

在本教程中,我们将加载一个 Asset Bundle,检索一个预制件,然后从检索到的数据中在场景中创建(实例化)一个GameObject

准备工作

本教程使用前一个教程创建的文件。我们还提供了10_07文件夹中 AssetBundles 文件夹的副本。

如何操作...

要加载 Unity 资产包,请执行以下操作:

  1. 创建一个新的 3D Unity 项目。

  2. 导入提供的文件夹 AssetBundles

  3. 创建以下 C# AssetBundleLoader 脚本类,并将其作为一个组件添加到主摄像机实例中:

using UnityEngine;
 using System.IO;
 using UnityEngine.Networking;
 using System.Collections;

 public class AssetBundleLoader : MonoBehaviour
 {
     public string bundleFolderName = "AssetBundles";
     public string bundleName = "chapter11";
     public string resourceName = "cube";

     void Start()
     {
         StartCoroutine(LoadAndInstantiateFromUnityWebRequest());
     }

     private IEnumerator LoadAndInstantiateFromUnityWebRequest()
     {
         // (1) load asset bundle
         string uri = "file:///" + Application.dataPath;
         uri = Path.Combine(uri, bundleFolderName);
         uri = Path.Combine(uri, bundleName);

         UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(uri, 0);
         yield return request.SendWebRequest();

         // (2) extract 'cube' from loaded asset bundle
         AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(request);
         GameObject cube = bundle.LoadAsset<GameObject>(resourceName);

         // (3) create scene GameObject based on 'cube'
         Instantiate(cube);
     }
 } 
  1. 运行 场景,应该会在场景中出现一个立方体,它是通过从资产包中提取预制件立方体文件创建的,然后基于这个预制件实例化一个新的 GameObject。

它是如何工作的...

Unity 方法 UnityWebRequestAssetBundle.GetAssetBundle(...) 期望一个指向命名资产包的文件协议 URI。这个包被加载到变量 bundle 中,然后使用 LoadAsset(...) 方法提取名为 cube 的文件。最后,在运行时基于这个预制件创建了一个 GameObject,结果是我们在运行场景时看到的立方体。

变量 bundleFolderNamebundleNameresourceName 定义了项目中的文件夹、资产包文件名和 Asset 包内的预制件。

如果你选择了 chapter11 清单文件(看起来像一张带有线条的纸张的图标),那么在检查器中,你可以看到它包含我们的立方体预制件:

    ...
     Assets:
     - Assets/Prefabs/cube.prefab 

在以下地址了解更多关于 AssetBundles 和 Unity 推荐的工作流程:

还有更多...

有些细节你不希望错过。

通过 AssetBundle.LoadFromFile() 加载资产包

对于从瓦片本地加载的替代 UnityWebRequest 是使用 AssetBundle.LoadFromFile():

    void Start()
     {
         // (1) load asset bundle
         string path = Path.Combine(Application.streamingAssetsPath, bundleName);
         AssetBundle myLoadedAssetBundle = AssetBundle.LoadFromFile(path);

         if (null == myLoadedAssetBundle)
         {
             Debug.Log("Failed to load AssetBundle: " + path);
             return;
         }

         // (2) extract 'cube' from loaded asset bundle
         string resourceName = "cube";
         GameObject prefabCube = myLoadedAssetBundle.LoadAsset<GameObject>(resourceName);

         // (3) create scene GameObject based on 'cube'
         Instantiate(prefabCube);
     } 

通过网络服务器托管加载资产包

尽管我们刚刚展示了如何从本地文件加载 AssetBundle,但通常这些资源是从网络服务器加载的。在这种情况下,URI 需要是一个互联网协议。

例如,如果文件是在本地的 8000 端口上提供服务的,那么主机将是 http://localhost:8000 等等:

    public string bundleName = "chapter11";
     public string resourceName = "cube";

     public string host = "http://localhost:8000";

     void Start() {
         StartCoroutine(LoadAndInstantiateFromUnityWebRequestServer());
     }

     private IEnumerator LoadAndInstantiateFromUnityWebRequestServer() {
         string uri = Path.Combine(host, bundleName);
         UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(uri, 0);
         yield return request.SendWebRequest();

         AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(request);
         GameObject cube = bundle.LoadAsset<GameObject>(resourceName);
         Instantiate(cube);
     } 

第十七章:与纯文本、XML 和 JSON 文本文件一起工作

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

  • 使用 TextAsset 公共变量加载外部文本文件

  • 使用 C# 文件流加载外部文本文件

  • 使用 C# 文件流保存外部文本文件

  • 加载和解析外部 XML 文件

  • 使用 XMLWriter 手动创建 XML 文本数据

  • 通过序列化自动保存和加载 XML 文本数据

  • 使用 XMLDocument.Save() 将 XML 直接保存到文本文件中创建 XML 文本文件

  • 从单个对象和对象列表创建 JSON 字符串

  • 从 JSON 字符串创建单个对象和对象列表

简介

基于文本的外部数据非常常见且非常有用,因为它既适合计算机阅读也适合人类阅读。文本文件可以用来允许非技术团队成员编辑书面内容,或者在开发和测试期间记录游戏性能数据。基于文本的格式还允许序列化——将实时对象数据编码成适合传输、存储和以后检索的格式。

Unity 将以下所有内容(以及 C# 脚本)视为文本资产

  • .txt:纯文本文件

  • .html.htm:HTML 页面标记(超文本标记语言)

  • .xml:XML 数据(可扩展标记语言)

  • .bytes:二进制数据(通过 bytes 属性访问)

  • .json:JSON(JavaScript 对象表示法)

  • .csv:CSV(逗号分隔变量)

  • .yaml:YAML 不是标记语言

  • .fnt:位图字体数据(与相关的图像纹理文件)

要了解有关 Unity 文本资产的手册页面,请点击以下链接:docs.unity3d.com/Manual/class-TextAsset.html

许多基于网络的系统使用 XML 进行异步通信,而不需要用户交互,因此产生了术语AJAX异步 JavaScript XML。一些现代基于网络的系统现在使用 JSON 进行基于文本的通信。因此,本章特别关注这两种文本文件格式。

整体图景

除了纯文本之外,还有两种常见的文本交换文件格式:XML 和 JSON。本章将讨论并举例说明这两种格式。

XML – 可扩展标记语言

XML 是一种元语言,即一组规则,允许创建标记语言来编码特定类型的数据。以下是一些使用 XML 语法的数据描述语言格式示例:

  • .txt:纯文本文件

  • .html.htm:HTML 页面标记(超文本标记语言)

  • .xml:XML 数据(可扩展标记语言)

  • SVG:可缩放矢量图形——一个由万维网联盟支持的开放标准图形描述方法

  • SOAP:用于在计算机程序和 Web 服务之间交换消息的简单对象访问协议

  • X3D:Xml 3D——一个用于表示 3D 对象的 ISO 标准——它是 VRML虚拟现实建模语言)的继任者

JSON – JavaScript 对象表示法

JSON 有时被称为 XML 的“无脂肪替代品”——提供类似的数据交换强度,但更小、更简单,因为它不提供可扩展性,并且仅使用三个字符进行格式化:

  • 属性 : 值:冒号字符用于分隔属性名称和其值

  • { }:花括号用于表示对象

  • [ ]:方括号用于表示值/对象的数组

你可以在www.json.org/xml.html了解更多关于 JSON 与 XML 的信息

在第十章“处理外部资源文件”中,演示了加载外部资源文件的方法,这些方法适用于图像、音频和文本资源。在本章中,还介绍了加载文本文件的一些额外方法。

在第十三章,“着色器图和视频播放器”,一些示例说明了如何使用 JSON 为数据库驱动的网页排行榜,以及 Unity 游戏与该排行榜的通信。

使用 TextAsset 公共变量加载外部文本文件

一种简单的方法是将数据存储在文本文件中,然后在编译前选择它们,可以使用类TextAsset的公共变量。

这种技术仅在游戏编译后数据文件不会发生变化时适用,因为文本文件数据是序列化的(混合到)通用构建资源中,因此构建创建后无法更改。

准备工作

对于这个示例,你需要一个文本(.txt)文件。在11_01文件夹中,我们提供了两个这样的文件:

  • cities.txt

  • countries.txt

如何操作...

要使用TextAsset加载外部文本文件,请执行以下步骤:

  1. 创建一个新的 2D 项目。

  2. 创建一个UI TextGameObject,使用Rect Transform将其居中显示在屏幕上,并设置其水*和垂直溢出为溢出。

  3. 将你希望使用的文本文件导入到你的项目中(例如,cities.txt

  4. 创建一个 C# ReadPublicTextAsset脚本类,并将其作为组件附加到你的UI Text GameObject

   using UnityEngine;
    using UnityEngine.UI;

    public class ReadPublicTextAsset : MonoBehaviour {
     public TextAsset dataTextFile;

     private void Start() {
         string textFromFile = dataTextFile.text;
         Text textOnScreen = GetComponent<Text>();
         textOnScreen.text = textFromFile;
     }
    } 
  1. Hierarchy视图中选择Main Camera,将cities.txt文件拖放到Inspector中的公共字符串变量dataTextFile

工作原理...

当场景开始时,文本文件的内容被读取到变量textFromFile中。找到UI Text组件的引用,并将该 UI 组件的文本属性设置为textFromFile的内容。用户可以随后在屏幕中间看到文本文件的内容。

使用 C#文件流加载外部文本文件

对于既从文本文件中读取又向文本文件中写入(创建或更改)的独立可执行游戏,通常使用.NET数据流进行读写。本示例说明了如何读取文本文件,而下一个示例将说明如何将文本数据写入文件。

此技术仅在将编译为 Windows 或 Mac 独立可执行文件时有效;例如,它不会为WebGL构建工作。

准备工作

对于此菜谱,您需要一个文本文件;在11_01文件夹中提供了两个文件。

如何操作...

要使用 C#文件流加载外部文本文件,请执行以下步骤:

  1. 创建一个新的 C#脚本类FileReadWriteManager
using System;
using System.IO;

 public class FileReadWriteManager {
     public void WriteTextFile(string pathAndName, string stringData) {
         FileInfo textFile = new FileInfo( pathAndName );
         if( textFile.Exists )
              textFile.Delete();

         StreamWriter writer;
         writer = textFile.CreateText();

         writer.Write(stringData);
         writer.Close();
     }

     public string ReadTextFile(string pathAndName) {
         string dataAsString = "";

         try {
             StreamReader textReader = File.OpenText( pathAndName );

             dataAsString = textReader.ReadToEnd();
             textReader.Close();
         }
         catch (Exception e) {
             return "error:" + e.Message;
         }

         return dataAsString;
     }
 } 
  1. 创建一个 C# ReadWithStream脚本类并将其实例作为组件附加到您的UI Text GameObject
using UnityEngine;
 using UnityEngine.UI;
 using System.IO;

 public class ReadWithStream : MonoBehaviour {
     private string fileName = "cities.txt";

     private string textFileContents = "(file not found yet)";
     private FileReadWriteManager fileReadWriteManager = new FileReadWriteManager();

     private void Start () {
         string filePath = Path.Combine(Application.dataPath, "Resources");
         filePath = Path.Combine(filePath, fileName);

         textFileContents = fileReadWriteManager.ReadTextFile( filePath );

         Text textOnScreen = GetComponent<Text>();
         textOnScreen.text = textFileContents;
     }
 } 
  1. 保存当前场景并将其添加到构建的场景列表中。

  2. 构建并运行您的(Windows、Mac 或 Linux)独立可执行文件。

  3. 将包含您数据的文本文件复制到您的独立应用程序的Resources文件夹中(即Start()方法中设置的第一个语句中的文件名——在我们的示例中,这是cities.txt文件)。

每次编译后,您都需要手动将文件放置在Resources文件夹中。

对于 Windows 和 Linux 用户:当您创建 Windows 或 Linux 独立可执行文件时,会创建一个包含可执行应用程序文件的数据文件夹_DataResources文件夹位于此数据文件夹内部。

对于 Mac 用户:Mac 独立应用程序的可执行文件看起来像一个单独的文件,但实际上是一个 macOS "包"文件夹。右键单击可执行文件并选择显示包内容。然后您将在 Contents 文件夹内找到独立应用程序的 Resources 文件夹。

  1. 当您运行构建的可执行文件时,您应该在应用程序窗口的中间看到加载并显示的文本文件内容。

工作原理...

当游戏运行时,Start()方法创建filePath字符串,然后从fileReadWriteManager对象调用ReadTextFile()方法,并将 filePath 字符串传递给它。此方法读取文件内容,并将其作为字符串返回,存储在textFileContents变量中。我们的OnGUI()方法显示这两个变量的值(filePathtextFileContents)。

注意,此菜谱需要使用System.IO包。C#脚本FileReadWriteManager.cs包含两个通用的文件读写方法,您可能会在许多不同的项目中找到它们很有用。

使用 C#文件流保存外部文本文件

此菜谱说明了如何使用 C#流将文本数据写入文本文件,无论是写入独立项目的Data文件夹还是Resources文件夹。

此技术仅在将编译为 Windows 或 Mac 独立可执行文件时有效。

准备工作

11_02文件夹中,我们提供了一个包含之前菜谱中创建的完成 C#脚本类的文本文件:

FileReadWriteManager.cs

如何操作...

要使用 C#文件流保存外部文本文件,请按照以下步骤操作:

  1. 创建一个新的 2D 项目。

  2. 将 C# FileReadWriteManager.cs脚本类导入到您的项目中。

  3. 将以下 C# SaveTextFile脚本类添加到主相机

using UnityEngine;
 using System.IO;

 public class SaveTextFile : MonoBehaviour {
     public string fileName = "hello.txt";
     public string folderName = "Data";
     private string filePath = "(no file path yet)";
     private FileReadWriteManager fileManager;

     void Start () {
         string textData = "hello \n and goodbye";
         fileManager = new FileReadWriteManager();
         filePath = Path.Combine(Application.dataPath, folderName);
         filePath = Path.Combine(filePath, fileName);
         fileManager.WriteTextFile( filePath, textData );
     }
 } 
  1. 保存当前场景并将其添加到构建的场景列表中。

  2. 构建并运行你的(Windows、Mac 或 Linux)独立可执行文件。

  3. 运行构建好的可执行文件后,你现在应该能在你的项目独立文件中的Data文件夹里找到一个名为hello.txt的新文本文件,其中包含 hello 和 goodbye 两行。

图片

在 Unity 编辑器内运行时可以测试这一点(即在构建独立应用程序之前)。要这种方式测试,你需要在项目面板中创建一个Data文件夹。

它是如何工作的...

当游戏运行时,Start()方法从公共变量fileNamefolderName创建filePath字符串,然后从fileReadWriteManager对象调用WriteTextFile()方法,并将filePathtextData字符串传递给它。此方法创建(或覆盖)一个包含接收到的字符串数据的文本文件(对于给定的文件路径和文件名)。

还有更多...

以下是一些你不希望错过的细节。

选择数据或资源文件夹

独立构建的应用程序包含一个Data文件夹和一个Resources文件夹。这两个文件夹中的任何一个都可以用于写入(或如果需要,其他文件夹)。我们通常将只读文件放入Resources文件夹,并使用Data文件夹来创建从头开始创建或内容已更改的文件。

在构建你的可执行文件之前,你可以指定不同的文件和文件夹名称(例如,Resources而不是Data)。确保在层次结构中选择了主摄像机 GameObject,然后更改检查器组件中的那些公共变量在保存文本文件(脚本)中的值。

加载和解析外部 XML

能够解析(处理包含 XML 格式数据的文本文件和字符串)非常有用。C#提供了一系列类和方法来简化此类处理,我们将在本食谱中探讨。

准备工作

你将在11_04文件夹中的playerScoreData.xml文件中找到以 XML 格式存储的玩家姓名和分数数据。此文件的内容如下:

<scoreRecordList>
        <scoreRecord>
            <player>matt</player>
            <score>2200</score>
            <date>
                <day>1</day>
                <month>Sep</month>
                <year>2012</year>
            </date>
        </scoreRecord>
        <scoreRecord>
            <player>jane</player>
            <score>500</score>
            <date>
                <day>12</day>
                <month>May</month>
                <year>2012</year>
            </date>
        </scoreRecord>
    </scoreRecordList> 

数据通过一个名为scoreRecordList的根元素进行结构化,该元素包含一系列scoreRecord元素。每个scoreRecord元素包含一个玩家元素(其中包含玩家的名字),一个分数元素(包含玩家的分数的整数值),以及一个日期元素,该日期元素本身包含三个子元素 - 天、月和年。

如何操作...

要加载和解析外部 XML 文件,请按照以下步骤操作:

  1. 创建一个包含以下内容的 C# PlayerScoreDate脚本类:
    public class PlayerScoreDate
     {
         private string playerName;
         private int score;
         private string date;

         public void SetPlayerName(string playerName)
         { this.playerName = playerName; }

         public void SetScore(int score)
         { this.score = score; }

         public void SetDate(string date)
         { this.date = date; }

         override public string ToString()
         {
             return "Player = " + this.playerName + ",
             score = " + this.score + ", date = " + this.date;
         }
     } 
  1. 创建一个 C# ParseXML脚本类并将其实例作为组件附加到主摄像机
using UnityEngine;
 using System;
 using System.Xml;
 using System.IO;

 public class ParseXML : MonoBehaviour {
     public TextAsset scoreDataTextFile;
     private PlayerScoreDate[] playerScores = new PlayerScoreDate[999];

     private void Start() {
         string textData = scoreDataTextFile.text;
         int numberObjects = ParseScoreXML( textData );

         for (int i = 0; i < numberObjects; i++)
             print(playerScores[i]);
     }

     private int ParseScoreXML(string xmlData) {
         XmlDocument xmlDoc = new XmlDocument();
         xmlDoc.Load( new StringReader(xmlData) );

         string xmlPathPattern = "//scoreRecordList/scoreRecord";
         XmlNodeList myNodeList = xmlDoc.SelectNodes( xmlPathPattern );

         int i = 0;
         foreach(XmlNode node in myNodeList){
             playerScores[i] = NodeToPlayerScoreObject(node);
             i++;
         }

         return i;
     }

     private PlayerScoreDate NodeToPlayerScoreObject(XmlNode node) {
         XmlNode playerNode = node.FirstChild;
         string playerName = playerNode.InnerXml;

         XmlNode scoreNode = playerNode.NextSibling;
         string scoreString = scoreNode.InnerXml;
         int score = Int32.Parse(scoreString);

         XmlNode dateNode = scoreNode.NextSibling;
         string date = NodeToDateString(dateNode);

         PlayerScoreDate playerObject = new PlayerScoreDate();
         playerObject.SetPlayerName(playerName);
         playerObject.SetScore(score);
         playerObject.SetDate(date);

         return playerObject;

     }

     private string NodeToDateString(XmlNode dateNode) {
         XmlNode dayNode = dateNode.FirstChild;
         XmlNode monthNode = dayNode.NextSibling;
         XmlNode yearNode = monthNode.NextSibling;

         return dayNode.InnerXml + "/" + monthNode.InnerXml + "/" + yearNode.InnerXml;
     }
 } 
  1. 运行场景,print()语句的输出应该在控制台窗口中可见:

图片

它是如何工作的...

PlayerScoreDate脚本类仅包含玩家按日期计分的三个数据项:

  • 玩家的名字(字符串)

  • 玩家的分数(整数)

  • 记录分数的日期(字符串——为了使这个食谱简短...)

注意,对于 C# ParseXML 脚本类,需要使用 SystemSystem.XmlSystem.IO 包。

TextAsset 变量 scoreDataTextFiletext 属性提供了 XML 文件的内容作为字符串,该字符串传递给 ParseScoreXML(...) 方法。

ParseScoreXML(...) 方法使用此字符串的内容创建一个新的 XmlDocument 变量。XmlDocument 类提供了 SelectNodes() 方法,该方法返回给定元素路径的节点对象列表。在此示例中,请求了一个 scoreRecord 节点列表。一个 for-each 语句循环遍历每个 scoreRecord,将当前节点传递给 NodeToPlayerScoreObject(...) 方法,并将返回的对象存储在 playerScores 数组的下一个槽位中。

NodeToPlayerScoreObject(...) 方法依赖于 XML 元素的顺序来检索玩家的姓名、分数和数据字符串。分数字符串被解析为整数,日期节点使用方法 NodeToDateString(...) 转换为日期字符串。创建一个新的 PlayerScoreDate 对象,并将名称、分数和日期存储在其中,然后返回该对象。

NodeToDateString(...) 方法通过解析包含三个日期组件的节点来创建一个日期字符串,以斜杠分隔。

更多...

以下是一些你不希望错过的细节。

从网络检索 XML 数据文件

如果 XML 文件位于网络上而不是你的 Unity 项目中,可以使用 WWW Unity 类。

使用 XMLWriter 手动创建 XML 文本数据

从游戏对象和属性创建 XML 数据结构的一种方法是通过手动编码一个方法来创建每个元素及其内容,使用 XMLWriter 类。

如何操作...

要使用 XMLWriter 创建 XML 文本数据,请按照以下步骤操作:

  1. 创建一个 C# CreateXMLString 脚本类,将其作为组件添加到 主相机
using UnityEngine;
 using System.Xml;
 using System.IO;

 public class CreateXMLString : MonoBehaviour {

     private void Start () {
         string output = BuildXMLString();
         print(output);
     }

     private string BuildXMLString() {
         StringWriter str = new StringWriter();
         XmlTextWriter xml = new XmlTextWriter(str);

         // start doc and root element
         xml.WriteStartDocument();
         xml.WriteStartElement("playerScoreList");

         // data element
         xml.WriteStartElement("player");
         xml.WriteElementString("name", "matt");
         xml.WriteElementString("score", "200");
         xml.WriteEndElement();

         // data element
         xml.WriteStartElement("player");
         xml.WriteElementString("name", "jane");
         xml.WriteElementString("score", "150");
         xml.WriteEndElement();

         // end root and document
         xml.WriteEndElement();
         xml.WriteEndDocument();

         return str.ToString();
     }
 } 
  1. 当场景运行时,XML 文本数据应在控制台面板中可见,并且应如下所示(添加了一些换行符以使输出更易于阅读...):
<?xml version="1.0" encoding="utf-16"?>
   <playerScoreList>
   <player>
   <name>matt</name>
   <score>200</score>
   </player>
   <player>
   <name>jane</name>
   <score>150</score>
   </player>
   </playerScoreList> 

它是如何工作的...

Start() 方法调用 BuildXMLString() 并将返回的字符串存储在输出变量中。然后,将此输出文本打印到控制台调试面板。

BuildXMLString() 方法创建一个 StringWriter 对象,XMLWriter 将 XML 元素的字符串构建到该对象中。XML 文档以 WriteStartDocument()WriteEndDocument() 方法开始和结束。元素以 WriteStartElement()WriteEndElement() 方法开始和结束。使用 WriteElementString() 添加元素的内容。

更多...

这里有一些细节,你不想错过。

添加新行以使 XML 字符串更易于阅读。

在每次调用WriteStartElement()WriteElementString()方法之后,你可以使用WriteWhiteSpace()添加一个换行符。这些在 XML 解析方法中被忽略,但如果你的意图是显示给人类看的 XML 字符串,新行字符的存在会使它更容易阅读:

xml.WriteWhitespace("\n ");

使数据类负责从列表创建 XML

要生成的 XML 通常来自对象列表,所有对象都是同一类。在这种情况下,让负责为这些对象的列表生成 XML 的对象类是有意义的。

CreateXMLFromArray类简单地创建一个包含PlayerScore对象的List<T>实例,然后调用(静态)方法ListToXML(),传入对象列表。

以下应该是一个单独的代码块:

using UnityEngine;
using System.Collections.Generic;

public class CreateXMLFromArray : MonoBehaviour {
   private List<PlayerScore> playerScoreList;

   private void Start () {
       playerScoreList = new List<PlayerScore>();
       playerScoreList.Add (new PlayerScore("matt", 200) );
       playerScoreList.Add (new PlayerScore("jane", 150) );

       string output = PlayerScore.ListToXML( playerScoreList );
       print(output); 
   }
}

所有艰苦的工作现在都由PlayerScore类负责。这个类有两个用于玩家姓名和得分的私有变量,以及一个接受这些属性值的构造函数。公共静态方法ListToXML()接受一个List对象作为参数,并使用XMLWriter构建 XML 字符串,遍历列表中的每个对象,并调用对象的ObjectToElement()方法。这个方法向接收该对象数据的XMLWriter参数添加一个 XML 元素:

using System.Collections.Generic;
using System.Xml;
using System.IO;

public class PlayerScore {
   private string _name;
   private int _score;

   public PlayerScore(string name, int score) {
       _name = name;
       _score = score;
   }

   static public string ListToXML(List<PlayerScore> playerList) {
       StringWriter str = new StringWriter();
       XmlTextWriter xml = new XmlTextWriter(str);
       xml.WriteStartDocument();
       xml.WriteStartElement("playerScoreList");
       foreach (PlayerScore playerScoreObject in playerList) {
          playerScoreObject.ObjectToElement( xml );
       }

       xml.WriteEndElement();
       xml.WriteEndDocument();
       return str.ToString();
    }

    private void ObjectToElement(XmlTextWriter xml) {
       // data element
       xml.WriteStartElement("player");
       xml.WriteElementString("name", _name);
       string scoreString = "" + _score; // make _score a string
       xml.WriteElementString("score", scoreString);
       xml.WriteEndElement();
    }
 }

通过序列化自动保存和加载 XML 文本数据

另一种从游戏对象和属性中处理 XML 数据结构的方法是通过自动序列化对象的内容。这种技术会自动为对象的公共属性生成 XML。这个配方使用了可以在标准System.Xml C#包中找到的XmlSerializer类。

这个配方是从这篇 2013 年(仍然有效!)Unify 社区维基文章改编的:wiki.unity3d.com/index.php?title=Saving_and_Loading_Data:_XmlSerializer

准备工作

11_06文件夹中,你会找到两个 XML 数据文件,允许你使用不同的 XML 文本文件数据文件测试读取器。

如何做到这一点...

要通过序列化创建 XML 文本数据,请执行以下步骤:

  1. 创建一个 C# PlayerScore脚本类:
    using System.Xml.Serialization;

     [System.Serializable]
     public class PlayerScore
     {
         [XmlElement("Name")]
         public string name;

         [XmlElement("Score")]
         public int score;

         [XmlElement("Version")]
         public string version;
     } 
  1. 创建一个 C# PlayerScoreCollection脚本类:
    using System.Xml.Serialization;
     using System.IO;

     [XmlRoot("PlayerScoreCollection")]
     public class PlayerScoreCollection
     {
         [XmlArray("PlayerScores"), XmlArrayItem("PlayerScore")]
         public PlayerScore[] playerScores;

         public void Save(string path) {
             var serializer = new XmlSerializer(typeof(PlayerScoreCollection));
             using (var stream = new FileStream(path, FileMode.Create)) {
                 serializer.Serialize(stream, this);
             }
         }
     } 
  1. 创建一个 C# XmlWriter脚本类,并将其实例作为组件附加到主摄像机
    using UnityEngine;
     using System.IO;

     public class XmlWriter : MonoBehaviour {
         public string fileName = "playerData.xml";
         public string folderName = "Data";

         private void Start() {
             string filePath = Path.Combine(Application.dataPath, folderName);
             filePath = Path.Combine(filePath, fileName);

             PlayerScoreCollection psc = CreatePlayScoreCollection();
             psc.Save(filePath);
             print("XML file should now have been created at: " + filePath);
         }

         private PlayerScoreCollection CreatePlayScoreCollection() {
             PlayerScoreCollection playerScoreCollection = new PlayerScoreCollection();

             // make 2 slot array
             playerScoreCollection.playerScores = new PlayerScore[2];

             playerScoreCollection.playerScores[0] = new PlayerScore();
             playerScoreCollection.playerScores[0].name = "matt";
             playerScoreCollection.playerScores[0].score = 22;
             playerScoreCollection.playerScores[0].version = "v0.5";

             playerScoreCollection.playerScores[1] = new PlayerScore();
             playerScoreCollection.playerScores[1].name = "joelle";
             playerScoreCollection.playerScores[1].score = 5;
             playerScoreCollection.playerScores[1].version = "v0.9";

             return playerScoreCollection;
         }
     } 
  1. 如果你能在项目面板中创建一个名为Data的文件夹,然后运行场景,你就可以快速在 Unity 编辑器中测试场景。大约 10-20 秒后,你应该会发现在Data文件夹中已经创建了一个名为playerData.xml的文本文件。

  2. 保存当前场景,然后将其添加到构建场景列表中。

  3. 构建并运行你的(Windows、Mac 或 Linux)独立可执行文件。

  4. 现在,你应该能在你的项目独立文件中的Data文件夹中找到一个名为playerData.xml的新文本文件,其中包含三个玩家的 XML 数据。

  5. playerData.xml 文件的内容应该是 XML 玩家列表数据,即:

    <?xml version="1.0" encoding="us-ascii"?>
     <PlayerScoreCollection

         >
       <PlayerScores>
         <PlayerScore>
           <Name>matt</Name>
           <Score>22</Score>
           <Version>v0.5</Version>
         </PlayerScore>
         <PlayerScore>
           <Name>joelle</Name>
           <Score>5</Score>
           <Version>v0.9</Version>
         </PlayerScore>
       </PlayerScores>
     </PlayerScoreCollection> 

它是如何工作的...

XmlWriterStart() 方法定义了文件路径(Data/playerData.xml),并通过调用 CreatePlayScoreCollection() 方法创建一个新的 PlayerScoreCollection 对象 psc。然后调用 PlayerScoreCollection 对象的 Save(...) 方法,传递文件路径。

XmlWriterCreatePlayScoreCollection() 方法创建一个新的 PlayerScoreCollection,并向其中插入一个包含两个 PlayerScore 对象的数组,这些对象具有以下名称/分数/版本值:

    matt, 22, v0.5
    joelle, 5, v.09 

PlayerScoreCollection 类的 Save(...) 方法为类类型创建一个新的 XmlSerializer,并使用 FileStream 告诉 C# 将 PlayerScoreCollection 对象(即其 PlayerScore 对象数组)的内容序列化为文本到该文件。

还有更多...

这里有一些你不想错过的细节。

定义 XML 节点名称

我们可以使用编译器语句来定义用于编码每个对象属性的 XML 元素名称。例如,我们为每个 PlayerScore 的名称属性定义了元素 Name(首字母大写 N):

    [XmlElement("Name")]
    public string name; 

如果没有声明,那么 XML 序列化器将默认使用小写属性名 name 为这些数据元素。

从 XML 文本加载数据对象

我们可以为 PlayerScoreCollection 类编写静态(类)方法,这些方法使用 XML 序列化器来加载 XML 数据,并从加载的数据创建数据对象。

这里有一个从文件路径加载的方法:

    public static PlayerScoreCollection Load(string path) {
         var serializer = new XmlSerializer(typeof(PlayerScoreCollection));
         using (var stream = new FileStream(path, FileMode.Open)) {
             return serializer.Deserialize(stream) as PlayerScoreCollection;
         }
     } 

可以编写另一个方法从文本字符串加载:

    public static PlayerScoreCollection LoadFromString(string text) {
         var serializer = new XmlSerializer(typeof(PlayerScoreCollection));
         return serializer.Deserialize(new StringReader(text)) as PlayerScoreCollection;
     } 

例如,你可以创建一个公共的 TextAsset 变量,然后通过调用此静态方法创建一个 PlayerScoreCollection 对象,然后通过以下代码循环遍历并打印出加载的对象:

    public TextAsset dataAsXmlString;

     private void Start()
     {
         PlayerScoreCollection objectCollection =   
         PlayerScoreCollection.LoadFromString(dataAsXmlString.text);

         foreach(PlayerScore playerScore in objectCollection.playerScores){
             print("name = " + playerScore.name + ", score = " + playerScore.score + ",
                 version = " + playerScore.version);
         }
     } 

创建 XML 文本文件 – 使用 XMLDocument.Save() 直接将 XML 保存到文本文件

有可能创建一个 XML 数据结构,然后使用 XMLDocument.Save() 方法直接将该数据保存到文本文件;这个配方说明了如何做。

如何做到这一点...

要直接将 XML 数据保存到文本文件,请执行以下步骤:

  1. 创建一个新的 C# PlayerXMLWriter 脚本类:
    using System.Xml;
     using System.IO;

     public class PlayerXMLWriter {
         private string filePath;
         private XmlDocument xmlDoc;
         private XmlElement elRoot;

         public PlayerXMLWriter(string filePath) {
             this.filePath = filePath;
             xmlDoc = new XmlDocument();

             if(File.Exists (filePath)) {
                 xmlDoc.Load(filePath);
                 elRoot = xmlDoc.DocumentElement;
                 elRoot.RemoveAll();
             }
             else {
                 elRoot  = xmlDoc.CreateElement("playerScoreList");
                 xmlDoc.AppendChild(elRoot);
             }
         }

         public void AddXMLElement(string playerName, string playerScore) {
             XmlElement elPlayer = xmlDoc.CreateElement("playerScore");
             elRoot.AppendChild(elPlayer);

             XmlElement elName = xmlDoc.CreateElement("name");
             elName.InnerText = playerName;
             elPlayer.AppendChild(elName);

             XmlElement elScore = xmlDoc.CreateElement("score");
             elScore.InnerText = playerScore;
             elPlayer.AppendChild(elScore);
         }

         public void SaveXMLFile() {
            xmlDoc.Save(filePath);
         }
     } 
  1. 创建一个 C# CreateXMLTextFile 脚本类,并将其实例作为组件附加到 主相机
    using UnityEngine;
     using System.IO;

     public class CreateXMLTextFile : MonoBehaviour {
         public string fileName = "playerData.xml";
         public string folderName = "Data";

         private void Start() {
             string filePath = Path.Combine( Application.dataPath, folderName);
             filePath = Path.Combine( filePath, fileName);

             PlayerXMLWriter playerXMLWriter = new PlayerXMLWriter(filePath);
             playerXMLWriter.AddXMLElement("matt", "55");
             playerXMLWriter.AddXMLElement("jane", "99");
             playerXMLWriter.AddXMLElement("fred", "101");
             playerXMLWriter.SaveXMLFile();

             print( "XML file should now have been created at: " + filePath);
         }
     } 
  1. 如果你在一个名为 Data 的文件夹中创建一个 Data 文件夹并在 Unity 编辑器中运行场景,你可以快速测试场景。大约 10-20 秒后,你应该现在在 Data 文件夹中找到一个名为 playerData.xml 的文本文件。

  2. 保存当前场景,然后将其添加到构建的场景列表中。

  3. 构建并运行你的(Windows、Mac 或 Linux)独立可执行文件。

  4. 你现在应该在项目的独立文件 Data 文件夹中找到一个名为 playerData.xml 的新文本文件,其中包含三个玩家的 XML 数据:

  5. playerData.xml 文件的内容应该是 XML 玩家列表数据:

它是如何工作的...

Start() 方法创建 playerXMLWriter,这是 PlayerXMLWriter 类的新对象,它将新的、必需的 XML 文件路径 filePath 作为参数传递。向 PlayerXMLWriter 对象添加了三个元素,用于存储三个玩家的姓名和分数。调用 SaveXMLFile() 方法并显示一个调试 print() 消息。

PlayerXMLWriter 类的构造函数方法如下:当创建一个新对象时,提供的文件路径字符串存储在一个私有变量中;同时,检查是否已存在任何文件。如果找到现有文件,则删除内容元素;如果没有找到现有文件,则创建一个新的根元素 playerScoreList 作为子数据节点的父元素。AddXMLElement() 方法为提供的玩家姓名和分数追加一个新的数据节点。SaveXMLFile() 方法将 XML 数据结构保存为文本文件,用于存储的文件路径字符串。

从单个对象和对象列表创建 JSON 字符串

JsonUtility 类允许我们轻松地从单个对象和 对象列表 创建 JSON 字符串。

如何实现...

要从单个对象和 列表 创建 JSON 字符串,请执行以下步骤:

  1. 创建一个名为 PlayerScore 的新 C# 脚本类:
    using UnityEngine;

     [System.Serializable]
     public class PlayerScore {
         public string name;
         public int score;

         public string ToJson() {
             bool prettyPrintJson = true;
             return JsonUtility.ToJson(this, prettyPrintJson);
         }
     } 
  1. 创建一个名为 PlayerScoreList 的新 C# 脚本类:
    using UnityEngine;
     using System.Collections.Generic;

     [System.Serializable]
     public class PlayerScoreList {
         public List<PlayerScore> list = new List<PlayerScore>();

         public string ToJson() {
             bool prettyPrint = true;
             return JsonUtility.ToJson(this, prettyPrint);
         }
     } 
  1. 创建一个名为 ToJson 的 C# 脚本类,并将其实例作为组件附加到 主摄像机
    using UnityEngine;

     public class ToJson : MonoBehaviour {
         private PlayerScore playerScore1 = new PlayerScore();
         private PlayerScore playerScore2 = new PlayerScore();
         private PlayerScoreList playerScoreList = new PlayerScoreList();

         private void Awake() {
             playerScore1.name = "matt";
             playerScore1.score = 800;

             playerScore2.name = "joelle";
             playerScore2.score = 901;

             playerScoreList.list.Add(playerScore1);
             playerScoreList.list.Add(playerScore2);
         }

         void Start() {
             ObjectToJson();
             CollectionToJson();
         }

         public void ObjectToJson() {
             string objectAsString = playerScore1.ToJson();
             print("1: Object to JSON \n" + objectAsString);
         }

         public void CollectionToJson() {
             string objectListAsString = playerScoreList.ToJson();
             print("2: List of objects to JSON \n" + objectListAsString);
         }
     } 
  1. 运行场景。应该有两个消息输出到控制台面板:
    1: Object to JSON
     {
         "name": "matt",
         "score": 800
     } 

应遵循以下步骤:

    2: List of objects to JSON
     {
         "list": [
             {
                 "name": "matt",
                 "score": 800
             },
             {
                 "name": "joelle",
                 "score": 901
             }
         ]
     } 

它是如何工作的...

PlayerScore 脚本类声明了两个公共属性:namescore。它还定义了一个公共方法 ToJson(),该方法通过 JsonUtility.ToJson(...) 方法返回包含属性值的字符串,这些属性值以 JSON 编码。

PlayerScoreList 脚本类声明了一个单个公共属性 list,它是一个 PlayerScore 对象的 C# List<>。因此,我们可以在我们的列表中存储零个、一个或任何数量的 PlayerScore 对象。还声明了一个单个公共方法 ToJson(),该方法通过 JsonUtility.ToJson(...) 方法返回包含 list 属性内容的字符串,这些内容以 JSON 编码。

它还定义了一个公共 ToJson() 方法,该方法通过 JsonUtility.ToJson(...) 方法返回包含属性值的字符串,这些属性值以 JSON 编码。

Awake() 方法创建两个 PlayerScore 对象,对象 playerScore1playerScore2 中包含一些演示数据。它还创建了一个 PlayerScoreList 类的实例对象,并将这两个对象的引用添加到该列表中。

Start() 方法首先调用 ObjectToJson() 方法,然后调用 CollectionToJson()

方法 ObjectToJson() 调用对象 playerScore1ToJson() 方法,该方法返回一个字符串,然后将其打印到控制台面板。

方法 CollectionToJson() 调用了对象列表 playerScoreListToJson() 方法,该方法返回一个字符串,然后该字符串被打印到控制台面板。

如我们所见,PlayerScorePlayerScoreList 类都定义了一个 ToJson() 方法,该方法使用了 JsonUtiltyToJson() 方法。在两种情况下,它们的 ToJson() 方法都返回一个字符串,该字符串是对象数据的 JSON 表示形式。

PlayerScore 类的 ToJson() 方法以以下形式输出 JSON 对象字符串:

    { "name": "matt", "score": 800 }. 

PlayerScoreList 类的 ToJson() 方法以以下形式输出 JSON 数组字符串:

    {
         "list": [
             { "name": "matt", "score": 800 },
             { "name": "joelle", "score": 901 }
         ]
     } 

如我们所见,JsonUtility 类的 ToString() 方法能够将单个对象和 C# 对象列表序列化为可存储的字符串。

从 JSON 字符串创建单个对象和对象列表

JsonUtility 类使我们能够轻松解析(处理)JSON 字符串并提取单个对象,以及对象列表。

如何实现...

要从 JSON 字符串创建单个对象和对象列表,请执行以下步骤:

  1. 创建一个名为 PlayerScore 的新 C# 脚本类:
    using UnityEngine;

     [System.Serializable]
     public class PlayerScore {
         public string name;
         public int score;

         public static PlayerScore FromJSON(string jsonString) {
             return JsonUtility.FromJson<PlayerScore>(jsonString);
         }
     } 
  1. 创建一个名为 PlayerScoreList 的新 C# 脚本类:
    using UnityEngine;
     using System.Collections.Generic;

     [System.Serializable]
     public class PlayerScoreList {
         public List<PlayerScore> list = new List<PlayerScore>();

         public static PlayerScoreList FromJSON(string jsonString) {
             return JsonUtility.FromJson<PlayerScoreList>(jsonString);
         }
     } 
  1. 创建一个 C# ToJson 脚本类并将其作为组件附加到 主相机
    using UnityEngine;

     public class FromJson : MonoBehaviour {
         private void Start() {
             JsonToObject();
             JsonToList();
         }

         public void JsonToObject() {
             string playerScoreAsString = "{ \"name\":\"matt\", \"score\":201}";
             PlayerScore playerScore = PlayerScore.FromJSON(playerScoreAsString);

             print(playerScore.name + ", " + playerScore.score);
         }

         public void JsonToList() {
             string playerScorelistAsString = "";

             playerScorelistAsString += "{";
             playerScorelistAsString += "\"list\": [";
             playerScorelistAsString += "        {";
             playerScorelistAsString += "            \"name\": \"matt\",";
             playerScorelistAsString += "            \"score\": 800";
             playerScorelistAsString += "        },";
             playerScorelistAsString += "        {";
             playerScorelistAsString += "            \"name\": \"joelle\",";
             playerScorelistAsString += "            \"score\": 901";
             playerScorelistAsString += "        }";
             playerScorelistAsString += "    ]";
             playerScorelistAsString += "}";

             PlayerScoreList playerScoreList = PlayerScoreList.FromJSON(playerScorelistAsString);

             foreach (var playerScore in playerScoreList.list) {
                 print("from list :: " + playerScore.name + ", " + playerScore.score);
             }
         }
     } 
  1. 运行场景。控制台面板应输出三条消息:
    matt, 201 

应该紧随其后的是:

    from list :: matt, 800
    from list :: joelle, 901 

它是如何工作的...

Start() 方法首先调用 JsonToObject() 方法,然后调用 JsonToList() 方法。

方法 JsonToObject() 声明了一个字符串,该字符串定义了一个 PlayerScore 对象:{name:matt, score:201}。该字符串使用转义的双引号字符来创建属性名称和文本数据(对于数值数据不需要引号)。JSON 字符串包含以下内容:

{ 
   "name":"matt",  
   "score":201 
} 

使用转义的双引号字符来表示属性名称和文本数据(对于数值数据不需要引号)。然后,类 PlayerScore静态 方法 FromJSON(...) 使用这个 JSON 数据字符串作为其参数被调用。该方法返回一个 PlayerScore 对象,然后其值被打印到控制台面板。

PlayerScore静态 方法 FromJSON(...) 调用了 JsonUtility 类的 FromJson() 方法,并提供了 PlayerScore 类类型。

方法 JsonToList() 声明了一个字符串,该字符串定义了两个 PlayerScore 对象的列表:{name:matt, score:800}{name:joelle, score:901}。JSON 字符串包含以下内容:

{ 
   "list": [         
         {             
               "name": "matt",             
               "score": 800         
         },         
         {             
               "name": "joelle",            
               "score": 901         
         }     
   ] 
} 

然后,使用该 JSON 数据字符串作为参数调用类 PlayerScoreList静态 方法 FromJSON(...)。该方法返回一个 PlayerScoreList 对象。一个 foreach 循环从 PlayerScoreList 对象的列表属性中提取每个 PlayerScore 对象。每个对象的 namescore 值被打印到控制台面板,前面带有列表中的文本。

PlayerScoreList 类的 静态 方法 FromJSON(...) 调用了 JsonUtility 类的 FromJson() 方法,并提供了类类型 PlayerScoreList

如我们所见,JsonUtility 类的 FromJson() 方法能够将数据反序列化为单个对象,以及存储在仓库对象内部的 C# 列表 对象,例如 PlayerScoreList

第十八章:虚拟现实和额外功能

本章将涵盖以下主题:

  • 从游戏中保存截图

  • 使用静态属性保存和加载玩家数据

  • 使用 PlayerPrefs 保存和加载玩家数据

  • 从文本文件地图加载游戏数据

  • UI 滑块更改游戏质量设置

  • 暂停游戏

  • 实现慢动作

  • Gizmo 显示场景面板中当前选定的对象

  • Gizmo 绘制的编辑器网格对齐

  • 创建 VR 项目

  • 将 360 度视频添加到 VR 项目中

  • 在 VR 环境中编辑 VR 内容 – XR 编辑器

简介

Unity 2018 的功能太多,无法在单本书中全部涵盖。在本章中,我们将展示一系列食谱,展示 Unity 中的 VR 游戏开发,以及我们希望包含的一系列其他 Unity 功能。

整体概念

除了纯文本外,以下三个部分将为您提供一个关于本章内容的想法。

虚拟现实

VR 是向玩家呈现沉浸式的视听体验,足够吸引他们沉浸于探索和与创建的游戏世界互动。

从一个角度来看,VR 只需要两个摄像头来为每只眼睛生成图像,以产生 3D 效果。但有效的 VR 需要内容、UI 控件和工具来帮助创建它们。在本章中,我们将探讨与 360 度视频和 Unity 的 XR 编辑器工具集一起工作的食谱。

Gizmos

Gizmos 是另一种 Unity 编辑器自定义。Gizmos 是游戏设计师在场景面板中提供的视觉辅助工具。它们可以用作设置辅助(帮助我们了解我们在做什么),或者用于调试(理解为什么对象没有按预期表现)。

Gizmos 不是通过编辑器脚本绘制的,而是作为 Monobehaviours 的一部分,因此它们仅适用于当前场景中的 GameObject。Gizmo 绘制通常通过两种方法进行:

  • OnDrawGizmos(): 这在每个帧上执行,针对层次结构中的每个 GameObject

  • OnDrawGizmosSelect(): 这在每个帧上执行,仅针对当前在层次结构中选定的 GameObject

Gizmo 图形绘制使得绘制线条、立方体和球体变得简单。更复杂的形状也可以使用网格绘制,您还可以显示位于项目文件夹中的 2D 图像图标(位于:Assets | Gizmos)。

本章中的一些食谱展示了 Gizmos 如何有用。通常,从编辑器扩展创建的新 GameObject 将关联有有用的 Gizmos。

运行时保存/加载数据

当在本地加载/保存数据时,重要的是要记住可以使用的数据类型。当编写 C#代码时,我们的变量可以是语言允许的任何类型,但当通过 Web 界面通信或使用 Unity 的PlayerPrefs类与本地存储通信时,我们在可以处理的数据类型上受到限制。当使用PlayerPrefs类时,我们仅限于保存和加载整数、浮点数和字符串。我们提供了几个菜谱,展示了在运行时保存和加载数据的方法,包括使用静态变量、PlayerPrefs 以及包含 2D 游戏关卡文本格式数据的公共 TextAsset。

从游戏中保存截图

在这个菜谱中,我们将学习如何捕获游戏中的快照,并将它们保存到外部文件。更好的是,我们将使选择三种不同方法成为可能。

这种技术仅在 Unity 编辑器中或构建为独立 Windows 或 Mac 可执行文件时(例如,Web Player 构建将无法使用)有效:

准备工作

要遵循此菜谱,请将位于16_01文件夹中的截图包导入到您的项目中。该包包括主相机、立方体和球体——这样你就可以在截图时识别它们了!

如何操作...

要保存游戏中的截图,请按照以下步骤操作:

  1. 创建一个新的 3D 项目。

  2. 通过选择以下菜单导入截图包:**资产 | 导入包 | 自定义包**...

  3. 打开截图包中提供的场景。

  4. 创建一个 C#的CaptureScreenshot脚本类,并将其实例对象作为组件添加到主相机中:

using System.Collections;
 using UnityEngine;
 using System.IO;

 public class CaptureScreenshot : MonoBehaviour {
     public string prefix = "Screenshot";
     public enum CaptureMethod {
         SCREENSHOT_PNG,
         READ_PIXELS_PNG,
         READ_PIXELS_JPG
     };
     public CaptureMethod captureMethod = CaptureMethod.SCREENSHOT_PNG;
     public int screenshotScale = 1;

     // A slider from 0 to 100 from which to set JPG quality
     [Range(0, 100)]
     public int jpgQuality = 75;

     private Texture2D texture;
     string date;

     void  Update () {
         if (Input.GetKeyDown (KeyCode.P)){
             TakeShot();
         }
     }

     private void TakeShot() {
         date = System.DateTime.Now.ToString("_d-MMM-yyyy-HH-mm-ss-f");

         if (CaptureMethod.SCREENSHOT_PNG == captureMethod){
             string fileExtension = ".png";
             string filename = prefix + date + fileExtension;
             ScreenCapture.CaptureScreenshot(filename, screenshotScale);
         } else {
             StartCoroutine(ReadPixels());
         }
     }

     IEnumerator  ReadPixels () {
         byte[] bytes;
         yield return new WaitForEndOfFrame();

         int screenWidth = Screen.width;
         int screenHeight = Screen.height;
         Rect screenRectangle = new Rect(0, 0, screenWidth, screenHeight);
         texture = new Texture2D (screenWidth, screenHeight, TextureFormat.RGB24, false);
         texture.ReadPixels(screenRectangle, 0, 0);
         texture.Apply();

         switch(captureMethod){
             case CaptureMethod.READ_PIXELS_JPG:
                 bytes = texture.EncodeToJPG(jpgQuality);
                 WriteBytesToFile(bytes, ".jpg");
                 break;

             case CaptureMethod.READ_PIXELS_PNG:
             default:
                 bytes = texture.EncodeToPNG();
                 WriteBytesToFile(bytes, ".png");
                 break;
         }
     }

     void WriteBytesToFile(byte[] bytes, string fileExtension) {
         Destroy (texture);
         string filename = prefix + date + fileExtension;
         string path = Application.dataPath;
         path = Path.Combine(path, "..");
         path = Path.Combine(path, filename);
         File.WriteAllBytes(path, bytes);
     }
 } 
  1. Hierarchy中选择主相机,在Inspector中访问CaptureScreenshot (Scripted)组件。将Capture Method设置为SCREENSHOT_PNG。将Screenshot Scale更改为2

如果你想你的图像文件名不以“截图”这个词开头,请在“前缀”字段中更改它:

  1. 播放场景。每次按下P键时,都会在项目文件夹中保存一个大小为原始两倍的新截图。

工作原理...

对于每一帧,Update()方法都会检查是否按下了P键。如果按下,则调用TakeShot()方法。

方法TakeShot()捕获屏幕图像并将其作为文件存储在主 Unity 项目目录中(即与AssetsLibrary目录等并列)。主相机的CaptureScreenshot (Scripted)的公共设置决定了项目创建的图像文件属性。

三种类型的截图图像被定义为枚举类型:

  • SCREENSHOT_PNG:内置 Unity 函数CaptureScreenshot()。该函数能够按比例调整原始屏幕大小,这可以通过我们的公共属性Capture Scale来设置。

  • READ_PIXELS_PNG:使用ReadPixels()函数,编码为 PNG 格式。

  • READ_PIXELS_JPG:使用ReadPixels()函数,编码为 JPG。

通过内置的 Unity 函数WriteAllBytes()WriteBytesToFile(...)方法中将 ReadPixels 函数捕获的图像数据写入文件。

在所有情况下,创建的文件都将具有适当的*.png*.jpg文件扩展名,以匹配其图像文件格式。

更多内容...

我们使用ReadPixel函数展示了如何在不使用 Unity 的CaptureScreenshot()函数的情况下将图像保存到磁盘。这种方法的一个优点是它可以适应只捕获和保存屏幕的一部分——如果定义了一个不同大小的矩形。

保存和加载玩家数据——使用静态属性

在游戏中跟踪玩家的进度和用户设置对于给游戏带来更深的感受和内容至关重要。在这个食谱中,我们将学习如何使我们的游戏记住玩家在不同级别(场景)之间的得分。

准备工作

我们在16_02文件夹中包含了一个名为game_HigherOrLower的完整 Unity 项目包。为了遵循这个食谱,我们将导入这个包作为起点。

如何做...

要保存和加载玩家数据,请按照以下步骤操作:

  1. 创建一个新的 2D 项目并导入game_HigherOrLower包。

  2. 将每个场景按顺序添加到构建中(scene0_mainMenu,然后 scene1_gamePlaying,依此类推)。

  3. 通过玩几次游戏并检查场景内容来熟悉游戏。游戏从scene0_mainMenu场景开始,位于Scenes文件夹内。

  4. 让我们创建一个类来存储用户做出的正确和错误猜测的数量。创建一个名为Player的新 C#脚本,代码如下:

using UnityEngine;

 public class Player : MonoBehaviour {
   public static int scoreCorrect = 0;
   public static int scoreIncorrect = 0;
 } 
  1. 在场景scene0_mainMenu的左下角,创建一个名为Text - score的 UI Text GameObject,包含占位文本Score: 99 / 99

图片

  1. 接下来,将以下 C#脚本附加到 UI GameObject Text—score 上:
using UnityEngine;
 using System.Collections;

 using UnityEngine.UI;

 public class UpdateScoreText : MonoBehaviour {
   void Start(){
     Text scoreText = GetComponent<Text>();
     int totalAttempts = Player.scoreCorrect + Player.scoreIncorrect;
     string scoreMessage = "Score = ";
     scoreMessage += Player.scoreCorrect + " / " + totalAttempts;

     scoreText.text = scoreMessage;
   }
 } 
  1. scene2_gameWon场景中,将以下 C#脚本附加到主摄像机上:
using UnityEngine;

 public class IncrementCorrectScore : MonoBehaviour {
   void Start () {
     Player.scoreCorrect++;
   }
 } 
  1. scene3_gameLost场景中,将以下 C#脚本附加到主摄像机上:
using UnityEngine;

 public class IncrementIncorrectScore : MonoBehaviour {
   void Start () {
     Player.scoreIncorrect++;
   }
 } 
  1. 保存你的脚本,并玩游戏。当你从级别(场景)进步到下一个级别时,你会发现得分和玩家的名字被记住,直到你退出应用程序。

它是如何工作的...

Player类使用静态(类)属性scoreCorrectscoreIncorrect来存储当前正确和错误猜测的总数。由于这些是公共静态属性,任何场景中的任何对象都可以访问(设置或获取)这些值,因为静态属性在场景之间是记忆的。此类还提供了一个名为ZeroTotals()的公共静态方法,可以将这两个值重置为零。

当 scene0_mainMenu 场景被加载时,所有带有脚本的 GameObjects 将执行它们的 Start() 方法。名为 Text-score 的 UI Text GameObject 有一个 UpdateScoreText 类的脚本组件实例,因此脚本 Start() 方法将被执行,从 Player 类中检索正确和错误的总计,创建关于当前分数的 scoreMessage 字符串,并更新文本属性,以便用户看到当前分数。

当游戏运行且用户猜对(更高)时,则加载 scene2_gameWon 场景。因此,在这个场景中,主摄像机的 IncrementCorrectScore 脚本组件的 Start() 方法被执行,将 1 加到 Player 类的 scoreCorrect 变量上。

当游戏运行且用户猜错(更低)时,则加载 scene3_gameLost 场景。因此,在这个场景中,主摄像机的 IncrementIncorrectScore 脚本组件的 Start() 方法被执行,将 1 加到 Player 类的 scoreIncorrect 变量上。

下次用户访问主菜单场景时,将从 Player 类中读取正确和错误的总计新值,并且屏幕上的 UI Text 将通知用户他们的游戏更新总分。

更多内容...

有一些细节你不应该错过。

在第一次尝试完成之前隐藏分数

显示零分并不专业。让我们添加一些逻辑,以便只有在总尝试次数大于零时才显示分数:

void Start(){
   Text scoreText = GetComponent<Text>();
   int totalAttempts = Player.scoreCorrect + Player.scoreIncorrect;

   // default is empty string
   string scoreMessage = "";
   if( totalAttempts > 0){
     scoreMessage = "Score = ";
     scoreMessage += Player.scoreCorrect + " / " + totalAttempts;
   }

   scoreText.text = scoreMessage;
 } 

相关内容

参考本章中的以下配方以获取更多信息:

  • 保存和加载玩家数据 - 使用 PlayerPrefs

保存和加载玩家数据 – 使用 PlayerPrefs

虽然之前的配方说明了静态属性如何允许游戏在不同场景之间记住值,但这些值一旦游戏应用程序退出就会被遗忘。Unity 提供了 PlayerPrefs 功能,允许游戏在不同游戏会话之间存储和检索数据:

准备工作

这个配方基于之前的配方,所以请复制那个项目并在此基础上工作。

如何操作...

要使用 PlayerPrefs 保存和加载玩家数据,请按照以下步骤操作:

  1. 删除名为 Player 的 C#脚本。

  2. 编辑名为 UpdateScoreText 的 C#脚本,将其 Start() 方法替换为以下内容:

void Start(){
   Text scoreText = GetComponent<Text>();

   int scoreCorrect = PlayerPrefs.GetInt("scoreCorrect");
   int scoreIncorrect = PlayerPrefs.GetInt("scoreIncorrect");

   int totalAttempts = scoreCorrect + scoreIncorrect;
   string scoreMessage = "Score = ";
   scoreMessage += scoreCorrect + " / " + totalAttempts;

   scoreText.text = scoreMessage;
 } 
  1. 现在编辑名为 IncrementCorrectScore 的 C#脚本,将其 Start() 方法替换为以下代码:
void Start () {
   int newScoreCorrect = 1 + PlayerPrefs.GetInt("scoreCorrect");
   PlayerPrefs.SetInt("scoreCorrect", newScoreCorrect);
 } 
  1. 现在编辑名为 IncrementIncorrectScore 的 C#脚本,将其 Start() 方法替换为以下代码:
void Start () {
   int newScoreIncorrect = 1 + PlayerPrefs.GetInt("scoreIncorrect");
   PlayerPrefs.SetInt("scoreIncorrect", newScoreIncorrect);
 } 
  1. 保存你的脚本并玩游戏。退出 Unity 然后重新启动应用程序。你会发现玩家的名字、等级和分数现在在游戏会话之间被保留。

工作原理...

由于这个配方使用的是 Unity 提供的内置运行时类PlayerPrefs,因此我们不需要 Player 类。

Unity 的PlayerPrefs运行时类能够在用户的机器上存储和访问信息(字符串、int 和 float 变量)。值存储在 plist 文件(Mac)或注册表(Windows)中,类似于网络浏览器的 cookies,因此可以在游戏应用会话之间记住。

总正确和错误得分的值由IncrementCorrectScoreIncrementIncorrectScore类中的Start()方法存储。这些方法使用PlayerPrefs.GetInt("")方法检索旧的总分,将其加 1,然后使用PlayerPrefs.SetInt("")方法存储增加后的总分。

这些正确和错误的总数在每次加载scene0_mainMenu场景时都会被读取,并且通过屏幕上的 UI Text 对象显示得分总数。

有关PlayerPrefs的更多信息,请参阅 Unity 在线文档docs.unity3d.com/ScriptReference/PlayerPrefs.html

参考信息

有关更多信息,请参阅本章中的以下配方:

  • 保存和加载玩家数据 - 使用静态属性

从文本文件地图加载游戏数据

而不是为每个游戏级别手动创建和放置每个GameObject,一个更好的方法是可以创建包含行和列字符的文本文件,其中每个字符对应于要在相应位置创建的GameObject的类型。

在这个配方中,我们将使用文本文件和一组预制精灵来显示经典游戏NetHack屏幕的文本数据文件的图形版本:

准备中

16_04文件夹中,我们为这个配方提供了以下两个文件:

  • level1.txt(一个文本文件,表示一个关卡)

  • absurd128.png(一个 128 x 128 的精灵图,用于 NetHack)。

关卡数据来自 NetHack 维基百科页面,精灵图来自 SourceForge:

注意,我们还包含了一个包含所有预制件设置的 Unity 包,因为这可能是一个费时的任务。

如何操作...

要从文本文件地图加载游戏数据,请执行以下操作:

  1. 导入文本文件level1.txt和图像文件absurd128.png

  2. 在检查器中选择absurd128.png,并将纹理类型设置为 Sprite (2D/uGUI),并将 Sprite 模式设置为 Multiple。

  3. 在精灵编辑器中编辑此精灵,选择类型为 Grid,并将像素大小设置为 128 x 128,并应用以下设置:

  1. 在“项目”面板中,点击指向右侧的白三角图标以展开图标,以单独显示此精灵图集中的所有精灵:

图片

  1. 将名为 absurd128_175 的精灵拖放到场景中。

  2. 在“项目”面板中创建一个新的预制件 corpse_175,并将其从场景中的空白预制件精灵 absurd128_175 拖放到此。现在从场景中删除精灵实例。你现在创建了一个包含 Sprite 175 的预制件。

  3. 对以下精灵重复此过程(即为每个精灵创建预制件):

    • – floor_848

    • – corridor_849

    • – horiz_1034

    • – vert_1025

    • – door_844

    • – potion_675

    • – chest_586

    • – alter_583

    • – stairs_up_994

    • – stairs_down_993

    • – wizard_287

  4. 在“检查器”中选择主相机,并确保它设置为大小为 20 的正交相机,清除标志为纯色,背景为黑色。

  5. 将以下 C# 代码附加到主相机上,作为名为 LoadMapFromTextfile 的脚本类:

using UnityEngine;
 using System.Collections;

 using System.Collections.Generic;

 public class LoadMapFromTextfile : MonoBehaviour
 {
   public TextAsset levelDataTextFile;

   public GameObject floor_848;
   public GameObject corridor_849;
   public GameObject horiz_1034;
   public GameObject vert_1025;
   public GameObject corpse_175;
   public GameObject door_844;
   public GameObject potion_675;
   public GameObject chest_586;
   public GameObject alter_583;
   public GameObject stairs_up_994;
   public GameObject stairs_down_993;
   public GameObject wizard_287;

   public Dictionary<char, GameObject> dictionary = new Dictionary<char, GameObject>();

   void Awake(){
     char newlineChar = '\n';

     dictionary['.'] = floor_848;
     dictionary['#'] = corridor_849;
     dictionary['('] = chest_586;
     dictionary['!'] = potion_675;
     dictionary['_'] = alter_583;
     dictionary['>'] = stairs_down_993;
     dictionary['<'] = stairs_up_994;
     dictionary['-'] = horiz_1034;
     dictionary['|'] = vert_1025;
     dictionary['+'] = door_844;
     dictionary['%'] = corpse_175;
     dictionary['@'] = wizard_287;

     string[] stringArray = levelDataTextFile.text.Split(newlineChar);
     BuildMaze( stringArray );
   }

   private void BuildMaze(string[] stringArray){
     int numRows = stringArray.Length;

     float yOffset = (numRows / 2);

     for(int row=0; row < numRows; row++){
       string currentRowString = stringArray[row];
       float y = -1 * (row - yOffset);
       CreateRow(currentRowString, y);
     }
   }

   private void CreateRow(string currentRowString, float y) {
     int numChars = currentRowString.Length;
     float xOffset = (numChars/2);

     for(int charPos = 0; charPos < numChars; charPos++){
       float x = (charPos - xOffset);
       char prefabCharacter = currentRowString[charPos];

       if (dictionary.ContainsKey(prefabCharacter)){
         CreatePrefabInstance( dictionary[prefabCharacter], x, y);
       }
     }
   }

   private void CreatePrefabInstance(GameObject objectPrefab, float x, float y){
     float z = 0;
     Vector3 position = new Vector3(x, y, z);
     Quaternion noRotation = Quaternion.identity;
     Instantiate (objectPrefab, position, noRotation);
   }
 } 
  1. 选择主相机,将适当的预制件拖放到“检查器”中 LoadMapFromTextfile 脚本组件的预制件槽中。

  2. 当你运行场景时,你会看到基于精灵的 Nethack 地图出现,使用你的预制件。

它是如何工作的...

精灵图集被自动切割成数百个 128 x 128 像素的精灵方块。我们从这些精灵中创建了一些预制件对象,以便在需要时在运行时创建副本。

文件名为 level1.txt 的文本文件包含文本字符的行。每个非空格字符表示精灵预制件应该实例化的位置(列 = x;行 = y)。在 Start() 方法中声明并初始化了一个名为 dictionary 的 C# 字典变量,以将特定的预制件 GameObject 与文本文件中的某些特定字符关联起来。

Awake() 方法使用换行符作为分隔符将字符串拆分为数组。因此,现在我们有了 stringArray,其中包含文本数据的每一行的条目。BuildMaze(...) 方法使用 stringArray 被调用。

BuildMaze(...) 方法查询数组以找到其长度(此级别的数据行数),并将 yOffSet 设置为该值的一半。这样做是为了允许将预制件放置在 y = 0 的一半以上和一半以下,因此 (0,0,0) 是级别地图的中心。使用 for 循环读取数组中的每一行的字符串。它将字符串传递给 CreateRow(...) 方法,并附带对应于当前行的 y 值。

CreateRow(...)方法提取字符串的长度,并将xOffSet设置为该值的一半。这样做是为了允许将预制件放置在x = 0 的左侧和右侧的一半,因此(0,0,0)是地图中心的水*面。使用 for 循环从当前行的字符串中读取每个字符,并且(如果我们的字典中有该字符的条目)则调用CreatePrefabInstance (...)方法,传递字典中该字符的预制件引用以及xy值。

CreatePrefabInstance(...)方法在位置x, y, z处实例化给定的预制件,其中z始终为零,并且没有旋转(Quarternion.identity)。

UI 滑块更改游戏质量设置

在本菜谱中,我们将向您展示玩家如何通过提供 UI 滑块来控制质量设置。从这,他们可以从当前项目的可能质量设置数组中进行选择:

图片

准备工作

对于这个菜谱,我们准备了一个名为BallGame的包,包含两个场景。该包位于16_05文件夹中。

如何操作...

要创建一个玩家 UI 来更改游戏的质量设置,请执行以下操作:

  1. 创建一个新的 3D 项目并导入BallGame包。

  2. 打开名为scene0_ballCourt的场景。

  3. 在场景中,通过选择菜单:创建 | UI | 面板创建一个新的 UI 面板名为Panel-quality

  4. 在层次结构中选择 Panel-quality 游戏对象后,通过选择菜单:创建 | UI | 滑块创建一个新的 UI 滑块名为 Slider-quality。此游戏对象应该是 Panel-quality 的子对象。

  5. 在层次结构中选择 Panel-quality 后,通过选择菜单:创建 | UI | 文本创建一个新的 UI 文本游戏对象名为 Text-quality。此游戏对象应该是 GameObject Panel-quality 的子对象。在检查器中,将其 Transform Position Y 值设置为-25。

  6. 创建一个新的 C#脚本类名为QualityChooser,并将实例对象作为组件附加到第一人称控制器:

    using UnityEngine;
     using UnityEngine.UI;
     using System.Collections;

     public class QualityChooser : MonoBehaviour {
         public GameObject panelGo;
         public Slider slider;
         public Text textLabel;

         void Awake () {
             slider.maxValue = QualitySettings.names.Length - 1;
             slider.value = QualitySettings.GetQualityLevel();
             SetQualitySliderActive(true);
         }

         public void SetQualitySliderActive(bool active) {
             Cursor.visible = active;
             panelGo.SetActive(active);
         }

         public void UpdateQuality(float sliderFloat) {
             int qualityInt = Mathf.RoundToInt (sliderFloat);
             QualitySettings.SetQualityLevel (qualityInt);
             textLabel.text = QualitySettings.names [qualityInt];
         }
     } 
  1. 层次结构中,选择第一人称控制器。然后,从检查器中访问质量选择器组件,并将面板 Go、滑块和文本标签公共字段填充为 UI 游戏对象面板-quality、滑块和文本:

图片

  1. 层次结构面板中选择滑块。然后,从检查器中,对于滑块组件,找到名为 On Value Changed (Single)的列表,并单击加号以添加命令。

  2. 第一人称控制器层次结构拖入新命令的游戏对象字段。然后,使用功能选择器在动态浮点部分(无功能 | 质量选择器 | 动态浮点 | UpdateQuality)中找到 UpdateQuality 函数:

图片

  1. 当您播放场景时,您应该能够拖动质量滑块来更改质量设置。

工作原理...

你创建了一个包含 UI 滑块和 UI 文本对象的面板。

SetQualitySliderActive(...)方法接收一个 true/false 值,并使用这个值来显示/隐藏鼠标光标和 UI 面板。

UpdateQuality(...)方法从滑块的OnChange事件接收一个浮点值。此值被转换为整数,该整数是要选择的当前质量设置的索引。此索引既用于选择项目质量设置,也用于更新 UI 文本标签,显示当前所选质量设置的名称。

当场景开始时,在Awake()方法中,UI 滑块的最大值设置为项目质量项数减 1(例如,如果有五个项目,滑块将从 0 到 4)。此外,UI 滑块移动到对应当前质量级别的位置,并使用 true 值调用SetQualitySliderActive(...)方法,以显示鼠标指针和显示滑块和文本标签的 UI 面板。

还有更多...

这里有一些方法可以进一步使用这个菜谱。

查看/编辑质量设置列表

你可以通过选择菜单:编辑 | 项目设置 | 质量,来查看和修改项目可用的质量设置。

暂停游戏

尽管你的下一款游戏将非常吸引人,但你应该始终允许玩家暂停它以短暂休息。有时,游戏暂停用于让玩家休息,但另一个原因可能是更改一些游戏设置,如音量或图形质量。

暂停游戏通常涉及冻结游戏动作和隐藏或显示 UI 项目,以向玩家显示消息并提供 UI 控件来更改设置。

在这个菜谱中,我们将实现一个简单而有效的暂停屏幕,当游戏正在播放时隐藏之前菜谱的质量设置滑块,当游戏暂停时显示它。

准备工作

这个菜谱基于之前的菜谱,所以请复制那个菜谱并使用副本。

如何操作...

要在按下Esc键时暂停游戏,请按照以下步骤操作:

  1. 选择第一人称控制器,并在检查器中启用以下组件:

    • 角色控制器

    • 鼠标瞄准(脚本)

    • 角色引擎(脚本)

    • FPS 输入控制器(脚本)

    • 射击(脚本)

  2. 将以下名为PauseGame的 C#脚本添加到第一人称控制器:

    using UnityEngine;
     using UnityEngine.UI;
     using System.Collections;

     public class PauseGame : MonoBehaviour {
         private bool isPaused = false;
         private QualityChooser qualityChooser;

         void Start () {
             qualityChooser = GetComponent<QualityChooser>();
         }

         void Update () {
             if (Input.GetKeyDown(KeyCode.Escape)) {
                 isPaused = !isPaused;
                 SetPause ();
             }
         }

         private void SetPause() {
             float timeScale = !isPaused ? 1f : 0f;
             Time.timeScale = timeScale;
             GetComponent<MouseLook> ().enabled = !isPaused;
             qualityChooser.SetQualitySliderActive(isPaused);
         }        
     } 
  1. 编辑QualityController脚本类,并在Awake()方法中,将最后一行修改为将 false(而不是 true)传递给SetQualitySliderActive(...)方法:
        void Awake () {
             slider.maxValue = QualitySettings.names.Length - 1;
             slider.value = QualitySettings.GetQualityLevel();
             SetQualitySliderActive(false);
         } 
  1. 当你播放场景时,你应该能够通过按下Esc键暂停/恢复游戏,这将同时显示/隐藏控制游戏质量设置的滑块。

它是如何工作的...

要使用脚本暂停 Unity 游戏,我们需要将游戏的时间缩放设置为 0(并在恢复时将其设置为 1)。SetPause()方法根据isPaused变量的值执行这些操作:

  • isPause = true:时间缩放 0(暂停游戏),禁用MouseLook组件,并激活质量滑块和鼠标光标

  • isPause = false:时间缩放 1(恢复游戏),启用MouseLook组件,并停用质量滑块和鼠标光标:

图片

Update()方法中,对每一帧进行测试,检查是否按下了Esc键。如果按下,则切换isPaused的值,并调用SetPause()方法。

更多内容...

这里有一些方法可以进一步改进这个食谱。

了解更多关于质量设置的信息

我们更改质量设置的代码是对 Unity 文档中给出的示例的修改。如果您想了解更多关于这个主题的信息,请查看以下链接:docs.unity3d.com/ScriptReference/QualitySettings.html

提供更多游戏设置给用户

您可以添加更多 UI 面板,在游戏暂停时激活,例如,用于音量控制、保存/加载按钮等。

实现慢动作

自从 Remedy Entertainment 的Max Payne视频游戏以来,慢动作或子弹时间已经成为游戏中的一个流行功能。例如, Criterion 的Burnout系列成功探索了赛车类别中的慢动作效果。在这个食谱中,我们将实现一个通过按下鼠标右键触发的慢动作效果。

准备工作

对于这个食谱,我们将使用与之前食谱相同的包,即BallGame,它位于16_07文件夹中。

如何操作...

要实现慢动作,请按照以下步骤操作:

  1. BallGame包导入到您的项目中,并从项目面板中打开名为scene1_ballGame的场景。

  2. 创建一个名为BulletTime的 C#脚本类,并将其作为组件添加到第一人称控制器 GameObject 实例中:

    using UnityEngine;
     using UnityEngine.UI;
     using System.Collections;

     public class BulletTime : MonoBehaviour {
         public float slowSpeed = 0.1f;
         public float totalTime = 10f;
         public float recoveryRate = 0.5f;
         public Slider EnergyBar;
         private float elapsed = 0f;
         private bool isSlow = false;

         void Update (){        
             if (Input.GetButtonDown ("Fire2") && elapsed < totalTime)
                 SetSpeed (slowSpeed);

             if (Input.GetButtonUp ("Fire2"))
                 SetSpeed (1f);

             if (isSlow) {
                 elapsed += Time.deltaTime / sloSpeed;

                 if (elapsed >= totalTime)
                     SetSpeed (1f);                  
             } else {
                 elapsed -= Time.deltaTime * recoveryRate;
                 elapsed = Mathf.Clamp (elapsed, 0, totalTime);
             }

             float remainingTime = (totalTime - elapsed) / totalTime;
             EnergyBar.value = remainingTime;
         }

         private void SetSpeed (float speed) {
             Time.timeScale = speed;
             Time.fixedDeltaTime = 0.02f * speed;
             isSlow = !(speed >= 1.0f);  
         }
     } 
  1. 通过选择菜单:创建 | UI | 滑块,在场景中添加一个名为Slider-energy的 UI 滑块。请注意,它将作为现有 Canvas 对象的子对象创建。

  2. 选择滑块-能量,并在检查器中的矩形变换组件中设置其锚点如下:

    • 最小 X: 0,Y: 1

    • 最大 X: 0.5,Y: 1

    • 轴心 X: 0,Y: 1

  3. 选择滑块-能量,并在检查器中的矩形变换组件中设置其位置如下:

    • 左: 0

    • Y 轴位置: 0

    • Z 轴位置: 0

    • 右: 0

    • 高度:50:

图片

  1. 在检查器中,将滑块的子GameObject滑块区域设置为不活动:

图片

  1. 最后,从层次结构中选择第一人称控制器。然后,找到子弹时间组件,并将层次结构中的 GameObject 滑块-能量拖动到其能量条槽中。

  2. 播放你的游戏。你应该能够通过按住右鼠标按钮(或你为输入轴设置的任何替代选项)来激活慢动作。滑块将充当进度条,缓慢缩小,指示你剩余的子弹时间。

它是如何工作的...

基本上,要实现慢动作效果,我们只需要减小Time.timeScale变量。在我们的脚本中,我们通过使用slowSpeed变量来实现这一点。请注意,我们还需要调整Time.fixedDeltaTime变量,更新我们游戏的物理模拟。

为了使体验更具挑战性,我们还实现了一种能量条,以指示玩家剩余的子弹时间(初始值由totalTime变量给出,以秒为单位)。当玩家不使用子弹时间时,他/她的配额将根据recoveryRate变量填充。

关于 UI 滑块,我们使用了 Rect Transform 设置将其放置在左上角,并设置其尺寸为屏幕宽度的一半和 50 像素高。此外,我们已隐藏了手柄滑动区域,使其类似于传统的能量条。最后,我们不是允许玩家直接与滑块交互,而是使用BulletTime脚本更改滑块的值。

还有更多...

在以下子节中描述了一些如何进一步改进你的慢动作效果的建议。

个性化滑块

不要忘记,你可以通过创建自己的精灵或根据滑块的值更改滑块的填充颜色来个性化滑块的外观。

尝试将以下代码行添加到Update函数的末尾:

    GameObject fill = GameObject.Find("Fill").gameObject;
     Color sliderColor = 
Color.Lerp(Color.red, Color.green, remainingTime);
     fill.GetComponent<Image> ().color = sliderColor; 

添加运动模糊

运动模糊是一种与慢动作频繁相关的图像效果。一旦附加到相机上,它可以根据速度浮点值启用或禁用。有关运动模糊后处理图像效果的更多信息,请参阅以下链接:

创建音效氛围

Max Payne 闻名于使用强烈的、沉重的心跳声作为音效氛围。你也可以尝试降低音效音量,以传达角色在慢动作时的专注。此外,使用相机上的音频过滤器可能是一个有趣的选择。

使用 Gizmo 在场景面板中显示当前选定的对象

Gizmos 是在场景面板中提供给游戏设计师的视觉辅助工具。在这个菜谱中,我们将突出显示在场景面板中 层次结构 中当前选中的 游戏对象

如何做到这一点...

要在场景面板中创建一个显示所选对象的 Gizmo,请按照以下步骤操作:

  1. 创建一个新的 3D 项目。

  2. 通过选择菜单:创建 | 3D 对象 | 立方体来创建一个 3D 立方体。

  3. 创建一个名为 GizmoHighlightSelected 的 C# 脚本类,并将一个实例对象作为组件添加到 3D 立方体

    using UnityEngine;

     public class GizmoHighlightSelected : MonoBehaviour {
         public float radius = 5.0f;

         void OnDrawGizmosSelected() {
             Gizmos.color = Color.red;
             Gizmos.DrawWireSphere(transform.position, radius);

             Gizmos.color = Color.yellow;
             Gizmos.DrawWireSphere(transform.position, radius - 0.1f);

             Gizmos.color = Color.green;
             Gizmos.DrawWireSphere(transform.position, radius - 0.2f);
         }
     } 
  1. 制作大量 3D 立方体的副本,并将它们随机分布在场景中。

  2. 当你在层次结构中选择一个立方体时,你应该在场景面板中看到围绕所选 游戏对象 绘制了三个彩色线框球体。

它是如何工作的...

当在场景中选择一个对象时,如果它包含一个包含 OnDrawGizmosSelected() 方法的脚本组件,则调用该方法。我们的方法在所选对象周围绘制三个不同颜色的同心线框球体。

您可以在 Gizmos Unity 手册条目中了解更多信息:docs.unity3d.com/Manual/GizmosMenu.html

由 Gizmo 绘制的编辑器网格

如果需要将对象的定位限制到特定的增量,在场景面板中绘制一个网格非常有用,这有助于确保新对象基于这些值定位,并且还有代码将对象吸附到该网格上。

在这个菜谱中,我们将使用 Gizmos 绘制一个可自定义网格大小、颜色、线条数量和线长的网格。遵循此菜谱的结果将如下所示:

如何做到这一点...

要在场景面板中创建一个显示所选对象的 Gizmo,请按照以下步骤操作:

  1. 创建一个新的 3D 项目。

  2. 对于场景面板,关闭 Skybox 视图(或者简单地关闭所有视觉设置),以便为您的网格工作提供一个纯背景:

  1. 显示和更新子对象将由一个名为 GridGizmo 的脚本类执行。创建一个名为 GridGizmo 的新 C# 脚本类,其中包含以下内容:
    using System.Collections;
     using System.Collections.Generic;
     using UnityEngine;

     public class GridGizmo : MonoBehaviour {
         [SerializeField]
         public int grid = 2;

         public void SetGrid(int grid) {
             this.grid = grid;
             SnapAllChildren();
         }

         [SerializeField]
         public Color gridColor = Color.red;

         [SerializeField]
         public int numLines = 6;

         [SerializeField]
         public int lineLength = 50;

         private void SnapAllChildren() {
             foreach (Transform child in transform)
                 SnapPositionToGrid(child);
         }

         void OnDrawGizmos() {
             Gizmos.color = gridColor;

             int min = -lineLength;
             int max = lineLength;

             int n = -1 * RoundForGrid(numLines / 2);
             for (int i = 0; i < numLines; i++) {
                 Vector3 start = new Vector3(min, n, 0);
                 Vector3 end = new Vector3(max, n, 0); 
                 Gizmos.DrawLine(start, end);

                 start = new Vector3(n, min, 0);
                 end = new Vector3(n, max, 0); 
                 Gizmos.DrawLine(start, end);

                 n += grid;
             }
         }

         public int RoundForGrid(int n) {
             return (n/ grid) * grid;
         }

         public int RoundForGrid(float n) {
             int posInt = (int) (n / grid);
             return posInt * grid;
         }

         public void SnapPositionToGrid(Transform transform) {
             transform.position = new Vector3 (
                 RoundForGrid(transform.position.x),
                 RoundForGrid(transform.position.y),
                 RoundForGrid(transform.position.z)
             );
         }
     } 
  1. 让我们使用一个编辑器脚本来向 游戏对象 菜单添加一个新的菜单项。创建一个名为 Editor 的文件夹,并在该文件夹中创建一个名为 EditorGridGizmoMenuItem 的新 C# 脚本类,其中包含以下内容:
    using UnityEngine;
     using UnityEditor;
     using System.Collections;

     public class EditorGridGizmoMenuItem : Editor {
         [MenuItem("GameObject/Create New Snapgrid", false, 10000)]
         static void CreateCustomEmptyGameObject(MenuCommand menuCommand) {
             GameObject gameObject = new GameObject("___snap-to-grid___");

             gameObject.transform.parent = null;
             gameObject.transform.position = Vector3.zero;
             gameObject.AddComponent<GridGizmo>();        
         }
     } 
  1. 现在,让我们添加另一个用于 GridGizmo 组件的自定义检查器显示(和更新器)的 编辑器 脚本。同样,在您的 编辑器 文件夹中,创建一个名为 EditorGridGizmo 的新 C# 脚本类,其中包含以下内容:
    using UnityEngine;
     using UnityEditor;
     using System.Collections;

     [CustomEditor(typeof(GridGizmo))]
     public class EditorGridGizmo : Editor {
         private GridGizmo gridGizmoObject;
         private int grid;
         private Color gridColor;
         private int numLines;
         private int lineLength;

         private string[] gridSizes = {
             "1", "2", "3", "4", "5"
         };

         void OnEnable() {
             gridGizmoObject = (GridGizmo)target;
             grid = serializedObject.FindProperty("grid").intValue;
             gridColor = serializedObject.FindProperty("gridColor").colorValue;
             numLines = serializedObject.FindProperty("numLines").intValue;
             lineLength = serializedObject.FindProperty("lineLength").intValue;
         }

         public override void OnInspectorGUI() {
             serializedObject.Update ();

             int gridIndex = grid - 1;
             gridIndex =  EditorGUILayout.Popup("Grid size:",  gridIndex, gridSizes);                
             gridColor = EditorGUILayout.ColorField("Color:", gridColor);
             numLines =  EditorGUILayout.IntField("Number of grid lines",  numLines);
             lineLength =  EditorGUILayout.IntField("Length of grid lines",  lineLength);

             grid = gridIndex + 1;
             gridGizmoObject.SetGrid(grid);  
             gridGizmoObject.gridColor = gridColor;
             gridGizmoObject.numLines = numLines;    
             gridGizmoObject.lineLength = lineLength;    
             serializedObject.ApplyModifiedProperties ();
             sceneView.RepaintAll();
         }        
     } 
  1. 通过选择菜单:游戏对象 | 创建新吸附网格,将一个新的 GizmoGrid 游戏对象添加到场景中。应该添加一个名为 ___ 吸附网格 ___ 的新游戏对象到层次结构中:

  1. 选择 GameObject snap-to-grid,并在检查器中修改其一些属性。您可以更改网格大小、网格线的颜色、线条数量及其长度:

  1. 通过选择菜单:创建 | 3D 对象 | 立方体来创建一个3D 立方体。现在,将3D 立方体拖动到层次结构中,并将其作为 GameObject snap-to-grid 的子对象。

  2. 我们现在需要一个小的脚本类,以便每次 GameObject 在编辑器模式中移动时,它都会请求其位置通过父脚本组件SnapToGizmoGrid进行自动对齐。创建一个名为SnapToGizmoGrid的 C#脚本类,并将其作为组件添加到3D 立方体

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

     [ExecuteInEditMode]
     public class SnapToGridGizmo : MonoBehaviour {
         public void Update()
         {
     #if UNITY_EDITOR
             transform.parent.GetComponent<GridGizmo>().SnapPositionToGrid(transform);
     #endif
         }
     } 
  1. 在场景中随机分布大量 3D 立方体的副本——你会发现它们会自动对齐到网格。

  2. 再次选择 GameObject snap-to-grid,并在检查器中修改其一些属性。您会看到更改立即在场景中可见,并且所有具有SnapToGizmoGrid脚本组件的子对象都会自动对齐到任何新的网格大小更改。

它是如何工作的...

EditorGridGizmoMenuItem脚本类向 GameObject 菜单添加一个新项目。当选择时,会在层次结构中添加一个新的 GameObject,命名为 snap-to-grid,位置在(0, 0, 0),并包含GridGizmo脚本类的实例对象组件。

GridGizmo根据公共属性(网格大小、颜色、线条数量和线条长度)绘制 2D 网格。关于SetGrid(...)方法,以及更新整数网格大小变量 grid,它还会调用SnapAllChildren()方法,因此每次更改网格大小,所有子 GameObject 都会自动对齐到新的网格位置。

SnapToGridGizmo脚本类包含一个Editor属性[ExecuteInEditMode],这样当在编辑器中设计时更改其属性时,它将接收到Update()消息。每次调用Update()时,它都会在其父GridGizmo实例对象中调用SnapPositionToGrid(...)方法,以便其位置根据当前网格设置自动对齐。为了确保此逻辑和代码不会编译到任何最终游戏构建中,Update()的内容被包裹在#if UNITY_EDITOR编译器测试中。在为最终游戏编译构建之前,此类内容会被移除。

EditorGridGizmo脚本类是一个自定义编辑器检查器组件。这允许控制哪些属性在检查器中显示,如何显示,并且允许在更改任何值时执行操作。例如,在更改保存后,sceneView.RepaintAll()语句确保网格被重新显示,因为它会导致发送OnDrawGizmos()消息。

创建 VR 项目

在这个菜谱中,我们将通过在 Unity 中设置基本 VR 场景的步骤,使用 Windows 计算机上的 Vive VR 头戴设备。

准备工作

你需要设置站立模式或房间规模的 Steam VR。如果你还没有这样做,请按照以下步骤设置你的 Vive 头戴式设备,以便它为 Unity 游戏开发做好准备:

  1. 安装 Steam

  2. 安装 Steam VR

  3. 连接你的 Vive 头戴式设备

  4. 从 Steam 应用程序窗口中运行 Steam VR(点击 Steam 应用窗口右上角的 VR):

图片

  1. Steam VR 应该运行。然后,选择运行房间设置菜单项:

图片

  1. 将你的灯塔放置在你想使用的房间空间内。

  2. 从房间设置屏幕中选择你的房间设置:站立模式或房间规模:

    • 站立模式:

      • 使用头戴式设备设置中心

      • 使用头戴式设备定位地板

    • 房间规模:

      • 将你的灯塔放置在你想使用的空间内

      • 通过将手控制器放在地板上来校准地板

      • 在房间内走动,使用带有扳机的手控制器绘制你可以安全移动的空间

  3. 你现在可以探索 Steam VR 主界面。

如何做到这一点...

要创建一个基本的 VR Unity 项目,请按照以下步骤操作:

  1. 开始一个新的 3D 项目。

要么使用一个新的 3D Unity 项目,要么备份你即将添加 VR 功能的任何项目,因为你将添加一个会更改许多设置的包,这可能会破坏现有项目的设置。

  1. 从场景中删除主相机。

  2. 通过选择菜单:编辑 | 项目设置 | 玩家来显示 Unity Player 设置:

  3. 在检查器中检查虚拟现实支持选项:

图片

  1. 确保你已经登录到你的 Unity 账户(在访问资产商店之前)。

  2. 访问资产商店并从 Valve 公司搜索 Steam VR:

图片

  1. 下载并导入包(导入前会警告你备份...)。

  2. 从任何弹出窗口中选择你喜欢的选项:

图片

  1. 将从文件夹:项目 | SteamVR | 预制件中的[CameraRig]预制件的副本拖入场景。你会看到一个代表你的房间设置的 3D 空间(我们的示例显示了站立模式房间设置的基于矩形的空間):

图片

  1. 运行场景,戴上你的 VR 头戴式设备,拿起你的手控制器。你应该在虚拟空间中看到你的手控制器位置、扳机设置等的 3D 表示。

  2. 在你的场景中导入/创建 3D 对象 - 例如,将3D 立方体添加到 CameraRig 房间空间内的场景中。

  3. 运行场景,尝试移动虚拟手控制器与 3D 立方体碰撞:

图片

  1. 通过向 3D 立方体(并关闭重力)添加刚体组件来测试物理,将 Box Collider 添加到 GameObject Controller(右侧)并缩小。

  2. 运行场景 - 您应该能够使用虚拟手控制器推动3D 立方体

它是如何工作的...

您已经学会了设置 Vive 并定位和安装 Steam VR 软件包。

此软件包包含预制件,以便头戴设备和手持设备在您设置的站立/房间空间中工作。

您创建了一个包含 Vive 预制件的 Unity 项目。然后您向场景中添加了一个 3D 立方体,并通过向立方体添加碰撞器和手控制器 GameObject 与之交互。

这是在 Unity 2017.4.9 LTS 上测试的,因为当时它无法与 2018 版本完全兼容。

还有更多...

这里有一些进一步改进此菜谱的建议。

探索免费的 VR/XR 样本/教程

一些优秀的免费样本 VR 项目资源包括以下:

使用 Oculus Rift 进行设置

Oculus Rift 的设置与 Vive 类似,尽管它实际上与 Unity 的集成更好。您需要执行以下操作:

  1. 安装 Oculus 运行时。

  2. 设置红外摄像头所需的房间/站立模式。

  3. 无需下载任何包,也无需删除主相机。

您可以在 Oculus Rift Unity 文档网站上了解更多信息:developer.oculus.com/documentation/unity/latest/concepts/book-unity-gsg/

如果使用轻量级渲染管线,则使用单次遍历

如果您使用的是轻量级渲染脚本管线,则在设置 XR 时,在设置中为RenderingMethod*属性选择单次遍历,在菜单中选择:编辑 | 项目设置 | 玩家:

将 360 度视频添加到 VR 项目中

Google Earth VR 非常有趣!这张截图来自一个实时 VR 会话,显示了虚拟手控制器和一个虚拟屏幕菜单,显示有关 6 个建议访问地点的照片和文本:

经济实惠的 360 度摄像头意味着您可以轻松创建自己的,或者找到免费的在线 360 度图像和视频片段。在本教程中,我们将学习如何在 VR 项目中添加 360 度视频片段作为天空盒。您还将了解 360 度视频片段如何播放 3D 对象表面,包括球体的内部——有点像谷歌地球****VR模式,当您将球体举到头上查看其 360 度图像内容时:

图片

准备中

对于本教程,我们在16_07文件夹中提供了一个简短的Snowboarding_Polar.mp4视频。本项目基于上一个项目(一个基本的 VR 项目),因此复制该项目并在此基础上工作。

特别感谢 Kris Szczurowski 允许使用他的滑雪 360 度视频片段,并帮助这些 VR 项目教程。祝您博士学业顺利!

如何做...

要将 360 度视频添加到 VR 项目,请按照以下步骤操作:

  1. 将您的 360 度极地格式视频片段导入 Unity 项目(在我们的示例中,这是Snowboarding_Polar.mp4)。

  2. 在项目面板中选择视频资产,并在检查器中记下其分辨率(我们稍后会用到),例如,2,560 x 1,280:

图片

  1. 通过选择菜单:创建 | 空对象创建一个名为 video-player 的新空 GameObject。

  2. 在层级中选择视频播放器,并在检查器中,通过选择:添加组件 | 视频 | 视频播放器来添加视频播放器组件。

  3. 从项目面板中,将您的视频资产文件(例如,Snowboarding_Polar)拖动到视频播放器组件的视频片段属性中。

  4. 通过选择菜单:创建 | 渲染纹理创建一个名为VideoRenderTexture的新渲染纹理资产文件。

  5. VideoRenderTexture的分辨率设置为与视频资产分辨率匹配,例如,2,560 x 1,280:

图片

  1. 在层级中选择 GameObject 视频播放器,并为视频播放器组件设置目标纹理为VideoRenderTexture.

  2. 通过选择菜单:创建 | 材质创建一个名为video_m的新材质。

  3. 在项目面板中选择video_m,将其着色器更改为天空盒 | 全景:

图片

  1. 在检查器中,对于球形 HDR 属性,点击选择按钮并选择纹理VideoRenderTexture:

图片

  1. 打开照明设置面板,选择菜单:窗口 | 渲染 | 照明设置。在检查器中,将天空盒材质设置为video_m:

图片

  1. 播放场景,戴上您的 VR 头盔,您应该会看到 360 度视频在您周围播放。

它是如何工作的...

您已创建了一个具有视频播放器组件的 GameObject,并使其播放您的 360 度视频。您使此组件渲染与您的视频相同尺寸的渲染纹理。

您创建了一个 Skybox-panomaric 材料,并将您的渲染纹理作为此材料的纹理。然后,您将此材料设置为光照设置的 Skybox。

这是在 Unity 2017.4.9 LTS 上测试的,因为当时它还没有完全与 2018 版本兼容。

还有更多...

这里有一些进一步改进这个菜谱的建议。

在 3D 对象表面上播放 360 度视频

要在 3D 对象表面上播放 360 度视频,执行上述步骤,但不要将 Skybox 设置为video_m。相反,将您的 3D 对象的 Mesh Renderer 组件的材料设置为video_m

图片

这适用于具有反转法线的 3D 对象,例如,一个可以从内部观看的空心 3D 球体。

在 VR 环境中处理 VR 内容——XR 编辑器

Unity 的一个令人兴奋的项目是 XR-Editor,这是一个 VR 环境,允许您在 VR 中编辑场景。该项目提供了 VR UI 元素的优秀示例,包括 3D 菜单和激光指针选择器。它允许您在环境中查看控制台报告并与 GameObject 的层次结构交互:在这个屏幕截图中,我们可以看到主工作区菜单显示在虚拟左手控制器上,而虚拟右手控制器被展示为一个旋转菜单,用于执行可以对当前选定的 GameObject 执行的操作。

在这个旋转菜单中当前选中了“选择父项”选项:

图片

在这个菜谱中,我们将设置 XR-Editor 以在 Unity 项目中使用。

准备工作

从上一个菜谱中创建的基本 VR 项目的副本开始这个菜谱。

如何做到这一点...

在 XR 编辑器中,要在 VR 环境中处理 VR 内容,请按照以下步骤操作:

  1. 通过选择菜单:窗口 | 包管理器,安装 TextMeshPro 包,并选择 Text Mesh Pro。

  2. 从 Unity 博客页面上的链接下载 Editor XR 包:

  3. 将 EditorXR-package 包导入到您的项目中。

  4. 同意屏幕上的提示以修补输入管理器设置:

图片

  1. 应该在窗口菜单中有一个新的项目,窗口 | EditorXR。选择此菜单项:

图片

  1. 现在应该创建了一个新的、浮动的 EditorXR 应用程序窗口:

图片

  1. 穿上您的 VR 头盔并开始创作。不要运行场景——Editor XR 在设计时间工作,而不是运行时间,所以不要播放场景。

  2. 您可以通过将一个虚拟手控制器上的激光指针指向另一个手控制器上的菜单栏来显示主菜单:

图片

  1. 你将获得一个类似立方体、多面且可旋转的主菜单,你可以用激光指针选择其中的项目。例如,在菜单对象的“工作空间”部分,有显示控制台层次结构检查器锁定对象迷你世界性能分析器面板的选项:

图片

  1. 你可以选择“原语”菜单,在那里你可以创建新的 3D 对象:

图片

  1. 当选择一个 GameObject 时,一个轮式菜单提供了一系列操作,例如删除或选择父对象,等等:

图片

它是如何工作的...

在这个教程中,你学习了如何安装并开始与 Unity 的 Editor-XR 交互。

这是在 Unity 2017.4.9 LTS 版本上测试的,因为在写作时它还不能完全与 2018 版本兼容。

你可以在 Unity 博客上了解更多关于 Editor XR/VR 的信息:blogs.unity3d.com/2016/12/15/editorvr-experimental-build-available-today/.

第十九章:自动化测试

在本章中,我们将涵盖以下内容:

  • 生成和运行默认测试脚本类

  • 执行一个简单的单元测试

  • 使用数据提供者方法参数化测试

  • 单元测试一个简单的健康脚本类

  • 在 PlayMode 中创建和执行单元测试

  • 在 PlayMode 中测试门动画

  • 使用事件、日志和异常在 PlayMode 测试玩家健康条

简介

对于一个非常简单的计算机程序,我们可以编写代码,然后运行它,输入各种有效和无效的数据,看看程序是否按预期运行。这被称为先编码后测试的方法。然而,这种方法有几个显著的弱点:

  • 每次我们更改代码,以及运行与我们要改进的代码相关的新测试时,我们必须运行所有旧测试,以确保没有引入意外的修改行为(换句话说,我们的新代码没有破坏程序的其他部分)

  • 手动运行测试很耗时

  • 我们依赖于人类在每次重新运行测试,而这个测试可能使用不同的数据,或者可能省略某些数据,或者不同的团队成员可能采取不同的测试方法

因此,即使是简单的程序(大多数都不是简单的),某种快速、自动化的测试系统也很有意义。

整体图景

有一种软件开发方法称为测试驱动开发TDD),其中代码只有在所有测试通过后才会编写。因此,如果我们想添加或改进游戏程序的行为,我们必须以测试的形式指定我们想要的内容,然后程序员编写代码以通过测试。这避免了程序员编写不需要的代码或特征,或者花费时间过度优化本来可以过得去的事情等情况。这意味着游戏开发团队将工作方向指向所有成员都理解的目标,因为这些目标已经被指定为测试。

以下图表说明了基本的 TDD,即我们只编写代码直到所有测试通过。然后是编写更多测试的时间:

TDD(测试驱动开发)经常被总结为红-绿-重构:

  • 红色:我们编写会失败测试的代码(换句话说,对于我们希望添加到系统中或改进的新功能/行为)

  • 绿色:我们编写通过新测试(以及所有现有测试)的代码

  • 重构:我们(可能)选择改进代码(并确保改进后的代码通过所有测试)

以下两种软件测试类型如下:

  • 单元测试

  • 集成测试

单元测试

单元测试测试代码的“单元”,这可能是一个单独的方法,但也可能包括在测试的方法和检查最终结果之间执行的一些其他计算机工作。

“单元测试是一段代码,它调用一个工作单元并检查该工作单元的一个特定结果。如果对最终结果的假设被证明是错误的,则单元测试失败。”

——罗伊·奥舍格罗夫(第 5 页,《单元测试的艺术》(第二版)。

单元测试应该是这样的:

  • 自动化(一键运行)

  • 快速

  • 容易实现

  • 容易阅读

  • 独立执行(测试应相互独立)

  • 被评估为通过或失败

  • 相关内容将在明天

  • 一致(每次都得到相同的结果!)

  • 容易确定每个失败的测试中出了什么问题

大多数计算机语言都提供了 xUnit 单元测试系统,例如:

  • C#: NUnit

  • Java: JUnit

  • PHP: PHPUnit

Unity 提供了一种简单的方法在编辑器中(以及在命令行)编写和执行 NUnit 测试。

通常,每个单元测试都会分为三个部分,一个序列:

  • 准备:设置任何需要的初始值(有时,我们只是给一个变量赋值以提高代码可读性)

  • 行动:调用一些代码(如果适当,存储结果)

  • 断言:对被调用的代码(以及任何存储的结果)进行断言

注意到单元测试方法的命名(按照惯例)相当冗长——它由许多描述其功能的单词组成。例如,你可能有一个名为TestHealthNotGoAboveOne()的单元测试方法。其理念是,如果测试失败,测试的名称应该给程序员一个非常好的关于正在测试的行为的想法,因此可以快速确定测试是否正确,如果正确,则在程序代码中查找被测试的内容。单元测试命名的另一个惯例是不使用数字——只使用单词——因此我们在测试方法的名称中写“一”、“二”等等。

集成测试(Unity 中的 PlayMode 测试)

集成测试涉及检查交互式软件组件的行为,例如,使用实时、真实文件系统或与网络或其他在计算机上运行的应用程序通信的组件。集成测试通常不如单元测试快,并且可能不会产生一致的结果(因为组件可能在不同的时间以不同的方式交互)。

单元测试集成测试都很重要,但它们是不同的,应该被不同地对待。

Unity 提供了Play Mode测试,允许在 Unity 场景执行时进行集成测试。

你可以了解更多关于 Unity 测试的地方包括以下:

生成默认测试脚本类

Unity 可以为你创建一个默认的 C#测试脚本,从而让你能够快速开始创建和执行项目中的测试:

图片

如何做到这一点...

要生成默认测试脚本类,请按照以下步骤操作:

  1. 在项目面板中,创建一个名为“Editor”的文件夹

  2. 通过选择以下菜单显示测试运行器面板:窗口 | 通用 | 测试运行器

  3. 确保在测试运行器面板中选中了“编辑模式”按钮

  4. 确保在项目面板中选中了你的新编辑器文件夹

  5. 在测试运行器面板中,点击当前文件夹中的“创建测试脚本”按钮

  6. 你现在应该在编辑器文件夹中添加了一个新的 C#脚本

  7. 要在脚本类中运行测试,请点击测试运行面板中的“运行所有”按钮

  8. 你现在应该能在面板中看到所有的绿色勾号(勾选)

它是如何工作的...

Unity 会检查你在项目面板中是否选中了名为“编辑器”的文件夹,然后为你创建一个包含以下内容的 C# NewTestScript 脚本类:

    using UnityEngine;
     using UnityEngine.TestTools;
     using NUnit.Framework;
     using System.Collections;

     public class NewTestScript {
         [Test]
         public void NewTestScriptSimplePasses() {
             // Use the Assert class to test conditions.
         }

         // A UnityTest behaves like a coroutine in PlayMode
         // and allows you to yield null to skip a frame in EditMode
         [UnityTest]
         public IEnumerator NewTestScriptWithEnumeratorPasses() {
             // Use the Assert class to test conditions.
             // yield to skip a frame
             yield return null;
         }
     } 

在测试运行器面板中,你应该能看到脚本类及其两个方法列出。注意,测试运行器面板的第一行是 Unity 项目名称,第二行将显示Assembly-CSharp-Editor.dll,然后是你的脚本类名称,然后是每个测试方法:

图片

每个测试/类的状态都有三个符号表示:

  • 空圆圈:自脚本类上次更改以来未执行测试

  • 绿色勾号(勾选):测试成功通过

  • 红十字会:测试未通过

还有更多...

这里有一些你不想错过的细节。

从项目面板的创建菜单创建默认测试脚本

创建默认单元测试脚本的其他方法如下:

  • 从项目面板中选择以下菜单:创建 | 测试 | C# 测试脚本

编辑模式最小单元测试脚本框架

请注意,如果您只打算使用此脚本类在 EditMode 中进行测试,您可以删除第二个方法和一些使用语句,如下所示,以便为您提供一个最小的工作框架:

    using NUnit.Framework;

     public class UnitTestSkeleton
     {
         [Test]
         public void NewTestScriptSimplePasses()
         {
             // write your assertion(s) here
         }
     } 

简单的单元测试

就像打印 "hello world" 是大多数程序员的第一条程序语句一样,断言 1 + 1 = 2 可能是那些学习单元测试的人执行的最常见的第一个测试。这就是我们在本食谱中要创建的内容:

如何做...

要创建和执行一个简单的单元测试,请按照以下步骤操作:

  1. 在项目面板中,创建一个名为 Editor 的文件夹。

  2. 在您的编辑器文件夹中,创建一个新的 C# SimpleTester.cs 脚本类,包含以下内容:

    using NUnit.Framework;

     class SimpleTester
     {
         [Test]
         public void TestOnePlusOneEqualsTwo()
         {
             // Arrange
             int n1 = 1;
             int n2 = 1;
             int expectedResult = 2;

             // Act
             int result = n1 + n2;

             // Assert
             Assert.AreEqual(expectedResult, result);
         }
     } 
  1. 通过选择以下菜单显示测试运行器面板:窗口 | 通用 | 测试运行器。

  2. 确保测试运行器面板中的 EditMode 按钮被选中。

  3. 点击运行所有。

  4. 您应该看到您的单元测试执行的结果——如果测试成功完成,它旁边应该有一个绿色的勾号。

它是如何工作的...

您已声明 C# SimpleTester.cs 脚本类中的 TestOnePlusOneEqualsTwo() 方法是一个测试方法。当执行此测试方法时,Unity 测试运行器会按顺序执行每个语句,因此变量 n1n2expectedResult 被设置,然后计算 1 + 1 的结果存储在变量 result 中,最后(最重要的一点),我们断言执行该代码后应该为真的值。我们的断言表明 expectedResult 变量的值应该等于变量 result 的值。

如果断言为真,则测试通过,否则失败。通常,作为程序员,我们期望我们的代码通过,因此我们会非常仔细地检查每个失败,首先看看是否有明显的错误,然后可能检查测试本身是否正确(特别是如果它是一个新测试),然后开始调试并理解为什么我们的代码以这种方式表现,没有产生预期的结果。

更多内容...

这里有一些您不想错过的细节。

简短的测试,其中包含断言的值

对于简单的计算,一些程序员更喜欢通过直接将值放入断言中来编写更少的测试代码。所以,如下所示,我们的 1 + 1 = 2 测试可以表达为一个单一的断言,其中预期的值 2 和表达式 1 + 1 直接输入到 AreEqual(...) 方法调用中:

    using NUnit.Framework;

     class SimpleTester
     {
         [Test]
         public void TestOnePlusOneEqualsTwo()
         {
             // Assert
             Assert.AreEqual(2, 1 + 1);
         }
     } 

然而,如果您是测试的新手,您可能更喜欢先前的方法,其中准备、代码执行、结果存储以及关于这些结果的属性断言都清晰地按顺序排列在 Arrange/Act/Assert 序列中。

预期值后跟实际值

在使用断言比较值时,通常先给出预期的(正确)值,然后是实际值:

    Assert.AreEqual( <expectedValue>, <actualValue> ); 

虽然它对等式的真伪性质没有影响,等等,但在某些测试框架中测试失败时的消息可能会有所不同(例如,“得到 2 但期望 3”与“得到 3 但期望 2”有非常不同的含义)。因此,以下断言会输出一个可能会令人困惑的消息,因为 2 是我们期望的结果:

    public void TestTwoEqualsThreeShouldFail() {
         // Arrange
         int expectedResult = 2;

         // Act
         int result = 1 + 2; // 3 !!!

         // Assert
         Assert.AreEqual(result, expectedResult);
     } 

参考以下截图:

图片

使用数据提供程序方法参数化测试

如果我们使用一系列测试数据来测试我们的代码,那么除了值之外,每个测试之间可能几乎没有区别。我们不必重复我们的 Arrange/Act/Assert 语句,可以重用单个方法,Unity 测试运行器将遍历测试数据集合,为每组测试数据运行测试方法。向测试方法提供多组测试数据的方法称为数据提供程序,我们将在本食谱中创建一个:

图片

如何做...

要使用数据提供程序方法参数化测试,请按照以下步骤操作:

  1. 在项目面板中,创建一个名为 Editor 的文件夹。

  2. 在您的编辑器文件夹中,创建一个新的 C# DataProviderTester.cs 脚本类,包含以下内容:

    using NUnit.Framework;

     class DataProviderTester
     {
         [Test, TestCaseSource("AdditionProvider")]
         public void TestAdd(int num1, int num2, int expectedResult)
         {
             // Arrange
             // (not needed - since values coming as arguments)

             // Act
             int result = num1 + num2;

             // Assert
             Assert.AreEqual(expectedResult, result);
         }

         // the data provider
         static object[] AdditionProvider =
         {
             new object[] { 0, 0, 0 },
             new object[] { 1, 0, 1 },
             new object[] { 0, 1, 1 },
             new object[] { 1, 1, 2 }
         };
     } 
  1. 通过选择以下菜单显示测试运行器面板:窗口 | 通用 | 测试运行器。

  2. 确保在测试运行器面板中选择 EditMode 按钮。

  3. 点击运行全部。

  4. 您应该看到单元测试的执行结果。您应该看到TestAdd(...)测试方法的四组结果,每组对应于AdditionProvider方法提供的每个数据集。

它是如何工作的...

我们已经指出TestAdd(...)方法是一个具有编译器属性[Test]的测试方法。然而,在这种情况下,我们添加了额外的信息来声明此方法的数据源是AdditionProvider方法。

这意味着 Unity 测试运行器将从附加提供程序中检索数据对象,并为TestAdd(...)方法创建多个测试,每个测试对应于AdditionProvider()方法中的每组数据。

在测试运行器面板中,我们可以看到每个测试的行:

    TestAdd(0,0,0)
     TestAdd(1,0,1)
     TestAdd(0,1,1)
     TestAdd(1,1,2) 

单元测试简单的健康脚本类

让我们创建一些可能在游戏中使用,并且可以轻松进行单元测试的东西。不继承自 Monobehavior 的类更容易进行单元测试,因为可以使用关键字 new 创建实例对象。如果类经过精心设计,具有私有数据和具有明确声明依赖性参数的公共方法,那么编写一组测试来让我们有信心,这个类的对象在默认值以及有效和无效数据方面将按预期行为就变得容易了。

在这个菜谱中,我们将创建一个健康脚本类,以及对这个类的一系列测试。这种类可以用于游戏中的玩家健康,也可以用于人工智能(AI)控制的敌人:

图片

如何做到这一点...

要对健康脚本类进行单元测试,请按照以下步骤操作:

  1. 在项目面板中,创建一个 _Scripts 文件夹。

  2. 在你的 _Scripts 文件夹中,创建一个新的 C# Health.cs脚本类,包含以下内容:

    using UnityEngine;
     using System.Collections;

     public class Health
     {
         private float health = 1;

         public float GetHealth()
         {
             return health;
         }

         public bool AddHealth(float heathPlus)
         {
             if(heathPlus > 0){
                 health += heathPlus;
                 return true;
             } else {
                 return false;
             }
         }

         public bool KillCharacter()
         {
             health = 0;
             return true;
         }
     } 
  1. 在你的 _Scripts 文件夹中,创建一个名为 Editor 的新文件夹。

  2. 在你的编辑器文件夹中,创建一个新的 C# TestHealth.cs脚本类,包含以下内容:

using NUnit.Framework;

class TestHealth {
   [Test]
   public void TestReturnsOneWhenCreated()   {
      // Arrange
      Health h = new Health ();
      float expectedResult = 1;

      // Act
      float result = h.GetHealth ();

      // Assert
      Assert.AreEqual (expectedResult, result);
   }     

    [Test]
    public void TestPointTwoAfterAddPointOneTwiceAfterKill()    {
        // Arrange
        Health h = new Health();
        float healthToAdd = 0.1f;
        float expectedResult = 0.2f;

        // Act
        h.KillCharacter();
        h.AddHealth(healthToAdd);
        h.AddHealth(healthToAdd);
        float result = h.GetHealth();

        // Assert
        Assert.AreEqual(expectedResult, result);
    }

    [Test]
    public void TestNoChangeAndReturnsFalseWhenAddNegativeValue()     {
        // Arrange
        Health h = new Health();
        float healthToAdd = -1;
        bool expectedResultBool = false;
        float expectedResultFloat = 1;

        // Act
        bool resultBool = h.AddHealth(healthToAdd);
        float resultFloat = h.GetHealth();

        // Assert
        Assert.AreEqual(expectedResultBool, resultBool);
        Assert.AreEqual(expectedResultFloat, resultFloat);
    }

    [Test]
    public void TestReturnsZeroWhenKilled()    {
        // Arrange
        Health h = new Health();
        float expectedResult = 0;

        // Act
        h.KillCharacter();
        float result = h.GetHealth();

        // Assert
        Assert.AreEqual(expectedResult, result);
    }

    [Test]
    public void TestHealthNotGoAboveOne()    {
        // Arrange
        Health h = new Health();
        float expectedResult = 1;

        // Act
        h.AddHealth(0.1f);
        h.AddHealth(0.5f);
        h.AddHealth(1);
        h.AddHealth(5);
        float result = h.GetHealth();

        // Assert
        Assert.AreEqual(expectedResult, result);
    }
}
  1. 通过选择以下菜单来显示测试运行器面板:窗口 | 调试 | 测试运行器。

  2. 确保在测试运行器面板中选择了 EditMode 按钮。

  3. 点击运行全部。

  4. 你应该能看到你的单元测试执行的结果。

它是如何工作的...

下面将描述每个 C#脚本类。

脚本类 Health.cs

这个脚本类有一个私有属性;由于它是私有的,只能通过方法来更改。它的初始值是 1.0,换句话说,100%的健康值:

  • health (float): 有效范围是从 0(死亡!)到 1.0(100%健康)

有 3 个公共方法:

  • GetHealth(): 这个方法返回当前健康浮点数的值(应该在 0 和 1.0 之间)

  • AddHealth(float): 这个方法接受一个浮点数(要添加到健康值中的数量),并返回一个布尔值 true/false,表示值是否有效。注意这个方法的逻辑是接受 0 或更多的值(并将返回 true),但它将确保健康值的值永远不会超过 1

  • KillCharacter(): 这个方法将健康值设置为 0,并返回 true,因为在这个动作中总是成功的

脚本类 TestHealth.cs

这个脚本类有五个方法:

  • TestReturnsOneWhenCreated(): 这个测试检查当创建一个新的 Health 对象时,健康值的初始值是 1。

  • TestPointTwoAfterAddPointOneTwiceAfterKill(): 这个测试检查在杀死(健康值设置为 0)后,两次添加 0.1,健康值应该是 0.2。

  • TestReturnsZeroWhenKilled(): 这个测试检查在调用KillCharacter()方法后,健康值应立即设置为 0。

  • TestNoChangeAndReturnsFalseWhenAddNegativeValue(): 这个测试检查尝试向健康值添加负数时应该返回 false,并且健康值不应该发生变化。这个方法是一个具有多个断言(但都与操作相关)的测试示例。

  • TestHealthNotGoAboveOne(): 这个测试验证即使向健康值添加了很多值,总数超过 1.0,GetHealth()返回的值也是 1。

希望当你运行它们时,所有的测试都能通过,这会让你对Health.cs脚本类中的逻辑实现是否按预期工作有一定的信心。

在游戏模式中创建和执行单元测试

将游戏的大部分逻辑编写为隔离的、非 Monobehavior 类,这些类在编辑模式下易于单元测试。然而,游戏中的某些逻辑与游戏运行时发生的事情有关。例如,包括物理、碰撞和基于时间的活动。我们在 Play Mode 测试这些游戏的部分。

在这个菜谱中,我们将创建一个非常简单的 Play Mode 测试,以检查物理对 RigidBody 的影响(基于 Unity 文档中的示例):

图片

如何做...

要在Play模式下创建和执行单元测试,请按照以下步骤操作:

  1. 通过选择以下菜单显示测试运行器面板:窗口 | 通用 | 测试运行器

  2. 为所有汇编启用 PlayMode 测试。通过显示测试运行器面板右上角的下拉菜单,然后选择为所有汇编启用 Playmode 测试(对任何有关重新启动编辑器的消息点击确定):

图片

  1. 现在,重新启动 Unity 编辑器(只需关闭应用程序,然后使用项目重新打开它)。

在启用 PlayMode 后重新启动 Unity 编辑器应用程序非常重要。如果你没有这样做,那么你可能无法找到你的 PlayMode 测试脚本类,它们可以看到(并引用)你的 Monobehavior 类。

  1. 确保在测试运行器面板中选择了 PlayMode 按钮。

  2. 在测试运行器面板中,点击创建 PlayMode 测试汇编文件夹按钮。应该已经创建了一个名为 Tests 的新文件夹。

  3. 在项目面板中,打开Tests文件夹。它应该包含一个汇编定义文件 Tests.asmdef。

  4. 在测试运行器面板中,点击当前文件夹中创建测试脚本按钮 – 你可能希望将此脚本重命名为默认名称,NewTestScript

  5. 编辑你的新测试脚本,将内容替换为以下内容:

    using UnityEngine;
     using UnityEngine.TestTools;
     using NUnit.Framework;
     using System.Collections;

     public class NewTestScript
     {
         [UnityTest]
         public IEnumerator GameObject_WithRigidBody_WillBeAffectedByPhysics()
         {
             // Arrange
             var go = new GameObject();
             go.AddComponent<Rigidbody>();
             var originalPosition = go.transform.position.y;

             // Act
             yield return new WaitForFixedUpdate();

             // Assert
             Assert.AreNotEqual(originalPosition, go.transform.position.y);
         }
     } 
  1. 点击运行全部。

  2. 在层次结构中,你会看到创建了一个临时场景(命名为类似于 InitTestScene6623462364 的东西),并且创建了一个名为基于代码的测试运行器的 GameObject。

  3. 在游戏面板中,你会短暂地看到消息 显示 1 无相机渲染。

  4. 你应该看到你的单元测试执行的结果——如果测试成功完成,它旁边应该有一个绿色的勾号。

它是如何工作的...

带有[UnityTest]属性的函数作为协程运行。协程有暂停执行(当遇到 yield 语句时)并将控制权返回给 Unity 的能力,但然后在再次调用时从上次离开的地方继续(例如,下一帧、第二帧或任何其他)。yield 语句表示在哪个语句之后以及暂停执行多长时间。不同类型的 yield 的例子包括:

  • 等待直到下一帧:null

  • 等待给定的时间长度:WaitForSeconds(<seconds>)

  • 等待下一个固定更新时间周期(由于帧率不同,物理不会在每一帧应用):WaitForFixedUpdate()

方法GameObject_WithRigidBody_WillBeAffectedByPhysics()创建一个新的 GameObject,并将其附加一个 RigidBody。它还存储了原始的 Y 位置。yield 语句使 PlayMode 测试运行器等待下一个固定更新周期开始时物理开始。最后,断言原始 Y 位置不等于新的 Y 位置(在物理固定更新之后)。由于 RigidBody 的默认设置是应用重力,这是一个很好的测试,以验证物理是否被应用于新对象(换句话说,一旦应用了物理,它应该已经开始下落)。

测试门动画的 PlayMode

在上一个食谱中学习了 PlayMode 测试的基础知识后,现在让我们测试一下在游戏中可能会遇到的不*凡内容。在这个食谱中,我们将创建一个 PlayMode 测试,以确保当玩家的球体对象进入碰撞体时,门开启动画能够播放。

提供了一个场景,玩家的球体初始化为向红色门滚动。当球体击中碰撞体(OnTriggerEnter事件)时,一些代码将门的 Animator Controller Opening 变量设置为 true,从而将门从关闭状态过渡到开启状态,如以下截图所示:

图片

应感谢地面纹理的创作者;它由 Starline 设计,并在Freepik.com发布。

准备工作

对于这个食谱,已经在19_06文件夹中提供了一个 Unity 包 (doorScene.unitypackage)

如何操作...

要测试门动画的 PlayMode,请按照以下步骤操作:

  1. 创建一个新的 Unity 项目,并删除默认的 Scenes 文件夹。

  2. 导入提供的 Unity 包 (doorScene.unitypackage)。

  3. 将以下场景 doorScene 和 menuScene 添加到项目构建中(顺序无关紧要)。

  4. 确保当前打开的场景是 menuScene。

  5. 通过选择以下菜单显示测试运行器面板:窗口 | 通用 | 测试运行器

  6. 为所有程序集启用 Playmode 测试。通过在测试运行器面板右上角显示下拉菜单,并选择“为所有程序集启用 Playmode 测试”(点击 OK 以确认任何有关重启编辑器的消息)。

  7. 现在重新启动 Unity 编辑器(只需关闭应用程序,然后使用项目重新打开它)。

  8. 确保在测试运行器面板中选择了 PlayMode 按钮。

  9. 在项目面板中,选择顶级文件夹 Assets。

  10. 在测试运行器面板中,点击“创建 PlayMode 测试程序集文件夹”按钮。应该创建了一个名为 Tests 的新文件夹。

  11. 在项目面板中,打开Tests文件夹。它应该包含一个程序集定义文件Tests.asmdef

  12. 在测试运行器面板中,点击“在当前文件夹中创建测试脚本”按钮。将此脚本类重命名为DoorTest

  13. 编辑DoorTest.cs脚本类,将内容替换为以下:

    using System.Collections;
     using NUnit.Framework;
     using UnityEngine;
     using UnityEngine.SceneManagement;
     using UnityEngine.TestTools;

     public class DoorTest
     {
         const int BASE_LAYER = 0;
         private string initialScenePath;
         private Animator doorAnimator;
         private Scene tempTestScene;

         // name of scene being tested by this class
         private string sceneToTest = "doorScene";

         [SetUp]
         public void Setup()
         {
             // setup - load the scene
             tempTestScene = SceneManager.GetActiveScene();
         }
     } 
  1. 将以下测试方法添加到DoorTest.cs中:
    [UnityTest]
     public IEnumerator TestDoorAnimationStateStartsClosed()
     {
         // load scene to be tested
         yield return SceneManager.LoadSceneAsync(sceneToTest, LoadSceneMode.Additive);
         SceneManager.SetActiveScene(SceneManager.GetSceneByName(sceneToTest));

         // Arrange
         doorAnimator = GameObject.FindWithTag("Door").GetComponent<Animator>();
         string expectedDoorAnimationState = "DoorClosed";

         // immediate next frame
         yield return null;

         // Act
         AnimatorClipInfo[] currentClipInfo = doorAnimator.GetCurrentAnimatorClipInfo(BASE_LAYER);
         string doorAnimationState = currentClipInfo[0].clip.name;

         // Assert
         Assert.AreEqual(expectedDoorAnimationState, doorAnimationState);

         // teardown - reload original temp test scene
         SceneManager.SetActiveScene(tempTestScene);
         yield return SceneManager.UnloadSceneAsync(sceneToTest);
     } 
  1. 将以下测试方法添加到DoorTest.cs中:
    [UnityTest]
     public IEnumerator TestIsOpeningStartsFalse()
     {
         // load scene to be tested
         yield return SceneManager.LoadSceneAsync(sceneToTest, LoadSceneMode.Additive);
         SceneManager.SetActiveScene(SceneManager.GetSceneByName(sceneToTest));

         // Arrange
         doorAnimator = GameObject.FindWithTag("Door").GetComponent<Animator>();

         // immediate next frame
         yield return null;

         // Act
         bool isOpening = doorAnimator.GetBool("Opening");

         // Assert
         Assert.IsFalse(isOpening);

         // teardown - reload original temp test scene
         SceneManager.SetActiveScene(tempTestScene);
         yield return SceneManager.UnloadSceneAsync(sceneToTest);
     } 
  1. 将以下测试方法添加到DoorTest.cs中:
    [UnityTest]
     public IEnumerator TestDoorAnimationStateOpenAfterAFewSeconds()
     {
         // load scene to be tested
         yield return SceneManager.LoadSceneAsync(sceneToTest, LoadSceneMode.Additive);
         SceneManager.SetActiveScene(SceneManager.GetSceneByName(sceneToTest));

         // wait a few seconds
         int secondsToWait = 3;
         yield return new WaitForSeconds(secondsToWait);

         // Arrange
         doorAnimator = GameObject.FindWithTag("Door").GetComponent<Animator>();
         string expectedDoorAnimationState = "DoorOpen";

         // Act
         AnimatorClipInfo[] currentClipInfo = doorAnimator.GetCurrentAnimatorClipInfo(BASE_LAYER);
         string doorAnimationState = currentClipInfo[0].clip.name;
         bool isOpening = doorAnimator.GetBool("Opening");

         // Assert
         Assert.AreEqual(expectedDoorAnimationState, doorAnimationState);
         Assert.IsTrue(isOpening);

         // teardown - reload original temp test scene
         SceneManager.SetActiveScene(tempTestScene);
         yield return SceneManager.UnloadSceneAsync(sceneToTest);
     } 
  1. 点击“运行全部”。

  2. 当测试运行时,你首先会在层次结构、游戏和场景面板中看到创建了一个临时场景,然后是门场景正在运行,球体正滚向红色的门。

  3. 你应该能看到你的单元测试执行的结果——如果所有测试都成功完成,每个测试旁边应该有绿色的勾号(检查标记)。

它是如何工作的...

你添加了两个场景到构建中,因此它们可以在 PlayMode 测试期间使用 SceneManager 在我们的脚本中选择。

我们打开了 menuScene,以便在 PlayMode 测试期间清楚地看到 Unity 运行不同的场景——测试完成后,我们会看到 menu 场景被重新打开。

存在一个在每次测试之前执行的SetUp()方法。SetUp 和 TearDown 方法在每次测试前准备事情和测试后重置事物回原状非常有用。不幸的是,例如在每次测试前加载我们的门场景,然后在测试后重新加载菜单,需要等待场景加载过程完成。我们无法在SetUp()TearDown()方法中放置 yield 语句,所以你会看到每个测试在每个测试的开始和结束时都会重复场景加载:

// load scene to be tested
 yield return SceneManager.LoadSceneAsync(sceneToTest, LoadSceneMode.Additive);
     SceneManager.SetActiveScene(SceneManager.GetSceneByName(sceneToTest));

 // Arrange-Act-Assert goes here

 // teardown - reload original temp test scene
 SceneManager.SetActiveScene(tempTestScene);
 yield return SceneManager.UnloadSceneAsync(sceneToTest); 

对于每个测试,我们等待,要么是单个帧(返回null),要么是几秒钟(返回return new WaitForSeconds(...))。这确保了在测试开始运行之前,所有对象都已创建并且物理引擎已启动。前两个测试检查初始条件,换句话说,门开始于 DoorClosed 动画状态,并且 Animation Controller 的isOpening变量为 false。

最后一个测试等待几秒钟(这足以让球体滚到门前并触发开启动画),并测试门是否进入/已进入 DoorOpen 动画状态,以及 Animation Controller 的isOpening变量是否为 true。

如所见,PlayMode 测试比单元测试要复杂得多,但它意味着我们有一种方法来测试实际的游戏对象交互,当计时器和物理引擎运行时。正如这个示例所示,我们还可以为 PlayMode 测试加载自己的场景,无论是专门创建来测试交互的特殊场景,还是最终游戏构建中要包含的实际场景。

使用事件、日志和异常进行 PlayMode 和单元测试玩家生命条

在这个菜谱中,我们将许多不同类型的测试组合到许多游戏的一个功能上——一个表示玩家数值生命值的视觉生命条(在这种情况下,一个从 0.0 到 1.0 的浮点数)。尽管这个菜谱并没有全面测试生命条的各个方面,但它提供了一个很好的示例,说明我们可以如何使用 Unity 测试工具来测试游戏的不同部分。

提供了一个包含以下内容的 Unity 包:

  • Player.cs:一个玩家脚本类,管理玩家生命值,并使用委托和事件将健康变化发布到任何监听的视图

  • 两个视图类,它们注册以监听玩家生命值变化事件:

    • HealthBarDisplay.cs:这个脚本更新 UI 图像的填充量,以反映每个新收到的玩家生命值

    • HealthChangeLogger.cs:这个脚本将关于接收到的新的玩家生命值的消息打印到 Debug.Log 文件中

  • PlayerManager.cs:一个管理脚本,它初始化玩家和 HealthChangeLogger 对象,并允许用户通过按上箭头键和下箭头键来改变玩家的生命值(模拟游戏中的治疗/伤害)

  • 一个包含 2 个 UI 图像的场景——一个是生命条轮廓(红色心形和黑色轮廓),另一个是填充图像——显示从深蓝色到浅蓝色再到绿色,表示从弱到强的生命值)

这个菜谱允许展示几种不同类型的测试:

  • 进行 PlayMode 测试,以检查显示的 UI 图像的实际fillAmount是否与玩家生命值的 0.0 到 1.0 范围相匹配

  • 单元测试,以检查玩家生命值是否以正确的默认值开始,并在调用AddHealth(...)ReduceHealth(...)方法后正确增加和减少

  • 单元测试,以检查玩家对象是否发布了健康变化事件

  • 单元测试,以检查预期的消息是否记录在Debug.Log

  • 单元测试,以检查如果将负值传递给玩家的AddHealth(...)ReduceHealth(...)方法,是否会抛出参数范围异常。这在上面的屏幕截图中得到了演示:

感谢 Pixel Art Maker 提供的生命条图像:pixelartmaker.com/art/49e2498a414f221

准备工作

对于这个菜谱,在19_07文件夹中提供了一个 Unity 包(healthBarScene.unitypackage)。

如何操作...

要对玩家生命条进行 PlayMode 和单元测试,请按照以下步骤操作:

  1. 创建一个新的 Unity 项目,创建一个新的空场景,并删除默认的 Scenes 文件夹。

  2. 导入提供的 Unity 包(healthBarScene.unitypackage)。

  3. 打开 HealthBarScene 场景。

  4. 将 HealthBarScene 添加到项目构建中(菜单:文件 | 构建设置 ...)。

  5. 通过选择以下菜单来显示测试运行器面板:窗口 | 通用 | 测试运行器。

  6. 为所有程序集启用测试模式。通过在测试运行器面板右上角显示下拉菜单,并选择“为所有程序集启用测试模式(点击 OK 以确认重启编辑器)”来完成此操作。

  7. 现在重新启动 Unity 编辑器(只需关闭应用程序,然后使用项目重新打开它)。

  8. 确保在测试运行器面板中选择了“测试模式”按钮。

  9. 在项目面板中,选择顶级文件夹“Assets”。

  10. 在测试运行器面板中,点击“创建测试模式测试程序集文件夹”按钮。应该已经创建了一个名为 Tests 的新文件夹。

  11. 确保在项目面板中选择了“Assets”文件夹。创建一个名为 PlayModeTests 的新文件夹(现在它应该出现在“Assets”文件夹中)。

  12. 确保在项目面板中选择了“PlayModeTests”文件夹。在测试运行器面板中,点击“在当前文件夹中创建测试脚本”按钮。将此脚本类重命名为HealthBarPlayModeTests

  13. 编辑HealthBarPlayModeTests.cs脚本类,将内容替换为以下:

    using UnityEngine;
     using UnityEngine.UI;
     using UnityEngine.TestTools;
     using NUnit.Framework;
     using System.Collections;
     using UnityEngine.SceneManagement;

     [TestFixture]
     public class HealthBarPlayModeTests
     {
         private Scene tempTestScene;

         // name of scene being tested by this class
         private string sceneToTest = "HealthBar";

         [SetUp]
         public void Setup()
         {
             // setup - load the scene
             tempTestScene = SceneManager.GetActiveScene();
         }
     } 
  1. HealthBarPlayModeTests.cs中添加以下测试:
   [UnityTest]
     public IEnumerator TestHealthBarImageMatchesPlayerHealth()
     {
         // load scene to be tested
         yield return SceneManager.LoadSceneAsync(sceneToTest, LoadSceneMode.Additive);
         SceneManager.SetActiveScene(SceneManager.GetSceneByName(sceneToTest));

         // wait for one frame
         yield return null;

         // Arrange
         Image healthBarFiller = GameObject.Find("image-health-bar-filler").GetComponent<Image>();
         PlayerManager playerManager = GameObject.FindWithTag("PlayerManager").GetComponent<PlayerManager>();
         float expectedResult = 0.9f;

         // Act
         playerManager.ReduceHealth();

         // Assert
         Assert.AreEqual(expectedResult, healthBarFiller.fillAmount);

         // teardown - reload original temp test scene
         SceneManager.SetActiveScene(tempTestScene);
         yield return SceneManager.UnloadSceneAsync(sceneToTest);
     } 
  1. 点击“运行所有”。

  2. 当测试运行时,你首先会在“层次结构”、“游戏”和“场景”面板中看到创建了一个临时场景,然后运行带有可视健康条的 HealthBarScene。

  3. 你应该看到你的测试模式测试执行的结果——如果测试成功完成,应该有一个绿色的勾号(勾选标记)。

  4. 确保在项目面板中选择了“Assets”文件夹。创建一个名为 Editor 的新文件夹(现在它应该出现在“Assets”文件夹中)。

  5. 确保在项目面板中选择了“Editor”文件夹。在测试运行器面板中,点击“在当前文件夹中创建测试脚本”按钮。将此脚本类重命名为EditModeUnitTests

  6. 编辑EditModeUnitTests.cs脚本类,将内容替换为以下:

    using System;
     using UnityEngine.TestTools;
     using NUnit.Framework;
     using UnityEngine;

     public class EditModeUnitTests
     {

         // inner unit test classes go here

     } 
  1. EditModeUnitTests.cs中的EditModeUnitTests类内部添加以下类和基本测试:
    public class TestCorrectValues
     {
         [Test]
         public void DefaultHealthOne()
         {
             // Arrange
             Player player = new Player();
             float expectedResult = 1;

             // Act
             float result = player.GetHealth();

             // Assert
             Assert.AreEqual(expectedResult, result);
         }

         [Test]
         public void HealthCorrectAfterReducedByPointOne()
         {
             // Arrange
             Player player = new Player();
             float expectedResult = 0.9f;

             // Act
             player.ReduceHealth(0.1f);
             float result = player.GetHealth();

             // Assert
             Assert.AreEqual(expectedResult, result);
         }

         [Test]
         public void HealthCorrectAfterReducedByHalf()
         {
             // Arrange
             Player player = new Player();
             float expectedResult = 0.5f;

             // Act
             player.ReduceHealth(0.5f);
             float result = player.GetHealth();

             // Assert
             Assert.AreEqual(expectedResult, result);
         }
     } 
  1. EditModeUnitTests.cs中的EditModeUnitTests类内部添加以下类和限制测试:
    public class TestLimitNotExceeded
     {
         [Test]
         public void HealthNotExceedMaximumOfOne()
         {
             // Arrange
             Player player = new Player();
             float expectedResult = 1;

             // Act
             player.AddHealth(1);
             player.AddHealth(1);
             player.AddHealth(0.5f);
             player.AddHealth(0.1f);
             float result = player.GetHealth();

             // Assert
             Assert.AreEqual(expectedResult, result);
         }
     } 
  1. EditModeUnitTests.cs中的EditModeUnitTests类内部添加以下类和事件测试:
    public class TestEvents
     {
         [Test]
         public void CheckEventFiredWhenAddHealth()
         {
             // Arrange
             Player player = new Player();
             bool eventFired = false;

             Player.OnHealthChange += delegate
             {
                 eventFired = true;
             };

             // Act
             player.AddHealth(0.1f);

             // Assert
             Assert.IsTrue(eventFired);
         }

         [Test]
         public void CheckEventFiredWhenReduceHealth()
         {
             // Arrange
             Player player = new Player();
             bool eventFired = false;

             Player.OnHealthChange += delegate
             {
                 eventFired = true;
             };

             // Act
             player.ReduceHealth(0.1f);

             // Assert
             Assert.IsTrue(eventFired);
         }
     } 
  1. EditModeUnitTests.cs中的EditModeUnitTests类内部添加以下类和异常测试:
    public class TestExceptions
     {
         [Test]
         public void Throws_Exception_When_Add_Health_Passed_Less_Than_Zero()
         {
             // Arrange
             Player player = new Player();

             // Act

             // Assert
             Assert.Throws<ArgumentOutOfRangeException>(
                 delegate
                 {
                     player.AddHealth(-1);
                 }
             );
         }

         [Test]
         public void Throws_Exception_When_Reduce_Health_Passed_Less_Than_Zero()
         {
             // Arrange
             Player player = new Player();

             // Act

             // Assert
             Assert.Throws<ArgumentOutOfRangeException>(
                 () => player.ReduceHealth(-1)
             );
         }
   } 
  1. EditModeUnitTests.cs中的EditModeUnitTests类内部添加以下类和日志测试:
    public class TestLogging
     {
         [Test]
         public void Throws_Exception_When_Add_Health_Passed_Less_Than_Zero()
         {
             Debug.unityLogger.logEnabled = true;

             // Arrange
             Player player = new Player();
             HealthChangeLogger healthChangeLogger = new HealthChangeLogger();
             string expectedResult = "health = 0.9";

             // Act
             player.ReduceHealth(0.1f);

             // Assert
             LogAssert.Expect(LogType.Log, expectedResult);
         }
     } 

你可以看到,内部类允许在测试运行器面板中视觉上对单元测试进行分组。

图片

它是如何工作的...

让我们详细看看它是如何工作的。

测试模式测试

测试模式测试TestHealthBarImageMatchesPlayerHealth()加载HealthBar场景,获取 PlayerManager 实例对象的引用,PlayerManager 是标记为 PlayerManager 的 GameObject 的组件,并调用ReduceHealth()方法。此方法将玩家的健康值减少 0.1,因此从其起始值 1.0 变为 0.9。

PlayerManager GameObject 还有一个组件,是一个 C# HealthBarDisplay 脚本类的实例对象。此对象注册监听来自玩家类的发布事件。它还有一个公共 UI Image 变量,该变量已链接到场景中生命值填充图像的 UI Image。

当玩家的生命值减少到 0.9 时,它发布 OnChangeHealth(0.9) 事件。此事件被 HealthBarDisplay 对象实例接收,然后设置场景中链接的生命值填充图像的 fillAmount 属性。

TestHealthBarImageMatchesPlayerHealth() PlayMode 测试获取名为 image-health-bar-filler 的对象实例的引用,将此引用存储在 healthBarFiller 变量中。测试断言是,expectedResult 值为 0.9 与场景中 UI Image 的实际 fillAmount 属性相匹配:

Assert.AreEqual(expectedResult, healthBarFiller.fillAmount); 

单元测试

有几个单元测试,通过将它们放在自己的类中,放在 EditModeUnitTests 脚本类中,进行分组。

  • TestCorrectValues 类:

    • DefaultHealthOne(): 这个测试验证玩家的生命值的默认(初始值)是 1

    • HealthCorrectAfterReducedByPointOne(): 这个测试验证当玩家的生命值减少到 0.1 时,它变成了 0.9

    • HealthCorrectAfterReducedByHalf(): 这个测试验证当玩家的生命值减少到 0.5 时,它确实变成了 0.5

  • class TestLimitNotExceeded:

    • HealthNotExceedMaximumOfOne(): 这个测试验证玩家的生命值不会超过 1,即使尝试将其初始值 1 增加 1、0.5 和 0.1
  • class TestEvents:

    • CheckEventFiredWhenAddHealth(): 这个测试验证当玩家的生命值增加时,会发布 OnChangeHealth() 事件

    • CheckEventFiredWhenReduceHealth(): 这个测试验证当玩家的生命值减少时,会发布 OnChangeHealth() 事件

  • class TestLogging:

    • CorrectDebugLogMessageAfterHealthReduced(): 这个测试验证在玩家的生命值减少到 0.1 到 0.9 之后,Debug.Log 消息被正确记录
  • class TestExceptions:

    • Throws_Exception_When_Add_Health_Passed_Less_Than_Zero(): 这个测试验证当将负值传递给 AddHealth(...) 玩家方法时,会抛出 ArgumentOutOfRangeException

    • Throws_Exception_When_Reduce_Health_Passed_Less_Than_Zero(): 这个测试验证当将负值传递给 ReduceHealth(...) 玩家方法时,会抛出 ArgumentOutOfRangeException

这两个测试说明了命名测试的一个约定,即在方法名中的每个单词之间添加一个下划线 _ 字符,以提高可读性。

参见

在 Unity 文档中了解更多关于 LogAssert Unity 脚本引用的信息:

单元测试 C# 事件的方法是借鉴自 philosophicalgeek.com 上的一个帖子:

在这个健康条功能中,健康变化事件的委托-事件发布是一个发布者-订阅者设计模式的例子。在《第十七章》中了解更多关于设计模式和它们在 Unity 游戏中的实现,章节名为“额外功能和设计模式”。

第二十章:奖励章节

使用外部资源文件和设备

本章帮助您探索和选择游戏数据可以存储的不同位置。本章探讨了多种位置和策略,用于将游戏数据与逻辑分离,并在需要时加载数据。它还探讨了通过静态变量和 Unity PlayerPrefs 系统在场景之间保存运行时数据的方法。还介绍了一种在游戏运行时保存截图的方法。两个配方展示了如何将游戏组件作为 Unity Asset Bundles 加载和保存。

您可以在此处阅读本章:www.packtpub.com/sites/default/files/downloads/WorkingwithExternalResourceFilesandDevices.pdf

使用纯文本、XML 和 JSON 文本文件

本章介绍了在游戏中使用文本文件的不同方式来表示和传递数据。创建、读取和写入文本文件可以以多种方式执行,除了通用的纯文本文件外,还有常见的 JSON 和 XML 数据交换文本文件格式。本章提供了一系列配方,用于创建、读取和写入不同类型的文本内容。这些对于将数据保存在运行其上的设备上的游戏以及与在线客户端通信都很有用。

您可以在此处阅读本章:www.packtpub.com/sites/default/files/downloads/WorkingwithPlainTextXMLandJSONTextFiles.pdf

虚拟现实和附加功能

本章介绍了 Unity 2018 中未在其他章节中涵盖的一些附加功能。在本章中,我们将介绍一系列我们希望包含的附加 Unity 功能,例如慢动作、 Gizmos 以及暂停游戏配方。随着 VR 这种游戏中的新兴功能,我们还包含了一系列配方,展示了如何在 Unity 中开发 VR 游戏。

您可以在此处阅读本章:www.packtpub.com/sites/default/files/downloads/VirtualRealityandExtraFeatures.pdf

自动测试

本章介绍了在 Unity 中自动化测试代码和 GameObject 的方法。拥有一套可以快速执行的自动化测试意味着当程序员和游戏设计师对游戏进行修改时,他们可以快速检查是否产生了意外的后果,这些后果要么引发错误,要么使 GameObject 以它们不应有的方式行为。在本章中,我们将介绍 Unity Test Runner,并展示如何通过示例食谱自动化代码的独立测试(单元测试),以及如何在游戏模式测试中自动化代码与 Unity 系统(如物理和动画)交互的运行时测试。食谱以游戏组件健康条的运行时和单元测试结束,展示了日志和异常。

您可以在此处阅读本章:www.packtpub.com/sites/default/files/downloads/AutomatedTesting.pdf

posted @ 2025-10-25 10:31  绝不原创的飞龙  阅读(199)  评论(0)    收藏  举报