本次教程内容:
- 模块化的编程思维
- 事件概念的详细分析
- 字符串处理函数
- 通用的算法结构
能从架构上解决的问题,才是真正的解决了。凑合上的“解决”方案,充其量只是延缓了系统的崩溃,从而争取了解决问题的时间。
上节课讲到,在测试小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