本次教程内容:
- UML类图
- 字符处理的若干函数
- 脚本读取机制
上节课的类图如下
所谓类图,是一种分析工具,用于展示类之间的关系。便于我们进一步的分析。
类虽然不多,里面的关系还挺复杂,让我们看看类之间的关系:
先看空心三角箭头,它是一种比较明确的关系,术语叫做“泛化”,在这里意思可以理解为具体化。
RPG地图(MapRpg)和贪吃蛇地图(MapSnake)都是基础地图(BasicMap)的具体化。
地图管理器(MapManager)是基础地图管理器(BasicMapManager)的具体化。
再看空心菱形和实心菱形,它们的含义是“聚合”与“组合”。代表着局部与整体的关系,两者的差别在于结合的程度不同。
英雄信息目前是地图管理器的一部分,事件列表是地图的一部分,地图又是地图管理器的一部分。之所以地图与地图管理器用了实心菱形,因为考虑到地图管理器对地图的管理是密切的,地图不能离开地图管理器而单独存在。这一点与另外两者的关系都不同。
然后看箭头关系,它代表一般的关联,本图这里把这种关系表现为调用关系。即发出箭头的类,因为必须调用对方的某个函数,所以必须能够“看到”对方。
比如地图类因为必须借助地图管理器的executeAction函数来执行跨地图之上的动作,所以与BasicMapManager发生关联。
比如MapManager因为必须创建和实际执行事件,所以与Event发生关联。
这里还有一些关联关系,因为不是重点,所以没有表现出来。
比如BasicMapManager其实也能看到Event,但只是为了在函数的参数中说明,关联不大,所以省略。
比如MapManager其实可以看到MapRpg与MapSnake,但只是创建的时候使用,正常使用时,可以忽略它们的存在。
第二步,我们来看具体的类操作
两个核心功能类是Map和MapManager
Map的重点功能是handleKey、autoMove、initMap三个函数,这三个函数都必须都由MapManger调用。executeAction是为了让地图子类能够执行地图内的动作。
MapManager的重点功能是listen监听鼠标和executeAction执行动作。addMap是为了创建地图,loadEvent是为了创建事件。
从这张图来看,上节课做的最大的调整,是责任功能的重整,为此把Map类拆分为基础地图和扩展地图,并建立了BasicMapManager来提供接口。此外,事件的存储位置转到的地图类。
随着源代码增多,地图文件增多,以及音效文件未来也会增多,事件的定义将来肯定也会以文件的形式保存在外面,这些文件混放在一起,已经开始显得乱了。
我们打算建立两个子目录把这些文件分开存放,地图文件和将来的事件定义文件放在.\data\中,音效文件放在.\sound\中,源代码继续保留在根目录下。
这个增加的知识,与地图和事件对象的创建有关,与音效的执行有关,因此显然应当是地图管理器的知识。
dataPath= ".\\data\\";
sndPath= ".\\sound\\";
这里值得注意的是,在字符串中,“\ ”代表的转义符,如果想在字符串常量中表现“\ ”必须用两个“\\ ”表现一个。
另外,在addMap函数中
void addMap(string aName, int ax= 0, int ay= 1, int aMode= 1){
aName= lowCase(aName);
if (mapList.count(aName)== 0){
BasicMap *pmap;
if (aMode== 1){
pmap= new MapRpg();
} else {
pmap= new MapSnake();
}
pmap->mm= this;
pmap->x= ax;
pmap->y= ay;
pmap->mode= aMode;
pmap->loadMap(aName, dataPath+ aName+ ".txt");
mapList.insert(make_pair(aName, pmap));
}
}
因为地图对象此时已经不知道地图所在具体位置的知识,所以必须在调用前组合好完整的文件名,同时索性也剥夺了地图类关于地图文件扩展名的知识。于是把接口参数拆为两个,将地图名和地图的文件分开传。
之所以强调这个细节,是希望读者注意到,在面向对象编程的时候,知识的分配与传递实际上在很大程度上是一个程序设计的重点。
然后,我们来做本节课的重点:将事件定义移动到文件中。
未必避免一次改动过大造成出现难以定位的问题,我们仍然采取逐步改造的办法,第一步仅仅实现现有结构的事件信息从文本文件中读取。
我们采用一直和c++比较类似的结构保存事件信息。例如主地图中两个事件的格式为:
#start
move(10,5){
jump(map1);
}
move(30,5){
jump(snake,5);
}
#后面是地图名
下面每个事件用函数的形式来表现。
具体格式为:
触发机制(逗号分开的触发参数列表) {
动作(逗号分开的动作参数列表);
}
我们注意到,这样的格式下,可以支持一个事件中执行多个动作,这样显然是更有表现力的。但我们把这一调整留到下一步,因为同时调整Event的结构会导致修改动作过大。
loadEvent函数,终于可以名副其实地带上文件名参数了。
void loadEvent(string aFile){
ifstream fin(aFile.c_str());
if (fin) {
BasicMap *map1= 0;
string strCode= "";
while (!fin.eof()){
string str1;
getline(fin, str1);
str1= trim(str1);
if (str1==""){
} else if (str1[0]=='#'){
// 开始一张新地图
// 先把前一张地图的事件定义好
readEvent(map1, strCode);
// 继续本地图
string mapName= str1.substr(1);
map1= getMap(mapName);
if (!map1){
cout << "地图不存在" << mapName<< endl;
system("pause");
} else {
strCode="";
}
} else {
// 地图中的事件定义,先读入字符串,后分析
strCode+= str1;
}
}
// 文件全部读完后,处理最后一个地图的内容
readEvent(map1, strCode);
}
fin.close();
}
注意我们怎样读取事件的定义,读到#标记,就意味着开始一张新的地图,此时,把已经保存的上一张地图的事件信息进行处理。然后清空地图代码,逐行读取事件定义,累计起来,直到下一次读到另外一张地图。
对于最后一张地图,没有这种触发机制,我们在全部文件读完后,再次调用单地图信息读取函数。
其中trim函数,是一个字符串的常见函数,其功能是删除字符串首尾的空白字符,包括空格,TAB键,回车等。
因为在人类输入文本文件时,这几种空白符经常是被自然忽略的,所以我们希望计算机也能忽略它们。
为了实现这个功能,我们建立了4个函数。
//函数一:判断一个字符是否是空白字符
bool isBlank(char aChar){
return (aChar==' ' || aChar=='\t' || aChar==0x0a || aChar== 0x0d) ;
}
//函数二:消除左边的空白字符
string ltrim(string aStr) {
int i=0;
while (i< aStr.size() && isBlank(aStr[i])) {
i++;
}
return aStr.substr(i);
}
//函数三:消除右边的空白字符
string rtrim(string aStr) {
int i= aStr.size()- 1;
while (i>=0 && isBlank(aStr[i])) {
i--;
}
return aStr.substr(0, i+ 1);
}
//主函数:消除两边的空白字符,这是最常用的。
string trim(string aStr){
return ltrim(rtrim(aStr));
}
然后我们再看另一个关键函数readEvent
void readEvent(BasicMap *map1, string aCode){
if (!map1 || aCode==""){
return;
}
cout << aCode << endl;
// 内部格式,被定义为一个个类似函数的结构 trigger(triggerParm){Atciont(actionParm);...}
int i= 0;
while (i< aCode.size()){
string trigger= readBefore(aCode, i, '(');
if (trigger==""){
break;
}
string triggerParm= readBetween(aCode, i, '(', ')');
string inner= readBetween(aCode, i, '{', '}');
int j= 0;
string action= readBefore(inner, j, '(');
string actionParm= readBetween(inner, j, '(', ')');
Event evt1(trigger, triggerParm, action, actionParm);
map1->addEvent(evt1);
}
}
怎样读取这个类似c++函数定义的格式,代码逻辑意外的简单。
触发部分:
trigger:读取到“(”前
triggerParm:读取两个小括号之间的内容
代码部分,首先读取两个大括号之间的内容
action:同样的逻辑,读取到“(”前
actionParm:同样的逻辑,读取两个小括号之间的内容
(这里没有体现多个执行动作,所以也就没发挥分号的作用,读者可以先自己想一下,当我们准备支持多动作的时候,这里将怎样修改?)
所以,我们还须再实现两个关于字符串的功能函数readBefore和readBetween
// 读到某字符之前的内容
string readBefore(string aStr, int &aFrom, char aChar) {
string ret= "";
if (aStr.size()<= aFrom) {
return "";
}
int i= aFrom;
while (i< aStr.size() && aStr[i]!= aChar){
ret+= aStr[i];
i++;
}
aFrom= i;
return ret;
}
// 读两个字符之间的内容
string readBetween(string aStr, int &aFrom, char aChar1, char aChar2) {
if (aStr.size()<= aFrom) {
return "";
}
int i= aFrom;
while (i< aStr.size() && aStr[i]!= aChar1){
i++;
}
i++;
string ret= readBefore(aStr, i, aChar2);
aFrom= i+ 1;
return ret;
}
我们看到,readBefore的逻辑和前面的ltrim的很相似的,而在readBetween中,也使用了这个相似的逻辑,然后又调用了readBefore来实现其部分功能。
这个逻辑用我们前面的算法套路来分析就是:
遍历:字符串
条件:某个条件(是空白字符/不是某个字符)
处理:记录位置并退出(同时记录本区间的内容,或事后根据位置截取,两者等价)
这样,基本实现了事件信息的脚本化并从文件中读取。
正在小Q思考怎样继续取消更多的硬编码,小Pa又来了。
闯关贪吃蛇的效果很让小Pa满意,回头再看走迷宫的游戏,越来越觉得不顺眼了。
小Pa:现在这个迷宫游戏,一旦进入就必须连走三关才能出来,感觉好无聊啊。
小Q:因为你已经走了很多遍,所以觉得无聊,刚接触游戏的人,不会这样感觉的。
小Pa:当我们玩贪吃蛇的时候,如果感觉不喜欢了,随时可以撞墙退出,但迷宫游戏没有这个机制。
小Q:那又能怎么办呢?
小Pa:我的想法是,在迷宫地图中,加上呼出菜单的功能,打开菜单后,用户可以选择回到主界面。
小Q:嗯,能实现,你回去设计一下菜单选项,以及每个菜单项执行什么功能。
小Pa:这个不用设计,就一个选项:重新开始,功能就是回到开始地图。
小Q:OK,那你回去休息休息,让我想一想怎么做。
课程小结:
菜单功能对RPG游戏来说,是必不可少的。其实菜单所表现的就是一系列事件,所以它的结构可以和事件定义的结构非常相似。目前的功能,足以读取复杂度的脚本还有所不足,但对于我们现在的应用已经足够了。
欢迎加入编程教学讨论群:102494165
本教程每节课的源代码,统一下载地址
链接:https://pan.baidu.com/s/1q4aoYesre1PHaCoV8gkhDQ
提取码:8den