Unity 3D脚本编程与游戏开发

978-7-115-55875-6
作者: 马遥沈琰
译者:
编辑: 杨璐
分类: Unity

图书目录:

详情

本书以游戏开发为主要线索,全面讲解Unity 3D的编程技术,涵盖Unity 3D引擎的各个系统与模块。全书从帮助读者迅速建立脚本编程和游戏开发的框架思路开始讲起,逐步阐述Unity 3D游戏开发的核心概念,以及对游戏开发至关重要的物理系统和3D数学基础等技术基础。然后针对游戏中的界面、动画、特效与音频等Unity 3D各个常用模块的使用方法进行讲解,并详细介绍游戏开发中数据管理与资源管理相关的知识。随后通过潜入型游戏的完整案例将本书所讲知识融会贯通。最后,讲解游戏人工智能开发技术,以及对象池等高级编程技术,帮助读者提升应对实际工作的能力。 本书内容全面,讲解细致,适合游戏开发的初学者入门,也适合相关培训机构作为教材。

图书摘要

Unity 3D脚本编程与游戏开发

马遥 沈琰◎编著






人民邮电出版社
北京

内容提要

本书以游戏开发为主要线索,全面讲解Unity 3D的编程技术,涵盖Unity 3D引擎的各个系统与模块。全书从建立脚本编程和游戏开发的框架思路讲起,逐步阐述Unity 3D游戏开发的核心概念,以及对游戏开发至关重要的物理系统和3D数学基础等技术基础。然后针对游戏中的界面、动画、特效与音频等,对Unity 3D各个常用模块的使用方法进行讲解,并详细介绍游戏开发中数据管理与资源管理相关的知识。随后通过潜入型游戏的完整案例将本书所讲知识融会贯通。最后讲解游戏人工智能开发技术,以及对象池等高级编程技术,帮助读者提升应对实际工作的能力。

本书内容全面,讲解细致,适合游戏开发初学者入门,也适合相关培训机构作为教材。

前言

近几年来,Unity引擎在市场上获得了巨大的成功。市面上使用Unity制作的精品游戏层出不穷。不仅有许许多多专业的手游厂商在使用Unity引擎,一些广受好评的独立游戏也采用Unity引擎开发。

与游戏市场的繁荣发展相对应,网络上、书店里游戏开发的教程也逐渐多了起来。现在任何人都可以花半天时间跟着视频做一个小游戏,享受做游戏的乐趣,进而实现开发原创游戏的梦想。

编者在游戏开发教学工作中发现,很多同学对搭建场景、制作界面表现出很大兴趣,而一旦到了编写程序的环节,就会进入茫然的状态。有一些同学会照着范例敲出代码,有一些同学则会因为看不懂而放弃,而即便是坚持下来的人,大都也学得懵懵懂懂。他们能明白代码的大致含义,但很难自己写出来,更不会修改代码和独立实现游戏功能。

编者认为,许多游戏开发初学者编程能力不足的问题有必要加以改善。实际上,编程不仅是游戏制作过程中最重要的环节之一,而且它本身也富有创造的乐趣。为什么这么说呢?

首先,程序是游戏存在的基础。

游戏开发技术主要分为游戏设计、美术制作与技术实现三大方面。设计是游戏的灵魂,美术是游戏的外表,而技术是游戏的骨骼和血肉。人们总说,健康的身体是幸福生活的前提。同样,游戏的玩法和画风可以丰富多彩甚至千奇百怪,但无论什么游戏,都一定要有一个健全的软件结构,这样才能保证游戏设计正确实现,才能确保用户的正常体验。

其次,技术实现与游戏设计密不可分。

纵观电子游戏的历史,人们一直在利用新技术不断创造更新颖、更震撼和更自由的游戏。横板卷轴技术让《超级马里奥兄弟》得以诞生,实时3D渲染技术催生出了《雷神之锤》等第一人称射击游戏,而现代的软硬件技术更是让创造一个庞大虚拟世界的梦想成为可能。游戏技术与游戏设计互相促进:技术为设计带来可能性的空间,设计指导着技术的发展方向。

如今,成熟的游戏引擎已经提供了丰富的功能和特性,而且所有的功能特性都能被脚本调用、组织和控制。一旦掌握了一定的编程技术,你将会发现自己在虚拟的世界中无所不能。

编者相信每一个人都能够体会到这种创造的乐趣与成就感,而大多数人需要的仅仅是一架梯子,用于渡过最初的难关。

本书编者长期从事游戏开发教学工作,在教学工作中有很多积累和体会。很荣幸接到人民邮电出版社的邀请,给了编者一个将知识经验总结并发表的机会。

首先,要特别感谢皮皮关游戏开发教育的老师和同学们。他们给了编者有限的素材与无限的灵感,让辛苦的编写工作变成了一种奇妙的体验。

然后,感谢默默支持我们的家人,感谢所有帮助和批评过我们的朋友。

最后,还要感谢人民邮电出版社的编辑,他们用细致严谨的工作态度敦促编者尽全力写好每一个段落,相信这种认真的态度会让每一位读者受益。

由于编者水平有限,书中疏漏之处在所难免。如有任何意见和建议,请读者不吝指正,感激不尽。

编者
2020年冬

资源与支持

本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

● 配套资源及下载方式

本书提供数字资源下载,内容包括:

(1)所有案例的配套工程文件;

(2)180分钟实例制作过程的教学视频。

要获得以上配套资源,请在异步社区本书页面中点击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

● 提交勘误

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

● 扫码关注本书

扫描右方二维码,您将会在异步社区微信服务号中看到本书信息及相关的服务提示。

●与我们联系

我们的联系邮箱是szys@ptpress.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

● 关于异步社区和异步图书

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

第1章 Unity脚本概览

脚本编写并不困难,但是如果直接从细节开始讲起,会让读者难以看到脚本编程的全貌。因此本章不急于阐述脚本编写的细节,只介绍简单的修改物体位置、处理用户输入和检测碰撞的方法,让读者用最简单的方式做出第一个3D滚球跑酷游戏,体会脚本编程的思路和整体方法。

1.1 控制物体的运动

仅通过控制物体的位置,就能做出好玩的小游戏。本节将详细讲解创建脚本、改变物体位置和处理用户输入等基本操作,并对容易产生误解的地方做出提示。

1.1.1 新建脚本

首先在场景中新建一个球体,接着新建脚本并挂载到该球体上。

新建脚本有两种方法。第一种方法是在Project(工程)窗口的某个文件夹中,如在Assets(资源根目录)中单击鼠标右键,选择Create→C# Script选项,如图1-1所示。这时Unity会在该文件夹下建立一个扩展名为.cs的脚本文件(扩展名在Unity编辑器中是隐藏的),并立即让用户为其指定一个名称,默认为NewScript。

第二种方法是选中球体,在Inspector(检视)窗口中单击Add Component(添加组件)按钮,在菜单中选择New script(新建脚本)选项,再输入文件名,最后单击Create and Add(创建并添加)按钮,则会在Assets下新建指定名称的脚本文件,如图1-2所示。

如果采用第一种方法,那么创建的脚本文件就还未挂载到球体上。如果采用第二种方法,则在创建完成的同时脚本文件也挂载完成了。

将脚本文件挂载到球体上也有两种方法:一是拖曳Project窗口中的脚本文件到场景中的球体上,二是拖曳Project窗口中的脚本文件到Inspector窗口里所有项目的后面。推荐使用第二种方法,因为它更为直观。

将脚本文件拖曳到Inspector窗口时会出现一条蓝色标记线,代表插入的位置,如图1-3所示。一般来说脚本组件的顺序不是很重要,可以插入到任意两个组件之间或者最后。

当不小心挂载了多个同样的脚本组件时,在组件名称上单击鼠标右键,并选择Remove Component(删除组件)选项即可删除多余的组件,如图1-4所示。

小提示

创建脚本组件的注意事项

Unity规定,能够挂载到物体上的脚本文件必须是“脚本组件”(另有一种不是组件的脚本文件),脚本组件要继承自MonoBehaviour,且脚本代码中的class名称必须与文件名一致。一般脚本创建时会自动生成这部分内容,但是如果修改了脚本文件名,那么在挂载时就会报错。这时就必须修改文件名或class名称,让它们一致,这样才能正确挂载。

Unity支持一个物体挂载多个同样的脚本组件,但一般来说只需要一个。如果由于操作失误挂载了多个脚本组件,就要删除多余的,这也是建议把脚本文件拖曳到Inspector窗口内的原因,这样易于确认是否挂载了多个脚本组件。

1.1.2 Start和Update事件

双击脚本文件,或者在脚本组件上单击鼠标右键,选择Edit Script(编辑脚本),就可以打开脚本文件进行编辑。本书建议使用Visual Studio 2017或Visual Studio 2019作为代码编辑器。在初次安装Unity时会默认安装Visual Studio社区版,该版本可免费使用。

打开脚本文件,可以看到如下内容。

using UnityEngine;

public class Ball : MonoBehaviour {
  // Use this for initialization
  void Start () {
 }

  // Update is called once per frame
  void Update () {
 }
}

这个脚本文件名为Ball,引用了UnityEngine这个命名空间,组件类型为Ball,继承了MonoBehaviour,这些都是自动生成的部分。这里需要关注的是其中的Start()函数和Update()函数。

Start()函数在游戏开始运行的时候执行一次,特别适合进行组件初始化。而Update()函数每帧都会执行,在不同设备上更新的频率有所区别,特别是当系统硬件资源不足时,帧率就会降低,因此Update()函数实际执行的频率是变化的。

Start和Update又被称为“事件”,因为它们分别是在“该组件开始运行”和“更新该组件”这两个事件发生时被调用的。第2章会详细讲解脚本组件的各种事件函数。

这里介绍一个非常常用的方法:Debug.Log()用于向Console(控制台)窗口输出一串信息。下面改动脚本,加上两句输出信息的代码。

using UnityEngine;

public class Ball : MonoBehaviour {
  // Use this for initialization
  void Start () {
        Debug.Log("组件执行开始函数!"); 
 }

  // Update is called once per frame
  void Update () {
        Debug.Log("当前游戏进行时间:" + Time.time);
 }
}

运行游戏,找到Console窗口,如果没有打开,则选择主菜单中的Window→General→Console选项打开,如图1-5所示。

执行游戏,Console窗口内会产生大量信息,如图1-6所示。

暂停游戏,查看Console窗口中的信息,发现开始函数只执行了一次,后面所有的“当前游戏进行时间:秒数”都是Update()函数被调用时输出的。如果仔细观察,可以发现时间的流逝不太稳定,特别是在游戏刚启动的时候偏差较大。

Debug.Log()函数是写代码、查bug的好帮手,越是编程高手,越是使用得多。Debug.Log()函数也是最简单、最可靠的一种调试手段,未来还会反复使用这个方法,建议从现在开始就多多应用它。

1.1.3 修改物体位置

在Unity里修改物体位置,实际上就是修改Transform(变换)组件的数据。在Inspector窗口里可以方便地查看和修改Transform组件的位置(Position)、旋转(Rotation)和缩放(Scale)参数,如图1-7所示。

在界面中可以修改的参数在脚本中也能修改,而在脚本中可以修改的参数就不一定会在界面上出现了,因为脚本的功能比界面上展示的功能要多得多。

修改Transform组件中的Position有两种常用方法。一种是使用Translate()函数。

// 物体将沿着自身的右侧方向(X轴正方向也称为向右)前进1.5个单位
transform.Translate(1.5f, 0, 0);

另一种是直接指定新的位置。如果在游戏一开始就把物体放在(1, 2.5, 3)的位置,则只需要修改Start()函数。

void  Start () {  
      transform.position = new Vector3(1, 2.5f, 3);
}

这里可能有两个让人觉得奇怪的地方:“2.5”要写作“2.5f”,设置位置时要用new Vector3(x, y, z)这种写法。

这些主要源于C#语法的规定。直接写2.5会被认为是一个double类型的数,而这里需要的是float类型的数,所以必须加上f后缀。而写“new”的原因是Vector3是一个值类型,而position是一个属性,由于C#中引用和值的原理,因此不能使用“transform.position.y = 2.5f”这种写法直接修改物体的位置。这里的解释可能对初学者来说有点模糊,不过没有关系,可以先记住并使用这个语法,随着C#编程学习的深入自然会明白。

如果要做一个连续的位移动画,只需要让物体每帧都移动一段很小的距离就可以了。也就是说,可以把改变位置的代码写在Update()函数里,但是每帧都要比前一帧多改变一些位置。例如,以下两种方法都可以让物体沿z轴前进。

void Update () {
        transform.Translate(0, 0, 0.1f);
        // 在这个例子中等价于:
        transform.position += new Vector3(0, 0, 0.1f);
 }

以上两句代码只选择写其中一句即可,另一句可以注释掉。运行游戏,观察小球是否持续运动。

小提示

物体位置的两种写法有本质区别

如果深究,这里有两个要点:一是向量的使用和向量加法,二是局部坐标系与世界坐标系之间的区别和联系。

Translate()函数默认为局部坐标系,而修改position的方法是世界坐标系。3D数学基础知识会在后续章节详细说明,这里先把关注点放在位移逻辑上。

前面提到,由于系统繁忙时无法保证稳定的帧率,因此对于上面的写法,如果帧率高,小球移动就快;帧率低,小球移动就慢。我们需要在“每帧移动同样的距离”和“每秒移动同样的距离”之间做出选择。

按游戏开发的常规方法,应当选择“每秒移动同样的距离”。举个例子,如果帧率为60帧/秒时物体每帧移动0.01米,那么帧率只有30帧/秒时就应该每帧移动0.02米,这样才能保证物体移动1秒的距离都是0.6米。这里虽然原理复杂,但实际只需要略作修改即可。

void Update () {
        transform.Translate(0, 0, 5 * Time.deltaTime);
        // 或
        transform.position += new Vector3(0, 0, 5 * Time.deltaTime);
 }

Time.deltaTime表示两帧之间的间隔,如帧率为60帧/秒时这个值为0.0167秒,帧率只有10帧/秒时这个值为0.1秒,用它乘以移动速度就可以抵消帧率变化的影响。由于Time.deltaTime是一个比较小的数,因此速度的数值应适当放大一些。

1.1.4 读取和处理输入

Unity支持多种多样的输入设备,如键盘、鼠标、手柄、摇杆、触摸屏等。很多输入设备有着类似的控制方式,如按键盘上的W键或上箭头键,将手柄的左摇杆向前推,都代表“向上”,用Unity的术语表述则是“沿纵轴(Vertical)向上”。以下代码就可以获取用户当前的纵轴输入和横轴输入。

void Update () {
        float v = Input.GetAxis("Vertical");
        float h = Input.GetAxis("Horizontal");
        Debug.Log("当前输入纵向:"+v + "    " + "横向 :"+h); 
 }

Input.GetAxis()函数的返回值是一个float类型的值,取值范围为-1~1。Unity用这种方法将各种不同的输入方式统一在了一起。

以上代码会输出v和h的数值。运行游戏,然后按键盘上的W、A、S、D键或方向键,查看Console窗口是否正确读取到了输入值。

通过简单的乘法就可以将输入的幅度与物体运动的速度联系起来。

void Update () {
        float v = Input.GetAxis("Vertical");
        float h = Input.GetAxis("Horizontal");
        Debug.Log("当前输入纵向:"+v + "    " + "横向 :"+h);
        transform.Translate(h*10*Time.deltaTime, 0, v*10*Time.deltaTime);
 }

代码解释:先取得当前的纵向、横向输入(取值范围为-1~1),然后将输入值与速度(代码中的数字10)和Time.deltaTime相乘,以控制物体的移动。每帧最小移动距离为0,在60帧时x轴的每帧最大移动幅度是1*10*0.0167,即0.167米,z轴同理。

运行游戏,按W、A、S、D键或方向键,看看效果。

1.1.5 实例:实现一个移动的小球

下面利用上面所讲解的内容,实现一个移动小球的案例,具体操作如下。

01 新建一个球体。

02 调整摄像机的朝向和位置,可以设置为俯视、平视或斜上方视角。

03 为球体创建脚本Ball并挂载,完整代码如下。

using UnityEngine;

public class Ball : MonoBehaviour {

   public float speed = 10;  
void Start () {  
}

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

04 运行游戏。单击Game(游戏)窗口,然后按W、A、S、D键或方向键就可以看到球体以一定的速度移动了。运行游戏的效果如图1-8所示。

通过修改Translate()函数的参数,可以使球体沿不同方向移动,如修改为以下代码就能实现沿xy平面移动,而不是沿xz平面移动。

transform.Translate(h*speed*Time.deltaTime, v*speed*Time.deltaTime, 0);

用这种写法修改速度非常方便,不需要反复修改代码。因为speed是一个public字段(C#中类的成员变量称为字段,两种术语叫法,不影响理解),Unity会为这种字段提供一个快捷的修改方法。在场景中选中球体,在Inspector窗口中查看它的脚本组件,如图1-9所示。

读者会发现这里出现了一个Speed变量,值为10,这就是脚本中的public float speed = 10所带来的效果。由于在真实的游戏开发过程中有很多参数需要设计师反复细致调节,因此Unity默认将public字段直接放在界面上,用户可以随时修改速度,并且被修改的参数会在下一次Update()函数执行时立即生效,也就是说在游戏运行时也可以随时修改参数并生效。

唯一需要注意的是,如果游戏运行前Speed的值设定为20,运行时改为了30,那么停止运行游戏后,Speed的值会回到20。运行时的改动都不会被自动保存,需要在调整时记下合适的数字,停止运行后手动修改。将参数显示到编辑器中还有更多技巧,将在第2章继续讨论。

1.2 触发器事件

知道了物体如何移动,下一步就得知道如何判断一个物体是否碰到了另一个物体,这也是绝大部分游戏都需要用到的功能。例如,在经典游戏PONG中,球碰到边界和挡板会反弹;《超级马里奥兄弟》中的马里奥碰到蘑菇就会变大。

实际上,抛开高级游戏引擎提供的各种技术,直接判断物体之间的距离就足以实现碰撞检测,即两个物体之间的距离小于某个值,就是碰到了。早期的游戏就是这么做的,即便在今天,对某些需要特别优化的功能也可以用这种方法。

不过Unity等现代游戏引擎给出了更统一、更简便的方法——使用触发器。

触发器是一个组件,它定义了一个范围。当其他带有碰撞体组件的物体进入了这个范围时,就会产生一个触发事件,脚本捕捉到这个事件的时候,就可以做出相应的处理。

1.2.1 创建触发器

在之前的小球旁边创建一个立方体。创建的立方体已经自带碰撞体,即Box Collider,可以在Inspector窗口中看到,默认该碰撞体的范围就是立方体的范围,如图1-10所示。

在Unity中,触发器和碰撞体共用了同一种组件——Collider,实际上两者是不同的概念。勾选Box Collider面板中的Is Trigger选项,碰撞体就变成了同样外形的触发器,如图1-11所示。

并不是任何物体进入触发器的范围都会产生触发事件。产生触发事件的具体要求将在第3章物理系统中进行讨论。这里需要做的是,给小球添加一个Rigidbody(刚体)组件,并勾选Is Kinematic(动力学)选项,其他选项不重要,如图1-12所示。

做完以上步骤,碰撞的场景设置就完成了,接下来讲解如何编写脚本来处理触发事件。

1.2.2 触发器事件函数

碰撞和触发总是发生在两个物体之间,所以根据不同情况,可以选择其中一个物体进行碰撞或触发的处理。当前按照常规思路,被碰到的物体应该是立方体,因此给立方体新建一个脚本并挂载,取名Coin,用于处理碰撞。

触发事件实际上有3种,即开始触发(OnTriggerEnter)、触发持续中(OnTriggerStay)及结束触发(OnTriggerExit),分别代表另一个物体进入触发范围、在触发范围内、离开触发范围这3个阶段。这里只介绍开始触发事件,Coin脚本内容如下。

using UnityEngine;

public class Coin : MonoBehaviour {
      //  触发开始事件OnTriggerEnter,参数为碰撞体信息,即另一个
进入了该触发区域的物体的碰撞体
    private void OnTriggerEnter(Collider other)
    {
        Debug.Log(other.name + " 碰到了我");
   }
}

小提示

删除不必要的事件函数

可以看到左边代码里不必要的Start()函数、Update()函数等已经通通删除了。这是一种良好的编码习惯,可以减少不必要的函数执行。即使是函数体为空的函数,执行时依然会消耗CPU资源。

以上代码会接收到其他碰撞体进入触发区域的事件,且能获得该碰撞体的信息。上面的代码利用other.name输出了进入触发范围的物体名称。

现在运行游戏进行测试。测试方法是先运行游戏,再直接在场景窗口中拖曳球体,让它和立方体接触,然后观察Console窗口是否出现了消息提示,如图1-13所示。

可以看到,触发时Console窗口输出了消息,而且获得了物体名称Sphere。

1.2.3 实例:吃金币

通过控制物体的运动和触发器,很容易做出游戏中吃金币和金币消失的效果,下面就来实际操作一下。

01 为更好地测试,先在前面例子的基础上调整镜头位置,变成俯视角投。操作方法提示:摄像机位置归0,沿x轴转90°,然后适当沿y轴升高。摄像机的参考位置和旋转角度如图1-14所示。

02 新建球体,位置归0。挂载之前用过的Ball脚本,然后添加Rigidbody组件,并勾选Is Kinematic选项,与第1.2.1小节设置相同。

03 新建立方体,位置归0,向右平移摆放到球体旁边。

04 为了让“金币”更醒目,给立方体添加一个金黄色材质。先在Project窗口中新建一个Material(材质),如图1-15所示。

05 将材质命名为matCoin,如图1-16所示。

06 选中新建的材质,在Inspector窗口中将它的颜色修改为明亮的黄色,如图1-17所示。

07 将材质文件直接拖曳到场景中的立方体上,立方体就变成了黄色。这时选中立方体,在Inspector窗口中可以看到材质名称已经变为matCoin了,如图1-18所示。

完成以上操作后,在Game窗口中看到的效果如图1-19所示。

由于球体已挂载了之前的Ball脚本,因此可以根据输入进行移动。注意立方体要勾选Is Trigger选项,并挂载Coin脚本,设置与1.2.1小节相同。

由于金币被碰撞到以后就应该消失,因此修改的Coin脚本如下。

using UnityEngine;

public class Coin : MonoBehaviour {
    // 触发开始事件OnTriggerEnter,参数为碰撞体信息,即另一个进入了该触发区域的物体的碰撞体
    private void OnTriggerEnter(Collider other)
    {
        Debug.Log(other.name + " 碰到了我");
        // 销毁自己
        Destroy(gameObject);
    }
}

此处仅添加了一个Destroy()函数调用,该函数用于销毁物体,而参数gameObject指代的正是脚本所挂载到的物体。如何从物体找到组件,如何从组件找到物体,这些都会在第2章中详细讲解。

测试游戏,用键盘控制小球,检查小球碰到立方体后,立方体是否消失。

如果测试正常,就可以多复制几个立方体。选中立方体,按Ctrl+D快捷键就可以复制立方体,然后调整位置,如图1-20所示。

如果读者阅读到了这里,且跟着指引完成了演示案例,那么就能明白编写脚本的基本流程,并理解移动、碰撞等基本操作中每一句代码的含义。如此一来,利用这些知识就能制作一个相对完整的小游戏了。

1.3 制作第一个游戏:3D滚球跑酷

本节利用前面的知识来实现第一个较为完整的小游戏,如图1-21所示。

1.3.1 游戏设计

1. 功能点分析

游戏中的小球会以恒定速度向前移动,而玩家控制着小球左右移动来躲避跑道中的黄色障碍物。如果玩家能控制小球在跑道上移动一定距离则视为玩家通过关卡,触碰到障碍物或从跑道上掉落则视为失败。我们需要实现的功能点概括来说分为主角的运动、摄像机的移动和过关与失败的检测等。

2. 场景搭建

01 创建项目。打开Unity Hub或者单独的Unity,初始模板选择3D,如图1-22所示。建议使用Unity 2018.3以后的版本,这里使用的Unity版本为2019.2.13f1。

02 创建场景内物体。在场景中新建一个Cube(立方体)作为跑道,将其长度改为1000,宽度改为8(即立方体z轴和x轴的scale),然后将其位置沿z轴前移480,如图1-23所示。

03 新建一个Sphere(球体)作为玩家,重置它的初始位置(选择Transform组件右上角菜单中的Reset选项),按住Ctrl键拖曳球体的y轴,使其刚好移动到跑道上。

04 新建若干个Cube(立方体)作为障碍物,用上面的方法铺在跑道上面。为了便于区分,新建两个材质分别作为跑道和障碍物的材质,调整好颜色后直接拖曳到物体上即可,如图1-24所示。

小知识

按住Ctrl键拖曳物体的作用

按住Ctrl键拖曳物体会让其以一个固定值移动(可以在Edit→Snap Settings中修改这个固定值),调整物体的旋转和缩放时也是同理。这个功能在搭建场景时可以很方便地对齐物体的位置,特别是当物体为同一规格大小时。Unity中还有很多类似的很方便的快捷键,在用到时会介绍。

1.3.2 功能实现

1. 主角的移动

之前的实例中已经提到过如何控制小球的移动,因此此处不再赘述。与之前不同的是,在该案例的设计中,玩家只能控制小球左右移动,因此只需要获取横向的输入即可,纵向移动保持一个固定值。编辑好脚本后挂载在小球上,代码如下。

public class Player : MonoBehaviour
{
    public float speed;
    public float turnSpeed;

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

2. 摄像机的移动

摄像机移动的方法可以分为两种。一种是像控制小球一样为摄像机挂载控制脚本,使其与小球保持同步运动。另一种则更为简单直接,即将摄像机设置为小球的子物体,此时摄像机在没有其他代码控制的情况下会与小球保持相对静止,即随着小球移动。这里选择第二种方法,当设置好父子关系后调整摄像机到合适的高度和角度,如图1-25所示。

小提示

Unity中的父子关系

父子关系是Unity中相当重要的一个概念,此处可以不用深究,在第2章会详细说明。

1.3.3 游戏机制

1. 游戏失败

有两种情况会导致游戏失败,一种是碰到了障碍物,另一种是小球从跑道边缘掉落。

碰撞到障碍物与吃金币实例中的原理类似,将障碍物碰撞体上的Is Trigger选项勾选上,然后在障碍物脚本里的OnTriggerEnter()函数中检测碰撞。

不过游戏中的障碍物可能会有许多个,如果要一个个地分别做上述修改显然很麻烦,还容易遗漏。因此正确的做法是把其中一个障碍物设为Prefab(预制体),后面添加的障碍物都以这个Prefab为模板复制即可。

最后注意,要检测物理碰撞还需要在小球上添加Rigidbody组件。为了避免不必要的物理计算消耗,在这个游戏中完全用代码控制小球的移动,物理系统仅做检测碰撞使用,因此小球Rigidbody组件上的Is Kinematic选项要勾选上。障碍物脚本里的代码如下。

public class Barrier : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        // 防止其他物体与障碍物的碰撞被检测,我们只需要障碍物与小球的碰撞被检测到
        if(other.name=="Player")
        {
            Time.timeScale = 0;
        }
    }
}

小知识

什么是预制体?Time.timeScale是什么?

预制体简单来说就是一个事先定义好的游戏物体,之后可以在游戏中反复使用。最简单的创建预制体的方法是直接将场景内的物体拖曳到Project窗口中,这时在Hierarchy(层级)窗口中所有与预制体关联的物体名称都会以蓝色显示(普通物体的名称是黑色)。关于预制体的内容会在第2章详细说明。

Time.timeScale表示游戏的运行时间倍率,设置为0即表示游戏里的时间停滞,1即正常的时间流逝速度,2即两倍于正常的时间流逝速度,以此类推。

小球从跑道边缘掉落时也视为游戏结束。但是由于是直接用代码控制小球移动,与刚体有一点冲突,因此掉落部分的功能同样用代码来实现。在Player脚本里添加如下代码。

void Update()
    {
       ……
        // 一旦小球位置超出了跑道的范围则直接下落
        if(transform.position.x<-4||transform.position.x>4)
        {
            transform.Translate(0, -10 * Time.deltaTime, 0);
        }

        // 下落一定距离之后游戏结束
        if(transform.position.y<-20)
        {
            Time.timeScale = 0;
        }
    }

当游戏失败结束时应该允许玩家重新开始游戏,这里设置键盘上的R键为重置游戏的按键,在按R键后即可重新加载当前场景。在Player脚本里添加如下代码。

……
using UnityEngine.SceneManagement;
public class Player : MonoBehaviour
{
……
    void Update()
    {
        if(Input.GetKeyDown(KeyCode.R))
        {
            SceneManager.LoadScene(0);
            Time.timeScale = 1;
            return;
        }
……
    }
}

小提示

注意添加头部引用

SceneManager这个类型是属于UnityEngine.SceneManagement的,因此要添加头部引用后才能调用SceneManager.LoadScene(0)方法。这里参数0表示场景的序号,由于游戏现在只有一个场景,因此表示加载当前场景。

2. 游戏胜利

一般来说游戏都应该有一个最终目标,达成这个目标则视为过关或者胜利。不过也不绝对,类似Flappy Bird这样的游戏就没有最终目标。这里还是设置一个完成目标,即玩家跑了一定距离就视为过关。

这里使用一个看不见的触发器作为决定距离的终点,确保其范围能够覆盖跑道的宽度,当小球进入范围就表示游戏过关,如图1-26所示。终点物体脚本的代码如下。

public class End : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        if (other.name == "Player")
        {
            Debug.Log(" 过关 ");
            Time.timeScale = 0;
        }
    }
}

至此,这个小游戏的基本代码就完成了,之后会对其进行适当的修改,使其更完整。

1.3.4 完成和完善游戏

1. 测试自己的游戏

这时候可以开始测试自己设置的关卡难度了,一个好的游戏应当有一个合理的难度曲线。有一个小技巧可以提高这一步的效率,即单击场景视窗右上角的坐标轴图标,让场景摄像机迅速切换为对应轴方向的视角,而单击下面的Persp或Iso则分别代表切换摄像机为透视模式或正交模式,如图1-27所示。

小提示

场景摄像机不会影响实际游戏画面

场景摄像机指的是在Scene(场景视窗)里的、仅在编辑模式可用的摄像机。Hierarchy窗口中的摄像机决定在Game窗口里看到的实际游戏画面。注意不要将两者混淆。

这里可以切换场景摄像机为y轴方向正交,善用复制与按住Ctrl键拖曳功能搭建关卡。

2. 加入通关UI

在Hierarchy窗口中单击鼠标右键,通过选择UI→Panel选项创建一个UI面板,并以Panel为父节点创建一个Text组件,在Text组件中输入过关的信息,同时调整字体大小、位置等设置,如图1-28和图1-29所示。

小知识

只是创建了Panel,为什么自动添加了其他东西?

当直接创建任何UI下的组件时都会自动生成Canvas与EventSystem组件,这两个组件分别与UI的布局和交互相关,暂时不做深究。完整的UI系统会在后续章节介绍。

接下来要做的是在游戏开始时隐藏UI,在小球触发终点物体时再显示。终点物体的End脚本代码如下,同时注意修改Panel的名字为EndUI。

public class End : MonoBehaviour
{
    // 声明一个物体变量
    GameObject endUI;
    private void Start()
    {
        // 通过物体在场景中的名字来找到这个物体
        endUI = GameObject.Find("EndUI");
        // 在场景中隐藏这个物体
        endUI.SetActive(false);
    }
   private void OnTriggerEnter(Collider other)
   {
        if (other.name == "Player")
        {
            Debug.Log(" 过关 ");
            //在场景中显示这个物体
            endUI.SetActive(true);
            Time.timeScale = 0;
        }
    }
}

如此一来,当小球触碰到终点后,UI就会显示出来,如图1-30所示。

3. 加入摄像机运动效果

最后添加一个好玩的扩展功能:当控制小球左右移动时让摄像机往对应方向倾斜。具体的做法会涉及一些3D数学知识,会在后续章节中介绍。简单思路为:在Player脚本中使用获取的横向输入,以此控制摄像机的倾斜角度。在Player中添加如下代码,效果如图1-31所示。

void Update()
{
    ……
    Transform c = Camera.main.transform;
    Quaternion cur = c.rotation;
    Quaternion target = cur * Quaternion.Euler(0, 0, x * 1.5f);
    Camera.main.transform.rotation = Quaternion.Slerp(cur, target, 0.5f);
}

可以在此基础上加入更多细节,如音乐、音效和特效等。合适的音乐和特效可以让简单的游戏更吸引人。

第2章 Unity基本概念与脚本编程

如果将所有流行的游戏引擎做一个对比,会发现Unity所采用的概念架构极其简洁。在精简的基本概念之上提供丰富的功能是Unity引擎的一大特点,也是它如此流行的重要原因之一。

通过本章的学习,读者应该能掌握Unity最重要的基本概念,以及基本的脚本编写方法,对“简洁的概念架构”有深入的理解。

值得注意的是,本章是本书的核心。一方面,只需要掌握本章的内容,再加上一些开发技巧,理论上就已经可以制作出各种各样的小游戏了;另一方面,未来所编写的Unity脚本代码都会用到本章所讲解的基础知识,万变不离其宗。

2.1 Unity基本概念

用Unity创建的游戏是由一个或多个场景组成的,默认Unity会打开一个场景,如图2-1所示。

在游戏开发时,绝大部分操作都是在某一个场景中进行的,因此一开始不用关心多个场景之间的关系,只需关心在一个场景之内发生的事情。实际上,关键的概念只有GameObject(游戏物体)、Component(组件)和父子关系3个。

2.1.1 游戏物体和组件

“一个游戏场景中有很多物体”这句话很直观地表达了意思,Unity也正是这么设计的。

Unity将游戏中的物体称为GameObject,即游戏物体。一个场景中可以包含任意数量的游戏物体。在第1章创建过平面、球体、立方体等游戏物体,而组件则是实现功能特性的单元。要理解组件的作用,首先需要知道游戏物体只是一个空的容器,专门用来存放组件。这一点不太容易理解,下面将进行详细解释。

一个Unity场景可以看作一个虚拟世界,虚拟世界中可以有很多物体。在虚拟世界中,每个物体只有自身的名称、标签等基本信息。除此以外,物体的所有重要或不重要的性质,包括外形、颜色、父子关系、重量、碰撞体、脚本功能等,甚至包括物体的位置本身,都需要用组件表示。换句话说,如果虚拟世界中只有几个游戏物体,没有任何组件,那么这个世界就会空空如也。而我们仅仅知道有物体存在,却连物体的位置都无法表示。

游戏物体除了名称和标签等基本信息外,本身不具备任何可直观感受到的特性,但它拥有最关键的一个功能——挂载组件。“挂载”的意思是让物体拥有这个组件,即让组件附属于某个物体。能够挂载组件是游戏物体最主要的功能。

一个物体可以挂载任意多个组件,只要挂载合适的组件,物体就会“摇身一变”,变成游戏中的图片、UI界面、模型或摄像机等。多个组件共同作用,就能组成一个有功能性的物体;而多个有功能性的物体一起放在场景里,就能组成丰富多彩的游戏世界,如图2-2所示。

Unity中的组件繁多,在2.1.4小节会列举说明。这里先举几个具体的常见物体的例子,分析它们的组件,让读者对组件功能有一个大概的认识。

1. 空物体

前面提到,如果游戏物体没有组件,我们就连物体的位置都无法表示。为避免出现这种尴尬的情况,Unity规定任何物体必须有且只有一个Transform组件。也就是说,Transform组件和物体一一对应。

因此,所谓的“空物体”就是只包含一个Transform组件的物体。在Hierarchy窗口空白处单击鼠标右键打开菜单,选择Create Empty选项创建一个空物体,如图2-3所示。

空物体没有外形,因此在场景中无法看到它的外观,但是可以对它使用位移、旋转等工具,改变它的位置及其在空间中的朝向。

出乎意料的是,空物体是游戏开发中最常用的物体类型。虽然看起来改变空物体的位置是在做无用功,但实际上是有意义的,只是它的用途要结合“父子关系”才能真正发挥出来。

有一个恰当的类比:计算机中的文件夹既不能直接保存文档数据,又不能直接当作程序运行,但是日常使用计算机又离不开它。Unity中的空物体也具有相似的作用,但它的功能比文件夹要多,之后讲解到“父子关系”时,一切就都清楚了。

2. 球体等3D原型物体

与创建空物体的方法类似,再创建一些球体(Sphere)、立方体(Cube)、平面(Plane)等,它们有一个不常用的名字叫作原型物体(Primitive)。原型物体还包含胶囊体(Capsule)、圆柱体(Cylinder)、竖直小平面(Quad),如图2-4所示。

原型物体在抽象的小游戏中可以直接使用,在正式的游戏项目中,也可以用来给模型占位置,或者用来设置触发器范围等。

在第1章已经用过了球体、立方体等物体,读者可能已经理解了球体的基本功能,那为什么球体具备这些特性呢?下面简单分析一下它具有的组件。

除每个物体都具备的Transform组件外,球体还具有网格过滤器(Mesh Filter)、网格渲染器(Mesh Renderer)和球体碰撞体组件(Sphere Collider)。

图2-5所示的Mesh Filter被标记为Sphere (Mesh Filter)。这里的Mesh(网格)存储的是三维模型的三角形网格数据。

小知识

三维模型是由三角面组成的网格

一个能看到的三维模型是由很多三角面定义的基本外形,以及一个或多个材质定义的表面视觉属性组合形成的。其中每个材质又可能包含一张或多张贴图。

虽然也出现过三维模型的其他表示方法,但目前几乎所有的三维模型都是用三角面表示的,主流硬件设备也是以三角面作为三维模型的基本要素。

三角面网格是用一个个的顶点(用三维坐标表示),以及它们之间的连线(每三个顶点序号代表一个三角面)表示的。这样就组成了大量的三角形面,组合为Mesh(网格)。

另外,所有组件的最下方有一个颜色较浅的区域Default-Material。它不是组件,而是一个材质,是专门供网格渲染器使用的。在第1章里给物体换颜色,其实就是替换了材质。

有网格渲染器才能指定材质,删除网格渲染器,材质也会消失。网格渲染器将以指定的材质去渲染物体(渲染理解为“绘制”即可),得到特定颜色的物体。当然,能改变的不仅是物体的颜色,还有贴图、反光度和凹凸感等更多属性。

3. 灯光

Unity场景默认具备一个名为方向光源(Directional Light)的物体,如果没有它,Unity场景将是漆黑一片。那么方向光源是如何起作用的呢?相关的组件如图2-6所示。

从图中可以看到,除Transform组件外只有一个Light(光源)组件,其第1个参数Type(光源类型)是Directional(方向光),因此才有了一个给全场景照明的灯光。绝大多数场景都会有一个方向光源,作为场景照明的基础。Unity还支持其他类型的光源,如点光源、探照灯光源等,它们与方向光源一样都包含光源组件,区别是光源类型、参数不同。

4. 摄像机

Unity的场景默认有一个摄像机,叫作Main Camera。如果试着删除它,会发现场景窗口中并没有什么变化,但是Game窗口变成了漆黑一片。

在3D游戏中,用户看到的应当只是从场景的某个角度看到的一部分,这是显然的,而且是必要的。首先,3D渲染的原理就是从某个角度观察场景,渲染出从该角度下看到的情景,不指定观察角度和范围就无法进行渲染。其次,从游戏设计角度看,如果玩家能在场景中随意浏览,就能看到本不应当看到的东西,游戏机制也就乱了套。

因此这里的“摄像机”就充当了玩家的眼睛,其功能是通过Camera(摄像机)组件实现的,如图2-7所示。

在Camera组件中可以调节摄像机的各种参数,而这里需要关心的是摄像机的位置和角度。无论是电影还是游戏,摄像机的位置和角度都是导演或设计师最重视的,而位置和角度则是通过摄像机的Transform组件来调整的。

另外,除了视频还有音频。Audio Listener(音频侦听器)组件也与Camera组件类似,只不过Camera组件决定游戏画面,Audio Listener决定游戏音频。3D游戏中的声音也是有位置、强度、范围变化的,3D游戏也可以模拟出发声的位置,因此Audio Listener的位置会直接影响最终听到的声音。如果音源位置和Audio Listener位置距离过远,玩家甚至会听不到声音。另外也有无播放位置的音效,则无论从哪收听都一样清晰,如电影旁白、界面音效等。

总之,要在游戏中看到画面,就必须有Camera组件;要想听到声音,就必须有Audio Listener组件。默认的摄像机物体就包含了这两者。

2.1.2 变换组件

在前文的知识中讲到,每个物体有且仅有一个Transform组件。可见与其他组件相比,Transform组件显得非常特别。下面详细介绍它所具有的功能,如图2-8所示。

总体来说,Transform组件掌管着物体的3种空间位置属性:位置、朝向和缩放比例。此外,它还有核心功能——“父子关系”。

1. 位置

图2-9所示的Transform组件的位置(Position)有X、Y、Z这3个值,用来表示或修改物体在空间中的位置。其中X代表右方,Y代表上方,Z代表前方,读者可以记住这个对应关系,非常有用。

这3个值均为浮点数,且符合国际标准单位制,也就是说单位长度是1米。

我们既可以在场景窗口中用位移工具修改物体位置,又可以直接在Inspector窗口中修改数据来指定物体的位置。

2. 朝向

Transform组件中的Rotation原意为旋转。由于“旋转”这个词容易在“旋转的动作”和“已经旋转到的位置”之间混淆,因此在本书中会使用“朝向”一词,以表明它是物体目前所具有的状态。

按照三维设计软件的惯例,Unity中朝向也是用3个角度表示,分别是绕x轴、y轴和z轴的旋转角度,这种用3个角度表示朝向或旋转的方法叫作欧拉角(Euler Angle)。例如,要把一个立方体向右旋转45°,只需要将它的朝向的Y值改为45。

提前说明,用欧拉角调整场景中的物体,方便且直观,但实际在软件内部,使用欧拉角表示物体的朝向有着致命的弊端。虽然Unity在编辑器面板上使用欧拉角表示朝向,但在引擎内部是使用四元数表示朝向和旋转的。此问题会在第4章游戏开发数字基础中进行详细介绍。

3. 缩放比例

Transform组件中的Scale代表缩放比例。很明显,旋转、位移、缩放都包含X、Y、Z这3个数值,很容易想象到,缩放也是沿x轴、y轴、z轴的伸缩。例如一个立方体,只沿x轴放大,就会变成一个长棒;这时再沿z轴放大,会变成一个平板;然后通过调整沿y轴缩放的大小,可以调整平板的厚度。这些操作都非常直观。

物体是按比例缩放的,缩放的大小不能代表物体的大小。例如,1米的棒子伸长到10倍是10米,而10米的棒子伸长10倍是100米。缩放本身没有物理单位(或者说单位是1),物体的最终长度是本身长度乘以缩放比例得到的。

缩放默认使用的是物体局部坐标系。例如制作的长10米,宽、高各1米的长棒,如果随意旋转它,它的外形依然是长棒,也就是说同一个物体沿x轴、y轴、z轴缩放的比例不受旋转的影响。这一概念要在理解了世界坐标系和局部坐标系之后才好深入讲解。

4. 父子关系

一个场景中可以有很多物体,而这些物体并不是随意散布在场景里的,而是有“父子关系”的,如图2-10所示。

“父子关系”让多个物体形成嵌套的、树形的结构。很多游戏开发技术,如骨骼动画、指定旋转的锚点、统一物体生命期等问题都可以用“父子关系”表示或解决。由于“父子关系”很重要,因此下一小节将专门探讨“父子关系”。

2.1.3 “父子关系”详解

前文提到,Unity有着简洁的架构,对于笔者来说,最重要的概念只有物体、组件和“父子关系”。但是简洁性不能以牺牲功能为代价,在实际游戏开发中,有各种各样的实际需求。对于这些实际需求,引擎一定要提供可行的解决方案,否则引擎就是有缺陷的。接下来的内容可以说明,只要用好“父子关系”这一特性,就能很好地解决这些实际问题,而不需要引入其他概念。

1. 使用“父子关系”复用零件

在某些游戏引擎中,可以重复使用的零件也被称为“组件”。例如,制作出的一个灯泡既可以用在吊灯上,也可以用在台灯上。但是这样会带来一个概念上的小问题,因为一旦组件可以单独存在,那么概念就变得复杂了——又多出来一种可以单独存在的“组件”,甚至它还可以有自身的位置,如灯泡在吊灯里的位置是可以调节的。

而Unity严格规定了物体和组件分别能做什么、不能做什么。例如,组件必须挂载于物体之上,不能单独存在;组件本身不具有位置参数;由于组件与物体是一体的,因此脚本中引用组件实际上是引用了物体。这样的规定让组件无法独立存在,从表面上看限制了实现时的灵活性。

可以重复使用的灯泡显然是必要的,在很多游戏中都可以找到重复使用物体的例子。对复用零件的问题,Unity的解决方案是使用子物体。简单来说,只要把灯泡做成一个单独的物体,然后把它作为吊灯或台灯的子物体,这样灯泡就成了吊灯或台灯的一部分,问题就解决了。由于子物体可以任意移动位置,因此仍然可以把它放在吊灯的任意位置。

具体来说,一个灯泡可以由3D模型、点光源组成,也可以再给它加一些子物体装饰。无论灯泡做得多么花哨,但作为一个整体它用起来依然很方便,如图2-11所示。

2. “父子关系”与局部坐标系

每个场景整体都有一个大的坐标系,称为世界坐标系。世界坐标系也有x轴、y轴和z轴,在Unity中分别规定为右方、上方、前方。

而在很多情况下,只有世界坐标系是不够的。例如,在移动台灯的时候,希望灯泡能一起移动;旋转台灯的时候,希望灯泡能一起旋转;放大台灯的时候,希望灯泡能一起放大。在这里,台灯就构成了一个局部的坐标系,它内部物体的位移、旋转、缩放都要受到局部坐标系的影响,如图2-12所示。

简单来说,局部坐标系就是父物体自身的坐标系,父物体的位置就是局部坐标系的原点,父物体的右方、上方、前方分别就是局部坐标系的x轴、y轴和z轴。一旦缩放父物体,所有的子物体也会缩放。

举个实际例子,局部坐标系有一个非常经典的应用——实现房门的转动。

如果直接旋转一个门,一般门会沿着模型的中线旋转。如果要规定门的转轴的位置,就可以给门做一个空的父物体,将父物体定位在门的转轴位置。这时旋转父物体,门就会绕父物体转动了,如图2-13所示。

3. 用“父子关系”表示角色的骨骼

骨骼动画是现代3D游戏不可或缺的基本功能。简单来说,3D角色不仅有一个模型外观,该模型内部还有一个虚拟的骨骼,这个骨骼的运动会拉扯模型的表面材质跟着运动,这样一来只需要移动骨骼,就可以让模型做出各种动作了。

如果将人类的骨骼结构看作一种连接结构,那么从腰部往下看,有臀、左大腿、右大腿等,腰部以上则有躯干、左右胳膊、头部等,每一个可旋转的部分都有对应的关节连接。肩膀的旋转会带动整条胳膊的旋转,肘的旋转会带动小臂和手的旋转,再结合前文提到的局部坐标系思考,这些骨骼关系正好可以用“父子关系”表示,如图2-14所示。

事实也确实如此,Unity直接用“父子关系”表示模型骨骼,再在模型表面蒙上一层可伸缩的网格,就像皮肤一样(皮肤一样的网格组件叫作Skinned Mesh),这样就用“父子关系”解决了骨骼动画的问题。

小知识

Skinned Mesh Renderer组件

在Unity中看不到单独的Skinned Mesh组件。带骨骼的三维模型使用Skinned Mesh Renderer统一管理网格和材质,而不像一般的模型用Mesh Filter和Mesh Renderer分别表示。

4. 利用父物体统一管理大量物体

某些游戏会大量生成同类物体,如射击游戏中的子弹物体、塔防游戏中的大量敌人等。很多时候需要统一管理这些子弹,如一起隐藏、一起销毁等。如果它们散布在整个场景中,要同时对它们进行操作就会很麻烦。这时可以创建一个空物体作为父物体,让这些大量的同类对象都成为父物体的子物体。那么只要隐藏父物体,它们就会一起隐藏;只要销毁父物体,它们就会一起销毁。也就是说,结合父物体可以轻松设计出有效的物体管理器。

注意

慎重使用非等比例缩放

父物体的缩放会直接引起子物体的缩放,可以认为父物体缩放后,形成了一个按比例拉伸或收缩的局部坐标系。其中,如果沿x轴、y轴和z轴缩放的比例相同,就叫作等比缩放,如果沿3个轴缩放的比例不相等,就叫作非等比缩放。

等比缩放和非等比缩放实际差异很大。读者可以想象一下,当存在多级嵌套的“父子关系”,每一级物体都具有不同的缩放以及不同的旋转角度时,那么最后一级子物体的局部坐标系将会多么复杂和难以理解。

Unity官方文档指出:如果在父物体上使用了非等比缩放,那么在某些特殊情况下有可能导致子物体的位置、朝向计算错误,因此建议在父物体上尽可能避免非等比缩放。图2-15展示了立方体在父物体为非等比缩放时的一种情形。

图2-15 在父物体上使用非等比缩放的情形

2.1.4 物体的标签和层

在设计中可以使用物体的名称标识一个物体,但是很多时候名称会有冗长、重复和易变的问题。现代游戏引擎都具有标签(Tag)这一功能,简单来说,标签就是物体的另一个名字,但它有另一些特点。

与标签类似,层(Layer)也很常用,它更多的是与碰撞检测相关。例如,经典游戏《暗黑破坏神》中,玩家技能只能伤害怪物,而不会伤害玩家。那么就可以将技能、怪物、玩家分别定义在PlayerSkill、Monster、Player这3个不同的层,并在物理系统中指定PlayerSkill层只会和Monster层产生碰撞,而不会和Player层发生碰撞。

在Inspector窗口顶部可以查看和修改物体的Tag和Layer选项,如图2-16所示。

小提示

层(Layer)与层级窗口(Hierarchy)窗口不同

这里说的“层”指的是物体的“层”(Layer),而不是层级窗口(Hierarchy)窗口的“层级”,层级窗口的“层级”指的是“父子关系”层级。

英文中,往往每一个概念对应一个专用的名词。而中文一般使用词组,相似的词组容易混淆,注意区分即可。

1. 标签(Tag)的简要说明

①引擎内部对物体的标签建立了索引。通过标签查找物体,要比通过名字查找物体快得多。

②标签最多只能有32个。前几个是常用标签,具有特定含义,例如玩家(Player)、主摄像机(Main Camera)等。后面空白的标签可以自行定义和使用。

③举个例子,在射击游戏中,可以将表示玩家的物体标记为Player,将所有表示怪物的物体标记为Monster。这样无论玩家和怪物的名字是什么,都可以方便地编写逻辑代码。这些标签既便于查找所有怪物,又可以用于判断物体是不是怪物。

④善用标签有助于团队协作。例如,事先定义好游戏中的各类标签,很容易就知道某个物体是做什么用的,从而可以让关卡设计师、美术设计师和软件工程师更好地协作。

2. 层(Layer)的简要说明

①层与标签一样,也最多只有32个,同样也是前几层有特殊用途,例如默认层(Default)、透明特效层(TransparentFX)、忽略射线层(Ignore Raycast),未被引擎占用的层都可以自行定义和使用。

②层的第一个常用用法就是定义游戏世界中层与层之间是否发生碰撞。例如,足球游戏中,可以让足球和场上的裁判处于不同的层,且让两个层不会碰撞,这样可以避免很多麻烦。

③层的第二个常用用法与射线检测有关。“射线”是一条虚拟的线,大部分触屏、鼠标操作的3D游戏都要用到它。例如,用户单击地面时,就会向游戏世界发射一条射线,以确定单击到了什么位置。有时障碍物会阻挡射线,这时就可以设定该射线仅与地面层碰撞,而不与障碍物层碰撞,从而改善操作体验。

2.1.5 常用组件

Unity的组件非常庞杂,但用户可以随时制作新的组件,也可以在资源商店(Asset Store)里下载更多组件。表2-1展示了一些最常用的组件,供读者了解和参考。

2.2 用脚本获取物体和组件

Unity的初级脚本编写没有过于困难的部分,大多数读者遇到障碍的原因在初始学习时跳过了关键性知识。如果跳过了关键性知识,可能会造成知识体系断层,导致进一步学习变得困难。

虽然在第1章已经写过一些脚本,但是读者可能对每一句代码的含义和细节还不甚清晰,仍然需要一步一步把跳过的知识补齐。下面将会系统性地讲解脚本编程时最基本和较常用的功能,相信读者经过实践之后,就会对脚本编程具有更清晰的认识。

2.2.1 物体、组件和对象

为避免未来的讨论产生混淆,先澄清一个基本问题:某个自己编写的脚本是一种组件类(class),挂载到物体上的脚本是一个实例化的组件,即一个对象(编程语言中的object)。

从面向对象的角度来看,Unity的逻辑架构是非常自然的。一个游戏物体是一个对象;没有被挂载到物体上的脚本,是一个未被实例化的类,暂时还不是具体的对象;当脚本被挂载到物体上以后,就成了一个实实在在的对象。例如,同一个脚本被挂载到物体A上2次,又被挂载到物体B上1次,这样就创建了该脚本的3个实例,共有3个脚本组件对象。

脚本在执行时,一般已经挂载到了某个物体上。因此在脚本代码中,可以随时访问脚本目前挂载到了哪个物体对象,直接用gameObject即可。

// 输出脚本当前所挂载的物体对象的名称
Debug.Log(gameObject.name);

越是简单的代码越需要理解清楚。在脚本代码中,this表示该脚本对象自身,而gameObject是当前脚本的一个属性,指的是当前的物体,因此可以直接获取到。如果上文的代码不省略this,则完整写法如下。

// 输出脚本当前所挂载的物体对象的名称
Debug.Log(this.gameObject.name);

只要清楚物体、组件和对象的关系,下文的很多讨论都不难理解。暂时有点模糊也没有关系,可以在阅读和实践本章的内容以后,再回头揣摩前文所讲的每句话的含义。

2.2.2 获取组件的方法

获得某个物体以后,就可以通过物体获取到它的每一个组件。举个例子:先创建一个球体,新建一个脚本文件Test并将其挂载到球体上,脚本内容保持默认。球体的组件信息如图2-17所示。

为了获取到Sphere Collider组件,可以直接用游戏物体的GetComponent()方法,将Test脚本内容修改如下。

using UnityEngine;

public class Test : MonoBehaviour
{
    SphereCollider collider;
    void Start()
    {
        // 获取到组件后,将它的引用保存在collider字段中,方便下次使用
        collider = gameObject.GetComponent<SphereCollider>();
    }
}

以上是很常见的获取组件的代码,即先从当前脚本组件获取到游戏物体,再到游戏物体中找到某种组件,这种写法非常符合之前学习的概念。

Transform组件也可以用GetComponent()方法获得,但是由于Transform组件太过常用,因此随时可以通过字段transform访问到Transform组件,不需要通过代码获取。

Unity为了方便,设计了获取组件的简便写法。其思路是,在同一个物体上,从任意一个组件出发都可以直接获取到其他组件,不需要先获取游戏物体。也就是说,上面获取组件的写法有很多等价形式,具体如下。

collider = gameObject.GetComponent<SphereCollider>();
//  以下每一句写法均与上面一句等价
collider = this.GetComponent<SphereCollider>();
collider = GetComponent<SphereCollider>();    //  同上,省略了this
collider = transform.GetComponent<SphereCollider>();  // 通过transform组件获得其他组件
collider = transform.GetComponent<MeshRenderer>().GetComponent<SphereCollider>();
collider = transform.GetComponent<SphereCollider>().GetComponent<SphereCollider>(); 
//  多此一举的写法,但是结果也正确

初学者在看别人的代码时,很可能没有注意到GetComponent()方法的主体有时是物体,有时是组件,而上面的例子充分说明了获取同一个物体上的组件是十分灵活的。这里有一个明显的推论,也是编程时的常用技巧——可以用物体上任意一个组件代表该物体。也就是说,物体的一部分可以指代物体本身,因为它们同属于一个物体。

当一个物体可能包含多个同类型组件时,也可以直接获取到所有同类的组件,该方法名为GetComponents,它会返回一个装着所有找到的组件的数组。为了测试方便,将Test脚本挂载到物体上并重复3次,如图2-18所示。

Test脚本的Start()方法修改如下。

void Start()
{
    collider = GetComponent<SphereCollider>();
    // 获取到所有的脚本组件,放在数组中
    Test[] tests = GetComponents<Test>();
    Debug.Log(" 共有 " + tests.Length + " 个Test 脚本组件 ");
}

如果运行游戏,发现在Console窗口中输出了3遍“共有3个Test脚本组件”,可以分析一下为什么。

2.2.3 获取物体的方法

前面都是在同一个物体上操作,获取同一个物体的各个组件,接下来要直接获取不同的物体。因为很多时候都需要让代码同时操作多个不同的物体,这样才能得到更高的灵活性。例如玩家开枪时,要同时控制枪、子弹和火焰粒子等。

1. 通过名称获取物体

可以通过物体的名称直接获取物体,使用GameObject.Find()方法即可。新建一个立方体,并挂载一个TestGetGameObject脚本,内容如下。

public class TestGetGameObject : MonoBehaviour
{
    GameObject objMainCam;
    GameObject objMainLight;
    void Start()
    {
        objMainCam = GameObject.Find("Main Camera");
        objMainLight = GameObject.Find("Directional Light");
        Debug.Log("主摄像机:" + objMainCam.name);
        Debug.Log("主光源:" + objMainLight.name);
        // 将主摄像机放在这个物体后方1 米的位置
        objMainCam.transform.position = transform.position - transform.forward;
    }
}

以上代码获取了场景中的摄像机物体和方向光源物体,接着又把主摄像机移动到该脚本所在的物体后方1米。游戏运行后,在Game窗口中该物体会占据整个屏幕,因为物体离摄像机很近。阅读上面的代码时要注意每一个transform组件具体指的是哪个物体的transform组件。

GameObject.Find方法比较常用,但是它有两个弊端。第一,GameObject.Find()方法无法找到未激活的物体。第二,GameObject.Find()方法需要遍历场景中的所有物体,从性能上看是非常低效的。

为了验证GameObject.Find()方法能否找到未激活的物体,只需要在上面的例子中,禁用场景的方向光源。做法是在Inspector窗口中取消勾选Drectional Light选项即可,如图2-19所示。

这时再次运行游戏,会在Console窗口或者最下方输出一条报错信息,如图2-20所示。

这个错误是一个C#异常,异常类型为NullReferenceException(空引用)。双击该错误,就会定位到相应的源代码,其源代码如下。

Debug.Log("主光源:" + objMainLight.name);

这是由于当前objMainLight变量的值是空引用null,因此访问它的name字段会引发错误。这个例子一方面向读者展示了定位错误的基本方法,另一方面说明了GameObject.Find()方法的确无法找到未激活的物体。

GameObject.Find()方法的第二个弊端是,本质上它需要遍历场景中的所有物体才能找到指定名字的物体(如果有多个重名物体,则会返回最先找到的那个)。由于遍历物体会造成性能问题,因此这种方法非常低效,但在简单场景里很难察觉。而在一个完整的游戏中,场景中经常会有成千上万个物体,那时的效率差异就会相当明显。但是这也不代表就不能使用GameObject.Find()方法,例如按照现在的写法,仅在Start()方法中调用一次GameObject.Find(),然后将物体保存在变量objMainCam中,以后都不用再重新查找,这样就不会产生明显的效率差异。

2. 通过标签查找物体

在第2.1.4小节提到,标签可以用来高效地查找物体,具体方法就是先指定物体的标签,然后使用如下方法。

//  查找第一个标签为Player的物体
GameObject player = GameObject.FindGameObjectWithTag("Player")
//  查找所有标签为Monster的物体,注意返回值是一个数组,结果可以是0 个或多个
GameObject[] monsters = GameObject.FindGameObjectsWithTag("Monster");
//  注:以上两个方法名称的区别是两者差了一个“s”

除了在编辑器中修改物体的标签,也可以在脚本中修改物体的标签。

//  获得某个Player物体
GameObject m = GameObject.FindGameObjectWithTag("Player");
//  将它的标签设置为Cube
m.tag = "Cube";
//  判断m的标签是不是Cube
If (m.CompareTag("Cube"))
{
    ......//  略
}
//  上面的CompareTag用法等价于m.tag == "Cube",推荐使用CompareTag

用标签查找物体的效率明显优于用名称查找物体。

以上就是在场景中直接查找并获取物体的几种方法,再加上之前讲解的通过“父子关系”获取同一物体上的所有组件,将这些知识组合在一起,就掌握了快速定位任意物体或组件的方法,下一小节将详细阐述。

2.2.4 在物体和组件之间任意遨游

1. 通过“父子关系”获取物体

前文详细讲解了“父子关系”的重要性及用途,而读者通过观察Hierarchy窗口会发现,其实拥有大量“父子关系”的物体已经形成了树形结构,如图2-21所示。

既然是树形结构,自然就有从父节点找到子节点、从子节点获取父节点的简单方法。在Unity中,“父子关系”的表达是Transform组件的职责。在父子节点之间查找物体的相关方法和属性分别列举在表2-2和表2-3中。

2. 通过父子路径获取物体

只要合理运用上面介绍的方法和属性,就可以在“物体树”上灵活地移动到父节点或某个子节点,还可以通过路径一次移动很多步。其中有一个概念需要详细说明,那就是物体在场景中的“路径”。

图2-22所示的模型具有完整的骨骼结构,“父子关系”层次较多。其中根节点叫作unitychan,骨骼第一层叫作Character1_Reference,第二层叫作Character1_Hips(代表臀部),臀部的下面还包含了左腿(LeftUpLeg)、右腿(RightUpLeg),以及衣服、裙子等节点。

如果将脚本挂载到这个物体的根节点unitychan上面,然后从这里出发,找到右腿,则这个路径的表达如下。

"Character1 _ Reference/Character1 _ Hips/Character1 _ RightUpLeg"

查找物体的路径与操作系统的文件路径类似。当需要指明下一级节点时,就写出该节点的名称。如果还要继续指明下一级,就加上斜杠符号“/”分隔。如果要引用上一级节点,使用两个英文句号“..”即可。这样一来,理论上从一个物体出发就可以获取到场景中任意一个节点。其完整的测试代码示范如下。

using UnityEngine;

public class TestGetTransform : MonoBehaviour
{
    void Start()
    {
        Transform rightLeg = 
transform.Find("Character1 _ Reference/Character1 _ Hips/Character1 _ RightUpLeg");
        Debug.Log("获得了" + rightLeg.gameObject.name);

        Transform root = rightLeg.Find("../../..");
        Debug.Log("从右腿回到第一层节点" + root.gameObject.name);

        Transform leftLeg = rightLeg.Find("../Character1 _ LeftUpLeg");
        Debug.Log("从右腿出发找到左腿" + leftLeg.gameObject.name);
    }
}

上文的代码演示了3种情况。一是从根节点出发获取右腿;二是从右腿出发获取根节点,连用了3个“..”;三是从右腿出发获取左腿,方法是先返回上一级,再查找左腿。

3. 其他查找父子物体的方式

使用路径已经足够用来查找父子物体了,但某些情况下使用另一些方法更合适。例如获取父物体可以用transform.parent属性。

//  以下两种写法等价,p1与p2相同
Transform p1 = transform.parent;
Transform p2 = transform.Find("..");

获取子物体时,可以用子物体序号指定,所谓子物体序号需要说明一下。同一个父物体的第一级子物体都是兄弟关系(不包括子物体的子物体),子物体序号就是兄弟之间的序号,如长子、次子、三子的编号分别为0、1、2,依此类推。因此也可以用整数序号指定子物体。

//  演示:用序号获取右腿
Transform hips = transform.Find("Character1 _ Reference/Character1 _ Hips");
Transform rightLeg = hips.GetChild(1);

既然能用序号获取子物体,那么如果能知道子物体的总数,然后再结合二者使用就能得到遍历子物体的方法。很多时候要对所有子物体做统一的操作,只要结合使用transform.GetChild()方法和transform.childCount()方法即可,其示例代码如下。

void Test()
{
    for (int i=0; i<transform.childCount; i++)
    {
        GameObject child = transform.GetChild(i).gameObject;
        Debug.Log("第"+ i +"个子物体名称为:" + child.name);
        Debug.Log("它还有 " + child.transform.childCount + "个下一级子物体");
    }
}      

以上代码遍历了当前脚本所挂载物体的所有子物体,然后输出它们的名字,并输出每个子物体还拥有几个子物体。

4. 一些有用的技巧

在实际游戏开发中,经常用嵌套的多层“父子关系”表示一个复杂物体。为了方便起见,Unity提供了一系列直接从子物体中获取组件的方法,有了它们就不需要像GetComponent()方法那样先找到对应物体才能获取组件。这是一系列很实用的方法,虽然它们也可以用基本方法组合而成的方法代替,但是使用它们在很多情况下能精简代码,事半功倍,这些内容列举在表2-4中。

注意,以上方法以及常规的GetComponent()方法都可以通过任意游戏物体或任意组件调用,因为这些方法位于Unity的基类Object里,而游戏物体与组件都继承了Object类。

2.2.5 利用公开变量引用物体和组件

前面已经几乎介绍了所有获取物体与组件的方法,另外还有一种很常用、学习门槛也低的方法——使用公开的变量指定物体或组件。

首先在任意脚本组件中,添加一个公开的GameObject类型的变量。

using UnityEngine;

public class TestGetTransform : MonoBehaviour
{
    public GameObject other;
    void Start()
    {
        if (other != null)
        {
            Debug.Log("other 物体名称为 "+other.na me);
        }
        else
        {
            Debug.Log(" 未指定 other物体 ");
        }
    }
}

然后查看Inspector窗口,脚本属性中会多一个该变量的编辑框,默认值为None(类型),如图2-23所示。在第1章中已经提到,可以直接把任意符合该类型的物体拖曳到编辑框中。

得益于自洽的物体、组件体系,脚本中也可以使用任意组件类型的变量代替GameObject。

using UnityEngine;

public class TestGetTransform : MonoBehaviour
{
    public GameObject other;
    public Transform otherTrans;
    public MeshFilter otherMesh;
    public Rigidbody otherRigid;

    void Start()
    {
        // 可以任意使用前面定义的变量
    }
}

以上代码会改变Inspector窗口中的脚本属性,结果如图2-24所示。

上文的代码一共公开了4个变量,分别是Game Object、Transform、Mesh Filter和Rigidbody。以Other Mesh编辑框为例,任何具有Mesh Filter组件的物体都可以被拖入此框里,但是没有Mesh Filter组件的物体就不能被拖进来。

前文提过,Unity中每个组件一定有所挂载的物体。虽然变量的类型为Mesh Filter,但它所代表的是具有Mesh Filter的对象,而不是独立存在的组件。这一点初学者往往会有疑问,但清楚以后会发现这样设计是严谨、合理的。

这种拖曳的编辑方式在大型游戏项目中也很常用,某些项目往往会在脚本中使用数十个可编辑的公开变量,甚至会使用可改变长度的组件列表,方便添加更多数据,这也有助于程序员与设计师的协作。但是也有一些开发团队不喜欢这种方式,因为容易拖入错误的物体。具体是否使用这种方法依具体情况而定,但可以肯定的是,它是一种值得考虑的方案。

拖曳组件或物体来引用其他物体的方式非常直观方便,而且可以用组件类型作为限制,防止拖入错误类型的物体。

本小节讲解的获取物体和组件的方式主要有以下6种。

①通过名称或标签,可以找到任意未禁用的物体。

②通过“父子关系”,从一个物体出发,可以沿路径找到任意物体。

③只要获得了某个游戏物体或者该物体上的任意组件,就可以得到所有其他组件,也可以通过任意组件获得物体本身。

④可以遍历某个物体下一级的所有子物体。(遍历所有层级的子物体需要用到搜索算法)

⑤编写脚本组件时,this是指当前的脚本组件,this.gameObject就是本组件所挂载的物体,this.transform则是本物体的Transform组件。

⑥可以使用公开变量的方式,在编辑器里拖曳或选取物体。

这6种方式都是Unity脚本编程的基础,类似掌握了九九乘法表就能快速计算乘法一样,读者只要在实践中选用最合适的方法,就能快速获取到任意物体和组件。

最后补充一个实例,以说明怎样灵活运用多种查找方式。在UI中,界面往往是多种控件嵌套拼接起来的。如果需要在游戏开始时设置背包界面的内容,但背包界面(根节点是ItemPanel)默认是未激活状态,如图2-25所示。如果这里使用GameObject.Find(),则会因为未激活而找不到ItemPanel物体;如果使用路径查找,从另一个物体出发找到根节点Canvas的路径并不好写。那么如何解决呢?这里有一个好方法——分两步查找。

using UnityEngine;

public class TestGetTransform : MonoBehaviour
{
    void Start()
 {
         GameObject canvas = GameObject.Find("Canvas");
         // 有了Canvas,就可以用路径获得任意UI物体了
         Transform itemPanel = canvas.transform.Find("ItemPanel");
 }
}

因为大部分游戏都只有一个Canvas(UI画布),Canvas本身是不会被禁用的,所以可以分两步查找,先找到Canvas,再通过Canvas找到背包界面。这种方法在实际游戏开发中很有用,值得参考。

2.3 用脚本创建物体

在游戏设计中,需要用到的物体都可以通过编辑器摆放在场景中。但是很多时候,无法事先创建所有需要的物体,如子弹、炮弹或随机刷新的怪物。这些物体要么是根据玩家操作而随时创建,要么是依据游戏玩法在特定时刻创建,都无法事先确定它们在什么时候出现。

用脚本动态创建物体,即在游戏进行中创建物体,是一项基本功能,因此本节将详细讲解实现这一功能的基本方法。

2.3.1 预制体

在讲解创建物体的方法之前,先讲解如何创建一个预制体,以便对预制体的概念有一个感性的认识。

首先创建物体。例如创建3个球体,组成类似米老鼠的头像。

然后将物体从Hierarchy窗口拖曳到Project窗口中,如图2-26所示。在Project窗口中会看到一个文件名和物体一样的资源文件,可以对它重命名,这种资源文件就叫作预制体。

简单来说,预制体就是一个物体的模板,可以随时从Project窗口再次拖入场景,这就相当于用模板又创建了一个物体。而且在Hierarchy窗口中可以看到,所有借助预制体创建的物体的名称都变成了深蓝色,代表它和预制体有着内在的关联。

在游戏开发的实践中,一般将可能需要动态创建的物体,如怪物、子弹、导弹等都事先做成预制体,然后在游戏运行过程中由脚本负责创建即可。

1. 场景物体与预制体的关联

任何由预制体创建的物体,都会在Inspector窗口中多3个工具按钮,如图2-27所示。

这些工具按钮用于场景物体和预制体之间的操作。

Open:打开预制体。单击它可以打开单独的预制体编辑场景,对预制体的编辑会应用到所有关联的物体。

Select:选中预制体文件。单击它,Project窗口将自动定位到预制体文件,这个功能可以方便地找到当前物体是从哪一个预制体创建的。

Overrides:覆盖预制体。对物体的参数和组件做修改后,预制体文件本身是不变的。单击此按钮后,会弹出一个小窗口提示用户具体修改了哪些属性。

这个小窗口提供了丰富的功能。首先,确认了修改内容后,单击小窗口的Apply All按钮可以让这些修改应用到物体上;而单击Revert All按钮则会撤销所有改动,让物体回到和预制体相同的状态。

另外,小窗口中的每一个具体改动也是可以单击的,单击单独一项改动可以查看和对比修改细节,并且还可以单独应用(Apply All按钮)或撤销(Revert All按钮)某一项改动。

注意

谨防对预制体的误操作

由于对预制体的改动会应用到所有关联的物体上,因此在单击Apply All按钮时要考虑清楚,谨防不慎修改大量物体。如发现误操作,可以及时使用相应指令撤销上一次操作,快捷键为Ctrl+Z。

2. 编辑预制体

在新版本的Unity中,除了可以先修改物体再应用到预制体外,还可以在单独的场景中编辑预制体。鼠标左键双击预制体文件,打开一个独立的场景,在这个场景中对预制体的编辑会保存到预制体文件中。

编辑完成后,单击Hierarchy窗口左上方的向左的箭头,就可以回到主场景,如图2-28所示。

2.3.2 创建物体

利用预制体创建物体,要使用实例化方法Instantiate()。它需要一个预制体的引用作为模版,返回值总是新创建那个物体的引用。如果预制体以GameObject类型传入,那么返回的结果也是GameObject类型。

小提示

任意物体都可以作为模版,但不一定是预制体

预制体的类型是GameObject。有时候由于写代码时的失误,用场景中的某个物体作为Instantiate()方法的第1个参数,同样也能成功创建新物体。

这说明在游戏运行以后,预制体和其他物体有着同等的地位,都可以使用。这种设计一方面增强了脚本的灵活性,另一方面也经常出现因混淆而引起的各种bug。关键是要搞清楚引用对象的关系。

在实际使用时,有时候要具体指定新建物体的位置、朝向和父物体,因此Instantiate()方法也具有多种重载形式,它们的区别在于参数不同。编者挑选了3种常用的重载形式进行说明,如表2-5所示。

小提示

预制体也可以用组件代表

如果读者在IDE里查看Instantiate方法的原型,会发现第1个参数的类型有点奇怪。Instantiate方法的第1个参数是预制体,理应是一个GameObject类型,但实际上,这个参数的类型有Object和泛型两种。

这是由于此方法在设计时,兼容了“用组件代表预制体”这一用法,前面提过组件也可以代表所挂载的物体。如果用某个预制体上挂载的组件作为模板,那么Instantiate方法依然会把该物体创建出来,同时返回新物体上同名的组件。这种设计虽然保持了功能不变,但少了一步获取组件的操作。

从学习的角度出发,将Instantiate看作一种单纯的创建物体的方法,有利于排除细节的干扰,抓住问题的本质。

下面是一个创建物体的脚本范例。

using UnityEngine;

public class TestInstantiate : MonoBehaviour
{
    public GameObject prefab;
    void Start()
    {
        // 在场景根节点创建物体
        GameObject objA = Instantiate(prefab, null);
        // 创建一个物体,作为当前脚本所在物体的子物体
        GameObject objB = Instantiate(prefab, transform);
        // 创建一个物体,指定它的位置和朝向
      GameObject objC = Instantiate(prefab, new Vector3(3,0,3), Quaternion.
identity);
    }
}

以上代码利用预制体创建了3个物体,而且为了获得预制体的引用,特地将prefab变量公开,以便在编辑器中给它赋值。

先在编辑器中给prefab设置初始值,然后再运行脚本,就会以prefab为模版,创建3个物体。

再举一个例子,有时需要有规则地创建一系列物体。例如10个物体等间距围成一个标准的环形,这种情况用编辑器拖曳是很难做到精确的,最好是用脚本创建它们,其代码如下。

using UnityEngine;

public class TestInstantiate : MonoBehaviour
{
    public GameObject prefab;
    void Start()
    {
        // 创建10个物体围成环形
        for (int i=0; i<10; i++)
        {
            Vector3 pos = new Vector3(Mathf.Cos(i*(2*Mathf.PI)/10), 0,
                Mathf.Sin(i*(2*Mathf.PI)/10));
            pos *= 5;       // 圆环半径是5
            Instantiate(prefab, pos, Quaternion.identity);
        }
    }
}

为了让物体围成圆圈,上面的代码用到了圆的参数方程:

由于Mathf.Sin()方法和Mathf.Cos()方法的参数为弧度,因此代码中出现了Mathf.PI,它代表圆周率π。

2.3.3 创建组件

创建组件并将其添加到物体上,通常使用GameObject.AddComponent()方法。以下代码先获取Cube物体,再给它添加Rigidbody组件。

using UnityEngine;

public class TestInstantiate : MonoBehaviour
{
    void Start()
    {
        GameObject go = GameObject.Find("Cube");
        go.AddComponent<Rigidbody>();
    }
}

AddComponent使用时要带上一个尖括号,里面写上要创建的组件的类型。这种写法与GetComponent类似,它们都利用了C#的泛型语法。AddComponent是GameObject类的方法,调用主体是某个游戏物体。

2.3.4 销毁物体或组件

使用Destroy()方法可以销毁物体或组件。为了演示效果,下面编写一个略带互动性的例子。

using UnityEngine;

public class TestDestroy : MonoBehaviour
{
    public GameObject prefab;
    void Start()
    {
        // 创建20个物体围成环形
        for (int i=0; i<20; i++)
        {
            Vector3 pos = new Vector3(Mathf.Cos(i*(2*Mathf.PI)/20), 0,
      Mathf.Sin(i*(2*Mathf.PI)/20));
            pos *= 5;       // 圆环半径是5
            Instantiate(prefab, pos, Quaternion.identity);
        }
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.D))
        {
            GameObject cube = GameObject.Find("Cube(Clone)");
            Destroy(cu be);
        }
    }
}

这段代码演示了创建物体与销毁物体,它是在之前创建物体的代码基础上修改而成的。运行游戏时,会先创建20个物体,然后每当用户按D键,就会删除一个物体。

在实践中会发现很多细节,如用Instantiate()方法创建的物体会带上“(Clone)”后缀,因此仅仅阅读书本是不够的。

接下来有个问题是,如果将上文的代码修改如下,会导致错误吗?

void Update()
    {
        if (Input.GetKeyDown(KeyCode.D))
        {
            GameObject cube = GameObject.Find("Cube(Clone)");
            Destroy(cu be);
            cube.AddComponent<Rigidbody>();
        }
    }

上述代码表示销毁cube之后,又紧接着给cube添加Rigidbody组件。用程序思维来考虑,这个做法是有问题的,销毁物体会导致引用失效,而使用失效的引用会导致异常。

但是通过测试,竟然发现没有错误。这是因为Unity在设计之初就考虑了引用失效的问题,因此在执行Destroy()方法后,并不会立即销毁该物体,而是稍后放在合适的时机去销毁。这样就保证了在当前这一帧里,对cube的操作不会产生错误。

在个别情况下如果有立即销毁的需求,Unity提供了DestroyImmediate()方法。

在游戏开发中,代码执行的“时机”是一个根本性的难题,实践中大量bug背后都是时机不合适导致的。这点读者要在实践中慢慢体会。

2.3.5 定时创建和销毁物体

游戏中延迟创建物体和延迟销毁物体是常见的需求。

延迟创建物体一般用于等待动画结束和定时刷新怪物等。延迟销毁物体则用于定时让子弹、尸体消失等情况,因为游戏中的物体不能只创建而不销毁,不然物体会越来越多,从而导致游戏卡顿甚至无响应。

延迟需要准确定时,如在未来的第几秒执行。在2.5节会讨论协程方式延迟,而这里用简易的Invoke()方法。Invoke()方法有两个参数,第1个参数是以字符串表示的方法名称,第2个参数表示延迟的时间,单位为秒。

Invoke()方法可以延迟调用一个方法,但要求该方法没有参数也没有返回值。

可以用Invoke()方法编写一个每隔0.5秒生成一个物体的动态效果,其代码如下。

using UnityEngine;

public class TestInvoke : MonoBehaviour
{
    public GameObject prefab;
    int counter = 0;
    void Start()
    {
        Invoke("CreatePrefab", 0.5f);
    }

    void CreatePrefab()
    {
      Vector3 pos = new Vector3(Mathf.Cos(counter * (2 * Mathf.PI) / 10), 0, 
Mathf.Sin(counter * (2 * Mathf.PI) / 10));
        pos *= 5;       //  圆环半径是5
        Instantiate(prefab, pos, Quaternion.identity);
        counter++;
        if (counter < 10)
        {
            Invoke("CreatePrefab", 0.5f);
        }
    }
}

由于Invoke()方法不支持参数,因此需要用巧妙的方式实现,读者可以把它当作一个程序设计练习进行阅读。上面的代码依靠在被调用方法的内部再次调用Invoke(),模拟实现了循环过程,并用counter计数器限制了循环的次数。试着执行上面的代码并分析过程,有助于理解Invoke调用的过程。

与延迟创建物体类似,延迟销毁也同样可以用Invoke()实现。但延迟销毁的需求更为常见,因此Unity为Destroy()方法增添了延时的功能。Destroy()方法的第2个参数就可以用于指定销毁延迟的时间。为了试验,可以修改第2.3.4小节中测试程序的Update()方法。

 void Update()
    {
        if (Input.GetKeyDown(KeyCode.D))
        {
            GameObject cube = GameObject.Find("Cube(Clone)");
            //Destroy(cu b e);
            //  将上一句改为延迟0.8 秒
            Destroy(cube, 0.8f);
            cube.AddComponent<Rigidbody>();
        }
    }

这样修改,可以在按住D键0.8秒以后,销毁物体。当然这里只是讲解了其原理和用法,更好的使用场景见本章最后的完整游戏实例。

2.4 脚本的生命周期

到这里为止,本书已经讲解了Unity的大部分核心功能。下文将再补上一环——脚本的生命周期。

脚本的生命周期(MonoBehaviour Lifecycle)是Unity官方给出的术语。实际上读者可以简单将它理解为,一个脚本的创建和销毁两个关键事件,以及在此过程中可能触发的各种事件。这里最关心的是所有事件的种类,以及它们的触发时机,因为脚本逻辑只有写在合适的事件里,且在合适的时机执行,才能恰到好处地实现想要的功能。

Unity提供的事件非常多,大部分事件暂时用不到。接下来分模块、挑重点进行讲解。

2.4.1 理解脚本的生命周期

首先要确定,脚本虽然功能强大,但它毕竟是Unity的众多系统之一,完全受到引擎的管理和调度。在脚本中可以编写各种功能,但什么时候脚本会被调用,这完全由Unity决定。

以熟悉的Start()方法和Update()方法为例。当在组件脚本中写下Update()方法时,就意味着向引擎“注册”了更新事件。当引擎对所有组件执行更新操作时,也会捎带这个脚本组件。反过来说,如果没有定义Update()方法,那么引擎在更新时,就会跳过这个脚本。

小提示

建议彻底删除不需要的事件方法

默认脚本中已经写好了Start方法和Update方法。如果不需要Update方法,最好将它的定义彻底删除。

经过上面的讲解,读者应该能想到原因:只要Update存在,就算内容是空的,这个空白的方法也会被调用,理论上每秒会带来数十次调用方法的性能开销。虽然这个性能开销很小,但是调用频率较高且毫无必要。

可以把引擎每一帧需要做的事,想象成在标准跑道上跑一圈。在跑一圈的过程中会有很多项常规工作,也有一些突发事件需要处理。引擎允许脚本订阅它所挂载物体的各类事件,当这个事件发生时,引擎就会通知脚本组件,并运行相应的方法。这些事件的种类较多,大体上包含了初始化、物理计算、更新、渲染和析构等方面,因此编者挑选了一些常用的脚本事件,归纳在图2-29中。

为帮助读者更好地理解以上内容,再举一个例子。现在读者应该知道,脚本代码的执行,只是引擎整体运行的一个小环节。由于脚本执行时还占用着计算资源,引擎还等待着脚本执行完毕,因此脚本方法必须尽快执行,尽快返回。如果方法的执行时间超过了数十毫秒,就会引起明显的脚本卡顿,如果脚本出现了死循环等问题,就会导致整个游戏进程卡死。读者可以在Start方法中编写一个死循环做试验。

由于不能妨碍引擎的正常运行,因此当需要延迟或定时执行操作时,不能用死循环或休眠等方式,以免影响代码的执行。2.3.5小节所说的Invoke()方法和之后讲解的协程,都是用来实现延迟或定时操作的方式。

小提示

死循环会导致Unity程序卡死

在脚本中出现死循环等情况,会导致Unity主进程卡死。如果彻底卡死无响应,就只能用计算机操作系统的任务管理器强行结束任务。

2.4.2 常见的事件方法

MonoBehaviour的事件非常多,官方文档中共列举了64个。下面将其中较为常见的几十个事件按逻辑分类,并列举出来,如表2-6所示。

读者可以浏览各种事件函数,大致了解引擎提供的各种事件,方便未来实践时使用。

2.4.3 实例:跟随主角的摄像机

在第1章制作的3D滚球跑酷游戏里,已经实现了让摄像机跟随主角小球移动的功能。直接把摄像机作为小球的子物体,虽然是一种较方便的做法,但是也有很大缺陷,如小球旋转时摄像机也会跟着旋转。而让摄像机跟随小球移动最好的方法是让摄像机受脚本控制单独运动,而不是作为子物体直接受其他物体控制。

制作跟随物体平移的摄像机,步骤如下。

01 新建脚本FollowCam,并将它挂载到Main Camera(主摄像机)上。

02 编辑脚本代码如下。

using UnityEngine;

public class FollowCam : MonoBehaviour
{
    // 追踪的目标,在编辑器里指定
    public Transform followTarget;
    Vector3 offset;
    void Start()
    {
        // 算出从目标到摄像机的向量,作为摄像机的偏移量
        offset = transform.position - followTarget.position;
    }
    void LateUpdate()
    {
        // 每帧更新摄像机的位置
        transform.position = followTarget.position + offset;
    }
}

03 回到Unity编辑器,给脚本指定追踪的目标即可,如图2-30所示。

跟随摄像机的脚本有很强的通用性,可以用在任意游戏里。例如,可以把它应用在第1章制作的3D滚球跑酷游戏里,需要修改的地方只有以下3点。

(1)将FollowCam脚本挂载到主摄像机上。

(2)拖曳主角小球到脚本组件的Follow Target参数上。

(3)拖曳Hierarchy窗口里的主摄像机标签,使其不再作为小球的子物体,而是作为独立物体。

再次运行游戏,可以看到效果和以前几乎没有区别。虽然效果相似,但是原理已经大不相同了。最后,注意观察在脚本代码中,特意将摄像机位置的更新写在LateUpdate()方法中,而不是Update()方法中。这是为了确保在主角移动之后再移动摄像机,避免摄像机在主角之前更新位置,但这样可能会造成画面运动不平滑的效果。

2.4.4 触发器事件

在第1章的实例里,笔者在设计游戏时使用了尽可能少的Unity功能,但是在制作小游戏时会发现一点——很难避免使用触发器。如果没有触发器,就需要用大量数学运算来检测物体之间的碰撞。本节将专门介绍与触发器有关的3个事件:OnTriggerEnter、OnTriggerStay和OnTriggerExit。

演示工程很简单,在默认场景中创建一个立方体和一个球体,如图2-31所示。

接下来的操作步骤如下。

01 假设小球是运动的,且已经有了碰撞体组件。给小球添加Rigidbody组件,并勾选刚体的Is Kinematic选项。

02 假设立方体表示一个静止的范围,勾选立方体的Box Collider中的Is Trigger选项,将它变成一个触发器。

03 如果需要一个透明但有触发效果的范围,可以禁用立方体的Mesh Renderer组件。

04 创建脚本TestTrigger,并将其挂载到立方体上,其内容如下。

小提示

必须给运动的碰撞体加上刚体组件

要让两个物体之间产生触发器事件或者碰撞事件,就要求其中一个物体必须带有刚体组件(可以是动力学刚体)。如果两个物体均不含刚体组件,那么就不会触发物理事件。

那么在两个物体中,应该给哪一个物体挂载刚体组件呢?答案是应当给运动的物体挂载刚体组件(可以是动力学刚体)。这些规定背后的原因,会在第3章物理系统中详细讲解。

using UnityEngine;

public class TestTrigger : MonoBehaviour
{

    private void OnTriggerEnter(Collider other)
    {
        Debug.Log("---- 碰撞! " + other.name);
    }

    private void OnTriggerStay(Collider other)
    {
        Debug.Log("---- 碰撞持续中…… " + Time.time);
    }

    private void OnTriggerExit(Collider other)
    {
        Debug.Log("==== 碰撞结束 " + other.name);
    }
}

这样一个简单的测试脚本足可以体现这3个触发事件的含义了。为简单起见,运行游戏后,在场景窗口中将球体直接移动到立方体范围内,然后再远离,就会依次触发这3个事件。其在Console窗口里输出的内容如图2-32所示。

如果仔细观察,会发现输出信息的时间间隔是0.02秒,这正是默认的FixedUpdate事件的时间间隔。这一时间间隔的含义也会在之后的第3章物理系统中详细讲解。

触发器是制作各种游戏时常用的功能,除了在编辑器里设置,编写脚本时也总是会用到与触发器相关的3个事件。

2.5 协程入门

之前提到,定时创建或销毁物体,可以使用Invoke方法。但是通过第2.3.5小节延迟创建多个物体的代码可以看出,大量使用Invoke()方法的代码比较难编写,而且难以理解。实际上,Unity已经提供了“协程”这一概念,专门处理复杂的定时逻辑。

协程的原理有点复杂,本节仅解释它的大致用法,让读者通过简单的例子先将协程技术运用起来,在后文会进一步详细讲解协程的概念。

下面的代码实现了一个简单的计时器,每隔2秒就会在Console窗口中显示当前游戏经历的时间。

using UnityEngine;

public class TestCoroutine : MonoBehaviour
{
    
    void Start()
    {
        // 开启一个协程,协程函数为Timer
        StartCoroutine(Timer());
    }

    // 协程函数
    IEnumerator Timer()
    {
        // 不断循环执行,但是并不会导致死循环
        while (true)
        {
            //  打印4个汉字
            Debug.Log(" 测试协程 ");
            //  等待1秒
            yield return new WaitForSeconds(1);
            //  打印当前游戏经历的时间
            Debug.Log(Time.time);
            //  再等待1秒
            yield return new WaitForSeconds(1);
        }
    }
}

执行效果如图2-33所示。

这里对以上代码做一个简单的解释。StartCoroutine方法开启了一个新的协程函数Timer(),这个协程函数返回值必须是IEnumerator。Timer函数中由于while(true)的存在,会永远运行下去。Timer()函数每当运行到yield return语句,就会暂时休息,而new WaitForSeconds(1)会控制休息的时间为1秒,1秒后又接着执行后面的内容。

换个角度看Timer()函数,它创造了一个优雅的、可以方便地控制执行时间的程序结构,不再需要使用Invoke()那种烦琐的延迟调用方法。任何需要定时执行的逻辑都可以通过在循环体中添加代码,或是再添加一个新的协程函数来实现。不必担心开设过多协程对效率的影响,只要不在协程函数中做很复杂的操作,那么创建协程本身对运行效率的影响非常有限。

有了协程,就可以重写第2.3.5小节里面的例子,其代码如下。

using UnityEngine;

public class CoroutineCreate : MonoBehaviour
{
    public GameObject prefab;
    void Start()
    {
        StartCoroutine(CreateObject());
    }

    // 协程函数
    IEnumerator CreateObject()
    {
        for (int i=0; i<10; i++)
        {
            Vector3 pos = new Vector3(Mathf.Cos(i * (2 * Mathf.PI) / 10), 0, Mathf.
Sin(i * (2 * Mathf.PI) / 10));
            pos *= 5;       // 圆环半径是5
            Instantiate(prefab, pos, Quaternion.identity);
            //  等待0.5秒
            yield return new WaitForSeconds(0.5f);
            // 0.5秒之后继续执行
        }
    }
}

运行结果与第2.3.5小节一致,都是每隔0.5秒创建一个物体,物体围成环形,但是此处代码简单许多。

2.6 实例:3D射击游戏

接下来做一款简易的俯视角度的射击游戏,作为本章的实践项目,如图2-34所示。

2.6.1 游戏总体设计

本游戏是一个俯视角度的射击游戏。玩家从侧上方俯视整个场景,主角始终位于屏幕中心位置。其具体玩法描述如下。

(1)完全使用键盘控制,由W、A、S、D键控制角色的方向移动,J键控制射击。(这样做主要是为了简化游戏输入部分的逻辑。)

(2)玩家具有多种武器,如手枪、霰弹枪和自动步枪,每种武器可以按Q键切换。

(3)场景上除了玩家角色还有若干敌人。敌人会向玩家方向移动并射击玩家。

(4)玩家角色和敌人都有生命值,中弹后生命值减少,减为零时则死亡。

2.6.2 游戏的实现要点

在游戏实现上,尽可能只使用本章提到过的功能,不使用其他高级功能,这样也能做出具有一定可玩性的游戏。下面将列举一些功能点。

1. 主角脚本

为方便起见,把键盘控制和主角行为编写在同一个Player脚本里。主角的移动使用Input.GetAxis实现。

2. 跟随式摄像机

为了有更好的灵活性,编写一个FollowCam脚本,可以用于跟踪场景中任意物体。

3. 武器系统

多种武器具有不同的射击逻辑,如能否持续发射、连续发射时间间隔、一次发射几颗子弹等均有区别,因此要把武器系统单独编写在一个脚本组件里。

4. 发射子弹

子弹的实现只需要两个步骤,首先由武器创建子弹,其次子弹的飞行逻辑由子弹自身的脚本所控制。

5. 游戏全局管理器——GameMode

某些数据是全游戏唯一的,如玩家杀敌数量。这种数据最好保存在全游戏唯一的对象上。因此要特别创建GameMode,专门用来保存全局数据。另外它还负责整体游戏的逻辑,如刷新UI界面。

6. 敌人移动和射击的实现

敌人的移动看起来和玩家几乎一样,但最重要的区别是,敌人的移动不是由键盘触发的,而是敌人自发地进行移动。因此需要给敌人编写一点简单的“智能”逻辑。

2.6.3 创建主角

先简单搭建场景,再创建主角。

01 创建一个蓝灰色平面作为地板,位置归0,且放大到4倍左右。

02 创建一个3D胶囊体(Capsule),命名为Player,适当调整位置,让它站在地板中间,如图2-35所示。

03 主角要有一个红色的脸以表示正面。给胶囊体添加一个立方体子物体,大小设置为0.5倍,并将立方体改为红色材质,如图2-36所示。

04 创建脚本Player以控制主角的移动。其脚本内容如下。

using UnityEngine;

public class Player : MonoBehaviour
{
    // 移动速度
    public float speed = 3;

    // 最大血量
    public float maxHp = 20;

    // 变量,输入方向用
    Vector3 input;
    // 是否死亡
	    bool dead = false;

    // 当前血量
    float hp;

    void Start()
    {
        // 初始确保满血状态
        hp = maxHp;
    }

    void Update()
    {
        // 将键盘的横向、纵向输入,保存在input变量中
        input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        // 未死亡则执行移动逻辑
        if (!dead)
        {
            Move();
        }
    }

    void Move()
    {
        // 先归一化输入向量,让输入更直接,同时避免斜向移动时速度超过最大速度
        input = input.normalized;
        transform.position += input * speed * Time.deltaTime;
        // 令角色前方与移动方向一致
        if (input.magnitude > 0.1f)
        {
            transform.forward = input;
        }

        // 以上移动方式没有考虑阻挡,因此使用下面的代码限制移动范围
        Vector3 temp = transform.position;
        const float BORDER = 20;
        if (temp.z > BORDER) { temp.z = BORDER; }
        if (temp.z < -BORDER) { temp.z = -BORDER; }
        if (temp.x > BORDER) { temp.x = BORDER; }
        if (temp.x < -BORDER) { temp.x = -BORDER; }
        transform.position = temp;
    }
}

05 运行游戏,简单测试。通过W、A、S、D键或方向键可以控制主角的移动,主角移动时面朝移动方向,且移动到地板边缘时无法继续移动。如果发现地板大小与移动范围不一致,修改脚本中的BORDER参数和地板大小即可。由于3D平面默认为10米宽,因此BORDER参数乘以2再除以10就等于地板的缩放比例。

读者这时可能会发现,人物的移动有一种“延迟感”,当松开按键的时候,人物还会继续行走一小段距离。这是因为GetAxis()函数在处理输入时故意设计成模拟手柄摇杆操作导致的。要修正这个问题,可以把GetAxis()函数改为GetButton()函数,或者通过修改工程设置解决。修改工程设置的方法如下。

01 选择主菜单Edit→Project Settings,打开Project Settings窗口,如图2-37所示。

02 在Project Settings窗口左侧的纵向列表中选择Input选项,展开多层嵌套的小三角形,找到Horizontal和Vertical两个输入项,它们分别代表横、纵输入轴,其内容是类似的。最后将它们的Gravity(回中力度)与Sensitivity(敏感度)的值改为100,如图2-38所示。

修改完成后,就可以去掉方向键的“延迟感”,横向输入和纵向输入会很快地在0与1之间切换,而不会有从0逐渐增加到0.5,再增加到1.0的过程。

2.6.4 调整摄像机

先将摄像机调整到合适位置。将主角的位置参数中的X、Z设置为0,把摄像机置于主角后上方、斜向下45°即可。摄像机位置参数如图2-39所示。

摄像机目前还是在固定位置,应改为随主角移动而平移。将摄像机直接作为主角的子物体也是可行的,但在第2.4.1小节已经给出了通用的跟随式摄像机实现方法,因此在这里直接应用即可。

将脚本挂载到主摄像机上以后,要注意在编辑器中指定Target对象。指定方法是将Hierarchy窗口中的Player物体拖曳到脚本组件的Target字段上,如图2-40所示。

最后测试游戏,会发现摄像机跟随玩家移动。在商业级游戏开发中,会让摄像机的移动更平滑,而这只需要在FollowCam脚本中做一些修改即可。

2.6.5 实现武器系统和子弹

实现玩家的移动不算困难,实现武器系统才是本游戏的特色和开发重点。为了充分发挥组件式编程的优点,要把武器系统写成一个独立的组件Weapon,无论主角还是敌人,都可以调用这个武器组件。这会让逻辑的实现更严谨,同时也具有充分的灵活性。

using UnityEngine;

public class Weapon : MonoBehaviour
{
    // 子弹的prefab
    public GameObject prefabBullet;
    // 几种武器的CD时间长度

    public float pistolFireCD = 0.2f;
    public float shotgunFireCD = 0.5f;
    public float rifleCD = 0.1f;

    // 上次开火时间
    float lastFireTime;

    // 当前使用哪种武器
    public int curGun { get; private set; }     // 0 手枪,1 霰弹枪,2 自动步枪

    // 开火函数,由角色脚本调用
    // keyDown代表这一帧按下开火键,keyPressed 代表开火键正在持续按下
    // 这样区分是为了实现手枪和自动步枪的不同手感
    public void Fire(bool keyDown, bool keyPressed)
    {
        // 根据当前武器,调用对应的开火函数
        switch (curGun)
        {
            case 0:
                if (keyDown)
                {
                    pistolFire();
                }
                break;
            case 1:
                if (keyDown)
                {
                    shotgunFire();
                }
                break;
            case 2:
                if (keyPressed)
                {
                    rifleFire();
                }
                break;
        }
    }

    // 更换武器
    public int Change()
    {
        curGun += 1;
        if (curGun == 3)
        {
            curGun = 0;
        }
        return curGun;
    }

    // 手枪射击专用函数
    public void PistolFire()
    {
        if (lastFireTime + pistolFireCD > Time.time)
        {
            return;
        }
        lastFireTime = Time.time;
        GameObject bullet = Instantiate(prefabBullet, null);
        bullet.transform.position = transform.position + transform.forward * 1.0f;
        bullet.transform.forward = transform.forward;
    }

    // 自动步枪射击专用函数
    public void RifleFire()
    {
        if (lastFireTime + rifleCD > Time.time)
        {
            return;
        }
        lastFireTime = Time.time;
        GameObject bullet = Instantiate(prefabBullet, null);
        bullet.transform.position = transform.position + transform.forward * 1.0f;
        bullet.transform.forward = transform.forward;
    }

    // 霰弹枪射击专用函数
    public void shotgunFire()
    {
        if (lastFireTime + shotgunFireCD > Time.time)
        {
            return;
        }
        lastFireTime = Time.time;

        // 创建5颗子弹,相互间隔10°,分布于前方扇形区域
        for (int i=-2; i<=2; i++)
        {
            GameObject bullet = Instantiate(prefabBullet, null);
            Vector3 dir = Quaternion.Euler(0, i * 10, 0) * transform.forward;

            bullet.transform.position = transform.position + dir * 1.0f;
            bullet.transform.forward = dir;

            //  霰弹枪的子弹射击距离很短,因此修改子弹的生命周期
            Bullet b = bullet.GetComponent<Bullet>();
            b.lifeTime = 0.3f;
        }
    }
}

上面直接给出了完整的Weapon代码,其中的霰弹枪射击的代码可能会让初学者很费解,读者可以暂时注释掉ShotgunFire()函数的后半部分。

武器实际上是通过不断创建子弹的prefab来发射子弹的,因此还需要创建子弹的预制体,如图2-41所示,其步骤如下。

01 创建一个球体,大小缩放为0.3倍,并增添金黄色材质。

02 添加Rigidbody组件,勾选Is Kinematic选项。

03 勾选Sphere Collider组件中的Is Trigger选项。

04 新建Bullet脚本,其内容如下,并将其挂载于子弹上。

05 将子弹拖曳到Project窗口中,做成prefab。

using UnityEngine;

public class Bullet : MonoBehaviour
{
    // 子弹飞行速度
    public float speed = 10.0f;
    // 子弹生命期(几秒之后消失)
    public float lifeTime = 2;
    // 子弹“出生”的时间
    float startTime;

    void Start()
    {
        startTime = Time.time;
    }

    void Update()
    {
        // 子弹移动
        transform.position += speed * transform.forward * Time.deltaTime;
        // 超过一定时间销毁自身
        if (startTime + lifeTime < Time.time)
        {
            Destroy(gameObject);
        }
    }

    // 当子弹碰到其他物体时触发
    private void OnTriggerEnter(Collider other)
{
    // 稍后补充碰撞的逻辑
}

接下来将武器逻辑与玩家逻辑联系起来,确保将Weapon脚本挂载到玩家物体上,将Bullet脚本挂载到子弹物体上,将Bullet的预制体拖曳到Weapon脚本的prefabBullet字段上。

如果这时测试,会发现缺少按键开火的逻辑,因此还需要修改Player脚本。首先,给Player脚本添加Weapon组件的引用,并在Start()函数中初始化,方便稍后使用武器。

  //  以下是代码片段,添加于Player脚本中
Weapon weapon;

    void Start()
    {
        hp = maxHp;
        weapon = GetComponent<Weapon>();
    }

Update()函数修改较多,因为要增加按键逻辑。其中J键用于开火,Q键用于切换3种武器。修改后的Update()函数如下:

 void Update()
    {
        input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        Debug.Log(input);

        bool fireKeyDown = Input.GetKeyDown(KeyCode.J);
        bool fireKeyPressed = Input.GetKey(KeyCode.J);
        bool changeWeapon = Input.GetKeyDown(KeyCode.Q);

        if (!dead)
        {
            Move();
            weapon.Fire(fireKeyDown, fireKeyPressed);

            if (changeWeapon)
            {
                ChangeWeapon();
            }
        }
    }

新增的更换武器的ChangeWeapon()函数内容如下。

    private void ChangeWeapon()
    {
        int w = weapon.Change();
    }

至此,武器逻辑已经基本完成,可以运行游戏进行测试,重点测试主角的移动、射击等功能,如图2-42所示。射击与切换武器的键分别是J和Q。也可以考虑加快主角的移动速度和子弹飞行速度。

2.6.6 实现敌人角色

由于敌人要随时找到并攻击玩家角色,因此敌人需要玩家角色的引用。先将玩家物体的标签设置为Player,然后创建敌人角色。

敌人角色与玩家角色造型基本一致,只是将白色换成深蓝色,如图2-43所示。当然读者也可以自己发挥,做出更有趣的外形。

为敌人角色编写脚本Enemy,其内容如下。

using UnityEngine;

public class Enemy : MonoBehaviour
{
    // 用于制作死亡效果的预制体,暂时不用
    public GameObject prefabBoomEffect;

    public float speed = 2;
    public float fireTime = 0.1f;
    public float maxHp = 1;

    Vector3 input;

    Transform player;
    float hp;
    bool dead = false;

    Weapon weapon;

    void Start()
    {
        // 根据Tag查找玩家物体
        player = GameObject.FindGameObjectWithTag("Player").transform;
        weapon = GetComponent<Weapon>();
    }

    void Update()
    {
        Move();
        Fire();
    }

    void Move()
    {
        // 玩家的input是从键盘输入而来,而敌人的input则是始终指向玩家的方向
        input = player.position - transform.position;
        input = input.normalized;
        transform.position += input * speed * Time.deltaTime;
        if (input.magnitude > 0.1f)
        {
            transform.forward = input;
        }
    }
    void Fire()
    {
        // 一直开枪,开枪的频率可以通过武器启动控制
        weapon.Fire(true, true);
    }

    private void OnTriggerEnter(Collider other)
    {
        // 稍后实现
    }
}

可以看出,敌人角色的代码完全可以仿照玩家角色编写,且随着对脚本的逐渐熟悉,某些复杂的功能的代码,如AI,会显得越来越简单。以上代码还复用了之前写好的Weapon组件,可见之前对武器系统的组件化是很有用的。

敌人角色脚本编写完成后,将Enemy脚本和Weapon脚本都挂载到敌人角色身上,那么敌人角色的实现就完成了。可以将敌人角色制作成预制体,方便之后创建多个敌人以进行测试。

接下来创建一个敌人角色进行测试,会发现敌人角色正在追击主角并不断开火,如图2-44所示。

最后,可以在编辑器中调整Player脚本组件中的主角移动速度、Weapon组件中的武器冷却时间,以及Enemy脚本组件中的敌人移动速度。由于Weapon脚本已经分别挂载到了玩家和敌人身上,因此玩家角色和每个敌人角色的武器发射频率都可以单独调整,互不影响。

2.6.7 子弹击中的逻辑

碰撞事件是相对的,因此子弹碰撞的逻辑可以写在Bullet脚本、Player脚本或Enemy脚本中。在制作时最好单独考虑碰撞问题,然后统一编写。要制作的效果如下。

①玩家角色的子弹击中敌人角色,会让敌人角色掉血。

②敌人角色的子弹击中玩家角色,会让玩家角色掉血。

③玩家角色的子弹不会击中玩家角色,敌人角色的子弹也不会击中敌人角色。

④玩家角色的子弹与敌人角色的子弹可以相互抵消,但是同类子弹不能抵消。这一设计可以根据读者的偏好添加或取消。

从以上分析可以看出,“敌人角色的子弹”与“玩家角色的子弹”是截然不同的,最好用某种机制区分出二者。这里编者采用的方法是把这两种子弹做成两个不同的预制体,一个命名为PlayerBullet,另一个命名为EnemyBullet。然后利用标签(Tag)做出区分,前者的Tag是PlayerBullet,后者的Tag是EnemyBullet。

具体操作是,首先复制之前做好的子弹的预制体,然后分别命名为PlayerBullet和EnemyBullet,最后双击PlayerBullet,将其Tag设为PlayerBullet,并为prefab选中标签,如图2-45所示。同理给EnemyBullet设置Tag为EnemyBullet。

做好两种子弹以后,注意要给玩家角色身上的Weapon组件和敌人角色身上的Weapon组件分别设置新的子弹预制体,这样就可以让两种子弹既使用同样的脚本,又有了明确区分。接着添加脚本碰撞逻辑,修改Bullet脚本,添加如下触发逻辑。

    // 当子弹碰到其他物体时触发
    private void OnTriggerEnter(Collider other)
    {
        // 如果子弹的Tag与被碰撞的物体的Tag相同,则不算打中
        // 这是为了防止同类子弹互相碰撞抵消
        if (CompareTag(other.tag))
        {
            return;
        }
        Destroy(gameObject);
    }

以上代码的作用是让子弹碰到任何其他物体后消失,包括碰到对方子弹也会消失。

接下来添加玩家被子弹击中的逻辑,修改Player脚本的OnTriggerEnter函数如下。

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("EnemyBullet"))
        {
            if (hp <= 0)  {  return;  }
            hp--;
            if (hp <= 0)  {  dead = true;  }
        }
    }

最后是敌人被击中的逻辑,修改Enemy脚本的OnTriggerEnter函数如下。

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("PlayerBullet"))
        {
            Destroy(other.gameObject);
            hp--;
            if (hp <= 0)
            {
               dead = true;
               //  这里可以添加死亡特效
               //Instantiate(prefabBoomEffect, transform.position, transform.rotation);
               Destroy(gameObject);
            }
        }
    }

通过以上修改,可以发现敌人的子弹已经能够击中玩家,且击中10次以后玩家会死亡,死亡的表现是不能再移动或射击了。

如果有子弹无法碰撞的问题,则需要检查和确认。

01 敌人和玩家的子弹都必须有碰撞体和刚体,在Rigidbody组件中已经勾选了Is Kinematic选项,且碰撞体组件中已经勾选了Is Trigger选项。

02与触发相关的函数已正确编写,子弹Tag已经正确设置。

03 如果读者设置的子弹速度很快,建议将子弹Rigidbody组件的Collision Detection选项设置为Continuous Dynamic(连续动态检测),如图2-46所示。这个选项可以确保高速飞行的物体不会错过碰撞事件。

2.6.8 完善游戏

通过不懈的努力,这个游戏已经初具雏形了。难得的是这个游戏仅仅用到了本章介绍的基础知识,不包含Unity其他的功能特性,对学习脚本编程来说是一个很好的练习。接下来为了让这个游戏更具可玩性,可以做如下修改。

01 修改玩家和敌人的移动速度,加快游戏节奏。同时让玩家行动灵活,子弹射击速度更快,使得玩家可以同时面对更多敌人。

02 将敌人做成预制体后,摆放多个敌人到场景上,给玩家制造压力。这样近战威力强大的霰弹枪也能派上用场。

03 制作一个创建敌人的辅助物体,并为其编写一个简单脚本,功能为隔一段时间就刷出新的敌人,从而让游戏一直进行下去。

04 编写一个计数器,让玩家在死亡前争取打败更多的敌人。

05 与刷新敌人的逻辑类似,可以定时刷新医疗包,让玩家恢复体力。

最后,由于游戏画面表现得过于朴素,可以用脚本编写一些特效,如敌人死亡时有爆炸消失的效果。在这里不使用Unity粒子系统,而是用脚本编写一个简单的动画效果。创建一个空物体并做成prefab,命名为BoomEffect,挂载BoomEffect脚本,脚本内容如下。

using System.Collections.Generic;
using UnityEngine;

public class BoomEffect : MonoBehaviour
{
    List<Transform> objs = new List<Transform>();

    const int N = 15;

    void Start()
    {
        for (int i=0; i<N; i++)
        {
            GameObject obj = GameObject.CreatePrimitive(PrimitiveType.Cube);
             obj.transform.parent = transform;  
obj.transform.localPosition = new Vector3(Mathf.Cos(i * 2 * Mathf.PI 
/ N), 0, Mathf.Sin(i * 2 * Mathf.PI / N));
            obj.transform.forward = obj.transform.position - transform.position;
            objs.Add(obj.transform);
        }
    }

    void Update()
    {
        foreach (Transform trans in objs)
        {
            trans.Translate(0, 0, 10 * Time.deltaTime);
            trans.localScale *= 0.9f;
            if (trans.localScale.x <= 0.05f)
            {
                Destroy(gameObject);
            }
        }
    }
}

以上脚本创建了15个小方块,并让小方块朝向不同的角度。然后每一帧每个小方块都向前移动,做出以环形扩散的效果,同时方块不断缩小,且缩小到一定程度后销毁整个物体。关键是,由于这些小方块都是特效的子物体,因此当整个特效物体销毁时,15个小方块也会销毁。

制作好这个动画后,可以放在场景中,播放游戏进行测试。如果测试通过,则将此预制体拖曳到敌人脚本的Prefab Boom Effect字段上,并在敌人死亡时创建此特效,这就做出了敌人死亡时爆炸消失的效果。

2.6.9 测试和改进

近一两年,俯视角度的射击游戏非常流行,著名的手机游戏《元气骑士》就是其中的代表。俯视角度的游戏的制作容易理解、门槛低,而且很容易将战斗、武器、解谜与冒险元素加入其中。虽然本章的最后只是实现了一个射击游戏的雏形,但是通过调整参数,加入一些游戏玩法和机制,就能做出一款可玩性很高的俯视角度的射击游戏。

美中不足的是,使用本章的角色移动方法,墙体将无法阻挡玩家的移动,玩家会穿墙而过。这一问题有多种解决方法,列举如下。

一是使用Unity提供的内置组件——角色控制器(Character Controller)解决。

二是使用刚体移动代替直接修改位置的移动方式。在第3章物理系统中会详细讲解。

三是利用射线检测等功能,自己动手实现一个更完善的角色控制器。这一方案做出的控制器可以实现比Unity提供的控制器更合适的移动效果。

总之,希望通过这个完整的案例,能让读者深入理解本章所讲解的全部基础内容。

相关图书

Unity游戏开发入门经典(第4版)
Unity游戏开发入门经典(第4版)
Unity  3D游戏开发技术详解与典型案例
Unity 3D游戏开发技术详解与典型案例
从零开始:快速入门Unity 3D游戏开发
从零开始:快速入门Unity 3D游戏开发
AR开发权威指南:基于AR Foundation
AR开发权威指南:基于AR Foundation
VR与AR开发高级教程:基于Unity(第2版)
VR与AR开发高级教程:基于Unity(第2版)
基于Unity的ARCore开发实战详解
基于Unity的ARCore开发实战详解

相关文章

相关课程