Q3 MOD制作入门教程 

2006-12-19 14:19 发布

4383 1 0
 
来自:www.q3acn.com

0. 前言

"blah, blah, blah..."

各位quaker晚上好,白天也好,这里是q3acn.com的article栏目,这次为你准备的内容是mod制作入门教程,在屏幕的右方你可以看到目录,其中第1节是给还没有自己编译过q3游戏部分源代码的朋友看的,第2节和第3节是游戏源代码的一些入门知识,就算你不打算做mod,只是对quake3的运行机制有兴趣,这两节也很适合你,最后两节我们就要自己动手做两个有趣的功能,不过如果你直接从第4或第5节开始看的话,有些概念可能会不大明白,建议你还是按部就班的依次阅读下去。

整篇教程比较长,你可以需要把它们保存到硬盘上离线浏览,里面有几张图片,你要选择全部html或者是mht。

在解释或分析源代码的过程中,我会尽量不直接说,这个是什么意思,而那个又是干什么用的,我会尽量把我如何知道它们的含义和用途的这个过程也写出来,一方面是为了你以后解决自己的问题提供经验,另一方面,即使你不打算继续研究这些代码,我也希望这篇入门教程能给你带来多一些DIY的乐趣。

可能你对编程不熟悉,甚至对编程一点概念没有,请不要畏缩,虽然对这些源代码进行修改需要一定的编程经验,和一段时间c语言的学习,但是如果只是阅读和理解它们,frag的经验远比编程经验有用,给一个没打过quake的编程高手这套源代码,他理解这些代码的速度绝对比你:一个quake老兵同时也是编程新手,要慢得多。在头几节对于出现C语言的地方和VC的一些技巧做出了一些解释和说明,当然,只是为了让你读起来比较流畅,想仔细研究代码和学习VC的话,你需要专门的教科书,注意,你不需要任何关于c++的知识,尽管VC又被称为VC++。

Disclaimer & Copyright

1、既然你已经点击了这篇文章,说明你对mod制作抱有兴趣,有必要在一开始说明,制作出来的mod只能被免费的通过网络发放,而不能被用于任何商业用途,这是你在安装源代码的过程中必然要同意的条款,而不只是quake社区乌托邦式的理想。(我知道进来看的人都是quaker,跟你说这个有点不尊重你,但我在生活中的确遇到过这样的人,跟我说“既然地图编辑器,游戏源代码都公开了,那做几幅地图,做几把新的枪,不就可以当任务版卖钱了嘛”,.............他们都是不打quake的,不要跟他们一般见识)

2、作者已经尽了最大努力保证文章中信息的正确性和准确性,但如果在阅读本文或依照本文进行操作的过程中造成了对你的软件/硬件/精神上的损害,与作者无关。

3、你可以在不做任何改动并注明原始出处(www.q3acn.com)和作者(ed9er)的前提下任意在网上转载全文或引用或文字图片,无须通知作者,但作者不允许传统媒体进行上述行为。








1. 运行你的第一个MOD

"Ease of use is damn important." —— John Carmack

1.1. 安装源代码

先准备好Q3_1.29h_src_fixed.zip这个文件(1.26M),这个包里我已经把原来1.29h源代码的一些编译错误改正了,另外添加了几个.bat;为了消除版本不一致可能带来的问题,你还需要有quake3 1.31。

你必须把zip中的所有文件解压到某个盘的根目录下的quake3目录,在这篇文章里我们用D:\quake3,如果你的quake3路径本身就是某个盘的根目录下的quake3目录,那么建议你不要解到这个目录下,换一个盘。

1.2. 生成qvm

你只需要简单的执行\quake3\code下的那几个bat文件,就可以生成相应的qvm文件,其中makeall.bat是生成所有三个模块的qvm文件,你可以现在就双击它,等它运行结束后,你就可以在\quake3\baseq3\vm目录下找到那些qvm文件,baseq3这个目录是q3asm.exe来建立的,我们无法更改它的名字,这也是为什么建议不要解压到quake3的运行路径。

下面我们用1.17源代码的readme里写的慢速火箭弹来做例子试试在quake3里运行我们的mod。

到game\g_missile.c的649行,把900改成300,存盘,运行code\game.bat,生成qagame.qvm,这时我们要把这个文件打包成zip:直接在\quake3\baseq3\vm这个文件夹的图标上用右键菜单来生成vm.zip,这样可以方便的保存文件的路径信息(和你的winzip的设置有关系,有可能你需要在baseq3目录上生成zip,反正最后你要确定在用winzip打开这个文件时在qagame.qvm这行的path列可以看到"vm\"),然后把vm.zip改名为vm.pk3,到你的quake3运行路径下建立mymod目录,把这个pk3拷贝过去,然后运行quake3.exe +set fs_game mymod +map q3dm17,然后吃RL,开炮。

(如果你运行的结果和预期不一致,火箭弹还是以常速飞行,请检查以上步骤,尤其是zip内目录的信息)

1.3. 用dll来实现mod。

quake对mod的支持走过了一条还算比较曲折的道路,在quake1的时候,用的是著名的quake c,其实就是个复杂的脚本解释器,在97年左右的电脑商情报上曾有比较详细的讲解,carmack本人对于众多玩家通过这个简陋的qc来开始编程之路既感到自豪又感到惭愧,于是到了quake2的时候,通过若干个.plan与玩家进行讨论后,carmack直接采用了windows的dll机制来分离引擎与游戏代码,这样就局限了quake2的mod的跨平台性,在quake3的开发过程中,java逐渐火了起来,carmack也曾流露出直接采用java/jvm来分离引擎与游戏代码的想法,但由于java的诸多不足,使得他最后放弃java,不厌其烦的自己实现了qvm,具体可参见:A chat with John Carmack,

qvm是quake virtual machine的缩写,carmack采用了lcc编译器并做了一些修改,由lcc编译生成.asm,然后q3asm.exe这些.asm再进行一次处理,生成字节码,这些字节码会被quake3.exe在运行时转换成本地的机器码,这样既可以让玩家通过标准的C语言来编写MOD,同时又可以做到跨平台。而如果你需要使用到windows提供的各种系统调用或者是其他一些机制,qvm就无能为力了,但是,你仍然可以通过dll来实现,quake3在初始化的时候会在当前fs_game目录下寻找相应的dll,如果找不到,再去寻找qvm,但有一个很重要的区别,就是qvm可以打包在pk3文件中,而dll不能打包到pk3中。(看一下bg_lib.c,这个文件里实现了很多但不是全部的标准c函数,如果你使用了额外的函数,那么为了编译成qvm,你必须在这个文件里给出你的实现,如果是平台相关的函数,我就建议你不要做qvm的打算了,因为quake3支持的平台太多了,你不大可能拥有iMac, irix等等来测试并实现你的平台相关函数)

如果你机器上安装了VC6的话,你可以直接打开\quake3\code\quake3.dsw,在编译和运行之前你需要更改一下Project Setting,如下面两图,e:\quake3是我机器上的quake3运行路径,你需要把它改成你的quake3运行路径,quake3_1.31.exe是我用的exe的名字,你也需要更改,完成后设置game为活动项目,然后按F5就可以直接进入我们刚才改好的慢速火箭弹模式了;如果你需要在程序中设置断点,或者是进行单步执行,查看变量等debug动作的话,你需要把mymod/q3config.cfg里的r_fullscreen改为0,r_mode改成2,另外,不要通过VC的结束调试命令(Shift+F5)来强行终止quake3,这样会使你的系统不稳定,每次都用quake3的菜单或者是/quit来退出。



注意:当sv_pure为1的时候quake3只允许载入pk3文件以及.cfg、.menu文件,所以当你使用dll来调试的时候,一定要把sv_pure设为0,否则quake3会在你mod目录下寻找包含qvm的pk3并使用qvm,如果没有找到,quake3会使用baseq3下的缺省的qvm,当你最终发布的时候,再编译成qvm并打包成pk3。

如果你机器上没有安装VC6,并且你决心制作MOD或者是仔细研究q3游戏源代码的话,我强烈建议你装上它,而不要只是用UltraEdit和.bat,一方面是VC6强大的browse功能可以让你迅速查找变量、结构、函数的定义及调用关系等等,更重要的,你可以真正进入debug状态来运行代码,这在qvm是不可能的。

1.4. 如何从MOD菜单LOAD我们的mymod

首先,这个MOD的子目录下一定要有至少一个pk3文件或者qagamex86.dll,否则quake3不认为这是一个合法的MOD子目录,在MOD菜单中根本就看不到,这也是为什么我们在前面把vm\qagame.qvm打包的原因,你也可以不打包,直接在mymod下再建立一个vm目录,把qagame.qvm拷贝进去,然后再拷贝一个打过包的以.pk3结尾的地图文件进mymod,这样也可以工作,但是这样在sv_pure 1的情况下可能会有问题,还是建议你把qvm老老实实打个包。

然后,在MOD菜单中你就可以看到一栏"mymod",这是我们的目录名,但不是我们的MOD描述,我们需要在mymod下新建一个文本文件description.txt,里面只写一行:"My First Mod",这行文字就会显示在MOD菜单中,当然你也可以用"^1My First Mod^7"来显示红色的字,注意,description.txt不能打包到pk3中。






2. 代码的框架结构

"Discover YOUR World" —— Discovery Channel

我们先浏览一下它的目录结构:


\quake3\binnt:编译源代码所需的可执行文件lcc.exe, q3asm.exe
\quake3\source\lcc\bin:编译源代码所需的可执行文件cpp.exe, rcc.exe
\quake3\ui:菜单的定义,你可以用文本编辑器打开那些.menu文件看个究竟


\quake3\code\cgame:客户端(cgame: client game)的源代码
\quake3\code\game: 服务器端(game)的源代码
\quake3\code\q3_ui:用户界面(ui: user interface)的源代码
\quake3\code\ui: TA的新增的界面功能的源代码

code下面这四个子目录下面各有一个dsp,和.bat,那么,我们就可以从这整个代码里编译出四个独立的模块(dll或者qvm),它们分别是:cgame、game、q3_ui和ui,在这里,id把名字弄的比较混乱,ui目录下是TeamArena里新增加的界面功能,而q3_ui目录下才是quake3的界面功能,ui目录下编译可以得到uix86_new.dll和ui.qvm,q3_ui下编译完成后可以得到uix86.dll或者q3_ui.qvm,dll的文件名是正常的,而如果你编译q3_ui目录下的东西并得到了q3_ui.qvm的话,你必须把它改名为ui.qvm才可以正常使用;这些在那几个bat里已经都处理了,你不用操心了。

ui目录我估计没人会用得到,我也一行没看过,我们来看q3_ui目录,这里面的.c文件全部都以ui加下划线开头,仔细观察一下他们的名字,你很容易就会联想到你在游戏中的菜单操作,譬如:ui_addbots.c——添加bot,ui_controls2.c——操作设置,ui_cinematics.c——选择观看过场动画,等等,随便打开一个你都可以看到一大把menu什么的结构,和UI_XXXMenu_Init之类的函数,至此,你可以很有信心的说,这个目录下面的程序负责了(也只负责)所有菜单的显示,选择,反馈。

然后我们来看cgame目录,这个目录下面文件都以cg加下划线开头,在这里我们光看文件名就看不出个所以然了,必须去看文件的内容,主要注意函数的名称就可以了,在这里只能简单的跟你说它控制了客户端HUD、比分牌的显示,以及与game的协调,对玩家移动的预测(这也是为什么一个LAG以后玩家会觉得被往后拽了一下),model的载入,音效的控制,等等。

最后是最庞大也最重要的game目录,一开始是一些以ai加下划线开头的文件,它们是关于BOT的控制,然后是bg加下划线开头的文件,它们是同时会被cgame使用到的文件(bg: both game),然后是g加下划线开头的文件,它们定义了这个世界的运行规则(quake3.exe负责把这个世界展现到你的显示器上),最后是几个以q加下划线开头的文件,它们是各个模块(包括quake3.exe和q3radiant)在编译时都要用到的文件,这几个文件你永远不需要也不能去改动它们。

至此,你至少知道要改某样东西需要从哪里下手了,下面我们来仔细看一下game模块。

首先一个最根本的问题,编译完之后得到的这个qagamex96.dll有哪些函数供quake3.exe调用,会在什么时候被调用?一个解决方法是在命令行用VC自带的工具dumpbin /exports来查看;另一个办法就是在源代码里找编译dll时指定导出函数的关键字:dllexport,你会发现找不到,他们把导出函数的定义放到了game.def文件里面,打开看就可以看到了;最后一个办法就是查看源文件里函数的调用关系,找到最顶上的那个,也就是在game模块里不被任何函数调用的函数,应该就是由quake3.exe来调用的了,通过VC的browse功能可以很方便的展开被调用树,你会发现几乎所有函数最后都归结到一个叫vmMain的函数,这和我们在game.def里面找到的是一致的,它在g_main.c的183行。(如果你真的去打开game.def文件看了的话,你会知道另外还有一个dllEntry,马上会谈到它)

在vmMain的注释里写的很清楚:这是唯一一个进入子模块的入口。它的第一个参数叫command,看下函数体就可以发现,这个参数指明了quake3.exe因为什么样的原因来调用子模块:GAME_INIT(初始化)、GAME_SHUTDOWN(结束)、GAME_CLIENT_CONNECT(有玩家连入)、GAME_CLIENT_DISCONNECT(玩家离开)、GAME_RUN_FRAME(一个server祯开始)、GAME_CONSOLE_COMMAND(控制台命令),等等。至于在每一种情况下这个game模块都干了些什么,就需要你跟着那些函数一层层进去看了。

好,我们再使用VC的Browse功能来查看函数的调用树(刚才是被调用树),你会发现所有函数最后都终止在一些sprintf等很简单的函数,以及,许多以trap_开头的函数,查看这些trap_函数的定义,它们在g_syscalls.c里面,这个文件一开始定义了一个函数指针syscall,并在dllEntry里对它进行赋值,在trap_函数里使用它,也就是说,quake3.exe负责调用dllEntry,告诉game模块它提供的函数的地址,然后game模块就可以使用这个函数(syscall,一个函数指针)取得quake3.exe提供的功能,这些功能是什么呢?

我们知道quake3的pk3文件其实就是zip文件,那么如果我们需要读取这个pk3文件中的一副贴图或者是一个wav,我们该怎么办?先解压?不用,quake3.exe内已经实现了透明的zip文件读取,并维护着一个很大的目录树,你只需要调用两个trap函数就可以读取你需要的文件,在pk3中的文件!它们是trap_FS_FOpenFile和trap_FS_Read。

再譬如,在cgame中(在game里我找不到好的例子了,cgame也有同样的函数调用机制),我们需要播放一段wav,你该不会去用windows的MCI函数来控制声卡吧,我们有trap_S_StartLocalSound。

类似地,若干个trap函数,这些函数都是三无产品:没有文档没有注释没有实现,它们都隐藏在quake3.exe内部,对于某一个特定的trap函数,如果你要弄清楚它的调用格式,以及它完成的功能,你只能看它在已有的代码中是如何被调用的,然后模仿现有的格式来使用它,千万不要妄加揣测自行其道。这里有玩家制作的一部分trap函数的文档:Q3 Documentation Project,还没有全部完成,你如果觉得你吃透了某个trap函数的用法,也可以去添一笔。

至此,知道了入口和出口,我们知道我们可以干什么了,譬如,我们无法直接通过网络传输数据,因为没有这样的trap函数,我们只能利用quake3.exe内部的传输机制(譬如server command或config string,等等,这是个很大的话题,有的在下面的内容里会讲到,但主要还是*你自己去探索)。

OK,告一段落,弄清楚模块划分和vmMain、trap_之后,你在看代码的时候就会有点方向感了。

还有,在代码中你会经常碰到:


#ifdef MISSIONPACK

凡是看到这个东西,那都是TeamArena用的,把中间的部分略过,直到#endif或者#else,并且你要注意别把你添加的东西放到了这些ifdef内部。







3. Hello, Quake World!

"Things will be done when they are done, and they should be pretty good. :)" —— John Carmack

在开始这一节之前,建议你把目前整套代码拷贝一份出来,如果改了900为300,再改回去,这样做有两个目的,一是为了你以后可以方便的从一套干净原始的代码的基础上开始修改,二是你可以保留这份代码专门做学习用,在里面添加注释,写上自己的理解,等等。

3.1 如何在屏幕上打印文字

我们马上要做的事情是在屏幕的中央打印出"Hello, Quake World!"这行字,你可能立即会想到,en...肯定有个trap函数可以往屏幕上写东西,对,一点不错,但是考虑一下有没有现成的封装的更简便函数供我们使用呢?回想一下我们在游戏中的时候会遇到哪些出现屏幕中央的信息,en...FIGHT! 好,在code目录下查找含FIGHT的文件,用VC的Find in files,选中搜索子目录,选中区分大小写和匹配全字,搜索结果只有一个:


cgame\cg_servercmds.c(445): CG_CenterPrint( "FIGHT!", 120, GIANTCHAR_WIDTH*2 );

幸福啊,我们只要替换下参数就可以打印需要的东西了,这时你不妨看一下CG_CenterPrint的实现,你就会发现它并没有直接使用trap,它只是把你要打印的东西放到了一个叫cg的结构变量的centerPrint成员变量里面,而cg是一个全局变量,那我们继续查找cg.centerPrint,这次可以找到3个,头两个就是在CG_CenterPrint里面,我们已经看到过它们了,而第三个在1837行,双击这个查找结果,我们就到了CG_DrawCenterString函数体内,一层层进去看一下吧,最后到了CG_DrawChar函数体内终于使用了trap做真正的输出,现在你该不会还想自己用trap来做了吧。

那么,我们在什么时候打印这行字呢?比较常规的办法是通过控制台命令来开始打印,(你也可以在开枪的时候打印,在看完这一节后结合你已经知道的哪个函数是发射rocket,你就可以自己完成)。我们都知道在控制台的输入命令需要以正斜杠或反斜杠开头,这些命令又分为两大类:cvar的变量名和控制台命令,而cvar又分为server端的cvar和client端的cvar,譬如g_gametype就是server端的,而cg_drawGun就是client端的,他们分别可以在g_main.c和cg_main.c里找到,还有一类cvar,是由quake3.exe来创建的,譬如r_mode,你可以在子模块中设置它的值,(尝试在code目录开始查找r_mode,你可以学习到一个trap函数的用法),这类cvar我们称之为internal cvar;控制台命令也可以做game/cgame这样的划分,也有一些命令是由quake3.exe来负责执行的,譬如vid_restart(尝试查找vid_restart,你可以学习到另一个trap函数的用法),这部分命令我们称之为internal command。

3.2 cvar和command

在这里我想费点口舌讨论下quake3是如何管理这些cvar和命令的,我不知道确切答案,但从quake2的源代码和我们现在手上的1.29h的代码是可以做出一些推测的,当然,推测不一定正确,但至少和程序的表现一致。

quake3.exe在初始化时会负责创建和初始化它内部的internal cvar和internal command,cvar结构中包含了它的名称,缺省值,属性,internal command的结构中包含了命令名称和它们对应的函数,然后quake3.exe会依次载入ui、cgame、game模块(具体顺序我不知道),在ui模块的初始化过程中,它向quake3.exe注册它模块自己的cvar,同样,在cgame模块的初始化过程中,它也要向quake3.exe注册它自己的cvar(cg_main.c/Ln308),game模块也一样,在g_main.c/Ln319。

好,这时quake3.exe就知道了所有cvar的信息,当我们在控制台敲入cvar的名字时,quake3.exe就可以找到这个cvar的值,以及它的缺省值,显示出来给你看。在这里你可能会有疑问,我在quake3的控制台中敲命令更改cvar的值后,更改的应该是由quake3.exe维护的数据,那它们是如何被反映到game/cgame子模块中的呢?也就是说game/cgame里的程序怎么知道玩家对这个值做了更改呢?这个问题就留给你自己去解决了,(提示:GAME_RUN_FRAME和CG_DRAW_ACTIVE_FRAME时的vmMain)。

而控制台命令有所不同,当你敲入的字符串不是cvar时,整个过程比较复杂,我在这里画了张流程图,你可以看一下,里面有几个地方要说明,一是playing server的意思,它就是你在非dedicated的quake3里敲map q3dm17得到的那种server,一般大家在局域网上打着玩都是开这种server; 二是5、7、8这三步它们所能解释的命令列表,你可以根据图里的vmMain的参数到程序里找到, 我在这里把它们给出来,但还是希望你自己从vmMain开始寻找它们:5: cg_consolecmds.c/Ln433,7: g_svcmds/Ln398,8: g_cmds.c/Ln1578;三是在第2步中要注意"是否可执行"这个判断,在一个dedicated server上运行vid_restart显然是不可执行的。



在这里,我们把第5步能解释的命令称为cgame console command,第7步能解释的称为game console command,第8步的称为client command,我们可以画出这样的一张表:



你可能会问,对于一个dedicated server,类似/callvote这样的client command有什么意义?在game console command处理的最后,我们可以看到,如果是dedicated server,那么所有不能识别的命令都会被作为server的广播信息发送出去,并返回true,也就是说,不再会被当作client command查找。

直到现在,一切都还很清晰,但是且慢,看到有个文件叫cg_servercmds.c没有,我们已经知道了console command和client command,可什么是server command啊?来,我们把它打开来看,用VC的Browse的函数列表功能,我们可以看到,这个文件的关键所在应该是一个叫CG_ServerCommand的函数,到它的函数体里面一看,en,又冒出一把命令,其中有一个叫cp的,它唯一做的事情就是调用CG_CenterPrint,哈哈哈,白忙乎啦,原来已经有在屏幕正中打字符的命令了,好,打开quake3,进入地图,进入控制台,敲"\cp hello",没有反应,敲"\cp",还是没有反应,但是也不显示unknown cmd,奇怪啊奇怪,好,退出quake3,继续看代码。

我们来看CG_ServerCommand在什么时候会被调用,看它的被调用树,当然最后都是vmMain了,但在下面一级,我们看到是CG_DrawActiveFrame,这个函数是在vmMain(CG_DRAW_ACTIVE_FRAME)的时候被调用的,也就是说,这些所谓的server command是在画每一祯画面的时候被执行的,和我们从console输入的东西一点关系没有,那么,刚才我们敲"/cp hello"的时候自然也就不会有东西输出到屏幕上。

那么,这些命令是从哪里来呢?根据server command这个名字,我们觉得应该是由game模块来传输给cgame模块的,到code/game目录下查找cp,我们看到了诸如此类的调用:


trap_SendServerCommand( -1, va("cp \"%s" S_COLOR_WHITE " joined....\n\"", ... );

那么我们就完全有理由相信,这里的所谓server command,只是用来供game向cgame发布指令用的,它们和前面的command的最大区别就是它们是无法通过用户输入来执行的,同样在CG_ServerCommand的函数里,我们还可以看到map_restart,但它和我们敲命令map_restart完全是两个概念,后者是由quake3.exe来识别并执行的,一定要分清楚。

同样,我们在CG_ServerCommand还可以看到chat、cs等等,如果试图通过控制台输入这些命令,就会得到unknown cmd,而cp在这里是一个特例,它不但不返回unknown cmd,甚至在sv_pure 1的时候还会把你踢回主菜单,我对我画的那个流程图还是有信心的,我怀疑是quake3.exe内部对cp做了处理,所以我在quake3.exe中查找cp,找到一个地方很有嫌疑:nextdl..download...cp..getIpAuthorize,然后我们拿download来试验,同样不会返回unknown cmd(必须要进入地图),由此可以确认cp是quake3.exe内部能识别的命令,属于internal command,但它又和server command里的在屏幕正中打印字符的命令名字相同,就象两个map_restart一样,我甚至怀疑cp在quake3.exe里的意思是拷贝文件....哦,security hole?

讨论完这个特殊的cp,我们继续回到server command的话题上来,因为它不是通过控制台输入的,所以我们不能把它添加到上面的结构图中去,你只要知道有这么个东西,这么个机制存在,不要把它和结构图中的command弄混淆,并且知道我们可以利用这个机制从game向cgame发送指令就可以了

似乎到这里我们已经可以结束关于cvar和command的讨论了,不过不知道你注意到没有,我们没有找到console command和client command的注册机制,quake3.exe只是简单的把它不能解释的命令按照流程图里的顺序一步步交给子模块,并通过检查返回值来判断是否被解释执行,那么quake3.exe是如何知道这些命令的名称并且可以让你在控制台通过TAB自动完成功能查看到所有匹配的命令呢?答案在cg_consolecmds.c/Ln520。

3.3 实现

抱歉,让你在实现个如此简单的功能之前看了那么多东西,希望你没有太着急,这些付出都是会有回报的,我们下面就可以非常轻松了。

首先我们给这个命令起个名字,我们就叫它"hello"吧,然后我们要决定把它添加到哪一种command里面,当然不可能是internal command和server command,只能是console command或者client command,为了给下一节的内容做个铺垫,我在这里采用client command,你也可以自己试着用game/cgame console command来完成它,不同的实现适用于不同的情况,你可以根据前面的流程图看出来。

看到这里,你肯定已经手痒了吧,现在我们就开始动手。

根据我们在前面找到的client command在程序中的位置,我们到g_cmds.c/Ln1578,看ClientCommand这个函数,它通过比较命令的字符串来执行不同的函数,我们要把对于"hello"的处理添加进来,但是添加在什么地方呢?在函数中间有如下语句:


// ignore all other commands when at intermission
if (level.intermissiontime) {
Cmd_Say_f (ent, qfalse, qtrue);
return;
}

intermission的意思就是一局打完了,但新的还没开始,这个时候没有活动的玩家在地图中,大家屏幕上都是比分牌。我们的HelloWorld在这个时候也不该显示在屏幕上,所以我们要把它添加到这段的后面。

我们要添加一个else if在最后一个else前面,然后在else if中做我们要做的事情,这里我们看到原来的代码都只是简单的调用一个函数,我们就不用了专门写函数了,因为我们的功能很少,反正也只有一行;我们使用前面查找cp的结果:


trap_SendServerCommand( -1, va("cp \"%s" S_COLOR_WHITE " joined....\n\"", ... );

并把要显示的东西换成我们需要的,去掉里面的变参代换,改完以后这段程序应该是这个样子:


else if (Q_stricmp (cmd, "stats") == 0)
Cmd_Stats_f( ent );
else if (Q_stricmp (cmd, "hello") == 0)
trap_SendServerCommand( -1, va("cp \"Hello, Quake World!\""));
else
trap_SendServerCommand( clientNum, va("print \"unknown cmd %s\n\"", cmd ) );

运行一下试试,进入地图后敲"\hello",如果前面一节的慢速火箭弹你已经成功运行了的话,这次的hello应该也一点问题没有,但如果屏幕上没有反应,你需要检查编译的步骤,以及目录的设置。

3.4 简单的c语言解释

这一小段是给没有任何编程知识的人来看的,你可以跳过。

如果"cp \"Hello, Quake World!\""这样的写法让你感到困惑,你可以尝试把它改成你觉得直观的形式:"cp Hello, Quake World!",并且看一下运行效果,你会发现屏幕上只显示了一个单词Hello和后面的逗号,也就是说cp这个server command只显示了跟在它后面的第一个单词,我们可以在它的实现的地方(cg_servercmds.c/Ln968)看到:


CG_CenterPrint( CG_Argv(1), SCREEN_HEIGHT * 0.30, BIGCHAR_WIDTH );

在c语言里,字符串都是用双引号包起来的,如过你要在这个字符串的内容里有双引号,那么你就需要用一种特别的方式来书写,以免这个双引号被错误的解释为字符串的结束,这个特别的方式,就是用反斜杠开头:\"

那么,其实刚才va函数得到的字符串的内容是:


cp "Hello, Quake World!"

对于这样的情况,va函数会把它原封不动的返回,从而trap函数得到也是这个字符串,然后quake3.exe会负责把这个字符串发送给客户端的quake3.exe,然后这个quake3.exe再把它交给cgame模块来作为server command解释,在968这行语句上,我们看到了CG_Argv(1)这样的调用,这个函数会返回cgame模块收到的这一串server command的命令后面的第一个单词,就好比我们在dos下使用dir *.exe一样,这里,*.exe就是第一个单词,在取出这个单词之后,就可以调用CG_CenterPrint来显示它了。

那如果你把里面的\"去掉了,那么cgame收到的是:


cp Hello, Quake World!

这时,第二个单词是Hello,,而如果有引号的话,第二个单词就是引号内的整个字符串。

到这里你可能要问,既然va函数只是原封不动的返回,那我们何必用va呢,直接这样不也可以嘛:


trap_SendServerCommand( -1, "cp \"Hello, Quake World!\"");

当然可以!但是有时候你可能会需要在屏幕上显示玩家的名字,后面才是Hello;这就就要用到刚才提到的"变参",也就是可变参数,在我们修改之前,原来的trap_SendServerCommand可能是这个样子:


trap_SendServerCommand( -1, va("cp \"%s" S_COLOR_WHITE " joined the red team.\n\"",
client->pers.netname) );

这句是显示某玩家加入了红队,那么client->pers.netname里面就是玩家的名字,中间插入S_COLOR_WHITE是为了避免有的人不以^7结束,影响到后面字体的颜色,那么我们可以把我们的语句改成:


trap_SendServerCommand( -1, va("cp \"%s" S_COLOR_WHITE ": Hello, Quake World!\n\"",
ent->client->pers.netname) );

这时,%s就被替换成了玩家的名字。

关于c语言的格式,只能写那么多了,以下的内容里不再对c语言做解释,你该考虑弄一本c语言的课本了。

楼主新帖

TA的作品 TA的主页
B Color Smilies

全部评论1

  • 暴米花
    暴米花 2006-12-19 14:20:00
    3.5 -1 ?!

    在我们前面使用trap_SendServerCommand的时候,第一个参数用的一直是-1,这个参数是什么意思呢?我们看trap_SendServerCommand的函数定义,就可以看到,这个参数的名字叫clientNum,也就是client的编号,那么-1代表哪一个client呢?你已经猜到了,是所有client,也就是说,我们刚才改出来的代码会导致一个玩家敲hello,所有人屏幕上都显示字符,现在我们来改正它。

    我们的改动是在ClientCommand这个函数里,这个函数只有一个参数,居然也叫做clientNum,下面你知道该怎么办了。

    到这里,我们算是把这件事情做完了,不是很难,是吧?如果你有兴趣的话,可以继续试一试上面提到的在cgame console command或game console command里添加命令,把名字起做"cg_hello"和"g_hello"是不错的选择,(虽然看起来比较象cvar :),你也可以试试在发射火箭弹的时候往屏幕上写东西(这一个比那两个难一点,因为你要想办法到算出clientNum,在这个过程你又会学到些东西)。







    4. Jump Practice Tool

    "How many road must a man walk down,before you call him a man?" —— Bob Dylan

    这一节我写了两遍,第一遍完成的时候有8K,现在你看到的是第二遍的结果 (废话!),因为我觉得有了上一节的基础,你完全可以独立完成这个工作,而不需要我再一步步的在教程中把它完成,那样是在剥夺你的乐趣。

    跳跃练习几乎是所有quaker必定要做的功课,大家都在这上面花费了无数个小时,不知道你有没有想过如果我们可以象在q1/q2的sp模式里那样存取盘就好了,就不会再有跳进wp/重生后哼哧哼哧的赶过去,也不会再有为了精确定位起跳点用mg在地上打出一排起跑线这种事了。

    我们要做的事情就是要能保存并载入玩家的位置和视角,这个工作可以分解为如下部分:

    1、添加相应的client command命令:"savep"和"loadp"
    2、在savep的处理函数中保存当前位置和视角
    3、在处理loadp的函数中用前面保存的数据覆盖玩家当前的位置和视角
    4、为了方便练习PG/GL/RL的跳跃,在玩家进入游戏时自动拥有这三把枪,并弹药无限
    5、取消slime/lava/falling的伤害,以及RJ的伤害


    如果现在你就开始着手做,你可能会觉得这个东西还有点麻烦,这时我们的frag经验就变的很有价值了,回想下在游戏里有没有从一个位置忽然到另外一个位置的时候?你会脱口而出:传送门哎!英文叫TelePort,对,就从这里入手,找到原来的游戏是怎么处理玩家进传送门的,你会发现整件事情就变的简单多了。另外,更改初始状态在g_client.c/Ln1150附近,更改弹药无限在bg_pmove.c/Ln1620附近,取消伤害在g_combat/Ln975行附近,到那基本上都是一看就明白的。

    作为参考,你可以在这里下载我改完以后的代码,只包含被修改的文件,所有我添加或修改的代码都以如下格式开始和结束:

    添加:


    /// -----> add
    ....
    /// <----- end add

    修改:


    /// -----> modified
    ....
    /// <----- end modify

    你可以通过查找开始那三个斜杠找到它们,你也可以采用这种格式来进行你的工作,会给你以后带来很多方便。









    5. 地雷

    如果说上一节的例子只是有用而不是有趣的话,这一节的应该算比较有趣了,我们要在quake3里添加一种新的武器:地雷!其实添加武器是一件很麻烦的事情,要修改的东西非常之多,而且还需要做个model,我还从来没真正做过添加新的武器,在这里我们只能因陋就简一下,把GL改装成地雷发射器。这个东西比上一节也略微要难一点,所以就不再提供修改好的代码下载,你需要一步步看下去 —— 很好玩的!

    我们需要修改发射出来的榴弹的属性,让它不再跳来跳去,而且不会过2秒半就爆炸,同时,有玩家碰到它的时候它会自动爆炸,就那么多。

    我们找到发射榴弹的函数:fire_grenade(就在最早修改慢速火箭弹的那个函数附近),这个函数生成了一个新的实体(entity),并赋予它一些属性,这个实体就是发射出去的榴弹,看到函数里那个2500没有,它定义了榴弹发射后经过多长时间爆炸,单位是毫秒,这个值再加上当前游戏的时间,赋给了nextthink,然后在每一个server祯开始的时候,游戏会检查所有实体的nextthink,看它是不是已经小于当前时间,如果是的话,就说明它该think一下了,也就是调用这个实体的think函数(函数指针),可以看到,think函数在这里被赋予G_ExplodeMissile,也就是说,在2秒半以后这颗榴弹就会爆炸,我们把这个值改为300000,也就是5分钟,5分钟过后这个榴弹,哦,不,地雷,就报废了。

    然后我们要让它碰到地面就停住,不要弹来弹去,fire_grenade函数里只有EF_BOUNCE_HALF看起来象是运动属性的定义,我们查找EF_BOUNCE_HALF,就会发现一个叫G_MissileImpact的函数,它被G_RunMissile函数调用,在每个server祯开始的时候,游戏对所有类型是ET_MISSILE的实体调用G_RunMissile,你可以在G_RunFrame函数里看到这些检查和调用;当G_RunMissile检测到这个导弹(还有可能是RL/PG/BFG/HOOK)碰撞到墙壁,就会调用G_MissileImpact,现在我们来看G_MissileImpact。

    在G_MissileImpact的一开始,它就检查这个导弹是不是会弹跳的,如果是,就计算弹跳路径,然后返回,如果不是,则安置它的最终位置并爆炸;我们要把计算弹跳路径去掉,并且为了放置好正确的位置,把返回也去掉,让它继续往下执行,这个函数的一开始那段就应该是这个样子:


    /// -----> modified
    // check for bounce
    if ( !other->takedamage &&
    ( ent->s.eFlags & ( EF_BOUNCE | EF_BOUNCE_HALF ) ) ) {
    /// G_BounceMissile( ent, trace );
    G_AddEvent( ent, EV_GRENADE_BOUNCE, 0 );
    /// return;
    }
    /// <----- end modify

    那句G_AddEvent不要去掉,去掉就没声音了。

    继续往下看,对G_SetOrigin的调用应该就是设置最终位置,再往下就爆炸了,在G_SetOrigin后面加上:


    /// -----> add
    if (!strcmp(ent->classname, "grenade"))
    return;
    /// <----- end add

    改到这里,我们先进游戏看看效果,dm5有GL,而且比较小,load快,进去后我们发现它的确是不弹了,但一碰地下就炸了,应该是我们返回的太晚了,就在G_SetOrigin前面几行,有一句:


    ent->s.eType = ET_GENERAL;

    这句看上去就很不对头,把榴弹的类型都去掉了,而且还有一句


    ent->freeAfterEvent = qtrue;

    这句暂时不懂,不管它,把我们的代码移到它们前面,并自己添加上G_SetOrigin后直接返回,现在看起来应该是这个样子:


    /// -----> add
    if (!strcmp(ent->classname, "grenade")) {
    G_SetOrigin( ent, trace->endpos );
    return;
    }
    /// <----- end add
    ent->freeAfterEvent = qtrue;
    ent->s.eType = ET_GENERAL;
    SnapVectorTowards( trace->endpos, ent->s.pos.trBase ); // save net bandwidth
    G_SetOrigin( ent, trace->endpos );

    再进游戏看看效果,这次更有意思,在爆炸的烟雾散尽后我们发现,榴弹安安静静的躺在地上,哎,还是返回晚了,回去再看,看到前面那个if-else if-else里有很多G_AddEvent,很可能烟雾就是它们散布出来的,而且我们就知道ent->freeAfterEvent = qtrue;是针对这里添加的event而言的,继续把代码往前移:


    /// -----> add
    if (!strcmp(ent->classname, "grenade")) {
    G_SetOrigin( ent, trace->endpos );
    return;
    }
    /// <----- end add
    if ( other->takedamage && other->client ) {
    G_AddEvent( ent, EV_MISSILE_HIT, DirToByte( trace->plane.normal ) );
    ent->s.otherEntityNum = other->s.number;
    } else if( trace->surfaceFlags & SURF_METALSTEPS ) {
    ......... 省略

    这次就完全正常了,你现在肯定在心里嘀咕:“这个家伙明明知道最后结果还带我在这里兜圈子”,是啊,我现在是知道结果了,但在一开始不知道啊,你以后解决自己的问题的时候也一样要经历这样的过程,经验越多越好嘛。

    现在我们要让它在被玩家碰到的时候会自动爆炸,而不是等到5分钟,那是定时炸弹,不是地雷。

    做过地图的就会知道,地图里有种实体叫trigger,触发器,譬如DM7/RL处的那个方块,在FFA的时候用来控制进地牢的门,我们的地雷也需要这样的功能,查找trigger,你会看到一个叫G_TouchTriggers的函数,当然,查找结果很多啦,你浏览一会就会发现这个函数最管用,我也是这么浏览出来的。

    函数一开始判断来触发trigger的是不是玩家,以及是不是尸体,然后根据这个玩家的坐标扩展出一个立方体,调用trap_EntitiesInBox,判断立方体中有多少个实体,然后对于每一个实体,检查它是否有touch这个功能,以及有没有CONTENTS_TRIGGER这个标志,并且确保spectator只能和传送门发生关系,最后检查它是不是可被拾取的item,是的话,检查距离(这个item可能是正在半空中往下掉的),不是的话,要严格检查是否接触。

    我们的地雷应该有一定的范围,不一定非要贴在一起才爆炸,你想那榴弹才多大一点啊,再说大家都是跳着走路,要求那么严格的话,踩雷估计会变得跟拣宝一样,那么我们就忽略上面这两种检查,直接用函数开始的时候从玩家坐标扩展出来的这个立方体来判断,也就是说,被trap_EntitiesInBox返回的榴弹就算是被踩到了,改代码成如下样子:


    if ( hit->s.eType == ET_ITEM ) {
    if ( !BG_PlayerTouchesItem( &ent->client->ps, &hit->s, level.time ) ) {
    continue;
    }
    }
    /// -----> add
    else if (!strcmp ("grenade", hit->classname)) {
    }
    /// <----- end add
    else {
    if ( !trap_EntityContact( mins, maxs, hit ) ) {
    continue;
    }
    }

    我们还要给榴弹的实体加上touch函数和CONTENTS_TRIGGER标志,否则G_TouchTriggers不认为它是个trigger,到g_missile.c里加。

    我们不能直接用G_ExplodeMissile来做touch功能,因为函数类型不一致,我们自己写一个,放在文件的开头:


    /// -----> add
    void G_ExplodeMissile( gentity_t * );
    void G_ExplodeMissile_Touch (gentity_t *self, gentity_t *other, trace_t *trace)
    {
    G_ExplodeMissile (self);
    }
    /// <----- end add

    然后到fire_grenade里添加:


    /// -----> add
    // act as a trigger
    bolt->touch = G_ExplodeMissile_Touch;
    bolt->r.contents = CONTENTS_TRIGGER;
    /// <----- end add

    进游戏试试!恩,的确一碰就炸,而且120的血一下就死了,这地雷也太凶了点吧,怎么回事?我们没改杀伤力啊,正常的榴弹直接落到自己身上也应该只掉50点血啊,更别说是放在地上的了。

    唯一合理的解释:它爆炸了不止一次!G_TouchTriggers是在每一个server祯开始的时候被调用的,尽管在G_ExplodeMissile这个函数里有把freeAfterEvent设为true,但在这句前面添加的event可能在下一祯才开始,而且很有可能持续若干祯。只有在你机器比较慢的情况下,有可能保住命。

    知道了原因,我们想该怎么办,爆炸这个event是肯定要有的,而且也只能在event完了之后再free,不要问为什么只能这样,既然原来的游戏是这么做的,它必然有它的原因,在做改动的时候一定要知道自己在干什么,如果不知道在event结束之前就把这个实体给释放会导致什么后果,就不要这么改。

    每次触发器被触发的时候,G_TouchTriggers会调用被触发实体的touch功能,在这里实体是grenade,touch函数是我们自己添加的G_ExplodeMissile_Touch,我们可以在这个函数里阻止它再次爆炸,阻止它再次被触发,让它不再是触发器:


    /// -----> add
    void G_ExplodeMissile( gentity_t * );
    void G_ExplodeMissile_Touch (gentity_t *self, gentity_t *other, trace_t *trace)
    {
    G_ExplodeMissile (self);
    self->touch = NULL;
    }
    /// <----- end add

    再实验,正常了。

    还没完,我知道你在嘀咕,就那黑乎乎的一团放地上也能称为地雷?别人都不长眼睛啊!

    我只有一个解决办法,虽然不是很完美,找到我们刚才添加的那个G_SetOrigin调用,在G_MissileImpact函数里面,在里面加一行,改成这样:


    /// <----- add
    if (!strcmp(ent->classname, "grenade")) {
    trace->endpos[2] -= 0.5;
    G_SetOrigin( ent, trace->endpos );
    return;
    }
    /// <----- end add

    把位置的z坐标减0.5,沉到地下,就看不到了,当然,挂在天花板上的时候就会没有贴住,比较丑,在这里要改正这个问题的话写起来会比较长,你可以试试自己动手改,提示:trace.plane.normal里记录了实体碰撞到的平面的法向量,法向量的意思就是垂直于于该平面的单位向量,也就是这个平面的朝向。

    这个时候如果你还是在dm5来运行,你会发现,在PG那片地上是正常的,而上了台阶,到走廊(通向RA,上面有GL)的地板上,就不正常了,地雷的确是被触发了,我们可以看到烟雾,听到爆炸的声音,但它对玩家没有造成伤害;这就是我们大家都知道的被取消的的穿透地板伤害,具体的检测代码在CanDamage函数里,它被G_RadiusDamage调用;当然我们可以很简单的把CanDamage检查去掉,但这样会影响到其他的武器,我们采用另外一种办法:在爆炸前把它的位置再提高0.5,回到地板上。把G_ExplodeMissile一开始改成如下形式:


    /// -----> modified
    if (strcmp("grenade", ent->classname)) {
    BG_EvaluateTrajectory( &ent->s.pos, level.time, origin );
    SnapVector( origin );
    G_SetOrigin( ent, origin );
    } else {
    ent->r.currentOrigin[2] += 0.5;
    }
    /// <----- end modify

    // we don't have a valid direction, so just point straight up
    dir[0] = dir[1] = 0;
    dir[2] = 1;

    现在就一切正常了。

    上一节有改玩家重生时配备的武器的代码,你知道怎么弄了,但是我们给玩家初始的榴弹弹药应该是多少呢?给多了吧,扔得到处都是,给少了吧,如果地图里没有榴弹弹药怎么办?难道就只*吃别人掉在地上的GL?要想个办法。

    最好是能根据时间来控制,譬如5秒种榴弹弹药加1,在游戏中这种根据时间变化的数值太多了,我们只要找到原来的代码就好办,查找"->health",看到下面这样的结果嘛,就知道这里是根据时间来做的了:


    if ( ent->health > client->ps.stats[STAT_MAX_HEALTH] ) {

    它在g_active.c的ClientTimerActions函数体内

    不过它是按一秒一秒算的,我们需要以5秒为单位,在函数内加一个静态变量来统计过了几个1秒?不行,这个时间是和玩家相关的,必须在client结构里添加变量(g_local.h/Ln296):


    int timeResidual;
    int timeResidual2; /// <----- add one line

    然后在原来程序处理timeResidual的地方,ClientTimerActions开头,处理timeResidual2


    /// -----> add
    client->timeResidual2 += msec;
    while (client->timeResidual2 >= 5000) {
    client->timeResidual2 -= 5000;
    client->ps.ammo[WP_GRENADE_LAUNCHER] ++;
    }
    /// <---- end add

    进去玩一会,觉得还有什么不足的地方自己再添吧。譬如你可以添加一个cvar用来控制是使用地雷还是使用普通的榴弹,还有,现在的地雷在5分钟后报废时会爆炸,你可以通过nextthink的值和当前时间来判断它是被踩到还是被报废,就可以作出相应的动作:是否有烟雾,是否有伤害。








    6. 后记

    "在进行这些工作的时候,你是否觉得,游戏,同样可以让你学到很多的东西呢?" —— Maverick

    终于完成了这篇文章,希望你从中得到了不少乐趣,在这里要感谢id software和q3acn,没有前者,你我的生命将会比现在的少了很多东西,不光是frag时的快感,还有对一件事物全身心投入时的那种美好感觉,英语里把这种感觉称之为"passion";没有后者,我们的quake3之路也会很不一样。

    你可能会想,从test版算起,q3已经问世快三年了,doom3虽然不是指日可待,但也不是遥遥无期了,做q3的mod还有意义么?但是,看看国外那些长达上百页的mod列表吧,又有多少人安装并花很多个小时玩他们呢?我只想说,这些都不重要,我反正是把这些代码当成Id送给玩家的大玩具,比如上一节做地雷,给我感觉就象小时候做弹弓和水枪一样;而且你现在学到的知识和积累的经验,和你frag的经验一样,到了doom3的时候仍然能派上用场。crt和arq也不是一次就做出了现在的ra3和osp,他们都是从quake1就开始做MOD的。

    一个大型的复杂的MOD,改代码只是其中的一小部分,更多的工作在做地图,做模型,也希望国内能有越来越多的mapper和modeller。

    对你今后继续研究代码的几个建议:

    1. 良好的心态,刚开始的时候一定要不求甚解,不能操之过急,id花那么长时间写出来的代码,你不要指望十天半月就能全部搞懂。

    2. 保持注意力,在弄明白一个问题的过程中,抓住问题的关键,不要看到哪里不懂又开始在代码里到处乱翻,那样的话不出30分钟你就会发现自己陷入泥沼,甚至忘了自己一开始要干什么。

    3. 毅力,如果你脑海已经有一个非常好的主意,并且有强烈的愿望要实现它,那么,不要半途而废。

    4. 善加利用VC的Browse功能,和find in files,他们就相当于Yahoo!分类目录和Google。

    —— Happy coding & Happy fragging !

你可能喜欢

Q3 MOD制作入门教程 
联系
我们
快速回复 返回顶部 返回列表