【目标】:为游戏中的敌人赋予移动能力,使其可以按照预定的轨迹移动
【参考】:
1、MoonWarrior 工程源码
2、CCDictionary 解析xml总结
3、cocos2D-x权威指南(基本没什么用)
一、移动控制的大致架构
当前正在进行的尝试是制作一个简单的弹幕射击类游戏。我以前没有设计游戏架构的经验,这里设计的架构可能是非常糟糕的,仅作为一种早期的探索和尝试。
这里的处理的基本思路如下:
1、所有的敌人都由一个管理者管理。
2、每个人物有自己预制的一条行动路线。
3、每个行动路线可以被拆分为多个连续的简单路径。
具体到代码,会设置一个 EnemyManager 来管理所有的Enemy,其中又包含已经登场的敌人,和等待登场的敌人。每个Enemy都有一个自己的行动路线,称为 Movement,每个Movement又保有一个简单路径的队列,这个简单路径是以MovementFunction为基类的类型:
下面分别介绍各个部分。
二、人物管理:敌人的登场和离开
1、几种模式
这个部分面对的问题是:如何创建一坨敌人,让他们按照指定的时间入场和离开?
回忆我们玩过的游戏,可以大致总结出以下几种模式:
1、地雷式:中国RPG中相当常见的踩地雷(最新的轩辕剑6还在用这种模式)。完全不在主游戏界面中创建敌人,仅仅在满足一定条件时触发战斗,根据当时场景的信息(例如主角所在的位置),来创建敌人。这种方式最简单,或者说根本就不存在我们所说的问题。但是对于我们这里要做的射击游戏来说,是完全不可行的。
2、生态式:游戏仅仅预制敌人的位置,并赋予敌人一定的人工智能,使得其可以根据场景来自动采取行动。很多ARPG都是这个套路,或者是和下面的设定路线式结合使用。我这里主要是探究移动管理,所以这里暂时不采用这种方法。
3、脚本式:游戏场景设定脚本,所有的敌人完全按照脚本行动。很多弹幕射击类是这个方式(所以你背板就会容易不少)。我这里也就采取这个策略。
具体来说,就是通过一个脚本文件,设定所有的敌人的行为,然后通过一个Manager管理所有的敌人,使得其按照脚本行动。这个管理者, 就是我前面图中的 EnemyManager。
2、实现方式
EnemyManager 维护两个列表,一个是已经登场的敌人existingEnemyList,一个是将要登场的敌人waitingEnemyList。
游戏刚开始时,所有的敌人都处在等待登场的列表中,按照入场的时间顺序排列。然后在重载的update中检查列表头,让需要入场的敌人入场。update中同时还会更新每个已经入场的敌人的状态:
void EnemyManager::update(float dt) { static float ellapsedTime = 0; ellapsedTime += dt; //更新场上现有的 std::vector<PlaneBase *>::iterator itor; for ( itor = existingEnemyList.begin(); itor != existingEnemyList.end(); itor ++ ) (*itor)->updatePlane(dt); //如果有敌人该上场了,则将它加入,并移入 existingEnemyList while ( !waitingEnemyList.empty() && waitingEnemyList.front()->isTimeToAppear(ellapsedTime) ) { PlaneBase * newEnemy = popFromWaitingQueue(); addChild(newEnemy); existingEnemyList.push_back(newEnemy); } std::vector<PlaneBase *>::iterator iter = existingEnemyList.begin(); while ( iter != existingEnemyList.end() ) { if ( (*iter)->isTimeTodisappear() ) iter = removeFromExistingList(iter); else iter ++; } }这里有两个辅助的小函数:
//这里不做queue为空的检查 PlaneBase * EnemyManager::popFromWaitingQueue() { PlaneBase * ret = waitingEnemyList.front(); waitingEnemyList.pop(); return ret; } //先保存结果,我不清楚release会不会使得这里的erase失效 std::vector<PlaneBase *>::iterator EnemyManager::removeFromExistingList(std::vector<PlaneBase *>::iterator itor) { CCNode * item = (*itor); std::vector<PlaneBase *>::iterator ret = existingEnemyList.erase(itor); removeChild(item, true); item->release(); return ret; }
这里被管理的Enemy是一个加入了内存自动管理池的CCNode,在创建的时候,为了不在函数结束的时候自动释放,需要retain一把(这一套可以从 addChild中学习),那么在EnemyManager析构的时候,自然需要将所有的enemy的引用释放掉,以免内存泄露。
其实这一套在移除一个消失的敌人的时候也需要做,所以在上面的 removeFromExistingList 中就已经有了 release操作,这里还需要照葫芦画瓢再来一次:
EnemyManager::~EnemyManager() { //移除未释放的list中的元素,否则会内存泄露 while ( !waitingEnemyList.empty() ) popFromWaitingQueue()->release(); std::vector<PlaneBase *>::iterator iter = existingEnemyList.begin(); while ( iter != existingEnemyList.end() ) { iter = removeFromExistingList(iter); } }
最后,我们还需要把这个类做成单实例,方便外部访问。这里使用静态局部变量来实现:
EnemyManager * EnemyManager::getInstance() { static EnemyManager * manager = EnemyManager::create(); return manager; }
三、移动函数:让敌人动起来
但是要让敌人实现复杂的运动轨迹,就需要为其赋予一个移动函数,让他照着函数轨迹运动。虽然最终的目标是可以是一个连续的分段函数,不过这里先做简单的一段式的运动函数。
这里实际上我只实现了两种最基本的运动函数,一种是完全不动的,一种是多项式的。两者都继承于基类MoveFunctionBase。实现并不复杂,主要只有两个困难点。
1、多项式的保存
由于C中的数组是不知道length的,所以主要的麻烦在于确定多项式的项数。我这里想了两种初始化多项式的方法,一种使用模板,可以参考《非类型模板类》,缺点是使用的时候必须要特化,比较麻烦。另外一种就是构造的时候直接传入项数,这个简单直接,所以最终还是选择了这一种。
2、屏幕分辨率的适应
众所周知,android手机有多种分辨率。移动函数计算的结果应该是 position。但是对于同样一个 ccp(500, 600),可能在某些手机上在屏幕外,但是在某些手机上可能还只是在屏幕中央,所以需要适配屏幕分辨率。
我这里借鉴了 MoonWarrior的处理办法,具体实现是在 LevelManager.js 中。它的方法是取出屏幕大小,然后入场在屏幕分辨率的基础上再取偏移量:
case MW.ENEMY_MOVE_TYPE.VERTICAL: offset = cc.p(0, -winSize.height - enemycs.height); tmpAction = cc.MoveBy.create(4, offset); break;我这里更进一步,让移动函数计算出来的结果只是一个比例 (x, y),然后再用屏幕分辨率换算成实际坐标
(px, py) = (x * winSize.width, y * winSize.height)
这样,无论在什么样的屏幕上,至少位置应该是没有问题了。
3、移动的开始和结束
如何标记这段移动函数已经结束?我这里使用的是时间做为标记。每个移动函数都有一个持续时间,外部会不断调用其update来更新位置,同时累计已经持续的时间,并用这个 currentTime 和持续时间比较。如果超过持续时间, 则判定本次移动已经结束。
四、复杂的移动轨迹:连接多个分段的移动函数
这一步,是要把多段移动函数组合成一条复杂的移动曲线。这里暂时还不考虑循环移动的情况,使用一个队列来记录各段移动函数。然后类似于上面的enemyManager,不停的检查当前运动轨迹的情况,并更新队列。
1、构造函数
由于要初始化一个队列,所以又面临要传递数组的情况,这里使用另一种解决方案,用可变参数列表来解决。一些关键的语法点,可以参考《可变参数列表》。具体代码如下:
Movement::Movement(MoveFunctionBase * firstMove, ...) : totalStage(0), currentStage(0), currentMoveFunction(firstMove), isOver(false), currentPosition(firstMove->startPoint) { //放入第一个元素 pushIntoQueue(firstMove); //处理后面的可变参数列表 va_list itor; va_start(itor, firstMove); MoveFunctionBase * current = va_arg(itor, MoveFunctionBase*); while ( current != NULL ) { pushIntoQueue(current); current = va_arg(itor, MoveFunctionBase*); } va_end(itor); this->autorelease(); }同样需要注意的是,为了防止 CCNode在构造函数结束时被自动销毁,这里在 pushIntoQueue 的时候要 retain一下:
void Movement::pushIntoQueue(MoveFunctionBase * move) { movementQueue.push(move); move->retain(); totalStage ++; }
当然,在析构或出栈的时候,需要做对应的 release操作。
逻辑实在是很简单,这里直接贴一下代码。
CCPoint Movement::updatePosition(float dt) { //已经结束了,不再更新 if ( isOver ) return currentPosition; currentPosition = currentMoveFunction->updateMove(dt); //变化也发生在下一次更新时 if ( currentMoveFunction->isMoveOver() ) { currentStage ++; removeFrontOfQueue(); //理论上第一个判断成立即可 if ( currentStage >= totalStage || movementQueue.empty()) { isOver = true; } else { currentMoveFunction = movementQueue.front(); } } return currentPosition; }
OK,我们现在就有了一套可以使得敌人运动的机制了,不过既然是脚本,那么就要支持通过读取文件来创建,这是我们下一步的工作。
五、从list文件中创建
这里主要是参考《CCDictionary 解析xml总结》,这篇文章写的非常好。另外就是 CCParticleSystem 的源码。
1、CCDictionary的大致情况
以前就使用过 CCDictionary类,不过当时的用途非常简单,我们这里要稍微复杂一点。目前我已经知道的可以填入的类型有以下类型:
1)bool类型:使用 <true></true> 或者 <false></false>标签,TAG里面不需要填充
2)int类型:使用 <integer>1</integer> 标签
3)浮点数类型:使用<real>0.5</real>标签
4)字符串类型:使用 <string>{0.5}</string> 标签
5)数组类型:使用如下结构:
<array>
<??>……第一个元素……</??>
<??>……第二个元素……</??>
…………
<??>……第N个元素……</??>
</array>
6)字典类型:使用<dict>………</dict>标签
2、数据的解析
CCDictionary 有两个主要的解析数据的方法,一个是 objectForKey,返回的是 CCObject *,另一个是 valueForKey,返回的是 CCString。
按照我的使用习惯,一般对于基本类型和字符串类型,使用 valueForKey获得字符串,然后再调用CCString 的方法,如 IntValue,来转换成对应的值。需要注意的是,CCstring的各种转换的默认值都是0,或者是“”。
对于数组,或者字典,则一般使用 objectForKey,然后用 static_cast 来进行转换。
3、数组的解析
一般的数组类型解析都很简单,而对于数组,常见的有两种方法。
1)使用 <array>标签:
例如plist中这样的一个数组:
<key>movement</key> <array> <dict> 元素1 </dict> <dict> 元素2 </dict> <dict> 元素3 </dict> </array>(这里的元素1、2、3都是伪代码)
要解析这样一个数组,首先通过对应的key找到array指针:
CCArray * moveArray = static_cast<CCArray *>(dict->objectForKey("movement"));然后再需要使用的时候,将每个元素转成CCDictionary指针:
for ( unsigned int i = 1; i < moveArray->count(); i ++ ) { movementDict = static_cast<CCDictionary *>(moveArray->objectAtIndex(i)); }可能会有另外的疑惑:如果这里的数组保存的是基本变量,该如何处理呢?其实CCDictionary解析出来的结果都可以转换成 CCString *。所以将这里类型转换的目标替换成 CCString,然后再取intValue即可。
2)解析String:
上面的方法,如果是写一个dict这样比较大的元素,还可以接受,但是如果要保存一个float数组,还要用array再去一个个写plist,未免有些麻烦。我们可以参考cocos源码中 CCNS 中解析 CCPoint的方法,通过保存一个 {-0.1,-0.1} 这样的字符串,然后通过代码解析,来实现在plist保存float数组的目的。
//简化版,去掉很多强壮型判断 void RoninUtils::splitWithForm(const char* pStr, const char * token, std::vector<std::string> & strs) { RETURN_IF_COND(pStr == NULL); std::string content = pStr; int nPosLeft = content.find('{'); int nPosRight = content.find('}'); RETURN_IF_COND( nPosLeft >= nPosRight ); content = content.substr(nPosLeft + 1, nPosRight - nPosLeft - 1); int nend=0; int nbegin=0; while(nend != -1) { nend = content.find(token, nbegin); if(nend == -1) strs.push_back(content.substr(nbegin, content.length()-nbegin)); else strs.push_back(content.substr(nbegin, nend-nbegin)); nbegin = nend + strlen(token); } }基本的流程实际上就是抄 CCNs中同名函数的,不过那个函数解析的最长数组长度只有2,所以这里做了一点点小的扩展。另外由于plist是由我自己写,正确性可以保证,所以这里去掉了很多强壮型的判断语句。
到此,这一套移动管理的体系,就算基本完工了。