思考并回答以下问题:
- 作者为什么使用类不用继承MonoBehaviour也能具有定期更新的功能这种做法?怎么做到的?
本章涵盖:
- GameLoop由此开始
- 怎么实现游戏循环
- 在Unity3D中实现游戏循环
- P级阵地的游戏循环
- 结论
GameLoop由此开始
本章我们先跳脱GoF的设计模式,来讲解一个在游戏开发时特有的设计模式——游戏循环(Game Loop)。
游戏循环是“游戏软件”与“一般应用软件”在执行时,有不一样的运行方式而特别设计的一种“程序运行流程”。“一般应用软件”是以台式计算机的操作系统(Windows、MacOS)为例,这些“一般应用软件”指的就是Word、Excel、记事本等应用软件。它们的特色是:程序启动后会等待用户去操作它,给它命令,以被动的方式等待用户决定要执行的功能。所以,这类软件大多数都是以“事件驱动”的方式来设计的,屏幕显示画面上会有不少的“按钮”“菜单”等组件,等待用户对其单击或选择产生“事件”,从而让应用软件执行后续的功能,如图1所示。
图1 “一般应用软件”的接口示意图

但游戏软件有着完全不同的运行方式,我们可以试着想象,游戏执行之后就产生了一个虚拟世界,这个虚拟世界会“自己”运行,并且有自己的游戏规则。在这个世界中,玩家可能只是扮演其中一个会移动的角色,并且通过游戏杆或键盘与这个游戏世界互动。它不必等待玩家的反应,可能就会从某处出现一只怪物攻击玩家,或是跳出任务要求玩家去完成它。所以,游戏软件在设计时,必须提供一个机制让这个游戏世界能不断地更新,让其能自动产生各种情景与玩家互动,一般将这个更新机制称为“游戏逻辑更新”,如图2所示的示意图。
图2 “游戏逻辑更新”示意图

“游戏软件”与“一般应用软件”另外一个不同点是,游戏软件需要不断地进行“画面更新”,当玩家进入游戏世界赞叹画面美丽、动态逼真时,它正在不断地进行“画面更新”以产生动画的效果。而一般用于游戏性能评测值中的“每秒帧数”(FPS,Frame Per Second),通常指的是游戏系统在一秒钟之内能执行多少次“画面更新”,这个数值越高代表游戏的性能越好。
所谓的“游戏循环”,就是将上述提到的玩家操作、游戏逻辑更新和画面更新3项操作整合在一起的执行流程,如图3所示。
图3 游戏更新示意图

怎么实现游戏循环
如果游戏软件是从命令模式(Console)开始执行的话,那么游戏循环可以如下实现:
Listing1 Game Loop的简单写法
1 | void main() |
在早期使用Win32 API + DirectX来开发2D游戏时,如果配合Windows系统的消息机制,那就必须使用不同于一般应用程序的消息分配方式来完成。实现方式举例如下:
Listing2 在Win32API下写Game Loop
1 | int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR szCmdLine, int iCmdShow) |
在执行UserInput()时,应调用DirectInput来获取玩家的输入;在执行Render()时,则应调用DirectDraw来绘制游戏画面。
随着时代的演进,游戏界开始使用3D游戏引擎来开发游戏,这些游戏引擎也会提供回调函数(Callback Function),让开发者可以指定“在内定游戏循环”之外还要执行的游戏功能。
无论是早期的J2ME,还是近期的Android及iOS提供的SDK,游戏程序设计师都可以使用特定的实现方式,来实现游戏循环。
由于笔者从早期就开始接触游戏程序设计,已经习惯使用游戏循环的设计方式。从游戏初始化、数据加载、游戏系统设置、更新资源、加载存盘……,再到进入游戏、打怪……直到游戏结束等,我会让各个游戏系统按照一定的顺序来完成,所以在第一次接触新的开发平台工具时,总会在其中寻找最佳的游戏循环实现方式,Unity3D也不例外。因此,在本章剩余的内容中,我们将重点讲解如何在Unity3D中实现游戏循环及《P级阵地》的游戏循环设计方式。
在Unity3D中实现游戏循环
每一个放在Unity3D场景中的游戏对象(GameObject),都可以加上一个“脚本组件”(Script Component),在这个脚本组件中定义的类,必须继承MonoBehaviour,并且在类中加入特定的方法(Awake、Start、Update、……)。而这些方法在游戏运行时,就会按照Unity3D内部的运行流程按序被调用。
Unity3D内部的Game Loop,一个继承MonoBehaviour的Unity3D脚本,会按照一定的顺序被调用,如图4所示。
图4 Unity3D内部的Game Loop

利用Unity3D脚本组件的这个特性,我们可以在其中加入游戏循环的机制。实现时可以先在开始场景(Start Scene)中加入一个空的GameObject并更名为GameLoop。然后新建一个C#脚本组件,命名为GameLoop.cs,并将其挂在GameLoop的游戏对象上。之后在GameLoop.cs中完成下列程序代码:
Listing3 游戏主循环(GameLoop.cs)
1 | public class GameLoop : MonoBehaviour |
在GameLoop类的Start方法中编写游戏初始化的工作,而继承MonoBehaviour的子类,只要在类定义中增加一个Update方法,这个Update方法就会在每次Unity3D进行更新的时候被自动调用。这样的定期更新机制,刚好可以被应用在需要固定执行的功能上。因此,我们可以在Update方法中实现游戏所需要的“玩家控制功能”和“游戏逻辑更新”。至于画面更新的部分是最不用担心的,因为这一部分全部都由Unity3D引擎来帮开发者完成了。完成上述的步骤后,我们就可以在Unity3D中实现游戏循环。
将需要定时更新的游戏功能与Unity3D解耦(解除依赖关系)
在游戏内各系统的整合-中介者模式中介绍游戏系统时曾提到:开发者可以为“单一的游戏系统”加入定期更新功能。而所谓“单一的游戏系统”指的是一个游戏系统类被定义在一个.cs文件,但这个游戏系统类不想通过继承MonoBehaviour并挂入某一个Unity3D游戏对象的方式,来拥有定期更新的功能,它们希望能够使用另一种方式被定时更新。
虽然挂在游戏对象上的脚本类也可以达到定期更新的目的,但这样一来,这个“单一的游戏功能”类就与Unity3D引擎有了依赖关系。
所以解决的方案是,程序设计师可以只单纯地增加一个类,并且在其中声明一个需要被定期调用的函数Update,然后将这个类对象置于GameLoop.cs的Update()中,让GameLoop的Update随着每次Unity3D定期更新的机制,一同调用这个对象的更新函数。这样就可以达到类不用继承MonoBehaviour也能具有定期更新的功能:
Listing4 需要定时更新的游戏功能(GameFunction.cs)
1 | public class GameFunction |
Listing5 游戏主循环(GameLoop.cs)
1 | public class GameLoop : MonoBehaviour |
P级阵地的游戏循环
《P级阵地》中的游戏系统(IGameSystem)都属于“单一的游戏系统”,因为笔者在实现上希望能自己来掌控这些游戏功能被更新的时间点和方式,所以并未让它们继承Unity3D的MonoBehaviour类,而是将这些游戏系统对象都一起放在PBaseDefenseGame的Update()更新方法中一起被调用执行。而要让PBaseDefenseGame的Update()更新方法被定期调用,就必须利用本章介绍的GameLoop机制来实现这个目的。
所以,对于《P级阵地》中结合数种设计模式的结果,包含游戏循环中的“玩家操作”和“游戏逻辑更新”,都被从原本的GameLoop.cs中调整到PBaseDefenseGame的更新方法Update()内:
Listing6 游戏功能类中的Game Loop(PBaseDefenseGame.cs)
1 | public class PBaseDefenseGame |
而PBaseDefenseGame类的Update方法,则是由战斗状态类(BattleState)负责调用:
Listing7 战斗状态类配合Game Loop更新(BattleState.cs)
1 | public class BattleState : ISceneState |
当游戏进入战斗状态(BattleState)时,位于PBaseDefenseGame类内的“游戏循环”就能从GameLoop.cs中的Update()方法,通过BattleState类不断地被调用,那么位于PBaseDefenseGame类内的游戏逻辑(各个游戏系统)也就能不断地被更新:
Listing8 游戏主循环(GameLoop.cs)
1 | public class GameLoop : MonoBehaviour |
各对象的流程图如图7所示。
图5 各对象的流程图

结论
每一款游戏在实现时,都会有专用于这款游戏的“玩家操作”和“游戏逻辑更新”这两项特殊需求。因此,在PBaseDefenseGame类内实现“游戏循环”是比较好的设计方式,这样可以提高PBaseDefenseGame类整个移植到其他项目的可能性。
虽然《P级阵地》中大部分的游戏功能和用户界面类,都采用“不”继承MonoBehaviour的方式来运行,但对于会出现在场景中的每个游戏3D角色上,还是会搭配使用脚本组件(继承MonoBehaviour),所以每一个脚本组件还是会按照Unity3D引擎的流程去操作每一个游戏对象。