游戏物体和脚本

前排说明:关于本教程目录导航以及说明:猫式教程

本篇任务:创建一个钟表,你将会学到:

  • 使用基本物体创建一个钟表
  • 编写一个C#脚本
  • 通过转动指针来显示时间
  • 为指针做一个动画

这是 Unity 教程的第一篇,在这篇学习中,我们将创建一个简单的时钟并编写一些组件使其显示当前时间。你不需要有任何 Unity 的经验,但是你需要有一定的 Windows 窗口使用能力。

在文章最后我会放出本文原文的 PDF (注:英文原作者的),以及教程项目的仓库(代码仓库)

本教程使用Unity 2020.3.6f1编写,我采用 Unity 2020.3.38f1c1 来翻译复刻编写。

image-20220911093047113

创建项目

开始使用 Unity 之前,你需要先创建一个项目。

新项目

当你需要打开 Unity 的时候,需要通过 Unity Hub 启动器来完成 Unity 的版本安装和启动,你可以通过 Unity Hub 来创建或者打开,安装 Unity 等一系列操作。

如何选择 Unity 版本?

1
2
3
Untiy 每年会发布多个新版本,并且有两个并行的发布时间表。分别是:最稳定和最安全的是 LTS 版本。
LTS 代码会被官方长期支持并且每两年进行一次更新。此教程内容使用 LTS 版本。
最高版本的 Unity 是开发分支的版本,它引入了新功能并可能删除了旧的功能;这些版本并不如 LTS 版本可靠,并且每个版本仅支持

有时候该教程会包含一些疑问和回答,就如同上面所示的那样,在网页默认是不展开的,你可以通过单击来展开查看,需要注意的是:由于我的博客并没有原作者的展开栏一样的功能,所以我采用代码框来代替

当你创建一个新项目的时候,你可以选择对应的 Unity 版本和项目模板。本例中,我们将采用标准的 3D模板。创建完成后,它会被添加到 Unity Hub 的项目列表中,并且会在对应版本的 Unity 编辑器中打开。

我可以使用不同的渲染管道(Render Pipeline)创建项目吗?

1
可以的,区别在于不同的项目默认场景中所拥有的东西不一样。

编辑器布局

如果你是第一次使用 Unity 编辑器,那么你的编辑器布局(Editor Layout)可能是如下图所示:

image-20220911095311839

默认的布局包含了我们所需要使用窗口,你也可以通过拖动来重新布局自定义窗口布局。你还可以打开或者关闭对应的窗口。每个窗口都可以通过其右上角的三个点来访问其窗口配置选项。

你也可以通过 Unity 编辑器的右上角 Layout(布局) 下拉菜单来切换已经配置好的布局,你也可以通过它来保存当前布局。

Unity 的功能是模块化的,除了核心功能之外,还有一些额外的包(Packages)会被下载并包含在你的项目中。默认的 3D模板项目会包含如下的几个包,你可以在你的 Project(项目)窗口下查看到 Packages(包):

image-20220911100102092

你可以通过Priject(项目)窗口右上角的“眼睛”按钮来隐藏显示这些Packages(包),这只是为了减少编辑器的视觉混乱,并不会真的对项目的Packages(包)做任何处理,仅仅是隐藏了。在“眼睛”按钮旁边的数值表示了项目中含有多少个Packages(包)。

当然你也可以通过包管理器(Package Manager)来对项目中的包进行控制,包管理器(Package Manager)可以通过 Window / Package Manager 来进入。

image-20220911100643989

这些包为 Unity 提供了一些额外的功能,例如:Visual Studio 编辑器(Visual Studio Code Editor)添加了用于编写代码的 Visual Studio 编辑器(Visual Studio Code Editor)的集成。本实例并不需要使用其他包所提供的功能,仅保留 Visual Studio 编辑器的包,其他的我将其全部删除,这是因为我使用 Visual Studio 编辑器来编写代码。如果你使用不同的编辑器,则需要包含其他的集成包。

删除包最简单的办法就是使用包管理器(Package Manager),使用工具栏限制包列表为:在本项目中的(In Project),然后一次选择一个包,并使用右下角的删除(Remove)按钮。Unity 将会在每次删除后进行重编译,因此需要等待几秒钟才能完成该过程。

删除除了 Visual Studio 编辑器(Visual Studio Code Editor)之外的其他包后,我的项目窗口留下了三个可见包:Custom NUnit,Test FrameWork 和 Visual Studio Editor 。除了Visual Studio 编辑器(Visual Studio Code Editor)其他两个包存在是因为 Visual Studio 编辑器(Visual Studio Code Editor)依赖于这些包。

你可以通过项目设置(Project Settings)来让依赖项在包管理器(Package Manger)中可见,项目设置(Project Settings)在 Edit/Project Settings 然后选择 Package Manager 类型,在其中的 Advanced Settings 下启用,Show Dependencies

image-20220911102906069

色彩空间

现在渲染通常使用线性色彩空间(Linear color space)来完成,但是 Unity 依旧默认配置为使用伽马色彩空间(Gamma color space)。为了获得最佳的视觉效果,请在 Player 类别中,打开 Other Settings 面板,找到 Rendering 部分,确保 Color Space 被设定为 Linear 。Unity 会弹出警告说这个操作可能会需要比较长的时间,点击确认即可。

image-20220911103552295

现在还有必要使用伽马色彩空间(Gamma color space)吗?

1
2
仅当你需要针对旧硬件或者旧图形 API 的时候使用。
OpenGL ES2.0 和 WebGL 1.0 不支持线性空间,此外,在旧的一些移动设备上来说,伽马可能渲染会比线性空间要块一些。

示例场景

在刚刚创建的新项目中会包含一个名称为示例场景(Sample Scene)的场景,默认是打开的。你可以通过 Assets/Scenes 路径来找到它。

image-20220911104203043

默认情况下,项目窗口使用两列布局,你可以通过右上角三个点的配置菜单来切换到单列布局。

image-20220911104257858

示例场景(Sample Scene)包含一个主摄像机(Main Camera)和一个定向光源(Directional Light)。这些是游戏对象,它们陈列在场景下的层次结构窗口中。

image-20220911104609982

你可以通过层次结构窗口(Hierarchy)或者场景窗口来选择游戏对象,摄像机场景图标看起来像是老式胶片相机,而定向光源图标看起来像是太阳。

image-20220911104740048

如何进行场景漫游(navigate the scene)

1
2
3
你可以使用 alt 键或者鼠标右键结合鼠标移动来旋转视图
你还可以使用方向键来移动视角位置,通过鼠标滚轮键进行缩放
此外,你可以通过 F 键将视图聚焦到选定的游戏对象上

当一个物体(Object)被选中时,它的详细信息会显示在检查器(Inspector)窗口中,我们会在需要的时候来使用它。我们不需要对摄像机和灯光进行参数修改,因此我们可以通过层次结构窗口(Hierarchy)中单击它们左侧的眼睛图标来讲它们在场景中隐藏,这个图标默认是不可见的,只有我们的鼠标悬停在其上面的时候才会出现。这样做纯粹是为了减少场景窗口中的视觉混乱。

眼睛图标旁边的手型图标有用?

1
2
在眼睛图标旁边的列是一个手势形状的列。默认情况下,该图标也是不可见的,只有鼠标悬停在上面才会显示。
当手势图标被激活的时候,你就无法在场景窗口中选中物体了,只有在层级面板(Hierarchy)来选择物体。

构建一个简单的时钟

到目前为止,我们完成了项目设置,现在来正式开始创建我们的时钟了。

创建游戏对象

我们需要一个游戏物体(game object)来表示时钟。我们讲会从最简单的游戏物体开始,你可以通过 GameObject/Create Empty 来创建一个空物体。你也可以通过鼠标右键层次结构面板(Hierarchy)来打开该选项进行创建,或者使用层次结构面板(Hierarchy)左上角的加号来创建,这样就会讲游戏物体添加到了场景中。

你现在可以看到它已经存在并显示在了层次结构面板(Hierarchy)中,并且层次结构面板(Hierarchy)标有星号(*),它表示这该场景有着未保存的更改。

image-20220911153907458

选中该物体,在 Inspector 面板中就会显示物体的详细信息,在它的顶部是一个带有物体名称和一些配置选项。默认情况下该物体是已启用的,非静态(Static),未标记并且位于默认图层上的,这些配置都很好,除了它的名字,现在来将它的名称改为 Clock 。

image-20220911154733754

在它的名称下方是物体的组件(Component)列表。默认所以物体都会拥有一个 Transform 组件。它控制了游戏物体的位置(Position),旋转(Rotation)和缩放(Scale)。现在确保 Clock 物体的位置和旋转值为 0 ,其缩放应该统一为 1 (即不缩放,1 倍原始大小)。

那么二维物体呢?

1
2
在 2D 即二维编写程序的时候,你可以忽略其中一个维度。
专门用于 2D 物体(如 UI 元素)通常有一个 RectTransform 来代替,它是一个专门的 Transform 组件

因为游戏物体是一个空物体,所以它在场景中本身是不可见的。但是,在游戏物体的位置(世界中心位置)可以看到物体的操作工具。

image-20220911155524971

为什么选中 Clock 物体后看不到操作工具?

1
操作工具位于 Scene(场景)窗口中,而不是 Game(游戏)窗口中

可以通过编辑器工具栏左上角的按钮控制不同操作工具处于活动状态。这些工具也可以通过快捷键QWERTY 来激活。该工具栏最右侧的按钮是用来启用我们自定义的编辑器工具。默认情况下移动工具处于活动状态(启用状态)。

image-20220911155928408

工具栏右侧的另外三个按钮,分别影响物体对象的中心点,物体坐标系以及物体吸附。

其动态使用效果可以查看Unity 工具栏功能介绍

创建时钟的表面

虽然我们创建了一个时钟物体,但是我们并没有创建任何实际物体。我们必须向其添加 3D 模型,以便于渲染一些东西。Unity 中默认给我们提供了一些用来构建简单时钟的简单物体。现在来通过 GameObject/ 3D Object / Cylinder 场景中添加一个圆柱体,添加完成后请确保其的 Transform 和我们的 Clock 物体有相同的值。

image-20220911160847342

新创建的物体比之前的空物体多了三个组件(Component)。首先,它拥有一个 Mesh Filter,其中包含对圆柱体网格的引用。

image-20220911161236700

第二个是 Mesh Renderer。该组件(Component)的目的是确保物体网格会倍渲染。同时它还确定了所使用的材质,即该物体所使用的默认材质。该材质也会被显示在组件列表的最下方。

image-20220911161422280

第三个组件是 Capsule Collider ,用于 3D 物理。该对象表示一个圆柱体,但是它缺拥有一个胶囊碰撞体,因为 Unity 没有原始圆柱体碰撞体。现在我们将这个组件删除即可,因为我们不需要它,如果你希望在 Clock 使用物理学,最好使用 Mesh Collider 组件。你可以通过组件右上角三个点来删除组件。

image-20220911161814332

现在我们将圆柱体变成钟表的底面。这是通过减少其 Y 轴缩放比例分量来实现的。将其降低到 0.2 ;现在来将其 X 和 Z 轴的缩放分量扩大到 10 来获得一个比较大的钟表底面。

image-20220911162105818

我们的时钟应该是竖直站立或者悬挂在墙上的,而目前是平放的。我们可以将圆柱体旋转 $\frac{1}{4}$ 圈来解决这个问题。在 Unity 中,X 轴指向右侧,Y轴指向上方,Z轴指向前方。

image-20220911162516873

现在来将圆柱体名称更改为 Face,它代表了 Clock(时钟)的表面,是其一部分。我们可以通过将其拖动到 Clock 层级下方来实现其父子关系。

image-20220911162716377

子物体受制于父物体的变换,这意味着如果改变 Clock 物体的位置,Face 也会倍改变位置。旋转和缩放也是如此。你可以通过如此来制作复杂的对象层次结构。

创建时钟时间标识

时钟外侧往往有时间标识用来指示时间,现在我们来使用 12 块来表示 12 小时制的时间。

通过添加一个立方体(Cube)物体到场景中,并命名其为 Hour Indicator 12 ,使其成为 Clock 物体的子对象,其层级顺序无关紧要。

image-20220911163058758

然后将其进行为下图设置,使其成为一个扁平的物体。将其移动到 Face(钟表表面)上以指示 12 小时,同时移除它的 Box Collider 组件。

image-20220911163317496

由于时间标识器的颜色和表面颜色相近以至于很难看到。现在我们来对它创建一个单独的材质。通过 Assets/ Create / Material 或者项目窗口(Project)右上角的加号来创建。这里我们创建了一个材质资源,并将其命名为 Hour Indicator 。

image-20220911163729567

现在来选中材质然后单击其颜色,这样会打开一个颜色弹出窗口,该窗口提供了不同颜色的选择方式,我选择了深灰色,对应 16 进制的 494949 。我们不使用 Alpha 通道,所以它的值不需要设置。然后保持材质的其他属性不变即可。

image-20220911164032363

什么是 Albedo(反射率/漫反射率)?

1
2
Albedo 是一个拉丁词语,它的意思是 Whiteness (白色程度)
它表示物体被白光照亮某物时的某物颜色。

现在你可以通过将材质拖动到 场景(Scene)中或者层级窗口(Hierarchy)中的物体上来执行为物体赋予新的材质。你也可以在物体的 Mesh Renderer 组件的 Materials 属性改变其指向为你所指定的材质。

image-20220911164657105

十二小时时间标识

现在来复制 12 小时标识器,增加6,12,3,9小时标识,3,9小时标识其 X 位置应该为 4 和 -4。Y 轴的位置应该是 0 。另外它们的 Z 旋转设置为 90 ,这样它们就会旋转 $\frac{1}{4}$ 圈。

image-20220911165741792

然后复制创建另外的标识物体,这次要设置的是 1 标识,要将其 Y 位置设置为 3.464,并选择其 Z 轴旋转为 -30 .然后复制创建 2 小时标识,交互其 X 和 Y 轴的数值,然后将 Z 轴旋转加倍到 -60 。

image-20220911165931200

这些数值都是从哪里来的?

1
2
每小时沿着 Z 轴顺时针旋转 30°。在这种情况下,我们可以通过三角函数,30°的正弦为 1/2,其余弦为 2分之根号3,我们按照其距离中心为 4来计算,最终得到 X 为 2,Y为 2根号3,即约等于3.364。
对于旋转为 60°来说,只需要交互其正弦和余弦即可。

现在来复制其他小时标识,来完成 12 小时时间标识。

image-20220911170344215

创建指针

接下来创建时钟指针,现在来从小时指针开始。再次复制 Hour Indicator 12 并命名为 Hours Arm 。然后创建一个 Clock Arm 的材质来赋给 Hours Arm 使用。在该材质,我创建其为纯黑色,即十六进制的 000000。然后将其 X 比例减小到 0.3,将其 Y 比例增加到 2.5 。然后将其 Y 位置改为 0.75,使其指向 12 小时标识,同时也让其位置超过中心位置向下一点,这样使得其在旋转的时候拥有一点配重的感觉。

image-20220911171610682

时针的旋转需要围绕时钟的中心,但是现在如果改变其 Z 旋转,只会使其围绕自己的中心旋转。

image-20220911171610682

发生如上情况是因为旋转是相对于游戏物体的本地位置。要创建适当的旋转,我们必须引入一个轴并旋转该轴对象。所以创建一个新的空物体,并将其命名为 Hours Arm Pivot 并确保其位置和旋转为 0,且其缩放比例为 1 。然后使 Hours Arm 成为 Hours Arm Pivot 的子物体。

image-20220911172437344

现在来尝试转动时针轴,如果你在场景(Scene)视图中进行此操作,请确保工具栏右侧第一个按钮的设置为 Pivot 而不是 Center

image-20220911171610682

复制Hours Arm Pivot两次分别创建一个 Minutes Arm Pivot(分针)和一个 Seconds Arm Pivot(秒针)并相应的重命名它们,包括其包含的子物体。

image-20220911185055710

然后分别调整 Minutes Arm(分针)的值为如下图所示,需要注意的是:这些值是针对分针来说的,而不是其轴点。

image-20220911185204949

接下来是调整Seconds Arm(秒针)值如下:

image-20220911185309837

最后为秒针创建一个特殊颜色的材质来区别其他指针,我采用深红色,即十六进制的 B30000。

image-20220911185645927

在完成时钟场景的构建后,就是保存场景的好时机,你可以通过File / Save或者其指定的键盘快捷键来完成保存。

同时,时刻保持你的项目资源文件夹整洁是一个好习惯,所以我们会为材质创建一个Materials的文件夹用来存放我们为时钟创建的各种材质。

image-20220911190029217

时钟动画

现在我们创建的时钟仅仅是个物体,它并不会自己进行转动,需要我们对其进行动画处理,我们必须通过脚本来进行定义其行为,即为物体添加自定义组件(Component)。

C# 脚本

通过 Assets / Create /C# Scripts 并命名为 Clock。C# 是用于 Unity 脚本的编程语言,其发音为 C-Sharp。现在我们来创建一个 Scripts 文件夹来存放我们创建的脚本,来保持项目资源文件夹的整洁。

image-20220911190400650

当我们选中脚本后,在检查器(Inspector)面板会显示其内容。但是要编辑其内容,我们必须通过代码编辑器来完成。你可以通过按键 Open 进行编辑,也可以在项目文件面板双击对应的脚本。Unity 会通过首选项配置的编辑器打开选中的脚本。

image-20220911190729550

定义新组件类型

在你的代码编辑器加载脚本后,首先删除标准模板代码,因为我们将从头开始创建组件类型(Type)。

一个空的脚本文件没有定义任何东西。我们通过定义 Clock 类型,这样我们就可以在 Unity 中创建多个这样的组件。即使我们在本教程中仅仅使用这一个时钟。

在 C# 中,我们首先声明 Clock 是通过如下代码示例:

1
class Clock

从技术角度上来说,什么是类(Class)?】

1
你可以将类(Class)视为可以驻留在内存中的对象的蓝图,这些蓝图定义了这些对象应该包含的数据以及它们所有的功能。

因为我们不想限制哪些代码可以访问我们的Clock类型,所以最好在它的前面加上Public访问修饰符。

1
public class Clock

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

1
如果没有设定访问修饰符,编译器默认我们使用的是 internal class Clock,这将限制对于来自同一程序集的代码的访问,所以为了确保它始终有效,请默认将类设置为公开,即使用 Public 关键词

此时,我们的还并不是有效的 C# 语法,如果你保存脚本文件并且返回 Unity 编辑器中后,其编译的错误记录会显示在控制台(Console)窗口中。

我们在定义一个类型的时候,需要使用如下的一对大括号来完成对类型定义的结束表示:

1
public class Clock { }

现在来说,我们的代码是有效的。在保存文件并返回 Unity 编辑器后,Unity 检测到 Assets 的脚本文件发生变化,则会出发重新编译。完成后,选择我们的脚本,检查器(Inspector)面板会提示我们没有MonoBehaviour脚本。

image-20220911192411425

这意味着我们不能使用这个脚本在 Unity 中创建组件。至此,我们定义了Clock类的一个 C# 基本类型。但是我们自定义组件类型,必须使用 Unity 的MonoBehaviour类型,来继承它的数据和功能。

【**Mono-Behaviour是什么意思?**】

1
这是我们可以编写我们自定义组件来为游戏物体添加自定义行为。这就是 Behaviour 部分所指示的信息。它碰巧使用了英式拼写。而Mono 是指将自定义代码添加到 Unity 的方式,它使用了 Mono 项目,这是个将 .NET 框架跨平台实现的项目。而 MonoBehaviour 是一个旧的名称,为了向后兼容,所以一直使用到现在。

要使得Clock类型变成MonoBehaviour的子类型,我们必须更改我们对Clock类型的声明方式。我们可以在类型声明后面使用:来完成,如下代码所示,这样就使得Clock继承MonoBehaviour类型的所有内容。

1
public class Clock:Monobehaviour { }

但是,这些写编译器会报错,表示无法找到MonoBehaviour类型,这是因为我们需要引用命名空间UnityEngine,因为MonoBehaviour类型就包含在其中,所以要访问它,我们需要使用其完整的引用名称,即UnityEngine.MonBehaviour。代码示例:

1
public class Clock:UnityEngine.MonoBehaviour { }

什么是命名空间?

1
2
命名空间类似于网站域名,但是是用于代码部分,命名空间可以用来组织代码并防止代码中的名称冲突。
UnityEngine代码程序集是 Unity 自带的,所以你不需要再单独获取它,如果你导入了适当的编辑器集成包,则编译器会自动识别并使用它

在我们在访问 Unity 类型的时候,每次都带着UnityEngine是很不方便的,所以我们可以通过在文件头顶部分添加using EnityEngine 来完成整体声明,并以分号表示结束,代码示例:

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

现在我们可以将自定义的组件添加到我们的 Clock 游戏物体中了,你可以通过拖动脚本到物体来完成或者通过 Add Component (添加组件)来添加。

image-20220911194635356

需要注意的是:我的教程中大多数的代码类型都会链接到在线文档,例如MonoBehaviour 是一个链接,可以通过它来访问 Unity 中该类型的脚本 API 页面。

获取时针指针

如果希望可以旋转指针,则我们所创建的Clock类型,需要先有指针的定义。现在我们从时针开始。和其他游戏物体一样,我们可以调整其 Transform 组件来旋转。所以我们必须将时针轴 Transform 组件添加到Clock类型中,这可以通过在代码中添加一个字段来完成。

我将使用hours pivot来命名该字段,一般习惯上我们会将字段第一个单词小写,然后其他单词的首字母大写,然后将它们合并在一起,即hoursPivot,代码示例:

1
2
3
4
5
using UnityEngine;
public class Clock : MonoBehaviour
{
hoursPivot;
}

我们还必须声明字段的类型为 UnityEngine.Transform,在本例中,我们必须将其写在名称的前面。

1
2
3
4
5
using UnityEngine;
public class Clock : MonoBehaviour
{
Transform hoursPivot;
}

我们的类现在定义了一个可以保存对另一个对象的引用字段,该对象的类型必须是 Transform 类型,所以我们可以通过该字段来保存对时针的轴的 Transform 组件的引用。

默认情况下,字段是私有(private)的,这意味着你只属于Clock类型。但是该类不知道我们的 Unity 场景,所以没有办法直接将字段和正确的对象进行关联。我们可以通过将字段声明为可序列化(serializable)来改变这一点。这意味着当 Unity 保存场景的时候,它(该字段)应该包含在场景的数据中,它通过将所有数据按顺序排列并将其写入文件来实现。

将字段序列化(serializable)是通过将属性附加在它上面来完成的,使用 SerializeField 并将其写在方括号之间并声明在字段的前面。一般来说是写在字段的上面一行,当然你也可以放在同一行上。

1
2
3
4
5
6
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot;
}

我们不可以将其Public(公开)吗?

1
2
可以,但是让类的字段(变量)可以被公开访问是一种不好的行为。
经验上来说,仅仅需要被其他类型的 C# 代码访问的类型内容时才会公开类的内容,然后优先选择方法或者属性公开而不是字段。越难访问的东西就越容易维护,因为可以直接依赖它的代码更少。

一旦该字段序列化(serializable)后,Unity 将检测到并将其显示在 Clock 游戏物体的检查器(Inspector)面板中。

image-20220911200820460

要进行正确的链接(引用),我们可以将 Hours Arm Pivot 从结构层级(Hierarchy)面板拖动到对应的字段框,或者选择字段框右侧的圆形按钮进行选择。不论哪种方法,Unity 都会获取到 Hours Arm Pivot 物体的 Transform 组件并引用在字段上。

image-20220911201116320

获取所有指针

现在我们来对另外的分针和秒针做同样操作,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot;

[SerializeField]
Transform minutesPivot;

[SerializeField]
Transform secondsPivot;
}

因为声明的字段(变量)其类型是相同的,所以我们可以通过改变代码的编写方式来使得上述代码变得更加整洁:

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

//[SerializeField]
//Transform minutesPivot;

//[SerializeField]
//Transform secondsPivot;
}

【**// 表示什么?**】

1
2
双斜杠(//)在 C# 中表示注释的意思,编译器会忽略它们之后的所以文本直至最后。
如果需要,可以通过使用它带添加文本来阐明代码。

现在来将分针和秒针链接到字段(变量)上。

image-20220911201558164

初始化

现在我们获取所有的指针了,接下来就是旋转它们了。为此,我们需要让Clock类执行一些代码。这是通过向类中添加一个代码块来完成,称为:方法(Method)。该块必须以名称为前缀,安装约定大写,其命名为Awake,需要在组件唤醒的时候执行的代码可以写在里面。代码示例:

1
2
3
4
5
6
7
8
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

Awake{ }
}

方法(Method)有点类似于数学函数,例如 $F(X) = 2X +3$ ,函数接收一个数值,然后将其进行相关操作,输出结果。

如同数学函数一样,方法也可以产生结果,但是这不是必须的。我们必须声明结果的类型,就好像字段要声明其类型一样,或者你可以使用void来表示其不需要返回结果。在本例中,我们只想执行一些代码,不需要结果值,所以我们使用void,代码示例:

1
2
3
4
5
6
7
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake{ }
}

同样的,我们也不需要任何出入数据,但是我们必须将方法的参数定义在圆括号之间并用逗号分隔。在本例中,参数列表是一个空列表,代码示例:

1
2
3
4
5
6
7
8
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake(){ }
}

到现在为止,我们有了一个有效的方法,但是我们并没有做任何事情。如同 Unity 会检测我们定义的字段一样,它也会检测 Awake方法。当组件拥有Awake方法时,Unity会在组件唤醒的时候调用该方法。该方法发生在播放模式(Play)下被创建或者加载之后。我们目前处于编辑模式,所以并不会发生什么:

【**Awake方法必须要公共(Public)吗**】

1
Awake函数还有一些其他方法集合被认为是 Unity 的特殊事件方法,所以无论我们怎么声明它们,Unity 引擎都会找到它们并且在适当的时候执行调用它们。这将发生在托管 .NET 环境之外。

请注意,Awake 其他特殊的 Unity 事件方法在我的教程中同样有链接到他们的在线 Unity 脚本 API 页面。

通过代码来旋转

要旋转指针,我们可以通过改变物体的 Transform 组件的 localRotation 属性(property)来完成旋转。

什么是属性(Property)?

1
2
属性是一种伪装成字段的方法。它可能是只读或者只写的。
C# 约定将属性大写,但是Unity的代码并不这样做。

尽管在检查器(Inspector)面板中 Transform 组件的旋转是通过欧拉角(Euler angles,以度数为单位)来定义的,但是在代码中我们必须使用四元数(quaternion)来完成。

什么是四元数(quaternion)?

1
2
四元数是基于复数,用于表示 3D 旋转。
虽然它相比于欧拉角的X,Y,Z轴组合更难以理解,但是它有一些独特的特性,例如:它不会出现万向节死锁的问题。

关于四元数的可以查看四元数的可视化

我们可以通过调用 Quaternion.Euler 该方法创建基于欧拉角的四元数。为此,需要将其写入Awake方法在,最后以分号表示结束。

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
Quaternion.Euler;
}
}

该方法具有描述所需旋转的参数,需要对其传入三个参数,分别表示 X,Y,Z轴的旋转数值,代码示例:

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
Quaternion.Euler(0,0,-30);
}
}

此方法的执行结果是返回一个Quaternion类型的值(结构体,Strcut),其中包含绕 Z 轴顺时针旋转 30°,正好和时钟上的 1 小时匹配上。

要将其旋转结果应用在时针上,需要使用赋值语句将结果赋值给我们的时针,代码示例:

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
hoursPivot.localRotation = Quaternion.Euler(0,0,-30);
}
}

【**localRotationrotation有什么区别?**

1
2
3
loaclRotation属性表示Transform组件单独描述的旋转,它是相对于其父级的旋转。
相反而言,rotation属性表示世界空间中的旋转,这将会把整个层级结构考虑在内。
即一个是本地为中心(父级)旋转,一个以世界中心旋转的。

现在进入编辑器的播放模式(Play)。你可以通过 Edit/ Play来开始,或者使用快捷键,或者使用编辑器窗口顶部中心的播放按钮。进入播放模式(Play)后,Unity 会将焦点切换到游戏窗口(Game),该窗口会渲染 Main Camera 在 Scene(场景)面板中看到的内容。我们编写的 Clock 组件将会被唤醒,执行Awake方法的代码,将时钟设置为 1 点。

image-20220911210552600

如果你的摄像机并没有聚焦在时钟上,你可以调整它使得时钟在播放窗口中可见,但是请记住,退出播放模式时场景会重置(即恢复到播放前的状态),也就是所你在播放模式(Play)所做的任何操作都不会被保存。所以如果你需要对场景做更改,需要先退出播放模式。

获取当前时间

下一步是来计算我们当前的时间。我们可以使用 DateTime 类型来获取我们当前设备的时间。DateTime 类型并不是 Unity 的类型,而是 .NET 框架的功能之一,我们可以直接用来使用,其类型在System命名空间下。

DateTime 包含一个方法Now可以获取包含当前系统时间和日期的属性值。为了检测它是否正确,我们可以在Awake方法中使用Debug.Log来传递这个数值,代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
Debug.Log(DateTime.Now);
hoursPivot.localRotation = Quaternion.Euler(0,0,-30);
}
}

这样我们每次进入播放模式(Play)后就会记录一个时间戳,你可以通过控制台(Console)窗口或者编辑器底部的状态栏中看到它。

旋转指针

现在我们来调用DateTime.Now.Hour,这样它会在时间戳的基础上给我们一个小时的值。

因此,为了让时针能够来显示当前小时时间,我们需要将 -30°旋转乘以 当前的小时。在代码中,我们使用*来表示乘法。我们现在也不需要再记录当前时间,可以直接删除Debug.Log语句了。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
hoursPivot.localRotation = Quaternion.Euler(0,0,-30*DateTime.Now.Hour);
}
}
image-20220911211634971

为了清晰表示我们正在从小时转换为旋转的度数,们可以定义一个名称为hoursToDegrees的字段,因为 Quaternion.Euler 定义为浮点值,所以我们将会使用float类型。因为我们已经知道这个数值了,所以我们可以将其作为字段进行声明,然后使用的时候只需要用字段来表示该值即可。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
float hoursToDegrees = -30f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
hoursPivot.localRotation = Quaternion.Euler(0,0, hoursToDegrees * DateTime.Now.Hour);
}
}

什么是float(单精度浮点值)?

1
2
计算机并不能存储所有的数值,它必须最后被表示为 0 和 1 的二进制存储,这使得数值必须存储在有限的内存中,而不能做到存储无限的数值,这也就意味着在存储类似于 0.33333... 无限循环/不循环小数的时候,我们不得不丢弃一部分精度,即存储 0.3333 来表示 0.33333......
而存储浮点数就是用float表示这是一个浮点数类型,它在内存一般使用 4个字节的长度来存储。

如果我们直接声明一个没有后缀的数值,则会被默认为是一个整数(即整型数值),它不同于浮点类型数值,虽然编译器会自动转换它们的类型,但是我们一般会在float类型后通过添加f来表明我们的数值是float类型的。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
float hoursToDegrees = -30f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
hoursPivot.localRotation = Quaternion.Euler(0f,0f, hoursToDegrees * DateTime.Now.Hour);
}
}

因为每小时转动的度数都是相同的,我们可以通过将字段修饰为const来表示其是一个常量而不是字段(变量)。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
const float hoursToDegrees = -30f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
hoursPivot.localRotation = Quaternion.Euler(0f,0f, hoursToDegrees * DateTime.Now.Hour);
}
}

【**const类型有什么特殊之处?**】

1
const关键词表示该值永远不会改变,它的值会在编译期间计算并替换为常量来使用。

现在,我们来使用对其他两个指针做类似的处理,一分钟和一秒钟都是以 -6° 来表示,代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
const float hoursToDegrees = -30f, minutesToDegrees = -6f, secondsToDegrees = -6f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
hoursPivot.localRotation =
Quaternion.Euler(0f, 0f, hoursToDegrees * DateTime.Now.Hour);
minutesPivot.localRotation =
Quaternion.Euler(0f, 0f, minutesToDegrees * DateTime.Now.Minute);
secondsPivot.localRotation =
Quaternion.Euler(0f, 0f, secondsToDegrees * DateTime.Now.Second);
}
}
image-20220911212946178

我们在使用 DateTime.Now 调用三次该方法,这样会导致,每次获取的数值会产生微小的差异。为了确保这种情况不再发生,我们应该只检索一次时间,我们可以通过声明一个变量并将时间分配给它来完成这点。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
const float hoursToDegrees = -30f, minutesToDegrees = -6f, secondsToDegrees = -6f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Awake()
{
DateTime time = DateTime.Now;
hoursPivot.localRotation =
Quaternion.Euler(0f, 0f, hoursToDegrees * time.Hour);
minutesPivot.localRotation =
Quaternion.Euler(0f, 0f, minutesToDegrees * time.Minute);
secondsPivot.localRotation =
Quaternion.Euler(0f, 0f, secondsToDegrees * time.Second);
}
}

如果是变量,可以省略其类型声明,使用var关键字来替换它,这样可以缩短代码,但是只有当变量类型被分配值的时候才会推测其类型。

1
var time = DateTime.Now

让指针动起来

现在我们进入播放模式,指针只会变化一次,即进入播放模式(Play)的时刻,我们现在需要将之前的Awake方法改为Update。这是另一个特殊的事件方法,该方法只要我们保持播放模式(Play),Unity 就会在每帧调用一次该方法,而不是类似于Awake方法只调用一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using UnityEngine;
public class Clock : MonoBehaviour
{
const float hoursToDegrees = -30f, minutesToDegrees = -6f, secondsToDegrees = -6f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Update()
{
DateTime time = DateTime.Now;
hoursPivot.localRotation =
Quaternion.Euler(0f, 0f, hoursToDegrees * time.Hour);
minutesPivot.localRotation =
Quaternion.Euler(0f, 0f, minutesToDegrees * time.Minute);
secondsPivot.localRotation =
Quaternion.Euler(0f, 0f, secondsToDegrees * time.Second);
}
}
image-20220911212946178

需要注意的是:我们创建的 Clock 组件(脚本)在检查器(Inspector)面板中出现了一个选择框,这意味我们可以通过禁用组件来禁用其中的Update方法。

连续旋转

现在我们获得了一个离散的数字时针,但是通常来说,我们的时针具有缓慢旋转的指针,可以提供时间的模拟表示,现在来让我们对代码进行部分修改。

DateTime 类型并不包含小数数据,幸运的是,它确实有一个 TimeOfDay的属性。它返回一个TimeSpan类型的值,其中包含我们所需要的个数的数据,通过其TotalHours,TotalMinutes,TotalSeconds属性来设置我们的指针变化。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
const float hoursToDegrees = -30f, minutesToDegrees = -6f, secondsToDegrees = -6f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Update()
{
TimeSpan time = DateTime.Now.TimeOfDay;
hoursPivot.localRotation =
Quaternion.Euler(0f, 0f, hoursToDegrees * time.TotalHours);
minutesPivot.localRotation =
Quaternion.Euler(0f, 0f, minutesToDegrees * time.TotalMinutes);
secondsPivot.localRotation =
Quaternion.Euler(0f, 0f, secondsToDegrees * time.TotalSeconds);
}
}

如果这么写的话,编译器会给我们报错:无法从double转换为float类型,这是因为TimeSpan属性是双精度类型,即double类型。该类型提供了比float精度更高的类型。但是Unity 提供的方法仅仅接收一个单精度类型的数值。

所以我们可以通过显示转换类型来解决这个问题,此过程称为强制类型转换,通过在要转换的数值前面圆括号内写入要转换的类型即可。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using UnityEngine;
public class Clock : MonoBehaviour
{
const float hoursToDegrees = -30f, minutesToDegrees = -6f, secondsToDegrees = -6f;
[SerializeField]
Transform hoursPivot, minutesPivot, secondsPivot;

void Update()
{
TimeSpan time = DateTime.Now.TimeOfDay;
hoursPivot.localRotation =
Quaternion.Euler(0f, 0f, hoursToDegrees * (float)time.TotalHours);
minutesPivot.localRotation =
Quaternion.Euler(0f, 0f, minutesToDegrees * (float)time.TotalMinutes);
secondsPivot.localRotation =
Quaternion.Euler(0f, 0f, secondsToDegrees * (float)time.TotalSeconds);
}
}

image-20220911212946178

End

到目前为止,你已经了解了 Unity中创建对象(物体)和编写代码的相关基础了,下一个教程是: 构建函数可视化图形