Cocos2d开发系列(七)
最后更新于:2022-04-01 11:39:06
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://docs.gechiui.com/gc-content/uploads/sites/kancloud/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的行为写一些特殊的移动和射击组件。总之,这是一个开始编写滚屏游戏的绝佳起点,需要的只是不断去改进它。下一章,我将讲如果使用粒子特效为这个射击游戏增加炫目的视觉效果。