🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
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。看一眼下面的图片,然后想象一下这些敌人分别能干些什么: ![](https://box.kancloud.cn/2016-05-04_572a0ba38ada0.gif) 在写代码之前,先了解一下这些敌人有哪些行为是共性的,这样有些代码只用编写一次。代码复用是最重要的编码规范。我们先来看看敌人们都有哪些共性: ¥  发射子弹 ¥  何时何地发射子弹的判断逻辑 ¥  能被玩家的子弹击中 ¥  不能被其他敌人的子弹击中 ¥  能被多次击中(有生命值) ¥  有固定的行为和移动方式 ¥  死亡时显示特定的行为或动画 ¥  从屏幕以外进入屏幕后将会显示 ¥  当移出屏幕后将不再显示 你可能注意到,上面有些特性也符合玩家飞船。飞船也可以射击子弹,它也可能经受多次射击;当它被摧毁时也应该呈现某个行为或动画,它给人的感觉类似一个特殊的敌人。 扫描上述列表,会有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/](http://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的行为写一些特殊的移动和射击组件。总之,这是一个开始编写滚屏游戏的绝佳起点,需要的只是不断去改进它。下一章,我将讲如果使用粒子特效为这个射击游戏增加炫目的视觉效果。