本文是对教程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、虚拟摇杆的封装与使用
…………
现在,一起来学习吧。
打开SpriteBuilder,点击左上角File->new->Project
给项目起一个名字:PompaDroid,按照作者的意思,就是海扁机器人(Android)喽,这里语言选择Objective-C
工程建好之后,点击左上角发布按钮
然后点击file->open project in Xcode,这时SpriteBuilder的任务就完成了,在打开的Xcode中编译,运行,你就可以看到上面的SpriteBuilder的画面了,这就是3.x版本的HelloWorld界面。
现在,我们先把框架搭好。按下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的类都可以相应触摸事件了,这点接下来会详述。 |
<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]即可,实际上这就是该宏的声明。
接下来是时候开始真正的游戏编程了。
如果你打开其中的tiledMap地图,你会发现,每一块瓦片的大小都是32*32,该瓦片地图的从下往上数第三行包含着墙壁和地面两种资源。我们的主角只能在下面三行地面上行走。
回到编码上来,在GameLayer中添加如下属性声明:
@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]; }
现在,编译并运行,你会发先我们的地图已经成功加载进去了。
一个简单的状态机在同一时刻只有一种状态,每切换一种状态,就切换对应的一个行为,或者说是动画。
本游戏中,我们的主角和敌人都有五种状态:
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;
你可以将该头文件包含在预编译头文件Prefix.pch中,不过从Xcode6将该文件去除来看,Apple已经不支持我们这样了。当然,没什么影响,看个人习惯就好。SpriteBuilder生成的工程带着该文件,那么我们就用它吧。
暂时放着这些方法不管,我们先回到GameLayer,让我们的角色出现在屏幕上再说。
在GameLayer中添加下面的代码:
//引入头文件 #import "CCTextureCache.h" //在init方法中添加资源 [[CCSpriteFrameCache sharedSpriteFrameCache] addSpriteFramesWithFile:@"pd_sprites.plist"]; [[CCTextureCache sharedTextureCache] addImage:@"pd_sprites.png"];
//包含动画类头文件 #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; }
回到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]; }
- (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; } }
//添加到init方法中 self.userInteractionEnabled = TRUE;接着重写触摸触发方法:
- (void)touchBegan:(CCTouch *)touch withEvent:(CCTouchEvent *)event { [self.hero attack]; }
@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
+ (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; }
- (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]; }
注意:在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; }
//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;
//添加到GameScene中init方法里 self.hudLayer.dPad.delegate = self.gameLayer; self.gameLayer.hudLayer = self.hudLayer;