第二章教程18:UML类图分析


本次教程内容:

  • 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 

 

发布了24 篇原创文章 · 获赞 0 · 访问量 4562

猜你喜欢

转载自blog.csdn.net/xiaorang/article/details/105110071