转自:【Iphone 游戏开发】游戏引擎剖析
http://www.uml.org.cn/mobiledev/201107183.asp
为了解决“如何在IPHONE上创建一个游戏”这个大问题,我们需要首先解决诸如“如何显示图像”与“如何播放声音”等一系列小问题。这些问题关系到创建部分游戏引擎。就像人类的身体一样,游戏引擎的每个部分虽然不同,但是却都不可或缺。因此,首先从游戏引擎剖析开始本章。我们将会讨论一个游戏引擎的所有主要部分,包括应用程序框架、状态机、图像引擎、物理引擎、声音引擎、玩家输入和游戏逻辑。
写一个好玩的游戏是一项牵扯到很多代码的大任务。非常有必要从一开始就对项目进行良好的,有组织的设计,而不是随着进度的进行而到处杂乱添加代码。就像建造房屋一样,建筑师为整幢房屋勾画蓝图,建筑工人以此来建造。但是,许多对游戏编程不熟悉的编程人员会从根据导读建造出房屋的一部分,并随着学习的进行为其添加房间,这无疑将会导致不好的结果。
图2-1 游戏引擎的功能结构
图2-1显示了一个适用于大部分游戏的游戏引擎结构。为了理解一个游戏引擎的所有部分和它们是如何工作在一起的,我们可以先为整个游戏做设计,然后再创建我们的应用程序。在以下的几个小节中,我们的讲解内容将会涵盖图2-1的每个部分。
- 应用程序框架
- 游戏状态管理器
- 图像引擎
应用程序框架
应用程序框架包含使应用程序工作的必须代码,包括创建一个应用程序实例和初期化其他子系统。当应用程序运行时,会首先创建一个框架类,并接管创建和销毁状态机、图像引擎和声音引擎。如果我们的游戏足够复杂以至于它需要一个物理引擎,框架也会管理它。
框架必须适应于我们所选择的平台的独特性,包括相应任何的系统事件(如关机与睡眠),以及管理载入与载出资源以使其他的代码只需要集中与游戏。
主循环
框架会提供主循环,它是一切互动程序后的驱动力量。在循环中的每一次迭代过程中,程序会检查和处理接受到的事件,运行游戏逻辑中的更新并在必要时将内容描画到屏幕上。(参见图2-2)
图2-2 主循环序列
主循环如何实现依赖于你使用的系统。对于一个基本的控制台程序,它可能是一个简单的while循环中调用各个函数:
- while( !finished ) {
- handle_events();
- update();
- render();
- sleep(20);
- }
注意到这里的sleep函数。它使得代码休眠一小段时间不致于占用全部的CPU。
有些系统完全不想让用户代码那些写,它们使用了回调系统以强制程序员常规的释放CPU。这样,当应用程序执行后,程序员注册一些函数给系统在每次循环中回调:
- void main(void) {
- OS_register_event_handler( myEventHandler );
- OS_register_update_function( myUpdate );
- OS_register_render_function( myRender );
- }
一旦程序执行后,根据必要情况,那些函数会间隔性的被调用。IPHONE是最接近后面这个例子。你可以在下一章和IPHONE SDK中看到它。
游戏状态管理器
一个好的视频游戏不仅有一组动作来维持游戏:它会提供一个主菜单允许玩家来设定选项和开始一个新游戏或者继续上次的游戏;制作群屏将会显示所有辛勤制作这款游戏的人员的名字;而且如果你的游戏没有用户指南,应该一个帮助区域会给用户一些提示告诉他们应该做什么。
以上任何一种场合都是一种游戏状态,并且代表中一段独立的应用程序代码片段。例如,用户在主菜单调用的函数与导航与用户在制作群屏调用的是完全不同的,所以程序逻辑也是不同的。特别的是,在主菜单,你可能会放一张图片和一些菜单,并且等待用户选择哪个选项,而在制作群屏,你将会把游戏制作人员的名字描绘在屏幕上,并且等待用户输入,将游戏状态从制作群屏改为主菜单。最后,在游戏中状态,将会渲染实际的游戏并等待用户的输入以与游戏逻辑进行交互。
以上的所有游戏状态都负责相应用户输入、将内容渲染到屏幕、并为该游戏状态提供相对应的应用程序逻辑的任务。你可能注意到了这些任务都来自于之前讨论的主循环中,这是因为它们就是同样的任务。但是,每个状态都会以它们自己的方式来实现这些任务,这也就是为什么要保持他们独立。你不必在主菜单代码中寻找处理游戏中的事件的代码。
状态机
状态管理器是一个状态机,这意味着它跟踪着现在的游戏状态。当应用程序执行后,状态机会创建基本的状态信息。它接着创建各种状态需要的信息,并在离开每种状态时销毁暂时存储的信息。
状态机维护着大量不同对象的状态。一个明显的状态是用户所在屏幕的状态(主菜单、游戏中等)。但是如果你有一个有着人工智能的对象在屏幕上时,状态机也可以用来管理它的“睡眠”、“攻击”、“死亡”状态。
什么是正确的游戏状态管理器结构?让我们看看一些状态机并决定哪种最适合我们。
有许多实现状态机的方式,最基本的是一个简单的switch语句:
- class StateManager {
- void main_loop() {
- switch(myState) {
- case STATE_01:
- state01_handle_event();
- state01_update();
- state01_render;
- break;
- case STATE_02:
- state02_handle_event();
- state02_update();
- state02_render;
- break;
- case STATE_03:
- state03_handle_event();
- state03_update();
- state03_render;
- break;
- }
- }
- };
改变状态时所有需要做的事情就是改变myState变量的值并返回到循环的开始处。但是,正如你看到的,当我们加入越来越多的状态时,代码块会变得越来越大。而且更糟的是,为了使程序按我们预期的执行,我们需要在程序进入或离开某个状态时执行整个任务块,初始化该状态特定的变量,载入新的资源(比如图片)和释放前一个状态载入的资源。在这个简单的switch语句中,我们需要加入更多的程序块并保证不会漏掉任何一个。
以上是一些简单重复的劳动,但是我们的状态管理器需要更好的解决方案。下面一种更好的实现方式是使用函数指针:
- class StateManager {
- //the function pointer:
- void (*m_stateHandleEventFPTR) (void);
- void (*m_stateUpdateFPTR)(void);
- void (*m_stateRenderFPTR)(void);
- void main_loop() {
- stateHandleEventFPTR();
- m_stateUpdateFPTR();
- m_stateRenderFPTR();
- }
- void change_state( void (*newHandleEventFPTR)(void),
- void (*newUpdateFPTR)(void),
- void (*newRenderFPTR)(void)
- ) {
- m_stateHandleEventFPTR = newHandleEventFPTR;
- m_stateUpdateFPTR = newUpdateFPTR;
- m_stateRenderFPTR = newRenderFPTR
- }
- };
现在,即使我们处理再多状态,主循环也足够小而且简单。但是,这种解决方案依然不能帮助我们很好的解决初始化与释放状态。因为每种游戏状态不仅包含代码,还有各自的资源,所以更恰当的做法是将游戏状态作为对象的属性来考虑。因此,接下来,我们将会看看面向对象(OOP)的实现。
我们首先创建一个表示游戏状态的类:
- class GameState
- {
- GameState(); //constructor
- virtual ~GameState(); //destructor
- virtual void Handle_Event();
- virtual void Update();
- virtual void Render();
- };
接着,我们改变我们的状态管理器以使用这个类:
- class StateManager {
- GameState* m_state;
- void main_loop() {
- m_state->Handle_Event();
- m_state->Update();
- m_state->Render();
- }
- void change_state( GameState* newState ) {
- delete m_state;
- m_state = newState;
- }
- };
最后,我们创建一个指定具体游戏状态的类:
- class State_MainMenu : public GameState
- {
- int m_currMenuOption;
- State_MainMenu();
- ~State_MainMenu();
- void Handle_Event();
- void Update();
- void Render();
- };
当游戏状态以类来表示时,每个游戏状态都可以存储它特有的变量在该类中。该类也可以它的构造函数中载入任何资源并在析构函数中释放这些资源。
而且,这个系统保持着我们的代码有很好的组织结构,因为我们需要将游戏状态代码分别放在各个文件中。如果你在查找主菜单代码,你只需要打开State_MainMenu类。而且OOP解决方案使得代码更容易重用。
这个看起来是最适合我们需要的,所以我们决定使用它来作为我们的状态管理器。
图像引擎
图像引擎负责视觉输出,包括用户借以交互的图形用户界面(GUI)对象,2D精灵动画或3D模型动画,并渲染的背景与特效。
虽然渲染2D与3D图片的技术不尽相同,但他们都完成相同的一组图形任务,包括纹理和动画,它们的复杂度是递增的。
纹理
对于显示图片,纹理是中心。2D时,一个平面图片是以像素为单位显示在屏幕上,而在3D时,一组三角行(或者被称为网格)在数学魔法作用下产生平面图片并显示在屏幕上。这以后,一切都变得复杂。
像素、纹理与图片
当进行屏幕描绘时,基本单位是像素。每个像素都可以被分解为红、绿、蓝色值和我们马上要讨论的Alpha值。
纹理是一组关于渲染一组像素的数据。它包含每个像素的颜色数据。
图片是一个更高层的概念,它并非与一组特殊的像素与纹理相关联。当一个人看到一组像素,他的大脑会将它们组合成一幅图片,例如,如果像素以正确的顺序表示,他可能会看到一幅长颈鹿的画像。
保持以上这些概念独立是非常必要的。纹理可能包含构成长颈鹿图片的像素:它可能包含足够的像素来构成一只长颈鹿的多幅图片,或者仅包含构成一幅长颈鹿图片的像素。纹理本身只是一组像素的集合,它并不固有的知道它包含的是一幅图片。
透明度
在任一刻,你的游戏会有几个或者多个物体渲染在屏幕上,其中一些会与另外一个重叠。问题是,如何知道哪个物体的哪个像素应该被渲染出来呢?
如果在最上层的纹理(在其他纹理之后被描画)是完全不透明的,它的像素将会被显示。但是,对于游戏物体,可能是非矩形图形和部分透明物体,结果会导致两种纹理的结合。
2D图片中最常用的混合方式是完全透明。假如我们想画一幅考拉(图2-3)在爬在桉树顶上(图2-4)的的图片。考拉的图片以矩形纹理的方式存储在内存中,但是我们不想画出整个矩形,我们只想画出考拉身体的像素。我们必须决定纹理中的每个像素是否应该显示。
图2-3 考拉纹理
图2-4 桉树纹理
有些图片系统通过添加一层遮罩来达到目的。想象我们在内存中有一幅与考拉纹理大小一样的另外一份纹理,它只包含白色和黑色的像素。遮罩中每个白色的像素代表考拉应该被描画出来的一个像素,遮罩中的黑色像素则代表不应该被描画的像素。如果当我们将考拉描画到屏幕上时,有这样的一个遮罩层,我们就可以检查考拉对应的像素并仅将需要描画的像素表示出来。如果每个像素有允许有一组范围值而不是二进制黑/白值,那么它还可以支持部分透明(参见图2-5)。
图2-5 考拉遮罩纹理
纹理混合
为纹理而准备的存储容量大到足以支持每个像素都有一个范围值。典型的是,一个Alpha值占一个字节,即允许0-255之间的值。通过合并两个像素可以表现出有趣的视觉效果。这种效果通常用于部分透明化,例如部分或完全看透物体(图2-6)。
图2-6 部分透明的绿色矩形
我们可以为每个像素来设定Alpha以决定它们如何被混合。如果一个像素允许的范围值为0-255,Alpha的范围值也同样应当为0-255。尽管红色值为0表示当描画时不应该使用红色,但Alpha值为0则表示该像素根本不应该被描画。同样,128的红色值表示描画时应该使用最大红色值的一半,128的Alpha值表示当与另外一个像素混合时,应该使用该像素的一半颜色值。
当混合物体时,正确的排列物体顺序是非常重要的。因为每个混合渲染动作都只会渲染源物体与目标物体,首先被描画的物体不会与后描画的物体发生混合。尽管这在2D图片中很容易控制,但是在3D图片中变得非常复杂。
旋转
在2D图片中,大部分的纹理都会被直接渲染到目标上而不需要旋转。这是因为标准硬件不具备旋转功能,所以旋转必须在软件中计算完成。这是一个很慢的过程,而且容易产商低质量的图片。
通常,游戏开发人员会通过预先渲染好物体在各个方向的图片,并当物体某个方向上时,为其在屏幕上描画正确的图片来避免以上问题的发生。
在3D图片中,旋转的计算方式与照明相同,是硬件渲染处理过程中的一部分。
剪贴
由于某些在后面章节解释的原因,纹理的另外一个重要方面是剪贴。尽管我们目前的例子都是将源纹理直接描画到目标纹理上,但是经常会出现的情况是,需要将部分源纹理描画到目标纹理的有限的一部分上。
例如,如果你的源纹理是在一个文件中含有多幅图片,裁剪允许你仅将希望描画的部分渲染出来。
剪贴同样允许你进行描画限制到目标纹理的一小部分上。它可以帮你通过纹理映射以3D方式渲染物体,将纹理铺盖到三角形组成的任意形状的网格上。例如,一个纹理可以表示衣服或动物的毛皮,而且当3D角色穿着它移动的死后可能产生褶皱。这时候的纹理通常被称作皮肤。
动画
通过渲染连续的图片,我们可以确保玩家看到一个移动的物体,尽管他所做的仅仅是在同样的像素上,但这些像素在快速的改变颜色。这就是动画的基本概念。2D动画很简单,但3D动画通常牵扯到更多的物体与动作,因此更复杂。
除了讨论动画技巧,这一节还会讨论主要的优化类型可以使得我们的图像引擎有效的和可靠的完成复杂的不可能以原始方式来完成的图形任务。一些主要的优化技巧包括淘汰、纹理排序、使用智能纹理文件、资源管理和细节级别渲染。
2维动画:精灵
在2D图像中,如果我们要渲染马儿奔驰的完整场景,我们可以先创建出马儿的奔驰各个姿态的图片。这种图片成为一帧。当一帧接一帧的渲染到屏幕上时,马儿动起来了(见图2-7)。这和电影创建动画的方式非常相似,电影也是通过展示连续的帧来达到移动效果。
图2-7 斯坦福德的马的动作
为了将这些帧保存在一起,我们将它们放在同一个纹理中,称为精灵。通过前面章节我们描述的裁剪方法,将只包含当前帧内容的部分渲染到屏幕上。
你可以将每一帧渲染多次直到渲染该序列的下一帧。这取决于你希望你的动画播放的多快,以及提供了多少帧图片。事实上,通过渲染的帧速和顺序,你可以创造出多种特效。
3维动画:模型
与2D动画中每次重画时都维护一幅用来渲染的图片--精灵不同,3D动画是通过实际的计算的计算运动的几何效果。正如我们之前描述的,所有的3D物体都由包含一个或多个三角形构成,被称作网格。有多种可以使网格动起来的方法,这些技术与游戏发展与图形硬件有关。这些技术后的基本概念都是:关键帧。
关键帧与我们之前讨论的2D动画中的帧有些许不同。2维动画的美术人员画出每一帧并保存在纹理中。但是在3D中,只要我们保存了最特殊的几帧,我们就可以通过数学计算得到其他帧。
最开始的使用网格动画的游戏实际上存储了网格的多个拷贝,每一个拷贝都是都在不同的关键帧方向上。例如,如果我们在3D中渲染马儿,我们应该为上面精灵的每一个关键帧都创建网格。在time(1),第一帧被描画出来,在time(2),第二针被描述出来。
在主要关键帧之间,使用一种叫做“插值”的技术方法。因为我们知道time(1)的关键帧和time(2)的关键帧有着相同数量的三角形,但是方向稍有区别,我们可以创建当前时间点的临时的,融合了前面两个网格的网格。所以在时间time(1.5),临时网格看起来正好介于time(1)与time(2)之间,而在time(1.8),看起来更偏向于time(2)。
以上技术效率低下的原因是很明显的。它仅在只有少量的三角形和少量的关键帧时才是可接受的,但是现代图像要求有高解析度与生动细节的动画。幸运的是,有更好的存储关键帧数据的方法。
这就技术叫做“骨骼动画”(skeletal animation, or bone rigging)。还是以马儿为例,你可能注意到了大多数的三角形都是成组的移动,比如头部组、尾部组和四肢组。如果你将它们都看成是骨头关联的,那么将这些骨头组合起来就形成了骨骼。
骨骼是由一组可以适用于网格的骨头组成的。当一组骨骼在不同方向连续的表示出来的时候,就形成了动画。每一帧动画都使用的是相同的网格,但是都会有骨头从前一方位移动到下一个方位的细小的动作变化。
通过仅存储在某一个方位的网格,然后在每一关键帧时都利用它,我们可以创建一个临时的网格并将其渲染到屏幕上。通过在两个关键帧之间插值,我们可以以更小的成本来创建相同的动画。
动画控制器
动画控制器对象在抽象低层次的任务非常有用,如选择哪一帧来渲染,渲染多长时间,决定下一帧代替前一帧等。它也起到连接游戏逻辑与图像引擎等动画相关部分的作用。
在顶层,游戏逻辑只关心将设某些东西,如播放跑动的动画,和设定它的速度为可能应该每秒跑动数个单位距离。控制器对象知道哪个帧序列对应的跑动动画以及这些帧播放的速度,所以,游戏逻辑不必知道这些。
粒子系统
另外一个与动画控制器相似的有用对象是粒子系统管理器。当需要描画高度支离破碎的元素,如火焰、云朵粒子、火苗尾巴等时可以使用粒子系统。虽然粒子系统中的每个对象都有有限的细节与动画,它们组合起来却能形成富有娱乐性的视觉效果。
淘汰
最好的增加每秒钟描画到屏幕上的次数的方法是在每次迭代中都减少描画在屏幕上的数目的总量。你的场景可能同时拥有成百上千的物体,但是如果你只需要描述其中的一小部分,你仍然可以将屏幕渲染得很快。
淘汰是从描画路径上移除不必要的物体。你可以在多层次上同时进行淘汰。例如,在一个高层次,一个用户在一间关闭了门的房间里面是看不到隔壁房间的物体的,所以你不必描画出隔壁其他物体。
在一个低层次,3D图像引擎会经常移除部分你让它们描画的网格。例如,在任意合适的给定时间点,半数的网格几何体在摄影机背面,你从摄像机中看不到这些网格,看到的只是摄影机前方的网格,因此,当网格被渲染时,所有的在摄影机背后的网格都会被忽略。这叫做背面淘汰。
纹理排序
每次当一个物体被渲染到屏幕上时,图形硬件都会将纹理源文件载入到内存中。这是被称作上下文交换(context switching)的一部分。
如果要将三幅图片描画到屏幕上,而其中两幅图片共用同一个纹理资源,有两种办法来处理纹理排序:高效的方法是连续的渲染两幅共享资源的图片,这样只需要以此上下文交换,而低效的方法则需要两次上下文交换。你不应该将第三幅图片放在共享纹理的两幅图片之间描画。
在渲染处理过程中,通过排列共享纹理的物体可以减少上下文交换的次数,从而提高渲染速度。
纹理文件
在一开始就计划好纹理组织结构可以帮助你以最优化方式排列你的纹理。假设你准备在你的游戏中描画几何体,一个主角和一些生物。
如果前两个关卡是草地,接下来的关卡是沙漠,你可以将所有的树木、草、灌木、岩石以及花儿的图片来放到一起来渲染前两关,并将沙子图片放在另外一个纹理文件中用来渲染第三关。同样的,你可以将玩家虚拟人偶放到一个纹理中。如果所有的生物在所有关卡中都用到了,最优的方式可能是将它们放在一个纹理文件中。但是,如果第一关有吼猴与鼯鼠,而第二关只有森林鼠与苏里南蛤蟆,你可以将第一次前两种动物放在一个纹理中,将后两种放在一个纹理中。
资源管理
大部分的视频游戏在一个时间点只会渲染它们所有图片内容的一小部分。将所有纹理同时载入内存是非常低效的。
幸运的是,游戏设计通常决定了哪些资源在游戏的各个章节是可见的。通过保留必须的纹理为载入状态并卸载不使用的纹理,可以最有效的利用有限的内存资源。
还是使用前一节的例子,当游戏引擎载入第一关时,资源管理代码必须确保 吼猴与鼯鼠的纹理被载入到内存中。当程序进行到下一关时,资源管理代码会卸载那些纹理,因为它已经知道它们不会在第二关被使用。
细节层次
另外一个优化技巧,尤其是对3D图像,叫做细节层次。考虑当一个物体远离摄像机时,它看起来非常小,而且大部分细节都丢失了。你可以描画一个同样大小,却仅拥有简单网格的物体,或者甚至一张平面贴图。
通过保留不同细节层次的物体的副本在内存中,图像引擎可以根据与摄像机的距离决定使用哪个副本。
物理引擎
物理引擎是游戏引擎中负责距离、移动与其它游戏物理相关的部分。不是所有的游戏引擎都需要物理引擎。但是所有的图形游戏都在某种程度上有物理相关代码。
不相信吗?用“井字游戏”(tic-tac-toe)来举 个例子。确实是一个非常简单的游戏,但是即使这个游戏也有物理部分。当玩家选择一个正方形用来标记的时候,我们必须检查选择的正方形是否有效。如果是,我们就将打上标记并判断玩家是否获胜。这就是物理引擎所完成的两项基本任务的例子:侦测与解决。
碰撞侦测与碰撞解决
在你脑海中保持这两方面的独立性非常重要。在游戏代码中,侦测是独立于判定的。不是所有的物体与其它物体会以相同的方式发生碰撞,进而不是被侦测到的所有碰撞都会以相同的方式来解决。
例如,让我们假想一个游戏:O’Reilly野生冒险。假如玩家的虚拟人偶发现在自己无意间来到了O’Reilly野生保护区,它必须避免奇怪和危险的动物并回到安全的地方。
这个游戏中会发生以下几种物理交互:
1.玩家与地图的碰撞
2.动物与地图的碰撞
3.玩家与动物的碰撞
4.玩家与目的地的碰撞
第一种,玩家与地图的碰撞,非常简单。我们检测玩家的身体区域边界与关卡中的墙。如果玩家将与墙发生碰撞,我们稍微位移一下玩家,以使其不会与墙发生碰撞。
第二种交互稍微复杂一点。我们使用同样的侦测方法:检测动物的身体区域与关卡中的墙。但是我们的解决方法有一些不同,因为动物不是玩家控制,而是由电脑控制。我们解决情况1时,我们稍微位移一下玩家,以使其不会进入到墙里面。这是用来提醒玩家他正在撞向墙而且必须改变方向。
如果我们对情况2做同样的解决,AI不会认识到它正撞向墙,而且会继续走向墙里面。因此,我们分两步解决这种情况,首先稍微位移一下动物以使其不会与墙发生碰撞,然后通知AI动物撞上了一面墙。这样,游戏逻辑会控制动物改变移动方向。
第三种情况,我们首先检查玩家身体区域边界与动物身体区域。一旦我们侦测到了他们将发生碰撞,可能会发生不同的结果。如果这个动物是眼镜猴,他可能会逃跑;如果这个动物是毒蛇或者狮子,它可能会攻击玩家;骆驼可能会忽略玩家;蝗虫可能会被踩扁。
最后,第四种情况是一种新的情况。目的地与地图、玩家与动物是不同的,因为它没有图形表示。它是一个隐含的触发区域,当玩家踏入它
时,它会发出一个事件。幸运是,尽管它没有图形表示,它仍然具有物理表示。所以,我们依然可以侦测玩家的身体区域边界与目的地的区域边界。如果我们发现玩家到达了目标,就通知游戏逻辑,使其进入“玩家胜利”的游戏状态。
二维碰撞侦测
二维碰撞侦测是一个相对简单的处理过程。大部分都可以总结为如下的一段侦测常规:矩形对矩形、矩形包含点、圆对矩形、圆包含点与圆对圆(有可能也需要检查线段,不过那通常可以被避免)。
由于这些常规可能被每秒钟使用多次,因此确保它尽可能高效是非常重要的。为了达到这个目的,我们将会进行一系列低成本的测试来证明两个物体碰撞之前是没有碰撞在一起的:
- bool cd_rectangleToRectangle( Rect r1, Rect r2)
- {
- //can't be colliding because R1 is too far left of R2
- if( r1.x + r1.width < r2.x ) return FALSE;
- //can't be colliding because R1 is too far right of R2
- if( r1.x > r2.x + r2.width ) return FALSE;
- //can't be colliding because R1 is too far below R2
- if( r1.y + r1.width < r2.y ) return FALSE;
- //can't be colliding because R1 is too far above R2
- if( r1.y < r2.y + r2.width ) return FALSE;
- //if we get here, the two rects MUST be colliding
- return TRUE;
- }
尽管这样,当物体碰撞时,还是会有更多的代码被执行。大部分时候,物体都不会相互碰撞,我们针对于此进行了优化,因为它更有效率。
在我们继续之前,让我们看一个非常重要的概念:游戏对象的图形表示独立于它的物理表示。计算机每秒钟只能提供有限的计算总量,进行实时地物理模拟非常困难。正因为如此,游戏物理代码有一个悠长而骄傲的传统,那就是只要精确到让玩家觉得游戏正确即可。
比如,在我们上面提到的游戏中,我们玩家的虚拟人偶即将撞上一头犀牛(很明显,要么我们的玩家不太专心,要们就是被一只生气的母老虎追赶)。我们的玩家在跑动过程中,会有很多四肢动作,犀牛也是一样,它伸出它头上的牛角。
尽管玩家在跑动过程中,图形表示需要出玩家的手和脚,但是在物理表示中,我们真的需要知道四肢的具体坐标吗?我们真的需要去检查玩家的双手、双脚以及头是否与犀牛的头、脚、及牛角发生了碰撞(还有别忘记了尾巴!)?当然不,因为我们的游戏是按照我们的需要简单的用矩形表示玩家和用矩形表示犀牛。
三位碰撞侦测
三维碰撞侦测要比二维困难很多。你必须很熟悉三维数学计算,比如线形代数。而且,即使数学要求不高,3D游戏也拥有更复杂的游戏场景。幸运的是,你可以依赖合适的技术来帮助减少计算次数。当然,还是要精确到让玩家觉得游戏正确即可。
就像我们之前讨论的,一个物体的图像表示不同于它的物理表示。但是,有时我们需要确保他们之间的差别越小越好。想象一下第一人称射击游戏,我们不仅要知道一个玩家知否射中了另外一个玩家,还要知道他是否取得了一个爆头。很明显,一个简单的盒子边界不能满足需求,不过我们也无法提供对每一颗子弹都检查路径,判断其穿过的每一个虚拟人偶的每个三角形的检查。
我们使用同样的3D侦测,就像我们在2D中使用的侦测一样:在执行精确的与昂贵的测试之前,先执行一系列低成本的否定测试。以子弹为例,我们首先测试子弹有没有划过哪个玩家的边界框。如果没有击中他们,我们退出碰撞检查。对于击中了的边界框,找到起始击中点,并对更个中的所有三角形都做更多详细的测试。这是图形优化中的细节层次的物理版本。如果在最近的玩家身上的检查失败了,我们将会在下个玩家身上做详细检查。用这种方式,我们可以高效并准确的更复杂的场景,比如一颗子弹从一个玩家的双腿间飞过,击中了他身后的另外一位玩家。
碰撞解决
当碰撞被侦测到时,就要解决它。首先要考虑的是,什么样的低层次的或高层次的动作必须发生。低层次动作是指物理代码可以解决的,如调整玩家的位置以使其在地面上而不会摔到地面下。高层次的动作是指发送到游戏引擎的游戏逻辑部分的信号。这些信号可以让游戏逻辑知道一个动物何时否跑进了一堵墙里或者一个玩家是否到达了目的地。
有些碰撞响应需要同时多重高层次的和低层次的响应。例如,在弹钢珠游戏中,一个钢珠打到了防撞杆上,它应该正确的弹离防撞杆(低层次),也会同时向游戏逻辑发送一个信号,使得防撞杆产生动画效果、声音效果以及增加玩家的得分(高层次)。
当测试低层次的碰撞解决的代码时,要特别注意细节。最影响游戏感觉的重要元素之一就是物理代码。虚拟人偶可以快速的响应玩家的输入吗?赛车游戏能真实地模拟出悬挂系统与变速系统吗?当玩家发生大炮的时候,屏幕会抖动吗?
根本上,这是由设计人员的工作来让游戏感觉很好,但是这需要程序员的工作来实现它。不要害怕写一些特殊的测试用例以得到正确的结果。
例如,测试时常用的一项技术是为对象设置“flag”。当一个对象与另外一个对象“ground”接触时,你可以为这个对象设置一个“grounded” flag为true。当grounded flag为true时,可以认为这个物体在休息,不需要对其施加重力影响,或侦测它与地图的碰撞。这不但可以帮助你防止在一大堆静止物体上运行碰撞侦测,也可以避免在某些物理模拟钟,物体在地面附近产生抖动现象。
声音引擎
声音是游戏开发中经常忽略的一的环节,但是当你知道声音构成了人类玩视频游戏的1/3的感觉时,你肯定会很困窘。在合适的时机播放正确的声音使得程序员仅作很小的工作就可以为游戏增加分数。
基本的音效特征包括载入和卸载声音样本、播放声音、停止播放、暂停播放以及循环播放。典型的,所有的声音都可以以同样的音量播放,但是你可能希望玩家调整以使所有的声音都符合他们的喜好。
声音样本
在播放音效之前,你需要从文件或者缓存中载入声音样本。IPHONE API支持AAC与MP3格式的高品质的声音,也支持PCM和IMA4的小样品,以及其他一些格式。
播放声音
一旦载入了声音样本,API提供了函数以开始和停止播放样本。大多数API还提供了暂停和继续功能,还有一些允许你从特定点开始播放。虽然还有更多高级特性,但是基本上以上就是程序员全部所需要的了。
多声道声音
因为声音播放依赖于硬件,你在同一时间可以听到的声音是有限的。每种声音播放时都使用一个声道。在IPHONE中,所有的MP3和AAC样本共用相同的硬件声道,而且只有一个是可用的,多声道支持PCM和IMA4。
这意味着同一时间,只有一个MP3/AAC格式的样本可以被播放。典型的,但多个PCM/IMA4样本可以同时播放多个样本(同时与MP3/AAC播放)。
音乐与SFX
游戏中的大部分声音都可以分为两大类:环境音(典型的,背景音乐)和音效(SFX)。有时,用环境音代替音乐,但他们都共享同样的特点。
音乐一般是重复播放一个相对长的样本或者引出下一段音乐样本。通常情况下,只有一首音乐在同一时间播放,使得MP3/AAC格式限制变成了一个不成问题的问题。
声效要短的多,而且需要许多不同的样本,在同一时间重叠播放。PCM/IMA4格式很好的满足了这个需求。
由于PCM/IMA4也只有有限的声道数,因此如果你打算同时播放很多的SFX样本,其中一些可能不能被播放。所以,为SFX音效设定优先级以确保某些音效一定能播放就显得很重要了。
例如,让我们讨论之前提到的我们的玩家虚拟人偶走进了一个满是愤怒的犀牛的房间。每头犀牛都会播放愤怒的鼻息声,可能还会接着播放撞人的声音,而玩家虚拟人偶则会发出害怕的哭叫声。我们会给与玩家虚拟人偶声效更高的优先级,以使其不会被犀牛的撞人声效所淹没。
幸运的是,目前的IPHONE支持至少32声道,所以一般不太可能需要在这个平台上去刻意安排优先级。
输出设备与干扰
IPHONE支持内置的对讲机与听筒作为输出设备。建议不要同时使用它们。当玩家将听筒插入到IPHONE中时,音频会自动改为从听筒播放。
由于IPHONE是移动设备,有可能玩家在路上在的时候,听筒会掉落。一个好的设计选择是当听筒被移除的时候暂停游戏以给玩家足够的时间让他重新接入耳机。此时,你也可以选择同时提供停止播放声音。
最重要的是,应该从玩家的用户角度来考虑声效。玩你的游戏并不是用户使用IPHONE的唯一标准,所以不要让你的声效的优先级打扰到用户,否则玩家会将其关掉。
用户输入
游戏引擎的玩家输入部分集中于来接收自于操作系统的低层次的事件,然后将其转化为高层次的事件,这样,游戏逻辑代码可以在PC游戏中使用它。低层次的事件可以是鼠标和键盘事件。对于控制台游戏,他们可能产生于控制器的手、触发器与按钮。在我们的例子里面,IPHONE会处理触摸与旋转事件。
触摸事件
触摸屏接口的设计方式与其他大多数移动手机、控制台与PC平台的接口设计方式有着根本的区别。在用户触摸屏幕与应用程序接收到该事件之间有延迟(尽管IPHONE已经在将缩短延迟方面做的足够好了),但是真正的问题是,不管何时用户做出触摸动作,他的手指都会遮盖住部分屏幕,大大降低了游戏画面的可见性。
你可以通过提供一个图形按钮给用户来点击(回到了按钮点击系统,代价是屏幕空间),或者提供一个聪明的隐喻来解决这个问题。比如,如果玩家点击了屏幕上的一块空间,你可以让虚拟人偶朝那个方向走。这样可以省去用户的连续点击输入。
尽管用户接口设计是游戏设计人员的责任,但是编程人员需要告诉设计人员这个平台可以做什么和不能做什么。IPHONE支持以下触摸事件:
- 触摸开始
- 触摸移动
- 触摸结束
- 触摸取消
你可能会问什么情况下会触发“触摸取消”事件。当某个事件将你的应用程序挂起在“触摸开始”事件与“触摸结束”事件之间时,程序会收到“触摸取消”事件通知你不会收到其他的事件(如触摸结束)。
为了处理多点触摸,包含一个UITouch对象list的UIEvent对象被发送到你的应用程序中。如果只有一个指头触摸到屏幕,你只会接收到一个UITouch对象;如果两个指头触摸到屏幕,你会接收到两个UITouch对象等等。而且IPHONE可以追踪正在发生或最近发生的连续的5次轻击事件(一个触摸开始事件后紧跟一个触摸结束事件)。
不幸的是,通过以上事件来判断用户是单击、双击、扫过或者压缩动作会可能变得比较麻烦。虽然不是很困难,但是在一开始并不是很容易正确处理。看以下例子:
- Time 0: TouchStart - numTaps(0) numTouches(1) Touches { (40,40) }
- Time 100: TouchEnd - numTaps (1) numTouches(1) Touches { (40,40) }
- Handle single-tap..
到目前为止,用户触摸了屏幕一次,你的代码可以执行相应的单击处理逻辑。但是稍等!
- Time 0: TouchStart - numTaps(0) numTouches(1) Touches { (40,40) }
- Time 100: TouchEnd - numTaps (1) numTouches(1) Touches { (40,40) }
- Handled single-tap.. INCORRECTLY
- Time 200: TouchStart - numTaps (1) numTouches(1) Touches { (40,40) }
- Time 300: TouchEnd - numTaps (2) numTouches(1) Touches { (40,40) }
- FAILED to handle double-tap
用户第二次轻击了屏幕。如果你已经在收到触摸结束的事件时进行了处理,可能错误的处理了用户实际上的双击处理。
我们应该如何正确处理这种情况呢?解决方法是将第一次触摸结束事件推迟为定时回调。当第一次接收到触摸结束事件时,我们设置一个回调。如果我们在回调之前接收到了第二次触摸结束事件,我们可以判定用户进行了双击,并取消回调,执行双击处理。我们接收到了回调,我们认为用户没有进行双击并应该进行单击处理。
这里有表达两种情况的例子:
- Time 0: TouchStart - numTaps(0) numTouches(1) Touches { (40,40) }
- Time 100: TouchEnd - numTaps (1) numTouches(1) Touches { (40,40) }
- Initiate callback timer
- Time 200: TouchStart - numTaps (1) numTouches(1) Touches { (40,40) }
- Time 300: TouchEnd - numTaps (2) numTouches(1) Touches { (40,40) }
- Handle double-tap, cancel callback
这次,玩家进行了双击而且代码进行了正确的处理。
- Time 0: TouchStart - numTaps(0) numTouches(1) Touches { (40,40) }
- Time 100: TouchEnd - numTaps (1) numTouches(1) Touches { (40,40) }
- Initiate callback timer
- Time 500: Callback recieved
- Handle single-tap
现在,玩家进行了单击而且代码也进行了正确的处理。
注意,你不必为那些仅期待单击事件的接口加上这些处理。
侦测诸如扫过的动作会更麻烦一点,但也更容易正确处理。代码中必须为每次触摸保存起始点与终点,并算出用户划的线的方向是向上、向下、向左、还是向右。还要判断他的手划过的是否足够快。
解决高层次事件
一旦判定了用户执行的物理动作,你的代码必须能将它们转换为游戏逻辑组件可以使用的形式。具体怎么做需要依赖于你的游戏的上下文,但是这里有几种典型的形式:
- 如果玩家准备控制虚拟人偶,在玩家和游戏之间通常会有连续的交互。经常需要存储当前用户输入的表现形式。比如,如果输入装置为遥杆,你可能需要在主循环中记录当前点的x轴坐标和y轴坐标,并修正虚拟人偶的动量。玩家和虚拟人偶之间的是紧密地耦合在一起的,所以控制器的物理状态代表着虚拟人偶的高层次的状态模型。当遥杆向前拨动时,虚拟人偶向前移动;当“跳跃”按钮按下时,虚拟人偶跳起。
- 如果玩家正与游戏地图进行交互,那么需要另外一种间接的方式。比如,玩家必须触摸游戏地图中的一个物体,代码必须将玩家在屏幕上的触摸坐标转化为游戏地图的坐标以判定用户到底触摸到了什么。这可能只是简单的将y轴坐标减去2D摄像机坐标的偏移量,也可能是复杂到3D场景中的摄像机光线碰撞侦测。
- 最后,用户可能进行一些间接影响到游戏的动作,如暂停游戏、与GUI交互等。这时,一个简单的消息或者函数会被触发,去通知游戏逻辑应该做什么。
游戏逻辑
游戏逻辑是游戏引擎中是你的游戏独一无二的部分。游戏逻辑记录着玩家状态、AI状态、判定什么时候达到目的地、并生成所有的游戏规则。给出两个相似的游戏,他们的图像引擎与物理引擎可能只有细微差别,但是它们的游戏逻辑可能会有很大差异。
游戏逻辑与物理引擎紧密配合,在一些没有物理引擎的小游戏中,游戏逻辑负责处理所有物理相关内容。但是,当游戏引擎中有游戏引擎的时候,需要确保两者的独立。达到此目的的最好方式就是通过物理引擎向游戏逻辑发送高层次的游戏事件。
高层次事件
游戏逻辑代码应该尽可能仅处理高层次问题。它不应该处理当用户触摸屏幕时需要以什么顺序将什么描画到屏幕上,或者两个矩形是否相交等问题。它应该处理玩家希望向前移动,什么时候一个新的游戏物体应当被创建/移除以及当两个物体相互碰撞后应该做什么。
为了维持概念上的距离,处理低层次概念(诸如用户输入与物理引擎等)的代码应当创建高层次的消息并发送给游戏逻辑代码去处理。这不仅能保持代码的独立性与模块化,还会对调试有所帮助。通过查看高层次消息传递的日志,你可以判定是没有正确处理消息(游戏逻辑代码的问题),还是没有在正确的时机传送消息(低层次代码问题)。
一个非常基本的传递高层次消息的技术是写一个String并传递它。假如玩家按下了上箭头键,它的虚拟人偶必须向上移动。
- void onPlayerInput( Input inputEvt ) {
- if(inputEvt.type == IE_KEY && inputEvt.value == KEY_UP ) {
- g_myApp->sendGameLogicMessage( "player move forward" );
- }
- }
虽然上面的代码对程序员来说通俗易懂,但对于电脑来说却并不高效。它需要更多的内存与处理,远比实际需要的多。我们应该用提示来替代用户输入方法。比起一个字符串,它使用一个"type"和"value"。由于可能的事件都是结构化的和有限的,因此我们可以使用整数和枚举类型来我们消息中的事件信息。
首先,我们定义一个枚举类型来标识事件类型:
- enumeration eGameLogicMessage_Types {
- GLMT_PLAYER_INPUT,
- GLMT_PROJECTILE_WEAPON,
- GLMT_GOAL_REACHED,
- };
接着我们再创建一个枚举类型来标识事件的值:
- enumeration eGameLogicMesage_Values {
- GLMV_PLAYER_FORWARD,
- GLMV_PLAYER_BACKWARD,
- GLMV_PLAYER_LEFT,
- GLMV_PLAYER_RIGHT,
- GLMV_ROCKET_FIRED,
- GLMV_ROCKET_HIT,
- };
现在我们定义一个结构体来存储我们的消息数据:
- struct sGameLogicMessage {
- short type;
- short value;
- } Message;
现在,我们就可以像上一个例子代码一样,用一个对象来传递我们的消息:
- void onPlayerInput( Input inputEvt ) {
- if(inputEvt.type == IE_KEY && inputEvt.value == KEY_UP ) {
- Message msg;
- msg.type = GLMT_PLAYER_INPUT;
- msg.value = GLMV_PLAYER_FORWARD;
- g_myApp->sendGameLogicMessage( msg );
- }
- }
这看起来作了更多的工作,但它运行起来会更有效率。前一个(坏的)例子用了20个字节来传递消息(20个字符各占一个字节,别忘了终止符)。第二个例子只用了4个字节来传递同样的消息。但是更要的是,当sendGameLogicMessage()处理方法的时候,它只需要分析两个switch语句就可以找到正确的响应,而前一个例子则组要从字符串进行解析,速度很慢。
人工智能
游戏逻辑的另外一个职责就是管理AI代理。两类典型的游戏需要用到AI系统:一种是玩家与电脑竞赛;另外一种是在游戏世界中有半自主系统的敌人。在这两种情况下,AI代理为游戏世界中的物体的动作接受输入并提供输出。
在第一种类型游戏里,AI被称作专家系统。它被期待用来模拟理解游戏规则的人的行为动作,并可以采取具有不同难度的策略来挑战玩家。AI具有与玩家类似的输入与输出,可以近似的模拟玩家的行为。由于人类比现在的AI代理更擅长处理复杂信息,有时为专家系统提供的输入信息要多于给玩家的,以使AI系统看起来更智能。
例如,在即时战略游戏(RTS)中,战争迷雾用来限制玩家的视野,但AI敌人可以看见地图上所有的单位。尽管这样提高AI对抗更高智慧玩家的能力,但是如果优势变的太大,会让人觉得AI在作弊。记住,游戏的重要点是让玩家获得乐趣,而不是让AI击败他们。
在第二种类型的游戏中,可能有许多AI代理。每一个都独立,其不是非常智能。在某些情况下,AI代理会直接面对玩家,而有些可能是中立状态,甚至还有一些是前面两种状态的结合。
有些代理可能是完全愚笨的,提供特定的、有限的行为而且并不关心游戏世界中发生的事情。在走廊里面来来回回走动的敌人就是一个例子。有些可能是稍微有些愚笨,只有一个输入和一个输出,比如玩家可以打开和关闭的门。还有一些可能非常复杂,甚至懂得将它们的行为组合在一起。为AI代理选择恰当的输入允许你模仿“意识”和增加现实性。
不论AI代理有多么简单,一般都会它们使用状态机。例如,第一个例子中的完全愚笨的物体必须记录它在朝哪个方向走动;稍微愚笨的物体需要记录它是开的状态还是关的状态。更复杂的物体需要记录“中立”与“进攻性之间的”动作状态,如巡逻、对抗与攻击。
透明的暂停与继续
将游戏视作具有主要游戏状态的模拟是非常重要的。不要将现实世界时间与游戏时间混淆。如果玩家决定休息会儿,游戏必须可以暂停。之后,游戏必须可以平滑的继续,就像任何事情都没有发生一样。由于IPHONE是移动设备,保存与继续游戏状态变得尤其重要。
IPHONE上,在一个时间点只允许一个应用程序运行,用户也希望这些应用程序能够很快载入。同时,他们希望能够继续他们在切换应用程序之前所做的事情。这意味着我们需要具有在设备上保存游戏状态,并尽可能快的继续游戏状态的能力。对于开发游戏,一项任务是要求保持现在的关卡并可以重新载入它使玩家即使在重新启动应用程序后也可以继续游戏。你需要选择保存哪些数据,并以一种小巧的、稳定的格式将其写到磁盘上。这种结构化的数据存储被称为序列化。
根据游戏类型的不同,这可能比听起来要困难的多。对于一个解谜游戏,你将仅需要记录玩家在哪个关卡、以及现在记分板看起来是什么样的。但是在动作类游戏中,除了记录玩家虚拟人偶之外,你可能还需要记录关卡中的每个物体的位置。在一个特定时间点,这可能变得难以管理,特别是当希望它能够很快完成。对于这种情况,你可以在游戏设计阶段采取一些措施以确保成功。
首先,你必须决定什么东西是在保存游戏状态时必须保存的。火焰粒子系统中的每根小火苗的位置并不重要,但是在粒子系统的位置在大型游戏中可能很重要。如果它们能从关卡数据中获得,那么游戏中每个敌人的状态可能并不重要。用这种方式进一步考虑,如果你可以简单的让玩家的虚拟人偶从check point开始的话,那玩家虚拟人偶的确切状态与位置也可能不需要保存。
基于帧的逻辑与基于时间的逻辑
基于帧的逻辑是指基于单独的帧的改变来更新游戏物体。基于时间的逻辑虽然更复杂但却与实际游戏状态更紧密,是随着时间的流逝而更新游戏物体。
不熟悉游戏开发的程序员总是犯了将基于帧的逻辑与基于时间的逻辑混合的错误。 它们在定义上的区别是微妙的,不过如果处理不得当,会造成非常明显的BUG。
比如,让我们以玩家移动为例。新手程序员可能写出这样的代码:
- void onPlayerInput( Input inputEvent ) {
- if(inputEvt.type == IE_KEY && inputEvt.value == KEY_UP) {
- //apply movement based on the user input
- playerAvatar.y += movementSpeed;
- }
- }
每当玩家按下按键,虚拟人偶像前移动一点。这是基于帧的逻辑,因为每次移动的变化都会潜在的伴随着新的帧。事实上,在这个的例子中,每次玩家输入事件都会发生移动。这或多或少有点像主循环的迭代。移动的可视化影响只有在主循环的下次迭代中才会反映,所以任何迭代中间的虚拟人偶移动都会浪费计算。让我们做一下改进:
- void onPlayerInput( Input inputEvent ) {
- if(inputEvt.type == IE_KEY && inputEvt.value == KEY_UP) {
- //save the input state, but don't apply it
- playerAvatar.joystick = KEY_UP;
- }
- if(inputEvt.type == IE_KEY_RELEASE) {
- playerAvatar.joystick = 0;
- }
- }
- void Update() {
- //update the player avatar
- if( playerAvatar.joystick == KEY_UP ) {
- playerAvatar.y += movementSpeed;
- }
- }
现在我们知道,在键被按下的过程中,每次游戏循环中都只会被赋予一次速度。但是,这仍然是基于帧的逻辑。
基于帧的逻辑的问题是,帧变化不会总是以相同的时间间隔发生。如果在游戏循环中,渲染或者游戏逻辑会比通常耗费更多的时间,它可能会被推迟到下一次循环中。所以,有时你需要有60帧每秒(fps),有时,你只需要30fps。由于移动是适用于帧的,有时你只会以通常的一半速度来移动。
你可以用基于时间的逻辑来准确的表达移动。通过记录自从上次帧更新的时间,你可以适用部分移动速度。用这种方式,你可以以每秒为单位来标识移动速度,而不必关心当前帧速率是多少,玩家虚拟人偶的速度是一致的:
- void Update( long currTime ) {
- long updateDT = currTime - lastUpdateTime;
- //update the player avatar
- if( playerAvatar.joystick == KEY_UP ) {
- //since currTime is in milliseconds, we have to divide by 1000
- // to get the correct speed in seconds.
- playerAvatar.y += (movementSpeed * updateDT)/1000;
- }
- lastUpdateTime = currTime;
- }
在这个例子中,移动速度的总量将会是相同的,不管是2fps还是60fps。基于时间的逻辑需要一点额外的代码,但是它可以使程序更精确而不必在乎暂时的延迟。
当然可以用基于帧的逻辑来开发游戏。重要的是,不要混合它们。比如,如果你的图形代码使用基于时间的逻辑来渲染玩家虚拟人偶的移动动画,但是游戏逻辑代码却使用基于帧的逻辑在游戏世界中来移动它,这样移动的动画将不能玩玩家移动的距离完全同步。
如果可能的话,请尽量移除基于帧的逻辑。基于时间的逻辑将会对你有更大的帮助。
游戏逻辑组织结构
游戏逻辑代码的核心功能就是管理游戏状态的规则与进度。根据你的游戏设计,这可能意味着任何事情。但是,还是有一些基本模式基于制作的游戏的类型。
游戏逻辑不与任何一个特定的类相关联,它游戏状态对象中表现出来。当主游戏状态被初始化后,它将会为关卡载入与初始化必要的资源。例如猜谜游戏中的一组提示与单词、玩家虚拟人偶的图片数据以及玩家当前所在区域的图片数据。在游戏循环中,游戏逻辑将会接受用户输入,运行物理模拟,并负责处理所有的碰撞结局消息,模拟AI动作,执行游戏规则。最后,当应用程序需要终止主游戏状态,它会释放释放所有的游戏资源,并可能将游戏状态保存到硬盘驱动器上。
根据游戏的复杂度,你可能会发现很方便进一步分解游戏逻辑。比如,如果你在开发一款冒险游戏,你可能有一个充满环境数据(地面、建筑、河流、树等)、可以移动、与玩家交互的实体(玩家虚拟人偶、敌人、非玩家角色、开关、障碍物等),各种GUI使玩家作出特殊动作和显示重要信息的游戏世界。每种游戏特征都必须有大量的代码。虽然它们合在一起才能组成完整的游戏,但是你还是可以保持它们的工作模块化。
你可以创建一个Level Manager类来处理游戏关键,包括载入和卸载显示在游戏世界中的物理与图像数据与调用游戏引擎来侦测实体与游戏世界的碰撞。你还可以创建另外一个类或者一些类来处理游戏世界中存在的实体。每个类都载入和卸载渲染那些物体的必要的物理和图片数据,以及包括控制它们的AI。
最后,你可能创建另外一个单独的类来处理游戏中用户交互,以保持代码与三大概念独立。
这个体系结构适用于任何类型的游戏。首先评估游戏设计的主要特性,接着以某种方式组合,将相近的功能与数据组合在一起。
总结
你应该对创造一个游戏引擎时必须完成的任务有了一个基本的理解。这将会帮助我们在下一节创建这些元素,为我们的游戏做准备。