这是意念自己的毕业论文,在一个具体的实践之中,意念主要负责的是物件和GUI之外的其他游戏系统。意念才学疏陋,望众位前辈不吝赐教。由于代码质量不高、环境很难于配置、资源包过大等问题,意念暂先不提供代码和程序,未来有时间的时候组织一下这些曾经的代码,再向外发布。
文过三月,也有些新的想法,以后会慢慢跟大家聊的,欢迎拍砖哦^_^。
关键字与术语:
游戏、游戏引擎、高层引擎、规则、场景、物件、Terrain(地形)、解释器、Application Framework(应用程序框架)、GUI(Graphics User Interface 图形用户界面)、Manager(在本文中特指管理器)、触发器、设计模式(Design Patterns)、Singleton(单件模式,一种设计模式,使某个类在某个程序的生存期内,有且只有一份实例,而且可以在任何时候得到这份实例。)、Adapter(适配器模式,一种设计模式,将一个类的接口转换成客户希望的另外一个接口)、Factory(工厂模式,一种设计模式,提供一个创建一系列相关或相互依赖对象的接口,而无需指定他们具体的类)、Thanatos(死本能,代表恨与破坏的力量。死本能投射于外,则表现为求杀的希望,表现为侵犯和仇恨的根源;如死本能外投受挫,则为“自杀倾向”,包括自我谴责,自我惩罚,对敌手的嫉妒和对权威的反抗等。)、OGRE(Object-Oriented Graphics Rendering Engine)面向对象图形渲染引擎。
Abstract:
The abstraction of game engine is an important question in game programming, and the kernel of the question is “How could we give the game engine more adaptability?” In the paper we attempt to deduce the form which our game engine must be from our daily-lives. With the help of OGRE, I completed the game engine which has the form that we have just deduced.
The major questions of this paper are: First, why we make game engine? Second, how we make a game engine? The first question was answered in Part1, while the second in Part3. And the Preview gives us a theoretic conclusion to the second question. At the end of this paper, we used the game engine which has just been completed to make a simple game.
The main idea of the paper is to take game engine as a combination of the high-level game engine and the low-level game engine. We use the high-level game engine to support game logic, and low-level game engine to support device and platform API.
摘要:
游戏引擎和框架的抽象一直是游戏制作中的一个关键问题,其核心问题是如何令抽象好的引擎具有更好的适应性。本文尝试使用演绎法从我们所生活的世界推导出了高层引擎为了适应游戏需要所需要保持的形态,并通过实做利用OGRE完成了满足这个形态的一个简单的高层引擎。
本文的关键问题有两个:一是为什么要有游戏引擎,二是我们怎样来构架一个游戏引擎。在第一部分和第三部分我们分别回答了这个问题,绪论则作为对第二个问题所进行的演绎和推导。在本文的最后,我们利用完成的游戏引擎制作了一个简单的游戏。
游戏引擎应分为为高层的逻辑提供支持和为底层的功能提供封装的两个部分,这是本文的中心论点。
引擎概述
曾经有一段时期,游戏开发者关心的只是如何尽量多地开发出新的游戏并把它们推销给玩家。尽管那时的游戏大多简单粗糙,但每款游戏的平均开发周期也要达到8到10个月以上,这一方面是由于技术的原因,另一方面则是因为几乎每款游戏都要从头编写代码,造成了大量的重复劳动。渐渐地,一些有经验的开发者摸索出了一条偷懒的方法,他们借用上一款类似题材的游戏中的部分代码作为新游戏的基本框架,以节省开发时间和开发费用。于是就慢慢产生了游戏引擎。人对于游戏引擎的概念是逐步深入理解的,这个过程类似于其他技术的进步过程——毕竟游戏引擎也是一个程序。这个理解所立足的就是对“封装性”的理解。实际上在引擎这个概念下面更多的是每个人对引擎各自不同的理解:游戏引擎只是一个说法,至今为止没有一个公认的定义。
近几年一部分初学者所理解的引擎是“对底层功能的简单封装”,这个底层功能包括平台API、渲染API、音频API、流媒体API等,这样的引擎往往是一种C语言时代的思路,其划分是来自于各个不同部分之间的“功能”关系,而非“逻辑”关系。经典概念包括:渲染核心、内存管理、骨骼动画、帧动画、文件操作、物理库、网络库等等。这个在广为传诵的网文《游戏引擎剖析》(参考4)里面有最为明确的体系划分:
1、“渲染和构造3D世界,3D环境的光照和纹理”。渲染永远是引擎最具有技术含量的部分,就不说那动辄千百块钱的图形卡了,单是图形渲染相关技术的进步速率,就已经足以让人瞠目结舌了。“什么是渲染器,为什么它又这么重要呢?好吧,如果没有它,你将什么也看不到。它让游戏场景可视化,让玩家/观众可以看见场景,从而让玩家能够根据屏幕上所看到的东西作出适当的决断。”渲染所需的主要底层功能就是来支持OpenGL和DirectX的最新技术。由于这些技术不断更改,导致渲染器的更新换代也相当明显。好在OGRE本身就是一个很巧妙的渲染器,它为我们隐藏了很多渲染器的复杂性,让我们可以用近乎自然语言的方式来进行图形处理。
2、“内存使用,特效和API”。图形研究到高层次就不得不考虑到芯片的一些特性:例如显存和内存管理、Shader和其它重要的参数。这也是属于引擎必须染指的内容。
3、“模型与动画,细节级别LOD”。游戏引擎应该支持常见的模型文件格式并很好地渲染他们,如果游戏引擎需要用到自己的数据格式,那么它需要为几个主要的模型文件格式做导出插件,以满足美工的需要。
4、“物理,运动,效果”。物理系统可以让游戏尽可能地逼真。“作为游戏开发者来说,无论我们做什么,我们需要能够检测墙壁,检测地板,在世界中处理和其他对象的碰撞。这些是现代游戏引擎的必备。”先进的物理系统如ODE,可以在保证效率的前提下精确处理物理和运动学理论和公式,其中甚至包括流体力学。
5、“声音系统,音频APIs”。耳朵也是人的一个重要的感觉和信息获得器官,这一点应该很好理解。
6、“网络和连线游戏环境”。网络游戏必备。如今大多数真正有长久生命力的游戏都至少有一些连线成分。“最纯粹的单人游戏容易玩一次,也许两次,或者甚至三次如果它是非常好的游戏,但一旦游戏结束,就被束之高阁了。如果你想要有任何长久生命力,那么多人连线游戏就是形势的核心所在。”
7、“脚本系统”。你可以把游戏脚本认为是电影脚本,它们两者实质上是相同的。
8、“人工智能和导航”。
当按照这个思路建立了自己的引擎后,我们的引擎只是一个功能引擎,它没有任何逻辑关系。包括场景、地图、物件、规则等一系列游戏逻辑所直接相关的东西,它都没法直接提供。这个时候我们所具有的引擎大约是如同下图所示:
高层引擎概述
我们拿2D地图来做一个例子,在这样的引擎思路下,地图只是诸多图元的拼接、Blt(发音Blit,位图位块传输)和互相遮挡。这个思路确实反映出来了地图的本质,但是对于游戏逻辑来说,它太细了。因为游戏逻辑是不需要管你地图图元如何拼接、Blt和遮挡的。下图左就是针对这种设计思路的,而下图右则是提供了高层引擎的设计思路。通过对比可以发现,右边的设计思路更符合OO的封装原则,而左边的主要是比较古老的过程式填鸭。
图1-2 左边是直接在应用程序里硬编码底层功能,右边是在应用程序和底层引擎之间建立一个抽象层,有这个抽象层划分和承担游戏的基本逻辑。在OO大行其道的今天,你会用哪一种方法? |
而在这里我们理解的引擎除了功能元素之外,同时包括一些逻辑意义的部分,即部分开发者交流中所说的“游戏层引擎”或“高层引擎”,为何会存在这部分引擎呢?答案是为了方便我们表达游戏的上层逻辑。底层游戏引擎所立足的都是平台API,是与API严格相关的。目的就是为了要让外界看不见API,专心做外界的逻辑部分,但底层引擎只完成了一个目的就是通过封装API来完成一定功能,封装好的API是否就表明一定适应上层逻辑的要求呢?这根本不可能,因为它不是为了这个目的而存在的,例如骨骼动画和上层逻辑有什么关系呢?因此人们又提出了高层引擎的概念。这就回答了刚刚的问题,骨骼动画是应当包含在物件逻辑内部实现的,对外部应该是透明的。如果游戏逻辑需要细化到“谁谁谁,按照骨骼动作‘Walk2’来行走”,那就太麻烦了,这种情况下,比较普遍的做法是我们由来实现一个物件,然后为其设置一种状态叫做STATE_WALK2,在物件自己的逻辑里面当发现物件是处于这种状态的时候就开始引发“Walk2”动作,这样,最后的游戏逻辑只用简化到说“那个谁,向前方走一步”就可以了。实际的处理是,引擎层获取到了这个消息以后,向物件“谁”发送一个TranslateState(“走”)的消息,而物件“谁”获得这个消息后,根据当前状态自动进行状态机的切换。对于逻辑的开发者来说,这一切都是封装好的,透明的,他们只需要知道“当我说‘A向前走’,A就会向前走”就可以了,这样的引擎就不再简简单单是功能平铺的平房,而是具有一定逻辑保障的大厦了。STATE_WALK2到Walk2的对应关系在不同游戏引擎里面可以通过不同方式实现,最初也是最简单的方法是硬编码(Hard-Code),这种方法速度快,然而牺牲了程序的维护性,会给测试带来很大麻烦。现在,大部分的游戏引擎可以通过配置文件甚至是编辑器来解决此问题,以及与此类似的问题,这种数据驱动的方式使编码逻辑更加简单,同时也使设计者和导演工作更加方便。
下图是我们使用一款外国引擎的编辑器时的场面,在这个编辑器里面,既有物件编辑器,也有场景编辑器,同时也包括脚本——这个编辑器里用它来实现我们所说的规则——的编辑器:
图1-4 看着很像3DMax的一款游戏编辑器,中国目前大部分游戏 |
把话题引回来,对比前面我们得出的结论,做一个游戏,实际上就是在做场景(地图+物件)、规则系统、GUI系统和I/O控制系统。那么我们该怎么做呢?构建一个过于集中的,把所有功能都实现了的高层系统,只会降低高层引擎的可适应性,因此属于高层引擎更多的是对它们提供支持,这些支持包括:基本数据结构和组织方式(例如物件链表及查询操作、特殊的文件数据)、工具集等。通过这一层的存在,最高层逻辑只需要写:在场景中放置几只飞鸟,按照Sin函数路线飞行。至于飞鸟飞行中是怎么振翅,怎么偏航,这是在物件系统的具体物件类——这里是飞鸟——里可以决定的。为了最终产品的逻辑需要,我们迫不及待的需要一个“高层游戏引擎”,这是源自于一个很重要的思想,同时也是软件工程的基础思想:“软件产生于需求”。底层引擎层次的划分完全来自于平台和API的限制,因为毕竟我们要做的游戏必须跟某一个平台相关。而高层次的引擎结构则是跟需要达到的目的严格相关的,因为这是它的存在动机。
实际上现在大部分引擎都是或多或少地包括了高层引擎部分的,然而高层引擎的划分却并不容易,大部分引擎所面向的还是FPS这种游戏类型,做一款普遍适应的引擎是难上加难,因为不同游戏所需要的高层不一样。
我们这篇文章的基本目的,就是试验当拥有一个现有的底层引擎的时候,如何构建一个高层引擎,以及如何让这个高层引擎具有更强的适应性。
现在我们具有的引擎构造大抵如下:
OGRE(Object-oriented Graphics Rendering Engine,面向对象的图形渲染引擎),是国际上比较知名的开源图形渲染引擎。OGRE是用C++开发的面向对象且使用灵活的3D引擎。它的目的是让开发者能更方便和直接地开发基于3D硬件设备的应用程序或游戏。引擎中的类库对更底层的系统库(如:Direct3D和OpenGL)的全部使用细节进行了抽象,并提供了基于现实世界对象的接口和其它类。
OGRE系统主要包括:Render系统和Render插件、Material系统和Material脚本、Entity(主要是物件系统)、GUI系统和Overlay脚本、Texture和图片解码器、Archive系统和文件解码器、Scene插件(主要是地形系统)、粒子系统、日志、Dll动态导入和插件系统等等。而最后所有的系统全部归一个总管管理,这个总管就是Ogre::Root。
下图是Root的关联关系, Root是整个OGRE的核心部分,它关联着其他所有的组件,并把这些组件封装其中。
图2-2 OGRE核心部分框图(引用自OGRE的开发框图)
图2-2是整个OGRE的核心成分框图。下面我们引用Mage小组的《Ogre使用指南》里对这个框图的描述:
“Root:整个Ogre系统的入口点和管理器,它必须第一个被创建,最后一个被消毁。通过Root对象你可以配置系统,还可以获得系统内的其它对象。
RenderSystem:3D API的抽象层,它负责设置所有的渲染属性,并调用3D API执行渲染操作。
SceneManager:场景管理器,它负责组织组织场景,包括场景中Material、Light、Movable Object(Entity)和场景本身。
Material:定义场景中几何体的表面属性。
Entity:场景中的可运动物体。
SceneNode:代表位置和方向,Attach到SceneNode上的Entity可以继承其位置和方向。场景中的SceneNode以树的形式来组织。
Camera:场景中的视点。”
使用OGRE很简单,因为OGRE提供了自己的Application Framework,如果有兴趣可以参考一下附录里面一段使用OGRE Application Framework的标准代码:(示例来自OGRE自带的地形Demo:Terrain)
但细细分起来使用Application Framework开发,需要写的代码主要还是集中在下面三个方面:初始化、处理输入以及运行时帧循环。大部分时候,我们所需要做的主要是初始化和输入处理,而对于帧循环几乎不必要改动。
既然Application Framework本身封装的就这么好,为什么不能直接使用Application Framework来做游戏呢?来看看Application Framework里面都是些什么吧:
图2-3 Ogre Application Framework 工程
我们从中可以看到高层的封装包括有:一些物件类(AppBox、AppBall等)、物件碰撞检测支持(ODE,一个国际知名的开源物理引擎)、以及一个简单到不能再简单的RefAppWorld(这里面的World类似于我们前面讨论的场景系统),这种封装很难以满足我们的要求。不过看来OGRE的作者似乎也察觉到了OGRE本身的场景系统不能满足需要,因此在Application Framework中又重新构建了我们意义上的场景系统,这种场景系统对于单个场景的演示和Demo是够用了,但是在游戏普遍要求的多场景、甚至是大量场景前面,这种构架似乎又缺乏说服力。而且把ODE和OGRE放在这一层次硬性结合实际效果也并不好,经常出现碰撞检测错误导致穿墙、撞飞的尴尬场面。曾经我们准备在其基础之上建立自己的游戏框架,最后发现越来越陷入到OGRE为我们框死的条框之中。毕竟OGRE是为了OGRE开发者的目的而开发的,不是为了游戏而开发的,更不是为了我们的目的而开发的。
现在,我们准备彻底抛弃这个构架,转而制作自己新的构架,来亲手实践前面纯理论推导的“高层引擎”。当然,我们用的思路也并不超前,同样是Application Framework中已经利用过的“场景=地形+物件”思路,但是我们的构架需要考虑到更多的情况,因为我们所要做的游戏并不只是“第一第二世界”(场景),同时它还包括“第三世界”(规则),这一点是OGRE没有重点支持的。
第三部分 实作:基于OGRE图形引擎的游戏框架
图1-4 按照现在的划分诞生的高层引擎层的基本框架(第三部分所有图片)
3.1 场景系统 OGRE场景体系的分离和重新合成
首先我们发现,OGRE场景系统似乎现在和我们所理解的场景系统有点不合。OGRE是用一种渲染方面的理解来考虑场景的,而作为一个游戏似乎需要考虑得更远。“游戏需要渲染,但游戏不仅仅是渲染”。
要融入OGRE图形系统,需要程序结构和习惯的调整,而且所写的所有代码都需要受限于OGRE,以至于我们依照OGRE来写的高层游戏引擎很有可能会成为离开了OGRE就什么都做不了的东西。而且即使我们不离开OGRE框架,那么当OGRE以后翻新版本、做大的体系调整的时候,我们所做的高层游戏引擎也需要作极大调整,这当然不是我们想看到的。高层引擎是立足于需求的,OGRE底层改动了,只要需求没有改动,就应该保证高层引擎尽量不要改动,这首先是软件工程的原则。
怎么办呢?我们先从表面上来推导一下OGRE引擎与我们前面的层次化引擎体系的接合关系。
在现有的接合下,我们有很多框架安全方面的问题都没有考虑到,如果OGRE中的某个组件迫使我们更改上层架构,那将是危险的事情,因为上层架构即游戏逻辑不是为了OGRE而存在的,应该把这些事情都封装到底层来做。我们最希望的是让最终使用这个框架的人看不到一点跟OGRE相关的东西,他只需要考虑他自己的东西:游戏逻辑。就是为A送B一封情书会怎么样,以及D被C的车撞了一下会怎么样诸如此类的问题,如果在这最高层还迫使使用者考虑OGRE——把C和D的包围盒进行检测——那么只能说我们没有划分好、搭建好我们的引擎,换句直接一点的话说,我们的实作以失败告终了。这是我们对自己所做框架的最起码要求。因为只有当高层引擎留不下底层引擎的一点痕迹,我们最上层的需求和最底层的平台才是被高层引擎完全隔绝的,也就是说,无论底层平台如何变更,具体游戏逻辑是不需要改动的,需要作出改动的只是高层引擎。如图:
如图,理论上,高层引擎将底层和应用层完全隔离,对底层的修改将牵动高层修改,但不会牵动应用层的修改。这对于引擎是很关键的,当引擎改动的时候,如果使用这个引擎的所有应用层都需要修改的话,那么不知道全世界会有多少工作室、甚至是公司会发出鬼哭狼嚎的叫声。因此,模块化、层次化的思路早就是软件工程界的一个共识。
在我们现有的划分下,高层引擎需要完成下面的工作:
图3-2 基本的的高层引擎结构
我们把OGRE本身提供的功能列举一下,全部提供的用黑色块,部分提供的用浅绿色块。
图3-3 基本的的高层引擎结构与OGRE的切和关系
在这个划分中,我所负责的主要是地形系统、地形、场景和规则系统,而GUI和I/O控制系统、物件和物件系统、应用程序主要由另一位同学负责。在这里我主要也只讲述场景、地形、地形系统和规则系统。
场景:游戏的舞台
场景中,舞台是地形系统所支持的,而赋予场景生机活力的则是物件系统。物件系统和场景系统间的组织是有所联系的,例如超大场景管理器和普通室外场景管理器所要求的物件系统数据结构也是不一样的,前者由于可能存储海量的物件,因此可能对物件做分区处理;而后者则不同,因此可能会用统一的一张表(Map)或者哈希表(HashMap)来管理。物件和地形系统的相关性,可以在场景这一层次来解决,当场景调入的是这样的地形系统,它就需要调入合适的物件系统。什么样的物件系统最适配于某某地形系统?这是一个仁者见仁智者见智的问题,没有唯一的答案。物件系统最耗费效率的无非两点:自身逻辑和搜索算法,物件系统每一帧都会走自己的逻辑,而且外界经常会从物件数据结构里索引某一个具体物件,甚至是一帧索引十几遍物件,这两者对于物件系统的数据结构都有很高要求。
OGRE对于地形的支持比较庞大,实际上OGRE本身是没有具体的地形系统的,但我们可以通过写Plugin为原有的OGRE系统增添帮助。现有的几个Plugin包括:BSP管理器(plugin_BspSceneManager)、超大场景管理器(plugin_NatureSceneManager)、和我们这次用来作试验的四叉树室外场景管理器(plugin_OctreeSceneManager)。OGRE由于抽象度很高,因此在高层的代码层面上几乎察觉不到各个之间的区别,这当然方便了我们的抽象。只是OGRE地形系统是集成在Root里面的,没法随便打破,这样,我们所提供的地形系统相当于一个“壳”,只是重新封装了OGRE的场景管理功能,这就是设计模式中的Adapter(适配器)模式。
因此,这次所写出的Terrain就相当于OGRE::SceneManager之上的一层Adapter,基本上没有什么新的功能,这也是在图2-3中说这个系统已经是OGRE完全处理的原因。
Scene的一个功能是用来管理Terrain的,这一般发生在多Terrain的情况下,需要对诸多的Terrain资源统一管理,Scene掌管Terrain的生杀大权,正如舞台的形态决定了布景如何摆放一样。实际上OGRE::SceneManager中也有一部分功能是用来做这些事情的。由于需要的功能比较少,因此Scene掌握了下面这些基本方法:包括载入Terrain、销毁Terrain和更换Terrain等。
利用Adapter模式,将Terrain上升为一个接口类,以后无论OGRE内部对于SceneManager的变动有多大,Terrain由于是接口只需要更改接口的实现就可以了。而Scene则成为了这一部分的管理类,与底层OGRE在逻辑上无关。至此我们Scene-Terrain结构的简单场景系统就算是构架完毕,现有的这一部分类和接口如下:
关于Scene的另一个重要部分物件系统,由另一位同学向大家细细说明,这里只是稍稍提一下一些基本的物件设计思路。前面说过,物件是一个比较难于划分的体系,因为物件的属性比较多,而且无论何种属性都可以成体系。例如“生物体还是非生物体”、“生命期长还是短”等等。举个例子来说,对于一般生命期比较长的物件来说,可以按照Map或者Vector来存储,这样由于不会经常从数据结构中调入调出,而且查询算法又相对要快,使得这种数据结构显得比较有优势;但是生命期非常短的物件就不同了,例如子弹碰到墙上溅出的火花,火花的存在时间往往在1/10秒一级的,而且同时可能出现很多火花,如果用Vector或者Map,那将是一件非常恐怖的事情,且不说疯狂调入调出会有多大的时间损耗,本身火花根本就没必要对其进行查询操作,Map和Vector相对于List的唯一优势就此不复。因此对于这种生命期非常短的物件,用List就比用Map等数据结构优势要明显。这个划分仅仅是来自于“生命期长短”这个属性,而物件所具有的属性何止着一种呢?!即便是都按照Map或者MultiMap存储,也有按物件名称存储、按物件属性存储,等等很多种存储方式。如何抽象一个适用于游戏的物件系统,这是很多人心目中共同的问题。关于这个系统也有很多现行的方法,但是很难统一,毕竟物件的规则体系太复杂了。
规则:脚本系统
规则系统虽然并不难划分,但却是一个比较难于把握的系统,如前所述,规则系统是一个肉体所无法感觉到的世界,这样,只能用意识去感知的这个世界就充满了诸多变数。实际上规则系统并不是一个成形的系统,而是所有“游戏逻辑”的统称。这些逻辑或自成系统,或分布在其它系统内,构成了一个游戏严密而严谨的逻辑体系。
从功能上理解这个系统是一个普遍的方法,因为无论规则是多么多变,最终我们需要关注的那些总是会对感官世界产生影响,这个影响就是这些规则的功能。但这种划分办法并非是规则系统构建的全部,而仅仅是一种方向。用白话文说就是:“无论你怎样划分这个系统,最后只要完成这个功能就可以了。”
在做引擎的时候,很少有人会知道这个引擎会用到哪里,更不用说引擎应该满足哪些逻辑和哪些功能了。因此这些功能大部分是最后开发者拿到了引擎开始写游戏程序的时候才会考虑到的。对于引擎开发人员来说,它无形、充满变数,因此这是规则系统难于把握的重要原因。大部分游戏逻辑都是在引擎之外写的,而且中国很多游戏DEMO的逻辑都是靠硬编码实现的。
但是这并不表明引擎的开发人员就无事可做,因为你要对规则系统予以底层支持,有些东西是缺不了的。这主要包括:消息系统、游戏脚本、寻路算法和状态机等等,其中大部分是人工智能的标志性研究课题。这中间我认为最为重要的是脚本系统和消息系统。对于国外游戏引擎来说,强大的脚本系统早已成为了一个必备的利器,而国内的开发者还是处于脚本系统的教材和资料都很难找的阶段。
这里我们的引擎将为规则系统提供一套脚本支持,在后面的组装中你将会看到这个脚本是如何作为规则应用在游戏中的。对于规则系统也有其他很多种支持,例如状态机等等,好在各个逻辑体系之间是相对独立的,因此以后可以陆续增加。
脚本分为编译型脚本和解释型教本,对于外国很多游戏引擎所提供的都是编译型脚本分析器。我这里所提供的是一套解释型的脚本分析器,一是因为开发一个编译型分析器往往所需时间过长;二是对于我们的DEMO,解释型的已经足够用了,而且速度不慢。
脚本分析器提供的基本功能就是分析脚本,这就牵扯到了编译原理的词法分析和语法分析。在读入一行并对本行文本中的注释和空格成分予以消除后,剩下的部分转入词法分析,进一步被断为一个个独立的有意义的单词,最后通过语法分析来解释这些单词的意义。这里我们的语法比较简单,每一个独立的语句都类如下面的语句:
Index:
Funciton( param1 , “string param 2” );
第一个语句是标号语句,主要用于跳转的,例如Goto(Index)就可以从程序的任何一个位置跳转到Index,因此在我们的语法分析中,当发现了单词“:”之前有独立存在的单词时,就把这个独立的单词存储到一张Index表里面,以备跳转。而如果发现了“(”则把之前的独立单词作为Function,每一个Function唯一对应一段C++程序,从“(”到“)”之间的部分按“,”断开做多个Param,不带””的看作是常数参数,被””所包裹的是字符串参数,这些参数用做执行Function时的一些必须数据。语法分析的关键就是Function与C++代码的一一对应,即函数匹配,这里我们可以使用if来处理:
strCmd = ParseLine() //分析一行
if(strCmd == “Function”)
{
doFunction( getIntParam1() , getStrParam2() );
}
使用if可读性最好,但是比较慢,因为String比较会比常数比较要慢得多。因此也有的方法就是通过把脚本函数映射为唯一的数字,再通过数字来做比较。
例如我们建立如下的对应关系:Function : 101,并把这个对应关系存储到脚本解释器里面,这样,当解释器发现用作函数的单词Function的时候,就会把他翻译为101,然后再进行匹配:
nCmd = ParseLine() //分析一行,注意返回值不同了
switch(nCmd)
{
case 101:
{
doFunction( getIntParam1() , getStrParam2() );
}
}
这样就比原来快了很多。只是麻烦的一步就是需要一个个为脚本预先对应上这些数字。这些实际上都是在解释器的Run()函数里面运行的,在需要的地方,只需要调用Run(脚本文件名),Run就会自己去检测不同的脚本名称,然后实现各自的功能。
脚本分析器还需要有一个功能就是“功能注册”。开发引擎的时候我们几乎没有办法写出具体的脚本功能,做游戏的人拿到引擎后需要写一些具体的脚本功能,这时候需要提供给他们一个注册机制,来把脚本函数名称和功能一一对应起来。这里我们提供的唯一的注册机制就是这个switch(nCmd),如果添加了什么新的脚本,就需要为对应的编号增添新的实现。例如我们除了Function以外又添加了一个新的函数Walkto,对应编号102,文法是Walkto(param1 , param2 , param3),那么我们需要做的就是:
switch(nCmd)
{
case 101: // Function
{
doFunction( getIntParam1() , getStrParam2() );
}
case 102: // Walkto
{
WalkTo( getIntParam1() , getIntParam2() , getIntParam3() );
}
}
现在的注册机由于是隶属于引擎代码层面的,每一次添加新的脚本都会引起引擎更改和变动,前面我们说了,应该尽量避免引擎变动,怎么解决这个问题呢?关于脚本注册机的更好实现就是通过C++的多态。这样我们可以把Command实现为一个抽象类,各个具体Commond继承之并实现相应接口。例如:
class Command{ virtual void do() = 0; }; //抽象类
class Function : public Command{ //具体的一个Command
virtual void do()
{
doFunction( getIntParam1() , getStrParam2() );
}
};
但这些新加的类如何注册到解释器里面呢?因为在做解释器的时候我不可能知道会加哪些类进来啊!有办法,设计模式的工厂模式(Factory)给我们提供了明确的行动指南。我们可以另外实现一套Factory并指明Factory类型:
class CommandFactory{virtual string getType() = 0; virtual Command* create() = 0;};
class FunctionFactory : public CommandFactory{
virtual string getType() { return “Function”; }
virtual void create(){ return new Function;} //用Factory生成具体的Command
};
最后,我们需要在Run里面通过std::Map注册Factory,把Type和具体的Factory关联起来。这里我们就通过std::Map把“Function”和FunctionFactory关联起来。最后在进行函数匹配的时候,我们只需要:(伪代码)
//分析一行,得到strCmd
strCmd = ParseLine();
//从map里寻找对应strCmd的Factory,假设strCmd是”Function”,it->second里面就会存放FunctionFactory
iterator it = Map.find(strCmd);
if(it != Map.end() )
{
// 通过Factory生成Command对象
Command* Cmd = it->second->create();
// 执行Command对象的do方法
Cmd->do( );
// 销毁Command对象
Delete(Cmd);
}
OK,现在无论怎么往里面加Factory和具体的Command,这段属于引擎层的解释器代码都不需要改动了!Great。我们把这套流程画成图:
图3-6 利用工厂模式解决的解释器函数匹配图,会发生改变的用黑色标出,可见现在这套引擎几乎不会发生变动,以后需要的在别的地方写就可以了,健壮性相当高!
好了,到这里,脚本解释器本身就基本解决了。剩下的工作就是不断根据需要注册新的脚本功能了。
脚本怎样最终应用于规则呢?在我们现有的解释器下,谁想用脚本,就保留脚本文件的名称,然后调用CScript::Run( const string& filename )就可以完成任务。这样,我们需要为需要走脚本的每一个类都挂接一个成员:std::string m_strFile,来存储脚本文件的名称。而且,在这些类的Logic里面,我们需要手动调用CScript::Run来运行所储存的脚本:
if( m_strFile != “” )
{
CScript::getSingleton().Run(m_strFile);
}
如果一切无误,脚本就会运行。对于我们这个脚本机,有一个提速的手段。我们的脚本机每一次Run都会调入一次文件,分析后再关闭文件。如果一帧需要有10个物件走脚本逻辑,那么每一帧就起码会有10次磁盘操作,对于大量物件尤其是触发器(后面会提到)存在的情况,这是件严重的事情。提速的手段就是在最开始就按照文件名把文件内容一次提取到一个缓冲区内,这样走脚本逻辑的时候就不会走磁盘操作了,而是从内存缓冲区读取数据,对于动辄一个场景几十个物件的游戏来说,这种提速已经是普遍的做法。但每一帧都进行磁盘操作也并非一无是处,在调试的时候有时候需要经常改动脚本文件,对于一次调入的情况,每一次修改后必须重新启动游戏,而每帧重新读一次磁盘就不会遇到这种问题。如何选择合适的运行方式,这就需要看是在什么情况下运行了。
零件组装:具体游戏层的构建过程
框架搭完后需要一个具体的东西来证明我们的框架是否达到了预期目标。综上,我们对这个框架的要求是:
1、 利用框架开发的开发者不必要关注框架的底层细节,不必要关注OGRE,只需要关注各个组分之间的逻辑关系和存在方式。即满足框架良好的封装性。
2、 上层逻辑允且只允许与框架打交道。同时,框架中可以包含OGRE中无法绕开的重要组分。
3、 如果是框架没有完成的功能,应该可以通过对框架的临时扩展很好的完成任务。如果实在需要修改框架结构,接口也应该尽量避免改动。即满足接口安全。
这里有一点例外,就是所未完成的功能是OGRE未实现的功能,因为这个情况需要交给OGRE的维护人员去扩展OGRE库或我们自己来扩展OGRE库。除此之外,如果我们的测试没有完成既定目标,我可以认为自己失败了。
先看看我们的需求:
1、建立一个室外场景。即我们有一个地图体系。
2、有一个Player。即我们有一个物件系统和起码一个物件。
3、有若干怪物,怪物具有一定的智能,这个智能我们将用挂接在怪物身上的脚本来处理。即我们可以为怪物挂接怪物规则体系。
4、当Player杀死所有怪物的时候,游戏成功结束,否则当Player被杀死的时候游戏失败结束。即我们为世界挂接世界规则体系。
首先我们建立Application类,其主要功能是管理应用程序运行时的所有重要组件的初始化和删除工作。所有的Singleton单件都会在Application最开始的时候创建并分配堆内存,并在Application结束的时候销毁。这个Application就类似于Ogre的总管Root。而后是一些游戏层物件的准备工作,例如Player类、怪物类等等,并将这些类和类工厂注册到ObjectManager物件管理器里面去。这些工作都是立足于扩展的,不会修改原有的代码。Player类重点在于对键盘和鼠标的控制作出响应,使摄像机等随这些控制运动,而Monster则重点在于实现一些基本的AI逻辑,例如“搜索”、“索敌”、“攻击”,以供状态机或者脚本的需要。
在Application初始化的时候,我们在Scene创建时为其载入Scene.cfg场景配置文件,这个文件里包括了Terrain.cfg地形配置文件和object.cfg物件配置文件,以及一些其它与场景相关的内容,例如雾和Light等。Terrain.cfg就是Ogre的地形配置文件,而Object.cfg则确定了每一个物件所挂接的规则体系。这些规则体系的关键就是作为物件成员的脚本文件。
下面是一个标准的脚本文件:
ObjectNumber=1
#Object-1
ObjectType=7
ObjectPositionX=220
ObjectPositionY=220
ObjectPositionZ=220
ObjectScript=Insanity.AI
这里的ObjectScript=Insanity.ai就是为物件挂接了“疯狂”规则体系(AI)。
通过更改Terrain.cfg和Scene.cfg我们创建出来了一个室外场景,然后通过更改object.cfg为场景添加一个Player和几个怪物,并为每一个怪物挂一套AI规则。这样前三步就满足了。
那么世界的规则体系应该挂接到哪里呢?挂接到Scene里吗?
很多人是这么做的,而且这样做很简单,但是我并不决定采用这种方法。因为Scene的功能很明确,就是“管理”物件和地形,Scene应该厚厚道道地作一个管理员,而不是游戏逻辑的参与者。因此我决定采用另一种方法:就是物件系统所提供的触发器。
触发器的思路来自于一句论述:“肉眼所看不见的客观实在。”就是说,不能用肉体感知的,但是却在暗地里起作用的客观规律。对于我们程序员而言,触发器说白了就是表面上不可感知,在合适的时候检测当前的条件,当满足条件的时候按照预先的设定反馈给系统的特殊物件。使用触发器的另一个原因是在原来的作品中曾经使用过它,触发器所体现出来的的移植性和模块化比直接挂在场景上要好一些,而且性能影响并不多。
从唯理的论调中离开,回到我们的实践中。由于触发器属于物件系统,因此它每一帧都会检测自己内部的条件情况,并根据条件产生出相应的结果。我们这里的条件有两个,一个是if( Player杀死了所有的Monster ),结果就是“游戏成功结束”,另一个是if( Player的生命值低于0 ),结果就是“游戏失败”。这两个条件我们通过物件脚本系统挂接到触发器上,这样触发器每一帧都会来检测自己是否满足条件,当满足条件的时候:“BANG!”
当把触发器加上并注册到物件系统里面后,我们需求的第4点也就满足了,最后的工作就是不断测试和调试了。当一切无误的时候,就可以让它与大家见面了。
第四部分 结论和展望
通过对游戏世界的演绎,对游戏逻辑的归纳,以及对游戏底层工具的融合,逐步诞生了我们现在的框架。为了使框架更能经得起检验,我们使用了一些设计模式提供的方法来保证框架的安全性。现在的高层游戏引擎说明了一点,对于这个游戏,我们的框架达标了。但用这个框架开发一个新的游戏是否也会达标?答案是否定的。如果用这个框架去开发一个RPG游戏,那我们还缺技能、道具等诸多系统;如果去开发一个纸牌游戏,似乎我们的框架对于规则支持还不是太方便;如果去开发一个足球游戏,似乎我们的框架对AI的支持太差劲……而且就OGRE本身也在不断更新、提供新的功能,甚至有时候否定原有的类和接口。不过把话说回来,现在什么游戏引擎没有这种问题呢?写底层引擎容易,但是写一个通用的高层引擎层却很复杂,因为底层引擎只是跟变化缓慢的平台技术相关,但高层引擎层却是跟丰富多彩的游戏相关的。不仅如此,高层引擎的存在同时受底层引擎的功能限制。
需要走的路还很长,即便是对于国际知名的大公司,也是在不断的探索和实践中。但这并不表明前途就是渺茫的,我们通过这次实践作出了一次从底而上和自上而下的归纳和演绎的过程,在变化多端的具体游戏和变化缓慢的引擎中间寻找到了一个引擎层契合点。内容繁复、采用的表现手段多姿多彩的游戏世界,即便是再复杂,也可以通过一定程度的归纳演绎得出适应其规则和表现的契合点和高层引擎层架构。
附录
Terrain Example
/** /file Terrain.h /brief Specialisation of OGRE's framework application to show the terrain rendering plugin */
#include "ExampleApplication.h"
#include "OgreStringConverter.h"
#define FLOW_SPEED 0.2
#define FLOW_HEIGHT 0.8
class TerrainListener : public ExampleFrameListener
{
public:
TerrainListener(RenderWindow* win, Camera* cam) :ExampleFrameListener(win, cam) { };
// Override frameStarted event to process that (don't care about frameEnded)
bool frameStarted(const FrameEvent& evt)
{
float moveScale;
float rotScale;
float waterFlow;
static float flowAmount = 0.0f;
static bool flowUp = true;
// local just to stop toggles flipping too fast
static Real timeUntilNextToggle = 0;
if (timeUntilNextToggle >= 0)
timeUntilNextToggle -= evt.timeSinceLastFrame;
// If this is the first frame, pick a speed
if (evt.timeSinceLastFrame == 0)
{
moveScale = 1;
rotScale = 0.1;
waterFlow = 0.0f;
}
// Otherwise scale movement units by time passed since last frame
else
{
// Move about 100 units per second,
moveScale = 10.0 * evt.timeSinceLastFrame;
// Take about 10 seconds for full rotation
rotScale = 36 * evt.timeSinceLastFrame;
// set a nice waterflow rate
waterFlow = FLOW_SPEED * evt.timeSinceLastFrame;
}
// Grab input device state
mInputDevice->capture();
SceneNode *waterNode = static_cast<SceneNode*>(
mCamera->getSceneManager()->getRootSceneNode()->getChild("WaterNode"));
if(waterNode)
{
if(flowUp)
flowAmount += waterFlow;
else
flowAmount -= waterFlow;
if(flowAmount >= FLOW_HEIGHT)
flowUp = false;
else if(flowAmount <= 0.0f)
flowUp = true;
waterNode->translate(0, (flowUp ? waterFlow : -waterFlow), 0);
}
static Vector3 vec;
vec = Vector3::ZERO;
if (mInputDevice->isKeyDown(KC_A))
{
// Move camera left
vec.x = -moveScale;
}
if (mInputDevice->isKeyDown(KC_D))
{
// Move camera RIGHT
vec.x = moveScale;
}
if (mInputDevice->isKeyDown(KC_UP) || mInputDevice->isKeyDown(KC_W))
{
// Move camera forward
vec.z = -moveScale;
}
if (mInputDevice->isKeyDown(KC_DOWN) || mInputDevice->isKeyDown(KC_S))
{
// Move camera backward
vec.z = moveScale;
}
if (mInputDevice->isKeyDown(KC_PGUP))
{
// Move camera up
vec.y = moveScale;
}
if (mInputDevice->isKeyDown(KC_PGDOWN))
{
// Move camera down
vec.y = -moveScale;
}
if (mInputDevice->isKeyDown(KC_RIGHT))
{
mCamera->yaw(-rotScale);
}
if (mInputDevice->isKeyDown(KC_LEFT))
{
mCamera->yaw(rotScale);
}
if( mInputDevice->isKeyDown( KC_ESCAPE) )
{
return false;
}
// Rotate view by mouse relative position
float rotX, rotY;
rotX = -mInputDevice->getMouseRelativeX() * 0.13;
rotY = -mInputDevice->getMouseRelativeY() * 0.13;
// Make all the changes to the camera
// Note that YAW direction is around a fixed axis (freelook stylee) rather than a natural YAW (e.g. airplane)
mCamera->yaw(rotX);
mCamera->pitch(rotY);
mCamera->moveRelative(vec);
// Rotate scene node if required
SceneNode* node = mCamera->getSceneManager()->getRootSceneNode();
if (mInputDevice->isKeyDown(KC_O))
{
node->yaw(rotScale);
}
if (mInputDevice->isKeyDown(KC_P))
{
node->yaw(-rotScale);
}
if (mInputDevice->isKeyDown(KC_I))
{
node->pitch(rotScale);
}
if (mInputDevice->isKeyDown(KC_K))
{
node->pitch(-rotScale);
}
if (mInputDevice->isKeyDown(KC_F) && timeUntilNextToggle <= 0)
{
mStatsOn = !mStatsOn;
//Root::getSingleton().showDebugOverlay(mStatsOn);
showDebugOverlay(mStatsOn);
timeUntilNextToggle = 1;
}
// Return true to continue rendering
return true;
}
};
class TerrainApplication : public ExampleApplication
{
public:
TerrainApplication() {}
protected:
virtual void createFrameListener(void)
{
mFrameListener= new TerrainListener(mWindow, mCamera);
mFrameListener->showDebugOverlay(true);
mRoot->addFrameListener(mFrameListener);
}
virtual void chooseSceneManager(void)
{
// Get the SceneManager, in this case a generic one
mSceneMgr = mRoot->getSceneManager( ST_EXTERIOR_CLOSE );
}
virtual void createCamera(void)
{
// Create the camera
mCamera = mSceneMgr->createCamera("PlayerCam");
// Position it at 500 in Z direction
mCamera->setPosition(Vector3(128,25,128));
// Look back along -Z
mCamera->lookAt(Vector3(0,0,-300));
mCamera->setNearClipDistance( 1 );
mCamera->setFarClipDistance( 384 );
}
virtual void createViewports(void)
{
// Create one viewport, entire window
Viewport* vp = mWindow->addViewport(mCamera);
vp->setBackgroundColour(ColourValue::White);
}
// Just override the mandatory create scene method
void createScene(void)
{
Entity *waterEntity;
Plane waterPlane;
// Set ambient light
mSceneMgr->setAmbientLight(ColourValue(0.5, 0.5, 0.5));
// create a water plane/scene node
waterPlane.normal = Vector3::UNIT_Y;
waterPlane.d = -1.5;
MeshManager::getSingleton().createPlane(
"WaterPlane",
waterPlane,
2800, 2800,
20, 20,
true, 1,
10, 10,
Vector3::UNIT_Z
);
waterEntity = mSceneMgr->createEntity("water", "WaterPlane");
waterEntity->setMaterialName("Examples/TextureEffect4");
SceneNode *waterNode =
mSceneMgr->getRootSceneNode()->createChildSceneNode("WaterNode");
waterNode->attachObject(waterEntity);
waterNode->translate(1000, 0, 1000);
// Create a light
Light* l = mSceneMgr->createLight("MainLight");
// Accept default settings: point light, white diffuse, just set position
// NB I could attach the light to a SceneNode if I wanted it to move automatically with
// other objects, but I don't
l->setPosition(20,80,50);
mSceneMgr -> setWorldGeometry( "terrain.cfg" );
mSceneMgr->setFog( FOG_EXP2, ColourValue::White, .008, 0, 250 );
//mRoot -> showDebugOverlay( true );
}
};
/*
-----------------------------------------------------------------------------
This source file is part of OGRE
(Object-oriented Graphics Rendering Engine)
For the latest info, see http://www.ogre3d.org/
Copyright ?2000-2003 The OGRE Team
Also see acknowledgements in Readme.html
You may use this sample code for anything you like, it is not covered by the
LGPL like the rest of the engine.
-----------------------------------------------------------------------------
*/
/**
@file
Terrain.cpp
@brief
Shows OGRE's terrain rendering plugin.
*/
#include "Ogre.h"
#include "Terrain.h"
#if OGRE_PLATFORM == PLATFORM_WIN32
#define WIN32_LEAN_AND_MEAN
#include "windows.h"
#endif
#if OGRE_PLATFORM == PLATFORM_WIN32
INT WINAPI WinMain( HINSTANCE hInst, HINSTANCE, LPSTR strCmdLine, INT )
#else
int main(int argc, char *argv[])
#endif
{
// Create application object
TerrainApplication app;
SET_TERM_HANDLER;
try {
app.go();
} catch( Ogre::Exception& e ) {
#if OGRE_PLATFORM == PLATFORM_WIN32
MessageBox( NULL, e.getFullDescription().c_str(), "An exception has occured!", MB_OK | MB_ICONERROR | MB_TASKMODAL);
#else
std::cerr << "An exception has occured: " <<
e.getFullDescription().c_str() << std::endl;
#endif
}
return 0;
}
参考
(1)《游戏之王》——孙百英主编,科学普及出版社,ISBN:7-110-04493-9
(2)《设计模式》——Erich Gamma等,机械工业出版社,ISBN:7-111-07575-7
(3)OGRE文档和源代码——Ogre制作组(英国)
(4)《游戏引擎剖析》——Jake Simpson
(5)《圣剑群英传》文档和源代码——金点工作室(中国)
(6)《Ogre使用指南》——mage工作室(中国)