第二章教程15:事件系统初入

本次教程内容:

  • 模块化的编程思维
  • 事件概念的详细分析
  • 字符串处理函数
  • 通用的算法结构

能从架构上解决的问题,才是真正的解决了。凑合上的“解决”方案,充其量只是延缓了系统的崩溃,从而争取了解决问题的时间。

上节课讲到,在测试小Pa的第三张地图时,眼看走到出口了,却怎么也走不出去。

开发组和小Pa一起打开地图文件一看,原来那张地图的出口处由于没有墙,小白忘记了在后面补足空格。于是当检测可通行性的时候,就出现了阻碍。在编辑时用鼠标选择一下该区域就可以明显看出问题。

“这个问题好解决”小Pa准备动手修改,开发组长小Q想到了另一个问题:“把最后一张地图打开看看。”

果不其然,最后一张地图问题更多。“没关系,我来改。”小Pa叹了一口气,但很明显这是地图没有做好,把每一行的空格都补齐,虽然无聊,但看来是必不可少的工作。

“不用,这个问题我们来处理。”

在软件中,每次都依赖地图设计者手工把空格补齐,这是一种脆弱的处理办法。原始数据中的问题,最终仍然是程序员的问题。软件必须有一定的*容错性*

这个问题大概也是第一次,让我们的RPG中出现了一点点算法的感觉,题目是:输入是若干字符串,输出结果是按最长的一个字符串的长度,把所有字符串在后面补齐空格。


解决办法也是很明确的:

  • 1、找到最长字符串的长度L1
  • 2、遍历所有字符串,计算长度L2
  • 3、给每个字符串后面增加(L1 - L2)个空格

从软件的角度,我们可以直接修改loadMap函数,但更好的做法是增加一个函数fixMap专门来做地图修正,把原来的loadMap函数改为loadMapFromFile,另外再重写一个新的loadMap函数,同时将设置地图名的功能也包含进来。这里就是我们的设计原则之一,能够下放给下层模块的功能,尽量下放。
完整代码如下:

    void fixMap(){
        int L1= 0;
        for (int y=0; y< mapInfo.size(); y++){
            if (mapInfo[y].size()> L1){
                L1= mapInfo[y].size();
            }
        }
        for (int y=0; y< mapInfo.size(); y++){
            int L2= mapInfo[y].size();
            mapInfo[y]= mapInfo[y]+ repeatStr(L1-L2, " ");
        }
    }
    void loadMapFromFile(string aFileName){
        ifstream fin(aFileName.c_str());
        if (fin) {
            while (!fin.eof()){
                string str1;
                getline(fin, str1);
                mapInfo.push_back(str1);
            }
        }
        fin.close();
    }
    void loadMap(string aName){
        name= aName;
        loadMapFromFile(aName+ ".txt");
        fixMap(); 
    }

分析一下fixMap的代码逻辑:

第一轮循环:

  • 1、遍历:所有字符串
  • 2、处理条件:字符串长度超过记录值
  • 3、处理:更新记录值

第二轮循环:

  • 1、遍历:所有字符串
  • 2、处理条件:全部
  • 3、处理:字符串后面添加数量不等的空格

在第二轮循环中,每个字符串后面增加(L1-L2)个空格。补足空格应当写一个独立功能的函数repeatStr,切不可把此功能合并到fixMap中。这是刚刚掌握编程逻辑者最常犯的错误:缺少拆分模块的意识。

repeatStr函数,代码放在tools中,逻辑如下:

string repeatStr(int aNum, string aPart){
    string ret= "";
    for (int i=0; i< aNum; i++){
        ret= ret+ aPart;
    }
    return ret;
}

需求是生成一个指定长度的空格字符串,但我们实现的函数是重复若干任意字符串。这是在解决一个更一般的问题,这种解决思路是编程中一个基本的思路。
对于初学者来说,能经常在自己的软件中写出这样结构清晰,功能明确的小模块,才有进一步搭建较复杂系统的基础。否则遇到复杂一点的系统,思维是很难理清的。

如果你觉得调用时候repeat(L1-L2, " ")现得含义不够明确,这是可以理解的,我们可以再增加一个工具函数space,它的功能就是生成一个指定长度的空格字符串。代码如下:

string space(int aNum){
    return repeatStr(aNum, " ");
}


注意,这个函数直接调用了前一个工具,这种直接的封装,既保证代码的清晰度,又做到了抽象和解决一般问题。这是正确的编程实践方向。


补足空格问题得到了完美的解决,小Pa很高兴。将自己的工作成果:最后一张地图和鼓掌音效都提交给了开发组。由开发组来把它组装在一起。
组装的方式是还是通过事件。
前面一节课,小Q已经设计了几个位置事件,动作是地图跳转,作用是把几个地图连接了起来。


现在还得再增加三个事件:

  • 1、一个是地图载入事件,当最后一张地图载入时,增加播放音乐的动作
  • 2、最后一张地图上增加两个位置事件,其中一个的动作是地图跳转
  • 3、另一个动作是软件退出

描述一个事件,有四个方面:

  • 1、事件的触发类型
  • 2、事件的参数
  • 3、事件的动作
  • 4、动作的参数

只有把这些事件描述清楚了,我们才能把它们设置好:
三个事件的详细分析:

  触发类型  事件参数 动作 动作参数
事件1 地图载入 mapWin 播放音乐 鼓掌.mp3
事件2 移动 mapWin,10,4 地图跳转 map1
事件3 移动 mapWin,34,4 软件退出

目前我们已经看到了两类事件触发类型和三类动作类型,可以想见,随着游戏内容的丰富,触发类型和动作类型还会持续增加。

于是我们这样设计事件类,包括4个参数。其中触发类型和动作类型,都用一个字符串来表示,而事件参数和动作参数,考虑都有可能是多个(虽然现在动作参数只有一个),所以都用vector<string>来表示,这是一个合理的前瞻度。

    string trigger;
    vector<string> triggerParm;
    string action;
    vector<string> actionParm; 

我们应当在结构设计时,保持一定的前瞻性。但保持前瞻性既不能因此造成过大的额外成本,也不能用此取代持续重构的行为(可以让重构的频率更加合理)。
开发具有一定复杂度的系统,希望一步设计到位的想法是天真的。如果用这种想法指导工作,带来的只能是挫败感和烂尾的项目。
其实,所谓的前瞻度,与其说是考虑到未来可能会出现的变化,倒不如说是在解决现有问题的时候,尽量用架构的方法来解决。意思是让这个问题的解决放在软件的结构中,而非出现在内容中。

一个现成的例子是:在上节课的代码中,我们使用string来描述事件的动作,因为那时候的事件动作只有一个跳转地图,记录了目标地图,也就相当于记录了完整的事件动作。这样的做法让这里的字符串的内容出现了特殊的含义,这就是将问题的解决放在内容中。
本节课的做法则是建立了一个结构体,将事件信息完整保存了,这样的做法就是将问题解决放在了结构中。
考虑一下这两种处理方式的扩展性:如果动作类型增加,动作参数增加,字符串存储方案仍然能够解决,但必须增加更复杂的专用的字符串分析函数,这是非正规的处理方法,导致内部复杂性暴露于系统中。而结构体存储方案则可以正规地增加新的变量来存储更多的参数,所增加的复杂性会被封闭在类型内。
在系统规模尚小的时候,两种处理方法的差别不明显,但前一种处理方法如果不能及时被重构,而且不小心在其基础上构建了系统的其它部分,随着系统的复杂性增加,系统的可扩展性就会受到严重的制约,系统维护成本也会快速上升到一个不可接受的程度。

在详细介绍事件处理代码之前,我们先来看两组常用的字符串处理函数:
第一组:字符串转大/小写。正如我们了解的,c++本身就是一种大小写敏感的语言,相同字母但大小写不同的字符串被认为是不同的字符串,如果用于事件比较,最好是统一转成小写(或大写),才是可靠的。代码如下:

string lowCase(string s){
    int len=s.size();
    string ret= "";
    for(int i=0; i<len; i++){
        char c= s[i];
        if(c>='A'&&c<='Z'){
            c= c- 'A'+ 'a';
        }
        ret= ret+ c;
    }
    return ret;
}
string upCase(string s){
    int len=s.size();
    string ret= "";
    for(int i=0; i<len; i++){
        char c= s[i];
        if(c>='a'&&c<='z'){
            c= c- 'a'+ 'A';
        }
        ret= ret+ c;
    }
    return ret;
}


注意两者的算法逻辑是相同的:

  • 1、遍历:字符串的所有字符
  • 2、处理条件:大小写的判断法
  • 3、处理:大小写的转换法

第二组:字符串拆分与组合。所谓拆分,就是把一个由某个字符(比如逗号)连接起来的字符串,拆为一个字符串数组。而组合,是拆分的反运算,就是把一个字符串数组用某个字符连接起来。在事件的数据初始化和触发判断中,会用到这两者处理,代码如下:

vector<string> split(string aStr, char aDem = ' ') {
     vector<string> sv;
     sv.clear();
     stringstream res(aStr);
     string temp;
     while (getline(res, temp, aDem)) {
         sv.push_back(temp);
     }
     return sv;
}
string link(vector<string> aList, char aDem){
    string ret;
    for (int i=0; i< aList.size(); i++){
        if (i>0) {
            ret+= aDem;
        }
        ret+= aList[i];
    }
    return ret;
}


字符串拆分函数split,又一次利用了stringstream的特点,以及getline函数的一个特殊用法,实现了拆分。
分析一下算法逻辑

  • 1、遍历:输入字符串
  • 2、处理条件:读到分隔符(靠getline自动实现)
  • 3、处理:加入动态数组中

组合函数link,必须保证连字符只能出现在中间,而不在字符串的前后出现额外的连字符。

分析算法逻辑:

  • 1、遍历:数组中的每个字符串
  • 2、处理条件:第一个无连字符,后面均有
  • 3、处理:连入字符串

我们注意到本节课讲了5个函数的算法逻辑,都具有一结构上的相似性。事实上这种结构,涵盖了计算机处理逻辑中相当大的一批算法,具有一定的普适性,希望读者能留下印象。

再看事件定义的完整代码:
 

struct Event{
    string trigger;
    vector<string> triggerParm;
    string action;
    vector<string> actionParm; 
    Event(string aTrigger= "move", string aTriggerParm="", string aAction="jump", string aActionParm= ""){
        trigger= aTrigger;
        triggerParm= split(aTriggerParm, ',');
        action= aAction;
        actionParm= split(aActionParm, ',');
    }
    string getTriggerText(){
        string ret= "";
        ret+= trigger+ "_"+ link(triggerParm, '_');
        return ret;
    }
};

注意初始化函数的处理方法决定了事件定义的方法,这里的处理,正是前面所说的,用内容的方式来处理。这种做法当然是有局限性的,但也有其利益(与硬编码一样),在开发过程中我们可以这样做,而且经常会这样做,利用它的益处,避免它的危害。软件开发过程实际是一个充满价值权衡的动态过程。

下面是在MapManager中对事件列表进行初始化的代码:

    MapManager(){
        hero.mark= "♀";
        hero.color= 14;
        {
            Event evt1;
            evt1={"move", "map1,40,19", "jump", "map2"};
            //cout << evt1.getTriggerText() << endl;
            eventList.insert(make_pair(evt1.getTriggerText(), evt1));
        }
        {
            Event evt1;
            evt1={"move", "map2,40,1", "jump", "map3"};
            //cout << evt1.getTriggerText() << endl;
            eventList.insert(make_pair(evt1.getTriggerText(), evt1));
        }
        {
            Event evt1;
            evt1={"move", "map3,40,19", "jump", "mapWin"};
            //cout << evt1.getTriggerText() << endl;
            eventList.insert(make_pair(evt1.getTriggerText(), evt1));
        }
        {
            Event evt1;
            evt1={"Load", "mapWin", "playSound", "鼓掌.mp3"};
            //evt1={"move", "mapWin,12,4", "playSound", "鼓掌.mp3"};
            //cout << evt1.getTriggerText() << endl;
            eventList.insert(make_pair(evt1.getTriggerText(), evt1));
        }
        {
            Event evt1;
            evt1={"move", "mapWin,12,4", "Jump", "map1"};
            //cout << evt1.getTriggerText() << endl;
            eventList.insert(make_pair(evt1.getTriggerText(), evt1));
        }
        {
            Event evt1;
            evt1={"Move", "mapWin,28,4", "quit", ""};
            //cout << evt1.getTriggerText() << endl;
            eventList.insert(make_pair(evt1.getTriggerText(), evt1));
        }
        //system("pause");
    }

这是一段重复性很强的代码,但对于初学者很有几个值得注意的地方:

  • 1、大括号的使用。

我们使用大括号作为初始化手段,同时我们使用大括号作为程序段的区分。在没有if/for/while这些代码时,我们一样可以使用大括号,大括号的一个好处是在其中定义的变量,出去之后就消失了,我们只是利用这一特性,让我们的硬编码代码尽量小改一些地方。

  • 2、初始化数据

初始化数据用逗号分开,对应着在构造函数用split(_,',')来拆分。虽然说它仍然是一种基于内容的特殊处理,但由于这种格式极为常用,所以在某种意义上来说,也可以算是一种标准化的处理手法了。
另外,初始化数据的硬编码中,我们特意地使用不同的大小写,以确保我们的代码中能够支持大小写无关性。大小写无关性可以算是一个最起码的容错需求,如果仅仅因为jump被写为Jump就导致软件不能正常运行,是难以被客户接受的。

  • 3、一词多义

在单人开发的项目中,尚且出现一词多义现象,在多人开发的项目中,一词多义更加难免。注意jump这个词,我们一方面把它当作一种触发机制,另一方面又把它当作一种动作。这并非是不合理的,我们代码中的函数也使用了jump这个词,我们确实是在jump函数中检测jump事件,也确实是在jump动作时调用jump函数。如果我们能够始终清楚这两者之间的关系,它就不会出现什么问题,就如同有诸葛亮在的时候魏延就不会出什么问题。

  • 4、注意对playSound的调试

我们不希望每次测试音效都必须走一个迷宫,于是我们我们把事件放在最后一张地图上,只须走几步,就能测试。

  • 5、被注释掉的cout

dev-C++不是一个很容易调试的IDE,与其研究它的调试方法,不如老老实实用cout作为最基础的调试工具。把一些复杂计算过程结果输出来,便于我们发现错误。

  • 6、system("pause");

让程序暂停,按任意键后继续,在辅助我们的调试工作时很有用处。
我们或许记得清屏幕的代码system("cls");
两者非常类似,其实都是系统功能的调用。

再看一下事件触发代码:

    void testMove(int ax, int ay){
        string str1;
        str1= "move_"+ currentMap.name+ "_"+ int2str(ax)+ "_"+ int2str(ay);
        testEvent(lowCase(str1));
    }
    void testJump(){
        string str1;
        str1= "load_"+ currentMap.name;
        testEvent(lowCase(str1));
    }
    void testEvent(string aTrigger) {
        if (eventList.count(aTrigger)== 1){
            Event evt1= eventList[aTrigger];
            if (lowCase(evt1.action)=="playsound") {
                playSound(evt1.actionParm[0]);
            } else if (lowCase(evt1.action)=="jump"){
                jumpMap(evt1.actionParm[0]);
            } else if (lowCase(evt1.action)=="quit"){
                exit(0);
            }
        }
    }

原来在英雄移动时直接调用testEvent函数,现在看来是不合适的。因为英雄移动只能触发其中一种事件,于是改为testMove函数。同时,增加一个testJump函数用于检测地图跳转事件。但未来避免重复,这两个函数的共同点应当被提炼出来,作为一个独立的函数。

课程小结:

好消息是,第一个小游戏:《走迷宫》已经成型了,可以玩了。
坏消息是,如果想对游戏进行调整,改变关卡数量、或者连改变入口和出口位置,都必须由程序员来操作。
现在的map.cpp代码中,使用了大量的硬编码把地图、事件、音效粘合起来。在这种模式下,客户变成了程序员的助手,只是提交资源和进行测试。程序员在获得了控制感的同时,也承担了过多的责任和工作量。就像一开始我们把地图数据从代码中分离出来一样,我们必须继续把代码和这些事件等数据分离出来。以能提供给客户和程序员双方更多的灵活性,在解决这个问题之后,合作的效率将会提升。

本教程每节课的源代码,统一下载地址
链接:https://pan.baidu.com/s/1q4aoYesre1PHaCoV8gkhDQ 
提取码:8den 

 

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

猜你喜欢

转载自blog.csdn.net/xiaorang/article/details/105003343
今日推荐