3.8 垂直射击游戏—加载游戏数据
为了使大家对CCSprite和各相关类的使用有更加直观的印象,下面我们结合前面的游戏示例,使用精灵表单优化游戏性能,同时在游戏开始和结束时添加菜单,让玩家对游戏有更多控制权。当然,在这个示例小游戏中,这种优化是看不出差别的。但这是最佳实践,建议读者以后编写游戏都以这种方式使用精灵。
**3.8.1 注释draw方法和背景
**
首先,在Xcode中打开之前的项目中把draw方法注释掉,同时恢复先前注释掉的添加游戏背景的代码段,编译并运行,如图3-6所示。
注意 这时必须触碰玩家飞机才可以发射子弹,同时,此刻的碰撞检测代码也有所差别,当然,玩家是感觉不到的。
3.8.2 加载游戏资源
首先需要让玩家决定什么时候开始游戏,因此要在游戏正式开始前添加一个菜单用来开始游戏。只有当玩家触碰此菜单时,才会正式开始游戏。为了使游戏开发更加贴近实际开发,这里介绍如何制作一个LoadingScreen场景来加载游戏所需要的图片、音效、背景音乐、存档等资源。
1 . 创建LoadingScreen
为了简单起见,该LoadingScreen只显示一个Loading字样,没有使用进度条。后面章节的实例中再考虑使用进度条,这样用户体验会更好一些。虽然界面显示比较简单,但这里向大家介绍的是一种通用的预先加载游戏资源的做法,以后任何Cocos2D游戏都可以使用此方法进行加载。
单击File→New→File,从左边选择Cocos2D v2.x模板,在右边的模板类中选择CCNode class模板类,选择CCLayer作为基类,然后选择next并保存到项目的VerticalShootingGame文件夹下。
用代码清单3-8中的代码替换LoadingScreen.h的内容。
代码清单3-8 LoadingScreen.h文件替换代码
#import <Foundation/Foundation.h>
#import "cocos2d.h"
@interface LoadingScreen : CCLayer {
CGSize winSize;
CGPoint winCenter;
int assetCount;
}
+(CCScene *) scene;
-(void) loadMusic:(NSArray *) musicFiles;
-(void) loadSounds:(NSArray *) soundClips;
-(void) loadSpriteSheets:(NSArray *) spriteSheets;
-(void) loadImages:(NSArray *) images;
-(void) loadAssets:(NSArray *) assets;
-(void) progressUpdate;
-(void) loadingComplete;
@end
LoadingScreen继承自CCLayer,提供一个静态scene方法供CCDirector对象调用。接下来定义3个成员变量,其中winSize代表游戏屏幕窗口大小,winCenter代表窗口的中点,assetCount用来保存游戏需要加载的资源总数。
2 . Loading方法
重点是接下来一系列的Loading方法,每个方法都接收一个NSArray数组作为参数。这些参数都是一些具体资源的文件名,参数的值是从一个配置文件中读出来的,后面我们在介绍实现时会给出来。这些方法及用途如下:
-(void) loadMusic:(NSArray *) musicFiles;(加载背景音乐)
-(void) loadSounds:(NSArray *) soundClips;(加载游戏音效)
-(void) loadSpriteSheets:(NSArray *) spriteSheets;(加载精灵表单)
-(void) loadImages:(NSArray *) images;(加载背景图片等图片资源)
-(void) loadAssets:(NSArray *) assets;(加载游戏字体、存档等资源)
-(void) progressUpdate;(更新游戏进度条,目的只是计算何时加载完成)
-(void) loadingComplete;(资源全部加载完成,切换到另一个游戏场景)
3 . LoadingScreen的具体实现
打开LoadingScreen.m文件,用代码清单3-9替换其中的内容。
代码清单3-9 LoadingScreen.m
#import "LoadingScreen.h"
#import "SimpleAudioEngine.h"
//The next scene you wish to transition to
#import "HelloWorldLayer.h"
@implementation LoadingScreen
+(CCScene *) scene
{
// 'scene' is an autorelease object
CCScene *scene = [CCScene node];
NSString *className = NSStringFromClass([self class]);
// 'layer' is an autorelease object.
id layer = [NSClassFromString(className) node];
// add layer as a child to scene
[scene addChild: layer];
// return the scene
return scene;
}
-(id) init
{
if ( ( self = [ super init] ) )
{
winSize = [[CCDirector sharedDirector] winSize];
winCenter = ccp(winSize.width / 2, winSize.height / 2);
CCLabelTTF *loadingText = [CCLabelTTF labelWithString:@"Loading..." fontName:@"Arial" fontSize:20];
loadingText.position = ccpAdd(winCenter, ccp(0,50));
[self addChild:loadingText];
}
return self;
}
-(void) onEnterTransitionDidFinish
{
[super onEnterTransitionDidFinish];
NSString *path = [[CCFileUtils sharedFileUtils] fullPathFromRelativePath:@"preloadAssetManifest.plist"];
NSDictionary *manifest = [NSDictionary dictionaryWithContentsOfFile:path];
NSArray *spriteSheets = [manifest objectForKey:@"SpriteSheets"];
NSArray *images = [manifest objectForKey:@"Images"];
NSArray *soundFX = [manifest objectForKey:@"SoundFX"];
NSArray *music = [manifest objectForKey:@"Music"];
NSArray *assets = [manifest objectForKey:@"Assets"];
assetCount = ([spriteSheets count] + [images count] + [soundFX count] + [music count] + [assets count]);
if (soundFX)
[self performSelectorOnMainThread:@selector(loadSounds:) withObject:soundFX waitUntilDone:YES];
if (spriteSheets)
[self performSelectorOnMainThread:@selector(loadSpriteSheets:) withObject:spriteSheets waitUntilDone:YES];
if (images)
[self performSelectorOnMainThread:@selector(loadImages:) withObject:images waitUntilDone:YES];
if (music)
[self performSelectorOnMainThread:@selector(loadMusic:) withObject:music waitUntilDone:YES];
if (assets)
[self performSelectorOnMainThread:@selector(loadAssets:) withObject:assets waitUntilDone:YES];
}
-(void) loadMusic:(NSArray *) musicFiles
{
CCLOG(@"Start loading music");
for (NSString *music in musicFiles)
{
[[SimpleAudioEngine sharedEngine] preloadBackgroundMusic:music];
[self progressUpdate];
}
}
-(void) loadSounds:(NSArray *) soundClips
{
CCLOG(@"Start loading sounds");
for (NSString *soundClip in soundClips)
{
[[SimpleAudioEngine sharedEngine] preloadEffect:soundClip];
[self progressUpdate];
}
}
-(void) loadSpriteSheets:(NSArray *) spriteSheets
{
for (NSString *spriteSheet in spriteSheets)
{
[[CCSpriteFrameCache sharedSpriteFrameCache] addSpriteFramesWithFile:spriteSheet];
[self progressUpdate];
}
}
-(void) loadImages:(NSArray *) images
{
CCLOG(@"LoadingScreen - loadImages : You need to tell me what to do.");
for (NSString *image in images)
{
//Do something with the images
[self progressUpdate];
}
}
-(void) loadAssets:(NSArray *) assets
{
//Overwrite me
CCLOG(@"LoadingScreen - loadAssets : You need to tell me what to do.");
for (NSString *asset in assets)
{
//Do something with the assets
[self progressUpdate];
}
[self progressUpdate];
}
-(void) progressUpdate
{
if (--assetCount)
{
//以备后面显示进度条用
}
else {
[self loadingComplete];
CCLOG(@"All done loading assets.");
}
}
-(void) loadingComplete
{
CCDelayTime *delay = [CCDelayTime actionWithDuration:2.0f];
CCCallBlock *swapScene = [CCCallBlock actionWithBlock:^(void) {
[[CCDirector sharedDirector] replaceScene:[CCTransitionFade transitionWithDuration:1.0f scene:[HelloWorldLayer scene]]];
}];
CCSequence *seq = [CCSequence actions:delay, swapScene, nil];
[self runAction:seq];
}
@end
虽然代码比较多,但是不要害怕,我们会依次解释其中的每个方法。
(1)+(CCScene *) scene
这个类方法的实现代码跟一般的有一些不同,它使用Objective-C的动态语言特性反射。首先使用NSStringFromClass得到类名:
NSString *className = NSStringFromClass([self class]);
此处className为LoadingScreen。然后使用NSClassFromString得到该类的类型:
id layer = [NSClassFromString(className) node];
这里还会给该类型发送一个node消息。这种反射和任意发送消息的能力使Objective-C语言具有强大的灵活性,也为游戏开发提供大量的便利。我们可以把关卡的配置、敌人的类型直接存储为文件,然后反射解析。
(2)init方法
该方法的实现非常简单,就是计算屏幕窗口大小和中点位置,然后初始一个CCLabelTTF对象,用来显示Loading字样。
下面我们来看比较重要的onEnterTransitionDidFinish方法。
(3)onEnterTransitionDidFinish方法
该方法首先加载一个配置文件,然后读取配置文件中的游戏资源名字列表并存储在不同的数组中。示例代码如下:
NSString *path = [[CCFileUtils sharedFileUtils] fullPathFromRelativePath:@"preloadAssetManifest.plist"];
NSDictionary *manifest = [NSDictionary dictionaryWithContentsOfFile:path];
NSArray *spriteSheets = [manifest objectForKey:@"SpriteSheets"];
NSArray *images = [manifest objectForKey:@"Images"];
NSArray *soundFX = [manifest objectForKey:@"SoundFX"];
NSArray *music = [manifest objectForKey:@"Music"];
NSArray *assets = [manifest objectForKey:@"Assets"];
assetCount = ([spriteSheets count] + [images count] + [soundFX count] + [music count] + [assets count]);
首先使用CCFileUtils获得preloadAssetManifest.plist文件的具体路径,调用NSDictionary的dictionaryWithContentsOfFile把该文件转换成一个字典对象;然后通过key值取出每种不同类型资源的数组;最后调用数组的count方法得到总共需要加载的资源数量。
继续之前,我们首先需要将资源添加到项目中,打开本章附带示例项目的chapter3/resource/progress目录,将其中的资源全部添加进项目中;然后查看preloadAssetManifest.plist的具体配置情况,在Xcode中打开Resources分组下的arts文件夹,并展开其中的项目,如图3-7所示。
由图3-7可以看出,加载的音效是bullet.mp3,精灵表单为gameArts.plist,背景音乐是game_music.mp3。
注意 本项目比较简单,资源数量有限,完成加载场景基本体会不到加载的过程,因为速度实在是太快了。
接下来我们看看后面的代码。
if (soundFX)
[selfperformSelectorOnMainThread:@selector(loadSounds:)
withObject:soundFXwaitUntilDone:YES];
if (spriteSheets)
[selfperformSelectorOnMainThread:@selector(loadSpriteSheets:)
withObject:spriteSheets waitUntilDone:YES];
if (images)
[self performSelectorOnMainThread:@selector(loadImages:)
withObject:images waitUntilDone:YES];
if (music)
[self performSelectorOnMainThread:@selector(loadMusic:)
withObject:music waitUntilDone:YES];
if (assets)
[self performSelectorOnMainThread:@selector(loadAssets:)
withObject:assets waitUntilDone:YES];
这段代码并不复杂,调用performSelectorOnMainThread在主线程上依次加载每种类型的游戏资源,同时waitUntilDone的值为YES能保证所有的资源按照序列依次加载。
其他方法这里就不一一介绍了,注意几个方法。比如:
预先加载音效:[[SimpleAudioEngine sharedEngine] preloadEffect:soundClip]
预先加载背景音乐:[[SimpleAudioEngine sharedEngine] preloadBackgroundMusic:music]
预先加载PNG图片:[[CCTextureCache sharedTextureCache] addImage:image]
预先加载精灵表单可以使用下面的方法:
[[CCSpriteFrameCache sharedSpriteFrameCache] addSpriteFramesWithFile:spriteSheet];
这个方法首先会加载与该plist方法名称相同但后缀为.png的纹理图片,把该plist的所有spirteFrame信息读取出来,以后可以通过spriteFrameWithName获取相应的精灵帧。
(4)progressUpdate和loadingComplete方法
目前,progressUpdate方法非常简单,只是更新资源的总数,当资源全部加载完毕时会调用loadingComplete方法。loadingComplete方法的作用是:过2秒之后运行一个场景切换特效跳转到游戏主场景,即HelloWorldLayer。
3.8.3 修改AppDelegate.m文件
1)添加所包含的头文件。在文件的顶部添加代码:
#import "LoadingScreen.h"
2)修改- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法。
在该方法最后,把之前的pushScene方法改成下面代码:
[director_ pushScene: [LoadingScreen scene]];
编译并运行游戏,首先会看到一个Loading界面,再进入游戏场景。但是我们希望在场景跳转之后,玩家可以看到有一个开始游戏的按钮,只有当玩家选择“开始游戏”时,敌机才出现,游戏才正式开始。因此这里需要对HelloWorldLayer的实现做一些修改。
3.8.4 修改HelloWorldLayer
1)打开HelloWorldLayer.h文件,在其中添加下列变量:
CCMenu *_startGameMenu;
BOOL _isGameStarted;
2)打开HelloWorldLayer.m的init方法,添加如代码清单3-10所示代码。
代码清单3-10 在init方法中添加代码
//15.add game start menu & relative game logic
_isGameStarted = NO;
[CCMenuItemFont setFontSize:20];
[CCMenuItemFont setFontName:@"Arial"];
CCMenuItemFont *startItem = [CCMenuItemFont itemWithString:@"开始游戏" block:^(id sender)
{
_isGameStarted = YES;
CCMenuItem *item = (CCMenuItemFont*)sender;
item.visible = NO;
//6.spawn enemy after 1.0 sec
[self performSelector:@selector(spawnEnemy)
withObject:nil
afterDelay:1.0f];
//7.enable accelerometer
self.isAccelerometerEnabled = YES;
//9.enable touch
self.isTouchEnabled = YES;
}];
startItem.position = ccp(winSize.width / 2, winSize.height / 2);
_startGameMenu = [CCMenu menuWithItems:startItem, nil];
_startGameMenu.position = CGPointZero;
[self addChild:_startGameMenu];
首先注意,这里把之前的代码清单2-11、2-14和2-21的部分代码移到block方法的内部。这里还需要注意,因为只有当玩家触碰“开始游戏”按钮之后,才会正式允许玩家控制飞机的飞行以及发射子弹,所以一定要把Menu的position设置为CGPointZero。
此时编译并运行,加载场景过程效果如图3-8所示,加载完资源以后的游戏画面如3-9所示。
注意 这里所使用的菜单是最简陋的一种,是直接使用系统内置字体创建的。当然,大家还可以通过label、sprite创建更加丰富多彩的按钮,可以参考Cocos2D自带的MenuTest项目。
3.8.5 代码重构
接下来要解决本章最重要的代码重构问题,使用SpriteFrameCache和SpriteFrame初始化精灵,这里用精灵表单来优化游戏性能。
1 . 添加精灵表单
在init方法的最上面添加代码:
CCSpriteBatchNode *batchNode = [CCSpriteBatchNode batchNodeWithFile:@"gameArts.png"];
batchNode.position = CGPointZero;
[self addChild:batchNode z:0 tag:kTagBatchNode];
这里给spriteBatchNode添加一个新的tag,所以需要在HelloWorldLayer.m文件最上方定义这个枚举常量:
enum {
kTagPalyer = 1,
kTagBatchNode = 2,
};
2 . 修改sprite的初始化方式
把spriteWithFile改写成spriteWithSpriteFrameName,同时把sprite加到batchNode中而不是加到layer里。这样做的好处是减少opengl call的次数,提高游戏渲染性能。具体如代码清单3-11所示。
代码清单3-11 使用精灵表单减少opengl call的次数
//2.add background
CCSprite *bgSprite = [CCSprite spriteWithSpriteFrameName:@"background_1.jpg"];
bgSprite.position = ccp(winSize.width / 2,winSize.height/2);
[batchNode addChild:bgSprite z:-100];
//3.add player's plane
CCSprite *playerSprite = [CCSprite spriteWithSpriteFrameName:@"hero_1.png"];
playerSprite.position = CGPointMake(winSize.width / 2, playerSprite.contentSize.height/2 + 20);
[batchNode addChild:playerSprite z:4 tag:kTagPalyer];
//5.initialize 10 enemy sprites & add them to _enemySprites array for future useage
const int NUM_OF_ENEMIES = 10;
for (int i=0; i < NUM_OF_ENEMIES; ++i) {
CCSprite *enemySprite = [CCSprite spriteWithSpriteFrameName:@"enemy1.png"];
enemySprite.position = ccp(0,winSize.height + enemySprite.contentSize.height + 10);
enemySprite.visible = NO;
[batchNode addChild:enemySprite z:4];
[_enemySprites addObject:enemySprite];
}
//10.init bullets
_bulletSprite = [CCSprite spriteWithSpriteFrameName:@"bullet1.png"];
_bulletSprite.visible = NO;
[batchNode addChild:_bulletSprite z:4];
此时运行代码,我们会发现有点问题,游戏行为变得有些古怪,因为之前操作playerSprite时是通过[self getChildByTag:kTagPlayer]来获取玩家精灵的。现在我们要改成[batchNode getChildByTag:kTagPlayer]。
3 . 定义-(CCSprite*) getPlayerSprite方法
为了减少重复修改,这里需要定义一个新方法-(CCSprite*) getPlayerSprite,其实现如代码清单3-12所示。
代码清单3-12 -(CCSprite*) getPlayerSprite实现
-(CCSprite*)getPlayerSprite{
CCSpriteBatchNode *batchNode = (CCSpriteBatchNode*)[self getChildByTag:kTagBatchNode];
return (CCSprite*)[batchNode getChildByTag:kTagPalyer];
}
要理解上述代码,我们的头脑中需要有一个node的层级关系图,首先通过当前layer找到batchNode,然后通过batchNode找到playerSprite。
此时还需要稍微修改其他内容来获得playerSprite的方法,修改以下代码:
CCSprite *playerSprite = (CCSprite)[self getChildByTag:kTagPlayer];
将其改写成:
CCSprite *playerSprite = [self getPlayerSprite];
共有三处需要修改,大家可以参考随书附赠的源码,这里就不再赘述了。
4 . 修改发射子弹的碰撞检测算法
另外单击玩家发射子弹的碰撞检测算法也要进行修改,如代码清单3-13所示。
代码清单3-13 修改单击玩家就发射子弹的碰撞检测算法
-(void) ccTouchesEnded:(NSSet *)touches withEvent:(UIEvent *)event{
//修改成,必须选中playerSprite才能够发射子弹
UITouch *touch = [touches anyObject];
CCSprite *playerSprite = [self getPlayerSprite];
CGPoint pt;
//简化为下面的一句代码调用
CCSpriteBatchNode *batchNode = (CCSpriteBatchNode*)[self getChildByTag: kTagBatchNode];
pt = [batchNode convertTouchToNodeSpace:touch];
if (CGRectContainsPoint(playerSprite.boundingBox, pt)) {
_isTouchToShoot = YES;
CCLOG(@"touched!");
}
}
相信通过这段代码大家能更清楚地认识到,bondingBox是相对于节点的父节点来计算的。如果要判断一个点是否在一个矩形区域内,首先要把它们都转化到同一个坐标系中,才能进行相应的判断。本例中把touch点和玩家飞机的矩形区域都转化到batchoNode的坐标空间中。
将-(void) updatePlayerPosition:(ccTime)dt方法中计算图片纹理宽度一半的语句:
float imageWidthHavled = playerSprite.texture.contentSize.width * 0.5f;
改成以下代码:
float imageWidthHavled = playerSprite.textureRect.size.width * 0.5f;
注意 谨慎使用sprite.texture.contentSize,只有当使用精灵图片文件初始化精灵时这种写法才有效,如果使用spriteBatchNode则会失效!!!
如果使用CCSpriteBatchNode创建精灵对象,就不能将精灵对象添加为当前层的子节点,而应添加为精灵表单的子节点。另外,以上方法是为了让大家对CCSprite相关类有更清晰的认识,在实际游戏开发中,并不需要使用上述这么复杂的步骤。
编译并运行到真机上面,好好享受学习的成果吧!