注:这是作为iOS 7 Feast的组成部分而发布的全新Sprite Kit教程。
在第1部分中,你创造了游戏的基础。到目前为止你已经在游戏中添加了侵略者,舰船和平视显示器(HUD)。你还编写了逻辑代码让侵略者可以自动移动并在倾斜设备时移动舰船。(
查看第1篇教程)
在第2部分也是最后一部分中,你将为舰船和外星人添加能力让它们相互开火!你同样也将添加音效和真实图像去替代彩色的矩形(注:即用于代表侵略者和舰船)而优化游戏。
现在让我们真正开始吧。
让你的舰船发射激光炮 首先,你需要思考自己想要的发射舰船激光炮的场景。你决定使用单发快射去发射大炮。但是你该如何检测这些单发快射?
你拥有两个选择: 1.UITapGestureRecognizer—-创造一个并将其附加到场景视图中。
2.touchesEnded:withEvent:—-你的场景是UIResponder的子类;因此你可以使用它直接检测碰触。
在这种情况下(touchesEnded:withEvent:),第二种方法是最佳选择。当你需要基于游戏中不同场景节点检测并处理碰触时,第一种方法会较为棘手,因为你只能够在场景视图中为UITapGestureRecognizer制定一个回调函数选择器。而其它方法则不适合放在这一例子中。
因为所有的SKNode节点(包括SKScene)都能通过touchesEnded:withEvent:直接处理碰触,第二种选择能够更自然地处理针对于节点的碰触—-当你开发的游戏带有更复杂发射处理时它将更有效。
既然你将在场景的touchesEnded:withEvent:方法中检测用户的发射,你该在这方法中做些什么?
发射可以在游戏过程中的任何一个点发生。与之相反的是你的场景将发生改变—-源自update:方法的独立间隔。所以在touchesEnded:withEvent:中任何时候你将如何储存快射检测,并在之后,也就是当被Sprite Kit游戏循环援用时对其进行加工。
答案是一个队列!你将使用一个简单的NSMutableArray在FIFO(先进先出)队列中储存你的快射。
在GameScene.m添加如下属性到类扩展中:
@property (strong) NSMutableArray* tapQueue;
现在将如下内容添加到didMoveToView:中,也就是在[self.motionManager startAccelerometerUpdates];后面:
self.tapQueue = [NSMutableArray array];
self.userInteractionEnabled = YES;
上述代码将快射队列初始化为一个空白数组,并确保用户互动适用于场景中,从而让它能够接收快射事件。
现在在#pragma mark – User Tap Helpers之后添加如下代码:
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
// Intentional no-op
}
-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
// Intentional no-op
}
-(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
// Intentional no-op
}
-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch* touch = [touches anyObject];
if (touch.tapCount == 1) [self.tapQueue addObject:@1];
}
前三个问题只是无意义的存根;之所以会添加它们是因为当你没有调用super而推翻touchesEnded:withEvent:时,苹果会建议你这么做。
touchesEnded:withEvent:方法本身非常简单。它只添加了一个条目到队列中。你不需要定制类在队列中储存快射,因为你所需要做的便是明确快射的出现。因此,你可以使用任何早旧的对象。在此,你可以将整数1作为当一快射的记忆(@1是全新的对象-文字语言,即将把文字1转变为一个NSNumber对象)。
因为你知道侵略者最终将向你的舰船发射子弹,所以在文件最上方的Custom Type Definitions上添加如下代码:
typedef enum BulletType {
ShipFiredBulletType,
InvaderFiredBulletType
} BulletType;
你将使用BulletType面向入侵者和你的舰船分享同样的子弹代码。这会于你和入侵者在同一个弹药商店中消费时出现!
接下来在#define kHealthHudName @”healthHud”下方添加如下代码:
#define kShipFiredBulletName @”shipFiredBullet”
#define kInvaderFiredBulletName @”invaderFiredBullet”
#define kBulletSize CGSizeMake(4, 8)
现在添加如下方法到Scene Setup和Content Creation中:
-(SKNode*)makeBulletOfType:(BulletType)bulletType {
SKNode* bullet;
switch (bulletType) {
case ShipFiredBulletType:
bullet = [SKSpriteNode spriteNodeWithColor:[SKColor greenColor] size:kBulletSize];
bullet.name = kShipFiredBulletName;
break;
case InvaderFiredBulletType:
bullet = [SKSpriteNode spriteNodeWithColor:[SKColor magentaColor] size:kBulletSize];
bullet.name = kInvaderFiredBulletName;
break;
default:
bullet = nil;
break;
}
return bullet;
}
该方法相对直接:它只是创造一个矩形且带有颜色的精灵去代表子弹,并设置了子弹的名字从而让你之后能在场景中找到它。
现在,添加如下方法到#pragma mark – Bullet Helpers中:
-(void)fireBullet:(SKNode*)bullet toDestination:(CGPoint)destination withDuration:(NSTimeInterval)duration soundFileName:(NSString*)soundFileName {
//1
SKAction* bulletAction = [SKAction sequence:@[[SKAction moveTo:destination duration:duration],
[SKAction waitForDuration:3.0/60.0],
[SKAction removeFromParent]]];
//2
SKAction* soundAction = [SKAction playSoundFileNamed:soundFileName waitForCompletion:YES];
//3
[bullet runAction:[SKAction group:@[bulletAction, soundAction]]];
//4
[self addChild:bullet];
}
-(void)fireShipBullets {
SKNode* existingBullet = [self childNodeWithName:kShipFiredBulletName];
//1
if (!existingBullet) {
SKNode* ship = [self childNodeWithName:kShipName];
SKNode* bullet = [self makeBulletOfType:ShipFiredBulletType];
//2
bullet.position = CGPointMake(ship.position.x, ship.position.y + ship.frame.size.height – bullet.frame.size.height / 2);
//3
CGPoint bulletDestination = CGPointMake(ship.position.x, self.frame.size.height + bullet.frame.size.height / 2);
//4
[self fireBullet:bullet toDestination:bulletDestination withDuration:1.0 soundFileName:@"ShipBullet.wav"];
}
}
一步一步检查fireBullet:toDestination:withDuration:soundFileName:中的代码,你做了如下任务:
1.创造一个SKAction能够将子弹移动到目的地,然后将其从场景中删除。这一结果连续执行了个人行动—-下一步行动只会在之前行动完成后发生。因此子弹只会在被移走时才被删除。
2.播放设定好的音效去传达子弹的发射。所有音效都包含在开始项目中,iOS知道如何找到并加载它们。
3.通过将子弹和音效放置在同一个群组中而同时移动子弹并播放音效。群组将平行运行它的行动,而非按照顺序。
4.通过添加子弹到场景中而进行发射。这让它能够出现在屏幕上并开始行动。
以下是你在fireShipBullets中所做的:
1.如果当前屏幕上并没有任何子弹,你便只能发射一枚子弹。这是一个激光炮,而不是激光机枪—-它需要花时间重新加载!
2.设置子弹的位置让它可以出现在舰船上方。
3.设置子弹的目的地,即在屏幕顶部以外。因为x坐标与子弹的位置是一样的,所以子弹将竖直飞射。
4.发射子弹!
在fireShipBullets中的第1点只允许同时发射一枚子弹是一种游戏决策,从技术上来看并非绝对需要。如果舰船可以每分钟发射数千枚子弹,《太空入侵者》未免就太过简单了。你的游戏的部分乐趣在于广泛地选择设计,并找准时机与入侵者相碰撞。
你的激光炮已经准备好发射了!
添加如下代码到Scene Update Helpers中:
-(void)processUserTapsForUpdate:(NSTimeInterval)currentTime {
//1
for (NSNumber* tapCount in [self.tapQueue copy]) {
if ([tapCount unsignedIntegerValue] == 1) {
//2
[self fireShipBullets];
}
//3
[self.tapQueue removeObject:tapCount];
}
}
让我们审视上述代码:
1.在你的tapQueue副本中循环;它必须是副本是因为在代码运行时你可能会修改最初的tapQueue,并在循环时修改数组。
2.如果队列条目是单发快射,处理它。作为开发者,你清楚地知道自己现在只能处理单发快射,而在之后防御双打快射(或其它行动)的可能性才是最佳行动。
3.从队列中删除快射。
注:processUserTapsForUpdate:在每次调用时完全耗尽了快射的队列。结合事实,如果屏幕上已经存在子弹,fireShipBullets将不会发射另外一枚子弹,空白的队列意味着额外或迅速的快射将会被忽视。只有第一次的快射才真正重要。
最后,在update:中添加如下代码作为第一行:
[self processUserTapsForUpdate:currentTime];
这在更新循环中调用了processUserTapsForUpdate: 并处理了任何用户快射。
创建你的游戏并运行!
Player-Bullets
创造入侵者的攻击 酷,你的舰船最终能够向任何邪恶的入侵者发射子弹了!你将尽快运行它们。
但是你可能已经注意到自己的子弹是直接穿过入侵者而不是炸毁它们。这是因为你的子弹还不足以检测到何时撞击入侵者。现在你需要修改这一点。
首先,你将通过添加如下代码到Scene Update Helpers中而让入侵者做出还击:
-(void)fireInvaderBulletsForUpdate:(NSTimeInterval)currentTime {
SKNode* existingBullet = [self childNodeWithName:kInvaderFiredBulletName];
//1
if (!existingBullet) {
//2
NSMutableArray* allInvaders = [NSMutableArray array];
[self enumerateChildNodesWithName:kInvaderName usingBlock:^(SKNode *node, BOOL *stop) {
[allInvaders addObject:node];
}];
if ([allInvaders count] > 0) {
//3
NSUInteger allInvadersIndex = arc4random_uniform([allInvaders count]);
SKNode* invader = [allInvaders objectAtIndex:allInvadersIndex];
//4
SKNode* bullet = [self makeBulletOfType:InvaderFiredBulletType];
bullet.position = CGPointMake(invader.position.x, invader.position.y – invader.frame.size.height/2 + bullet.frame.size.height / 2);
//5
CGPoint bulletDestination = CGPointMake(invader.position.x, – bullet.frame.size.height / 2);
//6
[self fireBullet:bullet toDestination:bulletDestination withDuration:2.0 soundFileName:@"InvaderBullet.wav"];
}
}
}
上述方法的核心逻辑如下:
1.如果屏幕上没有子弹的话只能发射一枚子弹。
2.在屏幕上收集所有的入侵者。
3.随机选择一个入侵者。
4.创造一枚子弹,并从选定的入侵者下方进行发射。
5.子弹应该直下飞行,并从屏幕下方离开。
6.发射入侵者的子弹。
在update:最后添加如下内容:
[self fireInvaderBulletsForUpdate:currentTime];
关于fireInvaderBulletsForUpdate:的调用开始让入侵者向你展开反击。
创建并运行游戏,你可以看到入侵者朝你的舰船发射紫色子弹,就如下图所示:
Enemy-Bullets
在游戏设计中你会注意到入侵者的子弹是紫色的,而你的舰船的子弹则是绿色的。这一强烈的颜色反差让我们能在激烈的战斗中更清楚地进行辨明。同样的,你也会在入侵者与自己的舰船相互攻击时听到不同的声音。不同音效的使用在某种程度上是基于不同风格,将赋予游戏更丰富的音频并让它更具有吸引力。并且这同时也涉及到了可及性的问题,即有7%至10%的男性以及0.5%至1%的女性是色盲。所以不同的音效将帮助这些人更轻松地玩游戏。
检测子弹何时撞击它们的目标 虽然现在子弹能够在屏幕上飞射着,但是却没有任何对象会因此被炸毁!这是因为你的游戏还没有撞击检测。它需要在舰船的子弹撞击入侵者,以及入侵者的子弹撞击到舰船时进行检测。
你可以手动完成这一设置,在每个update:调用中比较子弹/入侵者/舰船位置,并检查撞击。但是为什么不让Sprite Kit帮你做这些事?
因为你已经使用了物理主体,而Sprite Kit的物理引擎只能在一个主体撞击另一个主体时才能进行检测。对此,你将使用碰触检测—-而不是碰撞检测。你并不是在使用物理元素去移动子弹或入侵者,所以你不会对他们之间的物理碰撞感兴趣。碰触检测只会在一个物理主体覆盖了另一个主体(从空间上)时进行检测,否则它便不会移动或影响碰触到的实体。
有些游戏拥有许多不同的物理主体类型,并且不会对所有物理主体类型之间的碰触感兴趣。Sprite Kit只会检测你所命令的物理主体类别之间的碰触。
这既是一种速度优化也是一种正确性的约束,即某种碰触类型也许并不符合人们的期望。你可以通过定义类别位掩码控制那些物理主体是用于检测碰触开始。
添加如下代码到Custom Type Definitions中:
static const u_int32_t kInvaderCategory = 0×1 << 0;
static const u_int32_t kShipFiredBulletCategory = 0×1 << 1;
static const u_int32_t kShipCategory = 0×1 << 2;
static const u_int32_t kSceneEdgeCategory = 0×1 << 3;
static const u_int32_t kInvaderFiredBulletCategory = 0×1 << 4;
这些看似奇怪的常量是位掩码。位掩码是填充变量到一个单独的32位体无符号整数的方法。当作为u_int32_t进行储存时,位掩码可以拥有32个不同值。在这5个类别中每个类别会定义一种物理实体的类型。注意在每个例子中<< operator 右边的数字是如何不同的,这将保证每个位掩码是独特的且区分于其它代码。
添加如下代码到[self setupInvaders];之前:
self.physicsBody.categoryBitMask = kSceneEdgeCategory;
这一新代码为你的场景的物理实体设置了类别。
添加如下代码到makeShip(也就是在return ship;前面)为你的舰船设置类别:
//1
ship.physicsBody.categoryBitMask = kShipCategory;
//2
ship.physicsBody.contactTestBitMask = 0×0;
//3
ship.physicsBody.collisionBitMask = kSceneEdgeCategory;
以下是对于上述代码的分析:
1.设置舰船的类别
2.不检测舰船和其它物理实体间的碰触
3.检测舰船和场景外缘间的碰撞
注:你不需要设置舰船的collisionBitMask,因为只有你的舰船和场景拥有物理实体。在这种情况下默认的“所有”collisionBitMask已经足够了。因为你将添加物理实体到入侵者中,所以准确设置舰船的collisionBitMask将保证你的舰船只会与场景的边缘发生碰撞而不会与入侵者相撞。
当你做到这些时,你应该为入侵者设置类别,因为这将帮助你检测舰船的子弹与入侵者之间的碰撞。
添加如下代码到makeInvaderOfType:之后,也就是return invader;之前:
invader.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:invader.frame.size];
invader.physicsBody.dynamic = NO;
invader.physicsBody.categoryBitMask = kInvaderCategory;
invader.physicsBody.contactTestBitMask = 0×0;
invader.physicsBody.collisionBitMask = 0×0;
上述代码也明确了舰船发射的子弹,并让Sprite Kit检测舰船发射的子弹与入侵者间的碰触,但是碰撞却应该被忽视。
关照了舰船的子弹后,现在需要转向入侵者的子弹了!
在makeBulletOfType:中添加如下代码到第二个case声明最后面,即break前面:
bullet.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:bullet.frame.size];
bullet.physicsBody.dynamic = YES;
bullet.physicsBody.affectedByGravity = NO;
bullet.physicsBody.categoryBitMask = kInvaderFiredBulletCategory;
bullet.physicsBody.contactTestBitMask = kShipCategory;
bullet.physicsBody.collisionBitMask = 0×0;
该代码与之前的组块相似:它同样也定义了入侵者发射的子弹,并让Sprite Kit去检查入侵者发射的子弹与你的舰船之间的碰触,并且再一次忽视了碰撞元素。
注:为了让碰触检测能够运行,你必须通过设置bullet.physicsBody.dynamic = YES将舰船发射的子弹定义为动态。如果不这么做,Sprite Kit便不会检测这些子弹间的碰触,并且静态入侵者的定义变为invader.physicsBody.dynamic = NO。入侵者之所以是静态的是因为它们并不受物理引擎的影响而移动。Sprite Kit不会检测两个静态实体间的碰触,所以如果你需要检测两个物理实体类别间的碰触,至少一个类别必须具有动态物理实体。
你也许想要知道为什么contactTestBitMask值是对称的。举个例子来说吧,为什么你设置了一个入侵者的contactTestBitMask=0×0但是一个舰船发射的子弹为contactTestBitMask = kInvaderCategory?
原因在于当Sprite Kit检测任何两个物理实体A和B间的碰触时,只有其中一个实体需要宣称它应该测试与其它实体间的碰触,而不是双方。只要A宣称它可以与B进行碰触,或B宣称它能与A相碰触,那么这种碰触便是可检测得到的。我们不需要两个实体同时宣称,它们应该测试与彼此间的碰触。
基于一种实体类型设置contactTestBitMask。你可能更想基于两种实体类型设置contactTestBitMask值,这也没关系,只要你能选择其中一种方法去完成便可。
基于这些改变,你的游戏物理引擎将检测到舰船发射的子弹与入侵者间的碰触,以及入侵者发射的子弹与你的舰船间的碰触。但是物理引擎将如何通知你的游戏这些碰触?
答案便是使用SKPhysicsContactDelegate。
执行物理碰触委托方法 打开GameScene.h并基于如下代码修改@interface:
@interface GameScene : SKScene <SKPhysicsContactDelegate>
这宣称了你的场景将作为物理引擎的委托代表。SKPhysicsContactDelegate的didBeginContact:方法会在每次两个物理实体发生碰触时执行,即基于你是如何设置物理实体的categoryBitMask和contactTestBitMask。一会后你将执行didBeginContact:。
就像快射那样,碰触可以随时发生。所以didBeginContact:可以随时执行。但是按照你的离散时间信号,你只能在update:被调用的时候处理碰触。所以,就像快射那样,你将创造队列去储存碰触,直到它们可以通过update:获得处理。
回到GameScene.m并添加如下新属性到类扩展中:
@property (strong) NSMutableArray* contactQueue;
现在添加如下代码到didMoveToView:的最后,也就是在self.userInteractionEnabled = YES;行后面:
self.contactQueue = [NSMutableArray array];
self.physicsWorld.contactDelegate = self;
这只是初始化了一个空白的碰触队列,并将场景设置为物理引擎的碰触委托。
接下来添加如下方法到#pragma mark – Physics Contact Helpers中:
-(void)didBeginContact:(SKPhysicsContact *)contact {
[self.contactQueue addObject:contact];
}
该方法在你的碰触队列中简单记录了碰触,让你能在之后update:执行时处理。
在同样的位置上添加如下方法:
-(void)handleContact:(SKPhysicsContact*)contact {
//1
// Ensure you haven’t already handled this contact and removed its nodes
if (!contact.bodyA.node.parent || !contact.bodyB.node.parent) return;
NSArray* nodeNames = @[contact.bodyA.node.name, contact.bodyB.node.name];
if ([nodeNames containsObject:kShipName] && [nodeNames containsObject:kInvaderFiredBulletName]) {
//2
// Invader bullet hit a ship
[self runAction:[SKAction playSoundFileNamed:@"ShipHit.wav" waitForCompletion:NO]];
[contact.bodyA.node removeFromParent];
[contact.bodyB.node removeFromParent];
} else if ([nodeNames containsObject:kInvaderName] && [nodeNames containsObject:kShipFiredBulletName]) {
//3
// Ship bullet hit an invader
[self runAction:[SKAction playSoundFileNamed:@"InvaderHit.wav" waitForCompletion:NO]];
[contact.bodyA.node removeFromParent];
[contact.bodyB.node removeFromParent];
}
}
该代码相对直接,以下是相关解释:
1.不允许同样的碰触出现两次。
2.如果一个入侵者子弹撞击了你的舰船,将你的舰船和子弹从场景中删除,并播放一个音效。
3.如果舰船撞击了一个入侵者,将入侵者和子弹从场景中删除并播放一个不同的音效。
添加如下代码到#pragma mark – Scene Update Helpers:
-(void)processContactsForUpdate:(NSTimeInterval)currentTime {
for (SKPhysicsContact* contact in [self.contactQueue copy]) {
[self handleContact:contact];
[self.contactQueue removeObject:contact];
}
}
上处代码只是关于碰触队列,面向队列中的每个碰触调用handleContact: 。
添加如下代码到update:最上方去调用你的队列处理器:
[self processContactsForUpdate:currentTime];
创建并运行你的应用,并开始向入侵者发射子弹!
Exchange-Fire
现在,当你的舰船子弹撞击了一个入侵者时,入侵者会从场景中小时,并出现爆炸声。相反地,当入侵者子弹撞击了你的舰船,代码便会将你的舰船从场景中删除,并且也会出现不同的爆炸声。
基于你的相关技能,你可能需要运行几次才能看到入侵者和舰船被摧毁。击中Command R并再次运行。
更新你的视图显示器(HUD) 你的游戏看起来不错,但是它还缺少一些东西。即游戏中没有足够的戏剧性张力。如果你未得到奖励,那么使用子弹撞击入侵者的优势是什么?如果没有惩罚,那么被入侵者撞击到的劣势又是什么?
你可以给予使用舰船子弹撞击入侵者的行为分数点作为奖励,并在玩家被入侵者的子弹击中时减少他们舰船的生命值。
添加如下属性到类扩展中:
@property NSUInteger score;
@property CGFloat shipHealth;
你的舰船的生命值将从100%开始,但是你将按照0到1的范围去储存它。
添加如下代码到setupShip:
self.shipHealth = 1.0f;
上述代码设置了你的舰船初始生命值。
现在你可以在setupHud将如下行:
healthLabel.text = [NSString stringWithFormat:@"Health: %.1f%%", 100.0f];
换成:
healthLabel.text = [NSString stringWithFormat:@"Health: %.1f%%", self.shipHealth * 100.0f];
新一行代码基于你的舰船的实际生命值(而不是100的静态值)设置了初始HUD文本。
接下来添加如下两个方法到#pragma mark – HUD Helpers:
-(void)adjustScoreBy:(NSUInteger)points {
self.score += points;
SKLabelNode* score = (SKLabelNode*)[self childNodeWithName:kScoreHudName];
score.text = [NSString stringWithFormat:@"Score: %04u", self.score];
}
-(void)adjustShipHealthBy:(CGFloat)healthAdjustment {
//1
self.shipHealth = MAX(self.shipHealth + healthAdjustment, 0);
SKLabelNode* health = (SKLabelNode*)[self childNodeWithName:kHealthHudName];
health.text = [NSString stringWithFormat:@"Health: %.1f%%", self.shipHealth * 100];
}
这些方法非常直接:更新分数和分数标签,更新舰船的生命值和生命值标签。第1点仅仅保证了舰船的生命值不会趋于负数。
最后一步是在游戏过程中的适当时间调用这些方法。用如下更新方案替换handleContact::
-(void)handleContact:(SKPhysicsContact*)contact {
// Ensure you haven’t already handled this contact and removed its nodes
if (!contact.bodyA.node.parent || !contact.bodyB.node.parent) return;
NSArray* nodeNames = @[contact.bodyA.node.name, contact.bodyB.node.name];
if ([nodeNames containsObject:kShipName] && [nodeNames containsObject:kInvaderFiredBulletName]) {
// Invader bullet hit a ship
[self runAction:[SKAction playSoundFileNamed:@"ShipHit.wav" waitForCompletion:NO]];
//1
[self adjustShipHealthBy:-0.334f];
if (self.shipHealth <= 0.0f) {
//2
[contact.bodyA.node removeFromParent];
[contact.bodyB.node removeFromParent];
} else {
//3
SKNode* ship = [self childNodeWithName:kShipName];
ship.alpha = self.shipHealth;
if (contact.bodyA.node == ship) [contact.bodyB.node removeFromParent];
else [contact.bodyA.node removeFromParent];
}
} else if ([nodeNames containsObject:kInvaderName] && [nodeNames containsObject:kShipFiredBulletName]) {
// Ship bullet hit an invader
[self runAction:[SKAction playSoundFileNamed:@"InvaderHit.wav" waitForCompletion:NO]];
[contact.bodyA.node removeFromParent];
[contact.bodyB.node removeFromParent];
//4
[self adjustScoreBy:100];
}
}
以下是方法中所发生的改变:
1.当舰船被入侵者的子弹击中时调整它的生命值。
2.如果舰船的生命值为0,将舰船和入侵者的子弹从场景中删掉。
3.如果舰船的生命值大于0,只删除入侵者的子弹。稍微模糊舰船的精灵以暗示破坏的发生。
4.当入侵者遭到撞击时,在分数上添加100个点。
上述内容同样也解释了为什么你将舰船的生命值储存为0至1的数值,尽管你的生命值是从100开始。因为阿尔法值的范围是0至1,你可以使用舰船的生命值作为舰船的阿尔法值去代表累进破坏。这很方便!
再次创建并运行你的游戏;你将看到当子弹撞击一个入侵者时分数会发生改变;同时你也应该看到当舰船遭遇撞击时,它的生命值也会改变,如下图所示:
Scores-Updating
优化你的入侵者和舰船图像 你已经耐着性子去面对这些红,绿,蓝和洋红色的矩形。保持视觉效果的简单化非常有效,因为它让你能够专注于获得正确的逻辑。
现在你将添加一些实际的图像精灵去创造更加现实化的游戏—-并让它变得更有趣!
用如下代码换掉makeInvaderOfType::
-(NSArray*)loadInvaderTexturesOfType:(InvaderType)invaderType {
NSString* prefix;
switch (invaderType) {
case InvaderTypeA:
prefix = @”InvaderA”;
break;
case InvaderTypeB:
prefix = @”InvaderB”;
break;
case InvaderTypeC:
default:
prefix = @”InvaderC”;
break;
}
//1
return @[[SKTexture textureWithImageNamed:[NSString stringWithFormat:@"%@_00.png", prefix]],
[SKTexture textureWithImageNamed:[NSString stringWithFormat:@"%@_01.png", prefix]]];
}
-(SKNode*)makeInvaderOfType:(InvaderType)invaderType {
NSArray* invaderTextures = [self loadInvaderTexturesOfType:invaderType];
//2
SKSpriteNode* invader = [SKSpriteNode spriteNodeWithTexture:[invaderTextures firstObject]];
invader.name = kInvaderName;
//3
[invader runAction:[SKAction repeatActionForever:[SKAction animateWithTextures:invaderTextures timePerFrame:self.timePerMove]]];
invader.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:invader.frame.size];
invader.physicsBody.dynamic = NO;
invader.physicsBody.categoryBitMask = kInvaderCategory;
invader.physicsBody.contactTestBitMask = 0×0;
invader.physicsBody.collisionBitMask = 0×0;
return invader;
}
以下是新代码的作用:
1.面向每个入侵者类型加载一对精灵图像—InvaderA_00.png和InvaderA_01.png ,并通过它们创造SKTexture对象。
2.使用像纹理等作为精灵的基础图像。
3.在连续的动画循环中赋予这两个图像活力。
所有的图像都包含于开始的项目中,iOS知道如何找到并加载它们,所以在这里你不需要做其它事了。
创建并运行你的应用;你应该看到一些类似如下截图的画面:
AppScreenshot_InvaderSprites
看起来很酷吧!接下来你需要将块状的绿色舰船换成较现代的版本。
用如下代码替换makeShip:
-(SKNode*)makeShip {
//1
SKSpriteNode* ship = [SKSpriteNode spriteNodeWithImageNamed:@"Ship.png"];
ship.name = kShipName;
//2
ship.color = [UIColor greenColor];
ship.colorBlendFactor = 1.0f;
ship.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:ship.frame.size];
ship.physicsBody.dynamic = YES;
ship.physicsBody.affectedByGravity = NO;
ship.physicsBody.mass = 0.02;
ship.physicsBody.categoryBitMask = kShipCategory;
ship.physicsBody.contactTestBitMask = 0×0;
ship.physicsBody.collisionBitMask = kSceneEdgeCategory;
return ship;
}
这一代码看起来有点不同。
1.你的舰船精灵是根据图像所构思的。
2.最初舰船图像是白色的,就像入侵者的图像。但是代码设置了精灵颜色让图像变为绿色。这有效地将绿色与精灵图像混合在一起。
创建并运行你的游戏;你将看到证实的绿色舰船如下图那样出现:
official-looking green ship
玩一会你的游戏——你注意到什么?尽管你可以炸毁入侵者,但是却不存在明确的胜利或失败。这并不像太空之战,对吧?
执行最后的游戏 思考你的游戏该如何结束。怎样的情况将引出游戏的结局?
你的舰船的生命值降至0。
你破坏了所有入侵者。
入侵者离地球太近。
现在你将为上述的每个情境添加检测。
首先添加如下代码到#pragma mark – Custom Type Definitions中,在kShipSize:的定义下方:
#define kMinInvaderBottomHeight 2 * kShipSize.height
上述代码定义了入侵者入侵地球的最高点。
接下来添加如下输入内容到#import,即在文件最上方:
#import “GameOverScene.h”
上述代码所输入的名为GameOverScene的场景开头已经呈现在开始项目中。
接下来添加如下新属性到类扩展中:
@property BOOL gameEnding;
这设置了各种游戏结束场景。
现在添加如下两个方法到#pragma mark – Game End Helpers:
-(BOOL)isGameOver {
//1
SKNode* invader = [self childNodeWithName:kInvaderName];
//2
__block BOOL invaderTooLow = NO;
[self enumerateChildNodesWithName:kInvaderName usingBlock:^(SKNode *node, BOOL *stop) {
if (CGRectGetMinY(node.frame) <= kMinInvaderBottomHeight) {
invaderTooLow = YES;
*stop = YES;
}
}];
//3
SKNode* ship = [self childNodeWithName:kShipName];
//4
return !invader || invaderTooLow || !ship;
}
-(void)endGame {
//1
if (!self.gameEnding) {
self.gameEnding = YES;
//2
[self.motionManager stopAccelerometerUpdates];
//3
GameOverScene* gameOverScene = [[GameOverScene alloc] initWithSize:self.size];
[self.view presentScene:gameOverScene transition:[SKTransition doorsOpenHorizontalWithDuration:1.0]];
}
}
以下是发生在第一个方法里的情况,即检测游戏是否结束了:
1.获得所有留在场景中的入侵者。
2.循环访问入侵者以检测是否有任何入侵者的高度太低了。
3.为你的舰船获得一个指示器:如果舰船的生命值降至0,玩家便被当成死亡,玩家的舰船将被从场景中删除。在这种情况下,你将获得一个nil值指代场景中没有任何玩家舰船。
4.不管你的游戏是否结束,如果不再有入侵者会出现,或者一名入侵者太低了,或者你的舰船被摧毁了,那么游戏便算是结束了。
第二个方法才真正结束了游戏,并呈现了游戏结束的场景。
1.一次结束游戏。否则你将多次尝试在场景中呈现游戏,而这将是绝对的漏洞。
2.停止加速计的更新。
3.呈现GameOverScene。你可以检测GameOverScene.m的细节,但这是一个带有简单的“游戏结束”信息的基本场景。如果你轻敲它的画场景将开始另一次游戏。
在update中添加如下代码作为代码的第一行内容:
if ([self isGameOver]) [self endGame];
上述内容是为了在每次场景更新时检查游戏是否结束。如果游戏结束了,它便会呈现出游戏结束的场景。
创建并运行,炸毁入侵者直至游戏结束。希望你能在入侵者摧毁你时抢占先机摧毁对方。一旦你的游戏结束,你就会看到类似下图的场景:
AppScreenshot_GameOver
轻敲游戏结束场景,你便能够再次游戏!
最后:优化和精确 游戏开发的真实性在于,剩下20%的工作将花费与之前80%的工作同样的时间。当你正致力于自己的下一款游戏时,你最好能够开始快速迭代低保真度的图像资产(游戏邦注:如彩色矩形),如此你便能够快速明确游戏是否足够有趣。
如果基于彩色矩形的游戏并不有趣,那么即使拥有花俏的图像也不可能有趣!你应该先明确游戏玩法和游戏逻辑,然后带着花俏的图像资产与吸引人的音效开始创造。
话虽这么说,在面向App Store发行前优化游戏真的非常必要。App Store是一个拥挤的市场,只有真正的优化才能让你的应用突显于竞争中。尝试着添加一些小动画,故事情节和少许的可爱元素,这将更有效地吸引用户。同样地,如果你所创造的是经典游戏,那就考虑忠实于游戏。
如果你是《太空入侵者》的粉丝,你便会知道自己的游戏再制错过了一个重要元素。在最初游戏中,当入侵者前进速度越快,他们便会越逼近屏幕底部。
这是早前CPU用于运行最初《太空入侵者》游戏时所遇到的情况—-游戏循环速度越快,出现的入侵者数量便会越少,因为每个循环过程将没有多少事做。最终游戏程序员Tomohiro Nishikado决定将这一行为留在游戏中作为一个挑战机制。
你将更新游戏去整合这一游戏机制而迎合怀旧游戏纯粹主义者的需求。
添加如下方法到#pragma mark – Invader Movement Helpers中:
-(void)adjustInvaderMovementToTimePerMove:(NSTimeInterval)newTimePerMove {
//1
if (newTimePerMove <= 0) return;
//2
double ratio = self.timePerMove / newTimePerMove;
self.timePerMove = newTimePerMove;
[self enumerateChildNodesWithName:kInvaderName usingBlock:^(SKNode *node, BOOL *stop) {
//3
node.speed = node.speed * ratio;
}];
}
让我们检查这一代码:
1.忽视假的数值——少于或等于0的值意味着极快或极慢的移动,这并没有任何意义。
2.设置场景的timePerMove为给定值。这将在moveInvadersForUpdate:中加速入侵者的移动。记录改变的比率从而让你能够相应调整节点的速度。
3.加速入侵者的动画,从而让动画能够更快地在两帧间循环。比率将确保如果每次移动的新时间为旧时间的1/3,那么新动画的速度便是旧动画速度的3倍。设置节点速度以确保所有行动能够快速运行,包括精灵帧之间的动画行动。
现在你需要想办法唤醒这一新方法。
如下修改determineInvaderMovementDirection:
…
case InvaderMovementDirectionDownThenLeft:
proposedMovementDirection = InvaderMovementDirectionLeft;
// Add the following line
[self adjustInvaderMovementToTimePerMove:self.timePerMove * 0.8];
*stop = YES;
break;
case InvaderMovementDirectionDownThenRight:
proposedMovementDirection = InvaderMovementDirectionRight;
// Add the following line
[self adjustInvaderMovementToTimePerMove:self.timePerMove * 0.8];
…
新节点将减少20%入侵者每次移动时间。这将帮助他们提高25%的速度(游戏邦注:4/5的移动时间意味着5/4的移动速度)。
创建并运行你的游戏,并观看入侵者的移动;你将注意到这些入侵者在越靠近屏幕下方时移动速度便会越快:
Final-Screen
这是一个快捷的代码改变,即将使你的游戏变得更具挑战性且更加有趣。如果你将从入侵者手中拯救地球,你还是好好表现吧!花些时间去做出这样的细微调整是区别一款好游戏与出色游戏的关键。
接下来要怎么做? 以下是来自Sprite Kit教程的最后示例项目。
我鼓励你去实验自己的《SKInvaders》。玩游戏,做出调整并明确自己可以做什么!分析你的代码,做出修改,如果最终能够看到一个有趣的新功能的诞生,这便会成为许多游戏开发惊喜的一部分。
以下是关于如何调整游戏的相关理念:
在GameOverScene中添加胜利或失败信息
提示:添加属性到能够储存信息的GameOverScene。思考如何在场景中设置属性以及如何将其呈现在屏幕上。
在游戏中添加一个标题场景 提示:添加另一个SKScene子集(称为TitleScene),最初是由你的GameViewController呈现出来。轻敲这一场景将实现在你现有GameScene的转变。
当你的舰船连续使用3枚子弹射击入侵者时添加连胜奖励
提示:添加属性到你的GameScene去追踪射击vs错失:击中时增加,错失时重置为0。当玩家进行连续3次的射击时,呈现一个特殊的“STREAK!”动画并播放一个特别的音效。
当入侵者被子弹击中时为其设置动画 提示:着眼于handleContact:并思考你将如何使用SKAction行动序列去赋予被击中的入侵者动画。这可能会让你的游戏状态变得更复杂?你是否需要将这些入侵者标记为“死亡”如此在未来的碰触或记分时将不再考虑他们,并同时给予他们“死亡动画”,但是不会将其从场景中删除?
添加“boss”入侵者到游戏中,即只能够在屏幕上方呈现水平移动
提示:为这一入侵者的名字和类别添加新的常量。使用一个像素图像工具,如Pixen去绘制你自己的“Boss入侵者”。确保他足够邪恶!着眼于现有的代码并思考你需要在哪里添加代码去管理新的入侵者。
在场景中摆脱节点和FPS调试信息 提示:思考GameScene是何时并且在哪里被创造出来以及初始化的。
添加“玩家生命”功能去替代生命值
提示:现在,你的游戏呈现出了舰船生命值。相反地,让玩家在每次舰船遭遇袭击时失去一个“生命”。当你不再剩下任何舰船时游戏便算结束。你将如何在屏幕上呈现玩家生命?
添加计算机生成的画外音到游戏中 提示:在iOS7中使用一个新功能让你的iPhone传达出你所给予的任何NSString。
添加一个高分列表 提示:追踪玩家分数。在每次游戏后呈现给玩家高分列表。如果你想要进一步推动游戏的个性化,那就让它们能够基于每个分数输入首字母。只在某些区域执行这一方法,并避开Game Center。
在你的舰船和入侵者之间添加防卫盾牌 提示:在最初的游戏中,玩家的舰船上方存在“防卫盾牌”,即能够吸收入侵者的子弹。每个撞击盾牌的子弹将破坏部分盾牌。当太多子弹在同一个领域撞击盾牌时,它将塑造一个通道让子弹能够不受阻碍地穿越过去。这些盾牌是均匀分布的,之间都带有一些缝隙。玩家可以将自己的舰船隐藏在盾牌之下。
基于这一教程系列,你将通过创造一款非常酷的经典游戏而学习到有关Sprite Kit的一些新技巧。
现在你便可以享受新游戏的乐趣了。