某愿朝闻道译/原文地址

制作一个时钟 :

  • 使用简单的物体建立一个时钟
  • 书写C#脚本
  • 调整时钟指针来显示时间
  • 添加指针的动画效果

本教程我们将制作一个简单的时钟并使用基本组件来控制它显示当前时间. 你只需要少量的Unity编辑器知识即可. 如果你已经至少摆弄了Unity几分钟, 并且可以找到场景(Scene)窗口, 那我们就可以开始了.

本教程需要使用Unity2017.1.0或更高版本

游戏物体与脚本 - 图1
是时候创建时钟了

建立简单时钟

创建一个3D项目并打开. 你不需要任何额外的资源包. 如果你没有没有自定义编辑器窗口布局, 那么它会显示为默认布局, 也就是下图的样子

游戏物体与脚本 - 图2

本教程我们将使用2by3布局, 你可以从右上角的布局下拉选项中设置. 我还将Project窗口的显示模式从两排变为了一排, 这样会更适合竖直窗口, 你可以通过Project窗口上方的小锁头图标右侧的下拉按钮中选择One Column Layout选项来进行设置. 我还通过场景窗口的Gizmes下拉选项禁用了Show Grid(显示网格)选项.

游戏物体与脚本 - 图3
按上述方式设置好的2by3窗口布局

为什么我的Game窗口比较小, 还有黑边?

这个问题可能在使用高分辨率时导致. 可以这样解决 : 点击Game窗口顶部的分辨率设置下拉框, 如下图所示位置处, 单击展开后, 取消勾选顶部的的Low Resolution Aspect Ratios选项

游戏物体与脚本 - 图4

创建游戏物体

默认场景包含两个游戏物体, 它们会在Hierarchy窗口中罗列, 同时你也可以在场景窗口内看到它们的图标. 第一个物体叫Main Camera, 用来将场景内容渲染从而被我们看到, Game窗口看到的画面就是通过它产生的. 第二个物体叫Directional Light, 它用来为场景照明.

接下来我们要创建一个空物体, 前往菜单位置GameObject / Create Empty.你也可以在Hierachy窗口点击右键使用弹出的快捷菜单完成这个操作. 该操作完成后, 我们在场景内就添加了一个新的游戏物体, 在Hierachy中右键点击该物体, 弹出的菜单中选择Rename, 将该物体改名为Clock

游戏物体与脚本 - 图5

Inspector窗口将会显示你所选择的物体详细信息. 当你选择Clock后, 会看到它的Inspector头部显示着一个输入了物体名称输入栏, 并还显示了一些其他配置选项 : 名称前面的物体启用勾选项, 右侧的Static勾选项, 下方的Tag下拉选项和Layer下拉选项, 这些内容在本例都不需要更改.

在头部内容下方, 会显示物体的全部组件(Component)列表, 所有的物体都必须且只能包含一个Transform组件.

以上就是Clock物体目前包含的所有内容

游戏物体与脚本 - 图6
Transform组件包含了物体的位置信息, 旋转角度, 以及在3D空间的尺寸缩放倍数. 请确保Clock的Position(位置)和Rotation(旋转)都是0, 并且Scale(缩放)都是1, 如上图所示

2D物体是什么样的?

当我们制作2D内容时, 你可以无需考虑三维空间中的一个维度. 专门用来处理2D的物体比如说UI元素, 多数都有一个称之为Rect Transform的组件, 这个组件是经过了特殊处理的Transform组件

制作时钟表盘

虽然我们已经有了叫做Clock的代表时钟的物体, 但是我们实际在场景中还看不到任何内容. 我们需要添加一个3D模型物体. Unity包含一些内置的基本物体可以用来制作我们的时钟. 让我们通过菜单GameObject / 3D Object / Cylinder添加一个Cylinder圆柱体物体. 确保它Transform组件的所有属性设置与Clock物体一致
游戏物体与脚本 - 图7 游戏物体与脚本 - 图8
这个新建的物体比Clock物体多了三个组件. 第一个是Mesh Filter组件, 代表的是Unity内置的圆柱体Mesh网格. 第二个是Capsule Collider组件, 用于3D物理系统中的各项计算. 第三个是Mesh Renderer组件, 它的作用是对物体的Mesh进行渲染, 成为我们可以看到的内容, 它同时还决定了渲染使用什么Material(材质), 此时它使用的是Unity内置的默认材质, 在上图我们可以看到, 这个默认材质也列在Inspector中, 位于组件列表下方, 名字叫做Default-Material

虽然新建的物体代表的是一个圆柱体, 但是它使用的是胶囊碰撞体(Capsule Collider), 因为Unity没有专门准备内置的圆柱碰撞体, 本例中我们不需要它, 上图位置中, 点击Capsule Collider名称右侧的齿轮状按钮, 在弹出菜单中点击Remove Component按钮来移除该组件.
游戏物体与脚本 - 图9
移除了碰撞体后

接下来让我们将圆柱体的形状做一些调整. 设置圆柱体Transform组件的Scale属性的Y值为0.1, 让它的高度减少, 然后设置Scale的X和Z为10, 这样我们的圆柱体就变成了一个大大的表盘
游戏物体与脚本 - 图10 游戏物体与脚本 - 图11
尺寸调整后的圆柱体

然后我们要修改圆柱体物体的名称为Face, 它将作为时钟的表盘. 由于它是时钟的一部分, 所以我们应该让它成为Clock物体的子物体(Child Object), 你可以在Hierarchy窗口中直接拖拽Face到Clock名称上松手, 两者的名称在Hierarchy窗口中的显示关系就会变成如下图所示, 这样Face就成为了Clock的子物体, 相对的, Clock是Face的父物体(Parent Object).
游戏物体与脚本 - 图12

子物体将从属于父物体, 这意味着, 当Clock改变位置, 进行旋转, 或是发生缩放, Face的对应属性也会随之同步变化, 就好像它们是一个整体一样.

创建时钟刻度

时钟表盘周围应该显示一些标记物来指示具体的时间位置, 也就是所谓的时钟刻度. 我们接下来要为我们时钟添加对应12小时的刻度.

通过菜单GameObject / 3D Object / Cube添加一个立方体(Cube). 修改它的Scale属性为(0.5, 0.2, 1), 这样它就变成了一个长方块. 然后需要将它放到时钟表盘中. 设置它的Position属性为(0, 0.2, 4), 此时它的位置代表了时钟的12点刻度. 最后将它重命名为Hour Indicator
游戏物体与脚本 - 图13 游戏物体与脚本 - 图14
12点钟的刻度

这个刻度不是很容易看清, 因为它的颜色与Face相同. 我们需要给它设置一个另外的材质(Material), 通过菜单Assets / Create / Material或Project窗口点击右键的菜单. 来创建一个新的材质资源New Material. 在Project中点击该材质资源, 设置它的Albedo属性为偏暗的色值, 比如R73, G73, B73. 设置完成后我们将其改名为Clock Dark

游戏物体与脚本 - 图15 游戏物体与脚本 - 图16
为材质资源设置颜色

什么是albedo?

Albedo(反射色)是一个拉丁文单词, 你可以简单的理解它代表了材质的颜色

设置Hour Indicator的材质为我们的新建材质. 你可以直接把材质资源从Project窗口拖拽到Hierarchy窗口中的物体名称上完成材质的设置. 也可以在Hour Indicator的Inspector窗口中, 设置其Mesh Renderer组件的Materials属性材质列表中的第一个材质.
游戏物体与脚本 - 图17
黑色的Hour Indicator

此时我们的12点钟设置好了一个刻度, 不过我们怎么再加个1点钟呢? 表盘上总共需要12个刻度, 圆的一周是360度, 所以我们可以算出, 1点刻度, 需要我们将现在的刻度Rotaion属性的Y设置为30, 也就是绕Y轴旋转30度. 如下图所示, 动手试一下
游戏物体与脚本 - 图18 游戏物体与脚本 - 图19
将Hour Indicator旋转了30度, 位置不太对

虽然我们对刻度进行了旋转, 但是它却依然留在12点刻度的位置上. 这是因为当我们设置物体的Rotation属性时, 它是相对自己的位置中心进行旋转的.
我们需要沿着表盘边缘移动1点的刻度. 我们不需要自己手动计算需要放置的位置, 利用父子物体之间的规则就可以为我们代劳. 首先我们将Hour Indicator的Rotation重置为(0, 0, 0). 之后创建一个新的空物体, 将其Position和Scale均设置为0, 然后将Hour Indicator设置为它的子物体
游戏物体与脚本 - 图20

现在我们设置新建物体的Rotation的Y为30, 会发现Hour Indicator旋转到了表盘上1点刻度的位置, 这是因为旋转父物体时, 子物体会绕着父物体的位置中心旋转而不是自己的位置中心.
游戏物体与脚本 - 图21

记着在Hierarchy窗口选中这个父物体, 使用键盘Ctrl+D键来创建这个物体的副本. 每个物体的副本都比上一个副本额外增加30度旋转, 重复该过程, 直至表盘包含了12个刻度, 且都旋转到了正确的位置.
游戏物体与脚本 - 图22
十二小时刻度

完成上述步骤后, 我们就不在需要进行旋转时候用到的父物体了. 让我们在Hierarchy窗口选择一个Hour Indicator把它拖动到Clock上, 这样它就变成了Clock的子物体. 此时, Unity会通过调整Hour Indicator的Transform的值来保障它在场景空间中的位置和旋转状态不变. 重复该过程, 直至12个刻度都设置为Clock的子物体, 然后就可以删除所有的旋转时用到的父物体. 你可以通过Ctrl+左键的方式一次选择多个刻度物体, 然后一次性的完成拖拽到Clock的操作.
游戏物体与脚本 - 图23
将刻度物体设置为Clock的子物体

我看见了小数位很多的值, 比如90.00001, 是出错了吗?

这种情况的发生时因为Position值, Rotation值以及Scale值是浮点型数字. 这种类型的数字的精度有限, 从而会导致最终存储的值与你设置的值出现极小的偏差. 但是你不需要担心, 类似0.0001这种极小数字不会对我们的计算造成可感知到的影响

制作表针 Creating the Arms

我们可以使用相同的方法来构成表针. 首先创建一个新的立方体Cube, 命名为Arm并且为其设置与刻度一样的暗色材质. 设置Arm的Scale为(0.3, 0.2, 2.5), 让它变得更为狭长. 设置它的Position为(0, 0.2, 0.75)从而使它位于表盘上方并指向12点钟, 你也可以设置它指向相反的方向.
游戏物体与脚本 - 图24 游戏物体与脚本 - 图25

前面几张图中出现的场景光源的小太阳图标去哪了?

我给它挪动了个位置, 防止他干扰我们观察场景中的时钟. 由于它是平行光源, 所以它的位置变化并不会有任何影响

为了让表针绕着表盘的中心转动, 像之前我们为时间刻度制作父物体一样, 为表针创建一个父物体, 起名为Hours Arm. 创建好这个物体后依然要先检查和确保它的Position和Rotation都是0, Scale都是1. 接着我们将Arm设置为它的子物体, 再将它设置为Clock的子物体, 设置完成后Hierarchy窗口中的结构如下图所示 :
游戏物体与脚本 - 图26

赋值Hours Arm两次, 将复制出的两个父物体分别命名为Minutes Arm和Seconds Arm. 分针应该比时针更加细长, 所以让设置Minutes Arm的Arm子物体的Scale为(0.2, 0.15, 4)并设置Position为(0, 0.375, 1), 设置完成后分针将显示在时针上方.

对于Seconds Arm的子物体, 设置其Scale为(0.1, 0.1, 5), Position为(0, 0.5, 1.25). 为了进一步区分这些表针, 为秒针物体创建一个叫做Clock Red的材质, 设置其Albedo色值为(197, 0, 0)并将该材质应用给Seconds Arm的Arm子物体.
游戏物体与脚本 - 图27 游戏物体与脚本 - 图28
三个表针都创建完毕了

我们的时钟现在已经制作完毕了, 此时是个保存场景的好时机. 这可以将我们在场景中进行的修改保存到场景资源文件. 保存快捷Ctrl +S
游戏物体与脚本 - 图29
场景资源文件和材质资源文件(你的场景文件名称也许跟这里不一样, 这并没有影响)

如果你在哪个步骤卡住了, 想要对比一下完成的内容, 或是想跳过创建时钟的顾聪, 你可以下载包含了上述步骤内容的资源包. 你可以通过菜单Assets / Import Package / Custom Package导入它, 或是将它直接拖拽至Unity窗口中.

时钟动画 Animating the Clock

我们的时钟现在不能正确的指示时间. 它只是场景中一个静止的物体. 我们需要一个可以控制时钟显示时间的组件, 而Unity并没有内置的类似组件, 这就需要我们通过书写代码来实现, 这种代码制作的组件被称之为脚本(Scripts). 通过菜单Assets / Create / C# Script添加一个脚本资源文件, 并命名为Clock
游戏物体与脚本 - 图30 游戏物体与脚本 - 图31
Clock脚本资源

当脚本文件被选择后, Inspector中会显示它的代码内容以及一个打开脚本编辑器的Open按钮. 你也可以直接双击脚本文件来打开脚本编辑器. 打开脚本后会看到, 它里面已经包含了一些脚本组件默认的代码模板, 如下所示 :

  1. using System.Collections;
  2. using System.Collections.Generic;
  3. using UnityEngine;
  4. public class Clock : MonoBehaviour {
  5. // Use this for initialization
  6. void Start () {
  7. }
  8. // Update is called once per frame
  9. void Update () {
  10. }
  11. }

以上是C#代码, 它可以在Unity中书写脚本. 为了理解代码的工作原理, 我们需要删除这里的所有代码, 从0开始.

我听说好像还可以使用JavaScript写脚本?

Unity也支持其他编程语言, JavaScript是其中之一, 但是它的真正名字应该叫做UnityScript. 截止Unity2017.1.0版本, 依然支持使用它, 而之后从2017.2.0版本将移除创建JavaScript的资源菜单, 并且未来对它的支持也会越来越少
(译者注, 就学C#就行了, JavaScript在最新版已经完全不支持了)

定义组件类型 Defining a Component Type

一个空的文件是无效的脚本. 脚本文件中必须包含有效的代码, 以用来定义我们的时钟组件. 我们需要创建叫做Clock的类(Class). 一旦建好这个类, 我们就可以将它作为一个组件在Unity中使用.

接下来我们开始书写Clock中的代码

  1. class Clock

什么是类(class)?

你可以将类理解为用来在计算机中创造东西的图纸. 这个图纸定义了被创造的东西需要包含什么数据, 以及这个物体都能完成什么功能.
虽然类的作用不止于此, 但是通常来说它们用来提供各种数据和功能.

因为我们想让Clock这个类能够被有需要的情况所使用, 那么它就应该类似公共设施, 所以, 顺理成章的, 我们要对它设置一个访问修饰符(access modifier), “Public”

  1. public class Clock

类的默认访问修饰符是什么?

如果你不在class前方书写访问修饰符, 那么就会被视为书写了internal修饰符. 这会限制对类的使用, 如果不希望因此导致运行问题, 记得在class前面书写public访问修饰符

此时我们依然没有书写完一句正确的C#代码. 我们只是声明了一个叫做Clock的类, 却还没有定义这个类是什么样子的, 这需要在类名后面添加一一对大括号, 并在其中书写对应的代码. 我们先添加这对大括号.

  1. public class Clock {}

此时, 我们书写了一段完整的, 符合C#语法的代码. 尽管这段代码目前什么也做不了. 此时让我们Ctrl+S保存代码内容, 并返回Unity窗口. Unity会自动检查脚本资源文件的修改, 并在发现修改后自动进行代码编译. 所以可能会让你感到有或短的卡顿时间, 这是正常的. Unity编译完代码后我们可以正常操作, 选择Clock脚本文件. 查看Inspector会发现一个白色气泡图标后面书写了一段提示文字, 大意就是告诉我们, 该资源文件不包含MonoBehaviour脚本.
游戏物体与脚本 - 图32

这就是说, 我们不能用这个脚本文件来创建一个Unity脚本. 因为我们之前的代码只是定义了一个普通的类, 而Unity中需要使用基于MonBehaviour的类创建脚本

MonoBehavior是什么意思?

我们可以通过编程来实现我们专属的订制组件,实现特定的功能特性(behavior), 而Unity中用来实现自定义代码的机制, 源自.NET框架下的一个跨平台开源实现(implementation), 这个开源项目的代号就叫Mono, 所以两者结合, 就产生了MonoBehavior这个名称.

回到脚本编辑器, 修改代码来更改Clock类的声明, 通过一个冒号使它扩展为MonoBehavior的子类, 这使得Clock类继承了MonoBehavior的全部功能

  1. public class Clock : MonoBehaviour {}

编辑完代码回到Unity界面, 会发现红色的报错提示文字, 编译器提示我们The type or namespace name ‘MonoBehaviour’ could not be found, 大意就是说, 编译器找不到MonoBehavior这个类型. 出现这个问题是因为MonoBehavior类型包含在UnityEngine这个命名空间(namespace)中. 为了可以让编译器找到MonoBehavior这个类型, 我们可以使用完整的类型名称 : UnityEngine.MonoBehavior :

  1. public class Clock : UnityEngine.MonoBehaviour {}

什么是命名空间?

命名空间类似网站的域名, 只不在代码中它就是类型的域名. 网站域名可以包含二级域名, 命名空间也可以有子命名空间.
命名空间的主要作用就是防止不同人开发的类名重复而导致的命名冲突

不过如果我们总是在类型前面加上命名空间名, 就不太方便书写代码了, 我们可以选择告诉编译器, 在找不到某个联系时, 优先查找我们设置的特定命名空间, 我们通过在代码顶部增加using UnityEngine;来达到该效果, 注意, 这段代码后面一定要加上分号;

  1. using UnityEngine;
  2. public class Clock : MonoBehaviour {}

现在我们可以为我们的Clock添加我们自己的组件了. 你可以直接将改脚本文件从Project窗口拖拽到Hierarchy窗口中的物体名称上进行脚本组件添加, 也可以通过点击物体Inspector窗口中的Add Component按钮来进行添加.
游戏物体与脚本 - 图33
在Clock物体上加入了Clock脚本组件

此时, Clock物体就拥有了Clock类所对应的数据和功能, 当然, 实际上我的Clock还没有书写任何有效的功能代码.

获得表针 Getting Hold of an Arm

为了转动表针, Clock物体需要能够获知表针物体的存在. 让我们先从时针开始, 想所有游戏物体一样, 时针也能够通过改变Rotation的属性值来进行旋转. 所以我们需要让Clock物体可以控制时针的Transform.

我们可以在Clock类的大括号之间添加一个数据来代表时针的Transform, 同时为这个数据起个名字, 我们将类中用来存储数据的东西叫做字段(Field).

Hours Transform是个不错的名字, 然而在代码中的字段名称需要使用连续的单词, 不能有空格. Unity建议的命名规范是首单词字母小写, 后续的单词则大写首字母, 所以最终我们决定使用的字段名称是hoursTransform :

  1. public class Clock : MonoBehaviour {
  2. hoursTransform;
  3. }

不要忘记在字段名后面书写分号;

之前加的using UnityEngine;代码呢?

它还需要在代码中, 我只是在这个教程的代码中不再显示它了. 随着教程代码的增多, 我将主要贴出更改部分的代码, 但是放心, 我会显示足够多的相关代码以便你能清楚被省略代码的上下文关系.

我们还需要定义字段的类型, 完整类型名是UnityEngine.Transform, 它需要写在字段名前面 :

  1. Transform hoursTransform;

我们的类现在定义了一个可以用来存放另一个物体Transform数据的字段.

默认情况下字段是私有的(private), 这将导致字段只能被属于Clock类的代码使用, 所以我们需要将字段设置为公开(public), 从而能够被任何其他代码访问和修改

  1. public Transform hoursTransform;

公开字段是一种较差的编程方式吗

一般情况系下, 我们应该少使用公开字段. 然而在Unity中需要使用公开字段来获取物体数据. 也许你有能力不使用公开字段也一样可以得到所需的数据, 但是这也会使你的代码量变多, 和变得的繁杂

将字段设置为public后, 该字段就会在脚本所属物体的Inspector中显示, Unity会自动的将脚本中Public的公开字段显示到Inspector中, 注意, 下图的属性名称是Hours Transform与代码中的字段名略有不同, 这是Unity在Inspector中自动将字段名首字母大写, 并拆分单词加空格导致的, 是正常现象.
游戏物体与脚本 - 图34

接下来我们要将Hours Arm拽到该区域, 也可以点击区域右侧的圆形小按钮, 在弹出的窗口中选择Hours Arm
游戏物体与脚本 - 图35

在Inspector窗口将Hours Arm设置到Clock脚本的到hoursTransform中后, Unity就可以将时针的Transform数据传递给Clock脚本内部了.

获得全部三个表针 Knowing all Three Arms

我们接下来对分针和秒针同样的方法获取它们的Transform数据. 所以我们需要在Clock脚本中再增加两个对应它俩的字段 :

  1. public Transform hoursTransform;
  2. public Transform minutesTransform;
  3. public Transform secondsTransform;

我们会发现这三个字段类型都是Transform, 并且都是public的, 所以还可以像下面这样写的更简洁一点 :

  1. // 两个连续斜线后面的内容叫做代码注释, 编译器不会编译注释文字
  2. // public Transform minutesTransform;
  3. // public Transform secondsTransform;
  4. public Transform hoursTransform, minutesTransform, secondsTransform;

上面代码的两个斜线//是做什么的?

两个斜线//代表同一行中, 在它们右侧书写的任何文字都被视为注释, 编译器不会编译注释内容. 注释用来帮助你在代码中书写笔记或说明, 帮助你更好的阐明和记录代码功能. 在上面的代码中我们使用它们注释了两行代码, 等同于在代码中删除了它们, 但使用注释的方式删除代码, 依然可以让我们看到它们原来的内容.

重复设置时针的过程, 设置好分针Minutes Arm和秒针Seconds Arm
游戏物体与脚本 - 图36

获取时间 Knowing the Time

现在我们可以在Clock脚本中得到表针Transform组件位置数据了, 下一步需要获取到当前的时间数据. 我们需要在Clock脚本中执行一些代码来做到这一点.

我们将在代码中添加一段被称之为方法(Method)的代码. 这一段代码有自己的名字, 并也使用大括号来标记属于自己的代码内容, 这段代码的名字就是Awake, 这是Unity的MonBehaviour类中自带的方法, 用来在程序运行过程中脚本组件初次启动时执行为它书写的代码.

  1. public class Clock : MonoBehaviour {
  2. public Transform hoursTransform, minutesTransform, secondsTransform;
  3. Awake {}
  4. }

方法(Methods)类似于数学函数(function), 比如像是f(x)=2x+3. 这个函数会根据参数X的值进行一系列运算, 然后得到一个数字结果. 对于代码中的方法, 类似于f(p)=c, p代表的是方法的输入参数, c代表的代码执行结果. 不过我们将要书写的Awake方法, 并没有有一个具体的计算结果, 换句话说, 这个方法的计算结果是空的(Void), 所以我们在方法名称前面需要写上void单词, 来告诉Unity这是一个没有返回结果的方法 :

  1. void Awake {}

我们也不需要对该方法输入任何数据. 我们需要在方法名称后面使用圆括号来标记方法需要的参数, 如果方法没有参数, 也需要书写一对空的圆括号 :

  1. void Awake () {}

至此, 我们已经书写完了Awake方法的代码, 尽管它还没有任何实际功能. 就像Unity会发现我们标记为public的字段一样, Unity也会发现Awake方法, 每当一个脚本代码中包含Awake方法, Unity就会在脚本组件被唤醒时(when awakens)执行Awake方法内的代码, 这个过程发生在程序运行期间, 脚本被创建或加载之后.

Awake方法不需要public吗?

Unity中有一批类似Awake的方法, Unity将特殊对待它们.
Unity会发现这类方法, 并在满足特定条件时执行它们的代码, 无论我们是否声明了它们. 我们不应该将这些方法标记为public, 因为它们并不需要被除了Unity引擎以外的任何其他东西使用.

我们需要在Awake方法中加入一段代码, 来输出调试信息, 以测试下我们的方法是不是能正确工作. UnityEngine.Debug类包含一个公开的方法Log, 可以输出信息. 我们将一段字符串作为参数传递给它, 字符串参数需要使用双引号包裹. 同时不要忘了, 在书写完这一行代码后记得在结尾处加分号 :

  1. void Awake () {
  2. Debug.Log("Test");
  3. }

写完上面的代码, 回到Unity窗口, 点击窗口上方的Play按钮, 进入运行模式. 运行后你会在窗口下方的状态栏看到Log方法输出的字符串信息(如果没看到任何输出, 检查下是不是忘记将Clock脚本添加给Clock物体了). 你也可以在控制台窗口(Console Window)查看这些信息, 打开控制台窗口的菜单是Window / Console. 控制台窗口还提供了了一些额外的信息, 比如你点击某条输出的字符串, 会在下半部分窗口看到字符串是哪一个脚本组件发出的.

看到输出信息说明我们的方法工作正常, 接着就需要我们解决的就是在方法中添加获取时间数据的代码. UnityEngine这个命名空间中包含叫做Time的类, 它包含一个叫做的time属性(Property), 看它的名字好像满足我们的需要, 我们试着修改Awake方法的代码来输出一下这个属性的值:

  1. void Awake () {
  2. Debug.Log(Time.time);
  3. }

什么是属性(property)?

属性指的就是将自己装扮(pretend)成字段的方法. 它可以设置为只读(read-only)或只写(write-only).

上述代码运行后会输出0, 那是因为Time.time获取到的是开始运行模式后经过的秒数. 而因为Awake会在运行后第一时间运行, 还没有任何时间经过, 所以这里返回的是0.

为了获得我们电脑上的系统时间, 需要使用DateTime结构(structure). 它属于System这个命名空间下.

什么是结构(structure)?

结构(structure)像类(class)一样可以看做一种图纸. 与类不同之处在于结构只被当做一种简单的数值类型, 就像是数字类型或是字符串类型, 而不是被作为一个对象(object). 你可以像定义类一样定义结构, 只不过需要将class关键字替换为structure

DtaeTime有一个公开属性Now. 它可以用来获取系统时间, 现在我们修改一下代码 :

  1. using System;
  2. using UnityEngine;
  3. public class Clock : MonoBehaviour {
  4. public Transform hoursTransform, minutesTransform, secondsTransform;
  5. void Awake () {
  6. Debug.Log(DateTime.Now);
  7. }
  8. }

现在前往Unity窗口进入运行模式就可以看到输出了当前时间

旋转表针

接下来我们要根据获得的时间来旋转表针. 我们先设置时针.
DateTime.Now有一个属性叫做Hour可以返回当时间的小时部分 :

  1. void Awake () {
  2. Debug.Log(DateTime.Now.Hour);
  3. }

我们可以通过Hour属性返回的小时数据来设置时针的旋转. 在Unity中, 旋转数据是以四元数(Quaternion)的形式存储的, 可以通过Quaternion类的Euler方法创建一个四元数. 该方法需要三个参数代表在X,Y和Z轴上的物体旋转角度, 来生成对应的四元数 :

  1. void Awake () {
  2. // 注释了输出时间字符串的代码
  3. // Debug.Log(DateTime.Now.Hour);
  4. Quaternion.Euler(0, DateTime.Now.Hour, 0);
  5. }

什么是四元数(quaternion)?

四元数用来描述3D空间内的物体旋转状态, 非常复杂. 尽管比3D向量的概念要难以理解的多, 但它有一些很有用的特点. 比如, 使用四元数控制物体旋转不会出现万向锁(gimbal lock)问题.
UnityEngine.Quaternion是结构, 而不是类

Quaternion.Euler方法的三个参数的数值类型都是都是浮点型小数. 为了准确的声明传递给方法的参数类型, 我们需要在数字后方加上f字母作为后缀 :

  1. Quaternion.Euler(0f, DateTime.Now.Hour, 0f);

我们的时钟有十二个刻度, 每两个刻度直接角度相差30度. 我们需要将小时数字乘以30来对应旋转的角度, C#中乘号用星号*来表示 :

  1. Quaternion.Euler(0f, DateTime.Now.Hour * 30f, 0f);

为了让我们的代码更容易理解, 我们新增一个字段degreesPerHour来代表30这个角度变化, 这个字段的数据类型是float, 也就是浮点型小数 :

  1. //该字段的含义是每两个刻度之间的间隔角度
  2. float degreesPerHour = 30f;
  3. public Transform hoursTransform, minutesTransform, secondsTransform;
  4. void Awake () {
  5. //用新增的字段degreesPerHour代替原来的30这个固定数字
  6. Quaternion.Euler(0f, DateTime.Now.Hour * degreesPerHour, 0f);
  7. }

在我们这个例子中, 刻度的间隔角度不需要改变, 我们管这种字段叫做常量(constant), 在编写代码的过程中, 我们可以在对字段添加修饰符const, 代表这个字段是常量, 不可以被代码修改它的值(修改常量的值编译器会报错), 防止我们不小心书写修改它的代码. 本例中, degreesPerHour字段就是一个常量, 我们给它加上const修饰符 :

  1. const float degreesPerHour = 30f;

常量有什么特殊的?

const关键字表示字段不会被除声明语句以外的其他代码改变它的数值, 它只能在声明的时候进行一次赋值. 只有基本数据类型(primitive types)可以被定义为常量, 比如数字类型.

虽然我们已经计算出了时针需要旋转的角度, 但是时针此时依然不会发生旋转, 这还需要我们设置时针Transform组件的localRotation属性 :

  1. void Awake () {
  2. hoursTransform.localRotation = Quaternion.Euler(0f, DateTime.Now.Hour * degreesPerHour, 0f);
  3. }

游戏物体与脚本 - 图37
运行游戏 , 时针指向了4点
(你运行后时针的指向取决于你电脑的时间,不过巧了我翻译到这里时正好是下午4点; 我发现原作者没有调整游戏的摄像机, 对于一些非常萌新的新手可能会发现没法好好观察自己的时钟, 如果你之前都按照教程一步步再做, 在这一步你可以在Hierarchy窗口选中Main Camera, 设置他的Rotation为(90, 0, 0), Position为(0, 10, 0), 这样运行后就可以在时钟正上方观察它了)

什么是localRotation?

localRotation指的是相对于其父物体的旋转角度, 这就是所谓的物体的”本地空间(local space)”. 这样无论父物体Clock是否发生了旋转, 时针都可以正确的相对它旋转120度, 指向4点.
与localRotation属性对应的还有一个rotation属性, 这是指的物体在世界空间(local space)下的旋转角度, 世界空间就是与父物体无关的坐标系, Y轴始终向上, X轴始终向右, Z轴始终向前. 如果我们为时针设置该属性的角度, 那么如果父物体Clock发生了旋转, 时针就不能正确的指向4点的刻度了.
请注意, 我们的例子中, Clock没有进行任何旋转, 所以这两个属性看不出差别

现在时针在运行模式下可以指向正确的刻度. 让我们对分针和秒针进行同样的旋转处理, 但是它们的刻度变化每次是6度而不是30度 :

  1. const float degreesPerHour = 30f, degreesPerMinute = 6f, degreesPerSecond = 6f;
  2. public Transform hoursTransform, minutesTransform, secondsTransform;
  3. void Awake () {
  4. hoursTransform.localRotation = Quaternion.Euler(0f, DateTime.Now.Hour * degreesPerHour, 0f);
  5. minutesTransform.localRotation = Quaternion.Euler(0f, DateTime.Now.Minute * degreesPerMinute, 0f);
  6. secondsTransform.localRotation = Quaternion.Euler(0f, DateTime.Now.Second * degreesPerSecond, 0f);
  7. }

游戏物体与脚本 - 图38
运行游戏后, 分针和秒针也都进行了正确的旋转

我们使用了DateTime.Now三次, 来获取时间的小时部分, 分钟部分和秒钟部分. 代码执行有先后之分, 理论上来说这样会可能造成取值上的误差. 为了保障我们只使用同一时刻的时间, 我们可以只获取一次DateTime.Now的值, 将其存储在方法的一个变量(variable)中, 然后每次我们都由这个变量获取我们需要的时间部分 :

  1. void Awake () {
  2. DateTime time = DateTime.Now;
  3. hoursTransform.localRotation =
  4. Quaternion.Euler(0f, time.Hour * degreesPerHour, 0f);
  5. minutesTransform.localRotation =
  6. Quaternion.Euler(0f, time.Minute * degreesPerMinute, 0f);
  7. secondsTransform.localRotation =
  8. Quaternion.Euler(0f, time.Second * degreesPerSecond, 0f);
  9. }

什么是变量(variable)?

变量(variable)在使用上类似字段(field), 它们的区别是, 变量只存在于某个方法内部, 它属于方法, 而不属于类.

表针动画 Animating the Arms

我们虽然已经可以在运行时设置表针的旋转角度, 但是它们全部都是静止的. 为了让时钟的表针能随着时间而变化转动, 需要将我们的Awake方法改成Update方法. 这个方法会在游戏运行期间每一帧(frane)执行一次代码, 而不是只在刚开始运行时候执行一次 :

  1. void Update () {
  2. DateTime time = DateTime.Now;
  3. hoursTransform.localRotation =
  4. Quaternion.Euler(0f, time.Hour * degreesPerHour, 0f);
  5. minutesTransform.localRotation =
  6. Quaternion.Euler(0f, time.Minute * degreesPerMinute, 0f);
  7. secondsTransform.localRotation =
  8. Quaternion.Euler(0f, time.Second * degreesPerSecond, 0f);
  9. }

CooperativeRadiantIchneumonfly-mobile.mp4 (49.5KB)随时间变化的表针动画

加入Update方法后, Inspector中的脚本组件名称前方多了一个复选框, 这允许我们通过取消复选框来禁止脚本的Update方法在每帧执行.
游戏物体与脚本 - 图40

连续平滑转动 Continuously Rotating

我们的表针精确的指向每一个小时, 分钟, 或秒针. 它看起来像是一个电子表, 每一次都瞬间跨越一段距离进行指示. 但是带有表针的时钟都是平滑连续的旋转而不是跳跃式转动. 我们可以让时钟表针选择不同的转动方式.

首先在脚本中添加一个新的字段continuous, 它控制表针在连续平滑转动和跳跃转动直接切换, 像是一个开关, 所以我们声明它为一个bool逻辑类型 :

  1. public Transform hoursTransform, minutesTransform, secondsTransform;
  2. //注意要添加public修饰符 要在Inspector中对其进行手动更改
  3. public bool continuous;

bool类型只有两种值, 逻辑真true或逻辑假false, 默认值为false, 所以我们需要前往脚本的Inspector窗口手动勾选该字段的复选框, 表示将其设置为true, 代表我们要开启表针的连续平滑转动模式
游戏物体与脚本 - 图41
勾选Continuous的复选框

现在我们要通过代码来控制表针连续平滑转动, 复制Update方法的全部代码, 粘贴在它的大括号下面一行, 然后将这两个Update分别改名为UpdateContinuous 和 UpdateDiscrete :

  1. void UpdateContinuous () {}
  2. DateTime time = DateTime.Now;
  3. hoursTransform.localRotation =
  4. Quaternion.Euler(0f, time.Hour * degreesPerHour, 0f);
  5. minutesTransform.localRotation =
  6. Quaternion.Euler(0f, time.Minute * degreesPerMinute, 0f);
  7. secondsTransform.localRotation =
  8. Quaternion.Euler(0f, time.Second * degreesPerSecond, 0f);
  9. }
  10. void UpdateDiscrete () {
  11. DateTime time = DateTime.Now;
  12. hoursTransform.localRotation =
  13. Quaternion.Euler(0f, time.Hour * degreesPerHour, 0f);
  14. minutesTransform.localRotation =
  15. Quaternion.Euler(0f, time.Minute * degreesPerMinute, 0f);
  16. secondsTransform.localRotation =
  17. Quaternion.Euler(0f, time.Second * degreesPerSecond, 0f);
  18. }

然后我们重新创建一个新的Update方法. 如果continuous的值是true, 那么我们就应该使用UpdateContinuous方法, 这个逻辑选择过程可以使用if语句. if后面使用圆括号包围一段逻辑表达式, 如果表达式为真, 则执行if其后大括号内的代码; 否则, 将会跳过if语句大括号内的代码, 不予执行 :

  1. void Update () {
  2. if (continuous) {
  3. UpdateContinuous();
  4. }
  5. }

应该在哪一行书写新的Update方法?

在Clock类的大括号内内即可. 它的书写位置相对于类中其他方法的位置不会有对它的执行有任何影响. 你可以在另外两个方法上面的行书写, 也可以在它们下面的行书写

接着我们还需要为if语句书写一个可选代码段, 用来处理if后面的表达式为假的情况. 这就要用到else语句. 我们可以在else语句的代码块中调用UpdateDiscrete方法 :

  1. void Update () {
  2. if (continuous) {
  3. UpdateContinuous();
  4. }
  5. else {
  6. UpdateDiscrete();
  7. }
  8. }

我们现在可以在两种方法直接切换, 但是方法的功能现在是完全一样的, 我们需要调整UpdateContinuous方法的内容, 让它能够连续平滑地控制表针的转动. 有个坏消息, 那就是DateTime不会为我们均匀的提供带有小数的时间值. 好消息是, 它提供了一个TimeOfDay属性, 可以通过它得到一个TimeSpan类型的值, 该类型包含的TotlaHours方法, TotalMinutes方法以及TotalSeconds方法, 可以返回我们需要的时间数据 :

  1. void UpdateContinuous () {
  2. TimeSpan time = DateTime.Now.TimeOfDay;
  3. hoursTransform.localRotation =
  4. Quaternion.Euler(0f, time.TotalHours * degreesPerHour, 0f);
  5. minutesTransform.localRotation =
  6. Quaternion.Euler(0f, time.TotalMinutes * degreesPerMinute, 0f);
  7. secondsTransform.localRotation =
  8. Quaternion.Euler(0f, time.TotalSeconds * degreesPerSecond, 0f);
  9. }

上述代码会导致编译错误, 不能运行程序, 因为在设置角度中新增加的值类型不正确. TimeSpan类的几个方法返回的数值类型是双精度浮点小数, 即double类型, 而我们的角度常量是单精度float类型, double类型小数的精度比float类型的小数精度更高.

单精度类型够准确吗?

对于多数游戏, 是的. 尽管它在处理微小的数值差异时可能会出现问题, 但是使用double类型的数字意味着字段或变量在内存中也需要占用双倍的内存空间, 从而导致性能消耗增加, 所以多数游戏引擎使用floats.

我们可以将double类型值转换为float类型的值. 这个过程中导致的数字精度损失对于本例来说没有任何影响. 转换方法就是在需要转换的数值前方加上圆括号包围的float类型名, 从而将完成数值的转换, 然后才会参与公式计算, 写法如下 :

  1. hoursTransform.localRotation =
  2. Quaternion.Euler(0f, (float)time.TotalHours * degreesPerHour, 0f);
  3. minutesTransform.localRotation =
  4. Quaternion.Euler(0f, (float)time.TotalMinutes * degreesPerMinute, 0f);
  5. secondsTransform.localRotation =
  6. Quaternion.Euler(0f, (float)time.TotalSeconds * degreesPerSecond, 0f);

GlitteringOilyFieldmouse-mobile.mp4 (98.51KB)运行游戏后, 表针连续平滑的转动起来

至此, 你应该已经对于Unity中的脚本与物体概念有了基础认识. 下一个教程 : 组成图形

教程完整代码package
教程PDF