Cocos2d-x塔防游戏_贼来了——基础知识储备

2014.10.25 技术干货 by cocos

介绍

塔防是指一类通过在地图上建造炮塔或类似建筑物,以阻止游戏中敌人进攻的策略型游戏。近年来《植物大战僵尸》、《兽人必须死》、《保卫萝卜》等题材五花八门的塔防游戏发展速度可谓迅猛。所以为了成为游戏界的新一代潮人,我们必须紧跟时代的步伐,分享一款基于Cocos2d-x-3.0rc0引擎的标准塔防游戏的制作教程,让你也过把塔防瘾。

你可以先下载第一部分的游戏代码,下载地址:

运行该部分demo,你将看到一个小偷在指定的地图路线上行走。效果图如下所示:

]

在本章文章中,我们会先介绍一些简单的基础知识,为塔防开发做个预热,其中包括的内容如下:

  • 辅助工具的介绍,帮助你简化和优化Cocos2d-x游戏开发。
  • 分辨率适配,使游戏能够很好的支持多屏幕多分辨率的移动设备。
  • 创建游戏场景。

前期知识储备

为了更好更方便的实现程序中的某些功能,我们往往都会用到一些Cocos2dx引擎配套或支持的编辑器来辅助完成这些模块。如果你没听说过这些编辑器,那你都不好意思说你会Cocos2d游戏开发。尽管这些工具在我之前的教程中已经不厌其烦的讲了好多次了,但是为了照顾一些初学者,下面我们还是先来简单的介绍下,大神可直接绕过本段。

Tiled Map编辑器

顾名思义,Tiled Map编辑器用于制作地图,可以叫它瓦片地图编辑器。

它制作的地图可保存为TMX格式的文件,可以被Cocos2d-x很好的支持。瓦片地图(Tile Map)不但生成简单,并且可以灵活的用于引擎中。在塔防类游戏中,用它来制作场景地图再适合不过了。

你可以在官网下载该编辑器,不清楚怎么使用的同学可以在网上搜索它的使用教程,你也可以参考【cocos2d-x官方文档】瓦片地图这篇文章。

TexturePacker

游戏中一般会有比较多的图片资源,如果有很多很多的资源,那加载这些资源是非常费时间和内存的,所以如何高效地使用图片资源对于一款游戏是相当重要的。在cocos2d中,我们一般会将图片资源打包成一张大图,这样加载图片不仅节省了空间,而且还提升了速度。

在Cocos2d-x引擎开发中,常又到的两种图片编辑打包工具,即 Zwoptex 和 Texturepacker。我们的教程里用到的是Texturepacker,你可以到它的官方网站下载并安装。

Texturepacker工具的每个设置项都给出了相应的提示信息,使用起来非常简单,打包过后你会得到两个文件,其中一个是plist文件,它是图片信息的属性列表文件;另一个则是打包后的图片文件,可以是png,jpg,pvr.ccz等等格式。建议打包为pvr.ccz格式,因为使用这种图片格式的好处有两点:1、可以使你的应用程序更小,因为图片是被压缩过了的。2、你的游戏能够启动地更快。

粒子编辑器

Cocos2d-x引擎提供了强大的粒子系统,它在模仿自然现象、物理现象及空间扭曲上具备得天独厚的优势,为我们实现一些真实自然而又带有随机性的特效(如爆炸、烟花、水流)提供了方便。

常用的粒子编辑器也有两种,一种是ParticleDesigner,另一种是ParticleEditor。尽管ParticleDesigner编辑器要比ParticleEditor美观的多,但就我个人而言,我还是觉得ParticleEditor更好用一些,它比较适合新手。之前我也写过一篇关于如何使用ParticleEditor编辑器相关的文章,大家可以参考一下。

提示:ParticleDesigner不支持Windows系统,所以如果你是Windows系统,最好还是选择使用ParticleEditor吧。

分辨率适配

分辨率适配是我们教程中必讲的一块内容,这在我之前的游戏教程中也已经讲过很多次了。虽然Cocos2dx中提供了方便的函数接口供开发者们调用实现分辨率的适配,简单的几句代码就可以搞定一切,但是很多人都没用弄清它的原理,包括之前的我。所以,我强烈的建议大家阅读一下:Cocos2d-x 多分辨率适配完全解析这篇文章,虽然它不是针对最新版Cocos2dx引擎,但它还是能很清楚的告诉你分辨率适配的原理和方法。

下面是实现分辨率的适配的方法,打开AppDelegate.cpp文件,在applicationDidFinishLaunching方法中添加以下几行代码:

glview->setDesignResolutionSize(480.0f, 320.0f, ResolutionPolicy::FIXED_HEIGHT);
std::vector<std::string> searchPath;
searchPath.push_back("height_864");
CCFileUtils::getInstance()->setSearchPaths(searchPath);
director->setContentScaleFactor(864.0f / 320.0f);

特别要说明的是,本游戏的地图资源大小为1536 * 864。我们要制作一个高度方向上全部显示的游戏,所以选择分辨率模式为:FIXED_HEIGHT。故此,计算内容缩放因子时的参数为:资源高度 / 屏幕分辨率高度。height_864是搜索的文件夹名。

关于分辨率模式的最新讲解你可以参考使用Cocos2d-x制作三消类游戏Sushi Crush(第一部分)这篇文章的分辨率适配部分。

游戏场景

新建一个PlayLayer类,该类将是我们的游戏主场景。打开AppDelegate.cpp文件,在applicationDidFinishLaunching()函数中将它设置为第一个启动的游戏场景:

CCScene *pScene = PlayLayer::scene();
pDirector->runWithScene(pScene);

PlayLayer的定义如下:

class PlayLayer : public Layer
{
public:
    PlayLayer();
    ~PlayLayer();

    virtual bool init() override;
    static Scene* createScene();
    CREATE_FUNC(PlayLayer);

private:
    SpriteBatchNode *spriteSheet;
    TMXTiledMap* map;
    TMXObjectGroup* objects;
    Vector<Node*> pointsVector;

    void initPointsVector(float offX);
    void addEnemy();
};

在第一部分demo中,我们只需要在PlayLayer场景中加载一幅游戏地图和一个敌人,所以PlayLayer的内容不会很多,后文中我们会详细的介绍它。到此本章的内容就介绍完毕了。

当然本章我们还没有讲解完第一部分demo的全部内容,这里由于篇幅的原因,我们也留在了后面的章节。

本章我们继续第一章的内容,主要实现地图的创建和加载。在创建地图时,我们会在其上标记出一些点来表示敌人的行进路线,同时会在程序中获取这些标示的路径点对象。在后续的文章中,敌人会一个一个的搜索这些路径点,并依次朝着搜索到的点移动。

地图

地图的创建

下面我们先来用地图编辑器制做一幅地图,你可以在地图上设置塔防游戏地形的布局,同时为敌人铺设一条行走的道路,让它有路可走。
这里你可能会想:道路弯弯曲曲的,毫不规则,我怎么知道怎样让敌人沿着它走啦!——这的确是个令人困惑的问题。

不过不用担心,Tiled Map编辑器牛逼的功能就要派上用场了,它支持对象组(ObjectGroups)功能,我们可以在地图上创建一个对象层,用对象来标示敌人移动轨迹的途经点。如下图所示:

创建一幅地图可分为两个步骤:

  1. 创建普通图层“bg”,它是我们的背景层,你可以根据自己的喜好,任意设置地图的布局。
  2. 创建对象层“obj”,并添加对象。这里对象就是指图中的小矩形,用这些矩形对象就可以计算敌人的行进路线,它们记录了敌人们移动的顺序和位置坐标。也正因为如此,我们在添加这些对象时是不需要在乎其大小的,注意摆放位置和顺序就行了。这里我们可以为矩形对象添加属性名来标示它的顺序,从0开始,依次增加,Cocos2d-x会为我们创建ValueMap类型的结构来保存相关的数据。打开.txm文件,会更有利于你清楚的理解。如下所示:
 <objectgroup name="obj" width="48" height="27">
  <object name="0" x="333" y="861"/>
  <object name="1" x="339" y="497"/>
  <object name="2" x="727" y="494"/>
  <object name="3" x="726" y="306"/>
  <object name="4" x="427" y="303"/>
  <object name="5" x="436" y="54"/>
  <object name="6" x="1106" y="48"/>
  <object name="7" x="1097" y="588"/>
  <object name="8" x="821" y="597"/>
  <object name="9" x="803" y="815"/>
  <object name="10" x="458" y="815"/>
 </objectgroup>

加载地图到场景

创建好地图后,接下来我们需要把地图加载到场景中去,具体代码在PlayLayer的init方法中实现:

// 1
map = TMXTiledMap::create("map1.tmx");
// 2
auto bgLayer = map->getLayer("bg");
bgLayer->setAnchorPoint(Point(0.5f, 0.5f));
bgLayer->setPosition(Point(winSize.width / 2 ,winSize.height / 2));
// 3
objects = map->getObjectGroup("obj");
this->addChild(map, -1);
  1. 将TMX地图文件加载到游戏中需要用Cocos2dx提供的TMXTiledMap类,直接通过.txm文件就可以创建瓦片地图。
  2. Tiled Map编辑器中每一个Layer都可以用TMXLayer类表示,获取地图的层可以通过getLayer(“层名”)来得到。
    获取背景层以后,我们需要重新设置它的显示位置和锚点。因为按我们前面的分辨率适配方案来看,在默认情况下,如果把地图直接添加到场景中,地图会从屏幕的左下角为起点开始显示,超出屏幕的后半段部分将被裁剪,如下方左图所示状态。

    显然地,这是不合理的,地图应该位于屏幕正中,所以我们设置它的显示位置和锚点把它放在正中。另外,winSize = Director::getInstance()->getWinSize();
  3. 获取地图的对象组TMXObjectGroup通过getObjectGroup(“层名”)来得到。

获取路径点信息

PlayLayer中,我们定义了Node类型的Vector来保存从地图中获取的路径点,之所以用Node表示路径点,那是因为对于路径点这一概念而言,我们只需要知道它的坐标信息就可以了,它不需要有太多的功能,所以Node类型的对象足已。其定义为:Vector<Node*> pointsVector;

TMXObjectGroup对象objects中存放了我们需要的所有路径点信息,我们需要遍历objects中所有的对象,把相应的数据值取出来保存在向量pointsVector中。具体方法如下:

void PlayLayer::initPointsVector(float offX)
{
    Node *runOfPoint = NULL;
    // 1
    int count = 0;
    ValueMap point;
    point = objects->getObject(std::to_string(count));
    // 2
    while (point.begin()!= point.end())
    {
        // 3
        float x = point.at("x").asFloat();
        float y = point.at("y").asFloat();
        // 4
        runOfPoint = Node::create(); 
        runOfPoint->setPosition(Point(x - offX , y  ));
        this->pointsVector.pushBack(runOfPoint);
        count++;
        point = objects->getObject( std::to_string(count));
    }
    runOfPoint = NULL;
}

理解这个方法非常重要,所以让我们一部分一部分代码来看。

  1. 调用getObject(“对象名”)方法获取对象组中的对象,它将返回ValueMap类型的值。
    在TXM中,我们为每一个对象都取了名字,从0开始,按路径顺序依次加1,这里的count就标示了对象的名称。
    ValueMap的定义为:std::unordered_map<std::string, Value> ValueMap; 它其实就是键-值对的集合,我们可使用键作为参数来获取某个属性的值。关于Vaule,请参考文章:Vaule
  2. 遍历每一个对象;
  3. 获取每个对象的X值,Y值;
  4. 创建一个Node来保存路径点,并且设置它的坐标位置,然后把它加到pointsVector向量中去,方便以后查找。
    要注意的是,我们需要修正路径点的坐标值,瓦片地图的背景层我们已经放置到了屏幕正中,但对象层开始显示的位置依旧是从屏幕最左端开始,如果不修正就会造成如下所示的错位状况:
    分辨率适配时,我们选择了FIXED_HEIGHT 模式作为分辨率适配模式,它会纵向放大地图以适应屏幕的高度,横向按原始宽高比放大,所以这里我们只需要修正X轴的坐标就可以了。为了和地图背景层位置保持一致,我们修正X轴坐标把它放在正中。如下图所示:

    X轴的偏差值offX为:(地图宽 - 屏幕宽)/2,即:( map->getContentSize().width - winSize.width )/ 2;

好了,到现在我们已经能成功的在场景中加载地图了,下一章中我们将会继续围绕第一部分demo,讲解如何在场景中添加敌人。

经过前两章的学习,我们已经成功的加载了地图,获得了地图的信息。本章我们主要的任务是在游戏中添加敌人,并让敌人沿着固定的路径行走。

敌人

接下来我们来创建进攻的敌人,试想一下,在一款塔防游戏中怎么可能只有一种进攻的敌人啦,如果真是那样,那这款游戏也未免太无聊了。所以为了创建形形色色的敌人类型,这里我们可以先创建一个敌人的基类,这样不管你的游戏中有多少种类的敌人,都可以通过继承这个基类来创建。

关于创建基类,我们还是先来思考下敌人有什么特征。这里我们的敌人它是动态的,会沿着固定的路径点行走,并会根据地形情况改变身体方向。了解了这些特征以后,审时度势,就可以开始我们的工作了。

创建敌人基类

这里我们先来创建了一个叫做EnemyBase的基类,下面是其定义:

class EnemyBase : public Sprite
{
public:    
    virtual bool init() override;
    CREATE_FUNC(EnemyBase);

    Animation* createAnimation(std::string prefixName, int framesNum, float delay);
    void changeDirection(float dt);
    Node* currPoint();
    Node* nextPoint();
    void runFllowPoint();
    void setPointsVector(Vector<Node*> points);    
private:
    Vector<Node*> pointsVector;
protected:
    int pointCounter;
    Animation *animationRight;
    Animation *animationLeft;
    CC_SYNTHESIZE(float, runSpeed, RunSpeed);    
};

在EnemyBase类中,我们定义了敌人的各种属性。包括敌人移动的路径点集,当前移动路径点,移动速度,左右方向动画等等目前为止我们需要的所以信息。

pointsVector属性是为了把敌人和地图数据很好的关联起来,这样我们就可以把从地图中获取的路径点赋值给敌人,然后敌人就可以通过它方便的计算行进路线和方向了。
这么一来,获得敌人当前所处的路径点和下一个移动点就显得至关重要了,下面来看看它的实现方法:

Node* EnemyBase::currPoint()
{
    return this->pointsVector.at(pointCounter);
}

Node* EnemyBase::nextPoint()
{
    int maxCount = this->pointsVector.size();
    pointCounter++;
    if (pointCounter < maxCount  ){
        auto node =this->pointsVector.at(pointCounter);
        return node;
    }
    else{
        pointCounter = maxCount -1 ;
    }
    return NULL;
}

你也看到了,其实这并不难,我们通过变量pointCounter的值就能获得它们。当我们想获取下一个移动点的时候,我们就递增pointCounter的值,如果它没有超过pointsVector的最大尺寸,则返回该处的路径点。

让敌人动起来

1. 动画

Cocos2dx中,一个动画是由一些精灵帧序列组成的。下面我们就来教你将多张图片打包到一起,并利用打包好的图片生成一个动画。 在此之前先让我们来了解以下几个概念:

  • SpriteFrame(精灵帧):包含纹理与纹理中的一个矩形区域,表示纹理的一部分。一个精灵显示的内容就可以用精灵帧表示,同时精灵帧还是帧动画的基本元素。
  • SpriteFrameCache(精灵帧缓存类) 用来存储精灵帧,缓存精灵帧有助于提高程序的效率。 SpriteFrameCache是一个单例模式,不属于某个精灵,是所有精灵共享使用的。
  • AnimationFrame(动画帧):由精灵帧与单位延时组成,可以表示变速动画中的一帧。通常,匀速动画的单位延时为1。
  • Animation(动画):由动画帧组成,表示一个动画的内容。
  • Animate(动画动作): 动画的播放器,使用动画对象创建,只能作用于精灵。为了播放一个动画,通常先创建动画帧或框帧,然后用它们创建动画,最后利用动画创建动画动作,并指派一个精灵来执行此动作。

现在不理解没有关系,我们的代码中将会帮助大家进一步的理解这些概念。

图片打包用上文中提到的TexturePacker工具完成,下面我们来看看创建动画具体怎么实现:

Animation* EnemyBase::createAnimation(std::string prefixName, int framesNum, float delay)
{
    // 1
    Vector<SpriteFrame*> animFrames;   
    // 2 
    for (int i = 1; i <= framesNum; i++)
    {
        char buffer[20] = { 0 };
        sprintf(buffer, "_%i.png",  i);
        std::string str =  prefixName + buffer;
        auto frame = SpriteFrameCache::getInstance()->getSpriteFrameByName(str);
        animFrames.pushBack(frame);
    }
    // 3
    return Animation::createWithSpriteFrames(animFrames, delay);
}

这里我们定义的createAnimation(prefixName, framesNum, delay)方法中,参数分别表示精灵纹理的前段名称,动画帧数,每帧间隔时间。

  1. 创建一个帧缓存Vector向量存储动画的每一帧。
  2. 遍历每一帧,通过getSpriteFrameByName(“纹理名”)方法中创建动画的每一帧,同时把它加到 animFrames向量里面去。
  3. createWithSpriteFrames方法基于一个精灵帧向量,返回一个Animation对象。

接下来,我们来让我们的动画都起来,下面的方法中,我们不光让我们的敌人动了起来,还会让它在某些位置改变自身的方向:

void EnemyBase::changeDirection(float dt)
{
    auto curr = currPoint();

    if(curr->getPositionX() > this->getPosition().x )
    {
        runAction( Animate::create(AnimationCache::getInstance()->getAnimation("runright"))) ;
    }else{
        runAction( Animate::create(AnimationCache::getInstance()->getAnimation("runleft"))  );
    }
}

获取当前敌人所处的路径点,比较当前路径点的x坐标值与实际x坐标值,如果前者更大,则敌人方向朝右,反之朝左。如下图所示:

所以在创建txm地图文件时,我们要特别地根据路径方向创建对象。

2. 按固定路线移动

让敌人能够前进的代码,如下所示:

void EnemyBase::runFllowPoint()
{
    auto point = currPoint();
    setPosition(point->getPosition());
    point = nextPoint();   
    if( point!= NULL ){
        runAction(CCSequence::create(MoveTo::create(getRunSpeed(), point->getPosition())
                                        , CallFuncN::create(CC_CALLBACK_0(EnemyBase::runFllowPoint, this))
                                        , NULL));
    }
}

基于我们前面所讨论过的,这里面的代码应该比较容易懂。这里,我们对EnemyBase对象使用了3种类型的action:

  • MoveTo: 让敌人从一点移动到另一点,getRunSpeed()是移动过程花费的时间,point->getPosition()是移动地目标位置。
  • CallFuncN: 它可以让你为某个执行此action的对象指定一个回调函数,这里我们指定的回调函数是:runFllowPoint本身。所以这个函数就会重复地调用自身,不断地判断计算得到下一个路径点,让敌人MoveTo到那个路径点地位置,其中CC_CALLBACK_0宏是将函数与对象绑定在一起,0表示这个函数有0个参数。
  • Sequence: 它允许我们把一系列的action组成一个action序列,并且这些acton可以按顺序执行。一次执行完所有的action。在上面的例子中,我们让对象首先执行MoveTo,等MoveTo完成后,马上就会执行CallFuncN action。 接下来, 为CallFuncN action增加一个回调函数。

创建敌人子类-小偷

最后,我们来新建一个Thief类,其继承于EnemyBase。

Thief.h定义如下:

class Thief : public EnemyBase
{
public:
    virtual bool init() override;    
    static Thief* createThief(Vector<Node*> points);

};

由于EnemyBase类中已经给出了敌人的各种逻辑方法,所以在Thief中,我们只需要初始化变量,实现具体的方法,就可以实现一个很普通的敌人了。
下面是小偷的init方法:

bool Thief::init()
{
    if (!Sprite::init())
    {
        return false;
    }
    // 1
    setRunSpeed(6);
    animationRight = createAnimation("enemyRight1", 4, 0.1f);
    AnimationCache::getInstance()->addAnimation(animationRight, "runright");
    animationLeft = createAnimation("enemyLeft1", 4, 0.1f);
    AnimationCache::getInstance()->addAnimation(animationLeft, "runleft");
    // 2
    schedule(schedule_selector(EnemyBase::changeDirection), 0.4f);
    return true;
}
  1. 初始化小偷的移动速度,左右方向的动画。
  2. 调用定时器schedule刷新自定义的changeDirection函数,0.4为刷新间隔时间。只有这样我们的小偷才会动起来,并且在路径点处判断是否反向。

createThief方法是我们创建小偷的接口函数。在场景中,你可以直接调用createThief方法创建一个小偷,参数points是地图上获取的路径点集合。

Thief* Thief::createThief(Vector<Node*> points)
{
    Thief *pRet = new Thief();
    if (pRet && pRet->init())
    {
        // 设置小偷的路径点集
        pRet->setPointsVector(points);
        // 让小偷沿着路径点移动
        pRet->runFllowPoint();
        pRet->autorelease();
        return pRet;
    }
    else
    {
        delete pRet;
        pRet = NULL;
        return NULL;
    }
}

现在敌人就创建好了,你还可以添加更多其它不同类型的敌人。为了让我们的教程更简洁,这里我们只添加了Thief这1种。