Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2

本文是“使用Cocos2D 3.x开发横版动作游戏”系列教程的第二篇,同时也是最后一篇。是对How To Make A Side-Scrolling Beat Em Up Game Like Scott Pilgrim with Cocos2D – Part 2的翻译,加上个人理解而成。最重要的是将文中所有代码转换为Cocos2D 3.x版本。众所周知,3.x与2.x的区别非常之大,在触摸机制、渲染机制等方面都与之前版本有了本质的区别。这里将本人摸索的结果加上,供大家参考。

在上一篇教程中,我们已经加载了TiledMap,创建了Hero,并且实现了一个简单地虚拟摇杆。但是我们的虚拟摇杆还无法投入到使用中,无法移动我们的Hero,并且我们并没有加入敌人,这实在不算是一个横版动作游戏。

本篇教程中我将带着大家完成这个游戏,包括实现人物的移动,地图的滚动,碰撞检测、创建敌人、人工智能以及音乐播放。

首先,你要有我们上一篇教程最后写好的项目,也就是我们的第一部分的代码。如果你还没有,你可以从这里下载。

接下来让我们开始吧!

主角动起来!

上一篇教程的最后,如果大家编译并运行项目,点击我们的虚拟摇杆,会发现项目会crash,因为我们还没有实现协议方法。我们马上就会进行实现,不过首先,既然英雄要动起来,那么我们要准备好walk的动作,打开Hero.m,在init方法中添加下面的代码:
//walk action
        NSMutableArray *walkFrames = [NSMutableArray arrayWithCapacity:8];
        for (int i = 0; i < 8; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"hero_walk_%02d.png", i]];
            [walkFrames addObject:frame];
        }
        CCAnimation *walkAnimation = [CCAnimation animationWithSpriteFrames:walkFrames delay:1.0 / 12.0f];
        self.walkAction = [CCActionRepeatForever actionWithAction:[CCActionAnimate actionWithAnimation:walkAnimation]];

到现在你应该对这些代码很熟悉了,因为创建所有的动画动作的流程基本上都是一样的:依次创建精灵帧并保存->用这些精灵帧生成动画对象->使用动画对象生成动作对象->赋值给相关属性。

接下来,找到ActionSprite.m,添加下面的方法
- (void)walkWithDirection:(CGPoint)direction {
    if (self.state == kActionStateIdle) {
        [self stopAllActions];
        [self runAction:self.walkAction];
        self.state = kActionStateWalk;
    }
    
    if (self.state == kActionStateWalk) {
        self.velocity = ccp(direction.x * self.walkSpeed, direction.y * self.walkSpeed);
        if (self.velocity.x >= 0) {
            self.scaleX = 1.0;
        } else {
            self.scaleX = -1.0;
        }
    }
}

这里检测之前角色的状态,规定只有当角色是idle状态时才可以切换到walk状态。然后让该角色执行创建好的walk的动作,接下来,根据传入进来的参数——角色移动的方向来决定角色的“朝向”,也就是scaleX属性(默认朝右)。之前提到过,direction是一个点,横坐标的正负决定着角色x轴的移向,纵坐标的正负决定着角色y轴的移向。

准备工作完成之后,我们就可以来实现之前没有实现的三个协议方法了。
找到GameLayer.m,在最后添加这三个方法的实现:
#pragma mark - SimpleDPadDelegate

- (void)simpleDPad:(SimpleDPad *)dPad didChangeDirectionTo:(CGPoint)direction {
    [self.hero walkWithDirection:direction];
}

- (void)simpleDPad:(SimpleDPad *)dPad isHoldingDirection:(CGPoint)direction {
    [self.hero walkWithDirection:direction];
}

- (void)simpleDPadTouchEnded:(SimpleDPad *)dPad {
    if (self.hero.state == kActionStateWalk) {
        [self.hero idle];
    }
}

每当你按下、移动我们的虚拟摇杆时就会触发主角的walkWithDirection:方法,而当你抬起手指时,主角又会进入idle状态并执行idle的动作。
现在编译然后运行app,按下虚拟摇杆移动主角。这次不会再crash了!
Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2_第1张图片
不过等一下- -我们的英雄好像只能原地踏步,并没有实际移动。为什么会这样?
让我们回到刚才的walkWithDirection:方法,仔细看一下,会发现这个方法里只不过是改变了角色的velocity属性,并没有实际上改变角色的位置。因此我们还需要“实时”更新角色的位置。
分析一下这里的实现思路:ActionSprite类需要移动,但是他不会知道自己在地图中的具体位置,因此他永远不知道何时走到地图的边缘,何时与地图上的障碍物碰撞……。他所知道的就只有自己的“期望”位置——由用户操纵虚拟摇杆决定的下一步该去的位置。因此我们需要在GameLayer中将这个“期望”位置加以判断并切换为“真正”位置。所以说,移动的实现要同时用到这两个类。
之前在ActionSprite里我们已经定义了一个CGPoint类型的属性——desiredPosition。这个就表示角色的“期望”位置,现在我们切到ActionSprite.m中,添加下面的方法:
#pragma mark - Schedule

- (void)update:(CCTime)delta {
    if (self.state == kActionStateWalk) {
        self.desiredPosition = ccpAdd(self.position, ccpMult(self.velocity, delta));
    }
}

这个方法干了什么呢?首先判断状态,然后将角色的速度(有正负)乘以时间(得到距离),加上当前的位置对应坐标,得到的就是角色的“期望”位置。
注意:在3.x中,我们不需要显示调用[self scheduleUpdate];了,当我们重写了CCNode的update:方法时,Cocos2D会自动为我们每帧调用这里的方法。因此,此时我们实现了Hero的“期望”位置更新逻辑之后,就不需要在GameLayer的update中调用该方法了(当然,调用了也没有事,而且可能逻辑更清晰一些),这是与原教程的另一个不同之处。
接下来切换到GameLayer.m,添加如下代码:
#pragma mark - Schedule

- (void)update:(CCTime)delta {
    [self updatePosition];
}

- (void)updatePosition {
    float posX = MIN(self.tileMap.tileSize.width * self.tileMap.mapSize.width - self.hero.centerToSides, MAX(self.hero.centerToSides, self.hero.desiredPosition.x));
    float posY = MIN(ROAD_MAP_SIZE * self.tileMap.tileSize.height + self.hero.centerToBottom, MAX(self.hero.centerToBottom, self.hero.desiredPosition.y));
    
    self.hero.position = ccp(posX, posY);
}

#pragma mark - EndGame

- (void)dealloc {
    [self unscheduleAllSelectors];
}

然后在顶部添加宏定义
#define ROAD_MAP_SIZE 3
在这段代码中,你设置了一个定时器,不断刷新主角的位置。主角的位置并不是简单地设置为“期望”位置,因为主角不能跑到地图之外。拿x方向来说,这里用了centerToSides作为最小值,地图横线距离减去centerToSides作为最大值,规定主角在这个范围之间移动。Cocos2D中有一个宏ccClamp可以实现同样地功能。
现在编译并运行,你会看到,你的主角真正动起来了,而且碰到左侧边界以及上下边界时都会停下来。
Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2_第2张图片

但是,这里却有另一个问题,那就是如果主角一直往右走,很快就从屏幕上消失了,也就是说,地图的视心无法随着主角移动。
这里就需要一点数学知识了,在GameLayer.m中“Schedlue”部分添加该方法:
- (void)updateViewPointCenter:(CGPoint)point {
    float x = MAX(VISIBLE_SIZE.width / 2, point.x);
    float y = MAX(VISIBLE_SIZE.height / 2, point.y);
    
    x = MIN(x, (self.tileMap.mapSize.width * self.tileMap.tileSize.width) - VISIBLE_SIZE.width / 2);
    y = MIN(y, (self.tileMap.mapSize.height * self.tileMap.tileSize.height) - VISIBLE_SIZE.height / 2);
    CGPoint actualPoint = ccp(x, y);
    
    CGPoint centerPoint = ccp(VISIBLE_SIZE.width / 2, VISIBLE_SIZE.height / 2);
    CGPoint viewPoint = ccpSub(centerPoint, actualPoint);
    
    self.position = viewPoint;
}

接着在update:方法中调用该方法:
//在update:方法中添加下面这一行
[self updateViewPointCenter:self.hero.position];
这个方法比较三个点的x、y的大小:传入点,当前视中心点,到达最右边缘后视中心点。初始状态下“当前视中心点”也可以看做“到达最左边缘后视中心点”。也就是说,当传入点在左右边缘范围内移动时,视中心点随着该点的移动而不断切换。而到达两侧边缘后,视中心点固定不变。这样就达到了随着传入点(主角的位置)移动视图的效果。另外,我们这里与传统纵版射击类游戏不同的是,很多纵版射击类游戏通过两张背景拼接、移动Sprite来达到移动背景的效果。而这里移动的却是layer,也就是self.position。
不要忘了在update中调用该方法。
现在编译并运行,这次你真的可以随意移动你的主角了。
Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2_第3张图片

敌人出现!

能够随意走动当然很有趣,不过如果仅仅如此那么这个游戏未免太无聊了些。是时候让我们英雄的敌人出现了。
接下来的步伐会快一些了,因为我们已经有了角色的基类ActionSprite,有了Hero类的经验,你会在敌人Robot类中发现很多熟悉的面孔。
command+N新建一个类Robot,继承自ActionSprite。在Robot.m中添加如下代码:
- (id)init {
    self = [super initWithSpriteFrame:[[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"robot_idle_00.png"]];
    if (self) {
        
        //idle
        NSMutableArray *idleFrame = [NSMutableArray arrayWithCapacity:5];
        for (int i = 0; i < 5; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"robot_idle_%02d.png", i]];
            [idleFrame addObject:frame];
        }
        CCAnimation *idleAnimation = [CCAnimation animationWithSpriteFrames:idleFrame delay:1.0 / 12.0f];
        self.idleAction = [CCActionRepeatForever actionWithAction:[CCActionAnimate actionWithAnimation:idleAnimation]];
        
        //attack
        NSMutableArray *attackFrame = [NSMutableArray arrayWithCapacity:5];
        for (int i = 0; i < 5; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"robot_attack_%02d.png", i]];
            [attackFrame addObject:frame];
        }
        CCAnimation *attackAnimation = [CCAnimation animationWithSpriteFrames:attackFrame delay:1.0 / 24.0f];
        self.attackAction = [CCActionSequence actionOne:[CCActionAnimate actionWithAnimation:attackAnimation] two:[CCActionCallFunc actionWithTarget:self selector:@selector(idle)]];
        
        //walk
        NSMutableArray *walkFrame = [NSMutableArray arrayWithCapacity:6];
        for (int i = 0; i < 6; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"robot_walk_%02d.png", i]];
            [walkFrame addObject:frame];
        }
        CCAnimation *walkAnimation = [CCAnimation animationWithSpriteFrames:walkFrame delay:1.0 / 12.0f];
        self.walkAction = [CCActionRepeatForever actionWithAction:[CCActionAnimate actionWithAnimation:walkAnimation]];
        
        self.walkSpeed = 80;
        self.centerToBottom = 39.0f;
        self.centerToSides = 29.0f;
        self.hitPoints = 100;
        self.damage = 10;
    }
    
    return self;
}

很熟悉不是吗?创建动画、创建动作、为属性赋值……
接下来,让我们为游戏添加一些Robot,打开GameLayer.h,添加属性声明:
@property (strong, nonatomic) NSMutableArray *robots;

然后打开GameLayer.m,引入Robot.h头文件,添加initRobots方法并在init中调用,initRobots方法实现如下:
- (void)initRobots {
    int robotsNumber = 50;
    self.robots = [NSMutableArray arrayWithCapacity:robotsNumber];
    
    Robot *temp = [Robot node];
    int minX = VISIBLE_SIZE.width + temp.centerToSides;
    int minY = temp.centerToBottom;
    int maxX = self.tileMap.mapSize.width * self.tileMap.tileSize.width - temp.centerToSides;
    int maxY = ROAD_MAP_SIZE * self.tileMap.tileSize.height + temp.centerToBottom;
    
    for (int i = 0; i < robotsNumber; i++) {
        Robot *robot = [Robot node];
        [self addChild:robot z:-5];
        [self.robots addObject:robot];
        
        [robot setPosition:ccp(RANDOM_RANGE(minX, maxX), RANDOM_RANGE(minY, maxY))];
        robot.desiredPosition = robot.position;
        robot.scaleX = -1;
        
        [robot idle];
    }
}

这里,你指定了机器人总数,并通过我们之前定义的宏来创建随机数,用来决定Robots的位置,然后将这些robots保存起来并添加到地图上去。
编译并运行,你就能看到成群的机器人出现在地图上了。
Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2_第4张图片
不过还是有个小问题, 你会发现我们的主角被robots给遮挡住了,按照常理来说,精灵之间的遮挡关系应该是这样的:在地图的稍“下”方的精灵来遮挡在地图稍“上”方的精灵。也就是说,上面那副图片,应该是英雄遮挡机器人才对。
谈到遮挡,就自然会想到精灵的绘制顺序,也就是z属性。该属性越高,精灵越晚被绘制,精灵就会出现在越上方(这里的上是指遮挡关系中的上)。按照上面的需求,我们需要动态地改变精灵的z属性来实现该功能。但是你会发现如果你直接设置精灵的z属性是没有用的,这里你需要的是一个方法reorderChild:z:。
在GameLayer.m中“Schedule”部分添加方法reorderActors并在scheduleUpdate:方法中调用,其实现如下:
- (void)reorderActors {
    for (CCNode *sprite in self.children) {
        if ([sprite isKindOfClass:[ActionSprite class]]) {
            [self reorderChild:sprite z:self.tileMap.mapSize.height * self.tileMap.tileSize.height - sprite.position.y];
        }
    }
}

注意在3.x中reorder方法需要导入头文件:
#import "CCNode_Private.h"


这里根据精灵在地图上的y轴的位置来动态调整精灵的z轴,这样就不会出现上述问题了,现在编译运行,你就可以看到正确的遮挡关系了:
Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2_第5张图片

碰撞检测

接下来我们要解决一个很重要的问题:攻击与被攻击的检测。还记得之前在Define.h中定义了一个结构体BoundingBox吗:
//struct
typedef struct BoundingBox {
    CGRect actual;
    CGRect original;
} BoundingBox;
这个结构体是干什么用的呢?它定义了两个CGRect类型的变量。original用来表示精灵的初始化状态下的坐标与大小,由于精灵(主角和Robot)是会动的,所以我们需要一个actual来记录某一时刻精灵的实际位置与大小(大小往往不变,变的是方向,后文会有详述)。
有了这个结构体,我们就可以用actual来做碰撞检测了。因此我们要先完善这个结构体相关的代码。打开ActionSprite.h,添加如下代码:
//boundingBox
@property (assign, nonatomic) BoundingBox hitBox;
@property (assign, nonatomic) BoundingBox attackBox;

//create bounding box
- (BoundingBox)createBoundingBoxWithOrigin:(CGPoint)origin andSize:(CGSize)size;

提供一个方法用来创建BoundingBox。然后在ActionSprite.m中实现:
#pragma mark - BoundingBox

- (BoundingBox)createBoundingBoxWithOrigin:(CGPoint)origin andSize:(CGSize)size {
    BoundingBox box;
    
    box.original.origin = origin;
    box.original.size = size;
    
    box.actual.origin = ccpAdd(self.position, ccp(origin.x, origin.y));
    box.actual.size = size;
    
    return box;
}

- (void)transformBoundingBox {
    //to consider the direction of sprite, we should use scale
    //onle _xxx could be assinged,self.xxx is not assingable
    _hitBox.actual.origin = ccpAdd(self.position, ccp(self.hitBox.original.origin.x * self.scaleX, self.hitBox.original.origin.y * self.scaleY));
    _hitBox.actual.size = CGSizeMake(self.hitBox.original.size.width * self.scaleX, self.hitBox.original.size.height * self.scaleY);
    
    _attackBox.actual.origin = ccpAdd(self.position, ccp(self.attackBox.original.origin.x * self.scaleX, self.attackBox.original.origin.y * self.scaleY));
    _attackBox.actual.size = CGSizeMake(self.attackBox.original.size.width * self.scaleX, self.attackBox.original.size.height * self.scaleY);
}

#pragma mark - Setter

- (void)setPosition:(CGPoint)position {
    [super setPosition:position];
    
    [self transformBoundingBox];
}

第一个方法中根据传入的CGPoint和CGSize创建BoundingBox,此时actual和original的区别是一个position。
第二个方法是用来更新BoundingBox的,根据角色当前的位置和朝向(scale)来改变actual,因为original在设立好之后就是不变的了。其作用就是更新actual用。
接下来的setter表示每次更新角色的位置时同时更新BoundingBox。

我知道你可能会对hitBox和attackBox有疑惑,不过我们先把代码写上,写完了我会给出解释的。
打开Hero.m,在init方法中添加对自己的hitBox和attackBox的定义:
//based on center of sprite
        self.hitBox = [self createBoundingBoxWithOrigin:ccp(-self.centerToSides, -self.centerToBottom) andSize:CGSizeMake(self.centerToSides * 2, self.centerToBottom * 2)];
        self.attackBox = [self createBoundingBoxWithOrigin:ccp(self.centerToSides, -10) andSize:CGSizeMake(20, 20)];

同样地,打开Robot.m, 在init方法中添加对自己的hitBox和attackBox的定义:
         //based on center of sprite
         self.hitBox = [self createBoundingBoxWithOrigin:ccp(-self.centerToSides, -self.centerToBottom) andSize:CGSizeMake(self.centerToSides * 2, self.centerToBottom * 2)];
        self.attackBox = [self createBoundingBoxWithOrigin:ccp(self.centerToSides, -5) andSize:CGSizeMake(25, 20)];

现在,我们来解释一下这两个box分别是什么作用。玩游戏的时候,我们会有一个常识——角色会“出拳”(近战)来攻击敌人,只有当出的拳打中敌人的身体时才算击中。这就是attackBox和hitBox。下面有一副图清晰地解释了原因:

之前将状态机的时候为角色定义了五种状态:平常、攻击、走路、受伤、死亡。我们已经实现了三种,现在还差受伤和死亡状态,接下来马上就会用到了,所以我们现在给出实现。
打开ActionSprite.m,添加下面两个方法:
- (void)hurtWithDamage:(CGFloat)damage {
    if (self.state != kActionStateKnockedOut) {
        [self stopAllActions];
        [self runAction:self.hurtAction];
        
        self.state = kActionStateHurt;
        
        self.hitPoints -= damage;
        if (self.hitPoints <= 0) {
            [self knockOut];
        }
    }
}

- (void)knockOut {
    [self stopAllActions];
    [self runAction:self.knockedOutAction];
    
    self.hitPoints = 0;
    self.state = kActionStateKnockedOut;
}

只要角色没有死亡,被攻击时就会切换到受伤状态,执行动画动作,然后判断生命值,若HP低于0,则变成死亡状态。
当然,为了完成这些动画,我们需要为主角和Robot分别定义这两个动作。又是老朋友了:]
打开Hero.m,在init方法中定义walk动作之后添加代码:
        //hurt action
        NSMutableArray *hurtFrames = [NSMutableArray arrayWithCapacity:3];
        for (int i = 0; i < 3; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"hero_hurt_%02d.png", i]];
            [hurtFrames addObject:frame];
        }
        CCAnimation *hurtAnimation = [CCAnimation animationWithSpriteFrames:hurtFrames delay:1.0 / 12.0f];
        self.hurtAction = [CCActionSequence actionOne:[CCActionAnimate actionWithAnimation:hurtAnimation] two:[CCActionCallFunc actionWithTarget:self selector:@selector(idle)]];
        
        //knock out action
        NSMutableArray *knockOutFrames = [NSMutableArray arrayWithCapacity:5];
        for (int i = 0; i < 5; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"hero_knockout_%02d.png", i]];
            [knockOutFrames addObject:frame];
        }
        CCAnimation *knockOutAnimation = [CCAnimation animationWithSpriteFrames:knockOutFrames delay:1.0 / 12.0f];
        self.knockedOutAction = [CCActionSequence actionOne:[CCActionAnimate actionWithAnimation:knockOutAnimation] two:[CCActionBlink actionWithDuration:2.0f blinks:10.0f]];
类似地,打开Robot.m,在同样地位置添加:

        //hurt action
        NSMutableArray *hurtFrames = [NSMutableArray arrayWithCapacity:3];
        for (int i = 0; i < 3; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"robot_hurt_%02d.png", i]];
            [hurtFrames addObject:frame];
        }
        CCAnimation *hurtAnimation = [CCAnimation animationWithSpriteFrames:hurtFrames delay:1.0 / 12.0f];
        self.hurtAction = [CCActionSequence actionOne:[CCActionAnimate actionWithAnimation:hurtAnimation] two:[CCActionCallFunc actionWithTarget:self selector:@selector(idle)]];
        
        //knock out action
        NSMutableArray *knockOutFrames = [NSMutableArray arrayWithCapacity:5];
        for (int i = 0; i < 5; i++) {
            CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"robot_knockout_%02d.png", i]];
            [knockOutFrames addObject:frame];
        }
        CCAnimation *knockOutAnimation = [CCAnimation animationWithSpriteFrames:knockOutFrames delay:1.0 / 12.0f];
        self.knockedOutAction = [CCActionSequence actionOne:[CCActionAnimate actionWithAnimation:knockOutAnimation] two:[CCActionBlink actionWithDuration:2.0f blinks:10.0f]];

这里规定,受伤后立刻恢复为idle状态,死亡后闪烁两秒。
一切都准备就绪,最后一步:碰撞检测。我们有了要检测的东西BoundingBox,有了检测成功后的执行动作,现在,在GameLayer.m的touchBegan方法中添加下面这段代码:
//添加到touchBegan的[self.hero attack]后
//collision detection
    if (self.hero.state == kActionStateAttack) {
        for (Robot *robot in self.robots) {
            if (robot.state != kActionStateKnockedOut) {
                if (fabsf(robot.position.y - self.hero.position.y) < 10) {
                    if (CGRectIntersectsRect(robot.hitBox.actual, self.hero.attackBox.actual)) {
                        [robot hurtWithDamage:self.hero.damage];
                    }
                }
            }
        }
    }

这一小段代码做了三件事:
1、状态检测,确保英雄的状态是attack,Robot的状态不是死亡。
2、范围检测,确保英雄和Robot的y轴间隔不会太大。
3、矩形相交检测,也就是简单地碰撞检测了。
如果上述三个条件均满足,那么就判定为英雄攻击到了Robot,那么Robot就应该执行相关的动作和逻辑了。
现在编译并运行,享受战斗吧!


简单的AI与决策树

你一直打,一直打,最后把所有的机器人都打到了,然后。。就没有然后了。这样有什么意思呢?所谓游戏就是要输赢共存。如果这些机器人都是扮演沙袋的角色,那么这游戏也不会有人玩的。我们要让机器人攻击英雄!这就要用到AI(Artificial Intelligence)的相关知识了。
首先简单介绍一下决策树的知识,树型结构,一个父结点,多个子节点。决策树,指某一时刻某一状态下有多种选择,通过某种算法来确定下一步的选择。我们的AI就依据与此。由Robot自己去判断,当距离主角较远时是朝主角移动还是静止,当靠近主角时是攻击主角还是“挠一挠头”。
打开Robot.h,添加属性声明:
@property (assign, nonatomic) double nextDecisionTime;
在Robot.m中的init方法中进行初始化:
self.nextDecisionTime = 0;
见名知意,这个属性表示下一次决策时间。
接下来应该是本篇教程中最麻烦的一个方法了,别着急,其实也是很好理解的。
在GameLayer.m中的“Schedule“部分添加方法实现:
<span style="font-size:14px;">- (void)updateRobots:(CCTime)delta {
    int alive = 0;
    int randomChoice = 0;
    float distanceSQ = 0;
    
    for (Robot *robot in self.robots) {
        [robot update:delta];
        
        
        if (robot.state != kActionStateKnockedOut) {
            alive++;
            
            if (CURRENT_TIME > robot.nextDecisionTime) {
                distanceSQ = ccpDistanceSQ(robot.position, self.hero.position);
                
                
                if (distanceSQ <= 50 * 50) {
                    robot.nextDecisionTime = CURRENT_TIME + FLOAT_RANDOM_RANGE(0.1, 0.5);
                    randomChoice = RANDOM_RANGE(0, 1);
                    
                    if (randomChoice == 0) {
                        
                        if (self.position.x >= robot.position.x) {
                            robot.scaleX = 1.0;
                        } else {
                            robot.scaleX = -1.0;
                        }
                        
                        //attack and collision detection
                        [robot attack];
                        if (robot.state == kActionStateAttack) {
                            if (self.hero.state != kActionStateKnockedOut) {
                                if (fabsf(robot.position.y - self.hero.position.y) < 10) {
                                    if (CGRectIntersectsRect(robot.attackBox.actual, self.hero.hitBox.actual)) {
                                        [self.hero hurtWithDamage:robot.damage];
                                        }
                                    }
                                }
                            }
                        }
                        
                        
                    } else {
                        [robot idle];
                    }
                    
                } else if (distanceSQ <= VISIBLE_SIZE.width * VISIBLE_SIZE.width) {
                    robot.nextDecisionTime = CURRENT_TIME + FLOAT_RANDOM_RANGE(0.5, 1.0);
                    randomChoice = RANDOM_RANGE(0, 2);
                    if (randomChoice == 0) {
                        //move
                        CGPoint direction = ccpNormalize(ccpSub(self.hero.position, robot.position));
                        [robot walkWithDirection:direction];
                    } else {
                        [robot idle];
                    }
                }   //distance
                
                
                
            }   //if (CURRENT_TIME > robot.nextDecisionTime)
            
        }   //if (robot.state != kActionStateKnockedOut)
        
    }   //foreach
    
}</span>

然后,在update:方法中添加调用:
[self updateRobots:delta];


让我们来慢慢看看这一段代码做了哪些事:
1、通过一个变量alive保存存货的Robot的数量,当一个Robot的状态为knockedOut时判定为死亡,该变量用于随后判断输赢。
2、检查当前时间是否达到Robot的下一个相应决策时间。如果达到了,就说明该Robot需要做一个新的决定了。这里用到了我们之前定义的宏。
3、检查Robot与英雄之间的距离,这决定着Robot的可选决策。从这里开始分支,如果距离过远(超过屏幕距离)则不需要让其作任何选择(也就是说保持idle状态即可)。如果距离稍远,就通过随机数决定是朝主角方向移动还是静止不动。如果距离足够近,那么依然通过随机数决定是否出拳攻击。
4、如果Robot决定攻击,那么与之前在检测英雄攻击Robot一样,作Robot攻击英雄的碰撞检测。
5、注意到Robot的移动方向不再是规定好-1、1数值的CGPoint了,而是数值随机的单位向量。
每次Robot做出一个新的决策时,重置其nextDecisionTime为当前时间+一随机数值。为了能让Robot在随后做出下一次决策。

最后,在updatePosition方法中添加对Robot的位置的更新代码:
//在updatePosition方法中添加
    for (Robot *robot in self.robots) {
        float robotPosX = MIN(self.tileMap.tileSize.width * self.tileMap.mapSize.width - robot.centerToSides, MAX(robot.centerToSides, robot.desiredPosition.x));
        float robotPosY = MIN(ROAD_MAP_SIZE * self.tileMap.tileSize.height + robot.centerToBottom, MAX(robot.centerToBottom, robot.desiredPosition.y));
        robot.position = ccp(robotPosX, robotPosY);
    }


终于完成了~~现在,编译并运行,感受一下机器人大军的厉害吧:]
Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2_第6张图片

现在,我们的游戏已经有胜有负了,是时候为其添加胜负界面了,这里界面部分大家就可以自己写了,我就只是简单地展示一段文字即可
在GameLayer.m中添加方法:
- (void)endGameWithResult:(GameResult)result {
    NSString *label = nil;
    if (result == kGameResultLost) {
        label = @"Game Over";
    } else if (result == kGameResultWin) {
        label = @"You Win!";
    }
    
    CCButton *restartBtn = [CCButton buttonWithTitle:label fontName:@"Arial" fontSize:30];
    [restartBtn setPosition:CENTER];
    [restartBtn setTarget:self selector:@selector(restartGame)];
    restartBtn.name = @"restart";
    [self.scene addChild:restartBtn z:500];
}

- (void)restartGame {
    CCTransition *trans = [CCTransition transitionFadeWithDuration:0.4f];
    trans.outgoingSceneAnimated = YES;
    trans.incomingSceneAnimated = YES;
    [[CCDirector sharedDirector] replaceScene:[GameScene node] withTransition:trans];
}
其中GameResult是自己定义的枚举变量,其值也都在if-else块中体现出来了。

关键是在什么时候调用呢?
我们知道,胜利的时机是所有机器人都被打败的时候,也就是之前那个用于计算活着的机器人数量的变量alive为0的时候。失败的时机是主角的HP为0的时候,也就是说处理胜负的逻辑都体现在updateRobots:方法中。
在updateRobots相应位置添加代码:
//添加到updateRobots方法的最下端,最外层循环之外
//check game win
    if (alive == 0 && [self getChildByName:@"restart" recursively:YES] == nil) {
        [self endGameWithResult:kGameResultWin];
    }

//添加到[self.hero hurtWithDamage:robot.damage];之后
//check game over
    if (self.hero.state == kActionStateKnockedOut && [self getChildByName:@"restart" recursively:YES] == nil) {
        [self endGameWithResult:kGameResultLost];
    }
和这群机器人玩得愉快!
Cocos2D教程:使用SpriteBuilder和Cocos2D 3.x开发横版动作游戏——Part 2_第7张图片


锦上添花:游戏音效

这么热血沸腾的游戏这么能不配有一个燃曲呢?感谢作者 @Allen Tan为我们提供的音效素材(当然还有本系列教程以及一切资源)。
将从上一篇教程中下载下来的资源文件夹中Sounds文件夹拖入项目,勾选上copy items if needed。
注意:3.x中Cocos2D已经不用SimpleAudioEngine来处理音乐了,而是采用OpenAL,对应的类名为OALSimpleAudio,对于我们使用来说大同小异。同样的单例、同样的prepare,同样的play……

打开GameLayer.m,添加方法initMusics并在init中调用,initMusics方法的实现:
- (void)initMusics {
    [[OALSimpleAudio sharedInstance] preloadBg:@"latin_industries.aifc"];
    [[OALSimpleAudio sharedInstance] playBg:@"latin_industries.aifc" loop:YES];
    
    OALSimpleAudio *audio = [OALSimpleAudio sharedInstance];
    [audio preloadEffect:@"pd_botdeath.caf"];
    [audio preloadEffect:@"pd_herodeath.caf"];
    [audio preloadEffect:@"pd_hit0.caf"];
    [audio preloadEffect:@"pd_hit1.caf"];
}

打开ActionSprite.m,在hurtWithDamage:方法中添加:
        int randomSound = RANDOM_RANGE(0, 1);
        [[OALSimpleAudio sharedInstance] playEffect:[NSString stringWithFormat:@"pd_hit%d.caf", randomSound]];

打开Hero.m,重写knockOut方法:
- (void)knockOut {
    [super knockOut];
    
    [[OALSimpleAudio sharedInstance] playEffect:@"pd_herodeath.caf"];
}

打开Robot.m,同上:
- (void)knockOut {
    [super knockOut];
    
    [[OALSimpleAudio sharedInstance] playEffect:@"pd_botdeath.caf"];
}

一切完成!!现在编译并运行,享受你自己写的完整的横版动作游戏吧

何去何从?

恭喜你,你亲手写出了一个非常有意思的横版动作游戏。完成了包括人物与地图的移动、攻击与被攻击的检测、人工智能与决策树、音乐播放等功能的实现。
你可以从这里获取到我们工程的完整的源代码与所有资源。 再次感谢本文的作者 @Allen Tan。
虽然本教程已经结束了,但你能做的还有很多,一块真正可玩的横版动作游戏还要包括很多功能我们没有实现,例如Boss系统、装备系统、任务系统…………所以,永远不要停下你前进的步伐!
As usual,如果你对本教程有任何疑问或者建议,请在下方留下你的评论。

你可能感兴趣的:(cocos2d,横版动作游戏)