本文是对教程How To Make A Side-Scrolling Beat Em Up Game Like Scott Pilgrim with Cocos2D – Part 1的部分翻译,加上个人理解而成,最重要的是将文中所有代码转换为Cocos2D 3.x版本。众所周知,3.x与2.x的区别非常之大,在触摸机制、渲染机制等方面都与之前版本有了本质的区别。这里将本人摸索的结果加上,供大家参考。
通过本系列教程你可以学到:
1、Cocos2D 3.x版本的工程创建以及编写
2、TiledMap瓦片地图的简单使用
3、角色状态机的使用
4、敌人AI与简单的决策树
5、碰撞与攻击检测
6、虚拟摇杆的封装与使用
…………
现在,一起来学习吧。
游戏开始:新建游戏工程
Cocos2D 3.x的官方推荐新建项目的方法是利用SpriteBuilder,并且再建完项目之后是否继续使用SpriteBuilder就取决于你的游戏了,所以不用担心你对SpriteBuilder一无所知,因为该游戏中我们不会用到SpriteBuilder。打开SpriteBuilder,点击左上角File->new->Project
给项目起一个名字:PompaDroid,按照作者的意思,就是海扁机器人(Android)喽,这里语言选择Objective-C
工程建好之后,点击左上角发布按钮
然后点击file->open project in Xcode,这时SpriteBuilder的任务就完成了,在打开的Xcode中编译,运行,你就可以看到上面的SpriteBuilder的画面了,这就是3.x版本的HelloWorld界面。
游戏主场景
该游戏中只需要一个场景,因为我们去掉了所谓的开始界面、结束界面等,也没有加入什么装备界面或者是任务界面。将原本项目中自带的MainScene删掉,我们不需要这个类。现在,我们先把框架搭好。按下command+N,新建一些Objective-C类,分别是:
GameScene——我们的游戏主场景,主要功能是将实现游戏功能的两个Layer添加进来。
GameLayer——核心类之一,处理触摸(攻击),加载瓦片地图,实现游戏逻辑。
HUDLayer——放置虚拟摇杆的Layer,与GameLayer分开的原因后面会讲到。
建好以后,你的工程应该会类似这样:
当然了,如果你现在想编译运行,你还会发现你的项目在一开始就crash了,因为我们刚才已经把项目的入口删掉了,现在我们要换成我们自己的入口。
使用SpriteBuilder创建的项目与之前版本有很大的不同,尤其是在AppDelegate中,自习阅读一下的话,会发现这里干的事情是加载SpriteBuilder中的一些配置。我们之前熟悉的代码被提交到CCAppDelegate中了。
打开Soucre->Platforms->iOS->AppDelegate.m,找到最下面的startScene方法,将其替换为
<span style="font-size:18px;">return [GameScene node];</span>不要忘了引入头文件
<span style="font-size:18px;">#import "GameScene.h"</span>
这时编译运行,你就会看到——一片漆黑了。。也对,我们还没有添加任何代码呢。
注意:在Cocos2D 3.x中,如果你将GameLayer和HUDLayer继承与CCLayer,你会发现Xcode无法找到这个类,因为3.x舍弃了CCLayer,layer已经成了概念上的一个词语了。因此我们只需要简单的继承自CCNode即可。至于触摸机制,3.x使用全新的触摸机制,CCNode继承自CCResponder,由该类处理交互。换句话说,任何CCNode的类都可以相应触摸事件了,这点接下来会详述。 |
依然是我们的游戏场景类GameScene中,我们添加如下代码:
<span style="font-size:18px;">//导入头文件 #import "GameLayer.h" #import "HUDLayer.h" //添加属性声明 @property (strong,nonatomic) GameLayer *gameLayer; @property (strong,nonatomic) HUDLayer *hudLayer;</span>在.m中添加初始化方法init
<span style="font-size:18px;">- (id)init { self = [super init]; if (self) { self.gameLayer = [GameLayer node]; self.gameLayer.contentSize = CGSizeMake(self.gameLayer.tileMap.tileSize.width * self.gameLayer.tileMap.mapSize.width,self.gameLayer.tileMap.tileSize.height * self.gameLayer.tileMap.mapSize.height); [self addChild:self.gameLayer z:0]; self.hudLayer = [HUDLayer node]; self.hudLayer.contentSize = CGSizeMake(VISIBLE_SIZE.width,VISIBLE_SIZE.height); [self addChild:self.hudLayer z:1]; } return self; }</span>
重要:这里为每一个layer都设置了contentSize属性,这也是与之前的一个显著不同,因为在3.x版本中,响应触摸需要三个条件: 1、设置self.userInteractionEnabled = TRUE;来打开交互开关。 2、重写方法- (void)touchBegan:(CCTouch *)touch withEvent:(CCTouchEvent *)event或者Move、End等来实现交互。注意如果不写Began,写后面的方法是没有用的(注意到Began的返回值已经不是BOOL了,所以响应链机制也有所变化,后面会详述)。 3、最重要的,为你的Node设置contentSize,否则你是无法触发该响应的。而且只有在你设置的contentSize中才能触发。由于我们的tiledMap的实际大小并不只是屏幕大小,设置的时候需要注意。 |
这里VISIBLE_SIZE是一个方便操作用的宏,后面会给出,这里为了消除报错,简单地设置为[[CCDirector sharedDirector] designSize]即可,实际上这就是该宏的声明。
接下来是时候开始真正的游戏编程了。
加载瓦片地图
首先,从这里下载我们工程中会用到的所有的资源。然后将其中Sprite目录添加到我们的工程中,别忘了勾选上Copy items if needed。
如果你打开其中的tiledMap地图,你会发现,每一块瓦片的大小都是32*32,该瓦片地图的从下往上数第三行包含着墙壁和地面两种资源。我们的主角只能在下面三行地面上行走。
@property (strong,nonatomic) CCTiledMap *tileMap;然后在.m中添加方法:
-(id)init { if ((self = [super init])) { [self initTileMap]; } return self; } -(void)initTileMap { self.tileMap = [CCTiledMap tiledMapWithFile:@"pd_tilemap.tmx"]; [self addChild:_tileMap z:-6]; }
3.x中瓦片地图的类是CCTiledMap,这是与之前的一点不同。
现在,编译并运行,你会发先我们的地图已经成功加载进去了。
创建英雄
大多数2D游戏中,我们的主角都有各种不同的动画,表示不同的动作。那么现在的问题是:你怎么知道什么时候展示什么动画呢?这就要用到状态机了。一个简单的状态机在同一时刻只有一种状态,每切换一种状态,就切换对应的一个行为,或者说是动画。
本游戏中,我们的主角和敌人都有五种状态:
1、攻击
2、行走
3、受伤
4、死亡
5、平常
另外,状态的切换是有条件的。例如,如果角色正在攻击,那么他不能立刻变成死亡状态(先经过受伤状态)。
理论讲到这里就够了,现在开始编码。
正如之前说的,我们的主角和敌人都有这种状态切换的共性,因此我们抽象成一个超类。按下command+N新建一个类继承自CCSprite,取名为ActionSprite,然后在头文件中添加以下代码:
//actions @property(nonatomic,strong)id idleAction; @property(nonatomic,strong)id attackAction; @property(nonatomic,strong)id walkAction; @property(nonatomic,strong)id hurtAction; @property(nonatomic,strong)id knockedOutAction; //states @property(nonatomic,assign)ActionState state; //attributes @property(nonatomic,assign)float walkSpeed; @property(nonatomic,assign)float hitPoints; @property(nonatomic,assign)float damage; //movement @property(nonatomic,assign)CGPoint velocity; @property(nonatomic,assign)CGPoint desiredPosition; //measurements @property(nonatomic,assign)float centerToSides; @property(nonatomic,assign)float centerToBottom; //action methods -(void)idle; -(void)attack; -(void)hurtWithDamage:(float)damage; -(void)knockout; -(void)walkWithDirection:(CGPoint)direction; //scheduled methods -(void)update:(CCTime)dt;这里分类解释一下:
Actions:这五个属性都是CCAction类型的对象,用来执行不同状态下地动作。
States:角色的状态,ActionState是一个枚举类型的变量,稍后给出定义。
Attributes:角色的一些参数。
Measurements:这两个值用于以后角色定位用,因为Cocos2D中精灵的位置是以中心为参照的。
Action Methods:执行动作的方法,这里面包括状态判断与转移。
Scheduled methods:定时器方法,每一帧都会调用。
现在我们来把一些常量定义一下,为了方便,我们将所有常量定义到一个头文件中,新建一个头文件Define.h,然后添加如下代码:
//convenience measurements #define VISIBLE_SIZE [[CCDirector sharedDirector] designSize] #define CENTER ccp(VISIBLE_SIZE.width / 2,VISIBLE_SIZE.height / 2) #define CURRENT_TIME CACurrentMediaTime() //convenience methods #define RANDOM_RANGE(low,high) (low + arc4random() % (high - low + 1)) #define FLOAT_RANDOM ((float)arc4random()/UINT64_C(0x100000000)) #define FLOAT_RANDOM_RANGE(low,high) (low + (high - low) * FLOAT_RANDOM) //action states,enumeration typedef enum ActionState { kActionStateNone = 0,kActionStateIdle,kActionStateAttack,kActionStateWalk,kActionStateHurt,kActionStateKnockedOut } ActionState; //struct typedef struct BoundingBox { CGRect actual; CGRect original; } BoundingBox;
前面的一系列define都是为了方便操作用到,见名也能知意。ActionState就扮演着状态机(严格来说,应该是ActionSprite这个类)的角色。除去那个None状态以外都是之间说过的角色可能会有的状态。最后一项是用于第二篇教程中攻击与被攻击检测中的。此处为了方便,先写上即可。
你可以将该头文件包含在预编译头文件Prefix.pch中,不过从Xcode6将该文件去除来看,Apple已经不支持我们这样了。当然,没什么影响,看个人习惯就好。SpriteBuilder生成的工程带着该文件,那么我们就用它吧。
暂时放着这些方法不管,我们先回到GameLayer,让我们的角色出现在屏幕上再说。
//引入头文件 #import "CCTextureCache.h" //在init方法中添加资源 [[CCSpriteFrameCache sharedSpriteFrameCache] addSpriteFramesWithFile:@"pd_sprites.plist"]; [[CCTextureCache sharedTextureCache] addImage:@"pd_sprites.png"];
注意:在这里我遇到了本篇教程中最大的bug。如果你打开原网页,跟着原教程使用原素材,你会发现接下来从精灵图集中获取精灵时使用位置全部出错,换句话说你的动画根本不是你预想的那样,而是非常乱的几张图片在循环播放。这是因为: cocos2d 3.1中坐标系统的更改,在3.1中纹理空白部分的坐标被截断,依次让整个纹理依附在整个坐标系统上,方便编写自定义的着色器shader,如果你使用TP等软件制作精灵表单,可以选择使用spritebuilder载入资源,否则必须选择TP中的“Flip PVR”选项,如果不选择此项,整个精灵表单的系统会出现错乱。 详见:(3.1 rc1) spritesheet coordinates are off 这里附上我之前已经做好的图片,也就是代码中的pd_sprites.png,然后将pd_sprites.plist中资源来源做相应修改,如图: 图片: 对此,我会单独写一篇文章作记录、备忘用。 |
接下来,是时候创建我们的主角了。command+N信件一个Hero类继承自ActionSprite。在.m中添加如下代码:
//包含动画类头文件 #import "CCAnimation.h"
//init方法 - (id)init { CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"hero_idle_00.png"]; self = [super initWithSpriteFrame:frame]; if (self) { //idle action NSMutableArray *idleFrames = [NSMutableArray arrayWithCapacity:6]; for (int i = 0; i < 6; i++) { CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"hero_idle_%02d.png",i]]; [idleFrames addObject:frame]; } CCAnimation *idleAnimation = [CCAnimation animationWithSpriteFrames:idleFrames delay:1.0f / 12.0f]; self.idleAction = [CCActionRepeatForever actionWithAction:[CCActionAnimate actionWithAnimation:idleAnimation]]; self.centerToBottom = 39.0f; self.centerToSides = 29.0f; self.hitPoints = 100; self.walkSpeed = 80; self.damage = 20; } return self; }
这样你就创建好你的主角并且给他idle状态下的动画以及必要的参数值了。这里关于centerToBottom和centerToSides属性详见下图:
回到GameLayer中,包含我们的Hero的头文件并声明一个Hero的属性hero,然后在init方法中调用下面的方法initHero:
- (void)initHero { self.hero = [Hero node]; [self addChild:self.hero z:-5]; self.hero.position = ccp(self.hero.centerToSides,80); self.hero.desiredPosition = self.hero.position; [self.hero idle]; }
接下来去ActionSprite中实现idle方法:
- (void)idle { if (self.state != kActionStateIdle) { [self stopAllActions]; [self runAction:self.idleAction]; self.state = kActionStateIdle; self.velocity = CGPointZero; } }现在编译然后运行,你会在模拟器上发现我们的英雄正在“抖动” :]
攻击!攻击!
//attack action //添加到ilde action之后 NSMutableArray *attackFrames = [NSMutableArray arrayWithCapacity:3]; for (int i = 0; i < 3; i++) { CCSpriteFrame *frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:[NSString stringWithFormat:@"hero_attack_00_%02d.png",i]]; [attackFrames addObject:frame]; } CCAnimation *attackAnimation = [CCAnimation animationWithSpriteFrames:attackFrames delay:1.0 / 24.0f]; self.attackAction = [CCActionSequence actionOne:[CCActionAnimate actionWithAnimation:attackAnimation] two:[CCActionCallFunc actionWithTarget:self selector:@selector(idle)]];同样,在ActionSprite中实现attack action
- (void)attack { if (self.state == kActionStateAttack || self.state == kActionStateIdle || self.state == kActionStateWalk) { [self stopAllActions]; [self runAction:self.attackAction]; self.state = kActionStateAttack; } }
从这两个动作的实现上大家也应该能够看出所谓的状态机的工作原理了:检查状态——做出动作——切换状态。
- (void)touchBegan:(CCTouch *)touch withEvent:(CCTouchEvent *)event { [self.hero attack]; }
然后编译运行,触摸屏幕,你会发现你的Hero已经可以攻击了!
虚拟摇杆
接下来我们通过创建一个虚拟摇杆来让角色能够移动。虚拟摇杆我就不多废话了,相信大家已经非常熟悉了。这里看一下其实现。@class SimpleDPad; @protocol SimpleDPadDelegate <NSObject> - (void)simpleDPad:(SimpleDPad *)dPad didChangeDirectionTo:(CGPoint)direction; - (void)simpleDPad:(SimpleDPad *)dPad isHoldingDirection:(CGPoint)direction; - (void)simpleDPadTouchEnded:(SimpleDPad *)dPad; @end @interface SimpleDPad : CCSprite { CGFloat _radius; CGPoint _direction; //(-1,-1) represent left,bottom while (1,1) represent right,top } @property (assign,nonatomic) BOOL isHeld; @property (weak,nonatomic) id<SimpleDPadDelegate> delegate; +(id)dPadWithFile:(NSString *)fileName radius:(CGFloat)radius; - (id)initWithFile:(NSString *)fileName radius:(CGFloat)radius; @end
可以看出,这里用到的是iOS编程中最常用的设计模式之一——代理模式,其中radius指触摸点与中心水平线的角度,direction表示方向,第一个值-1表示左,1表示右,第二个值-1表示下,1表示上,因此我们的这个严格上来说并不是虚拟摇杆,而只是个8-方向控制器 :[
+ (id)dPadWithFile:(NSString *)fileName radius:(CGFloat)radius { return [[self alloc] initWithFile:fileName radius:radius]; } - (id)initWithFile:(NSString *)fileName radius:(CGFloat)radius { self = [super initWithImageNamed:fileName]; if (self) { self.userInteractionEnabled = YES; _radius = radius; _direction = CGPointZero; self.isHeld = NO; } return self; }
然后重写update方法:
- (void)update:(CCTime)delta { if (self.isHeld) { [self.delegate simpleDPad:self isHoldingDirection:_direction]; } }
注意:在3.x中,我们不需要再显示调用[self scheduleUpdate]了,只要你重写了update:方法,Cocos2D就会为你每帧调用该方法。 |
- (void)touchBegan:(CCTouch *)touch withEvent:(CCTouchEvent *)event { CGPoint touchPoint = [[CCDirector sharedDirector] convertToGL:[touch locationInView:touch.view]]; CGFloat distance = ccpDistanceSQ(touchPoint,self.position); if (distance < _radius * _radius) { //get angle 8 directon [self updateDirectionForTouchLocation:touchPoint]; self.isHeld = YES; return ; } [super touchBegan:touch withEvent:event]; } - (void)touchMoved:(CCTouch *)touch withEvent:(CCTouchEvent *)event { CGPoint touchPoint = [[CCDirector sharedDirector] convertToGL:[touch locationInView:touch.view]]; [self updateDirectionForTouchLocation:touchPoint]; } - (void)touchEnded:(CCTouch *)touch withEvent:(CCTouchEvent *)event { _direction = CGPointZero; self.isHeld = NO; [self.delegate simpleDPadTouchEnded:self]; } - (void)updateDirectionForTouchLocation:(CGPoint)location { float radians = ccpToAngle(ccpSub(location,self.position)); CCLOG(@"radians = %f",radians); //to make the angle be positive in clockwise direction float degrees = -1 * CC_RADIANS_TO_DEGREES(radians); CCLOG(@"degrees = %f",degrees); if (degrees <= 22.5 && degrees >= -22.5) { //right _direction = ccp(1.0,0.0); } else if (degrees > 22.5 && degrees < 67.5) { //bottom right _direction = ccp(1.0,-1.0); } else if (degrees >= 67.5 && degrees <= 112.5) { //bottom _direction = ccp(0.0,-1.0); } else if (degrees > 112.5 && degrees < 157.5) { //bottom left _direction = ccp(-1.0,-1.0); } else if (degrees >= 157.5 || degrees <= -157.5) { //left _direction = ccp(-1.0,0.0); } else if (degrees < -22.5 && degrees > -67.5) { //top right _direction = ccp(1.0,1.0); } else if (degrees <= -67.5 && degrees >= -112.5) { //top _direction = ccp(0.0,1.0); } else if (degrees < -112.5 && degrees > -157.5) { //top left _direction = ccp(-1.0,1.0); } if (_direction.x == -1.0) { CCLOG(@"left"); } else if (_direction.x == 1.0) { CCLOG(@"right"); } if (_direction.y == -1.0) { CCLOG(@"bottom"); } else if (_direction.y == 1.0) { CCLOG(@"top"); } [self.delegate simpleDPad:self didChangeDirectionTo:_direction]; }
虽然很长,尤其是那一段if-else块。。但是不难理解。现在一个一个分析:
注意:在2.x版本或者更早版本中, 我们可以让ccTouchBegan返回NO来阻断消息链传递。3.x中我们不能这样做了。因为touchBegan方法已经没有返回值了。3.x中采用的机制是,默认阻断,也就是当前层的触摸仅仅由当前层处理,如果你想传递下去,需要调用super,因此,我们的实现思路是,如果触摸点在虚拟摇杆内,就响应该触摸,否则,往下一层(GameLayer)传递。 |
//包含头文件 #import "SimpleDPad.h" //定义属性 @property (strong,nonatomic) SimpleDPad *dPad;在HUDLayer.m中添加方法:
- (id)init { self = [super init]; if (self) { self.userInteractionEnabled = TRUE; self.dPad = [SimpleDPad dPadWithFile:@"pd_dpad.png" radius:64]; self.dPad.position = ccp(64.0,64.0); self.dPad.opacity = 100.0 / 255.0; [self addChild:self.dPad]; } return self; }
然后,切换到GameLayer.h中,包含HUDLayer的头文件,让GameLayer实现协议SimpleDPadDelegate,然后定义一个HUDLayer类型的属性。
//add to top of file #import "SimpleDPad.h" #import "HudLayer.h" //add in between @interface GameLayer : CCNode and the opening curly bracket <SimpleDPadDelegate> //add after the closing curly bracket and the @end @property (strong,nonatomic) HUDLayer *hudLayer;
原文中HUDLayer使用的是弱引用,因为接下来GameLayer持有HUDLayer,HUDLayer强引用SimpleDPad,SimpleDPad通过代理持有GameLayer,但是既然代理已经是weak了,保留环已经被打破,个人感觉这里弱引用强引用皆可,特此实验。
//添加到GameScene中init方法里 self.hudLayer.dPad.delegate = self.gameLayer; self.gameLayer.hudLayer = self.hudLayer;
好了,接下来编译运行你的工程吧,你会看到一个虚拟摇杆出现在你的屏幕左下方。
点击你的摇杆,你就能看到Hero移动了!