PC游戏编程
目录
1 游戏程序理论
1.1 技术基础
1.2 游戏底层
1.3 编写规则
1.4 程序设计
1.5 制作流程
1.6 程序调式
1.7 代码优化
2 游戏实践讨论
2.1 制作概况
2.2 模块划分
2.3 游戏引擎
2.4 关键讨论
2.5 希望
1 游戏程序理论
我做游戏的历史只有三年,我所写的内容都只是我在此期间的感觉和经验,还远远谈不上完整和正确,甚至有些内容我们自己也没有完全达到,我只是试图说明我们曾经是怎样做的和在最近的将来打算怎样做。
我读过一些关于如何制作游戏的文章和书籍,有外国的,也有国内的。有的过于难懂(那些专业技术有的我根本看不懂),有的过于简单,而且大都着重于程序编写技术。我在这里希望能够提供其它方面的帮助,比如游戏中的程序设计,项目管理和与我们中国人编写游戏相关联的一些问题。
我希望能够给那些想编写商业游戏和正在编写商业游戏的人们提供一个样本,这不是最好的样本,而是在中国内地游戏制作业的初级阶段中一个普通产品的制作方法和经验。如果是一个初学者,我希望给他一个编写游戏程序的完整印象,让他至少了解编游戏和玩游戏是有区别的。
我希望阅读本文的人应该是一个程序员,熟练使用C语言和C++语言,曾经编写的单个程序长度应该在5000行(150k)以上。因为我在这里不会介绍任何有关编程语言的语法,编译等内容,如果你连这些内容都还没有弄清楚,请先学习一下,否则将很难与本文引起共鸣。假如你是一位美术设计,游戏策划人员或游戏项目管理人员,虽然你无法看懂里面的大部分内容,但是我也希望你能够了解里面的有关内容,因为如果你能够了解程序员的工作,我们就能够更好地配合。
我更希望那些不了解游戏的人们能够看到本文,我希望他们能够了解编写游戏程序与编写其它程序是一样艰巨和复杂的,制作游戏并不等同于玩游戏。
1.1 技术基础
1.1.1 人员
作一个商业游戏所需要的程序员的水平,可以分成三个级别:系统分析员、主模块程序员和外围程序员。
·系统分析员:
这里的系统分析员也许比不上程序员资格考试中的系统分析员,因为我们制作的游戏程序到目前为止都属于中小型程序,长度一般在50000到200000行之间(1.5M到6M),经过压缩之后源代码可以轻松放在一张软盘上。工作量一般在20到60人月之间。所以游戏程序的复杂程度比一些大型应用程序要小。但是,游戏系统分析员的工作也一样是复杂繁重的。他的主要工作是需求分析,程序设计和进度管理。
在需求分析阶段,主要是根据游戏设计制定的游戏方案确定那些内容是可以实现的,那些是不能实现的;可以实现的部分如何实现,不能实现的部分该如何修改。最后制定一个可行的游戏程序制作方案。这个方案最后被系统分析员写在程序设计文档中。当设计定稿后,制定开发计划,组织人力开发,监督制作成果。
·主模块程序员:
过去我们的系统分析员和主模块程序员由同一人担任,但是到后来,我们发现,因为程序的管理设计人员也要担任繁重的程序制作任务,很难面面俱到。所以我建议把主模块程序员独立出来。由他来负责对游戏主要运行部分进行编程。这需要主模块程序员具有非常丰富的编程经验,至少应该具有完成一个游戏的经验。但是其实每个人都是从不会到会,从没有经验到经验丰富的,不能满足条件也没有关系,只要程序难度小一些就可以了。
·外围程序员:
游戏的程序一般都会被分割程序若干个模块,如果该模块独立性比较大就可以交给其他人员完成。这些人只要有一定编程经验就可以胜任了。
1.1.2 编程平台
任何的计算机语言都可以被用来写游戏。游戏可以被运行在任何可能的计算机硬件设备上。但是,因为我的制作经验的局限,我无法向你们介绍用Cobal语言编写AppleII上运行的游戏。同样,我也不会教各位如何使用Visual Basic,Pascal或Java,任何形式的DOS程序也是我不会花时间讲解的。在我后面的讨论中将只涉及在windows95/98下的Visual C++编程。
现在,编写游戏时不能不提到Microsoft公司的DirectX。它是一套基于Windows95/NT的游戏底层接口,在我们的游戏中使用了它作为底层接口。关于它的使用想必大家已经很熟悉了,现在已经有中文的书籍出版,我不必赘述。
有的书上讲在编译和调试程序时最好使用Windows NT或双显示器,尤其因为DirectDraw中独占显示器。可是我试过之后发现都不实用。对于WindowsNT,它的4.0版只支持DirectX 3.0, 听说5.0版才支持DirectX 6.0,但我总不能等到NT5.0出了之后才开始编吧。对于双显示器,我一直耿耿于怀,外国人都很阔,每个人可以配备两台机器,可是,就算我找到两台计算机,程序装入的速度实在让我不耐烦,调试一遍时间太长,等不起。
我们应该重视的倒应该是计算机的兼容性问题。为我们提供测试的计算机是很少的,如何找到更多的条件才测试我们的程序是后期应该注意的问题,千万不要以为在Windows95时代计算机硬件的差别已经没有了,它们出起错来更危险。
1.1.3 语言
在任何一本写游戏编程的外文书籍里都会提到编程语言主要有两种,汇编和C。有的书里甚至不提倡使用C++。不使用C++的原因我猜有两个:第一,速度。据说C++代码比C代码慢10%。这个数字我没有测试过,但C++比C慢大概应该是对的。因为用C++比直接用C编写代码要方便和简单,所以速度应该会慢。第二,便于移植,移植这个词可很时髦,什么PC到PS啦,PS到Saturn啦,Saturn到N64啦,N64到Mac啦,Mac到PC啦之类,一个游戏必须要在所有地方出现才过瘾。如果使用C++就难了,因为象次世代游戏机上的开发环境,一般不支持C++,N64虽然说是支持但仍然建议使用C。
使用汇编的理由也是很简单的,那就是快!因为游戏编程中表现最明显的地方就是游戏的速度。汇编比C快的道理也是很明显的,那就是汇编要比C难写多了,速度自然快些。
那么速度就那么重要么?过去上学的时候,用汇编写出来的程序编译出来的一般十几k,后来用C写的小程序大的有几十k,而现在编译windows程序,一上来就有一百多k。过去的游戏就是绿色屏幕上几个字符,而现在都是真彩色的图象和实时三维。过去的游戏一个人就可以“搞定”,而现在必须分工合作。这些给我的感觉就是游戏越做越大,程序越写越长。是不是有一些方面开始变得与游戏速度一样甚至更加重要了呢?
有!这其实就是大家经常谈论到的一个问题:程序的Bug。一个程序如果越来越复杂,越来越长,而且多个人共同编写,这时候就越容易出现程序中的臭虫。我认为,以我们目前的程序编写水平而言,程序的正确性要远远高于它的效率。实际上,我在后面的主要篇幅都在讲如何提高程序的正确性上,而只有很少的篇幅介绍程序的优化。
所以我选择编程语言主要是从其正确性和效率两个方面来考虑的。目前我选择C++。
这不仅仅因为我对这个语言最熟悉,更因为它的特点,对两个方面都符合得很好。程序是人写出来的,如果程序越容易写,就越容易正确,这方面汇编语言显然不是候选者,Pascal虽然好,但是那些Begin和End写起来也太累。 C语言要省事得多,C++更进一步,而且有了封装,继承,多态的概念。可是编写最方便的首选应该是Visual Basic,Delphi和Java了。程序的框架都已经被准备好,只要在里面填东西就可以了。只是因为它们的效率比较低,而不能成为游戏编写的最佳语言。
现在很难想象一个游戏整个都是用汇编写的,因为一个10000行的C程序改用汇编写至少需要50000行,那其中出错的机会至少要多出5倍,而且汇编代码太过抽象,很难读懂。所以应该只在非常需要效率的地方才需要使用汇编语言。
C语言与C++的差别不大,而且我认为C++所带来的封装的概念足以弥补它们的速度差异。关于移植,有消息说Sega最新一代的游戏机DreamCast要使用Windows CE作为操作系统,那么C++应该也会支持的了。大融合的时代总会到来,不同担心。我的C语言代码也主要用于程序的底层。
那么,现在大家对我所使用的语言应该有所了解了。使用C++作为主要语言,同时辅助以C和汇编。
1.1.4 编译环境
Microsoft Visual C++2.0/4.0/5.0/6.0是我一直使用的编译器。虽然Borland C++和Watcom C++也可以编译Windows程序,我仍然认为Visual C++是最好的。没有别的理由,就是因为习惯了。如果你已经习惯使用Watcom C或Free C什么的,也没必要改。如果从来没有编过Windows程序,倒是可以先从这里开始。
我听说谁如果不会使用SoftICE调试程序就没有达到编程的顶峰,我一直以此为遗憾。我不会使用SoftICE。我也远远没有达到编程的顶峰。“竟然这样的人也编起游戏来了”,也许有的人会这样想,我想这没关系,因为本来谁都可以编游戏的嘛。
1.1.5 背景知识
编程序到底需不需要是专业人才,这也是个大家争论的话题。我还是那个观点,这只是一个起点的问题,它只表明入门时候的速度而已。但这并不是说你什么都不需要学。有许多东西需要我们学,甚至需要我们不停地学。
·硬件知识:
你总不能连自己的计算机是什么CPU都不知道吧。虽然我们不需要知道每个芯片的内部构造,但如果我们知道了Pentium CPU有两条并行流水线同时处理指令的话,我们就会特别注意这方面的优化了。
·操作系统:
我们的程序不一定需要多任务,多线程,但是了解我们的程序何时被调用是很重要的。
·数学:
数学是我最头痛的科目了。好在我们在这里所接触的大多是离散数学(我也很头痛!)。最基本的要算是数理逻辑,就是程序中最普通的判断语句中的那些“与”,“或”,“非”。其次是那些图论,概率。知道这些编写二维游戏就足够了。如果编写三维游戏,那就赶快复习一下线性代数吧。
·专业课:
这是计算机系中学到的有关课程。虽然数据库,网络的概念在游戏编写中都可能会用到,但是因为都是间接的,就不在这里说了。数据结构大概是最重要的课程了,这里所提到的数组,队列,堆栈,链表,表,树,图等概念在我们的程序中几乎都会或多或少地用到。另外,对于三维编程,还有一门叫计算机图形学,也是很重要的。
·文件:
与游戏密切相关的文件有图象文件,声音文件,视频文件等。这些知识越丰富对我们编写游戏就越有用。可是,这也并不是绝对的,我在刚开始写游戏时连BMP图的格式都不知道。
·美术:
大概每个游戏程序员都有过当“助美”(助理美工)的体验吧。当我们做程序测试,或美术师们没有时间完成裁图,切图等“体力”劳动时,自然由我们来做这些工作。但是,请记住,程序员永远是程序员,就算我们画得再好,也比不过他们的。
1.2 游戏底层
在我们编写每一个游戏时,都希望能有一部分代码可以重用,这样我们才能有更多的时间做更多的事情。这些重用的代码就慢慢形成了游戏底层。游戏底层多了,自然就形成了一些类别。
显示底层:
用于基本显示的函数。把常用的功能制作成函数,统一出错管理。对于Windows95/98下的游戏编程,我们一般使用DirectX作为大部分底层的底层。因为这部分是我们可以用于编程的最底层函数。对于二维显示,我们使用的是DirectDraw部分。为什么还需要在DirectDraw外面再包一层函数呢?原因有三:第一,DirectDraw提供的函数扩充性很强,函数参数较多,而我们使用时参数比较固定,包裹成类库后,参数更加可以封装起来;第二,DirectDraw提供的函数比较分散,而有许多操作是需要多个函数按照一定顺序执行的,包装起来后编写程序比较简单;第三,DirectDraw函数的返回值比较复杂,而且没有对错误进行系统的处理,我们需要自己制作一套错误处理函数,对错误代码进行解释。
显示底层有许多种做法,可以完全自己开发,也可以直接使用别人完成的函数库,比如Internet上比较流行的CDX类库就是对DirectDraw进行了很好封装的底层。
显示底层主要分成下面几个部分:
窗口处理:窗口的创建、管理、删除。
图形接口处理:图形设备的初始化、退出、显示模式的设定、主显示面、后缓冲区的建立。
二维图形处理:游戏使用的二维图象的装入、显示、剪裁、逻辑操作。
外围工具:游戏使用的鼠标,刷新率计算和显示,显示内存斟测,面丢失(Surface Lost)处理(在DirectX 6.0后据说可以不用),错误处理,调试函数。
关于显示底层还有两个问题需要说明:
第一,显示部分的调试。因为我们所做的游戏一般为独占模式,在游戏运行时不能跟踪(除非使用双显示器),所以我们只能另想办法,一个比较常用的办法是使用窗口模式和全屏模式切换。不一定在游戏运行中随时切换显示,在调试时事先指定也可以实现该功能。
第二,多媒体播放底层:
播放多种声音,音乐,视频等多媒体效果。比较古老的一种办法是使用Windows 3.1就开始使用的MCI调用。它是一种通用接口,使用不同的参数调用同一个函数就可以实现基本相同的功能。用MCI可以实现的主要媒体类型有MIDI,WAVE,CDAUDIO,和VIDEO。它的优点是使用简单。缺点则是缺乏交互性,而且同种类型的媒体不能同时播放。这样就很难实现多种音效的混合。
一个基于DirectX的调用是使用DirectSound。它是基于WAVE文件的一套播放函数。非常适合混音。缺点就是使用较复杂,而且如果想实现分段读取较长的WAVE文件,编程有些难度。
另外一个针对MIDI的DirectX是DirectMusic。它是DirectX系列中的一个新成员。
对于播放高质量的VIDEO(视频)文件,则有另外一套底层。比如Microsoft的ActiveMovie和Intel的Indeo Video。有了它们我们就可以放心地全屏播放AVI文件了。
对于这些底层,我们所做的只是简单地封装上一层函数,使我们在使用该功能时达到最简便。
但是假如你对这些现成的播放程序不满意,也可以完全自己开发。只要你知道这些文件的格式,再把它们存储成你需要的格式,用自己更加优化的算法将它显示出来就可以了。用这种方式你可以自己播放AVI VIDEO, MPEG, MPEG2或FLI/FLC。
文件封装底层:
对使用的文本,图象等数据进行封装。我们也常用这种方式把多媒体文件封装起来,防止别人事先查看。这种工作往往是循序渐进的,我们会根据自己的时间和实力,封装相应的部分。比如先封装比较简单的文本,图象。然后是较为复杂的声音文件,最后是最复杂的视频和动画文件。
有的时候封装也是为了压缩。不仅再安装时少占用硬盘,装入时也可以节省时间,有时在运行时也会减少内存占用而不会对速度有太大的影响。
根据文件类型的不同,我们可以这样分类:文本、图象、声音、视频、动画等。对于每种类型,我们还必须有打包函数和解包函数之分。
界面底层:
自己编制的类Windows风格的界面类库。我总有这样的习惯,希望在我的游戏里没有一点Windows的风格,于是我不会使用任何一点的有Windows风格的界面(游戏工具除外)。所以游戏的界面必须我们自己做。然而我又使用过MFC那一套界面类库函数,很佩服Microsoft的本事。也希望我们的游戏中有一套自己的界面系统。这套系统将会包含Windows用到的主要界面元素:位图(Bitmap)、按钮(Button)、检查框(Checkbox)、滚动条(Scrollbar)、滑块(Slider)等等。
虽然Windows允许我们在使用标准界面时自己画图,但是因为它只支持256色,是基于消息响应机制的显示,而且无法支持位图的封装,所以我们使用一套完全由自己制作的界面底层。这套底层建立在显示底层和文件封装底层之上,形成了一套比较完整的系统。
上面这些只是一般游戏所需要的底层,对于某些特殊类型的游戏,可能还会有战略游戏的显示,操作底层,RPG游戏的事件处理底层,战棋游戏的显示,智能底层,动作游戏的场景底层等。如果编写三维程序,则还会有三维显示和操作底层。这些底层都不需要也不可能在一开始就全部完成,而必然是在不断的修改和检验中完善的。它们也不可能一成不变,必然随着我们编程经验的积累和开发环境的改变而改变和完善。
每个编写游戏的程序员都会慢慢积累出符合自己游戏特点的底层函数库,这些函数库如果得到不断的发展和扩展将会越来越大,越来越好,这对于每个游戏制作公司和集体来讲都是一笔不可忽视的财富。
而这套函数库越来越复杂,再加上辅助的一整套工具,就可以被称之为游戏的引擎。有了引擎在手之后,不仅意味着我们有了一套低Bug的现成代码,程序员的工作量将会大幅度下降,而且他们可以把更多的精力放在游戏的优化、游戏的人工智能、游戏的操作、以及游戏性上面了。到了这个层次,我才可以想象一个好的游戏将会是什么样子。
1.3 编写规则
现在,只需要一个程序员编写的程序已经很少了,大部分的程序都需要多个程序员的协作才能完成。这期间必然需要程序员之间互相阅读代码。而代码的编写规则就是非常重要的环节。一段代码如果写的浅显易懂,不但在程序交流时非常方便,而且对于寻找程序错误也时很有帮助的。程序员也是人,是人就会犯错误,不管他多么地不愿意犯这个错误。所以我们也希望能制定下一个规则,使他们在一开始想犯错误也不行。于是就出现了程序的编写规则。
如果不按照这些规则编写代码编译程序是不会报出错误的。但是如果按照规则编写,则对我们的思路的整理、笔误的减少、错误的查找和代码的阅读都是极有好处的。
下面我只把规则中几个重要的部分加以简单的描述,在后面附录中会有一份我们已经使用了一段时间的完整规则。
命名规则:
程序中对变量和函数的命名看似简单,可是名字起得好与不好有很大的不同。对于变量我们一般采用匈牙利命名法和微软命名法的结合。对于函数我们一般要把模块名写在函数名的开始。对于类库我们一般要求类的成员和成员函数要紧密对应。
变量使用:
对于程序中所使用的所有变量,我们都需要其有明显的定义域。对于全局的变量要尽量封装。对于类库,所有成员必须封装。
函数使用:
我们对函数的参数和返回值做了要求。对于函数的使用范围也做了规定,要求范围越小越好。
注释:
对于程序注释,我们虽然不需要很多,但是在函数声明、变量声明、主要算法部分、源程序文件的开始都需要有注释。
调试:
我们的底层和Visual C++都提供了一些供调试用的代码,在我们的代码中需要适时地使用这些代码,以提高程序的纠错机会。
这里有几个问题需要特别说明:
第一,关于链表的使用。
我在编程中所遇到的所有死机错误中,有90%来自于指针,9%来自于字符串(其实也是指针的一种),1%来自死循环。而链表这种把指针的优势发挥到极限的方式我是不提倡使用的。有人讲如果不会使用链表就不是C程序员,那么现在我连个合格的程序员的资格都够不上了。的确,对许多人来讲,编写没有Bug的链表是很容易的事情,甚至对某些人而言,一个没有链表存在的程序几乎不能称得上是商业程序。我承认有许多天才的程序员在自己的链表程序中表现得十分出色,他的程序效率既高又没有Bug,但是我也知道还有更多的程序员并不是天才。就算是天才的程序员如果在他的十万行代码中有五万行于某些链表有关,这其中的某个指针出了一个错误,而这个错误又不是每运行一次都出现的时候,他又有多大的把握很迅速地找到这个错误呢?链表的复杂性在于它的地址的随机性,这使我们在调试跟踪时非常困难,我们很难随时观察每一个节点的信息,尤其当节点多的时候。所以我把链表的使用完全限制在算法的需要和该代码被某个独立模块完全封装的情况下。
实际上我在设计程序时,要求尽量减少指针在主要模块之间的传递,而使用效率较低但是不易出错的句柄ID的方式。
第二,关于类库的成员。
既然我们使用了类库,其中一个主要目的就是要把其中的成员完全封装起来。首先把它设置为私有,然后定义标准的一套Set和Get函数。这虽然使我们在写代码时比较烦琐,但是只有这样我们才可以严格规定该成员的只读属性和可写属性,减少对某些重要变量的无意修改。
第三,关于变量和操作符之间的间隔
许多人对于变量和操作符之间的距离并不在意,认为多个空格少个空格只是看起来好不好看而已。而实际上,这对我们是很重要的。因为如果我们把间隔固定下来,在查找某项操作时就可以使用带空格的字符串,而大大增加找到的机会。
1.4 程序设计
我们在编写自己第一个游戏时,往往是凭借自己的兴趣,想到哪就编到哪,有时连自己都没想到会编出这么大的程序来。那时侯我们对代码的修改往往是很随意和彻底的,因为本来都是自己的东西,而且更希望它更好。可是,程序越写越大,有一个问题就会越来越明显,那就是程序的错误。为了减少错误的产生,我们必须采取一些办法,所以也就产生了程序设计。程序设计的必要性还表现在另一个方面,那就是程序员间的合作。为了让每个程序员对我们所要做的事和我们将如何做这件事有个明确的了解,也为了让管理人员能够从量的角度了解任务的完成状况和进度,我们也必须有程序设计。否则我们就只能象一群乌合之众一般,鬼才知道这游戏要做到什么时侯。
一般,我个人认为,当一个程序需要写到10000行以上时就应该有比较独立的程序设计文档了。可是程序设计文档应该是什么样子呢?很抱歉,我也一直在寻找这个问题的答案。在我刚开始做游戏的时侯,没有人教,于是我翻出上学用的课本,打算来个生搬硬套,写来写去,发现,不行。这样的写法太累了。比如在软件工程课上所讲的数据流图,我好不容易写满了一张八开的纸,马上就发现自己已经糊涂了。就算不马上糊涂,过一段时间我也会认不出那些条条框框是什么了。就算我能记得这些图表的意义,我能够保证所写的代码和图表上的一样么?我写完后,还需要和这些表对一下。就算程序和文档中的内容一样,那如果我的设计方案改了呢?又需要先改文档,再改程序,再对程序。这样对我来讲可太累了。
后来,我才发现,原来书上所写确实有道理,可是它并不是面向我们这些人的,它所面向的是写大型程序的人。这些程序大到需要专门有这样的人来写文档。这些人只要把文档写好再确认完成的程序是否符合要求就可以了,根本不用去写一句的代码。是啊,如果编游戏也有这样的工作就好了。可是很遗憾,目前我尚未遇到可以聘有专职程序设计人员的公司。所以程序设计者就是程序编写者的这个事实起码在我们的目前阶段是很难避免的了。
那么怎么设计文档对我们用处又大又比较节省时间呢?每个公司和每位程序设计人员的习惯都不一样,我只介绍一下我们曾经使用过的一些方法和文档的类型。
程序设计总纲:
无论我们做什么,最先要清楚的一个问题就是:我们做什么?很弱智是不是?但是我发现有些人在很多时侯都“不知道”自己在做什么。程序设计总纲就是要告诉自己和自己的伙伴们,我们要做的是什么样的游戏程序。它包括:
游戏概述:游戏的背景、类型、操作方法、特点等;
程序概述:游戏程序的编译平台、硬件运行需求、语言、编程重点和难点、技术能力分析等;
程序员概述:参与编程的人的能力和在整个程序制做中的作用和地位;
程序模块划分概述:有本游戏所需要的所有程序,和程序内部的功能模块的划分。这部分只要有比较概括的说明就可以了。
这篇文档的作用就是让所有人都能够了解本游戏的程序将会是什么样子,它的特点是什么,完成它的难点又是什么。以及我们为什么要这样做,有谁来做等这样基本的问题。它所面向的对象除了有自己和本组内的所有程序员以外,还应该有本游戏的游戏策划,美术设计、项目负责人、项目经理、制片人等。如果有可能也应该和本公司的市场、销售、广告公关、出版、甚至公司总经理充分接触,听取各个方面的意见,也充分表达自己的意见。如果能做到制做本游戏项目的所有人员从上到下对本游戏的程序制做都能有一个基本的了解,理解和信心那是最好的。
程序模块划分:
这是对程序设计总纲中最后一个部分的详细说明。说明的内容主要有:该模块的功能、接口、技术要点、所用到的软件底层等。它所面向的对象一般是所有的程序员,但是如果让与程序合作的人员(美术、策划、经理)也能了解则更好,这样可以加强我们之间的勾通,让不懂编程的人也能了解编程人员的苦衷。
一般而言,一个游戏按功能划分(我们很少用其它的分类方法)可以有下面几个部分:
工具部分:在游戏的制做过程之中,从游戏策划的文档,游戏美工的图片到游戏完成,中间必须经过游戏程序的转换和加工。这些数据在进入程序之前必须经过规整,排序等操作。这部分工作过去一般都是由程序员来做的。在人手紧张和工作量比较小的时侯这样做还是可行的。但是如果这样的工作过于复杂,工作量太大,就不行了,我们需要一种方法来提高工作效率。这种方法就是工具。其实我们在计算机所做的任何工作都是在使用工具,唯一的区别只是这些工具都不是我们自己做的。因为游戏本身的特殊性,我们不可能总能找到我们所需要的工具,自己制做就是十分必要的工作。
这些工具也可以分成三个级别:别人的工具,我们自己的可以重用的工具,我们自己的只在本游戏使用的工具。别人的工具是最好的工具。原因很简单,因为它不用我们再去编了,不仅程序Bug较少,操作界面友好和简单,而且开发时间是零。所以如果可能一定要使用别人做好的工具。如果实在没有满足要求的工具,或者我们还需要该工具所没有的功能,我们只能自己编。那么如果这个工具可以被以后用到也不错。所以我们在编写任何工具的时侯,其通用性是我们考虑的首要因素。
工具与游戏其它部分的程序有一个最大的区别,那就是工具程序是独立的可执行文件,它与游戏的唯一交互方式就是存在磁盘上的文件。所以它比较适合交给其他的程序员制做。另外一个特点就是它一般都是针对非程序员的,所以如果工具做好了,也可以很大程度上帮助他们提高工作效率。
底层部分:这部分程序代码的最大特点是可重用性。它是游戏本身与计算机通用系统之间的桥梁,它与游戏的具体细节关系并不大,可以被使用到多种游戏类型和多个游戏之上。这对我们是非常重要的。实际上,我们编写程序所积累的最直接的财富就是可重用的工具和游戏底层。
游戏底层从与游戏的相关性可以有大概三个层次:
最底层:它们向下直接调用系统函数和功能,向上与游戏几乎完全无关,是游戏程序与计算机系统的直接桥梁。它包括:
·显示底层:提供游戏显示所需要的所有函数;
·文件底层:提供对游戏所需的各种文件处理的接口;
·多媒体底层:提供游戏所需的多媒体效果,如声音,视频等;
·对于三维游戏有模型管理底层:专门保存,处理和显示三维数据;
中间层:它们向下调用最底层的游戏底层,向上为游戏提供函数。与游戏无关。它可能:
·界面底层:使用显示和文件底层,提供界面类库和操作函数;
最高层:它们向下调用中间层和最底层,向上为某种类型的游戏提供特殊的逻辑支持。它可能有:
·战略游戏的命令执行底层,显示底层,地图底层。
·RPG游戏的事件处理底层,场景底层。
这三个层次的制做难度是越来越难的。它往往需要我们在制作过几个游戏之后提炼和总结出来(实际上我们到现在也还没有发展出第三个层次的底层)。但同时,这三个层次是越来越重要的,因为它距离一个成品游戏更进一步。我们在写游戏时可重用的部分越多,重用的层次越高,我们的工作量就越少。我们也就越可能有时间研究和开发新的技术,好的算法,我们的游戏也就会越做越精。
相比之下,前面的两个底层都与计算机系统有关,与游戏本身无关;第三个底层与计算机系统无关,与某种类型的游戏有关。
底层部分与工具部分也是密切相关的。一般每一套底层都需要一些工具来与之对应。比如,我的显示底层需要某种格式的图片,我们就要制做一个格式转换和图片观察的工具。把工具和底层结合起来分类,然后把它较给独立的程序员去完成,是一种比较常见的模块划分方法。
当然,我们在做游戏以前也不一定非要什么工具和底层不可,这些东西完全都可以“自然”产生。假如你能够坚持制做几个游戏,把可以反复使用的程序归纳起来,修改一下,就是一套底层了。如果我们在编写程序时,每时每刻都在想:这段代码是否可以在以后使用,怎样的写法可以使我在以后使用,那么我们在后来的修改工作中就会轻松很多。而假如我们在编写程序以前多花一些时间,把想要做的东西设计清楚,在一开始就可以把它写成独立的底层代码。这样积少成多,我们的财富就会越来越丰富。
游戏本身:这才是我们每次都要做的游戏。所有体现游戏特色的内容全在这里。当然也有的人认为如果我们的经验足够丰富,想得足够全面,连这部分的程序也可以重用起来。以后我们编游戏只要改一下图片,换几个名字,编一段故事,一个新游戏就产生了。实事上,也有公司制做了一些类似游戏工具的东西,其中做得最好的应该是象Director, Autherware那样的多媒体编辑工具吧。但是,它们都有其局限。最明显的一点就是:它们必须针对某一个类型的游戏或只能是多媒体程序,不同类型游戏的制做是有很大区别的,几乎很难用一个固定的模式将其完全函盖。其次,它们创新困难,现在游戏几乎成了创新的代名词,哪一个游戏做出来没有它自己的特点呢?而使用一个固定的工具是很难做到这一点的。第三,难以对它们进行优化。程序的优化往往和程序本身的特点有关,优化的程度越高,它的特殊性也会越大,所以,通用工具只能牺牲一些性能。我到目前还没有看到一个游戏制做工具可以达到商业效果,而多媒体工具之所以流行正因为制作多媒体产品所需的模式比较单一,对速度要求也不高。
所以,制作一个完全可以通用的游戏工具到目前仍然还只是一个梦想。但是,我想这是一个很美的梦,如果真的到那个时侯,我们制做游戏将会和拍电影一样简单(我指的只是制做的过程),只需要一个程序员根据策划的意思把图片和声音放在一起就可以了。
每个游戏都是不一样的,但它们都有一些相似的地方,比如:
游戏的操作:游戏者如何操作和控制游戏中的一个或多个主角;
游戏的显示:不是游戏的底层,而是与具体游戏有关的显示部分;
游戏的运动:游戏中有的内容是要不断变化的,包括位置变化,图片变化和形式变化;
游戏的智能;能够与人对抗的思考;
游戏的存储;把所有当前游戏需要的数据保存在磁盘上,然后再重新装入游戏;
游戏状态的转换:从界面到游戏,从游戏到动画,在不同的时侯有不同的操作和显示管理。
我们通常也会按照这样的规律对游戏进行模块划分。
游戏数据结构和算法:
有人讲程序就等于数据结构加算法。这句话很有道理。我们所编的程序其实就是把某种格式的数据(图片,主角的参数)经过一系列的转换,成为计算机屏幕上的数据(游戏本身)。所有的数据都需要以某种方式存储,这就是数据结构。而算法因为是与数据结构密切相关的,所以虽然它们不属于同一个概念,我把它们放在一起设计。
我们通常所使用的数据一般可以分成两个部分:数据库结构和当前结构。
这里的数据库不是什么商业数据库软件,而是游戏中所使用到的所有数据的集合。我还没有见到有哪个游戏在存储这些数据时使用商业数据库软件(比如FoxBase)。也许它们都被隐藏起来了吧,我猜想。但不管怎样,其意义是一样的,我们需要把所有在游戏中用到的数据都以一定的形式储存起来。同商业数据库一样,数据库的组成也是由字段组成,即数据元素。每个数据元素一般是一个类库或结构,有若干的成员。数据元素所组成的数组就是数据库结构中最主要的组成部分。
数据库的内容很多,而且按照一定的规则排列,而真正需要显示和当前需要计算的数据却很少,有些数据还是计算的中间结果,比如主角的位置,主角的动作等,这时侯就需要我们另外存储一些数据。它往往是数据库中的某个字段(数据元素),或一些数据的组合。
如果把数据结构按照功能分类,我们还可以把它分成主数据结构,模块数据结构。它们的定义域是不一样的。
在我们的游戏中,会有许多数据结构的类库,它们之间也必然有数据结构之间的数据传递和保存。如何处理好这些数据之间的关系是编写这部分内容最重要的考虑。
首先是数据结构定义域的问题。在无数的教学课本上都在不断提醒大家,要尽量避免使用全局量。为什么要这样做呢?第一个原因显然是因为这样做的人太多了,我们使用全局量直接,方便,快捷,有什么理由不用呢?而答案也很简单,因为它暗含了错误,因为它不易于阅读和修改。于是我们用两个规则来限止全局量的使用:第一,尽量使用局部全局量,使该全局量的定义域最小,比如基于源程序文件的全局量,只有整个游戏的核心结构是全局范围内有效。第二,对于所有的全局量都对其内容进行全面的封装,如果是类库则使用成员函数,如果是结构,则定义专门的函数对其内容进行处理。
其次是数据的传递。我们经常遇到数据结构之间的互相调用和嵌套,很多人都喜欢使用指针(又是指针)做这项工作,因为它象全局量一样:直接、方便、快捷。但是我因为个人水平的问题在使用指针时总遇到一些不便,比如,易于出错,而且一旦出错就是很严重的当机,程序非法退出。要知道现在每启动一次计算机仍然是很耗费时间的。还有另外一个问题,那就是指针不能存盘,我必须另外制作一套程序对结构的存盘做特殊处理。所以我一般使用索引ID号来代替指针。具体的使用是这样的:数据库结构是以数据元素为单位的数组,数据元素的索引(ID)就是它在数据库数组中的下标。所有其它结构对该数据元素的保存都只保存了该元素的ID号。指针只在从数据库根据索引得到元素时使用。这样的好处很明显,ID号是整型数据,可以通过该值的范围判断出是否有效,只要在其有效范围内,再通过指针取值肯定是有效的,几乎跟本不会出现非法指针的现象。
程序开发计划
有了要做什么和怎么做,下面也就是需要什么时侯做和由谁来做了。
一般我们都会制定一系列的里程碑。比如演示版,原型版(体验版),测试版和正式版等。在这之间也可能会有其它什么版本。然后制定完成每一个版本所需要的时间,主要内容和验收标准。除此之外,我们还有:
程序中每个模块的制作时间的表格,
每个程序员分工的说明,工作量说明。
编程过程中可能出现的风险和问题的说明。
在写完总计划以后,我们还会有更加详细的月计划,阶段性计划,我们还会写程序进度表。我们所做的一切都是为了游戏程序可以按时完成。
影响开发时间的主要因素有很多:
·资金因素:这些钱至少应该支持到我们可以把游戏做完,而且能够把我们的游戏向市场推出;
·市场因素:是否是销售旺季,是否有同类产品产生竞争,本类型,题材的游戏是否还受欢迎;
·策划因素:游戏的复杂性有多大,内容有多少,策划的进度是怎样的;
·美术因素:美术的制作进度是怎样的,何时可以提供需要的内容;
·技术因素:用户的计算机硬件是否已经准备好。我们的技术是否会过时,开发新技术的风险会有多大。还需要制作哪些外围工具;
·人员因素:程序员水平如何,配合状况如何。
程序设计人员需要仔细考虑这些因素,才能根据具体情况制定比较完善的程序制作计划。
但是,很不幸,我从没有按时完成一个游戏,所以我目前尚不知道妨碍我们按时完成进度的到底是计划不够好还是执行的人不够努力。总之,在做计划时一定要留有余地,而且我的经验是我们完成游戏的时间一般比计划的要多20%到30%。可是如果按照这样的计划去做游戏的话,一定会把投资人吓跑的。于是在现实生活中这种状况就变成了“先斩后奏”,写报告的时侯说时间很短,而到时侯我们就因为某些原因不得不拖期,反正钱也花了这么多了,必须再增加投资而把它做完。所以无论是直接制作游戏的人或者投资制作游戏的人,我都希望他们可以对这件事有个比较客观的了解。也许在将来制作游戏可以不必再加班,制作计划也可以按时完成,但是至少在我们这里,还必须随时面对它们,依靠我们顽强的毅力和坚忍不拔的精神把游戏做出来。
1.5 制作流程
等到游戏计划写完,并得到通过之后,我们便开始进入游戏制作的时间。其实游戏的制作可能从很早就开始了,比如早期的技术探讨和准备。而我在这里想再说得稍微远一些,远离现在的题目:程序,而转向整个游戏的制作。
整个游戏的制作过程一般可以分成三个阶段。策划阶段,制作阶段和调试阶段。它们之间的关系一般应该是在策划阶段后期,开始程序和美术的制作,在制作后期开始游戏的调试。它们三者的时间比应该是3:4:3。我认为这是一个理想状态,但是我们往往达不到理想状态,典型的情况是游戏策划迟迟不能定稿,我们不得不在一边设计游戏时一边制作,而到了后期,制作又很难按时完成,必然压缩调试时间,最后只好仓促地发售游戏。它们三者的时间变成了4:6:0。我所说的这种状态当然只是一个极端,但是我想在我们目前所开发的游戏中或多或少都有这方面的问题。
造成这个问题的原因也有很多:
·游戏立项不规范:人们不知道为什么要做这个游戏;
·策划设计工作准备不足:人们不清楚该做什么、什么时侯做、谁来做;
·制作人员水平有限,缺乏经验:自己做不好,或做不快,甚至都不知道该怎么做;
·资金:没有钱谁会做呢?
·项目缺乏管理:各部门缺乏协调,大家群龙无首,缺乏沟通,最后大大降低士气和工作热情。
但是无论这些因素都是些什么,以及它将如何严重地影响我们,我都觉得只要有一样东西就足以支持我们把这个游戏做完。这就是我们的坚持不懈的努力。现在,我们在市场上的游戏质量都不算很好,但是我要说,只要它被做出来了,就是一个成功的作品。我不管游戏玩家们对我这句话有何看法,我只是认为,在那些作品的背后一定有一个或一些人在努力着。也只有他们才能真正体会制作游戏的艰辛。而且,只要他们还能继续做下去,经验会逐渐增加,配合会更加默契,游戏也一定会越做越好。
游戏程序的制作过程与游戏的模块划分有关,大概可以分成三个阶段:工具制作阶段,底层制作阶段,游戏制作阶段。
底层制作可能是最开始进行的阶段,有可能与策划阶段同时,甚至更早。因为这个部分与具体游戏无关,而可能只与游戏所要运行的平台和我们所使用的开发工具有关。我认为,开始的时间应该越早越好。游戏程序员在技术上的准备如果越充分,制作起游戏来就会越顺利。
在策划大纲基本上完成后,也就是游戏的类型,模式基本上固定之后,就可以开始游戏工具的制作了。游戏工具一般是提供给美术人员,策划人员使用的(当然也有自己使用的),所以在用户界面上应该多多听取他们的意见。这些天天与我们在一起的用户,如果我们都不能好好对待,那就更别提我们游戏的用户了。
游戏本身程序的制作是最耗费时间和精力的地方,如果说底层程序和工具都可以把重点放在结构化和优化上,这部分程序正好相反,我们通常做不到这些。因为我们在写这部分程序的时侯,总是已经精疲力尽了,而且我们的代码已经很长很长,一旦发现了程序错误,能找到就很不错了,更别提把它改得漂亮一点。再加上游戏策划随时提出的一点小改动,就可能要改变我们整体的数据结构,做大的改动几乎是不可能的。所以到了游戏制作后期,把程序员叫作铁匠更合适一些,他们在到处打补丁(注意,千万别在写游戏底层的时侯也干这种事)。
在游戏制作的中后期,每当程序员一睁开眼,进入眼帘的就是满是Bug和缺乏功能的游戏,这样的一个程序如何才能变成游戏中真正需要得程序呢?无论当初程序计划制定得多么好,在这时候都显得有些不中用。但这并不说明计划没用,而是需要我们把计划变成我们真正需要的东西,这就是每日工作计划。名字听起来很正式,其实并没有什么特殊的格式,仅仅是把我还没有做出来的游戏的内容一条条地写出来,不必区分什么模块和内容的多少,只要手写而且自己看得懂,然后贴在机箱上,要保证随时看到。这些内容我可能在一个星期后也没做,但是它会时时提醒我还有什么没有做。随着时间的推移,有些项目已经完成了,又会有新的内容写进来,而等到你最后积累出一大叠这种计划单时,你会发现原来游戏已经做完了。
这种办法我一直在使用,有时侯真觉得自己没有长进,做事一点计划也没有,可是它就是这么有效,你不必有什么经验,也不必整天面对着程序发呆,每天只要考虑如何把下面的内容做好就可以了。
不过我还是希望以后的程序员不必这样写程序,如果所有的人都能够每天按照预先制定好计划完成工作,也不需要加班,最终做出的游戏又很不错就好了。希望这不是永远的梦想。
1.6 程序调式
我在前面曾经说过,我不会使用SoftICE来调试程序。这证明我并不是一个很聪明的程序员。这也证明我在调试程序时会遇到更多的困难。让我每天在汇编代码和死机中漫游是件很痛苦的事,所以我总在想如何才能摆脱它们。
如果我不能很快地找到这些难缠的Bug,我能否在一开始就避免它们呢,甚至减少它们出现的机会呢?答案是肯定的。
死机(在Windows系统中有时是“非法指令”错)错误很主要的来源来自指针,但这并不是说我们就不能使用指针。恰恰相反,我们在很多地方都使用指针,而且出错的机会很少。原因很简单,那是因为我们在使用被保护的指针。如果你能证明你所使用的指针永远都指在合法的位置上,它还会出错么?所以我们在分配空间时大多使用静态空间和连续空间。所谓静态空间就是数组,连续空间就是指针数组。我们通过数组的下标来限制指针的读取,这样非常方便也有效。
封装也是减少错误的很好办法。我们把对内存的分配和释放封装起来,把对全局量的读取封装起来。在查找问题时就可以很快地缩小可疑对象。
对函数的返回值和错误代码的认真对待也可以让我们飞快地找到问题所在。很多人刚开始编程时都认为程序很短小,没必要写那么复杂的错误处理信息,也没必要判定函数的返回值。而我则恰恰相反,无论对待多么简单的程序我都会做非常详尽的错误处理。我甚至把显示错误的对话框写在底层程序中,以供随时调用。假如出现了错误,我就可以迅速知道错误发生的位置以及发生错误的大概原因,我的程序还可以正常地退出,不是继续执行而造成死机。
使用调试信息可以帮助我在Debug版中获得更多的有用信息。比较常用的有Assert()和OutputDebugString()。在错误刚刚发生时就拦截它要比它造成了更严重的影响后要好。
代码写得好看一些也有助于查错。假如你在一行中写进好几句代码,你将如何逐行跟踪呢?我们如果把函数,成员函数写得规范对称,虽然麻烦一些,但别人在使用时就会轻松多了。
这些办法都是在我们编写代码时需要注意的内容,我想如果你在准备开始编程时就对此有所准备,那么到你编程结束时应该能够节省不少调试错误的时间。但是Bug是一定会有的,无论你在当初如何注意,因为我们毕竟是人,会犯错误。我想任何人都有过把恒等号(双等于号)写成赋值号(单等于号)的经历吧(好在在Visual C++ 6.0中有这方面的警告了)。没有别的办法,我们仍然需要面对死机。一般的指针错误只会导致“非法指令”或“未知的意外”错误,除非你向一个不知名的地方写了大量的内容(比如几百K以上),一般还是可以安全地退到系统中的。这得非常感谢微软,Windows95/98还是要比DOS好。可是如果你所访问的是系统区域,或申请了与硬件有关的调用,比如在DirectDraw的GetDC()中设置了断点,程序就会直接退回系统或干脆死机。
这个时侯就全凭我们自己了。但是也不要害怕,因为,我们所面对的仅仅是一些代码而已,我们有一个最为严格和伟大的力量:逻辑。这可能是程序员唯一能够全心全意依靠的力量了。所以请大胆地使用它。
如果程序出错,一定有它的道理。哪怕运行一千次只出现一次也是如此。所以我们改正错误的过程实际上也就是找到错误的过程。如何找到错误呢?可以试着用下面的方法:
屏蔽法:把一些代码去掉,再看错误是否消失了,如果已经消失,那么错误可能在刚才屏蔽掉的代码中,再屏蔽掉另外的代码,如果错误又出现了,则错误肯定在这段代码中。要注意的是,这样做不一定能够找到错误,因为,有时侯错误是由两段代码或多段代码相互影响造成的。而且有的时侯我们找到的地方可能并不是真正出错的地方,你只找到了错误被引发的地方,而引发错误的地方还需要我们再仔细找。比如,一个指针指错了地方,那么我们会发现出错的地方是引用指针的地方而不是写入指针的地方。这时侯就体现出封装变量的用处来了,我们可以很快查出这个指针到底在哪被修改了。
如果上面的方法不能奏效,而且错误是在我们刚刚修改了代码造成的,那么可以使用比较法,找到原来版本的文件进行比较。所以我们需要经常保存备份程序,一方面是为了保证程序不丢失,另一方面也会增加找到错误的机会。尤其是当我们到了编程的后期,我们的程序大都编完,正处于修改的状态,而时间又特别紧迫。如果一旦出现了错误,我们只要比较一下新旧版本,就很容易找到错误。
如果你跟踪了半天也没有结果,几网下去一条鱼也没捞上来,而且必须在全屏状态运行,不能单步跟踪,每执行一遍程序就死机,又需要重新启动,这又该怎么办呢?那也不要担心,我们还有最后一招:输出Log文件。在每一个需要监视的地方,增加一段程序,向磁盘文件输出一段文字,如果程序运行到这里,该文件就会多出一段文字,如果程序运行到这里之前就死机或退出了,那么出错的地方就在这段文字和上段文字之间。虽然这样做比较耗费时间和精力,但是基本上能够找到出错的地方。找到之后,我们再去找出错的原因吧。
但是万一你现在还是没有找到错误该怎么办呢?那也不要着急,我所遇到的找到错误的最长时间是整整一天,有的人会长达3天,但是我目前尚未遇到查找单个错误的时间超过一个星期的。如果你寻找错误的时间还没有达到这个长度,就不要急于认为这个错误你是永远找不到的。
其实最难找的错误并不是这些错误,因为这些错误是确实存在的,所以我们改正它只是时间问题。而比较难以调试的错误并不是每次运行都出现的,有的错误需要特定的环境和条件,比如只在某台计算机上出现,有的错误则需要特定的操作,比如打开某文件,再关上,有的错误则干脆需要运行一定的时间才可能出现,比如运行一个小时。这时侯我们的工作才真正艰巨起来。
这时侯,也往往是开发的最后关头,最紧张的时侯,如果出现了错误,将使我们直接面临一个最严峻的问题,到底是发行时间重要还是修改程序错误重要。我姑且不谈到底谁重要。关键是如何解决这些错误。这里你可以看到,把错误消灭于未然是多么重要。我们工作的重点现在转向了重现错误。使用出错的计算机,频繁重复执行某些有关的操作,随时存盘以保存最近时的信息,都是我们常用的办法。
有的时侯重新启动一次计算机,重新编译所有代码,更换一些驱动程序,都可以解决问题。但是有些问题我必须说明:
第一,要以负责任的态度来解决问题。如果你发现有一个地方很明显不会造成错误,但是修改一下之后错误确实消失了,这时侯千万要小心,因为这个错误可能这样被你又隐藏起来了,在以后的某个时侯(可能是最后的调试期)再次出现,这时侯它将会更加隐蔽。
第二,不要动不动就认为是编译器,驱动程序,操作系统或计算机硬件有问题,而不下功夫去寻找错误。这些东西确实会有问题,但它们出错的机会比起我们自己要小得多,我觉得初级程序员还很难发现它们的错误。有问题还是先从自己那里着手吧。
1.7 代码优化
我对代码优化的研究并不深,归根结底是因为我并不是个很聪明的程序员。我曾经见到过一本非常好的书,名叫<图形程序开发人员指南>,英文名是Michael Abrash's Graphics Programming Black Book Special Edition。说它好有两个原因:第一,这本书从头到尾讲得都是程序的优化;第二,我看不懂。我想对于那些高级程序员来讲,他们可能就根本写不出有错误的代码,或者至少他们对于防止程序错误很有一套,总之,程序的调试对他们来讲根本就不是问题,所以他们有的是时间来研究程序的优化。而我所能讲的显然不能与他们相比,仅仅是非常初步的内容而已。
最优化的代码就是没有代码。
我忘了这是谁曾经说过的话。但是我觉得很有道理。有时侯我们把那些外国人想象得多么了不得,其实他们大多只是把这句话应用了一下而已。想一个巧妙的办法有时要比节约几个指令周期有效得多。但是这与我们具体所写的代码和人的经验有关,我们很难只用几句话就把规则说清楚。要知道,游戏的速度有时比游戏的效果重要,如果你对当前我们最需要什么样的游戏有所了解,做起决定就会容易得多。
如果我们真的需要对某些代码进行实质性的优化,那么首先我们要搞清楚哪里最浪费时间。我们常用Profile,VTune或自制的时间计数器来测量时间。而往往最浪费时间的代码大都很少,多是大量的循环最占用时间。
优化的级别也有区别:算法级,语言级和指令级。
体现一个程序员水平最重要的地方就是算法。一个好的算法可以使用非常少的代码就实现原来很复杂的操作。而它也是很难做到的。尤其这些算法经常与负载的状况有关,所以需要比较和测试才能有好的效果。
语言级优化就是我们采用较少的C语言代码来代替冗长的。比如使用临时的指针来代替多级的成员读取。把某些赋值语句放到多重循环的外面,使用inline函数,使用指针或引用代替结构赋值,使用指针的移动代替内存拷贝,把初始化操作放在一开始而不是循环之中。它所遵循的原则就是“无代码”原则,减少需要执行的语句是提高速度的最直接的做法。一般的程序员只要做到这一层就应该可以实现比较明显的优化效果了,这样的程序比较简捷,运行效果也比较稳定。
指令级优化则要深入得多,我对这项工作也并不十分擅长。这里所要用的语言一般是汇编语言,调试和测试也比较复杂,程序不太容易看懂,也更容易出错,有时与硬件有关。但是它所能实现的效果可能是一般人所不能实现的。所以这种方法一般被高级程序员所使用,所针对的代码数量应该比较少,正是刀刃上的部分。这样的优化是以指令周期做为单位的,所以千万要注意,不要让我们费尽心机所做的优化效果,被另外一些很低效的C代码给抵销了。
优化的内容一般有:
代码替换:使用周期少的指令代替周期长的指令。比如使用左移指令代替乘数是2的倍数的乘法。使用倒数指令(如果有的话)代替除法指令。这要求我们对80x86的每一条指令都有很熟悉了解;
减少分支预测:这是pentium 以上CPU特有的功能,它会在执行该指令前预读一些指令,但是如果有分支就会造成预读的失效;
并行指令:这是pentium 以上CPU特有的多流水线的优势。两条(或多条,在pentium pro以上)参数无关的指令可以被并行执行;
MMX指令:在处理大量字节型数据时我们可以用到它,一次可以处理8个字节的数据;
指令的预读:在读取大量数据时,如果该数据不在缓存里,将会浪费很多时间,我们需要提前把数据放在缓存中。这个功能大概要在pentium II的下一代CPU Katmai中才会出现;
Katmai指令:想一次处理4个单精读浮点数么?那就使用Katmai CPU 中的有关指令吧。
在优化时要注意的问题有:
第一,不要本末倒置。先优化大的内容再优化小的部分。这样我们才总能找到最耗费时间的地方而优化它;
第二,要经常比较。需要对每一种可能的方法进行比较,而不能只听信书上写的。奇迹经常出现在这里;
第三,要在效率和可读性上掌握好平衡,不要光要求速度而不管结构如何,最后造成隐藏的错误;
2 游戏实践讨论
现在有很多人对国产游戏事业是又爱又恨的。爱的是希望能够出现一批真正属于我们自己的好游戏,恨的是这些游戏制作者们太不争气,到现在也没有做出来。我非常感谢他们,因为假如没有他们,也就没有了我们。我也感到非常抱歉,因为我们的作品目前还不能让人们满意。我想多说什么都是无用的,只有实事才能说话。前面我所说的大多是理论上的内容,是我们在几年的游戏制作过程中总结出的经验。这些内容有些可能是错误的,因为我们还没有从正面证明它,有些对我们至今仍然只是个美好的愿望,我们自己还没有真正做到。所以请大家在阅读时针对自己的情况进行取舍。
下面我利用我们曾经做过的一个游戏,具体分析它的制作过程和制作方法。希望借此为那些关心游戏制作的人提供尽可能多的材料,让他们了解得多一些。也为那些有志于游戏制作的人提供一些经验和教训,让他们少走一些弯路。要说明的是,我们在处理某些问题的时侯,所使用的方法很可能是非常普通的,甚至是笨拙的,别人看来可能有更好的办法。但是我不想与各位争论,假如您有什么更好的想法和办法,就把它用到你的游戏中去吧,我希望每一个喜爱游戏制作的人都能够制作出更好的游戏来。
2.1 制作概况
<赤壁>作为瞬间工作室成立以来的第一个作品,是1997年7月上市的,这个版本称作标准版。其后增加了网络功能,修改了一些Bug,增加了一些游戏事件,被称作增强版,增强版于1997年底上市。在此期间,我们又制作了日文版,韩文版和繁体版,又为国内的OEM厂商制作了相应的版本。据说<赤壁>全部的销售量超过了十万份,其中零售量超过一万五千份。这是与前导公司所有员工的努力分不开的。
<赤壁>的策划工作开始于<官渡>制作的后期,1996年6月。而程序开始的时间要晚一些,在1996年9月。那时侯我们碰巧见到了另一部国产游戏<生死之间>的早期版本。这给我们的震动非常大,因为这两个游戏的类型比较接近,而我们才刚刚开始这个游戏的制作而已。从程序开始动工,到第一个版本发行,一共9个月,先后参与程序编写的程序员有5人,总的工作量大约40个人月。程序代码的总量约为90000行,2.6MB。大部分用C++编写,少部分由C和汇编编写。我们使用Microsoft Visual C++ 4.0编译<赤壁>标准版,Microsoft Visual C++ 5.0编译<赤壁>增强版。可执行文件大小约为500多KB。
<赤壁>的工作进度如下:
程序设计期:1996年9月初至1996年11月底。
底层制作期:1996年12月初至1997年2月底。
游戏编写期:1997年3月初至1997年5月底。
游戏测试期:1997年6月初至1997年6月底。
日文版: 1997年8月。
OEM版: 1997年9月。
韩文版: 1997年11月。
繁体版: 1997年12月。
增强版: 1997年12月。
需要说明一点:那就是我们的所有程序全部都是自己完成的,没有使用任何其他人其他公司提供给我们的代码。要知道,我们公司当时还没有能力去购买国外的游戏引擎,而我个人连SoftICE都不会使用,更不要说跟踪研究别人的代码了。我们对别人的学习方式非常简单和直接,就是观察。通过观察猜测它所使用的方法,然后考虑自己如何把它实现。这可能是我想到的最笨的一种方法,如果一个程序员能力强,在制作游戏以前,详细分析了解别的游戏的算法我想一定是非常有用的,他在制作游戏时一定可以节约不少走弯路的时间。
另外,我们对<赤壁>的测试时间也是很短的,在程序基本稳定之后,我们大概只剩下两个星期左右的时间。所以有很多Bug。
2.2 模块划分
赤壁的程序分成五个大的部分和19个模块:
显示模块
战场显示模块
分为通用显示底层和游戏战场显示。
通用显示底层是基于DirectDraw的一套函数。
有关内容请详见DDApi.h, DDCompo.h。
游戏战场显示是根据游戏单元的类型,位置,状态,动画帧等数据将单元位图以适当形式显示在战场的适当的位置上。要显示的内容有:地形,单元(士兵/建筑/将领),攻击效果,魔法效果,远程武器物体,阴影等。
主要功能有:对图素的压缩和读取,图像的显示,单元归属颜色的转换,边界剪裁,遮挡关系,缩略图显示和响应,屏幕移动,阴影遮挡判断,攻击效果,魔法效果,远程武器物体的显示和移动。
有关内容请详见CBDraw.h, 显示单元的位图
CBDrawM.h, 显示特殊效果,比如水。
CBDrawOT.h, 显示特殊物体。
CBMini.h, 显示缩略图。
CBMap.h, 图素的操作。
CBShadow.h, 阴影的计算和显示。
CBOther.h, 特殊效果的显示和计算。
界面显示模块
根据游戏设计需要,在显示器相应的位置上显示游戏的各层界面。
它分为两个部分:界面底层部分和游戏界面部分。
界面底层是属于底层部分的通用函数库。它包括按钮,对话框,滑块,检查框等界面元素的实现。
有关内容请详见DDBitmap.h, 显示位图的基类。
DDButton.h, 按钮。
DDCheck.h, 检查框。
DDList.h, 列表框。
DDMenu.h, 菜单。
DDScroll.h, 滚动条。
DDText.h, 文字。
游戏界面主要有:游戏主菜单,新游戏菜单,读取进度菜单,保存进度菜单,网络选择菜单,系统设置菜单,任务提示菜单,结束菜单,错误处理对话框等。
有关内容请详见Interfac.h, 提供基本的游戏界面接口函数。
Interfa1.h, 处理所有的按钮Button信息。
Interfa2.h, 处理所有鼠标操作发出的消息。
net_face.h, 网络部分界面。
Marco.h, 所有按钮的消息ID。
CBprompt.h, 游戏战场中屏幕右方信息的显示。
CBAarray.h, 对游戏元素的查询。
DDComUn.h, 针对游戏中下达命令时的命令组。
过场动画模块
显示公司标志,制作群,历时回顾,片头,片尾和过场动画。
它有两个部分,第一是播放视频图像,第二是调用其它进程。
有关内容请详见CBAvi.h, 视频图像的播放。
Mciapi.h, 播放AVI文件的底层函数。
VCMApi.h, 高效率的播放AVI的底层函数。
CBGame.h, 播放结尾。
Interface.h, 程序状态的转换。
单挑显示模块
武将单挑时出现的专门画面。本部分与原始设计有出入,原始设计中可以对单挑进行操作,后来删减称为只播放一段Video。有关内容请详见CBAvi.h。
控制模块
鼠标控制模块
根据鼠标的位置设置鼠标的形状,对鼠标的操作对响应单元发出命令。
主要内容有:鼠标点击检测,目的地模式,命令构造,可建造区域判定,鼠标形状转换。
鼠标点击检测,主要判断鼠标点击的位置是否在某个单元上或地形上。详见CBMouse.h。
目的地模式,主要控制鼠标选择了单元(命令主体)后,可能对单元下达的命令的模式,根据不同的命令可能需要不同的目的地类型和命令参数。详见CBCtrl.h。
命令构造,通过鼠标的点击选择或按下某个,命令按钮,构造出具有命令主体,命令ID和命令客体的命令,放到命令队列中,供执行。详见CBCtrl.h。
可建造区域判定,在建造建筑时需要判断哪里可以建造哪里不可以, 并且显示出来。详见CBBuild.h。
鼠标形状转换,鼠标移动到某个界面或某个单元上时,或处于命令构造阶段时,需要对鼠标的形状做一定的改变,以显示当前的操作状态。详见CBMouse.h。
命令处理模块
根据鼠标和人工智能发出的命令,传送给对象单元,并将其转化成为单元的相应状态序列。
根据单元状态,判断单元状况,更改单元的状态。
详见CBCtrl.h, 命令的构造保存和传递。
CBRun.h, 命令的执行。
CBRDelay.h, 命令执行时需要的一些变量。
攻击计算模块
根据敌我双方的攻防力量,计谋的实施和阵型计算每一次打击敌人生命的损失。
详见CBRun.h, 攻击计算。
CBZhenFa.h, 阵法计算。
行军控制模块
根据单元的位置,速度,目的地和地形数据,计算行军路线,设置单元的下一步。详见March_n.h。
网络控制模块
游戏数据在网络上的传递,纠错。模拟机的建立和管理。详见Network.h。
策略模块
君主策略模块
计算机一方根据战场双方的力量对比和战斗模式计算对单元的生产,对敌人的攻击,产生命令。
详见TEmperor.h。
将领策略模块
每一支部队根据将领的属性,士兵的状况调整战斗的方式或判断撤退。
详见TGeneral.h。
本能策略模块
士兵单元面对周围的情况产生固定的基本反应。
详见Tai.h。
Tbnbase.h, 人工智能中需要的数据结构。
CBEyes.h, 人工智能与游戏主体结构之间的接口。
文件模块
资料数据文件模块
地形和单元图素的图像文件,相应控制数据文件。单元的各项属性数据文件,操作用数据文件,界面位置数据文件。
详见CBData.h, 游戏中使用的单元属性全局数据结构。
CBMap.h, 游戏中使用的地图图素,单元图素全局数据结构。
CBGame.h, 游戏中使用的单元全局数据结构。
存储数据文件模块
存盘用数据,记录战场上的所有单元的状态和思考数据。
详见CBGame.h, 存盘。
其它模块
文件封装模块
为减少程序使用的文件的数量,增加程序的安全性,将大量的图像文件和数据文件封装起来,供程序调用。
详见L_Allbmp.h, L_Image.h, L_Save.h, L_Scan.h, L_Text.h, Tools.h。
声音模块
背景音乐和音效。有混音和音量控制。
详见DsWave.h, 播放WAV文件。
文字模块
在非中文系统下显示汉字,日文,繁体汉字和韩文。详见puthz.h。
地图编辑器
为使美术人员方便快捷规范地制做战场地图,提供专门的地图编辑器。同时为整个游戏的文件系统,显示系统做技术上的准备。
这是一个单独运行的程序,详见Mapedit。exe。
安装程序
将游戏安装和卸载。详见Setup。exe。
2.3 游戏引擎
说到游戏的引擎,很多人都不知道它是什么,以为制作它有多么困难。引擎的概念也很混乱,至少现在我还不知道它的确切定义。但我想如果一个东西要被称作引擎,它应该具有这样一些特点:
它应该是由函数组成的。
它应该实现某项具体功能。
它应该是完整的。
它应该可以被重新使用。
从上面的要求可以看出,其实这就是作为底层程序的要求。我想没有必要把引擎认为是游戏的现成编写工具,只要2改一下美术就是另一个游戏了。只要这些程序代码将会被我们应用在以后的游戏中,我们就已经节约了很多的时间和精力。
下面我会说一下在<赤壁>的代码里,哪些将被看作我们的引擎。实际上,这些部分经过一些修改后正在被我们应用到新的产品中。
显示底层:
这是一套包裹在DirectDraw外面的函数。为了简化在调用DirectDraw函数时的复杂度我们使用了一些缺省参数和内部错误处理函数。建立了一个CDDSurface类库,使得对位图的使用更加简单。详见DDApi.h
在DDCompo.h中我们有关于游戏鼠标的一套操作。在屏幕独占模式中,Windows标准鼠标有时显示会不正常。于是我们自己制作了鼠标的显示方法。方法很简单,在每帧读取鼠标的位置,然后在该位置上显示一张位图。
在赤壁里面,我们没有使用双缓冲区的模式,而是只更新某个特定的区域。它的优点是当需要更新哪里的时侯就更新哪里,对于哪些在每帧中都只有小面积图像需要更新时是非常高效的。比如在486上,<赤壁>的主游戏界面里的鼠标移动仍然是十分流畅的。可惜的是,在<赤壁>的战场部分,它并没有优势,因为基本上是需要全屏刷新的。
在未来的游戏制作中,因为计算机的速度越来越快,所以我们当时所使用的模式恐怕变得不太适用,双缓冲区模式应该是主流方向。
多媒体底层:
主要包括声音和视频。我们使用了MCI设备来播放AVI,WAV,MIDI,CD AUDIO等内容。那曾经是我们在做上一个游戏的时侯完成的部分。但是它有很多缺点,比如不能同时播放多个WAV文件,这对于我们制作游戏音效是很重要的内容。
所以我们又使用DirectSound来播放声音。这里的难点在于当我们需要播放很长的文件时,不能一次读入,而需要建立新的线程按时装入声音。好在现在大部分游戏都使用CD Audio作为背景音乐,不需要WAV。
界面底层:
基于显示底层之上的界面元素其实并不好做。因为我们总希望它的响应方式与Windows95中相同。而大家在<赤壁>里看到的内容就与Windows95有些不一样。比如滚动条(ScrollBar)对鼠标的响应就非常简单,按钮(Button)的反应也有所不同。但是好在它比较简单,易于使用。
在每做完一个游戏之后,我们都习惯要把某些东西整理一下,看看它是否可以在以后被使用起来。而往往这些东西也都是需要不断修改的。因为程序运行的平台不一样了,它的用途也不一样了,而我们的编程水平也不一样了。但总之这些代码被较为完整地保留了下来,它必将是我们今后编程的基石。
2.4 关键讨论
我刚开始编写游戏的时侯总有一个想法,只要游戏的主要部分写完了,游戏也就差不多了。我也遇到过一些游戏制作组的成员,他们也大都是这样的想法,认为只要把游戏的演示版拿给别人一看,然后只要再投资让美工画一些画,游戏就可以做完了。其实事情并不想想象中那样简单。
在我看来,把游戏的大概样子做完了,顶多占整个游戏的三分之一。另外的两个三分之一分别是整个游戏的制作和测试。
举个简单的例子,比如我们在演示版中通常只有一个兵种和一个战场。游戏的显示效果可能很不错。但是,真正在游戏中不会只有一个兵种的,每方都会有大概十种兵,又会有三四方的敌人,这时侯你的显示底层是否能够胜任呢?内存是否会占用太多呢?这时侯还需要我们对其进行优化和修改。连游戏的底层显示部分都可能需要修改,更何况游戏中还有更多的内容呢?
下面我举一些<赤壁>中的例子,这些都可能是极小的问题,但都是我们需要仔细考虑的问题。在你准备开始制作一个即时战略游戏之前,你是否曾经考虑过这些问题呢?假如你对这些问题有所了解,那么你就应该可以非常有把握地马上开始制作游戏。如果没有,也没有关系,因为这些问题我也没有全都事先考虑过。
假如你有时间,可以对你自己的游戏多多考虑一下,这个游戏距离一个真正的产品,到底还缺什么?还有哪些模块和部分没有做完?当你对两者之间的差距有了一个明确的认识后,也就不会担心了。任何东西都是一点一点做出来的,只要按照你想做的内容去做就可以了。
程序状态的转换
我们在写DirectX程序的时侯,总有一种偏见,那就是不希望Windows界面出现在我们的游戏里。于是什么都需要我们自己做。比如说窗口。因为窗口的刷新需要我们自己管理,就觉得没有必要生成多个窗口了。这样所有的窗口消息就必须在唯一的一个函数里实现。可是我们的游戏里有很多种不同的操作,比如界面,系统菜单,播放视频等,这些内容就都必须在这个地方处理。所以我们就引入了程序状态这个概念。我们定义了一系列的状态,在每个状态里,有固定的操作和响应,状态之间的转换也在特定的时侯进行。这样我们就很容易把一些关系不大的内容独立开来,降低程序的复杂性。
其实我们在实现这一部分的时侯是很混乱的。你很难在代码中找到所有状态转换的地方。但是它的实现很简单,一般的规则就是程序的对称性。有专门的装入函数和释放函数,然后有显示函数,计算函数,鼠标消息响应函数,热键响应函数。在内部,需要结束本状态时就发出一个状态转换的消息。在外部,只要在主程序的主循环和消息响应函数处针对不同的状态执行它们不同的函数就可以了。
这里的关键在于状态的转换。因为状态在转换中一定会释放和申请大量内存,如果有的内存没有释放,转换次数一多就会出现问题。如果我们把状态转换的地方写好了,程序看起来也非常干净整齐,Bug也会比较少。
现在编写游戏,需要装入的图量非常大,很有必要制作一个装入时的画面,并且显示百分比。这是我们可能需要一个装入中的状态。遗憾的是<赤壁>并没有实现这个,它的淡入淡出效果仅仅是效果,程序在执行这部分时停止在这里。
有关内容请详见CBGame.cpp和Interfac.cpp。
执行任务
一个士兵在接到我的命令之后,便开始了它的行动。它察看了一下它的命令,这是由两个部分组成的。它先取出第一个部分,是行走。于是根据自己现在的位置和目的地位置先计算了一下路线,把自己当前的状态设置为正在移动,然后根据计算好的方向和自己的速度把自己移动到一个位置。如果这个位置已经到达目的地了,它就停止行走。又察看了一下剩余命令的部分,是攻击。于是它拔出刀砍向附近的一个敌人。敌人死了么?它不停地问自己。如果敌人真的"哇"的一声倒下了,它就得意地站在一旁,微笑着。
这就是我们在战场的一个角落所看到的一个士兵的表现。而实事上,在程序里我们也是这样做的。
我们把每个士兵做为一个单元,独立地处理自己的事物。我们把每个命令划分得更加细致,而称其为状态,执行每个状态时所需要的参数的执行的步骤是最简化的。每个命令都是由一个或多个状态组成的。一个状态满足后就自动转向下一个状态。我们为每个状态都编写专门的开始,运行中,和结束代码。这样每个士兵都在独立地按照顺序处理自己的事务,一个个复杂的单元行动就变得有条有理了。
有关内容请详见CBRun.cpp。
阴影
有人说Warcraft做得好,有人说C&C做得好。我属于前者一派,这不仅仅因为我对Warcraft的观察多一些,而且有一个理由足以说明Warcraft在程序上比C&C更高一筹。那就是阴影。大家都知道Warcraft是双层阴影,而C&C是单层。双层阴影的好处是更加真实。在我们去过一个地方之后,虽然我已经看清楚了地形,但是这里敌人的活动,我们不应该永远知道。可是要把单层阴影变成双层,并不是只要加上一层显示就可以解决问题的。
首先,增加一层阴影就增加了需要显示的时间。时间对于即时战略来讲是至关重要的。它直接影响到游戏的表现效果和操作速度。多加一层阴影就意味着减少了我们增加效果的机会。在我们这里,双层阴影占用了大约5-10%的显示时间。
其次,增加一层阴影就增加了特殊的计算。一层阴影只需要一个二进制数组记录哪里被打开,哪里被隐藏就可以了。而现在则不同,我们需要把那些已经打开的阴影再关上。我们的做法是生成一个二维数组,每位表示一个图素格子。当有一个士兵的视野可以打开这个格子上的阴影时,就把那里的计数器加一,离开时减一。当为0时则这块地形被重新隐藏。
第三,阴影下的单元需要特殊的处理。当我的士兵离开敌人阵地的时侯,敌人附近被半透明的阴影重新覆盖了,这时我们需要把正在那里活动的敌人士兵隐藏起来,而建筑不动。
这就是双层阴影所带来的。可是为了效果,我们不得不如此。看起来还是达到了效果。
除了双层阴影以外,阴影的边界也是很重要的问题。我们不可能把阴影做得和刀切的一样,而让它必须和打开的地面相结合。于是我们必须要有一套贴图,用于阴影边界的各个方向。好在我们在这里利用了一个偷懒的办法(当然是很巧的办法,是Onefish想出来的)。我们采用了一个椭圆的贴图,让相邻的椭圆相切,从而造成边界。因为椭圆是没有方向之分的,我们也就节省了一些贴图的内存和对使用哪张贴图的复杂计算。
有关内容请详见CBShadow.cpp。
选中谁了
<赤壁>战场里的格子是菱形的。这一点有多少人发现了?又有多少人知道它的代价?或许有人认为菱形的格子并不是个很好的注意,但我们恰巧在这里使用了它。当鼠标按下时我们需要知道它放在哪个格子里了。过去的经验是只要知道该点的位置和每个格子的宽度我们就可以很快地算出格子的序号。但是在这里不行,因为格子是菱形的,在一个矩形区域里的点不能被确定它到底属于哪个格子。
怎么办呢?我制作了一个二进制二维数组,大小是格子的宽和高。在这个区域里,如果是属于矩形内接菱形区域内的点设置为1,否则为0。当鼠标在某个矩形区域里时,我就用这个栅格去判断该点的位置上的数值是1还是0。是1则这个点在这个菱形格子里,否则不在。这个办法是我遇到的比较迅速的一个办法。
命令的产生
一个由游戏者发出的命令,一般有三个部分:命令主体,命令本身和命令客体。命令主体就是执行命令的人。我想对这支部队下达命令,这支部队就是命令的主体,在游戏里就是我们用鼠标选中的一群士兵。命令本身是命令的标识。我想下达的是什么样的命令,在游戏里,可能是我按下了某个命令按钮。命令客体是命令结果的接受者,如果我选择了攻击命令之后,再选择了一群敌人,那敌人就是命令客体。
如果所有的命令都这样下达,那编起程序就简单了,玩游戏的人也会累死了。我们常用的玩法是选择了命令的主体后直接用鼠标右键点击命令客体。而命令的产生就由命令主体和命令客体的类别来确定了。
这才是需要我们考虑的东西。
首先是选择命令主体。当我们在战场上鼠标一点,或圈了一个框,然后一松手,这时侯程序就开始算了。这里我要介绍一下<赤壁>的单元类型。我们把单元分成士兵,将领和建筑。士兵又有已经组建成部队的和尚未组建成部队的。士兵的种类可以分成一般士兵,工人和船。我们的选择规则是:组队的士兵优先于建筑。将领优先于组队的士兵。未组队的一般士兵优先于将领,未组队的船优先于一般士兵,未组队的工人优先于船。另外还有特殊处理。按下Ctrl键则选中该组队士兵的将领,按下Shift键则把刚选中的士兵添加到已选中的士兵中。
其次,是选择命令的客体。命令客体可分为地点和单元。地点有普通地点和资源地点。单元又分为敌人的和我方的。单元的种类有士兵,将领和建筑。士兵分为未组队的和组队的。士兵的种类有工人和一般士兵。建筑又分为一般建筑和资源建筑。我们需要根据命令主体和客体共同来判断命令是什么。不同的组合所产生的命令可能可能是不同的。比如,如果我选择了组队的工人作为命令主体,又选择了一个建筑作为命令客体。如果这个建筑是铁矿,那么命令就是采矿。如果这个建筑是我方的建筑,则这个命令是修理。如果这个建筑是敌人的建筑,那这个命令就是攻击了。
有关内容请详见CBCtrl.cpp。
眼睛函数
在游戏策划编写人工智能的时侯,总喜欢写成这样:“当匪兵甲发现敌人就在不远处时就去攻击。”而要把这句话变成程序,还需要不少的路要走。比如匪兵甲是如何发现敌人就在附近呢?首先必须要知道匪兵甲自己在什么地方,其次匪兵甲的视野有多大,也就是说多远算作附近,第三敌人在哪里。这三样东西匪兵甲是如何知道的呢?依靠的就是眼睛函数。
一般我们在编写人工智能时都会把它作为一个比较独立的模块。因为它的主要功能就是判断。它的出口同游戏者的鼠标操作一样,发布命令。如果它的入口也很明确和简单则这部分内容的难度就非常小了。可是人工智能需要得到大量的信息作为判断的依据,而我们的主体数据结构又可能写得不很好,这时候就需要我们另外制作一套程序把游戏内核与人工智能分开。这就是眼睛函数的用处。
编写人工智能的程序员只需要写出他想知道什么,然后交给游戏核心程序员,由游戏核心程序员从游戏核心数据中找到相应的信息,传递给人工智能。这样编写人工智能的程序员就可以根本不知道游戏核心数据是什么样子就可以开始工作了。但是这也是临时的办法,
如果我们可以把核心数据结构写得风雨不透那也不必如此劳神了。
有关内容请详见CBEyes.cpp。
命令队列与网络
本以为网络编程很简单,一写下来才知道头大。虽然这部分代码不是我写的,但是我也知道跟踪一个10兆大的Log文件是什么滋味。网络上的游戏通讯一般有两个办法:交换所有信息和交换重要信息。第一种方法是随时把自己的所有数据都传递到另一端,另一端根据这些数据进行显示和操作。它的优点是不需要同步,对网络状况要求低,计算也非常简单。缺点则是数据量太大,只传递少量数据还可以接受。所以这种方法多用于RPG游戏并多在Internet上使用。第二种方法则只传递关键的数据,计算机接到这些数据后对其进行计算,因为计算的结果是唯一的,所以可以保证网络两端一致。这种方法适用于那些数据量大的游戏,但它的编写和调试就比较复杂,而且对同步的要求较高。我们的<赤壁>就是采用的这种方法(<西游记>用的是第一种方法)。
在这种网络的联接方式中,最重要的问题一个是同步,一个是结果唯一。只有同步我们才能保证在每一轮的计算中所有计算机上的初始状况是统一的,只有结果唯一我们才能保证在执行了一段时间后所有计算机上的状态是统一的。这样才能保证网络游戏的正确性。
除了初始化等必要得操作以外,我们把游戏中的命令作为关键信息在网络上传递。游戏者对战场上部队的任何操作,下达的任何命令都会被传递给其它的计算机。如果游戏者没有操作,则我们制3在每一轮循环中传递同步信号。在这里我们遇到了一个DirectPlay中的问题,那就是信号并不是每次都会正确传递到对方那里的,尽管我们在DirectPlay中使用的参数要求系统必须在确认对方收到后才返回,可是它返回后对方依然没有收到。关于这个问题我没有查看有关文档,也许有更好的解决办法。所以我们自己制作了一套校验确认的方法。
但是我们在本机上发布的命令传递到另一台计算机上时一定会有延迟,这就不能保证初始状态的一致。所以我们采用了轮回制。任何人发布的命令并不会马上被执行,而是先存储到一个命令队列中。在每次循环中对曾经下达的所有命令统一进行发送,侦听。当所有计算机都收到了所有计算机本轮要执行的命令后,所有的命令开始被顺序执行。这样在任何一台计算机上本轮中所有命令的内容和顺序都是相同的,执行时才能保证其结果的一致。
有关内容请详见CBCtrl.cpp, CBRun.cpp。
兵种的颜色
这大概是即时战略游戏里最隐蔽的一个问题了。因为不做的人不知道,而做的人都心照不宣。在我们的游戏里,不同的君主用不同的颜色代表,属于该君主的士兵则在它的衣服上赋予相应的颜色。这样虽然兵种相同但是他们的颜色不同,这样就可以区分敌我了。但是如果我为每一个兵种都制作一套图素,那么就显得有些浪费了,这会占用过多的内存(我们这里所有兵种的图素量约有10MB,如果有四种颜色就需要40MB的图素)。谁都希望找到一个好的办法节省这些内容。
我们的方法很简单,抽色。我们在调色板里的留出固定的位置,需要改变的颜色就按照顺序存储在这里。在显示时,我们会根据传递进来的君主号为每张图素的颜色进行判断,如果颜色在这些区域里就根据规则偏移。这样各种颜色的士兵就会显示出来,而只需要一份图素。
这种方法的缺点就是速度慢,毕竟需要对每个点进行判断和处理。所以这部分也是我们针对MMX优化的主要部分之一。
怎么样?奇怪么?有关内容请详见CBDraw.cpp。
遮挡
我在设计游戏开始时就考虑过单元的遮挡问题,因为这曾经是<官渡>遇到的问题之一。游戏单元在屏幕上的位置会经常有所变动,但我们必须保证一点,即下面的物体总要把上面的物体遮住。但是我发现这个问题实际上在<赤壁>里几乎不存在。因为在每一帧都是重新显示一屏内的所有信息,只要我每次从上向下画图就可以了。当然这是个极为极端的例子。我们仍然需要考虑遮挡的问题。比如,人被山挡住的问题。我们通常为了在显示函数里显得结构化一些,都是先显示地形,再显示人物。那么当人物走到了山的上部时,就需要山峰挡住人,可是这时侯山都已经显示完了,我们必须再次显示山。而显示山的时侯又不能够显示整个的山,我们必须划分出显示的区域,让它可以显示又不会遮挡别的地形。
调整这部分内容可能占据了我制作显示部分的绝大部分的时间。好在最后实现了,但我仍然在想是否有更好的办法解决这个问题。
有关内容请详见CBDraw.cpp。
行军算法
我们在制作<官渡>时没有给士兵以一套完整的行军算法,让它去寻找一条到达目的地最近的路线,而在<赤壁>里想做一下尝试。就自制了这样一套算法。首先找到一条直线,然后围绕挡住这条直线的障碍物搜索一条可以行走的路线。说起来简单,做起来可很困难。这部分代码写了大约有四五个月。
当然它也仍然有缺点,比如如果在路上的障碍物内部有空洞,那么就会卡住。但好在它的速度还算可以,一百个人同时走路时才会感觉到一点停顿。
本来我们打算在<荆州>里有所改变的,我们采用了世界上比较流行的行军算法:A*算法。并且为它专门制作了一套底层,使它可以挂在任何一个地图结构里。
有关内容请详见March_n.cpp。
为什么有数量限制
又回到了老生常谈的话题。编程序的人一看就可以知道,对士兵数量进行限止的唯一理由就是没有使用链表。的确是我胆小的缘故。我没有在游戏的核心数据结构中使用链表,原因很简单,怕出错。我为每个游戏者开了200个单元的空间,也就是说每方游戏者都可以制造200个士兵和建筑。我想这个数量应该是够了,因为如果真的每个游戏者都生产出这么多数量的单元,游戏也跑不动了。
使用数组不仅出错的机会少一些,而且它对于查错也很有帮助。在我们的早期测试版中,我让游戏每隔10轮就自动存一次盘。存盘的内容就是简单的把这些数组内的内容写到文件中。如果程序跳出,马上调出存盘很快就会跟踪出错误的位置。后来,在<赤壁>标准版发行后,也有玩家反映有异常退出的错误,我就让他们把存盘文件寄给我,我就能很快发现问题。如果使用指针链表恐怕就没那么容易了,但也有一个好处。如果玩家玩出一个错误,只要他再重新玩很可能这个错误就不出现了。
有关内容请详见CBCtrl.cpp。
镂空贴图
我估计有不少初学编游戏的人都会有这个疑问,如何让一个人出现在地形上而身上没有黑边。要知道计算机的显示一般都是以矩形为单位的,一个边界不规则的图形该如何显示呢?
假如我们按点去操作,那么就可以判断如果人的图像上是黑色则跳过去,否则显示到背景上。但是谁都知道这样是很慢的。于是我们想到了使用逻辑操作来贴图。我们有一个真人的图像,边界是黑色的,我们叫源码。然后我们制作一个掩码,人的内部是黑色的实心,外部是白色。然后我们做两次逻辑操作,先把人的掩码与背景做“与”操作,这样背景就被扣出了一个黑色的轮廓,然后把人的源码与背景作“或”操作,人就呆在背景里了。
如果你看到我们的地图编辑器,就会发现我们就是这样做的。这是<官渡>的做法,在编写编辑器时我还不会使用DirectDraw呢。但是在DirectDraw环境下,我想大家就可以省事很多了,只要一个ColorKey的变量设置,DirectDraw就可以替你完成镂空贴图的操作。
从这里你也可以看到我们也许不会一开始就把游戏做好,但只要坚持下去,我们一定会越做越好。
有关内容请详见DDApi.cpp。
2.5 希望
有人说即时战略游戏已经过时了,或者已经做到头了。我的观点截然相反。
我认为它是目前唯一能够胜任大规模集团作战的游戏类型,它所能够带给人的东西我们还远远没有做到。我是很喜欢看电影的,尤其是战争电影,那些电影里的场面和效果在游戏里出现了么?有,但是还远远不够。
我承认我们的制作能力与国外的水平还有很大的差距,但是即时战略依然会有它的发展。
比如,即时战略已经开始慢慢向战棋式的战略游戏靠拢,采用多层战略地图,多级控制的方式。这就是一个可能的发展方向。即时战略也可以不采用采矿的方式,而利用其它(比如经营)方式来获得资源,这也是一个可能的发展方向。
我总是觉得,制作游戏就是在创造世界,虽然我们的这个世界还无法与真实的世界相比。但是,只要我们做了,就会有进步,就会离梦想近一点,也许我等不到那一天,可是也许某个看到我的文章的人等得到那一天,这我已经很高兴了。制作游戏的路刚刚开始,对于我们而言,我们希望能够继续做下去,可是上帝毕竟是广大的玩家,如果大家不认可,那我们还是趁早改行为好。但我总是有些不死心,总希望能够有人坚持下来,如果这个人不是我。我真心地希望能有更多的人了解游戏的制作,了解制作游戏的人,也希望有更多的人加入到这个充满了艰辛的事业来。因为美好的明天需要我们去创造,我们为游戏付出了很多,它也会同样地回报我们的。
以上内容仅是我个人的观点,如果有任何异议请与我联系,但是我不会与各位争论什么,因为只有做才是最重要的。如果有什么问题,也可以来信,但是我可能不会很快或很详细地回答,因为我现在正忙于制作新的游戏。
我的E-mail地址是liu-gang@263。net。
假如你已经整装待发,那么就开始吧,不要犹豫,人的一生只有一次,只要去生活就是了。