代码改变世界

Cocos2d开发系列(七)

2011-07-04 16:11  乱世文章  阅读(542)  评论(0编辑  收藏  举报

Learn IPhoneand iPad Cocos2d Game Delevopment》第8章 。

这种类型的游戏(shoot’emup游戏)最重要的是什么?射击的目标和需要躲避的子弹。本章,将为游戏添加一些敌人以及一个大 boss。

敌人和玩家将使用新的BulletCache 类射击不同的子弹,这些子弹来自同一个 pool。这个缓冲类会重用无效的子弹,以避免重复的内存分配和释放动作。同样,敌人会使用EnemyCache 类,因为待会屏幕上会出现成堆的敌人。

显然玩家可以向敌人射击。我会介绍基于组件编程的概念,用一种模板化的方式扩展游戏角色。除了 shooting 组件和 moving 组件,我们还会为 boss 老怪创建 healthbar 组件(生命值,俗称“血槽”)。毕竟,老怪不应该是一下就能pk 掉的,其生命值总是被一点点减少直至彻底干掉它。

一、添加 BulletCache 类

该类在是 “一站式” 的,可以一次性生成许多子弹。原来这些代码是放在 GameScene 类中,但这(指生成子弹)显然不该由 GameScene 来管。下面显示 BulletCache 的头文件,它包括了CCSpriteBatchNode 对象和无效子弹计数器nextInactiveBullet:

#import "cocos2d.h"

@interface BulletCache : CCNode {

CCSpriteBatchNode* batch;

 int nextInactiveBullet;

}

-(void) shootBulletAt:

(CGPoint)startPositionvelocity:(CGPoint)velocity

frameName:(NSString*)frameName;

@end

为了把 代码重构到 GameScene类之外,我需要把 initialization 方法和射击子弹的方法移到 BulletCache 类(代码见后)。接着,我决定使用一个CCSpriteBatchNode 变量,以免在每次需要这个对象时就得调用一次[CCNode CCSpriteBatchNode]方法。这会带来细微的性能优化。由于我会在类 GameScene 中加入 BulletCache 对象,因此很容易就能把 sprite batch node 传给 BulletCache。

注意,新的 BulletCache有一个问题,增加了scene的层次——一个额外的 CCNode。如果你担心这点,你也可以把 sprite batch node放在GameScene类中,用一个方法从BulletCahce 获取这个 sprite batch node。

但是,额外的函数调用开销有可能会使性能得以下降。如果你怀疑是不是真的对性能由影响,那就让你的代码可读性更好些。当有必要进行性能优化的时候再重构你的代码。

#import "BulletCache.h"

#import "Bullet.h"

@implementation BulletCache

-(id) init {

if ((self = [super init])) {

// 从当前贴图集中获得角色帧

CCSpriteFrame* bulletFrame =[[CCSpriteFrameCache sharedSpriteFrameCache]

spriteFrameByName:@"bullet.png"];

// 使用角色帧的贴图构建CCSpriteBatchNode

batch = [CCSpriteBatchNodebatchNodeWithTexture:bulletFrame.texture];

 [self addChild:batch];

// 创建子弹并加到 batch

for (int i = 0; i < 200; i++) {

Bullet* bullet =[Bullet bullet];

bullet.visible =NO;

[batchaddChild:bullet];

}

return self;

}}

 -(void) shootBulletAt:(CGPoint)startPositionvelocity:(CGPoint)velocity frameName:(NSString*)frameName{

CCArray* bullets = [batch children];

CCNode* node = [bullets objectAtIndex:nextInactiveBullet];

NSAssert([node isKindOfClass:[Bulletclass]], @"not a Bullet!");

Bullet* bullet = (Bullet*)node;

[bullet shootBulletAt:startPositionvelocity:velocity frameName:frameName];

nextInactiveBullet++;

if (nextInactiveBullet >= [bulletscount]) {

nextInactiveBullet= 0;

}

}

@end

shootBulletAt方法已经完全变了。它有3个参数:startPosition,velocity和frameName——取代 Ship类指针。然后这些参数被传递给 Bullet 类的 shootBulletAt 方法,这个方法现在已经变为:

-(void) shootBulletAt:(CGPoint)startPositionvelocity:(CGPoint)vel frameName:(NSString*)frameName {

self.velocity = vel;

self.position = startPosition;

self.visible = YES;

// 改变子弹的贴图,设置一个不同的角色帧去显示

CCSpriteFrame *frame = [[CCSpriteFrameCachesharedSpriteFrameCache] spriteFrameByName:frameName];

[self setDisplayFrame:frame];

[self scheduleUpdate];

}

velocity 和position 被直接赋值给 bullet。这意味着调用 shootBulletAt 方法的代码必需自己决定子弹的位置、方向和速度。这出于这样的考虑:子弹射击的动作会适应更多的变化,包括可以改变子弹的角色帧(用setDisplayFrame 方法)。因为子弹使用的是相同的贴图集、相同的贴图,所以需要通过设置相应的贴图帧来改变子弹的显示。实际上,渲染贴图的不同部分很轻松,并不会带来额外的开销。

在编辑 Bullet 类时,我还修正了一个边界问题——只有子弹移出屏幕右边时,才会设为不可见并被放会重用列表(其实这是一个bug)。通过在update方法中使用 CGRectIntersectsRect 检查子弹的边框和屏幕矩形,任何完全移出屏幕的子弹都会被标记为重用:

// 子弹离开屏幕后,设为不可见

 if (CGRectIntersectsRect([self boundingBox], screenRect) ==NO) {

……

}

screenRect变量出于方便和性能的原因,被存储为static 变量,因此它能被其他类访问,并不需要每次使用的时候创建。static 变量在类实现文件中声明并有效,比如 screenRect。它们就像类的全局变量,任何类实例都可以读取和修改。成员变量则不同,它们只存在于每个实例对象中。因为屏幕尺寸在游戏期间永远不会变,所有的子弹都需要用到它,把它存储为所有实例的static变量显然是行得通的。第一个实例负责给 screeenRect 赋值。 CGRectIsEmpty 方法负责检查 screenRect 变量是否未初始化——因为是static变量,只需要初始化一次就行了。

static CGRect screenRect;

......

// 确保只初始化一次

if (CGRectIsEmpty(screenRect)) {

CGSize screenSize = [[CCDirectorsharedDirector] winSize];

screenRect = CGRectMake(0, 0,screenSize.width, screenSize.height);

}

接下来,移除GameScene 类中原有的用于射击子弹的代码。此外,需要用初始化 BulletCache 来替换初始化 CCSpriteBatchNode (在GameScene 的 init 方法中):

BulletCache* bulletCache = [BulletCache node];

[self addChild:bulletCache z:1tag:GameSceneNodeTagBulletCache];

还需要为 bulletCache 添加一个访问方法以便其他类通过GameScene访问BulletCache实例:

-(BulletCache*) bulletCache {

CCNode* node = [self getChildByTag:GameSceneNodeTagBulletCache];NSAssert([node isKindOfClass:[BulletCache class]], @"not aBulletCache"); return (BulletCache*)node;

}

InputLayer 现在可以用BulletCache 发射子弹了。 子弹的位置、速度和所用的角色帧这些属性, 应当在 InputLayer 的update方法里传递给射击方法:

if (fireButton.active && totalTime> nextShotTime) {

nextShotTime = totalTime + 0.5f;

GameScene* game = [GameScenesharedGameScene];

Ship* ship = [game defaultShip];

BulletCache* bulletCache = [gamebulletCache];

// 射击前设置 position, velocity h和 spriteframe

CGPoint shotPos = CGPointMake(ship.position.x+ [ship contentSize].width * 0.5f, ship.position.y);

float spread = (CCRANDOM_0_1() - 0.5f) *0.5f;

CGPoint velocity = CGPointMake(1, spread);

 [bulletCache shootBulletAt:shotPos velocity:velocityframeName:@"bullet.png"];

}

重构后的射击过程添加了一些非常必要的灵活性。你可以设想一下,敌人现在可以使用同样的代码发射它们自己的子弹了。

二、敌人

此刻,对于敌人我们仅有一个模糊的概念,它们是干什么的?它们的行为是什么?对于敌人,最重要的是——你永远不知道他们该干什么。

就游戏而言,这意味着一切都要从头开始,要策划出你想让敌人做的事情,从而分析需要编写的代码。与真实世界不同,你完全控制着你的敌人们。是不是觉得自己很伟大?但在你或者其他人感到好笑之前,你需要为统治世界想出一个计划。

我创建了3种不同类型的敌人的图片。这里,我只知道其中一个应该是Boss。看一眼下面的图片,然后想象一下这些敌人分别能干些什么:

在写代码之前,先了解一下这些敌人有哪些行为是共性的,这样有些代码只用编写一次。代码复用是最重要的编码规范。我们先来看看敌人们都有哪些共性:

¥  发射子弹

¥  何时何地发射子弹的判断逻辑

¥  能被玩家的子弹击中

¥  不能被其他敌人的子弹击中

¥  能被多次击中(有生命值)

¥  有固定的行为和移动方式

¥  死亡时显示特定的行为或动画

¥  从屏幕以外进入屏幕后将会显示

¥  当移出屏幕后将不再显示

你可能注意到,上面有些特性也符合玩家飞船。飞船也可以射击子弹,它也可能经受多次射击;当它被摧毁时也应该呈现某个行为或动画,它给人的感觉类似一个特殊的敌人。

扫描上述列表,会有3种实现方式。可以创建一个类,把飞船、敌人、Boss都包含在其中。代码将是有选择地执行部分代码,这取决于敌人的类型。例如,射击代码可能为不同的类型提供不同的分支。对于对象有限的游戏,这是不错的办法——但它无法面对大规模的对象。随着游戏中加入越来越多地对象,你的游戏代码必将变得肥大臃肿。对这个类的任何部分进行修改,都会潜在地对敌人或者飞船的行为带来不希望的影响。用一个变量——敌人类型来决定代码执行路径是一种古老的C 编程方式,不符合 O-C 的面向对象特性。

这种方式至今仍然非常有用,但一定要慎用。

第二种方式,是创建一个类层次。用一个Entity类作为基类,从它派生出一个飞船类、2个怪物类、1个Boss类。很多程序员常这样干,对于游戏对象不多的情况这种方式也非常好用。但本质上,这和第一种方式没什么不同。基类封装了子类要用到的一些通用代码,但不是全部代码。当Entity类中的代码开始基于某个子类的类型执行某个分支时,情况变得糟糕——跟第一种方法一样了。如果小心一点,你应该确保把针对某种敌人的代码放在某个子类里,但在修改的时候很容易会把很多改动放到Entity类里。

第3种方式,是使用组件编程。这意味着不同的代码路径从Entity类层次结构中分离出来,这部分代码仅仅加到所需的子类中。比如一个“血槽”组件。基于组件的编程可以单独写成一本书,对于射击游戏这类项目而言,这显得有些杀鸡用牛刀了,因此我会混合后面两种方式一起使用,这里只是给出一个概念:

如何组合游戏对象而不是各自为政,以及这样做的好处。

我想说明的是,不存在最好的编码方式。选择某种方式完全是主观的,取决于个人经验和偏好。如果你愿意随着对手上开发的游戏的逐渐理解,不断重构你的代码库,能运行的代码比干净的代码更可取。经验让你不经过计划就能做出这些决定,让你能更快地创建更多复杂游戏。要想达到这个目的,从完成一个小游戏开始,然后慢慢地挑战自己的极限。这是个需要学习的过程,很不幸的,在这个过程中你的学习兴趣也很容易被好高骛远消灭掉。为什么每个老练的游戏编程人员会告诉新人,从简单入手,去重写经典的电玩游戏比如俄罗斯方块、帕克人、小行星。

三、Entity类

Entity 类是继承自 CCSprite,只包含了Ship类中的setPosition方法定义,以使所有的Entity 实例始终在屏幕内移动。我只对代码做了一小点改动(其实就是如下面代码所示的if语句,原来的代码是没有if语句的),屏幕外的对象可以移动到屏幕内,但一旦进入屏幕后,它们不能再离开屏幕区域。在这个射击类游戏中,敌人不会从你身边走开,而是站在屏幕中间为了演示一下EnemyCache,进行简单的介绍。屏幕区域检查只是简单检查一下sprite的边框是否完全被屏幕边框所包含,如果是的话,代码将让sprite始终保持在屏幕边框内:

-(void) {

}

setPosition:(CGPoint)pos

// 如果当前位置在屏幕外,则不需要让位置调整到屏幕内

// 这会允许对象从屏幕外部移动到屏幕内部

if (CGRectContainsRect([GameScene screenRect], [selfboundingBox])) {

...

 [supersetPosition:pos];

}

ShipEntity类取代了Ship类。由于Entity类已经包含了setPosition方法,ShipEntity类只剩下了initWithShipImage方法。该方法的代码没有改变。

四、EnemyEntity类

我们需要继续深入到EnemyEntity类,首先是头文件:

#import <Foundation/Foundation.h>

#import"Entity.h"

 

typedef enum{

EnemyTypeBreadman = 0,

EnemyTypeSnake,

EnemyTypeBoss,

EnemyType_MAX,

} EnemyTypes;

@interface EnemyEntity : Entity {

EnemyTypes type;

}

+(id) enemyWithType:(EnemyTypes)enemyType;

+(int) getSpawnFrequencyForEnemyType:(EnemyTypes)enemyType;

-(void) spawn;

@end

没有什么特别的。EnemyTypes 枚举用于3种不同的敌人类型,EnemyType_MAX用于在遍历时标志结束。EnemyEntity类使用了一个EnemyTypes变量存储类型,因此我可以用switch命令基于敌人的类型构建分支语句。EnemyEntity的实现包含许多代码,我会把它分成几个主题,并只显示相关的代码。首先是initWithType方法:

-(id) initWithType:(EnemyTypes)enemyType

{

type = enemyType;

NSString* frameName;

NSString* bulletFrameName;

int shootFrequency = 300;

switch (type)

{

case EnemyTypeBreadman:

frameName= @"monster-a.png";

bulletFrameName= @"candystick.png";

break;

case EnemyTypeSnake:

frameName= @"monster-b.png";

bulletFrameName= @"redcross.png";

shootFrequency= 200;

break;

case EnemyTypeBoss:

frameName= @"monster-c.png";

bulletFrameName= @"blackhole.png";

shootFrequency= 100;

break;

default:

[NSException exceptionWithName:@"EnemyEntityException" reason:@"unhandled enemytype" userInfo:nil];

}

 

if((self = [super initWithSpriteFrameName:frameName]))

{

//Create the game logic components

[self addChild:[StandardMoveComponent node]];

StandardShootComponent* shootComponent = [StandardShootComponent node];

shootComponent.shootFrequency= shootFrequency;

shootComponent.bulletFrameName= bulletFrameName;

[self addChild:shootComponent];

 

//enemies start invisible

self.visible = NO;

 

[self initSpawnFrequency];

}

return self;

}

 

方法一开始是变量赋值,根据敌人的类型,使用switch语句为每种类型提供默认值:敌人的角色帧名以及子弹的角色帧名。switch的default分支抛出异常,因为其他类型在Enemytypes枚举中未定义。这样,如果你定义了一种新的敌人类型,但是如果它不会动,或者发射出了错误的子弹,那么你会得到一个错误警告:哈,你忘记修改某些东西了!

最后别忘了调用[super init…]方法,否则super无法正确初始化并导致一个奇怪的错误然后崩溃。

接下来创建了一个组件,并把它加到EnemyEntity中。后面我会访问这个组件,在此你只需要知道StandardMoveComponent 能让敌人移动并射击。

把注意力放到initSpawnFrequency方法。

-(void) initSpawnFrequency

{

// initialize how frequent the enemies willspawn

if(spawnFrequency == nil)

{

spawnFrequency = [[CCArray alloc] initWithCapacity:EnemyType_MAX];

[spawnFrequency insertObject:[NSNumber numberWithInt:80] atIndex:EnemyTypeBreadman];

[spawnFrequency insertObject:[NSNumber numberWithInt:260] atIndex:EnemyTypeSnake];

[spawnFrequency insertObject:[NSNumber numberWithInt:1500] atIndex:EnemyTypeBoss];

//spawn one enemy immediately

[self spawn];

}

}

+(int) getSpawnFrequencyForEnemyType:(EnemyTypes)enemyType

{

NSAssert(enemyType < EnemyType_MAX, @"invalidenemy type");

NSNumber* number = [spawnFrequency objectAtIndex:enemyType];

return [number intValue];

}

-(void) dealloc

{

[spawnFrequency release];

spawnFrequency = nil;

[super dealloc];

}

我们把每种类型的敌人的出场频率记录在静态数组spawnFrequency里。第一个EnemyEntity实例负责初始化CCArray数组。CCArray不能存储原始数据类型比如整型,因此使用了NSNumber类。使用insertObject方法而不用addObject方法是为了保证对象加入时的顺序,同时别人看到这个枚举值也映射了对应的敌人类型。

dealloc方法释放了CCArray对象,并将其设为nil,这点非常重要。作为静态变量,第一个EnemyEntity对象在运行其dealloc方法时会释放spawnFrequency的内存,如果spawnFrequency不被设为nil,下一个EnemyEntity对象的dealloc方法将视图再次释放,这会“过度释放”spawnFrequency对象,导致程序崩溃。如果spawnFrequency为nil,任何发给它的消息都会被忽略,包括release消息。

spawn方法用于“生成”一个游戏对象:

-(void) spawn

{

CCLOG(@"spawn enemy");

// Select a spawn location just outside theright side of the screen, with random y position

CGRect screenRect = [GameScene screenRect];

CGSize spriteSize = [self contentSize];

float xPos = screenRect.size.width + spriteSize.width * 0.5f;

float yPos = CCRANDOM_0_1() * (screenRect.size.height - spriteSize.height)+ spriteSize.height * 0.5f;

self.position = CGPointMake(xPos, yPos);

// Finally set yourself to be visible, this alsoflag the enemy as "in use"

self.visible = YES;

}

因为EnemyCache用于统一创建所有的敌人,这里整个spawn 方法只是设定一个随机数的y坐标,x坐标是在右侧屏幕以外。visible属性在其他地方会用到,尤其是在组件类中,用于判断EnemyEntity当前是否已使用。如果visible为NO,它可以被“生出”并显示,如果为YES,它就会按照固定的逻辑运行。

五、EnemyCache类

从名字上看,这会让你想到BulletCache类,它也持有了大量已初始化对象,以便快速和简单地重用,减少了游戏时对象的创建、释放动作,而这恰恰是导致游戏流畅性下降的原因之一。尤其是动作游戏,这种不流畅给玩家体验带来了灾难性后果。以下是EnemyCache的头文件。

#import <Foundation/Foundation.h>

#import "cocos2d.h"

 

@interface EnemyCache : CCNode

{

CCSpriteBatchNode* batch;

CCArray* enemies;

int updateCount;

}

 

@end

CCSpriteBatchNode对象包含全部敌人角色(sprite),CCArray则储存了每种敌人的列表。updateCount变量在每帧生成一个敌人时自动增加。init方法与BulletCache的init方法十分类似:

-(id) init

{

if((self = [super init]))

{

//从贴图集缓存中得到图片

CCSpriteFrame* frame = [[CCSpriteFrameCache sharedSpriteFrameCache] spriteFrameByName:@"monster-a.png"];

batch = [CCSpriteBatchNode batchNodeWithTexture:frame.texture];

[self addChild:batch];

[self initEnemies];

[self scheduleUpdate];

}

return self;

}

 

但initEnemies方法就复杂多了:

 

-(void) initEnemies

{

// 创建enemies 数组,用于存放每种类型的敌人

enemies = [[CCArray alloc] initWithCapacity:EnemyType_MAX];

// 有多少种敌人,就创建多少个数组

for (int i = 0; i < EnemyType_MAX; i++)

{

//根据敌人种类的不同,设置不同的数组容量。

int capacity;

switch (i)

{

case EnemyTypeBreadman:

capacity = 6;

break;

case EnemyTypeSnake:

capacity = 3;

break;

case EnemyTypeBoss:

capacity = 1;

break;

default:

[NSException exceptionWithName:@"EnemyCacheException" reason:@"unhandled enemytype" userInfo:nil];

break;

}

//不需要alloc数组,当数组被加到enemies数组时会自动retain

CCArray* enemiesOfType = [CCArray arrayWithCapacity:capacity];

[enemies addObject:enemiesOfType];

}

for (int i = 0; i < EnemyType_MAX; i++)

{

CCArray* enemiesOfType = [enemies objectAtIndex:i];

int numEnemiesOfType = [enemiesOfType capacity];

for (int j = 0; j < numEnemiesOfType;j++)

{

EnemyEntity* enemy = [EnemyEntity enemyWithType:i];

[batch addChild:enemy z:0 tag:i];

[enemiesOfTypeaddObject:enemy];

}

}

}

有意思的是,CCArray* enemies 对象自身包含了多个CCArray*对象,每种类型的敌人使用一个CCArray*。这是一个典型的 2 维数组。enemies 变量需要用alloc 分配内存,否则initEnemies 方法一结束它的内存会被释放。相反,enimies数组中的CCAray 元素对象不需要alloc,因为当它被add 到enemies数组中时会被自动retain。每种敌人所用的CCArray数组,其初始容量为该类型一次允许加到屏幕中的个数。每种敌人的CCArray数组使用addObject方法加到enemies数组。用这种方式可以创建层次深度。事实上,cocos2d结点层次结构也是通过在CCNode 类中定义一个CCArray* children成员变量来构建的。

我将enimies数组的创建和初始化分别放在在两个单独的循环体中,尽管它们其实也可以在一个循环中进行,但它们明显是属于不同的任务,应该保持分离——至于因此导致的性能上的额外开销,是微乎其微的。

根据在CCArray初始化时的初始容量,相同数目的敌人被构建出来并加入到CCSpriteBatchNode中,然后又加到对应的某种敌人使用的CCArray中。通过CCSpriteBatchNode也能访问到敌人,但单独把这些敌人放在分开的数组中更方便处理,代码列表如下所示:

-(void) spawnEnemyOfType:(EnemyTypes)enemyType

{

CCArray* enemiesOfType = [enemies objectAtIndex:enemyType];

EnemyEntity* enemy;

CCARRAY_FOREACH(enemiesOfType, enemy)

{

//查找可重建的敌人,重用

if (enemy.visible== NO)

{

//CCLOG(@"spawn enemy type %i",enemyType);

[enemy spawn];

break;

}

}

}

 

-(void) update:(ccTime)delta

{

updateCount++;

 

for (int i = EnemyType_MAX- 1; i >= 0; i--)

{

int spawnFrequency = [EnemyEntity getSpawnFrequencyForEnemyType:i];

if (updateCount % spawnFrequency == 0)

{

[self spawnEnemyOfType:i];

break;

}

}

}

update方法使计数器updateCount加1。这并不会多花费多少时间,但却是值得的,因为他会使我们接下来更轻松一些。

For循环比较奇怪,循环变量i从EnemyType_MAX开始递减,一直到i为负值。这个目的是为了让EnemyTypes 更大的怪物更早出生。例如,当boss怪和蛇同时出现时,首先让boss怪出生。否则会导致这样的事情发生,蛇会和boss争抢出生机会,甚至阻塞了Boss的出生。这个出生逻辑有一个副作用,我把它保留给你自己去解决,如果你要写一个自己的射击游戏,你可能不得不自己实现一些东西。

spawnFrequency被EnemyEntity 的getSpawnFrequncyForEnemyType方法所赋值。

+(int) getSpawnFrequencyForEnemyType:(EnemyTypes)enemyType

{

NSAssert(enemyType < EnemyType_MAX, @"invalidenemy type");

NSNumber* number = [spawnFrequency objectAtIndex:enemyType];

return [number intValue];

}

这个方法首先断言enemyType是否是有效值。然后从spawnFrequency数组中取出指定类型的敌人的NSNumber对象并返回其intValue值。

回到update方法,接下来使用取模运算%,计算updateCount能否被spawnFrequency所整除,意思是只有updateCount数到指定的数时(updateCount是个计数器),某个怪才会降生。

spanEnemyOfType方法从enemies数组中取出对应的CCArray,然后只需要遍历指定的类型的CCArray数组,而不用去遍历整个CCSrpiteBatchNode:

-(void) spawnEnemyOfType:(EnemyTypes)enemyType

{

CCArray* enemiesOfType = [enemies objectAtIndex:enemyType];

EnemyEntity* enemy;

CCARRAY_FOREACH(enemiesOfType, enemy)

{

//find the first free enemy and respawn it

if (enemy.visible== NO)

{

//CCLOG(@"spawn enemy type %i",enemyType);

[enemy spawn];

break;

}

}

}

如果找到一个visible为NO的怪,调用其spawn方法。如果所有的该类怪的visible都是YES,当前屏幕上该类怪的数目已经达到最大,不再产生这种类别的怪,这样就限制了屏幕上同一种怪的数量。

六、Component类

Component类在游戏逻辑中被视作插件。如果把一个component(组件)加在一个entity类,则该entity可以执行组件的行为:移动,射击,动画,显示生命值等等。编写组件的好处是它能自动工作,因为它们与父容器(CCNode)交互,并尽可能地不对父容器做出要求。有时候组件要求父容器必须是一个EnemyEntity类,但实际上你可以在任何类型的EnemyEntity(子类)上使用它。组件类可根据使用组件的类来配置。例如,这是一个在EnemyEntity中使用StandarShoortComponent组件的例子:

StandardShootComponent* shootComponent = [StandardShootComponent node];

shootComponent.shootFrequency= shootFrequency;

shootComponent.bulletFrameName= bulletFrameName;

[self addChild:shootComponent];

 

shootFrequency和bulletFrameName变量是根据EnemyType来初始化的。把StandartShootComponent添加到EnemyEntity类,该类将会拥有射击的能力。因为组件类未对父容器做任何限制,你甚至可以把组件加到ShipEntity,使玩家飞船以指定射速进行自动射击。通过简单地激活或失活射击组件,你可以用很少的代码实现给玩家更换武器的效果。你仅仅是把射击代码隔离出来,然后把组建植入游戏对象并设置一些参数而已。

让武器失效并切换武器的逻辑很简单。甚至,你可以把组件使用到其他游戏。组件在封装可重用代码时非常有用,在许多游戏引擎中组件是一种标准机制。如果你想进一步了解游戏组件,请到我的blog(www.learn-cocos2d.com/2010/06/prefer-composition-inheritance/)。

StandardShootComponent的头文件如下:

@interface StandardShootComponent : CCSprite

{

int updateCount;

int shootFrequency;

NSString* bulletFrameName;

}

 

@property (nonatomic) int shootFrequency;

@property (nonatomic, copy) NSString* bulletFrameName;

 

@end

有两件事情值得注意。首先StandardShootComponent派生自CCSprite,尽管它没有使用任何贴图纹理。因为CCSpriteBatchNode只能包含CCSprite对象,而所有的EnemyEntity对象都被加到了CCSpriteBatchNode,而且EnemyEntity的子节点,这些都是StandardShotComponent的作用对象。因此StandardShootComponent需要从CCSprite继承以满足CCSpriteBatchNode的要求。

第2是一个NSString 指针,bulletFrameName,用@property关键字封装成了属性。如果你足够细心,应该发现在@property定义中的copy关键字。这说明只要给这个属性赋值,将产生一个复制操作。这样做对于确保这个字符串始终可用很重要, 因为字符串通常都是autorelease对象。我们也可以用retain对象,问题在于,如果源字符串被改变,这将影响到bulletFrameName,这可能不是我们希望的。

当然,copy关键字还意味着我们要负责在dealloc中释放它,如下所示。

@implementation StandardShootComponent

 

@synthesize shootFrequency;

@synthesize bulletFrameName;

 

-(id) init

{

if((self = [super init]))

{

[self scheduleUpdate];

}

return self;

}

 

-(void) dealloc

{

[bulletFrameName release];

[super dealloc];

}

 

-(void) update:(ccTime)delta

{

if(self.parent.visible)

{

updateCount++;

if (updateCount >= shootFrequency)

{

//CCLOG(@"enemy %@ shoots!",self.parent);

updateCount = 0;

GameScene* game = [GameScene sharedGameScene];

CGPoint startPos = ccpSub(self.parent.position, CGPointMake(self.parent.contentSize.width * 0.5f, 0));

[game.bulletCache shootBulletFrom:startPos velocity:CGPointMake(-2, 0) frameName:bulletFrameName];

}

}

}

 

@end

 

真正的射击代码首先要检查父对象是否visible为YES,否则射击代码显然不应该被调用。BulletCache发射子弹时使用组件bulletFrameName 属性和固定的速度进行发射。 开始位置startPos并不是指组件自己的位置,而是使用父容器的位置和contentSize计算出来的:子弹位于角色的左边。

对于常规的怪,一个startPos就足够了,但对于Boss来说,用它的嘴或者鼻子来发射子弹,这才酷呢!我把这个工作也留给了你:为组件增加一个属性,以便子弹的初始位置可以被设置。当然,你也可以创建一种单独的BossShootComponent类,专门给Boss设计一种更复杂的射击模式。StandardMoveComponents 也是一样的, boss怪也可能需要在屏幕右边的某个位置不停盘旋。

七、击中物体

几乎忘记了——你其实是想向怪物们开火并击中它们,不是吗?

BulletCache类是检查子弹击中物体的理想地点。我把方法加在了BulletCache中。实际上是3个方法,2个是public的,1个是private方法,如下所示。使用这两个方法:isPlayerBulletCollidingWithRect和isEnemyBulletCollidingWithRect方法的目的是为了隐藏根据子弹的主类进行碰撞检测的内部细节。

-(bool) isPlayerBulletCollidingWithRect:(CGRect)rect

{

return [self isBulletCollidingWithRect:rect usePlayerBullets:YES];

}

 

-(bool) isEnemyBulletCollidingWithRect:(CGRect)rect

{

return [self isBulletCollidingWithRect:rect usePlayerBullets:YES];

}

 

-(bool) isBulletCollidingWithRect:(CGRect)rect usePlayerBullets:(bool)usePlayerBullets

{

bool isColliding = NO;

Bullet* bullet;

CCARRAY_FOREACH([batch children], bullet)

{

if (bullet.visible&& usePlayerBullets == bullet.isPlayerBullet)

{

if(CGRectIntersectsRect([bullet boundingBox],rect))

{

isColliding = YES;

//remove the bullet

bullet.visible= NO;

break;

}

}

}

return isColliding;

}

 

你也可以把usePlayerBullets 参数暴露给其他类,但这样把这个参数由bool类型改变为enum类型时只会更难,一旦你想使用第3种子弹怎么办?

只对看得见的子弹进行检测,同时要检查isPlayerBullet 属性,确保怪物们不会被自己的子弹击中。其实碰撞检测是件简单的事情,你可以使用CGRectIntersectsRect,如果子弹真的击中了什么,子弹自身也应该“消失”。

EnemyCache类持有所有的EenemyEntity对象,这里也是调用方法去检测是否有怪物被玩家击中的好地方。现在EnemyCache类增加了checkForBulletCollisions方法(会由update方法来调用):

-(void) checkForBulletCollisions

{

EnemyEntity* enemy;

CCARRAY_FOREACH([batch children], enemy)

{

if (enemy.visible)

{

BulletCache* bulletCache = [[GameScene sharedGameScene] bulletCache];

CGRect bbox = [enemy boundingBox];

if([bulletCache isPlayerBulletCollidingWithRect:bbox])

{

//This enemy got hit ...

[enemy gotHit];

}

}

}

}

 

在这里,很方便遍历所有的怪物,并忽略那些当前不可见的。使用BulletCache的isPlayerBulletCollidingWithRect方法以及怪物的boundingBox属性进行检测,我们能快速地发现一个怪是否被玩家子弹击中;如果击中,就调用EnemyEntity的gotHist方法,该方法只是简单地把怪变为不可见。

我把飞船被怪物子弹击中的练习留给了你。你必须在ShipEntity方法中调用update方法,然后实现checkForBulletCollisions方法并在update方法中调用它。你还要改变isPlayerBulletCollidingWithRect方法和isEnemyBulletColligingWithRect方法,当子弹击中时播放声效。

八、Boss的血槽

作为Boss,不应该一枪毙命。应该向玩家显示boss 的生命值,当boss被击中时血槽中的数值就减少一点。首先,需要在EnemyEntity类中增加一个hitPoints成员变量(即血点),用于表明怪物需要多少次击中才会KO。initialHitPoints变量储存怪物满血状态下的血点值,因为怪物被杀死后我们需要恢复它原来的血点(别忘记,我们的怪都是可以被“重用”的)。对头文件所做的修改如下:

@interface EnemyEntity : Entity {

EnemyTypes type;

int initialHitPoints;

int hitPoints;

}

@property (readonly, nonatomic) int hitPoints;

为了表现血槽,我们需要一个组件类。很显然这就是HealthbarComponent类:

@interface HealthbarComponent : CCSprite

{

}

 

-(void) reset;

 

@end

HealthComponent类的实现则比较有趣。HealthBarComponent 根据怪物的剩余血点更新它的scaleX属性(这个scaleX来自于CCNode)。

-(id) init

{

if((self = [super init]))

{

self.visible = NO;

[self scheduleUpdate];

}

return self;

}

 

-(void) reset

{

float parentHeight = self.parent.contentSize.height;

float selfHeight = self.contentSize.height;

self.position = CGPointMake(self.parent.anchorPointInPixels.x, parentHeight + selfHeight);

self.scaleX = 1;

self.visible = YES;

}

 

-(void) update:(ccTime)delta

{

if(self.parent.visible)

{

NSAssert([self.parent isKindOfClass:[EnemyEntity class]], @"nota EnemyEntity");

EnemyEntity* parentEntity = (EnemyEntity*)self.parent;

self.scaleX = parentEntity.hitPoints/ (float)parentEntity.initialHitPoints;

}

else if (self.visible)

{

self.visible = NO;

}

}

 

@end

 

血槽可以根据父对象的visible属性在可视/不可视之间切换。reset方法把血槽放到怪物角色的顶上。因为血点减少是通过修改scaleX属性来显示的,scaleX也应当被重置。

update方法中,当血槽的父对象是可视时,首先判断父对象是不是EnemyEntity类,因为血槽组件要使用到在EnemyEntity中才有效的某些属性,我们必须确保它的父类必须是EnemyEntity类。我把scaleX属性修改为百分数值:用当前血点除以满血点。因为不知道什么时候血点会变,我们只有在每一帧都进行这个计算,不管血点到底有没有发生变化。这样做有点性能上的浪费,对于复杂计算而言,最好是从EnemyEntity的onHit方法去调用血槽组件的方法。

在EnemyEntity的init方法中,如果怪物类型为EnemyTypeBoss,则把组件HealthbarComponent加到EnemyEntity对象。

注意:parentEntity.initialHitPoints被强制转换为float,否则”/”是进行整数除法,这样的结果永远是0。将除数使用float类型就可以保证除法是小数点除法,以得到非0的小数。

if (type == EnemyTypeBoss) {

HealthbarComponent*healthbar = [HealthbarComponent spriteWithSpriteFrameName:

@"healthbar.png"];

[self addChild:healthbar];

}

spawn方法进行了扩展,包括把血点重置为满血,调用子组件中的所有血槽组件的reset方法(如果由多个的话)。我省略了对怪物类型的判断,因为血槽是很通用的,可以被任何怪物用到。

-(void) spawn

{

//CCLOG(@"spawn enemy");

// 出生地点选择在屏幕右边,y坐标值为随机数

CGRect screenRect = [GameScene screenRect];

CGSize spriteSize = [self contentSize];

float xPos = screenRect.size.width + spriteSize.width * 0.5f;

float yPos = CCRANDOM_0_1() * (screenRect.size.height - spriteSize.height)+ spriteSize.height * 0.5f;

self.position = CGPointMake(xPos, yPos);

// 出生后就表示看得见了

self.visible = YES;

// 重置血点,因为我们重用的对象很可能才被打死

hitPoints = initialHitPoints;

// 重置一些组件,如血槽

CCNode* node;

CCARRAY_FOREACH([self children], node)

{

if ([node isKindOfClass:[HealthbarComponent class]])

{

HealthbarComponent* healthbar = (HealthbarComponent*)node;

[healthbarreset];

}

}

}

 

九、结论

做出一个完整并优雅的游戏是一个很大的成果,包括大量的重构,修改代码改进射击以及允许更多的特性并让它们和谐相处。本章,学习了BulletCache和EnemyCache类的作用,使用它们对某个类的所有实例进行管理,便于在一个地方集中访问这些实例。同时起到一种“实例池”的作用,有助于改善性能。

Entity类层次示范了如何把你的类分离出来,而不需要每个游戏对象都设计一个类。使用组件类和cocos2d结点这样的层次结构的好处在于,你可以把一些很特别的功能创建为即插即用的类。这有助于用复合的方式而非继承的方式构造你的游戏对象。以这种方式编写游戏逻辑能更“柔性”,同时代码的复用性更好。最后,还学习了如何向怪物射击,以及BulletCache和EnemyCache类如何以一种直接的方式完成这个目的。HealthbarComponent提供了一个组件编程的极好例子。

这个游戏到这里还有几件事情等你完成。首先最主要的是,玩家从来不会被子弹击中。可能你想为蛇加上一个血槽,或者为boss的行为写一些特殊的移动和射击组件。总之,这是一个开始编写滚屏游戏的绝佳起点,需要的只是不断去改进它。下一章,我将讲如果使用粒子特效为这个射击游戏增加炫目的视觉效果。