Unity-剑英的C#提高篇(一)主循环
最近,我离开了服务三年多的公司,原因很现实——经济问题。此前,在迷茫之际,机缘巧合与哒嗒网络的吴总交流,让我发现了VR游戏这片新领域。回顾这段有些慌乱的时光,自觉荒废了不少时间,现在抽空回来和大家深入探讨C#相关知识。
之前我写过C#入门系列,内容较为基础。如今,我认为是时候提升一下难度了。从编写一段简单的程序,到开发一个应用程序或者一款游戏,其中的区别究竟在哪呢?其实,核心差别在于执行时间:一段程序的执行时间通常较短,而一个应用或游戏的执行时间则相对较长。
在游戏开发中,“帧”是一个关键概念。简单来说,它类似于电影胶卷的一格。电影胶卷一格一格地在镜头前放映,每一格在画面上停留一段时间后切换到下一格。电影通常以每秒24格的速度播放,而游戏的帧率常见为每秒30帧或60帧。游戏的逻辑就是在Update函数中一帧一帧地执行。那么,Update函数是如何被驱动的呢?答案就是主循环。
主循环的基本概念
我们先来看一个基本的控制台程序,经典的“Hello World”。这个程序大家都很熟悉,运行时会一闪而过,因为它执行完毕后就立即结束了。如果我们编写一个死循环版本的程序呢?没错,主循环本质上就是一个死循环。正是这个死循环,让一段简单的程序有了成为应用或游戏的可能。
我们将Update函数从代码中拆分出来,会发现代码结构变得有些眼熟。再加上OnStart函数,就更能看出Unity的MonoBehaviour的实现原理了。实际上,任何程序中都存在主循环。在常用的界面框架中,主循环通常被隐藏起来,只提供事件型的接口。虽然主循环看起来很简单,但在游戏开发中,它的作用至关重要。游戏程序中事件型框架相对较少,大部分逻辑需要从主循环层面开始构建,这就要求开发者对主循环有深入的理解,能够基于主循环创建各种不同的逻辑模式。
主循环和定时器
接下来,我们探讨主循环与定时器的关系。假设我们有一个需求:每三秒钟打印一条日志。该如何实现这个三秒钟的计时呢?电影的帧率是稳定的,每秒24帧,我们可以通过数帧来大致估算时间。但游戏的帧率是不稳定的,每一帧的时间并不固定。不过,Unity提供了一个参数Time.DeltaTime,它表示从上一帧开始到当前帧开始所经过的时间,单位为秒。
由于我们知道了每一帧的时间间隔,将这些间隔累加起来,就可以实现一个计时器。这个计时器的计时非常精准,你可以用秒表来验证。这就是一个由不稳定帧率的主循环驱动的计时器。目前,这个计时器只是单纯地计时,我们可以让它执行一些具体的任务。
在游戏开发中,固定帧率只是一种理想状态,大部分情况下难以实现。几乎所有的游戏逻辑都涉及到计时问题,都需要考虑在浮动帧率下如何进行逻辑控制。需要牢记的是,时间是连续累加的。
主循环与缓动
使用定时器驱动数值变化的过程,在某些情况下被称为缓动。像Dotween、Itween这类名字中带有“tween”的库或插件,都是用于实现缓动效果的。它们封装了各种缓动模式的代码,能为开发者节省不少时间。
我们以一个简单的例子来说明缓动的原理:让一个立方体在三秒钟内从A点移动到B点。在所有缓动系统中,通常以零表示开始,以一表示结束。我们可以让之前实现的计时器在三秒钟内完成从零到一的变化,这就是一个缓动周期。我们可以通过给timer加上一个系数来改变其变化速度,使其在三秒钟内完成从零到一的过程。不过,从代码规范的角度来看,既然它被命名为timer,就不应该用于表示从零到一的缓动进度,我们可以将其命名为lerp。
将上述代码编写成脚本,挂载到一个立方体上,然后在begin和end中填入不同的值,运行程序,就能看到缓动效果。了解缓动框架背后的原理,即使你使用了高级的缓动库,也会对你的开发有所帮助。
主循环和状态机
在入门篇中,我们探讨了时空观的问题,从程序的角度来看,这就是状态的概念。图灵机和冯诺依曼机是计算机的基础,而它们的核心就是状态机。即使在高级语言和顺序执行的程序体系中,状态机依然是编程的基础。
我们在单步调试时设置的每一个断点,其实就代表一个状态。程序是由一个个状态构成的。从更大的尺度来看,在功能模块和程序结构方面,状态机也是功能设计的基础。例如,游戏中当前处于主菜单还是战斗菜单,是在进行充值还是消费等,同一时刻必然处于某一个功能状态,这是我们设计的基本准则。
游戏框架的设计通常从状态的分割开始。有些框架提供了比状态机更高级的模式,如导航器,它实际上也属于状态机的范畴,但导航器可以记录之前的状态,支持返回操作。你可以通过打开一个手机应用,点击不同的功能,然后按返回键来体验导航器的工作原理。如今,随着手游的流行,游戏界面借鉴了很多应用程序的设计,导航器设计成为了一种非常主流的设计方式。
由于这只是一个抛砖引玉的提高过程,我们不会详细编写一个导航器框架,而是着重解释状态机背后的原理。状态机的代码实现可能会比较长,我们将其分为Update函数和OnGUI函数两部分来看。乍一看,这段代码似乎只是简单的if-else语句,但实际上,状态机的本质就是switch-case或if-else,只不过需要进行结构化设计。当逻辑状态较少时,使用if-else语句是可行的,但为了使代码更具扩展性和可维护性,我们可以将其进行结构化设计。
我们抽象出一个表达状态的接口,这样主要的代码就会变得非常简洁。不过,具体的实现代码可能会相对复杂一些。有了这样的设计基础,即使需要添加更多的状态,也能轻松应对。当然,实际开发中遇到的问题会比这个示例更加复杂。
通过对主循环与定时器、缓动和状态机的探讨,我们可以看到主循环在游戏开发中的核心地位。深入理解主循环及其相关概念,将有助于我们更好地开发出高质量的游戏。