Unity

代码编辑器

建议使用Vscode,建议安装两个插件:

窗口

窗口一览

窗口布局的设置

可使用「菜单栏-窗口-布局」中的操作来加载、导入、导出窗口布局。

游戏视图中模拟器的选择

  • 长宽比适中:Apple iPhone 13 Pro Max

  • 长宽比过小:Apple iPad Mini 4

  • 长宽比过大:Samsung Galaxy Z Fold2 5G

俯视角渲染模式的设置

透视叠层的排序方法的设置

「菜单栏-编辑-项目设置-图形-摄像机设置」中,「透明度排序模式」值改为「自定义轴」,「透明度排序轴」的「x、y、z」改为你想要的用于渲染排序的权值。

图片轴心的设置

点击「项目视图-你要更改轴心的图片」,更改「检查视图-Sprite模式-轴心」的值为「自定义」。点击下方的「SpriteEditor」,弹出窗口,「轴点单位模式」的「Normalized」是轴心坐标相对于图片大小的比例,「Pixels」是轴心坐标的像素数,注意编辑结束关闭该窗口前需点击「应用」。

大量同种图片可批量设置轴心。

场景视图中操作点的设置

更改完图片轴心后,若点击场景视图中选中的对象的操作点未改变,则可以试一下更改场景视图上部的值为「轴心」:

对象Sprite排序点的设置

选中你想更改的对象,更改「检查视图-SpriteRenderer-Sprite排序点」的值为「轴心」。

补充:像素图模糊的解决方案

往Unity中导入像素图时,由于像素图一般较小,会被处理得很模糊,这时我们在项目视图中找到该图片,打开,将「过滤模式」的值改为「点(无过滤器)」,然后点击应用即可。

Transform

检查视图设置

函数调用

因为是第一次将编写脚本相关的知识,所以先引入一些前置知识。

建议在「项目视图-Assets」下创建一个文件夹「Scripts」,在该文件夹下创建一个C#类型文件。

这是新建C#脚本后的初始内容:

1
2
3
4
5
6
7
8
9
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class NAME/*脚本名*/ : MonoBehaviour
{
void Start(){}
void Update(){}
}

其中这个类的名称一定要与脚本文件的名称保持一致,且一定要继承MonoBehaviour,否则无法在Unity中使用。

可以使用:

1
Debug.Log("Hello world!");

在命令行中输出Hello world!,用于调试。

前置知识引入完毕。

一般来说如果需要使用脚本所在对象自身的某个组件会使用GetComponent,但因为任何对象都有「Transform」组件,所以Unity中自带一个名为transform的属性可以直接使用:

1
2
3
4
5
6
Vector3 transform.localPosition
Vector3 transform.position
Vector3 transform.localEulerAngles
Vector3 transform.eulerAngles
Vector3 transform.localScale
Vector3 transform.lossyScale

这六个属性的含义分别是「相对于父级的位置」、「全局位置」、「相对于父级的旋转」、「全局旋转」、「相对于父级的缩放」、「全局缩放」。

除了lossyScale是只读的,其他都是可读可写的。更改时不能只更改单个维度,如transform.position.x = 1f,只能一并更改,如transform.position = new Vector3(1f, 0f, 0f)

localEulerAngleseulerAngles在被赋值时可以被赋任意值,但其会将每一个维度上的值都化为 [0,360)[0,360) 的值,读取时只会读到这个范围的值。另外localRotationrotation这两个属性的使用相对复杂,可见后文「四元数与旋转」一章。

SpriteRenderer

检查视图设置

  • 「翻转」

    勾选后贴图沿镜面翻转,常用于减少贴图量、动画量。

  • 「Sprite排序点」

    见之前章节「俯视角渲染模式的设置-对象Sprite排序点的设置」。

  • 「排序图层」和「图层顺序」

    点击「排序图层-AddSortingLayer」即可进入管理图层列表。

    「排序图层」和「图层顺序」都是值越高渲染时越靠上。

    「排序图层」和「图层顺序」和「透视叠层的排序方法」,这三者的优先级是递减的。

函数调用

要使用脚本所在对象自身的「SpriteRenderer」组件,就需要GetComponent函数获取了,建议获取组件的代码放在Awake函数中:

1
2
3
4
5
private SpriteRenderer sr;
private void Awake()
{
sr = GetComponent<SpriteRenderer>();
}

常用属性:

1
2
3
4
bool sr.flipX
bool sr.flipY
string sr.sortingLayerName
int sr.sortingOrder

这四个属性的含义分别是「翻转x轴」、「翻转y轴」、「排序图层」、「图层顺序」。

均为可读可写。

代码相关补充知识

变量的赋值位置

建议如果是需要在编辑器检查窗口中调值的public变量,在声明时赋值;而如果是不需要在编辑器检查窗口中调值的private变量,在Awake函数中赋值。

如果你想让一个变量在每次启动游戏时被赋为某一个值,而你却在声明时赋值,且你又在编辑器检查窗口中调了它的值,则在进入游戏时他不会是你在代码中声明变量时赋的值,而是你调的值。

常用标签属性

标签属性能是你的脚本在编辑器检查窗口中有着不一样的显示。

使用方法是在代码中声明变量前加上一些内容,例如:

1
2
3
4
5
[Header("NAME")]
public int a;
public int b;
[Space(30)]
public int c;

效果:

  • [Header("XXX")]

    标题。

  • [Space]/[Space(XXX)]

    一段空行,参数可有可无。

  • [Tooltip("XXX")]

    将鼠标悬停在检查窗口的变量名上可显示提示。

  • [SerializeField]

    强制序列化并显示在检查窗口中。

  • [System.NonSerialized]

    强制不序列化并不显示在检查窗口中。

另外还有一个[HideInInspector],和[System.NonSerialized]有相似之处,但不建议使用,原因是他可能会让代码让我们认为的值和实际值不同。

对象的标签

不要将标签属性和对象标签搞混了,对象的标签是这个:

对象本身(GameObject)和上面的任意组件(例如Transform),都有两个和对象的标签相关的属性和方法:

1
2
transform.tag = "NAME";
transform.CompareTag("NAME");

tag可读可写,CompareTag返回bool值。

对象的名称

在脚本中可以使用GameObject类下的属性name读取或修改对象的名称:

1
2
Debug.Log(gameObject.name);
gameObject.name = "NEWNAME";

输入系统(InputSystem)

输入系统的设置

「菜单栏-编辑-项目设置-玩家-设置Windows,Mac,Linux-其他设置-配置-活动输入处理」的值改为「输入系统包(新)」。

安装InputSystem

点击「工具栏-窗口-包管理器」,弹出新窗口,新窗口左上方「包:XXX」改为「包:Unity注册表」搜索「InputSystem」,点击右下角「安装」。

创建InputControl文件

建议在「项目视图-Assets」下创建一个文件夹「Settings」,在该文件夹下创建一个「InputActions」类型文件,建议重命名为「InputControls」。

设置InputControl文件

双击打开新窗口,开始编辑。新建一个「ActionMap」。

ActionMap,Action和Binding

可以发现「ActionMap」,「Action」,「Binding」形成了共三层的从属关系,每个「ActionMap」包含几个「Action」,每个「Action」包含几个「Binding」。

在游戏中的不同的按键使用场景,例如开始界面和游戏界面,按键有不同的功能,这时需要不同「ActionMap」来管理按键。

不同的设备有不同的按键,为了使游戏对多种设备兼容,一个「Action」需要有多个「Binding」,当然也可以是同一设备上同一按键操作绑定给多个按键,例如w和上箭头。

Action的设置

  • 「动作」

    「ActionType」有三个选项,这里只介绍其中的「按钮」和「值」。

    • 「按钮」

      「初始状态检测(InitialStateCheck)」检测该按钮初始时是否处于激发态上,并根据判断结果的是否来决定是否一开始就执行操作。

    • 「值」

      多用于持续更改的状态的输入,如鼠标的位置,手柄的状态。

      如果有多个设备绑定这个Action,只会发送其中一个设备(最受控制的)的输入。

  • 「Interactions」

    在「Binding」中也可设置「Interactions」,但只作用于该「Binding」,在「Action」中设置可作用于所有子「Binding」。

    所有输入检测模式都会发出3种信息,并将这些信息传给被调用的函数,分别是「started」、「performed」、「canceled」。接下来介绍不同输入检测模式何时发出这些信息。

    • 「Default」

      没有添加「Interactions」时,即默认时,是这种状态。

      当输入设备响应会调用「started」回调(例如按钮按下,或者鼠标开始拖动);当设备响应中时会调用「performed」回调(在「started」后触发,调用一次,若为「值」的话,当值发生变化会再次触发,例如遥感,按住之后每次偏移位置都会触发一次);当输入设备结束响应会调用「canceled」回调(例如按钮松开,或者鼠标停止拖动)。若为「PassThrough」的时候,只会调用「performed」回调。

      一般使用「按钮」时不使用「Default」;使用「值」时使用「Default」就可以了。

    • 「Press」

      模拟按钮。

      所有该类输入模式的「started」都于键被按下是发出,「canceled」都于键被松开时发出。

      其子模式「PressOnly」的「performed」于键被按下时发出;「ReleaseOnly」的「performed」于键被松开时发出;「PressAndRelease」的「performed」于键被按下或松开时都会发出。

    • 「Tap」

      模拟短按按钮。

      「started」于键被按下时发出。若「MaxTapDuriation」时间内松开键,触发「performed」,否则触发「canceled」。

    • 「Hold」

      模拟长按按钮。

      「started」于键被按下时发出;「canceled」于键被松开时发出。如果在「MaxTapDuriation」时间后仍未松开,则此时触发「performed」。

    • 「SlowTap」

      模拟长按按钮,虽然叫什么什么Tap,实际上和「Hold」更像。

      「started」于键被按下时发出。如果在「MaxTapDuriation」时间内松开键,则松开时触发「canceled」,否则松开时触发「performed」。

    • 「MultiTap」

      模拟连按短按按钮。

      「started」于第一次键被按下时发出,包括第一次在内,若连续短按「TapCount」次,且满足每次短按的时长不超过「MaxTapDuration」,短按与短按之间时长不超过「MaxTapSpacing」,则在最后一次短按松开时触发「performed」,否则在要求不满足时触发「canceled」,且停止判定。

Binding的设置

  • Binding

    常用的「Path」有键盘上的各个键(可通过监听来添加),和「Press(Touchscreen)」、「Position(Touchscreen)」。

PlayerInput组件的添加和配置

添加组件「PlayerInput」,将InputControl文件填入,将「Behavior」改为「InvokeUnityEvents」,然后下方会出现「事件」,展开。将层级视图中的某对象填入,再选择要调用的函数(当然,得先写一段脚本并安装在该对象上,这样才有的可调用),这样,从硬件设备的信息输入到调用函数的一条信息传递链就完成了。

3D图标的设置

添加完「PlayerInput」组件后,会发现场景视图中该对象操作点位置处出现了一个这样的图标,看起来很碍事:

例如摄像机也有这种图标:

如果想关闭,进行如下操作:

编写脚本

头文件和函数格式

如果希望该脚本中的一些函数能使用InputAction里的内容,需要在头文件处加上:

1
using UnityEngine.InputSystem;

被调用的函数的格式应为:

1
public void NAME(InputAction.CallbackContext context){/**/}

访问修饰符需要为public才能在Unity中「PlayerInput」组件的函数列表中找到。但有趣的是,如果先将访问修饰符设为public,再在「PlayerInput」组件的函数列表中找到该函数,后将访问修饰符改为private,虽然组件的函数列表中找不到了,但是能正常运行且不会报错。

这里不加InputAction.CallbackContext context也能成功调用,但那里面是「PlayerInput」传给被调用函数的信息,包括各种输入设备传来的信息,一般都会使用。

信息类型的判断

这是三个bool值,用于判断传来的是哪种信息。

1
2
3
context.started
context.canceled
context.performed

获取和使用点击屏幕的位置

为获取点击屏幕的位置,首先要将「Binding」设为「Position(Touchscreen)」;将「动作」设为「值」。

通过context.ReadValue可以读取「动作」为「值」的信息的内容,例如屏幕位置是二维向量信息,我们使用下方代码:

1
screenposition = context.ReadValue<Vector2>()

直接使用context.ReadValue获取的是点击屏幕的位置,单位是像素。使用Unity内置函数将其转换为世界坐标:

1
worldposition = Camera.main.ScreenToWorldPoint(screenposition);

在一个可以向前、向左、向右跳跃的跑酷游戏中,我们需要根据点击屏幕的位置和玩家控制的对象的位置判断跳跃方向。一种方法是将这两个位置作为向量做差,再取其单位向量,设定阈值,判断单位向量横坐标或纵坐标是否超过阈值,效果如图:

代码如下:

1
2
3
4
5
6
7
8
9
10
11
public enum Direction
{
Up, Left, Right
}
// ...

Vector2 offset = (worldposition - (Vector2)transform.position).normalized;
private Direction dir;
if (offset.x <= -0.7f) dir = Direction.Left;
else if (offset.x < 0.7f) dir = Direction.Up;
else dir = Direction.Right;

2D刚体(Rigidbody2D)

「2D刚体」的「BodyType」有3个选项。分别是「Dynamic」、「Kinematic」和「Static」。

若对象有「2D刚体」组件,禁止使用「变换(Transform)」组件来控制对象,而应使用后者的函数或变量来操纵,否则可能出现bug。

尽管经常将「2D刚体」表述为相互碰撞,但实际上发生碰撞的是每个刚体所连接的「2D碰撞体」。如果没有碰撞体,刚体不能相互碰撞。

一般来说,值的判断放在「Update」中,而物理内容放在「FixedUpdate」中。

Dynamic

Dynamic具有可用的全套属性,可互动性最高,一般例如玩家自身控制的对象等所使用。

  • 「模拟的」:如果不希望该物体参与物理交互,请取消勾选。注意,取消勾选该选项与移除该组件效果不同,因为移除该组件的效果等同于「Static」,这一点之后也会提到。

  • 「重力大小」:大于0的话对象会像下落,俯视角游戏需将其值设为0。

  • 「碰撞检测」:有「离散的」和「持续」两个选项。

    • 「离散的」:通过计算对象以当前速度移动一个极小时间后的位置来判断碰撞,消耗性能较少,但若速度过快可能会穿模。

    • 「持续」:通过计算对象间的下一个碰撞点来判断碰撞,消耗性能较多,但不会因速度过快而穿模。

  • 「插值」:有三个选项,一般使用「外推」。

  • 「Constraints-冻结旋转」:若勾选上则不会旋转,一般勾选。

Kinematic

Kinematic设计为在模拟条件下移动,如果没有勾选「使用完全运动学联系」,则只能与「Dynamic」交互,且与「Dynamic」碰撞时可视为质量无穷大,即其运动状态不会改变。

Static

Static设计为在模拟条件下(游戏运行时)完全不动。

需要注意的是,如果对象有「2D碰撞体」或其他碰撞体组件却没有「2D刚体」组件,则效果完全等同于有「2D刚体」组件且为「Static」状态。

三种BodyType之间的触发和碰撞关系

条件

要使两个对象之间发生触发或碰撞关系,需同时满足下面3个条件:

  • 两个对象都有「2D碰撞体」或其他碰撞体组件。

  • 两个对象都没有不勾选「模拟的」的「2D刚体」。注意可以没有「2D刚体」,其效果等同于「Static」。

  • 还需满足:

    • 若两个对象的「2D碰撞体」组件有至少一个勾选了「是触发器」(详见「2D碰撞体-检查视图设置」),还需满足如下条件即可发生触发关系:

    • 若两个对象的「2D碰撞体」组件均未勾选「是触发器」还需满足如下条件即可发生碰撞关系:

游戏中效果

  • 触发关系

    在游戏中不会有任何体现。

  • 碰撞关系

    在游戏中体现为两个物体以不过快的速度相遇时会发生碰撞而不会重叠(实际上也会有些重叠,只不过肉眼难以发现),运动状态被改变。但以过快的速度相遇时会发生互相穿过等意料之外的情况。

代码中效果

  • 触发关系

    分别在刚开始接触的一刻,接触期间,刚结束接触的一刻自动触发OnTriggerEnter2DOnTriggerStay2DOnTriggerExit2D三个Unity自带函数。使用方法如:

    1
    private void OnTriggerStay2D(Collider2D other){/**/}

    传参other是与其发生关系的对象的「2D碰撞体」组件。

    两个对象发生关系时,两者的脚本均会同时触发相同的函数。

    每次开始接触或结束接触OnTriggerEnter2DOnTriggerExit2D只会触发一遍。而OnTriggerStay2D会一直触发。注意,如果「休眠模式」设置的不是「从不休眠」,两物体会因为休眠而在移动很微弱或不移动时停止调用OnTriggerStay2D

  • 碰撞关系

    分别在刚开始接触的一刻,接触期间,刚结束接触的一刻自动触发OnCollisionEnter2DOnCollisionStay2DOnCollisionExit2D三个Unity自带函数。

    其他与「触发关系」相同。

2D碰撞体(Collider2D)

场景视图中的显示设置

勾选「菜单栏-编辑-项目设置-2D物理-Gizmos-始终显示碰撞器」即可在场景视图中始终显示碰撞体,无需选中对象即可看到。

也可在代码中设置bool类型Physics2D.alwaysShowColliders的值来控制是否始终显示碰撞器,不过这种方法只能在开始运行后生效。

检查视图设置

2D碰撞体有很多种,我们以最常用的「BoxCollider2D」为例。

  • 「编辑碰撞器」:可以快速拖拽编辑碰撞体大小。

  • 「是触发器」:若不勾选,则该碰撞体是一个能进行碰撞的实体,两个都未勾选该选项的碰撞体碰撞后他们的运动状态会变化。若勾选,则表明你只想要让该碰撞体作为触发器发送信号,不希望他们像实体一样发生碰撞改变运动状态。

  • 「偏移、大小、边缘半径」:参考下方图片。

使用缓动函数控制移动

跨类跨文件的调用

放在不同目录,不同文件下的所有代码中的所有命名空间,所有类在Unity进行编译的时候都相当于放在一起。所以跨文件的调用相当于同文件的调用。接下来我们探讨跨类调用的方法。

注意,这里讨论的不是跨组件的调用,跨组件的调用使用GetComponent等函数。

方法一:实例化

直接将想使用的类实例化即可:

1
2
3
4
5
6
7
8
9
10
11
12
public class A
{
public int b = 10;
}
public class Test
{
private A classA = new A();
private void Update()
{
Debug.Log(classA.b);
}
}

注意,因为某种原因,实例化的类如果里面套着类,那个套在里面的类没有办法是用,编译器会报错,如果想使用类里套的类,应对其直接示例化。例如首先有这样一个嵌套类:

1
2
3
4
5
6
7
public class A
{
public class B
{
public int c = 20;
}
}

这会编译错误:

1
2
3
4
5
6
7
8
public class Test
{
private A classA = new A();
private void Update()
{
Debug.Log(classA.B.c);
}
}

这样编译成功:

1
2
3
4
5
6
7
8
public class Test
{
private A.B classB = new A.B();
private void Update()
{
Debug.Log(classB.c);
}
}

方法二:静态

static将类定义成静态的就可以不实例化直接调用。

1
2
3
4
public static class A
{
public static int b = 10;
}

自定义的缓动函数

很多人使用Vector3.Lerp函数来模拟缓动,也有人使用Vector3.SmoothDamp,但他们都有两个问题:

  • 无法使对象的坐标移动到完全与目标点坐标相等,例如你想从0移到3,最后只会无限逼近3,也就是2.999…。

  • 种类不够丰富。

我们可以使用非Unity自带的自定义缓动函数,主体部分来源于一个github库,经过了我的加工。注意,该代码额外地使用了using System;

自定义缓动函数的格式:

1
float Easing.Work(float ori, float des, ref float nowtime, float tottime, float deltime, Func<float, float, float> mode)
  • ori:最初值。

  • des:最终值。

  • nowtime:当前距本次运动开始的时间,开始运动前要将其赋值为0,但运动过程中不要更改它的值。

  • tottime:本次运动总时间。

  • deltime:本次更新距上一次更新时间。建议将该函数放在Update函数中使用,且deltime传入Time.deltaTime

  • mode:运动模式。在Easing.Mode的子类中有各种可选模式,可以对照缓动函数速查表

  • 返回值:经过这次更新的值。

示例:

1
2
3
4
5
6
private void Update()
{
transform.position = new Vector3(transform.position.x,
Easing.Work(0f, 3f, ref nowtime, 2f, Time.deltaTime, Easing.Mode.Bounce.EaseInOut),
transform.position.z);
}

动画

动画文件分为「动画(Animation)」和「动画器控制器(AnimatorController)」两种,后者用于控制前者,两者搭配使用。动画组件也有「Animation」和「Animator」两种,但「Animation」组件已经过时,我们只使用后者。

建议在「项目视图-Assets」下创建一个文件夹「Animations」,之后所有的动画文件都会存储在此。

两种文件在Unity中打开的效果如图:

Animator组件

需将「AnimatorController」文件填入「控制器」中。

Animation文件

检查窗口基本设置

  • 「循环时间」:若勾选,则该动画为循环动画;若不勾选,超出动画时间后动画显示的状态是最后一帧。

  • 「循环动作」:若勾选,则对一次动画播放完和下一次动画播放前的过渡进行处理。

动画窗口基本设置和添加关键帧

若没有设置一秒帧数的位置,在显示设置中勾选「ShowSampleRate」即可出现。

注意,只有在场景视图或层级视图中选中含「Animator」组件,且该组件填入了含该「Animation」文件的「AnimatorController」文件,才能使用「选择同一AnimatorController下的Animation」和拖拽加入新的关键帧。

添加事件和对事件的设置

点击「添加事件」可以在当前帧添加事件。注意不要在同一帧处添加多个事件,否则在窗口中这几个事件会重叠。

事件的图标:

点击该图标,检查窗口中显示如下。选择想要触发的函数即可。

关键帧记录模式

点击「启用/禁用关键帧记录模式」可以开启关键帧记录模式,开启该模式后,动画窗口的时间轴呈红色,关闭时呈蓝色。

开启该模式后,在「场景视图」、「检查视图」等中进行该对象的任何信息的更改将被记录在当前帧作为关键帧,保存在动画中。两个关键帧之间的这些帧将作为两个关键帧的过渡。例如我在0:00处设置缩放为1,将时间轴拖动到1:00,设置缩放为2,那么这1秒的动画就是该对象逐渐变大:

AnimatorController文件

动画器窗口

自带三个状态:

  • 「Entry」状态:从这里进入。

  • 「Exit」状态:如果进入根状态机的「Exit」状态,则回到「Entry」状态;如果进入子状态机的「Exit」状态,则退出该子状态机,返回父层级。

  • 「AnyState」状态:只能指出,不能指入。表示任意状态的特殊状态。如果希望任意状态满足某些转移条件都能转移,可以使用该状态。

右键状态节点,可以添加转移、设置默认状态(「Entry」指向的状态)。

在左侧「参数」栏中可编辑参数。

状态在检查窗口中的显示

点击状态节点,检查窗口中显示相关信息:

没有什么常用的东西。

转移在检查窗口中的显示

点击转移边,检查窗口中显示相关信息:

  • 「Transitions」和「Conditions」

    这两栏分别位于最上方和最下方。其中「Transitions」是「Conditions」的父层级,也就是一个「Transitions」可以包含多个「Conditions」。在能否转移的判断中,「Transitions」之间是或的关系,「Conditions」之间是与的关系。

  • 「有退出时间」、「退出时间」

    若勾选,则只有在「退出时间」设置的时间点处可以进行转移。动画的起始时间为0,结束时间点1,「退出时间」的值也是相对于0和1的,例如设为0.3则为动画播放了30%的那个时间点;若不勾选,则随时可转移。

    建议将之前「Animation文件-检查窗口基本设置-循环时间」勾选,「退出时间」设为0~1之间的数。

  • 「固定持续时间」、「过渡持续时间」、「过渡偏移」

    用来设置动画转移时的过渡效果,不常用,一般将其值都设为0。

    注意,若开启该功能,在有持续时间的转移过程中,若指向的状态此时已达到作为指出状态再转移的条件,也不能再转移,必须等到持续时间结束后再满足条件,才能转移。

编写脚本

修改参数

像「Rigidbody2D」那样,在脚本中我们也需要先声明一个Animator类的变量,并获取它,比如我们声明的变量名叫anim

使用以下代码可以更改该Animator类的变量所对应的「Animator」组件所含的「AnimatorController文件」中相应名称的参数的值。

1
2
3
4
anim.SetBool("NAME1", true);
anim.SetInteger("NAME2", 25);
anim.SetFloat("NAME3", 2.5f);
anim.SetTrigger("NAME4");

值得一提的是Trigger这种参数。他有点像bool,但不同的是它只能在SetTrigger的时候由false变为true,就是说如果连着多次使用SetTrigger,其值为true,不会变回false;并且只能在转移时由true变为false,就是说如果有一个转移的条件是一个Trigger参数,此时该Trigger为true,进行转移,同时值变为false

使用动画事件

在「Animation文件-添加事件和对事件的设置」章节中,我们已经知道了触发事件如何调用函数。需要注意的是函数的格式,无需使用public,使用private即可,且不需要传参:

1
private void NAME(){/**/}

自适应相机

接下来我们将写一个名叫CameraControl的脚本,该脚本将被安在摄像机上。

相机跟随

我们想让相机始终跟随玩家,即让玩家操控的对象始终在屏幕中的某一位置。

首先获取玩家操控的对象上的「Transform」组件,先声明一个Transform变量:

1
public Transform player;

然后在Unity中将该对象拖拽到该位置即可。

每帧根据该对象的坐标更改摄像机坐标,但要注意为了避免摄像机抖动,每一帧需要等该对象的坐标更新完成后在更新摄像机的坐标,于是需要将更新摄像机坐标的语句放在LateUpdate中:

1
2
3
4
private void LateUpdate()
{
transform.position = new Vector3(transform.position.x, player.position.y + OffsetRatio * CameraHeight, transform.position.z);
}

这个OffsetRatio * CameraHeight是偏移量,因为我们不想让该对象处于屏幕正中,具体在后面会提到。

相机自适应屏幕(窗口)大小

例如在一个跑酷游戏中,我们制作了一个长条状的地图。有两种长宽比不同的屏幕,我们想要这两种屏幕中显示的宽度相等:

我们需要了解几个Unity自带变量(常量):

  • Screen.widthScreen.height

    当前屏幕(窗口)的宽和高,单位是像素,只读。

  • Camera.main.orthographicSize

    当前摄像机捕捉范围的沿y轴长度的一半,单位是Unity编辑器中的单位长度,可读可写。

我们还需自己声明几个变量,这些变量的访问修饰符应被设为public以便在Unity编辑器检查窗口中调值和在其他地方调用:

  • CameraWidthCameraHeight

    当前摄像机捕捉范围的沿x轴和y轴长度。注意,因为屏幕长宽比是确定的,所以摄像机的长宽比也是确定的,所以这两个变量知道一个就能确定另一个。所以应使用[System.NonSerialized]将其中一个设为在检查窗口中不可见。

  • OffsetRatio

    玩家控制对象距摄像机中心的沿y轴长度与摄像机捕捉范围的沿y轴长度的比例。

最终,我们根据Screen.widthScreen.height以及提前设置的CameraWidthOffsetRatio来确定Camera.main.orthographicSize的值:

1
2
CameraHeight = ((float)Screen.height * CameraWidth) / (float)Screen.width;
Camera.main.orthographicSize = CameraHeight / 2;

传递信息

传递信息函数之间的区别

传递信息函数内置于Unity,可以直接使用。

  • SendMessage

    调用本对象中所有相应名称的函数。

  • SendMessageUpwards

    调用本对象和本对象的所有父级对象中所有相应名称的函数。

  • BroadcastMessage

    调用本对象和本对象的所有子孙级对象中所有相应名称的函数。

传递信息函数的相同用法

被调用函数的访问修饰符可以为private

SendMessage为例,其他两个函数的声明与之完全相同。

函数的声明:

1
2
3
public void SendMessage(string methodName,
object parameter = null,
SendMessageOptions options = SendMessageOptions.RequireReceiver);

用法例如:

1
2
3
4
5
private void Start()
{
SendMessage("NAME");
}
private void NAME(){/**/}
  • methodName:要调用的函数名。这里有一个技巧,当需要用到任意变量、类型或成员的名称作为字符串常量时,可以使用C#自带的nameof,例如:

    1
    2
    3
    4
    5
    private void Start()
    {
    SendMessage(nameof(NAME));
    }
    private void NAME(){/**/}
  • parameter:传参。

    null就是传0个参,给parameter赋不为null的值就是传1个参,但是不能传多个。如果传了0个参,则只能被含有0个参数的相应名称的函数接收;如果传了1个参,则可以被含有0或1个参数的相应名称的函数接收;含有多个参数的相应名称的函数永远不能被调用。

    如果想传多个,可以使用数组:

    1
    SendMessage(nameof(NAME), new object[]{3, true});
  • options:是否必须被接收。

    有两种赋值SendMessageOptions.RequireReceiverSendMessageOptions.DontRequireReceiver,前者表示如果没有任何函数接收,则报错;后者不报错。

定时调用函数

  • Invoke

    1
    public void Invoke(string methodName, float time);

    time时间后调用同MonoBehaviour下的名为methodName的函数。

  • InvokeRepeating

    1
    public void InvokeRepeating(string methodName, float time, float repeatRate);

    time时间后第一次调用同MonoBehaviour下的名为methodName的函数,之后每隔repeatRate时间再调用一次。

  • CancelInvoke

    1
    2
    public void CancelInvoke(string methodName);
    public void CancelInvoke();

    第一种停止调用同MonoBehaviour下的名为methodName的函数;第二种停止调用同MonoBehaviour下的所有挂起的函数。

  • IsInvoking

    1
    2
    public bool IsInvoking(string methodName);
    public bool IsInvoking();

    第一种检测同MonoBehaviour下的名为methodName的函数是否被挂起;第二种检测同MonoBehaviour下是否有任意被挂起的函数。

启用与禁用

对象的启用和禁用

对于一个对象而言,当且仅当他和他的所有父级的checkbox都是被勾选的状态时他是启用状态,反之是禁用状态。

这三个GameObject类的方法和属性用于设置或读取和启用禁用有关的信息:

  • SetActive:方法,无返回值,有一个bool类型的传参,表示将脚本所在对象的checkbox勾选或取消勾选。

  • activeSelf:属性,bool类型,表示脚本所在对象的checkbox有无勾选。

  • activeInHierarchy:属性,bool类型,表示脚本所在对象是否处于启用状态。

脚本所在对象的GameObject类可以直接在代码中使用,名叫gameObject,如将脚本所在对象checkbox取消勾选:

1
gameObject.SetActive(false);

注意,禁用脚本组件或脚本组件所在对象,会停止调用Update等函数,但不会影响OnTriggerStay2D等函数和已挂起的Invoke

组件的启用和禁用

对于一个组件而言,当且仅当其所在对象是启用状态,且组件的checkbox勾选时他才是启用状态,反之是禁用状态。

所有有checkbox的组件都可以使用以下属性来控制checkbox(有些组件无checkbox,如Transform):

  • enable:属性,bool类型,表示checkbox是否勾选。

组件和对象的生成与销毁

对象的生成

使用Instantiate函数生成对象,该函数共有5种重载:

1
2
3
4
5
public static Object Instantiate(Object original);
public static Object Instantiate(Object original, Transform parent);
public static Object Instantiate(Object original, Transform parent, bool instantiateInWorldSpace);
public static Object Instantiate(Object original, Vector3 position, Quaternion rotation);
public static Object Instantiate(Object original, Vector3 position, Quaternion rotation, Transform parent);

其中1、2种可实现的功能被包含在第3种中;第4种可实现的功能被包含在第5种中,所以只要弄懂第3、5种重载就能理解Instantiate函数了。

共有传参:

  • original:用于生成对象的模板,生成的对象相当于它的克隆。可以是预制体,也可以是游戏中的对象。

  • parent:对象生成后在层级视图中的父级,注意传参的是父级对象的「Transform」组件,不是父级本身(在Unity中,「Transform」组件本来就有储存父级子级的作用)。若函数中没有该传参,则相当于null,也就是作为根层级。

前3种重载的特有传参:

  • instantiateInWorldSpace:若为true,则生成的对象的位置、旋转、缩放三种值的全局值与模板对象相等;反之,生成的对象的三种值的局部值(相对于父级)与模板对象相等。若函数中没有该传参,则相当于true

后2种重载的特有传参:

  • positionrotation:如果含这两种传参,则生成的对象的相应值为传参的值。注意,不是和模板对象的值叠加,是覆盖。

组件的添加

使用GameObject.AddComponent函数在指定对象上添加指定组件,例如下面的代码表示在本脚本所在的对象的父级上添加「Rigidbody2D」组件:

1
2
3
GameObject gob = transform.parent.gameObject;
Rigidbody2D rb;
rb = gob.AddComponent<Rigidbody2D>();

注意,该函数不会覆盖对象上原有的组件。也就是说,如果该组件不能在同一对象上有多个,且该对象上已经有了一个该组件,则添加失败,返回null

对象和组件的销毁

使用Destory,既可以销毁对象,也可以销毁组件。

1
public static void Destroy(Object obj, float time = 0f);
  • obj:要销毁的对象或组件,如果是对象,则会销毁该对象以及所有子孙级对象。

  • time:在time时间后执行操作。

使用DontDestroyOnLoad,可以在切换场景(之后会讲解)期间保留指定对象。

1
public static void DontDestroyOnLoad(Object obj);
  • obj:可以是对象也可以是组件。会保留组件所在对象以及所有子孙级对象。

需要注意的是,使用 DontDestroyOnLoad 虽然避免了对象在切换场景后消失的问题,但是又出现了多次打开该场景导致该场景中的对象被保存了多次的问题。解决这个问题的方法是将 DontDestroyOnLoad 结合单例模式使用。

单例模式就是指维持某个对象在任何时候都只存在一个的一种编程技巧,其实就是开一个静态的该对象的数据类型,然后每次创建一个该对象就判断这个静态对象是否为 null,是就保留该对象,否则删除该对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Permanent : MonoBehaviour
{
public static Permanent instance;

private void Awake()
{
if (instance == null)
instance = this;
else
Destroy(this.gameObject);
DontDestroyOnLoad(this);
}
}

查找对象

GameObject.Find

使用示例:

1
GameObject go = GameObject.Find("NAME");

"NAME"不一定仅仅是对象的名称,可以是一个路径。
且查找方法有按绝对路径查找和按相对路径查找。绝对路径查找的格式/XXX/XXX,相对路径查找的格式XXX/XXX

这里的相对路径不是相对于该脚本所在对象的相对路径,而是相对于任意对象的相对路径,例如GameObject.Find("a/b")是寻找对象名称是b,父级名称是a的对象,而祖父级没有要求,可以是任意对象或null

若有多个满足要求的查找结果,则函数会返回哪个难以控制,所以要避免出现多个满足要求的查找结果。

若查找的对象为隐藏对象,即本身或任何父级被禁用,则是否能查到难以控制,所以要避免用GameObject.Find查找隐藏对象,例如:

1
2
Debug.Log(GameObject.Find("/a/b/c") != null); // true
Debug.Log(GameObject.Find("/a/d") != null); // false

c和d都被设为禁用,唯一不同在于c多了一个父级,结果c能被找到,d无法被找到。

Transform.Find

使用示例:

1
Transform tr = transform.Find("NAME");

从使用示例中可看出Transform.FindGameObject.Find的一些不同,前者是调用一个具体的Transform类的函数,后者是调用静态的GameObject的函数。这也说明前者的调用结果与被调用的那个Transform类有关,具体见下文。

按绝对路径查找和GameObject.Find的相同。

按相对路径查找和GameObject.Find的不同。是按照相对于该「Transform」组件所在对象的相对路径查找。且可以查找隐藏对象。

父子关系的查询与管理

Unity中的父子关系,也就是层级关系,被存储于Transform类中,所以父子关系的查询与管理的相关属性和方法都在Transform类中,使用例如:

1
transform.parent = null;

这些属性和方法分两种,与父级相关和与儿级相关。

与父级相关

  • parent

    属性,父级的Transform类,可读可写。

    注意,试图更改该属性,或调用SetParent函数以使新的父级被设为当前的子孙级的做法无效,不会产生任何变化,且不会报错。

  • SetParent

    方法,有两种重载:

    1
    2
    public void SetParent(Transform parent);
    public void SetParent(Transform parent, bool worldPositionStays);
    • parent:将其设为新的父级。

    • worldPositionStays:若为true,则变换后的位置、旋转、缩放三种值的全局值与变换前相等;反之,变换后的三种值的局部值(相对于父级)与变换前相等。第一种重载相当于该参数为true

  • SetAsFirstSiblingSetAsLastSiblingSetSiblingIndex

    三个方法,前两个没有传参,第三个有一个int传参。

    这三个函数都是改变该对象作为其父级的儿子的下标,分别表示下标改为第一个(0),改为最后一个,改为指定值。注意,若SetSiblingIndex传入的参数,也就是指定的下标值,不在[0,childcount)[0,\mathrm{childcount})中(包括负数),则不会报错,而是相当于调用SetAsLastSibling

与儿级相关

  • childCount

    属性,子集个数,只读。

  • DetachChildren

    方法,没有传参,将所有子级(不包括孙级)的parent设为null

  • GetChild

    方法,有一个int传参,表示想要的子级的下标,返回值为该子级的Transform类。

    注意,如果传入的参数,也就是指定的下标值,不在[0,childcount)[0,\mathrm{childcount})中,会报错。

四元数与旋转

四元数的简介

四元数是一种广泛用于计算机等领域的数学工具,本质是超复数,可以用于表示三维空间中的旋转等。四元数和欧拉角相比优点有不会有万象锁锁死的问题;和矩阵乘法相比优点有较为简洁,只需存储四个数字。Unity中的旋转就是通过四元数来存储和实现的。

四元数的用法

四元数类名为Quaternion

四元数用于存储旋转的操作。

四元数类里的属性x/y/z不是欧拉角的绕x/y/z轴的旋转角度,注意到四元数类里还有一个属性w,这四个变量就是四元数。

Transform.rotation就是四元数,他表示旋转的状态。

四元数之间常用的操作有赋值和乘法。四元数之间可以赋值(废话),但要注意的是,如果你使用某种方法构建了四元数表示某种旋转操作(后面会讲),不要直接把它赋值给rotation,因为旋转操作只是将一种状态转化为另一种状态的桥梁,不是最终的结果。

四元数之间可以相乘,Unity重载了四元数类的*运算符:

1
public static Quaternion operator*(Quaternion lhs, Quaternion rhs);

注意,四元数的乘法是不遵守交换律的,这是由于旋转本身就不遵守交换律,由于我们现在只探讨2D游戏,只会用到绕z轴旋转,不会体现这一点,就先不深入研究。一般使用新状态=旧状态*操作,也就是状态*=操作,例如:

1
2
Quaternion q = Quaternion.identity;
transform.rotation *= q;

四元数还可以和向量相乘,*运算符还有另一种重载,用于旋转向量:

1
public static Vector3 operator*(Quaternion rota, Vector3 point);

作用就是将point向量进行rota旋转操作,返回旋转后的向量,注意顺序不能反,否则会有编译错误。

构建四元数

首先是四元数类的一个静态只读属性:

  • identity

    表示无旋转。

    与表示状态的四元数相乘时,相当于没乘:

    1
    transform.rotation *= Quaternion.identity;

    直接赋值给表示状态的四元数时,相当于设置成欧拉角三个维度的值都为0:

    1
    2
    transform.rotation = Quaternion.identity;
    Debug.Log(transform.eulerAngles == Vector3.zero); // True

然后的这几种方法构建出来的四元数表示旋转操作,即应该使用乘法将其与表示状态的四元数相乘。

  • AngleAxis

    1
    public static Quaternion AngleAxis(float angle, Vector3 axis);

    返回的四元数表示绕指定轴旋转指定角度。指定轴:过对象轴心且方向为axis的轴;指定角度:angle度,其中绕转方向的判定方法为用左手握住轴,伸出大拇指并指向轴向量所指的方向,另外四个手指的绕转方向即为指定方向(类似右手螺旋定则只不过是左手)。

  • Euler

    1
    2
    public static Quaternion Euler(float x, float y, float z);
    public static Quaternion Euler(Vector3 euler);

    返回的四元数表示先围绕z轴旋转z度,再围绕x轴旋转x度,最后围绕y轴旋转y度(这里的xyz轴都是局部轴)的操作,绕转方向的判定方法同AngleAxis

  • FromToRotation

    1
    public static Quaternion FromToRotation(Vector3 fromDirection, Vector3 toDirection);

    返回的四元数表示从向量fromDirection旋转到向量toDirection的操作,即旋转的方向和度数与之相同。

  • Inverse

    1
    public static Quaternion Inverse(Quaternion rota);

    返回的四元数表示rota的逆操作,即向相反方向旋转相同度数。

接下来这一大类方法既可以构造表示旋转操作的四元数,也可以构造表示状态的四元数。

  • Lerp/LerpUnclamped/Slerp/SlerpUnclamped

    这四个方法都返回一个四元数,表示插值的结果,四个方法传参完全一样,以Lerp为例:

    1
    public static Quaternion Lerp(Quaternion from, Quaternion to, float t);

    from的比率是0,to的比率是1,插值结果的比率是t

    插值函数一般放在UpdateFixedUpdate中,用于逐帧变换:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public Transform tr;
    public float speed;
    private float time;
    private Quaternion q1, q2;
    private void Start()
    {
    time = 0;
    q1 = transform.rotation;
    q2 = tr.rotation;
    }
    private void Update()
    {
    time += Time.deltaTime;
    transform.rotation = Quaternion.Slerp(q1, q2, speed * time);
    }

    上方代码是传入了两个表示旋转状态的四元数参数,并且将返回值赋值给rotation;也可以传入两个表示旋转操作的四元数参数,并且将返回值和状态值相乘,如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public float speed;
    private float time;
    private Quaternion initial;
    private Quaternion q1, q2;
    private void Start()
    {
    time = 0;
    initial = transform.rotation;
    q1 = Quaternion.identity;
    q2 = Quaternion.AngleAxis(80, Vector3.forward);
    }
    private void Update()
    {
    time += Time.deltaTime;
    transform.rotation = initial * Quaternion.Slerp(q1, q2, speed * time);
    }

    XXXXXXUnclamped的区别:前者的t被限制在 [0,1][0,1] 间,当然你可以传入不在这个区间里的数,但是小于0的数效果等同于0,大于1的数效果等同于1.

    LerpSlerp的区别:Lerp是线性插值,多用于位移变换;Slerp是球形插值,多用于旋转变换。如果在旋转变换中使用Lerp,变换的速度会随着时间变化而变化,比如有可能越来越慢,最后趋近于速度为0;而在旋转变换中使用Slerp是均匀的。

    可以将插值函数和自定义缓动函数搭配使用。

最后这几种方法构建出来的四元数表示旋转的状态。

  • RotateTowards

    1
    public static Quaternion RotateTowards(Quaternion from, Quaternion to, float maxDegreesDelta);

    返回的四元数表示从from的旋转状态向to的旋转状态旋转maxDegreesDelta度数,但是不会超过to。有异议的是传入负数的情况,文档中说反向旋转不会超过与to相反的方向,但是经我测试可以超出。

    与插值函数有类似的地方,但是插值函数确定插值位置的是比率,而该函数是具体的度数。

  • LookRotation

    1
    public static Quaternion LookRotation(Vector3 forw, Vector3 upw = Vector3.up);

    返回的四元数表示的是旋转的状态而非操作,所以应使用赋值而非乘法。

    z轴将与forw对齐,X 轴与forwupw的差积对齐,y轴与z轴和x轴的差积对齐。如果forwupw量值为零或forwupw共线,则返回Quaternion.identity

    例如下方代码让该对象绕z轴旋转使其x轴始终指向tr

    1
    2
    3
    4
    5
    public Transform tr;
    private void Update()
    {
    transform.rotation = Quaternion.LookRotation(Vector3.forward, Quaternion.AngleAxis(90, Vector3.forward) * (tr.position - transform.position));
    }

Physics2D

碰撞体检测的返回信息

在Physics2D中,有关碰撞体检测到的对象的返回信息会存储在RaycastHit2D类中,下面是RaycastHit2D类中的常用属性:

  • transform/collider/rigidbody:检测到的对象的变换、碰撞体、刚体(如果没有就是null)。

  • pointVector2类型,检测到该物体的检测点,下一小节中也会提到这个概念。

  • distancefloat类型,从发出检测射线的原点到检测点的距离。

碰撞体检测的方式

碰撞体检测就是确定出一个检测区域,检测有无碰撞体与该区域相交,有多少。其中怎样确定这个区域取决于使用的「检测模型」和「检测模式」,而返回值是一个还是多个和占用内存的情况取决于「返回方法」。每一个函数都是一种「检测模型」、「检测模式」和「返回方法」的组合,所以能明白这3件事所有碰撞体检测的函数都不在话下了。

检测模型

「检测模型」比较好理解,就是各种几何图形,例如Box(矩形)、Circle(圆形)、Capsule(胶囊形)、Line(线段)、Ray(射线)、Point(点)等。函数会有一些传参来确定这些图形的坐标和大小。如BoxVector2类型参数originsizeangle,分别表示中心点坐标,大小,旋转角度。

检测模式

首先,两种检测模式都有3个共有的参数,layerMaskminDepth /maxDepth,不常用。

  • OverlapXXX:相交检测,顾名思义就是检测有无碰撞体和「检测模型」相交,也就是检测区域就是这个模型的区域。没有特有的参数。

  • XXXCast:射线检测,由「检测模型」的区域上的所有点向某个特定方向发射有特定长度的射线(或者叫线段),所有射线所形成的区域就是检测区域。也可以理解成将「检测模型」向某个特定的方向拖动特定距离,所有扫过的面积就是检测区域。有两个特有参数:

    • directionVector2类型,表示特定方向,其模长没有意义。

    • distancefloat类型,默认值为Mathf.Infinity,表示特定长度。

    BoxCast为例,下方代码的检测区域如图:

    1
    Physics2D.BoxCast(new Vector2(0, 0), new Vector2(2, 3), 30, new Vector2(1, 2), 5);

    注意,检测区域并非只有边界,整个区域都是检测区域。

返回方法

在函数名中,返回方法体现为后缀:

  • 无后缀:没有特有的参数。相交检测模式的返回值为Collider2D,射线检测模式的返回值为RaycastHit2D。如果能检测到多个,射线检测模式会返回最近的(RaycastHit2D.distance最小),相交检测模式会返回哪个不确定。如果没有检测到,返回null

  • XXXAll:没有特有的参数。相交检测模式的返回值为Collider2D[],射线检测模式的返回值为RaycastHit2D[]。如果能检测到多个,射线检测模式返回的数组中元素的顺序是按RaycastHit2D.distance递增。

  • XXXNonAlloc:有特定的参数results,相交检测模式的results的类型为Collider2D[],射线检测模式类型为RaycastHit2D[]。返回值为int类型,表示检测到的碰撞体个数。传入results时无需给其中元素赋值,但是要给数组设置合适的大小,因为检测过程中如果出现数组大小不够的情况也不会扩大数组,超出部分舍弃。比较节省内存,因此需要反复执行的话建议使用。

可视化碰撞体检测

在调试过程中,我们看不到检测区域,调试起来比较困难,使用Unity自带的Debug工具,我们可以自己写一套可视化碰撞体检测。以「检测模型」为Box的六种函数为例:可视化碰撞体检测

使用方法和Physics2D中的碰撞检测函数完全一致,只不过Physics2D换成了m_Physics2D

使用C#事件解耦

耦合和解耦

耦合是一个软件结构内不同模块之间互连程度的度量。耦合度越高,代码间独立性越差,高耦合是我们不想看到的。解耦是解开耦合,降低耦合度的行为。

C#事件的用法

前置-创建C#函数对象

C#函数对象中常用的有FuncAction两种:

  • Func:有返回值的函数,定义方法:

    1
    Func<float, bool, int> NAME;

    其中<>中的最后一个类型为返回值的类型,前面的按顺序为函数传参的类型。例如该行代码表示定义了一个传参类型依次为floatbool,返回值为int的函数。

  • Action:无返回值的函数,定义方法:

    1
    Action<float, bool> NAME;

    <>中的类型按顺序为函数传参的类型。如果没有任何传参,则无需写<>,例如:

    1
    Action NAME;

C#函数对象常用于函数传参、C#事件等:

  • 函数传参:函数本身也可以作为参数:

    1
    void work(Func<float, float, float> func)
  • C#事件:见下文。

定义C#事件

C#事件本质是一个函数对象,使用方法是在定义时加上event关键字,且关键字只能用于函数(FuncAction等),不能用于变量。定义例如:

1
public event Func<int, int> eve;

一般我们会单独创建一个类,在其中定义C#事件,因为这是C#自带,所以不用继承MonoBehaviour,也不需要使用UnityEngine等命名空间,但是因为需要使用FuncAction等,需要使用命名空间System

注册和注销

C#事件的作用是一并调用一些函数,那么调用那些函数呢?答案是所有注册表上的函数,也就是已注册未注销的函数,注册和注销的方法就是使用C#事件的+=-=,左侧是C#事件的对象名,右侧是要注册/注销的函数名,注意注册/注销的函数的参数和返回值类型要与C#事件一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static class a
{
public static event Action<int> eve;
}

public class b : MonoBehaviour
{
private void OnEnable()
{
a.eve += func;
}
private void OnDisable()
{
a.eve -= func;
}
private void func(int para)
{
Debug.Log(nameof(func) + " " + para);
}
}

实际应用时,通常在Unity自带的OnEnable函数中将想调用的函数注册,在OnDisable函数中将函数注销。这两个函数分别在脚本刚开始启用/禁用时被调用。

创建调用函数

C#事件有一个特性就是除了在定义该函数对象的类中,其他位置除了+=-=这两个方法其他方法禁用,但是我们还需要在定义该函数对象的类外调用这个C#事件,那我们就需要先在在定义该函数对象的类中创建一个调用函数。如下:

1
2
3
4
5
6
7
8
public static class a
{
public static event Action<int> eve;
public static void Calleve(int para)
{
eve?.Invoke(para);
}
}

?.是一种语法糖,表示如果不为null才执行,上方代码的第6行等同于:

1
if (eve != null) eve.Invoke(para);

Invoke是C#事件的一个方法,用于调用所有在该事件上注册的函数,调用顺序是按照注册顺序,如果函数还带有返回值(Func类型),Invoke会返回最后一个注册的函数的返回值。

整体逻辑

C#事件起到一个控制枢纽的作用,他记录下需要被调用的函数,时机到时,只要调用C#事件,就能调用诸多函数。并且调用C#事件的语句处(上图中c类)不需要知道之后C#事件又调用了谁,这是由被调用函数在自己的类中(上图中b类)的注册和注销决定的,这有效地做到了解耦。

时间

读取和设置循环执行的事件函数的时间

UpdateFixedUpdate是最常用的两种循环执行的事件函数,使用Unity自带的Time类里的属性可以读取和设置他们的相关时间信息,常用的如下,这些属性都是float类型而且是静态,所以无需实例化,直接使用即可:

每个属性有3个关键字,分别是:

  • 有无fixed

    • fixed:在Update中指本次Update开始执行的时刻的时间(这里的时间指从游戏开始计时,以秒为单位的总时间);在FixedUpdate中指本次FixedUpdate开始执行的时刻的时间。

    • fixed:在Update中指上一次FixedUpdate开始执行的时刻的时间;在FixedUpdate中指本次FixedUpdate开始执行的时刻的时间。

  • 有无delta

    如有delta,其值等于该函数中无delta的同类属性减去上一次调用同种函数时无delta的同类属性。例如先后执行了UpdateFixedUpdateUpdate,按顺序称为函数1、2、3,则在函数3中的deltatime等于函数3中的time减去函数1中的time

  • 有无unscaled

    如有unscaled,则表示不受时间缩放(之后会讲)影响;反之表示受其影响。

以上的这些所有的属性中,只有fixedDeltaTime可读可写,其他都是只读。他也是最常用的属性之一,用于读取和更改FixedUpdate的执行间隔。

时间缩放

改变Time类里的属性timeScale可控制时间缩放,用于慢动作和暂停。其值越小时间流动越慢。值为0时,相当于游戏暂停,不会调用FixedUpdate函数。

如果变更了timeScale,建议也将fixedDeltaTime变更相同的倍数。例如timeScale0.5,建议将fixedDeltaTime也乘0.5

加载场景

和场景有关的类、方法和属性在命名空间SceneManagement中,需要使用:

1
using UnityEngine.SceneManagement;

SceneManager

使用SceneManagement命名空间中的SceneManager类可以对场景进行控制。

Unity支持多场景,也就是同时存在多个场景,但是这样做相对复杂,很多情况下使用单场景即可。而如果使用单场景,大部分SceneManager类里的方法就没有必要使用。所以需要掌握的函数方法不多:

  • LoadSceneAsync

    1
    2
    public static AsyncOperation LoadSceneAsync(string sceneName, SceneManagement.LoadSceneMode mode = LoadSceneMode.Single);
    public static AsyncOperation LoadSceneAsync(int sceneBuildIndex, SceneManagement.LoadSceneMode mode = LoadSceneMode.Single);
    • sceneName:需要加载的场景的名称(就是文件名,不需要加后缀)。

    • sceneBuildIndex:需要加载的场景在「BuildSettings(生成设置)」中的下标。

    • mode:生成方式(详见下一小节)。

    需要注意的是,就算使用名称生成场景,也需要在「菜单栏-文件-生成设置-Build中场景」中添加该场景才能正常生成。

  • GetActiveScene

    1
    public static SceneManagement.Scene GetActiveScene();

    返回当前活动的场景。可以通过对其加载来重新开始游戏。

LoadSceneMode

使用SceneManagement命名空间中的LoadSceneMode枚举量可以选择加载场景的方式。该枚举量有两种取值:

  • Single:关闭所有已存在的场景,加载新的场景。

  • Additive:不关闭任何已存在的场景,加载新的场景。

AsyncOperation

AsyncOperation类并不是SceneManagement命名空间中的。

LoadSceneAsync的返回值就是一个AsyncOperation类,用它我们可以读取和设置加载场景的进度和加载结束后是否立即激活,以下是它的常用属性:

  • progressfloat类型,只读,取值 [0,1][0,1]。表示加载进度。

  • isDonebool类型,只读。表示加载是否已经完成。

  • allowSceneActivationbool类型,可读可写。表示加载完成后是否激活场景。其实如果该属性设置为false,场景加载的进度也不会停在100%处,而是停在90%处,isDone保持为false。设置为true后场景才会激活。

该类还有一个事件:

  • completed:返回值为void,有一个AsyncOperation类型的参数的事件(关于事件的具体用法请见前文「使用C#事件解耦」)。在场景加载完成后调用所有注册在该事件上的函数,如果处理程序是在操作完成后注册的,并且已调用 complete 事件,则将同步调用该处理程序。

UI

首先我们将介绍添加UI的基本方法和注意事项,然后将围绕与UI有关的组件来介绍。

添加UI

在「层级窗口」中右键选择「UI」即可添加UI:

注意到如果你的场景中没有UI,不管你添加任何UI,都会自动添加名叫「EventSystem」的对象,且你添加的UI会作为名叫「Canvas」的对象的子级:

基本类组件

StandaloneInputModule

在检查视图中打开自动创建的叫「EventSystem」的对象,在「StandaloneInputModule」组件中会有一段报错,这是因为他还在使用旧版的输入系统,点击下方按钮即可切换成新输入系统:

Canvas

自动添加的名叫「Canvas」的对象会自带名叫「Canvas」的组件,任何UI对象都必须含「Canvas」组件或为含「Canvas」组建的对象的子孙级。

可以创建多个含「Canvas」组件的对象。

  • 渲染模式:有三种取值:

    • 屏幕空间-覆盖

      这种状态下UI并非场景中存在的物体,而是最后在画面渲染的之后添加在画面上的,你可以理解为这些UI被画在你的屏幕上。正因如此,他们会挡在场景中所有对象的前面。暂停菜单、按钮、得分显示等适合使用该种渲染模式。

      在此状态下,该组件所在对象的「RectTransform」组件被其驱动而无法在检查视图中编辑。

      在此状态下的UI对象在场景视图中显示为远远大于其他对象的物体,这是为了方便区分他们和场景中实际存在的物体。

    • 屏幕空间-摄像机

      这种状态下UI是场景中存在的物体,与其它的所有物体一起渲染,所以也会与其他物体产生遮挡关系。

      在此状态下,该组件所在对象始终面对摄像机。这表明在此状态下,该组件所在对象的「RectTransform」组件被其驱动而无法在检查视图中编辑。

    • 世界空间

      这种状态下UI是场景中存在的物体,与其它的所有物体一起渲染,所以也会与其他物体产生遮挡关系。

      在此状态下,该组件所在对象不必面对摄像机。这表明在此状态下,该组件所在对象的「RectTransform」组件不被其驱动而可以在检查视图中编辑。

  • 像素完美:是否应该无锯齿精确渲染UI。

RectTransform

「RectTransform」是创建UI对象时自带的能代替「Transform」的功能更强大的组件。

中心、轴心和锚点

我们在之前就介绍过中心和轴心,我们再来回顾一下,然后介绍功能更强大的锚点:

  • 中心:几何中心,对于一个矩形来说,就是对角线的交点。

  • 轴心:一个基准点,图标为蓝色圆圈:

  • 锚点:锚点共有四个,他们是一个矩形的四个顶点,这个矩形也可以缩成一条线段或一个点,图标为顶点处的四个小箭头:

矩形变换的功能

每个「RectTransform」组件都有一个矩形变换区域(后简称为“区域”),这个区域是一个矩形。被「Canvas」驱动的区域就是长宽比和屏幕一致的矩形。

「RectTransform」功能强大就在于他能根据不同的屏幕长宽比而调整区域的位置和大小。

锚点的确定

锚点不能超出父级的区域,四个锚点组成的矩形平行于父级的区域。

锚点的位置在父级的区域中的比例是确定的,又因为不能超出,所以两个属于 [0,1][0,1] 的数就可以确定一个锚点;又因为锚点矩形平行于父级区域,所以确定两个对角锚点即可确定四个锚点。

旋转缩放前区域的确定

因为区域还可以做相对于轴心的旋转和缩放(之后会讲),我们将做相对于轴心的旋转和缩放之前的区域叫前区域,之后的叫后区域。

前区域是平行于父级的区域和锚点矩形的矩形。他的四个顶点分别对应四个锚点,每个顶点与每个锚点的沿xy两个轴向的偏移距离是固定的,单位为Unity单位长度。

轴心的确定

轴心的位置在前区域中的比例是确定的,且可以超出前区域。

对区域进行旋转缩放

前区域只是为了方便理解矩形变换的中间量,后区域才是最终的结果。

后区域由前区域相对于轴心旋转和缩放而来。

如果UI对象的父子关系比较复杂,有多层关系,再进行旋转和缩放的话其效果可能会难以控制。所以除了用于特殊效果外,尽量少使用旋转和缩放。

检查视图中的设置

视觉类组件

Image

「Image」组件是用于显示图像的UI组件,除了单独使用,许多其他的UI组件也需要依赖于他。

未赋值源图像时,有一些属性不会显示。

  • 可遮盖:如果勾选,则可被其父级的「Mask」组件(详见之后的小节)遮盖;反之不会。

  • 图像类型:控制图像的特殊显示类型,使其呈现和原图不一样的效果:

    • 简单:与原图效果相同。

    • 已切片

      该种图像类型是将原图切成九宫格,当图片被伸缩时,四个角区域的大小不变;四个边区域只有一个维度伸缩,另一个维度不变;中心区域两个维度都伸缩:

      而九宫格的设置是在原图的「SpriteEditor」,和设置图片的轴心(详见前文「俯视角渲染模式的设置」一章)是在一个位置。在「项目视图」中点击该图片,然后在「检查视图-SpriteEditor」中拖动绿色框:

      • 填充中心:是否填充中心区域。

      • 每单位像素乘数:将四个角区域和四个边区域不缩放的维度进行缩放。这个数越大,这些区域越小。

    • 已平铺

      同样是是将原图切成九宫格,当图片被伸缩时同样是四个角区域的大小不变。不同的是四个边区域是有一个维度平铺,另一个维度不变;中心区域两个维度都平铺。

      平铺就是划定范围大于原图范围时,图片重复排列一直延伸到达到划定范围;划定范围小于原图范围时就将原图缩小到划定范围。

    • 已填充

      分为很多种填充模式,如水平、竖直、旋转:

      常用于制作进度条。

如果想获取更多系统自带的源图像,可通过以下方法:

Mask

「Mask」组件的作用是将创建一个遮罩,其所在对象的所有子孙对象的UI图像只能在遮罩的范围内显示,UI图像处于遮罩范围外的部分不可见:

必须有一个「Image」组件与「Mask」组件在同一个对象上才能使「Mask」起作用,这个「Image」为「Mask」提供遮罩图像。Alpha值等于0的像素有遮罩,不可见;Alpha值不等于0的像素没有遮罩,可见。

勾选「显示遮罩图形」后,遮罩图像也会被显示;反之会被隐藏。

互动类组件

Button

按钮对象的创建

再层级视图中右键选择相应对象即可创建,需要注意的是有两个按钮:「按钮TextMeshPro」和「旧版-按钮」。创建按钮时会自带一个子物体「Text」,这两种按钮唯一的区别就是这个字物体上的组件是「Text」还是「TextMeshPro」。也可以将该子物体删掉,除了无法在按钮上写字之外对正常使用没有影响。

创建出来的按钮对象会自带「Button」组件。

检查视图中的设置
  • Interactable:是否允许该按钮输入信息。如果取消勾选,该按钮仍可见,但是不接收信息。

  • 过渡:有四种取值:

    • 无:按钮的外观始终不会发生变化。

    • 颜色色彩:按钮的每个状态对应一种颜色。注意不是这种颜色的纯色,而是该颜色和「目标图形」的图片叠加的结果。

    • Sprite交换:按钮的每个状态对应一张Sprite图片。

    • 动画:使用动画控制按钮的每个状态以及状态间的切换。

    按钮的状态将在下一小结展开。

  • 持续淡化时间

    状态间过渡的时间,「过渡」为「颜色色彩」时特有。

  • 鼠标单击()

    每当鼠标点击按钮后松开时,执行列表中所有的方法。

    只有访问关键字为public,返回值为void,无传参或传参只有一个且为boolintfloatstringObject中的一个的方法才能加入此列表。

按钮状态

按钮共有5种状态,分别是:

  • 正常:未进行任何操作时。

  • 高亮:鼠标放在该按钮上,但是并未点击。

  • 按下:鼠标点击按钮但未松开。

  • 选择:鼠标点击按钮然后松开后,且重新点击其他位置之前。

  • 禁用:「Interactable」取消勾选时。

处于按下状态或选择状态时,按「Enter」键也可以触发按钮。

值得注意的是,如果「游戏视图」中使用的是「模拟器」,则无法显示高亮状态,应使用「游戏」。

使用代码添加和去除监听函数

所有以上和以下脚本的属性都可以通过代码来读取和修改,监听函数同样可以通过代码添加,只不过方法较更改属性更复杂,需要展开讲解,就以「Button」组件为例:

需要使用UI的命名空间才能使用UI类:

1
using UnityEngine.UI;

在脚本中添加以下代码,并挂在和「Button」组件的同一对象上:

1
2
3
4
5
6
7
8
9
public void func()
{
Debug.Log("func");
}
private void Start()
{
Button b = GetComponent<Button>();
b.onClick.AddListener(func);
}

使用RemoveListenerRemoveAllListeners可以去除监听函数。

以上代码相当于在「检查视图」中的「鼠标单击()」列表中添加或去除了func函数。但是注意在代码中添加的监听函数并不会在「检查视图」中的「鼠标单击()」显示,这和一般修改属性不同。还要注意无法使用RemoveListenerRemoveAllListeners去除手动在列表中添加的监听函数。

Slider

滑动条对象的创建

在层级视图中右键选择相应对象即可创建。创建按钮时会自带一系列子物体:

  • Background:大小不变的背景

  • Fill:大小随拖动而改变的进度条

  • Handle:用于拖动的手柄。

创建出来的按钮对象会自带「Slider」组件。

检查视图中的设置

大部分属性和用法和「Bottom」完全相同,这部分不会再提及。接下来介绍剩下的属性中常用的:

  • 方向:分为从左到右、从右到左、从下到上、从上到下。处于按下状态或选择状态时,如果是前两种方向,按「→」、「←」键也可以调节;如果是后两种方向,按「↑」、「↓」键也可以调节。

  • MinValue/MaxValue:能拖动到的最小/大值。

  • 整数:若勾选,则取值只能为整数。

  • 值改变时(Single)

    每当值改变时,执行列表中所有的方法。

    只有访问关键字为public,返回值为void,无传参或传参只有一个且为boolintfloatstringObject中的一个的方法才能加入此列表。

Scrollbar

「Scrollbar」是滚动条自带的组件,而滚动条一般不独立使用,一般配合滚动视图使用。

滚动条对象的创建

在层级视图中右键选择相应对象即可创建。创建滚动条时会自带一系列子物体:

其中对象Scrollbar和Handle上各有一个「Image」组件,在「游戏视图」中分别体现为滚动条的背景和手柄,而SlidingArea上无「Image」组件,所以没有图像的显示,作用仅为限制手柄滚动的范围。

检查视图中的设置

仅介绍特有且常用的属性:

  • 大小:取值为从0到1的浮点数,控制手柄的长度。

  • 步骤数量:取值为从0到11的整数。取值为0或1时,可以连续移动;反之,不能连续移动,只有步骤数量个位置可以移动到。

ScrollRect

「ScrollRect」是滚动视图自带的组件,滚动视图是一个沿x轴及y轴滚动的窗口,需要用到多种组件。

滚动视图对象的创建

在层级视图中右键选择相应对象即可创建。创建滚动视图时会自带一系列子物体:

其中对象ScrollView和Content上各有一个「Image」组件,在「游戏视图」中分别体现为整个矩形区域的背景和滚动条之间的内容区域。

对象Viewport上虽然有「Image」组件,但是该组件并不是用于显示图像,而是用于给「Mask」组件提供遮罩范围,所以Viewport没有图像的显示,作用仅为遮罩。

对象ScrollbarHorizontal和ScrollbarVertical分别为水平和竖直的滚动条,其子级和作用与直接创建的滚动条完全一致。

检查视图中的设置
  • 内容:需要滚动显示的内容。

  • 水平/竖直:启用水平/竖直滚动。

  • 运动类型

    • 不受限制的:滚动超出Content的范围后,没有任何回弹的作用。

    • 弹性的:滚动超出范围后,有回弹的作用。如果选择该种运动类型,会有特有属性「弹性」,用于控制松开鼠标后回弹的速度,值越小回弹越快。

    • 已钳制:无法滚动超出Content的范围。

  • 惯性:如果勾选,则松开鼠标后Content还会向前移动,像惯性一样;如果取消勾选则停止滑动后Content立即停止移动。

    如果勾选,则会有特有属性「减速率」,用于控制松开鼠标后减速(加速)的速率。值为0时效果和取消勾选相同;值为1时将一直保持该速度滑动;值属于 (0,1)(0,1) 时减速;值属于 (1,+)(1,+\infty) 时加速。

  • 滚动灵敏度:顾名思义。

  • 可视性

    • 永久:永远不隐藏相应的滚动条。

    • 自动隐藏:该滚动条所控制的维度上,Content的长度小于等于显示区域的长度时,隐藏该滚动条。但是显示区域不会扩展到该滚动条原来所在的区域上:

    • 自动隐藏并展开视口:该滚动条所控制的维度上,Content的长度小于等于显示区域的长度时,隐藏该滚动条。且显示区域会扩展到该滚动条原来所在的区域上:

  • 间距:「可视性」设为「自动隐藏并展开视口」时的特有属性。

    Scrollbar与Viewport的矩形变换区域之间的间距,正数表示相离,负数表示相交。

    因为UI图像的四周一般有一圈透明的区域,导致除去这一圈透明之后的图像大小小于矩形变换区域的大小,所以一般要将这个值设成一个负值。

    数值为0的效果:

自适应布局

自适应屏幕的背景图

「CanvasScaler」是创建「画布(Canvas)」对象时自带的组件,用于缩放所有子孙级UI对象,以自适应不同分辨率的屏幕。

通常会创建一个UI背景,方法是先创建一个「面板(Panel)」对象,然后将你想作为背景的图片赋值给该对象的「Image」组件。然后我们设置该对象的「RectTransform」组件:将缩放全设为1,四个锚点全置于屏幕中央,矩形变换区域也置于屏幕中央。

这时因为屏幕长宽比与背景图长宽比不一致,可能会出现背景图无法覆盖整个屏幕的情况,需要用到「画布」对象上的「CanvasScaler」组件:

参考分辨率设为背景图的分辨率,其他与上图保持一致即可。

自适应UI组

自动布局系统中非常重要的两个概念:布局元素和布局控制器。

布局元素就是参与自适应布局的UI元素,任何一个UI对象都可以作为布局元素,布局元素有以下6种属性:最小宽(高)度、最佳宽(高)度、弹性宽(高)度,默认值都是0。如果想更改这些值,需要用到组件「LayoutElement」。

布局元素只提供自己的这6种属性的值,而不直接设置自己的尺寸,但是给布局控制器通过这些值来计算他们的尺寸和位置。

LayoutElement

用于更改布局元素的布局属性。

  • 忽略布局:若勾选,则布局控制器将不会让该布局元素参与布局。

  • 6种属性:

    • 最小XX:布局时该布局元素长度将不会小于该值(当然布局控制器需要开启「控制子对象大小」才能改变布局元素的长度,之后会讲),即使长度超出布局控制器的矩形变换区域。

    • 最佳XX:如果所有布局元素的最小XX已得到分配,且还有剩余空间,则分配最佳XX。布局元素将按照他们 最佳XX最小XX\text{最佳XX}-\text{最小XX} 的比例将剩余空间按比例分配,直到所有布局元素的长度同时达到最佳XX或剩余空间被分配完。

    • 弹性XX:如果所有布局元素都达到最佳XX后仍有剩余空间(当然还需要布局控制器开启「子力扩展」,之后会讲),将分配弹性XX。布局元素将按照他们弹性XX的比例将剩余空间按比例全部分配。

  • 布局优先:当一个对象上有不止一个包含布局属性的组件,则取该值最高的那个。

HorizontalLayoutGroup和VerticalLayoutGroup

这两个组件分别用于水平布局和竖直布局,使用时将该组件作为添加到某对象上,然后将所有需要参与布局的布局元素作为其子对象。

两者的功能相对应,用法相似,这里以「Horizontal Layout Group」为例。

  • 填充:页边距。注意当内容过大导致左右页边距冲突时,如果「子级对齐」是从左到右,则满足左侧页边距;反之满足右侧。

  • 间距:最小间距,即使长度超出布局控制器的矩形变换区域。如果「控制子对象大小」不勾选而「子力大小」勾选,则实际间距大小可以超出该值。

  • 子级对齐:x轴y轴两个维度上都有对齐一侧、对齐另一侧、居中三个选项。

  • 反向排列:如果不勾选,则布局元素排列顺序为在层级视图中由上到下的顺序;勾选则反之。

  • 控制子对象大小:如果不勾选,则不能改变布局元素,也就是子级的矩形变换区域的大小,所以在这种情况下最小XX、最佳XX、弹性XX都无法分配,而间距大小可以改变;反之则可以改变布局元素的大小。

  • 子力扩展:如果不勾选,则布局元素的最佳XX分配完后不会分配弹性XX,间距也不会扩大;反之则要求布局元素、间距、页边距必须铺满整个布局控制器的矩形变换区域。

GridLayoutGroup

该组件用于网状布局。

和水平布局和竖直布局不同,网状布局会忽略所有布局元素的布局属性,并统一分配一样的长宽给每个布局元素。

协程

C#迭代器

因为Unity协程的实现基于C#迭代器,这里先对迭代器做简单讲解。

迭代器的概念和基本用法

迭代器是用于按顺序遍历容器中的所有元素的接口。C#中迭代器有两个接口:IEnumeratorIEnumerable。这两个接口作用有所不同,我们暂时不用了解他们具体的区别,只需要理解迭代器即可。

迭代器支持两种功能,分别是按顺序给容器中的所有元素赋值和按顺序读取所有元素。其中读取用的就是foreach语句:

1
2
3
4
5
IEnumerable<int> b = CreateIE();
foreach(int i in b)
{
Console.WriteLine(i);
}

而按顺序给所有元素赋值的操作比较复杂,涉及到yield关键字。

yield关键字

这是一个返回迭代器中元素的函数方法,其中用到了yield关键字。

1
2
3
4
5
static IEnumerable<int> CreateIE()
{
yield return 0;
yield return 1;
}

yield关键字后可以跟return,也可以跟break

  • yield break;:终止当前函数,迭代结束。例如调用下方函数只会输出yes:

    1
    2
    3
    4
    5
    6
    static IEnumerable<int> CreateIE()
    {
    Console.WriteLine("yes");
    yield break;
    Console.WriteLine("no");
    }
  • yield return XXX;:容器下一个元素是XXX。注意该语句不会迭代结束。例如下方函数生成的迭代器中的元素依次为0 2 4 6 8;

    1
    2
    3
    4
    5
    6
    7
    static IEnumerable<int> CreateIE()
    {
    for (int i = 0; i <= 8; i += 2)
    {
    yield return i;
    }
    }

关于yield语句和迭代器,还有很重要的一点需要注意,迭代器的调用不会立即执行,例如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static IEnumerable<char> CreateIE()
{
Console.Write("1 ");
yield return 'b';
Console.Write("2 ");
yield return 'c';
Console.Write("3 ");
}
static void Main(string[] args)
{
IEnumerable<char> numbers = CreateIE();
Console.Write("a ");
foreach (char i in numbers)
{
Console.Write($"{i} ");
}
}

其输出:

1
a 1 b 2 c 3

注意所有字母均在主函数中输出,数字均在CreateIE函数中输出。我们发现并非在调用CreateIE函数后函数中的语句就执行完了,而是等到迭代器中的元素需要被读取时,每次需要被读取一个值,就在CreateIE函数中执行语句直到遇到一个yield return,返回这个值;需要读取下一个值时,再在CreateIE中执行语句。

这和多线程不一样,多线程是几个线程一并执行。而CreateIE是和主函数不断交替执行语句的权力。

Unity协程

Unity自带函数开始和停止协程

协程全称协同程序。

Unity中和协程有关的部分用到的迭代器都是IEnumerator

Unity有3个函数用于开始/停止协程:

  • StartCoroutine

    1
    2
    public Coroutine StartCoroutine(IEnumerator routine);
    public Coroutine StartCoroutine(string methodName, object value = null);

    传参可以为迭代器,也可以为返回值为迭代器的函数的名称以及该函数的传参,如果是后者,传参只能为0或1个。

    返回值为协程类Coroutine

    销毁该脚本或是如果脚本所在对象已禁用也会停止协程;禁用该脚本不会停止协程。

  • StopCoroutine

    1
    2
    3
    public void StopCoroutine(IEnumerator routine);
    public void StopCoroutine(string methodName);
    public void StopCoroutine(Coroutine routine);

    传参为迭代器时注意,StopCoroutine和开始协程时StartCoroutine传入的应是同一个实例化的迭代器,例如这个是错误的:

    1
    2
    3
    IEnumerator Func(){/* ... */}
    StartCoroutine(Func());
    StopCoroutine(Func());

    这个是正确的:

    1
    2
    3
    4
    IEnumerator Func(){/* ... */}
    IEnumerator ie = Func();
    StartCoroutine(ie);
    StopCoroutine(ie);

    还要注意,传参为迭代器的StopCoroutine只能终止传参为迭代器的StartCoroutine创建的的协程;字符串的也只能终止用字符串创建的;但是传参为Coroutine的都可以终止。

  • StopAllCoroutines

    1
    public void StopAllCoroutines();

    停止该脚本上的所有协程。

迭代器生成函数的逻辑和yield关键字

我们在上一节了解到,一个返回值类型为迭代器的函数,每次调用yield return之后,会将执行语句的权力交还给主程序,等到下次需要时主程序再将执行语句的权力交还给该函数。对于Unity而言,这个主程序交还权力给该函数的时间是下一帧,也就是每帧协程都会运行到下一个yield。而具体在每帧的何时运行,可见Unity文档-事件函数的执行顺序

yield return XXX;XXX的不同也会由不同的效果:

  • yield return null;/yield return 任何数字;:挂起直到下一帧。

  • yield return asyncOperation类;:挂起直到该异步操作结束。

  • yield return Coroution类:挂起直到该协程结束。

  • yield return Func(XXX):挂起直到该函数结束。

  • yield return new WaitForSeconds(0.3f);:挂起直到0.3秒后,会受时间缩放影响。

  • yield return new WaitForSecondsRealtime(float);:挂起直到0.3秒后,不会受时间缩放影响。

  • yield return new WaitUntil(bool);:挂起直到输入的参数为true

  • yield return new WaitWhile(bool);:挂起直到输入的参数为false

使用协程制作加载界面

利用以下代码,可以制作一个有加载进度条和百分比数字的加载场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Slider slider;
public Text text;
private AsyncOperation operation;
IEnumerator ILoad(string scenename)
{
operation = SceneManager.LoadSceneAsync(scenename);
while(!operation.isDone)
{
slider.value = operation.progress;
text.text = (operation.progress * 100).ToString() + "%";
yield return null;
}
}
private void Start()
{
StartCoroutine(ILoad("Title"));
}

本地保存数据

C#文件IO

本地保存数据需要将数据从本地文件写入和读出。

与控制台IO不同的是,文件IO用到的不是Console,而是File静态类,使用前需要using命名空间:

1
using System.IO;

File类中常用的静态方法:

  • WriteAllText

    1
    public static void WriteAllText(string path, string? contents);

    创建一个指定路径的新文件,向其中写入指定的字符串,然后关闭文件。如果目标文件已存在,则覆盖该文件。

  • ReadAllLines

    1
    public static string[] ReadAllLines(string path);

    打开一个指定路径的文件,逐行读取文件中的所有文本,然后关闭此文件。每行的内容各存入string[]的一个位置,\n\r不会被存入。

    使用前应先用Exists判断该文件是否存在。

  • Exists

    1
    public static bool Exists(string path);

    判断指定路径的文件是否存在。

设置保存文件的路径

保存文件需要地址,而且需要在不同系统、不同用户的电脑上找到合适的地址,这就要用到Unity自带的静态属性:

  • Application.persistentDataPathstring类型,可以用于保存数据的文件夹的地址,在不同系统中不同。

注意到这是文件夹地址,而该文件夹中的文件地址需要在末尾加上/FILENAME

序列化插件

虽然掌握了C#文件IO的方法,但本地保存数据时如果一个一个将需要保存的数据值按顺序输入到文件中,再在需要用的时候一个一个按顺序从中读取并赋值,未免过于繁杂。

使用序列化插件可以一个函数将要输出的所有数据由其他类型变成一个字符串类型以便输出,还可以一个函数将该字符串变回去。

在「菜单栏-窗口-包管理器-左上角加号-添加来自gitURL的包」中输入「com.unity.nuget.newtonsoft-json」下载序列化插件。

在代码中需要using命名空间:

1
using Newtonsoft.Json;

常用的有两个方法,分别用于序列化和反序列化,他们都是静态类JsonConvert的静态方法:

  • SerializeObject

    1
    public static string SerializeObject(object? value);

    将传参序列化,返回序列化后的字符串。例如将new List<int>{3, 1, 4, 7}序列化,返回的字符串是"[3,1,4,7]"

  • DeserializeObject

    1
    public static T? DeserializeObject<T>(string value);

    将传参反序列化。

本地保存数据的代码实现

下方代码中abc就是要本地保存的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
string path;
string[] readcontent;
string writecontent;
public float a;
public List<int> b;
public List<string> c;
private void Awake()
{
path = Application.persistentDataPath + "/data.sav";
}
private void Read()
{
if (File.Exists(path))
{
readcontent = File.ReadAllLines(path);
a = JsonConvert.DeserializeObject<float>(readcontent[0]);
b = JsonConvert.DeserializeObject<List<int>>(readcontent[1]);
c = JsonConvert.DeserializeObject<List<string>>(readcontent[2]);
}
else
{
a = 3.1415926f;
b = new List<int>{3, 1, 4, 7};
c = new List<string>{"Hello ", "Unity!"};
}
}
private void Write()
{
writecontent += JsonConvert.SerializeObject(a) + "\n";
writecontent += JsonConvert.SerializeObject(b) + "\n";
writecontent += JsonConvert.SerializeObject(c) + "\n";
File.WriteAllText(path, writecontent);
}

音频

Unity中音频系统常用组件是「AudioSource」和「AudioListener」,分别是声音的发出者和声音的接收者。一个项目中只需要有一个「AudioListener」,一般都在MainCamera上,但可以有多个「AudioSource」。

音乐文件被称作「AudioClip」,可以是任意音频文件类型。可以在Unity资源商店中挑选合适的素材。

这三者在代码中的类名就叫AudioSourceAudioListenerAudioClip

AudioSource

检查视图设置

常用属性如下:

  • AudioClip:将需要播放的音频填在这里。

  • 静音

  • 唤醒时播放:在比Awake更早的时候就开始播放。注意如果想使用该功能,应在游戏运行前就在检查窗口中给「AudioClip」赋值,而不是在Awake或更晚的时候。

  • 循环

  • 音量

  • 空间混合

    空间混合就是设置2D模式和3D模式所占比例。

    Unity的音频系统分为两种模式:2D和3D。其实这两种模式和几D没关系,2D模式就是指音量大小音调等都与「AudioSource」和「AudioListener」之间的距离无关,「AudioSource」发出什么声音「AudioListener」就接收到什么样的声音;3D模式是考虑两者之间的距离等因素,比较贴近实际。

    在2D游戏中使用3D模式时要注意,MainCamera的默认z轴坐标值是-10,而一般对象的z轴坐标值是0。这种情况下就算音源对象和MainCamera的x轴y轴坐标都相同他们间仍有10的距离,也达不到最大声音。所以建议将MainCamera的z轴坐标设为0,并将其「裁剪平面-近」设置为0,否则看不到z轴坐标为0的对象。

  • 多普勒级别:如需关闭多普勒效应,该值设为0。

  • 音量衰减/最小距离/最大距离:参考下方图像设置。

代码编写

AudioSource类的常用属性和方法:

  • clip:属性,类型为AudioClip。就是检查视图中的「AudioClip」。

  • Play:方法,无返回值无传参。开始播放。