RPG制作之路
天眼
一、绪论
RPG是角色扮演游戏的英文缩写,它是纸上冒险游戏和电脑结合的产物,自从诞生起就以独特的
冒险和故事性吸引了无数的玩家。我个人认为,RPG游戏是各类游戏中最能表述故事,体现情感的一
种游戏。RPG游戏不但自身在不断发展着,而且还不断吸取着其他游戏的精华,如加入冒险类的解密
情节(大多数RPG游戏都有),加入动作游戏的战斗方式(暗黑破坏神),引入战棋类游戏的策略战
斗部分(金庸群侠传),随着3D技术的发展,优秀的三维RPG游戏也在不断的涌现(如魔法门六)。
然而这些优秀的游戏并不是凭空产生的,它同其他软件类产品一样,它也是由许多的程序人员编
制出来的,而且它的产生还离不开策划人员、美工和音乐制作人员协同的努力工作。RPG游戏的灵魂
是剧情,然而它却和其他类型游戏不同,它的特别强的故事性使得游戏的美工、音乐几乎同样重要。
然而却也不能因为重视它们而忽视了剧情,因为这三者互相失衡而导致失败的游戏的确不在少数。
我非常喜欢编程序,看着计算机在自己的程序控制下说一是一,说二是二,让它向南它不敢往北
,哪怕是完成了一个非常小的程序,自己也会有一种成功的喜悦。当完成一个久未解决的难题时,真
想跳起来振臂高呼(我也真的这么干过,被人骂作"神经")。相信真正沉浸其中的人都会有此感觉
!
自从我刚刚接触电脑起,我就同样接触了游戏,虽然那只是在APPLE-II上的非常简单的游戏,我
就几乎沉迷了。沉迷的当然不是玩这些游戏,而是自己编这些游戏。那时我真的很惊讶,原来可以通
过自己的努力,让电脑给其他人提供这么吸引人的娱乐服务(那时我一直以为电脑是来进行科学计算
的)。虽然我那时在苹果机上编制的游戏简直没有娱乐性,但我还是很看重那一段时光,因为我在那
时不但认识到了电脑的超凡的功能和极端广泛的应用范围,而且还为我打下了程序设计的基础。
这些年来,我自己编的游戏大大小小也有十来个,虽然真正的成品很少,但我还是努力尝试了游
戏的各种类型,包括射击、经营模拟、即时策略、战棋,当然最多的还是RPG游戏。 下面我就结
合自己的经验,讲解一下RPG游戏制作的具体方法和步骤,献给那些有程序设计经验且想自己制作游
戏的玩家。所有这些都是我个人的实践和思考的结果,可能会有许多不对的地方,欢迎大家批评指正
。
二、策划部分
1. 策划的必要性
有句老话叫"未雨而绸缪",是说应当为即将发生的事做好准备,在这里可以理解为即将开始的
游戏制作做好准备工作。因为即使是一个很小的游戏或者是软件,也有很多独立的方面需要完成。如
标题画面,操作界面,和系统有关的设置甚至还有安装等很多方面。如果事先不经过计划,在制作的
过程中必然会不断发现没有考虑到或者考虑不周全的地方,如果这时在进行改动,你的程序、美工和
音乐大概都得跟着改,有时甚至会导致整个工作重来。这样先前的许多工作都变成了白白的时间浪费
。我以前也走过这样一段弯路,总是一开始就坐在计算机前开始编程序,并且不断发现某些方面没法
实现,不得不修改以前的代码,有时还因为一个重要的方法技术上没有办法实现,被迫放弃制作。过
了一段时间后又突然解决了这个问题,再想拾起以前的工作继续下去,几乎已经不可能了。经过几番
挣扎,我终于认识到了策划的重要性,现在,无论是做什么东西,我总是"三思而后行",有时我还
会提前编一些小程序验证某些方法的可行性,不会再盲目开始了。
相信大部分有的程序设计经验的玩家都会同意我的看法,我之所以说这些话也是为了让大家少走
些弯路,更快的掌握制作游戏的方法。
2. 剧情的策划
很多游戏制作者将详细的剧情策划放在第一步,我对此有不同的看法。
剧情的策划固然重要,因为引人入胜的剧情往往是RPG游戏制作的关键,然而理想化的剧情策划
经常为程序设计制造难题,程序虽然可以不断完善,但出于技术和面向玩家阶层的考虑,程序并不是
万能的,策划者却往往不清楚程序描述剧情的能力达到到什么程度,当程序能力有所不及,就得重新
修改策划了!这是所有人都不愿看到的。
将剧情策划放在程序设计之后更是不可能的,因为程序设计者将无所指导,他不知道自己的程序
需要达到什么程度!
想到这里,我们不仅想到了在C语言程序中两个互相调用函数的情况,谁先谁后呢?那时,解决
的方法是函数原形,将相对基础的函数的函数名、输入输出参数以函数原形的方式写在所有调用它的
函数之前。同样我们可以将相对基础的"剧情"的大体框架和对程序的要求放在工作的第一步,在程
序设计完成以后在填充它的具体细节。在程序的基础上完成具体的剧情细节,这样就能成分发挥程序
的表述能力了!
3. 剧情框架
应当主要描述剧情的时代背景,环境氛围,画面风格,事件的表述方法。至于具体的故事细节,
等到程序完成以后在进行设计也未尝不可。
对于改编电影、小说的游戏,剧情已定,首先所需要的就是决定是否需要照搬原著的剧情?很重
要的一点是电影、小说有自己独到的表现方法,有些对于游戏是值得借用的,但并不表示要完全借用
。如果完全借用,那和电影、小说本身还有什么区别呢,我相信大多数人是不喜欢VCD般的游戏和电
子小说般的游戏的。
4. 决定程序设计的基础
(1) 这一部分主要是指游戏的图形模式(分辨率、颜色深度),以及采用的特殊效果、音乐、
音效和动画的支持。由于本人对3D编程涉猎不多,所以主要以2D为主。
图形模式由显示卡决定,由显示器支持,一般游戏常用的显示模式有
320 X 200 X 8 bits
640 X 480 X 8 bits
800 X 600 X 8 bits
1024 X 768 X 8 bits
640 X 480 X 16 bits
800 X 480 X 16 bits
1024 X 768 X 16 bits (需显存2M)
640 X 480 X 24 bits
800 X 600 X 24 bits (需显存2M)
1024 X 768 X 24 bits (需显存4M)
未注明的显存1M即可
现在的显示器大都支持640X480、800X600、1024X768这三种分辨率,而显示卡对颜色深度的支持
与显存有关:
颜色深度(bit)
所须显存数量=横坐标分辨率X纵坐标分辨率X-------------
8
颜色深度(bit)
颜色数 = 2
8
如颜色深度8 bit即 2 = 256 色
有些显示卡支持32bit或者更高的颜色深度,但24bit的颜色深度就已经使人眼分辨颜色的能力
达到极限,这些更高的颜色深度主要用于高质量的图象和视频设计。
注:256色的调色机理和16bit、24bit的不同,现在的编程语言大都已经封装了对显示卡的控
制,制作者不用了解其中的原理就可以很方便的使用。
分辨率越大、颜色数更多,图形的表现自然更好,但对显存、内存和数据传输的需求就越高,图
形数据文件也就越大。考虑到大多数电脑玩家的显卡都至少有1M显存,因此640x480x8bit、
800x600x8bit、640x480x16bit、800x600x16bit、640x480x24bit成了大多数游戏通用的分辨率,我
在制作游戏时也主要考虑这几种分辨率。需要较快的屏幕显示速度可以采用256色,需要光影等特殊
效果的最好采用16bit或24bit的颜色数,这些需要依具体情况定。至于分辨率,可以单独使用一种,
也可以同时支持几种,因为对于各种编程软件来说,这似乎并不是什么难题!
(2) 图块类型的选择
大部分的RPG游戏的图形都是由数层图案逐层覆盖而成,比如最底的一层为地表图案如草地、石
子路等等;次一层为地物,包括其中行走的主角和NPC的图案;再高一层可以为覆盖于前一层的物体
之上的东西,如屋顶、树梢等等;如果需要的话,还可以再在上面加入表示天气一层,如白云、雨水
什么的。当这些图层重叠起来以后,上面一层镂空的地方露出下面的一层,这样就形成了"人在地上
走,云在人上飘"的层次高度效果。当然你也可以制定自己的图层,实现所需的特殊效果。
游戏的每一个图层可以由多种方法实现:
——单张图片构成
非常容易发挥美术效果,适合做地表,天气效果,但所需的内存和硬盘空间着实不小。
——多张不规则图片构成
美术效果不错,适合做为地物层,但当需要构成遮挡效果时,计算较为麻烦 。
——多张规则图片组成
最节省内存和硬盘空间,适合做任何层次,遮挡也非常容易处理,但对美工的要求比较高
。
大多数的RPG游戏还是采用最后一种,因为技术上最为简单(用二维数组就可以描述),所以我
也比较倾向于这种,主要讲讲它。
规则图案组成层次也有几种:
——矩形(出于对各个方向卷轴速度一致的考虑,多为正方型)图片拼接而成,本层内的图片
不存在互相遮挡的关系,只需从左到右,从上到下依次画出即可。只能创建出顶视或非常假的无遮挡
3D效果。(如《侠客英雄传》、早期的《勇者斗恶龙》系列)
——等边六角型图片拼接,就象蜂巢,本层内图片不存在遮挡关系。这种方式多用在战棋类游
戏中,或RPG游戏的战斗部分,在行走地图中不常用(因为得有六个行走方向,象是《英雄无敌》系
列)。
——矩形图片逐行覆盖而成,这种游戏的图片按着从左到右,从上到下依次画出,但每一行的
图片都覆盖前一行少许,但一个图片只覆盖本列的图片。这样的话,就可以理解为近处的物体遮挡了
远处东西,就是屏幕上部离玩家最远,下部离玩家最最近。产生一定的立体效果。(如《侠客英雄传3》、《新蜀山剑侠》和《剑侠情缘》)
——矩形图片交错覆盖,每一行不但覆盖前一行,而且横向偏移一段距离。这样就产生了视角
倾斜的效果,正方型的底面在这种视角中呈菱形。这种效果比上一种方法能产生更好的立体感觉,美
工好的话可以以假乱真。(如《仙剑奇侠传》、《金庸群侠传》和《暗黑破坏神》等)
在这一部分很难用言语描述,我只讲各种类型,以后制作HTML版,再加入示意图片。大家只要
先有一个概念就行,
至于具体的数据描述和绘制方法我会在以后的程序部分讲解。
(3) 构成游戏的基本要素。
单线还是多线剧情?
单线剧情的和多线剧情对于程序员的工作来说,差别不大。但对于策划人员来说,却决不相同
。多线剧情相对于单线拥有数倍的工作量,而且大量的分支很容易搞乱。但多线剧情的可玩性十分诱
人,越来越多的制作者开始采用它。如果你决定使用这种方式,一定要做好策划,管好所有的资料数
据。(我的建议是专门编制一个管理多分支剧情的(信息、文本)编辑器,这并不时小题大做,因为
是你完全可以在编制下一个游戏时再次使用它。如果要求不太高的情况下,使用HTML语言是一种节省
效率的方法。)
哪些人物属性?
人物属性在RPG游戏中往往可以决定战斗、升级和剧情,是玩家非常在意的东西,因此对人物属
性的设定也要多化些工夫。拥有较多的属性可以产生更多的趣味性,你不必担心占用内存的问题,因
为这跟图象数据的内存需求比起来简直微不足道。但切记不要强硬的添加无用或是作用不大的属性。
什么样的战斗方式?
战斗是RPG游戏中的重头戏,目前RPG游戏的战斗方式有以下几种:
——敌我交替攻击的回合制:主要为早期的RPG游戏所采用,代表如《仙剑奇侠传》
——敌我同时攻击的回合制:为《剑侠情缘》中对前一种战斗方法的改进,意义不大。
——战棋式的回合制:最有代表的是《金庸群侠传》
——即时战斗:就在行走地图上展开的战斗,如《圣光岛》和《暗黑破坏神》
——格斗游戏方式:这是国内的晶合公司的在其游戏《毁灭天地》中的独创。(其实我也这么想
过,就是没有做过!)
前两种战斗的实现方式较为简单,后三种则相当于融入了另外一种游戏的概念,所以制作起来比
较困难,不过也很有挑战性,很可能会吸引更多的玩家。
什么样的升级系统?
升级系统模仿了人类的成长,随着能力的提高去面对更加强大的敌人,也是RPG游戏所特有的魅
力,但近来也被不少其他类型的游戏(如即时策略和动作类)所吸收。作为传统的RPG当然更不能少
。目前各类RPG游戏的升级系统很单一,也是目前者最有有发挥潜力地方。在这里,我讲一些自己的
设想和看法。
——通过战斗获取经验值来升级,由等级数、资质之类的人物的属性来决定升级所需的经验
值,等级的提升又提高人物的某些属性,提高人物的战斗力。这也是最基本的升级规律,只需要制定
一些公式就可以实现,当然记着还要包含一定随机性(使用伪随机函数)。
——吸取MUD的优点,设置一些如"根骨"、"敏捷"、"悟性"等多项资质属性,对角色各
项属性的成长影响各自不同,通过剧情可以修改这些基本参数,创建出多种不同的角色成长方式。
——对于偏重于战斗的RPG游戏,设置较多"全局"角色,即在整个游戏过程都存在,他们拥
有同主角一致的升级系统,采用半随机(由人物参数、随机值和剧情共同决定)获取经验值。这样就
可以产生即时性的人物成长,当玩家游手好闲时,对手却在不断成长,给喜欢战斗的玩家以压力。(
如果只通过战斗来取得游戏的胜利未免有练功机器之嫌,应当提供多种成功的乐趣,不一定非要通过
战斗)
需要特别注意得是:这些跟升级有关的公式和数据需要仔细记录,还需要便于后期修改。因为这
些东西直接决定着战斗和升级,和游戏的可玩性息息相关,加上很难以估算和决定,为了保证可玩性
,需要在后期花大量的时间修改完善。如果每改动一次都需要重新编译程序,那任何人都会受不了的
!(我通常的做法是使用文本格式的脚本描述语言)
当你完全决定了所有上面这些时,就可以开始真正的程序工作了!当然你可能发现漏掉了一些细
节,但它们的作用不是很关键,我在程序部分再讲。
三、程序设计
从这一章起,我开始讲RPG游戏程序部分的具体实现,涉及代码的部分我主要采用自然描述语
言和类似C的描述语言。相信有一定程序设计基础的人都能看懂!
1.脚本描述语言
(1) 什么是脚本描述语言?为什么要用它?
玩过很多RPG游戏,打不过去的地方经常用PCT改,有时候偶然发现游戏的某个文件是文本
文件,仔细阅读发现竟然象是某种语言程序,不是汇编,不是PASCAL,也不是C,这究竟是什么呢?
这其实是游戏制作者自己定义的一种脚本描述语言,制作者一般使用它来简化剧情的设计工作。
为什么它能简化剧情的设计呢,当我们了解了脚本描述语言本身后,再来说明这一点。
脚本描述语言是一种解释语言,解释语言不同于编译语言之处就在于它在执行之前不需要编译成
机器代码,而是通解释机将语句解释成机器代码再执行。它的优点在于源程序可以方便快捷的修改,
因为不需要修改解释器,所以也不需要进行编译。脚本描述语言就是针对一种某特殊平台的描述再这
个平台上特定行为和动作的一种解释语言。游戏的脚本描述语言就是建立游戏本身这个平台上,描述
游戏中的情节事件的解释语言。
为什么要用脚本描述语言呢?因为RPG游戏的剧情往往是一个庞大而充满相互联系的故事群,
每个故事可能又由许多小事件组成,这复复杂杂的关系就如同我们设计的程序,函数就象事件,事件
可以包含事件,函数也可以包含函数,剧情中我们可以通过各种选择实现不同的分支情节,程序中我
们也可以通过条件分支来执行不同的程序段。这样的相似性必然会让我们想到用程序语言来描述剧情
。然而如果我们在直接在游戏程序中用"IF""THEN"之类的语句来描述剧情,这样必然使剧
情依附于程序,剧情的改动也就意味着程序的改动,在RPG游戏的制作中,这显然是不可行的。有
了上面对脚本描述语言的了解,我们不禁会这样想:如果用脚本描述语言呢?在游戏程序中加入解释
机,专门解释执行描述剧情的脚本语言。这样在改动脚本时,自然就不用改动程序了!如此一来,我
们修改剧情时,根本不用在意游戏程序本身,只需要一个简单的文本编辑器就行了,如此带来的工作
效率,相信不用我说大家也了解了!
(2)脚本描述语言的语法关键字和函数
脚本描述语言最基本的单位是语句,它应当具备最基本语法,如表达式求值(包含各种常用的运
算),无条件转向,条件分支,循环,子函数等。变量有用户自定义数据也有游戏的全局数据;而描
述稍微复杂一些的功能可以采用全局函数,这些全局函数就象C语言的库函数或者是WINDOWS
的API一样,和各种变量一起在表达式中引用或者作为其他函数的参数。
下面是我制作的RPG游戏的一个事件脚本文件。
// 示例事件1
say(1,165,"大家好!我是张斌,这是我做的第十七个游戏。")
say(11,30,"这是一个测试!用来检验我的游戏引擎。")
say(32,300,"你好!在我这里可以点播MIDI乐曲。")
choose_flag=choose(4,"请选择乐曲:","乐曲一?乐曲二?乐曲三?乐曲四")
midi(choose_flag)
say(36,30,"小子,你来找死!")
push
s0=fight(7)
if(s0==1) goto(WIN)
msg("你打输了了!")
gameover=1
:WIN
pop
msg("你打赢了!")
end
这个事件的编号在地图装入时赋值给了一个NPC,当主角接触到这个NPC时,这个事件被触
发,于是这个文件被读入内存,开始解释执行。我们逐行解释这个文件:
第一行 前面的"//"同C++语言一样表示注释一行。
第二行 是一个函数,名称是 SAY ,有三个参数,前两个是整数1和165,第三个是字符"大家好……
"。这个函数的意义是在屏幕的纵坐标165的位置上显示人物1(头像和姓名)的语言"大家好…"
第三行 同上,在屏幕纵坐标30的位置显示人物11的话"这是一个测试……"
第四行 同上,在屏幕纵坐标300的位置显示人物32的语言"你…"
第五行 choose是一个选择函数,在屏幕上出现"请选择乐曲"的信息和4项选择,分别是"乐曲1","乐曲2","乐曲3","乐曲4",当玩家选择一个并按下回车后,
玩家选择的选项号码(0表示第一个
选项,1表示第二个,依次类推)作为这个函数的返回值赋给变量choose_flag。
第六行 midi是一个播放MIDI音乐的函数,它的唯一参数就是乐曲号,我们可以看到它的参数是变量
choose_flag,这就表示根据choose_flag中的值来选取播放的乐曲,而choose_flag中恰恰就放的是
在前一语句中我们选择的号码,因此midi函数就会播放我们前面选择的乐曲。
第七行 仍然是人物语言显示
第八句 因为要进入战斗场景,所以用push函数将当前场景的参数保存。待战斗结束后可以再用pop
函数取出,借此恢复战斗前的场景。
第九句 fight是战斗事件函数,参数表示战斗事件的号码,这里表示第7号战斗事件。战斗的结果
(0表示输1表示赢)赋值给变量s0
第十句 if语句(也可以理解为函数)对装着战斗结果的标量s0进行判断,如果s0为1(战斗胜利)
,则执行后面的goto函数,跳转到标号为WIN的语句(就是":WIN"那一行),否则继续执行下面的语
句。
第十一句(s0==0,战斗失败)msg函数表示再屏幕上显示信息"你打输了!"
第十二句 给变量gameover赋值为1,处理这个脚本事件的解释器检测到此变量为1,就终止事件然后
结束游戏。
第十三句 为作为跳转语句目标行
第十四句 pop函数弹出战斗前的场景信息,恢复场景
第十五句 msg显示信息"你打赢了!"
第十六句 end函数表示事件结束
事件结束后,脚本解释器会释放这段脚本占用的内存。
脚本中的"gameover"对应这个游戏程序中的一个全局变量"gameover",由于使用了指针,在脚本
中对它的引用就等同于对游戏程序中"gameover"的引用。同样对应于游戏程序中的其他全局变量,也
可以通过自己定义的脚本变量来引用。如地图大小"mapx"和"mapy",当前主角位置"cx","cy"等等,
这样就可以直在脚本语言中引用它们。如if(cx==5&&cy==7)判断主角是否在地图(5,7)这个位置上
if(cx==mapx-1&&cy==mapy-1) 判断主角是否在地图的角上这段脚本中"say","msg","choose",
"fight","midi"都是描述游戏中情节的函数,在游戏程序中都有相应的函数与之对应,它们有些有
返回值,有些没有。这些返回值还可以用来构成表达式,如:
midi(choose(3,"请选择乐曲:","乐曲一?乐曲二?乐曲三")+1)
这个条语句的含义就成了选择"乐曲一"的时候,实际放乐曲二,选择"乐曲二"的时候放乐曲三,
选择"乐曲三"的时候放乐曲四。
上面那段脚本中的"if","goto"可以被理解为控制语句也可以被理解成函数。所有的控制语句函
数化后,可以使脚本程序程序的格式更加统一,便于阅读。
同样对数组的引用也可以函数化,如对地图(X,Y)位置的图案类型的赋值在游戏程序中为
map[x][y]=12,是一个标准的数组元素,而在脚本程序中的引用则成了map(x,y)=12,x,y成了函数
map的两个参数。虽然形式上是函数,但实际的使用中仍然等同于变量map[x][y](因为程序内部使用
的是指针),因此可以进行赋值运算。
下面再看一段脚本文件
//示例事件2
say(12,300,"公子,你要买我的宝剑吗?")
say(1,30,"这宝剑多少钱?")
say(12,300,"30两银子!")
say(1,30,"这也太贵了!")
say(12,300,"30两也嫌贵,这剑可是削豆腐如泥哦!")
say(1,30,"让我考虑一下!")
choose_flag=choose(2,"买吗?","买了 不买")
if(choose_flag==1) goto(NoBuy)
if(haveobj(1)<30) goto(NoMoney)
msg("你花30两买下了这把破剑!")
say(12,300,"您走好!")
addobj(1,-30)
end
:NoBuy
say(12,300,"小气鬼,30两也不肯出!")
end
:NoMoney
say(12,300,"真是个穷光蛋,快滚!")
end
第一句 仍然是注释
第二句 到第七句是主角(1)和卖剑的(12)人物对话
第八句 是选择"买"还是"不买"
第九句 如果选择了不买,跳转到:NoBuy
第十句 haveobj对应游戏程序中的函数haveobj,参数是物品的种类返回值是拥有这种物品的数量(
物品1表示银子,银子的数量就是两数)这一句是判断主角拥有银子的数量如果小于30两,则跳转
到NoMoney
第十一句 显示买下剑的信息
第十二句 卖剑的招呼你走好
第十三句 addobj函数表示为主角增加物品,第一个参数为物品的种类(1为银子),第二个参数为
增加的数量(为负则是减少)
第十四句 事件结束
第十五句 不想买剑,跳转到这里
第十六句 卖剑的骂你小气鬼
第十七句 事件结束
第十八句 想卖剑但钱不够,跳转到这里
第十九句 卖剑的让你滚蛋
第二十句 事件结束
通过上面这两段脚本语言文件,我们可以清楚的了解到脚本语言的的变量对应着游戏程序中的全
局变量、函数对应着游戏程序中的函数,通过脚本语言,我们可以轻易的引用游戏中的各项值(主角
属性,物品,地图等等),引用游戏中的表述方法(人物对话,播放音乐,旁白信息等等)。如此以
来我们构建剧情不就轻而易举了吗?
然而,我们不能高兴的太早了,因为真正艰辛的,才刚刚开始!我们下一次将开始讲如何构件脚
本的解释机!
(3)解释机的编程
在任何编程语言中,表达式计算都是最重要的,它在我们的脚本描述语言中同样存在。因此在脚
本解释机中,我们最先实现的就是表达式求值。我们在写源程序的时候进行计算非常简单:
如 3*(3+2)-4
然而在脚本描述语言中,解释机所得到的并不是如此简单的算式,而是一个从文本文件中提取的
一个字符串 "3*(3+2)-4"将这个字符串转化成一个数,可不是那么简单。如果你认真学习过算法,应
该能够从容的实现它。
我们先看一个简单一点的算式 "32+41*50-2"
我们把自己看成是计算机,从字符串的左边开始扫描,当扫描到'3'时,可以知道这是一个数的
开始,将它记如入一个空闲的字符串的第一位buf[0],当我们扫描到'2',它仍然是个数字,是我们
正在记录这个数的新的一位,我们将它放入buf[1],当我们扫描到'+'时,它不是数字了,我们也就
知道第一个数读完了,在记录它的字符串的下一个位置buf[2]放入字符串结束标志'0'我们得到的这
个数放在buf中,是"32",通过一些编程系统提供的字符串转整型数的函数,我们可以将这个字符串
转化为数值,即使你用的编程系统没有这个函数,根据数字字符的ASCII码值,我们自己也可以
很容易实现:
'0',"1","2"...'9'的ASCII码值分别为 48~57,如果一个数字字符的ASCII码为n,则
它代表的数字值为n-48。如一个数char str[5]={"2341"},它的值就可以写成
(str[0]-48)*1000+(str[1]-48)*100+(str[2]-48)*10+str[3]-48于是我们可以写出下面的将字符串
转变为整数的C语言函数(其他语言也类似)
int stoi(char *str) //str是以0为结束的字符串
{
int return_value=0,i=0;
while(str{i}!=0) //字符串结束
return_value=return_value*10+str[i++]-48;
return(return_value);
}
知道了这个"32"是32,我们将它记下(装入一个变量),再继续往下扫描,直到字符'4',我们
知道我们得到了一个"+",它是一个二元运算符号,我们接着向下扫描利用上面扫描32的方法,我们
得到了另一个数41,我们现在知道了32+41,考虑一下,我们在什么情况下能将它们相加:
要么是在41后字符串结束,要么紧接着41的是一个比'+'优先级低或相等的运算符,如'-'。将它
们相加后得到73,我们就回到了刚刚得到31的那一步。
如果41后面跟着的是一个更高优先级的运算符,如本例中的'*',我们必须先进行这个乘法运算
,那只好先将31和'+'保存起来,接着向下扫描,我们又得到了50和其后的运算符'-',判断的方法和
刚才一样,因为'*'比'-'的优先级高,所以我们可以放心的先将41*50算出,得到2050。这时我们现
在所扫描到的算式就成了32+2050-2,我们再次比较运算符'+'和'-',优先级相同,我们就可以先算
32+2050了,得到2082,我们继续向后扫描,了解到在2082-2后字符串结束,我们可以继续计算最后
一步2082-2=2080,最后这个字符串表达式的结果就是2080。
现在我们再来看看括号:如 3*(3==2+2*5)-4这个式子,在读完3*之后我们读到得不是一个数,
而是一个括号,这时我们需要先算括号内的式子,所以的先将3和*保存起来,比较==和+,得先计算
加法,先将3==保存,再来比较+和*,先计算2*5得到10,因为下面一个等到算完2*5得到10,因为后
面是括号,所以要取出先前保存的数和运算符进行计算,但一直要取到前一个括号,但我们顺序存了
3*、3==、和2+,怎么知道前一个括号在那里呢?方法是在遇到前括号时也保存括号的标记,这样的
话,我们算到这一步时所保存的顺序为:3*,(,3==和2+,我们遇到一个后括号,就取出以前保存的
数进行运算,先算2+10得12,再算3==12得0,这时取出了括号(,我们这才知道这个括号内得运算完
结,现在的算式剩下了3*0-4,再比较*和-,先算3*0得0,最后得结果就是0-4得-4。
在上面的运算中,我们在遇到高优先级的运算时,需要将前面的数值和运算符保存,但我们并不
太清楚需要保存几次,如这两个算式:
1=2==3+4*5 1+2+3+4+5
它们在计算过程中需要保存的的数和运算符个数是不同的,前一个需要先算4*5结果为20,再算
3+20结果为23,再算2==23结果为0,再算1=0(在一般的语言中,象这样给常数赋值是禁止的,但在
我们脚本语言的运算中,为了保持一致性,我们允许这样的式子,但赋值被忽略),最多情况下需要
要保存三个数和运算符。而后一个式子一个也不用保存,从左到右依次运算就行了。
我们发现这些数和运算符的使用都是"先存的后用,后存的先用",这不是堆栈吗?对!我们就
用堆栈来保存它们。
堆栈的实现很多软件书中都已经讲过,心中有数的读者自然可以跳过下面这一段。
一般实现堆栈可以用链表或数组,我们犯不上用链表这么复杂的数据结构,用比较简单的数组就
可以了。对于使用C++的朋友可以用类的形式来实现
class STACK //整数堆栈
{
int *stack; //存放数据的首地址
int p; //堆栈位置指示(也可以用指针)
int total; //预先定义的堆栈大小
public:
STACK(int no); //指定堆栈大小的构造函数
~STACK(); //析构函数
int push(int n); //压栈
int pop(int *n); //出栈
};
STACK::STACK(int no)
{
total=no;
stack=new int [total];
p=0;
}
STACK::~STACK()
{
delete[] stack;
}
int STACK::push(int n) //压栈
{
if(p>total-1)
return(0);
else
stack[p++]=n;
return(1);
}
int STACK::pop(int *n) //出栈
{
if(p<1)
return(0);
else
*n=stack[--p];
return(1);
}
如果用C也是一样,使用initSTACK来声明一个堆栈,但要记着在用完之后调用freeSTACK释放内
存
typdef struct STACK
{
int *stack; //存放数据的首地址
int p; //堆栈位置指示(也可以用指针)
int total; //预先定义的堆栈大小
};
int initSTACK(struct STACK *stk,int no)
{
stk=(struct STACK *)malloc(sizeof(STACK));
stk->total=no;
stk->p=0;
stk->stack=new int [total];
//如果stack不为零表示分配成功,堆栈初始化也就成功
if(stk->stack)
return(1);
free(stk); //如果失败释放内存
return(0);
}
void freeSTACK(struct STACK *stk)
{
if(stk)
{
delete[] stk->stack;
free(stk);
}
}
int pushSTACK(struct STACK *stk,int n) //压栈
{
if(stk->p>stk->total-1)
return(0);
else
stk->stack[stk->p++]=n;
return(1);
}
int popSTACK(struct STACK *stk,int *n) //出栈
{
if(stk->p<1)
return(0);
else
*n=stk->stack[--p];
return(1);
}
可以看出这种堆栈类在声明对象时要给出堆栈的大小,对于我们的表达式求值来说,100个单
元足够了。但有人不禁会想到,上面这些都是整数堆栈,对于运符怎么存储呢?其实是一样的,我们
可以给运算符编上用整数序号来代表,这样就可以利用整数堆栈来保存了。给运算符编号的另一个好
处是可以利用运它的高位来代表运算符的优先级!如下面一个函数将字符串运算符转化成含优先级的
序号,只要比较这些序号高位值的大小就可以得出谁得优先级高了。(下面这个函数只对二元运算符
编号,没有处理一元和多元,因为它们都可以用二元运算表示。)
int convert_mark(char *str)
{
//优先级高
if(strcmp(str,"*")==0) return(240); //0xf0
if(strcmp(str,"/")==0) return(241); //0xf1
if(strcmp(str,"%")==0) return(242); //0xf2
if(strcmp(str,"+")==0) return(224); //0xe0
if(strcmp(str,"-")==0) return(225); //0xe1
if(strcmp(str,"<<")==0) return(208); //0xd0
if(strcmp(str,">>")==0) return(209); //0xd1
if(strcmp(str,"<")==0) return(192); //0xc0
if(strcmp(str,"<=")==0) return(193); //0xc1
if(strcmp(str,">")==0) return(194); //0xc2
if(strcmp(str,">=")==0) return(195); //0xc3
if(strcmp(str,"==")==0) return(176); //0xb0
if(strcmp(str,"!=")==0) return(177); //0xb1
if(strcmp(str,"&")==0) return(160); //0xa0
if(strcmp(str,"^")==0) return(144); //0x90
if(strcmp(str,"|")==0) return(128); //0x80
if(strcmp(str,"&&")==0) return(112); //0x70
if(strcmp(str,"||")==0) return(96); //0x60
if(strcmp(str,"=")==0) return(80); //0x50
if(strcmp(str,"+=")==0) return(81); //0x51
if(strcmp(str,"-=")==0) return(82); //0x52
if(strcmp(str,"*=")==0) return(83); //0x53
if(strcmp(str,"/=")==0) return(84); //0x54
if(strcmp(str,"%=")==0) return(85); //0x55
if(strcmp(str,">>=")==0) return(86); //0x56
if(strcmp(str,"<<=")==0) return(87); //0x57
if(strcmp(str,"&=")==0) return(88); //0x58
if(strcmp(str,"^=")==0) return(89); //0x59
if(strcmp(str,"|=")==0) return(90); //0x5a
//优先级低
}
在RPG得脚本描述语言中,我们基本用不上小数,因此我们在实际的二元运算中得到的将是三
个整数,其中两个是参与运算的数,另一个是运算符的序号,我们还得对此编出进行运算的函数。如
:
//运算求值 n1是第一个参加运算得数,n2是运算符号得序号
//n3是第二个参加运算的值
int quest(int n1,int n2,int n3)
{
int ret=0;
switch(n2)
{
case 240:ret=n1*n3;break; // "*" 乘法
case 241:ret=n1/n3;break; // "/" 除法
case 242:ret=n1%n3;break; // "%" 求余数
case 224:ret=n1+n3;break; // "+" 加法
case 225:ret=n1-n3;break; // "-" 减法
case 208:ret=n1<<n3;break; // "<<" 左移
case 209:ret=n1>>n3;break; // ">>" 右移
case 192:ret=n1<n3;break; // "<" 小于
case 193:ret=n1<=n3;break; // "<=" 小于等于
case 194:ret=n1>n3;break; // ">" 大于
case 195:ret=n1>=n3;break; // ">=" 大于等于
case 176:ret=n1==n3;break; // "==" 等于
case 177:ret=n1!=n3;break; // "!=" 不等于
case 160:ret=n1&n3;break; // "&" 与
case 144:ret=n1^n3;break; // "^" 异或
case 128:ret=n1|n3;break; // "|" 或
case 112:ret=n1&&n3;break; // "&&" 逻辑与
case 96:ret=n1||n3;break; // "||" 逻辑或
case 90:ret=n1|n3;break; // "|="
case 89:ret=n1^n3;break; // "^="
case 88:ret=n1&n3;break; // "&="
case 87:ret=n1<<n3;break; // "<<="
case 86:ret=n1>>n3;break; // ">>="
case 85:ret=n1%n3;break; // "%="
case 84:ret=n1/n3;break; // "/="
case 83:ret=n1*n3;break; // "*="
case 82:ret=n1-n3;break; // "-="
case 81:ret=n1+n3;break; // "+="
case 80:ret=n3;break; // "=" 赋值
case -1:ret=n3;break; // 用来表示前括号
case 0:ret=n1;break; // 空运算
}
return(ret);
}
我们可以看到,在上面得有关赋值得运算中,我们实际上并没有进行赋值,因为我们还没有任何
变量来接受赋值,下一次里我们再来讲讲将游戏中的数据作为变量进行运算和赋值,这可是最激动人
心的哦!
注意:解释机并不是独立的软件程序,它是游戏源程序的一部
分,只有这样脚本解释语言它才可能通过它引用到游戏
中的变量和函数。
为了达到引用游戏中变量和函数的目的,我们专门定制一个函数,用来将字符串转变成整数(假
如起名为val,则它的函数原型就是int val(char *str))假若输入字符串是一个数字串,我们就可
以调用前面一讲讲过的将数字字符串转变为整数的函数将它转化为数值;如果输入字符串的第一个字
符是英文字母或者下划线,我们就根据这个字串返回它所代表的游戏中的变量。
例如,我们在游戏程序中定义了主角当前的位置是放在int cur_x,cur_y 当中,我们可以约定在
当在脚本语言中也用cur_x和cur_y来代表这两个变量(只所以用同形的字串,是为了便于记忆,当然
你也可以给用另外的字串代替),假若我们的这个函数得到的输入字串是"cur_x",我们就让val函数
返回它变量cur_x中的值。如果是"cur_y",我们就返回变量cur_y 的值。同样象人物属性、物品等等
,都可以约定的字符串形式引用。但对于数组元素呢?我们在C语言中都是采用跟在变量后的方括号
内写入数组索引值,采用方括号的目的是在编译时区别数组和函数。但在解释语言中步存在区别的问
题,所以象BASIC 都采用和函数相同的圆括号。所以我们在处理数组和函数时也基本相同如:
addobj(12,100)
map(23,32)
前一个是函数,表示给主角加100个物品12(12是物品代号)后一个是二维数组,表示地图上某
一点的物体,相当于map[23][32]。
假若输入的字串不是数字串,我们就可以将它拆分处理:如将"addobj(12,100)"分为"addobj"、
"12"、"100",共三项。对于cur_x就只得到一项"cur_x",根据它们的第一项,我们可以知道它们代
表的是那个变量或函数,拆分出的其他项就是数组的索引或函数的参数,因此我们可以很容易的指定
val的返回值(对于函数就是函数的返回值,对变量就是变量值)。
但如果圆括号内不仅仅是常数,而且有变量或者函数,或者是由函数变量组成的表达式,如:
addobj(map(23-cur_x,32)+1,100)
这样又怎么办呢?解决方法就是交叉的递归调用。
我们现在所做的一切最终就是为了将一个字符串表达式转变成一个整数,写成函数的形式就是(假设
函数名为cal)
int cal (char *str);
如果输入参数为1+addobj(map(23-cur_x,32)+1,100)+1,
它需要调用val 函数来求参加运算的一个数值
addobj(map(23-cur_x,32)+1,100)
而在val 函数中,对于addobj(map(23-cur_x,32)+1,100)会拆分出"map(23-cur_x,32)+1"这样的
项,它也是一个表达式,我们只有以它为参数再次调用cal求值。cal又会调用val求值
val("map(23-cur_x,32)"),val拆分得到"23-cur_x",,再次调用cal("23-cur_x"),cal再调用
val("cur_x")得到cur_x的值,再返回给前一个调用它的cal,如此逐曾返回,最终由cal求得真正的
结果。
cal 调用 val,val又去调用cal,这就形成了交叉的递归调用,第一次调用
cal("1+addobj(map(23-cur_x,32)+1,100)"),第二次调用cal为cal("map(23-cur_x,32)+1")时,第
三次调用为cal("23-cur_x"),递归调用一个函数时,系统会自动的为它开辟内存,生成一个副本,
这三次调用就同三个不同的函数一般。
如此一来,在复杂的表达式我们也能求出起结果了,但需要注意的一点是递归调用太多时耗费系
统资源太多,也容易出错,我们应当尽量避免。如在val中,如果对输入串拆分得到的项是数字串自
然不用去调用cal,如果拆分得到单独的变量或函数,如"map(cur_x,32)"经拆分得到的"cur_x"这一
项,因为没有运算,则直接递归调用自己val("cur_x")就可以了,而不用再调用cal了,这样就可以
减少递归调用的层次,减少内存消耗和发生错误的可能性。
我们可以发现,到目前为止,我们可以引用游戏中的变量和函数了,但我们还不能给游戏中的变
量赋值,也就是说我们在脚本语言中能够知道主角位置、主角的属性、所处的位置、拥有的物品等等
,但却不能改变它们,因为我们的赋值运算并没有真正对游戏中的这些变量赋值,这将使脚本描述语
言大失光彩,没关系,我们将在下一讲解。
首先我们分析我们以前进行的工作,
STACK(类或结构)用来暂存运算式中的数字和运算符代号
convert_mark 用来给运算符编含有优先级的代号
quest 用来计算两个整数运算的结果
val 用来将字符串(整数,变量,函数)转化成
整数结果
cal 将字符串表达式转化成整数
如果你看过以前的几讲,应该很容易搞清楚这些函数的基本调用关系。
+--> STACK
|
+--> convert_mark
cal--|
+--> quest
| |
| +--> val ---+
| |
+---------+-----+
我们现在再来考虑一下对游戏中的变量赋值,最先想到的自然是再进行实际运算的函数quest 中
实现,但quest 函数的输入参数只是三个整数,即使我们知道现在进行的是赋值运算,知道了运算的
结果,但却无法知道应该将结果赋值给那个变量。我们又会想到只在val 函数中才有可能拆分出代表
变量的字符串,才知道是那个变量,但在这个函数中我们并不知道这个变量参加的是何种运算,也不
知道运算的结果,所以也没有办法进行赋值。所以我们只有将希望放在调用它们两个的函数cal 上了
,因为是cal 调用的val,所以cal能得到运算的类型和结果,至于参加赋值运算的那个具体的变量,
因为val 函数返回的是这个变量的值,因此我们还不能确定进行运算的是那个变量,但如果将val 的
返回值改为指向这个变量的指针,我们不是既能引用又能赋值了!我们根据参加运算变量指针所指向
的值调用quest 函数就可以得到运算结果,我们再根据这个运算是否赋值运算再决定是否将这个结果
写入这个变量的指针。
需要注意的是,在val 函数中,如果判断出是变量,我们就返回它的指针就行,但如果是整数或
者是函数,它们并没有指向其值的指针,但我们可以定义一个静态(static)变量,用来存放这个整数
或者函数的结果,然后返回指向这个静态变量的指针就行了。(注意是静态变量,因为它在函数结束
后不释放内存。如果是一般的动态变量,在函数结束后就会释放,我们返回的指针指向的就是一个不
确定的值了!)当然你也可以采用全局变量。(因为它在整个程序执行期间都不释放内存)。
完成这几个函数,我们的字符串表达式求值部分就完成了,但我们的解释机并没有完成,我们还
需要无条件转移、判断、条件转移、循环一些控制语句,这些我们会在下一讲中完成!首先为了快速的解释执行,我们一般都将整个脚本文件读入内存,将脚本语
句一行的一行储存。因为移动内存指针
可比移动文件指针方便快速多了。
首先我们来看语句注释,我们可以采用";" "//","*"等注释一行,也可以用成对的"/*"和"*/注释一
整段。它的实现很简单,我们只要在将脚本文件读入内存时将这些行忽略就行了。(遇到这种注释一
行的标志,就读到此行末尾,但不在内存中保存。遇到是注释一段的起始标志,就一直读到注释一段
的结束标志,这其中读入的并不在内存中保存!)
首先我们来看看语句跳转,很自然的,可以通过指定语句在脚本文件中的行号来进行跳转(注意
不是BASIC中语句前的行号),但这样做法实现很简单,但对于脚本文件的编制和修改就麻烦大
了,因为你必许自己数出想要跳转到的那一行的行号,而这其中又要排除忽略掉的注释行,而当你每
次修改时删除或者增加一行,那么相关的跳转又要重新数行号。这恐怕会使人丧失编制脚本文件的耐
心。
解决这个问题较佳的办法是,在想要跳转的那一行前加一个标号行,跳转时指定哪个标号就行了
(结构化BASIC语言、汇编、C语言都是如此),在将这些脚本读入内存时忽略这些标号行,但
记录下它的标号名称和行号,再根据每个跳转中指定的标号名称,将它们的参数转化成行号。如:
假设脚本文件是这样的:
xxxxx //行0
xxxxxxx //行1
//此行是注释,读入内存时忽略
xxxxx //行2
:label //标号行读入内存时忽略
xxxxxxx //行3
xxxxxx //行4
xxxxxx //行5
xxxxxx //行6
xxxxxx //行7
xxxxxx //行8
goto(label) //行9
xxxxxxx //行10
xxxxxx //行11
/*
这其中的内容都是注释,读入内存时忽略
xxxxx
xxxx
*/
xxxxxxx //行12
xxxxxx //行13
xxxxxxx //行14
end //行15
读入内存并修改跳转参数后变为
xxxxx
xxxxxxx
xxxxx
xxxxxxx
xxxxxx
xxxxxx
xxxxxx
xxxxxx
xxxxxx
goto(3)
xxxxxxx
xxxxxx
xxxxxxx
xxxxxx
xxxxxxx
end
注意到其中goto的参数变成了想要跳转到的那一行在内存中的行号,而注释和标号行都被忽略了。
我们现在再讲讲如何具体实现:
首先将脚本文件中的每一行读入内存(当然要忽略注释),当读入的是标号行时(此行第一个字
符是':' ),将所有的标号名称和下一行的行号保存起来(规定标号名的长度和数量,比如规定变量
名少于32的字符,每个脚本文件中的标号不超过100个,标号名称可以顺序保存在一个二维字符数组
中,如
char label[100][32]
行号也顺序放入一个整数数组,如
int labelno[100]
当脚本文件读入内存后,再次扫描这些内存,如果遇到goto就比较goto后的参数和label中的内
容依次比较,如果相同,就将goto后的内容改变成labelno内相应的行号。
如此一来,我们就得到了最终的内存结果。
当内存中的这些脚本解释时,我们会用一个变量来放当前即将执行的行号,如果执行完一行,就
将这个变量加1,然后下次就选取这个变量指示的那一行语句执行。在进行跳转时,只要把这个变量
改变为goto后的行号即可。
当然,goto(xx)的形式我们也可以把它当作函数处理,在我们前面讲过的val 函数中,遇到goto
时将当前的命令行号变为xx即可。
这次主要讲解释机中对注释语句和转向语句的实现方法,下一次我们在来讲条件分支、循环等等
。
条件分支我们可以采用类似汇编语言的方法,在解释机内设置一个判断专用的标志变量(
if_flag),根据if(...)括号内的表达式设置这个变量。然后then(....)再根据这个变量的值决定是
否转向括号内指定的标号行(这些都是在前面讲过的函数 val里实现),如:
if(cur_x<10) //条件成立设置判断标志为1,反之为0
xxxxxx
xxxxxx
then(label1) //判断标志位为1则转向label1否则继续
xxxxxx
xxxx
:label1
xxxxxx
我们在读入脚本进内存时时,同goto一样也要将then括号中的标号转变为相应的行号。
这样我们就可以和汇编语言一样,结合其他变量实现循环。
s1=0 //给循环记数器设置初值
:label1
xxxxxxx //需要循环执行的语句
xxxxxx //需要循环执行的语句
s2=s1*10 //需要循环执行的语句
xxxxxx //需要循环执行的语句
s1+=1 //循环记数器自动增加
if(s1<10) //判断循环是否结束
then(label1) //如果没有结束跳转到label1
xxxxx //如果结束了继续执行下面这些行
另外还要说明的一点是,在RPG游戏时我们经常会遇到弹出有多项选择。比如说在买东西的时
候,会列出多个物品让你选择。我们把这多项选择也做成函数:
int choose(char *str,int n,char *item,int must)
就拿前面买东西来说,你在一个武器店的地图中放置一个店老板的NPC,他对应的脚本如下:
say(12,30,"您好,欢迎光临本店!")
:ask //询问您要什么
s1=choose("您要?",3,"买东西 卖东西 不要了",1)
if(s1==0)
then(buy) //跳转到买东西
if(s1==1)
then(sell) //跳转到卖东西
//不买也不卖
say(12,30,"不要了?您慢走!")
end
:buy //买物品
s1=choose("买什么?",5,"匕首 竹剑 钢剑 梭镖 铜锤",0)
xxxxxxxxxx //根据选择的选项s1
xxxxxxxxxxx //给玩家增加物品,减少金钱等等
xxxxxxxxx //
xxxxxxx //
xxxxxxxxxx //
if(s1==-1) //如果选择"买什么"时点了ESC退出
then(buy) //放弃买物品跳转到"您要什么"的选择
goto(ask) //买了一件物品后继续选择要买的物品
:sell //卖物品
s1=chooseobj() //从自己有的物品中选择一样
if(s1>100) //判断物品的种类
then(nosell) //决定是否跳转到nosell
xxxxxxxx //根据玩家选择的物品s1
xxxxxxxxx //减物品,加金钱等等
xxxxxx //
if(s1==-1) //选择要卖的物品时点了ESC键退出
then(ask) //放弃卖物品跳转到"您要什么"的选择
goto(sell) //卖完一件,选择还要卖的
:nosell //不收这种物品
say(12,30,"抱歉,这种东西我们不收!")
goto(sell) //继续选择要卖的物品
这其中的choose函数是我们在游戏程序中实现的一个多项选择的函数,以s1=choose("您要?",3,"买
东西 卖东西 不要了",1)为例
s1内放置的选项号码(第一个是0,第二个是1,依次类推)
"您要?"是多项选择时的提示
3是选项的个数
"买东西 卖东西 不要了"是三个供玩家选项,中间以' '分隔
1是强制玩家必须从这些选项中选择一个,不能按ESC键放弃选择(此时返回-1给s1),如果是
0则可以按ESC键放弃选择。
另外 chooseobj()也是我们在游戏中实现的一个函数。从玩家的物品中选择一样,返回它在玩家
物品匣中的位置,它在地图行走、战斗中的物品使用都可以使用。
前面的八篇讲了有关RPG游戏脚本解释机的实现,从这篇起,我们就开始从一个更高的位置对游
戏做统筹!
一、首先我们来看看一般RPG游戏的大体包括的模块。
1 系统初始化/结束:(system_init/system_exit)
在游戏进行之前,我们必须进行一些系统初始化的工作,象什么读取图片、音乐等数据,对屏幕
、声卡等硬件做初始化工作等等;同样,在游戏结束后,我们还需要进行一些整理工作,象是关闭设
备驱动、释放内存等等。
2 标题画面:(logo)
功能为显示游戏标题,选择菜单"新的游戏\读取进度\退出游戏"。并根据不同的选项分别调用
不同的模块。
有时,我们不需要另外做开始新游戏的模块,我们只要专门做一个游戏开始状态的进度,象读取
进度一样读取它,就可以完成这个功能。譬如:游戏最多能保存5个进度,从game1.sav到game5.sav,
我们事先做好一个进度为game0.sav,保存游戏初始的状态,玩家在读取进度时可以用通过
load_game(int n)调用(n=1,2,3,4,5)。当开始新游戏时则通过load_game(0)调用game0.sav。
3 读取/保存进度:(load_game/save_game)
读取就是从文件中读出一些数据装入游戏的变量中,保存就是将游戏中的变量保存到文件中。当
然你也可以指定一个算法在在数据读入和写入之前进行变换,从而起到对进度加密的效果。一般需要
保存的数据有:主角状态、主角所在地图、 npc状态、其他数据,这些可以根据游戏具体情况进行取
舍。另外进度的保存在游戏中进行,读取则在标题画面或者游戏进行中都行。(当然使用剧情脚本的
话,你甚至可以通过和某个npc交谈或者使用某件物品来保存进度。)
4 游戏进程: (run_game)
一般是一个较大的循环,在循环中处理玩家的按键、控制npc的运动、处理其他实时的事件、显
示地图等等。
二、模块的运行关系
游戏运行时,首先进行系统设置system_init(),然后调用标题画面i=logo(),如果i==0即玩者
选择"新的游戏",那么开始新游戏load_game(0),然后进行游戏run_game();如果i==1即选择"旧
的进度"则选择进度号l=choose_progress(),如果l==0返回标题画面。如果1<=n<=5则读取进度
load_game(l),然后再进行游戏run_game();如果i==2即玩者选择"退出游戏",则调用结束模块
system_exit(),然后结束程序。
当然在游戏进行过程中run_game()中,也可以读取进度load_game(l)和保存进度save_game(l);
三、其它
这些模块中,除了游戏进程模块run_game外,都比较容易实现,所以我们就略过不讲,今后着重
讲有关run_game的部分。
世界是在不停运动改变着的,我们用游戏所创造的虚拟世界也是这样,这种不断的运动在我们的
程序中就体现为循环。程序实际上是一种在计算机和用户之间进行交互的工具,为了响应用户的控制
,程序需要了解用户的输入信息,并把通过对计算机的控制做出相应的响应。但程序怎样了解用户的
输入信息呢?那就需要我们的程序主动的对用户用来输入信息的硬件(如键盘、鼠标、游戏杆等等)
进行检测。
用户可能在任何时候输入信息,因此程序也必须随时准备接收这种输入,在程序中接受输入的两
种方法有两种:一种是程序完全停止下来准备接收,直到接收到数据才开始继续运行;另一种是程序
以一定的频率在不断的循环,如果发现有输入信息,就对输入进行处理,如果没有输入信息时,程序
就继续循环并处理其他的事情。(就向tc里的bioskey(0)和bioskey(1),或者是windows编程中
GetMessage和PeekMessage)
注意:上面这两种方法的划分,是完全从编程的角度来看的,
即从某个函数或者方法来看的。实际上在硬件或者更
低级的机器语言中,输入的接收是完全采用循环检测
实现的。
显而易见,第一种方法有它的局限性,它是一种单向不可逆的交互过程,在需要用户一步步输入
信息的简单程序中比较适用,但在需要双向交互的实时程序中却难以适应。试想在动作或者射击类游
戏中,等待玩家每次按键后才运动、攻击的敌人是多么的愚蠢可笑呀!(我原来就在APPLE-II上做过
这样的游戏)
因此第二种方法才是游戏运行中关键的输入接收方法。也就是说,当玩家不进行输入操作时,程
序的循环就会去执行其他的事情,如控制敌人运动、攻击等等,而不是停下来等你输入。
当然,我们在游戏中也需要程序停下来等待输入的时候,比如"请按任意键 press any key...
"就是我们经常使用它的地方。
上面讲的这些并不是废话,因为在游戏中确需要区分这两种输入方法,正确的使用它们才能达到
你预期的效果。
比如:在 RPG游戏中,人物对话显示一屏后,就会等待玩家按键看下一屏对话。这时我们就可以
采用第一种方法,将程序完全停下来等待按键,也可以在玩家没有按键的时候在人物对话框的下方闪
烁着显示一个按键的图形,提示玩家按键。这时就需要采用上面提到的第二种方法。在游戏中这样的
细节很多,你完全可以自己决定采用什么的方法以达到什么样的效果。
我们的游戏主体,实际上就是在不断地处理着这样的用户输入、并对它做出响应的一个大的循环
体。有了这一概念,我们在对它进行设计时,就容易多了。
循环结构开始
--处理NPC
--检测用户按键
--如果是方向键,进行主角移动处理。如果触发事件,进
行事件处理。
--如果是Esc键,弹出游戏菜单,根据玩家选择处理。
--刷新屏幕(可以设定独立的定时事件完成)
循环结构结束
注意,其中的屏幕刷新,是由屏幕刷新率决定的,可以设置独立的定时事件来完成,也可以
放在这个主循环内进行。具体的实现方法,我们下次再讲。