SpriteKit實現(xiàn)經(jīng)典英雄打怪小游戲

寫在前面:

游戲開發(fā)菜鳥,本帥哥也是第一次研究SpriteKit,有很多都不懂,另外本文轉(zhuǎn)自王巍老師的博客點擊進(jìn)入目前正在學(xué)習(xí)中ing.....本文通俗易懂,看完絕對可以入門,當(dāng)然你得有OC基礎(chǔ)了,話不多說,直接往下看??

SpriteKit的加入絕對是iOS 7/OSX 10.9的SDK最大的亮點。從此以后官方SDK也可以方便地進(jìn)行游戲制作了。

如果你在看這篇帖子,那我估計你應(yīng)該稍微知道一些iOS平臺上2D游戲開發(fā)的東西,比如cocos2d,那很好,因為SpriteKit的很多概念其實和cocos2d非常類似,你應(yīng)該能很快掌握。如果上面這張圖你看著眼熟,或者自己動手實踐過,那更好,因為這篇文章的內(nèi)容就是通過使用SpriteKit來一步一步帶你重新實踐一遍這個經(jīng)典教程。如果你既不知道cocos2d,更沒有使用游戲引擎開發(fā)iOS游戲的經(jīng)驗,只是想一窺游戲開發(fā)的天地,那現(xiàn)在,SpriteKit將是一個非常好的入口,因為是iOS SDK自帶的框架,因此思想和用法上和現(xiàn)有的其他框架是統(tǒng)一的,這極大地降低了學(xué)習(xí)的難度和門檻。

什么是SpriteKit

首先要知道什么是SpriteSprite的中文譯名就是精靈,在游戲開發(fā)中,精靈指的是以圖像方式呈現(xiàn)在屏幕上的一個圖像。這個圖像也許可以移動,用戶可以與其交互,也有可能僅只是游戲的一個靜止的背景圖。塔防游戲中敵方源源不斷涌來的每個小兵都是一個精靈,我方防御塔發(fā)出的炮彈也是精靈。可以說精靈構(gòu)成了游戲的絕大部分主體視覺內(nèi)容,而一個2D引擎的主要工作,就是高效地組織,管理和渲染這些精靈。SpriteKit是在iOS7 SDK中Apple新加入的一個2D游戲引擎框架,在SpriteKit出現(xiàn)之前,iOS開發(fā)平臺上已經(jīng)出現(xiàn)了像cocos2d這樣的比較成熟的2D引擎解決方案。SpriteKit展現(xiàn)出的是Apple將Xcode和iOS/Mac SDK打造成游戲引擎的野心,但是同時也確實與IDE有著更好的集成,減少了開發(fā)者的工作。

Hello SpriteKit

廢話不多說,本文直接上實例教程來說明SpriteKit的基本用法。

好吧,我要做的是將非常風(fēng)靡流行婦孺皆知的raywenderlich的經(jīng)典cocos2d教程使用全新的SpriteKit重新實現(xiàn)一遍。重做這個demo的主要原因是cocos2d的這個入門實在是太經(jīng)典了,包括了精靈管理,交互檢測,聲音播放和場景切換等等方面的內(nèi)容,麻雀雖小,卻五臟俱全。這個小demo講的是一個無畏的英雄抵御外敵侵略的故事,英雄在畫面左側(cè),敵人源源不斷從右側(cè)涌來,通過點擊屏幕發(fā)射飛鏢來消滅敵人,阻止它們越過屏幕左側(cè)邊緣。在示例中用到的素材,可以直接從項目中獲得。另外為了方便大家,整個工程示例我也放在了github上,傳送門在此

配置工程

首先當(dāng)然是建立工程,Xcode8提供了SpriteKit模板,使用該模板建立新工程,名字就叫做SpriteKitSimpleGame好了。

  • Snip20170410_6.png
  • Snip20170410_7.png

因為我們需要一個橫屏游戲,所以在新建工程后,在工程設(shè)定的General標(biāo)簽中,把Depoyment Info中Device Orientation中的Portrait勾去掉,使應(yīng)用只在橫屏下運(yùn)行。
運(yùn)行后你會看到一靜態(tài)的Hello World圖片,如下,可以與用戶交互

  • spriteKit效果圖.gif

    先不用理會里面的源代碼,全部都干掉
    將GameViewController.m中的-viewDidLoad:方法全部替換成下面的-viewDidAppear:
- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    // 配置view 你會發(fā)現(xiàn)當(dāng)前的view就是``SKView ``可以在main.storyboard中查看
    SKView * skView = (SKView *)self.view;
    skView.showsFPS = YES;
    skView.showsNodeCount = YES;
    
    //創(chuàng)建和配置場景
    SKScene * scene = [MyScene sceneWithSize:skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;
    
    // 呈現(xiàn)場景
    [skView presentScene:scene];
}

加入精靈

SpriteKit是基于場景(Scene)來組織的,每個SKView(專門用來呈現(xiàn)SpriteKit的View)中可以渲染和管理一個SKScene,每個Scene中可以裝載多個精靈(或者其他Node,之后會詳細(xì)說明),并管理它們的行為。

現(xiàn)在讓我們在這個Scene里加一個精靈吧,先從我們的英雄開始。首先要做的是把剛才下載的素材導(dǎo)入到工程中。我們這次用資源目錄(Asset Catalog)來管理資源吧。點擊工程中的Images.xcassets,以打開Asset Catalog。將資源文件導(dǎo)入

  • 資源文件

開始coding吧~默認(rèn)的SpriteKit模板做的事情就是在GameViewController.m的self.view(這個view是一個SKView,可以到storyboard文件中確認(rèn)一下)中加入并顯示了一個SKScene的子類實例MyScene。正如在做app開發(fā)時我們主要代碼量會集中在ViewController一樣,在用SpriteKit進(jìn)行游戲開發(fā)時,因為所有游戲邏輯和精靈管理都會在Scene中完成,我們的代碼量會集中在SKScene中。在MyScene.m中,把原來的-initWithSize替換成這樣:

-(instancetype)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        /* Setup your scene here */

        //1 Set background color for this scene
        self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0];
        
        //2 Create a new sprite
        SKSpriteNode *player = [SKSpriteNode spriteNodeWithImageNamed:@"player"];
        
        //3 Set it's position to the center right edge of screen
        player.position = CGPointMake(player.size.width/2, size.height/2);
        
        //4 Add it to current scene
        [self addChild:player];
    }
    return self;
}
  1. 因為默認(rèn)工程的Scene背景偏黑,而我們的主角和怪物也都是黑色的,所以先設(shè)定為白色。SKColor只是一個define定義而已,在iOS平臺下被定義為UIColor,在Mac下被定義為NSColor。在SpriteKit開發(fā)時,盡量使用SK開頭的對應(yīng)的UI類可以統(tǒng)一代碼而減少跨iOS和Mac平臺的成本。類似的定義還有SKView,它在iOS下是UIView的子類,在Mac下是NSView的子類。
    SpriteKit中初始化一個精靈很簡單,直接用SKSpriteNode+spriteNodeWithImageNamed:,指定圖片名就行。實際上一個SKSpriteNode中包含了貼圖(SKTexture對象),顏色,尺寸等等參數(shù),這個簡便方法為我們讀取圖片,生成SKTexture,并設(shè)定精靈尺寸和圖片大小一致。在實際使用中,絕大多數(shù)情況這個簡便方法就足夠了。
    設(shè)定精靈的位置。SpriteKit中的坐標(biāo)系和其他OpenGL游戲坐標(biāo)系是一致的,屏幕左下角為(0,0)。不過需要注意的是不論是橫屏還是豎屏游戲,view的尺寸都是按照豎屏進(jìn)行計算的,即對于iPhone來說在這里傳入的sizewidth是320,height是480或者568,而不會因為橫屏而發(fā)生交換。因此在開發(fā)時,請千萬不要使用絕對數(shù)值來進(jìn)行位置設(shè)定及計算(否則你會死的很難看啊很難看)。
    把player加入到當(dāng)前scene中,addChild接受SKNode對象(SKSprite是SKNode的子類),關(guān)于SKNode稍后再說。
    運(yùn)行游戲,yes~主角出現(xiàn)在屏幕上了。
  • Snip20170410_10.png

源源不斷涌來的怪物大軍

沒有怪物的陪襯,主角再瀟灑也是寂寞。添加怪物精靈的方法和之前添加主角沒什么兩樣,生成精靈,設(shè)定位置,加到scene中。區(qū)別在于怪物是會移動的 & 怪物是每隔一段時間就會出現(xiàn)一個的。

在MyScene.m中,加入一個方法-addMonster

- (void) addMonster {
    
    SKSpriteNode *monster = [SKSpriteNode spriteNodeWithImageNamed:@"monster"];
    
    //1 Determine where to spawn the monster along the Y axis
    CGSize winSize = self.size;
    int minY = monster.size.height / 2;
    int maxY = winSize.height - monster.size.height/2;
    int rangeY = maxY - minY;
    int actualY = (arc4random() % rangeY) + minY;

    //2 Create the monster slightly off-screen along the right edge,
    // and along a random position along the Y axis as calculated above
    monster.position = CGPointMake(winSize.width + monster.size.width/2, actualY);
    [self addChild:monster];
    
    //3 Determine speed of the monster
    int minDuration = 2.0;
    int maxDuration = 4.0;
    int rangeDuration = maxDuration - minDuration;
    int actualDuration = (arc4random() % rangeDuration) + minDuration;
    
    //4 Create the actions. Move monster sprite across the screen and remove it from scene after finished.
    SKAction *actionMove = [SKAction moveTo:CGPointMake(-monster.size.width/2, actualY)
                                   duration:actualDuration];
    SKAction *actionMoveDone = [SKAction runBlock:^{
        [monster removeFromParent];
    }];
    [monster runAction:[SKAction sequence:@[actionMove,actionMoveDone]]];
    
}
  1. 計算怪物的出生點(移動開始位置)的Y值。怪物從右側(cè)屏幕外隨機(jī)的高度處進(jìn)入屏幕,為了保證怪物圖像都在屏幕范圍內(nèi),需要指定最小和最大Y值。然后從這個范圍內(nèi)隨機(jī)一個Y值作為出生點。
  2. 設(shè)定出生點恰好在屏幕右側(cè)外面,然后添加怪物精靈。
  3. 怪物要是勻速過來的話太死板了,加一點隨機(jī)量,這樣怪物有快有慢不會顯得單調(diào)
  4. 建立SKActionSKAction可以操作SKNode,完成諸如精靈移動,旋轉(zhuǎn),消失等等。這里聲明了兩個SKActionactionMove負(fù)責(zé)將精靈在actualDuration的時間間隔內(nèi)移動到結(jié)束點(直線橫穿屏幕);actionMoveDone負(fù)責(zé)將精靈移出場景,其實是run一段接受到的block代碼。runAction方法可以讓精靈執(zhí)行某個操作,而在這里我們要做的是先將精靈移動到結(jié)束點,當(dāng)移動結(jié)束后,移除精靈。我們需要的是一個順序執(zhí)行,這里sequence:可以讓我們順序執(zhí)行多個action。

然后嘗試在上面的-initWithSize:里調(diào)用這個方法看看結(jié)果

-(instancetype)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        //...
        [self addChild:player];
        [self addMonster];
    }
    return self;
}

  • Snip20170410_11.png

Cool,我們的游戲有個能動的圖像。知道么,游戲的本質(zhì)是什么?就是一堆能動的圖像!

只有一個怪物的話,英雄大大還是很寂寞,所以我們說好了會有源源不斷的怪物..在-initWithSize:的4之后加入以下代碼

    //...
    //5 Repeat add monster to the scene every 1 second.
    SKAction *actionAddMonster = [SKAction runBlock:^{
        [self addMonster];
    }];
    SKAction *actionWaitNextMonster = [SKAction waitForDuration:1];
    [self runAction:[SKAction repeatActionForever:[SKAction sequence:@[actionAddMonster,actionWaitNextMonster]]]];
    //...

這里聲明了一個SKAction的序列,run一個block,然后等待1秒。用這個動作序列用-repeatActionForever:生成一個無限重復(fù)的動作,然后讓scene執(zhí)行。這樣就可以實現(xiàn)每秒調(diào)用一次-addMonster來向場景中不斷添加敵人了。如果你對Cocoa(Touch)開發(fā)比較熟悉的話,可能會說,為什么不用一個NSTimer來做同樣的事情,而要寫這樣的SKAction呢?能不能用NSTimer來達(dá)到同樣的目的?答案是在對場景或者精靈等SpriteKit對象進(jìn)行類似操作時,盡量不要用NSTimer。因為NSTimer將不受SpriteKit的影響和管理,使用SKAction可以不加入其它任何代碼就獲取如下好處:

  • 自動暫停和繼續(xù),當(dāng)設(shè)定一個SKNodepaused屬性為YES時,這個SKNode和它管理的子node的action都會自動被暫停。這里詳細(xì)說明一下SKNode的概念:SKNodeSpriteKit中要素的基本組織方式,它代表了SKView中的一種游戲資源的組織方式。我們現(xiàn)在接觸到的SKSceneSKSprite都是SKNode的子類,而一個SKNode可以有很多的子Node,從而構(gòu)成一個SKNode的樹。在我們的例子中,MyScene直接加在SKView中作為最root的node存在,而英雄或者敵人的精靈都作為Scene這個node的子node被添加進(jìn)來。SKAction和node上的各種屬性的的作用范圍是當(dāng)前這個node和它的所有子node,在這里我們?nèi)绻O(shè)定MySecnen這個node(也就是self)的paused屬性被設(shè)為YES的話,所有的Action都會被暫停,包括這個每隔一秒調(diào)用一次的action,而如果你用NSTimer的話,恭喜,你必須自行維護(hù)它的狀態(tài)。
  • 當(dāng)SKAction依附的結(jié)點被從結(jié)點樹中拿掉的時候,這個action會自動結(jié)束并停止,這是符合一般邏輯的。

編譯,運(yùn)行,一切如我們所預(yù)期的那樣,每個一秒有一個怪物從右側(cè)進(jìn)入,并以不同的速度穿過屏幕。

  • sprtekit-monsters.gif

奧特曼打小怪獸是天經(jīng)地義的

有了英雄,有了怪獸,就差一個“打”了。我們打算做的是在用戶點擊屏幕某個位置時,就由英雄所在的位置向點擊位置發(fā)射一枚固定速度的飛鏢。然后這每飛鏢要是命中怪物的話,就把怪物從屏幕中移除。

先來實現(xiàn)發(fā)射飛鏢吧。檢測點擊,然后讓一個精靈朝向點擊的方向以某個速度移動,有很多種SKAction可以實現(xiàn),但是為了盡量保持簡單,我們使用上面曾經(jīng)使用過的moveTo:duration:吧。在發(fā)射之前,我們先要來做一點基本的數(shù)學(xué)運(yùn)算,希望你還能記得相似三角形之類的概念。我們的飛鏢是由英雄發(fā)出的,然后經(jīng)過手指點擊的點,兩點決定一條直線。簡單說我們需要求解出這條直線和屏幕右側(cè)邊緣外的交點,以此來確定飛鏢的最終目的。一旦我們得到了這個終點,就可以控制飛鏢moveTo到這個終點,從而模擬出發(fā)射飛鏢的action了。如圖所示,很簡單的幾何學(xué),關(guān)于具體的計算就不再講解了,要是算不出來的話,請考慮call你的中學(xué)數(shù)學(xué)老師并負(fù)荊請罪以示誠意。

  • spritekit-math.png

然后開始寫代碼吧,MyScene.m里的-touchesBegan:withEvent::,用下面的代碼替換掉原來的。


-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    /* Called when a touch begins */
    
    for (UITouch *touch in touches) {
        //1 Set up initial location of projectile
        CGSize winSize = self.size;
        SKSpriteNode *projectile = [SKSpriteNode spriteNodeWithImageNamed:@"projectile.png"];
        projectile.position = CGPointMake(projectile.size.width/2, winSize.height/2);
        
        //2 Get the touch location tn the scene and calculate offset
        CGPoint location = [touch locationInNode:self];
        CGPoint offset = CGPointMake(location.x - projectile.position.x, location.y - projectile.position.y);
        
        // Bail out if you are shooting down or backwards
        if (offset.x <= 0) return;
        // Ok to add now - we've double checked position
        [self addChild:projectile];
        
        int realX = winSize.width + (projectile.size.width/2);
        float ratio = (float) offset.y / (float) offset.x;
        int realY = (realX * ratio) + projectile.position.y;
        CGPoint realDest = CGPointMake(realX, realY);
        
        //3 Determine the length of how far you're shooting
        int offRealX = realX - projectile.position.x;
        int offRealY = realY - projectile.position.y;
        float length = sqrtf((offRealX*offRealX)+(offRealY*offRealY));
        float velocity = self.size.width/1; // projectile speed. 
        float realMoveDuration = length/velocity;
        
        //4 Move projectile to actual endpoint
        [projectile runAction:[SKAction moveTo:realDest duration:realMoveDuration] completion:^{
            [projectile removeFromParent];
        }];
    }
}
  1. 為飛鏢設(shè)定初始位置。
  2. 將點擊的位置轉(zhuǎn)換為node的坐標(biāo)系的坐標(biāo),并計算點擊位置和飛鏢位置的偏移量。如果點擊位置在飛鏢初始位置的后方,則直接返回
  3. 根據(jù)相似三角形計算屏幕右側(cè)外的結(jié)束位置。
  4. 移動飛鏢,并在移動結(jié)束后將飛鏢從場景中移除。注意在移動怪物的時候我們用了兩個action(actionMove和actionMoveDone來做移動+移除),這里只使用了一個action并用帶completion block移除精靈。這里對飛鏢的這種做法是比較簡明常見高效的,之前的做法只是為了說明action的sequence:的用法。

運(yùn)行看看現(xiàn)在的游戲吧,我們有英雄有怪物還有打怪物的小飛鏢,好像氣氛上已經(jīng)開始有趣了!

  • spritekit-add-projectile.gif

飛鏢擊中的檢測

但是一個嚴(yán)重的問題是,現(xiàn)在的飛鏢就算接觸到了怪物也是直穿而過,完全就是空氣一般的存在。為什么?因為我們還沒有寫任何檢測飛鏢和怪物的接觸的代碼(廢話)。我們想要做的是在飛鏢和怪物接觸到的時候,將它們都移出場景,這樣看起來就像是飛鏢打中了怪物,并且把怪物消滅了。

基本思路是在每隔一個小的時間間隔,就掃描一遍場景中現(xiàn)存的飛鏢和怪物。這里就需要提到SpriteKit中最基本的每一幀的周期概念。

  • spritekit-update_loop.png

在iOS傳統(tǒng)的view的系統(tǒng)中,view的內(nèi)容被渲染一次后就將一直等待,直到需要渲染的內(nèi)容發(fā)生改變(比如用戶發(fā)生交互,view的遷移等)的時候,才進(jìn)行下一次渲染。這主要是因為傳統(tǒng)的view大多工作在靜態(tài)環(huán)境下,并沒有需要頻繁改變的需求。而對于SpriteKit來說,其本身就是用來制作大多數(shù)時候是動態(tài)的游戲的,為了保證動畫的流暢和場景的持續(xù)更新,在SpriteKit中view將會循環(huán)不斷地重繪。

動畫和渲染的進(jìn)程是和SKScene對象綁定的,只有當(dāng)場景被呈現(xiàn)時,這些渲染以及其中的action才會被執(zhí)行。SKScene實例中,一個循環(huán)按執(zhí)行順序包括

  • 每一幀開始時,SKScene的-update:方法將被調(diào)用,參數(shù)是從開始時到調(diào)用時所經(jīng)過的時間。在該方法中,我們應(yīng)該實現(xiàn)一些游戲邏輯,包括AI,精靈行為等等,另外也可以在該方法中更新node的屬性或者讓node執(zhí)行action
  • 在update執(zhí)行完畢后,SKScene將會開始執(zhí)行所有的action。因為action是可以由開發(fā)者設(shè)定的(還記得runBlock:么),因此在這一個階段我們也是可以執(zhí)行自己的代碼的。
  • 在當(dāng)前幀的action結(jié)束之后,SKScene的-didEvaluateActions將被調(diào)用,我們可以在這個方法里對結(jié)點做最后的調(diào)整或者限制,之后將進(jìn)入物理引擎的計算階段。
  • 然后SKScene將會開始物理計算,如果在結(jié)點上添加了SKPhysicsBody的話,那么這個結(jié)點將會具有物理特性,并參與到這個階段的計算。根據(jù)物理計算的結(jié)果,SpriteKit將會決定結(jié)點新的狀態(tài)。
  • 然后-didSimulatePhysics會被調(diào)用,這類似之前的-didEvaluateActions。這里是我們開發(fā)者能參與的最后的地方,是我們改變結(jié)點的最后機(jī)會。
  • 一幀的最后是渲染流程,根據(jù)之前設(shè)定和計算的結(jié)果對整個呈現(xiàn)的場景進(jìn)行繪制。完成之后,SpriteKit將開始新的一幀。

在了解了一些SpriteKit的基礎(chǔ)概念后,回到我們的demo。檢測場景上每個怪物和飛鏢的狀態(tài),如果它們相撞就移除,這是對精靈的計算的和操作,我們可以將其放到-update:方法中來處理。在此之前,我們需要保存一下添加到場景中的怪物和飛鏢,在MyScene.m的@implementation之前加入下面的聲明:

@interface MyScene()
@property (nonatomic, strong) NSMutableArray *monsters;
@property (nonatomic, strong) NSMutableArray *projectiles;
@end

然后在-initWithSize:中配置場景之前,初始化這兩個數(shù)組:


-(instancetype)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        /* Setup your scene here */
        self.monsters = [NSMutableArray array];
        self.projectiles = [NSMutableArray array];
        
        //...
    }
    return self;
}

在將怪物或者飛鏢加入場景中的同時,分別將它們加入到數(shù)組中,

-(void) addMonster {
    //...
    
    [self.monsters addObject:monster];
}
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    for (UITouch *touch in touches) {
        //...
    
        [self.projectiles addObject:projectile];
    }
}

同時,在將它們移除場景時,將它們移出所在數(shù)組,分別在[monster removeFromParent][projectile removeFromParent]后加入[self.monsters removeObject:monster][self.projectiles removeObject:projectile]。接下來終于可以在-update:中檢測并移除了:

-(void)update:(CFTimeInterval)currentTime {
    /* Called before each frame is rendered */
    NSMutableArray *projectilesToDelete = [[NSMutableArray alloc] init];
    for (SKSpriteNode *projectile in self.projectiles) {
        
        NSMutableArray *monstersToDelete = [[NSMutableArray alloc] init];
        for (SKSpriteNode *monster in self.monsters) {
            
            if (CGRectIntersectsRect(projectile.frame, monster.frame)) {
                [monstersToDelete addObject:monster];
            }
        }
        
        for (SKSpriteNode *monster in monstersToDelete) {
            [self.monsters removeObject:monster];
            [monster removeFromParent];
        }
        
        if (monstersToDelete.count > 0) {
            [projectilesToDelete addObject:projectile];
        }
    }
    
    for (SKSpriteNode *projectile in projectilesToDelete) {
        [self.projectiles removeObject:projectile];
        [projectile removeFromParent];
    }
}

代碼比較簡單,不多解釋了。直接運(yùn)行看結(jié)果

  • spritekit-hit.gif

播放聲音

音效絕對是游戲的一個重要環(huán)節(jié),還記得一開始下載的那個資源文件壓縮包么?里面除了Art文件夾外還有個Sounds文件夾,我們把Sounds加入工程里,整個文件夾拖到工程導(dǎo)航里面,然后勾上“Copy item”。

我們想在發(fā)射飛鏢時播出一個音效,對于音效的播放是十分簡單的,SpriteKit為我們提供了一個action,用來播放單個音效。因為每次的音效是相同的,所以只需要在一開始加載一次action,之后就一直使用這個action,以提高效率。先在MyScene.m的@interface中加入

@property (nonatomic, strong) SKAction *projectileSoundEffectAction;

然后在-initWithSize:一開始的地方加入

self.projectileSoundEffectAction = [SKAction playSoundFileNamed:@"pew-pew-lei.caf" waitForCompletion:NO];

最后,修改發(fā)射飛鏢的action,使播放音效的action和移動精靈的action同時執(zhí)行。將-touchesBegan:withEvent:最后runAction的部分改為

//...
//4 Move projectile to actual endpoint and play the throw sound effect
SKAction *moveAction = [SKAction moveTo:realDest duration:realMoveDuration];
SKAction *projectileCastAction = [SKAction group:@[moveAction,self.projectileSoundEffectAction]];
[projectile runAction:projectileCastAction completion:^{
    [projectile removeFromParent];
    [self.projectiles removeObject:projectile];
}];

//...

之前我們介紹了用-sequence:連接不同的action,使它們順序串行執(zhí)行。在這里我們用了另一個方便的方法,-group:可以范圍一個新的action,這個action將并行同時開始執(zhí)行傳入的所有action。在這里我們在飛鏢開始移動的同時,播放了一個pew-pew-lei的音效(音效效果請下載demo試聽,或者自行腦補(bǔ)…)。

游戲中音效一般來說至少會有效果音(SE)和背景音(BGM)兩種,SE可以用SpriteKit的action來解決,而BGM就要慘一些,至少寫這篇教程的時候,SpriteKit還沒有一個BGM的專門的對應(yīng)方案(如果之后新加了的話我會更新本教程)。所以現(xiàn)在我們使用傳統(tǒng)的播放較長背景音樂的方法來實現(xiàn)背景音,那就是用AVAudioPlayer。在@interface MyScene()中加入一個bgmPlayer的聲明,然后在-initWithSize:中加載背景音并一直播放。

@interface MyScene()
//...
@property (nonatomic, strong) AVAudioPlayer *bgmPlayer;
//...
@end

@implementation MyScene

-(id)initWithSize:(CGSize)size {
//...
        NSString *bgmPath = [[NSBundle mainBundle] pathForResource:@"background-music-aac" ofType:@"caf"];
        self.bgmPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:[NSURL fileURLWithPath:bgmPath] error:NULL];
        self.bgmPlayer.numberOfLoops = -1;
        [self.bgmPlayer play];
//...
}

AVAudioPlayer用來播放背景音樂相當(dāng)?shù)暮线m,唯一的問題是有可能你想在暫停的時候停止這個背景音樂的播放。因為使用的是SpriteKit以外的框架,而并非action,因此BGM的播放不會隨著設(shè)置Scene為暫停或者移除這個Scene而停止。想要停止播放,必須手動顯式地調(diào)用[self.bgmPlayer stop],可以說是比較麻煩,不過有時候你不并不想在暫停或者場景切換的時候中斷背景音樂的話,這反倒是一個好的選擇。

結(jié)果計算和場景切換

到現(xiàn)在為止,整個關(guān)卡作為一個demo來說已經(jīng)比較完善了。最后,我們可以為這個關(guān)卡設(shè)定一些條件,畢竟不是每個人都喜歡一直無意義地消滅怪物直到手機(jī)沒電。我們設(shè)定規(guī)則,當(dāng)打死30個怪物后切換到新的場景,以成功結(jié)束戰(zhàn)斗的結(jié)果;另外,要是有任何一個怪物到達(dá)了屏幕左側(cè)邊緣,則本場戰(zhàn)斗失敗。另外我們在顯示結(jié)果的場景中還需要一個交互按鈕,以便我們重新開始一輪游戲。

首先是檢測被打死的怪物數(shù),在MyScene里添加一個monstersDestroyed,然后在打中怪物時使這個值+1,并在隨后判斷如果消滅怪物數(shù)量大于等于30,則切換場景(暫時沒有實現(xiàn),現(xiàn)在留了兩個TODO,一會兒我們再實裝場景切換)

@interface MyScene()
//...
@property (nonatomic, assign) int monstersDestroyed;
//...
@end


-(void)update:(CFTimeInterval)currentTime {
//...
  for (SKSpriteNode *monster in monstersToDelete) {
    [self.monsters removeObject:monster];
    [monster removeFromParent];
            
    self.monstersDestroyed++;
    if (self.monstersDestroyed >= 30) {
        //TODO: Show a win scene
    }
  }
//...

另外,在怪物到達(dá)屏幕邊緣的時候也觸發(fā)場景的切換:

- (void) addMonster {
    //...
    SKAction *actionMoveDone = [SKAction runBlock:^{
        [monster removeFromParent];
        [self.monsters removeObject:monster];
        
        //TODO: Show a lose scene
    }];
    //...
}

接下來就是制作新的表示結(jié)果的場景了。新建一個SKScene的子類很簡單,和平時我們新建Cocoa或者CocoaTouch的類沒有什么區(qū)別。菜單中File->New->File…,選擇Objective-C class,然后將新建的文件取名為ResultScene,父類填寫為SKScene,并在新建的時候選擇合適的Target即可。在新建的ResultScene.m的@implementation中加入如下代碼:

-(instancetype)initWithSize:(CGSize)size won:(BOOL)won
{
    if (self = [super initWithSize:size]) {
        self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0];
        
        //1 Add a result label to the middle of screen
        SKLabelNode *resultLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"];
        resultLabel.text = won ? @"You win!" : @"You lose";
        resultLabel.fontSize = 30;
        resultLabel.fontColor = [SKColor blackColor];
        resultLabel.position = CGPointMake(CGRectGetMidX(self.frame),
                                       CGRectGetMidY(self.frame));
        [self addChild:resultLabel];
        
        //2 Add a retry label below the result label
        SKLabelNode *retryLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"];
        retryLabel.text = @"Try again";
        retryLabel.fontSize = 20;
        retryLabel.fontColor = [SKColor blueColor];
        retryLabel.position = CGPointMake(resultLabel.position.x, resultLabel.position.y * 0.8);
        //3 Give a name for this node, it will help up to find the node later.
        retryLabel.name = @"retryLabel";
        [self addChild:retryLabel];
    }
    return self;
}

我們在ResultScene中自定義了一個含有結(jié)果的初始化方法初始化,之后我們將使用這個方法來初始化ResultScene。在這個init方法中我們做了以下這些事:

  • 根據(jù)輸入添加了一個SKLabelNode來顯示游戲的結(jié)果。SKLabelNode也是SKNode的子類,可以用來方便地顯示不同字體、顏色或者樣式的文字標(biāo)簽。
  • 在結(jié)果標(biāo)簽的下方加入了一個重開一盤的標(biāo)簽
  • 我們?yōu)檫@個node進(jìn)行了命名,通過對node命名,我們可以在之后方便地拿到這個node的參照,而不必新建一個變量來持有它。在實際運(yùn)用中,這個命名即可以用來存儲一個唯一的名字,來幫助我們之后找到特定的node(使用-childNodeWithName:),也可以一堆特性類似的node共用一個名字,這樣可以方便枚舉(使用-enumerateChildNodesWithName:usingBlock:方法)。不過這次的demo中,我們只是簡單地用字符串比較來確定node,稍后會看到具體的用法。

最后不要忘了這個方法名寫到.h文件中去,這樣我們才能在游戲場景中調(diào)用到。

回到游戲場景,在MyScene.m的加入對ResultScene.h的引用,然后在實現(xiàn)中加入一個切換場景的方法

#import "ResultScene.h"

//...
-(void) changeToResultSceneWithWon:(BOOL)won
{
    [self.bgmPlayer stop];
    self.bgmPlayer = nil;
    ResultScene *rs = [[ResultScene alloc] initWithSize:self.size won:won];
    SKTransition *reveal = [SKTransition revealWithDirection:SKTransitionDirectionUp duration:1.0];
    [self.scene.view presentScene:rs transition:reveal];
}

SKTransition是專門用來做不同的Scene之前切換的類,這個類為我們提供了很多“廉價”的場景切換效果(相信我,你如果想要自己實現(xiàn)它們的話會頗費一番功夫)。在這里我們建立了一個將當(dāng)前場景上推的切換效果,來顯示新的ResultScene。另外注意我們在這里停止了BGM的播放。之后,將剛才留下來的兩個TODO的地方,分別替換為以相應(yīng)參數(shù)對這個方法的調(diào)用。

最后,我們想要在ResultScene中點擊Retry標(biāo)簽時,重開一盤游戲。在ResultScene.m中加入代碼

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];
        SKNode *node = [self nodeAtPoint:touchLocation];
        
        if ([node.name isEqualToString:@"retryLabel"]) {
            [self changeToGameScene];
        }
    }
}

-(void) changeToGameScene
{
    MyScene *ms = [MyScene sceneWithSize:self.size];
    SKTransition *reveal = [SKTransition revealWithDirection:SKTransitionDirectionDown duration:1.0];
    [self.scene.view presentScene:ms transition:reveal];
}

運(yùn)行游戲,消滅足夠多的敵人(或者漏過一個敵人),應(yīng)該能夠可能到場景切換和結(jié)果顯示。然后點擊再來一次的話,將重新開始新的游戲。

Snip20170410_12.png

關(guān)于Sprite的一些個人補(bǔ)充

至此,整個Demo的主體部分結(jié)束。接下來對于當(dāng)前的SpriteKit(iOS SDK beta1)說一些我個人的看法和理解。如果之后這部分內(nèi)容有巨大變化的話,我會盡量更新。首先是性能問題,如果有在iOS平臺下使用cocos2d開發(fā)的經(jīng)驗的話,很容易看出來SpriteKit在很多地方借鑒了cocos2d。作為SDK內(nèi)置的框架來說,又有cocos2d的開源實現(xiàn)進(jìn)行參考,效率方面超越cocos2d應(yīng)該是理所當(dāng)然的。在現(xiàn)有的一系列benchmark上來看,實際上SpriteKit在圖形渲染方面也有著很不錯的表現(xiàn)。另外,在編寫過程中,也有不少技巧可以使用,以進(jìn)一步進(jìn)行優(yōu)化,比如在內(nèi)存中保持住常用的action,預(yù)先加載資源,使用Atlas等等。在進(jìn)行比較全面和完整的優(yōu)化后,SpriteKit的表現(xiàn)應(yīng)該是可以期待的。

使用SpriteKit一個很明顯的優(yōu)點在于,SKView其實是基于UIKit的UIView的一套實現(xiàn),而其中的所有SKNode對象都UIResponder的子類,并且實現(xiàn)了NSCoding等接口。也就是說,其實在SpriteKit中是可以很容易地使用其他的非游戲Cocoa/CocoaTouch框架的。比如可以使用UIKit或者Cocoa來簡單地制作UI,然后只在需要每幀演算的時候使用SpriteKit,藉此來達(dá)到快速開發(fā)的目的。這點上cocos2d是無法與之比擬的。另外,因為SKSprite同時兼顧了iOS和Mac兩者,因此在我們進(jìn)行開發(fā)時如果能稍加注意,理論上可以比較容易地完成iOS和Mac的跨平臺。

由于SKNode是UIResponder的子類,因此在真正制作游戲的時候,對于相應(yīng)用戶點擊等操作我們是不必(也不應(yīng)該)像demo中一樣全部放在Scene點擊事件中,而是應(yīng)該盡量封裝游戲中用到的node,并在node中處理用戶的點擊,并且委托到Scene中進(jìn)行處理,可能邏輯上會更加清晰。關(guān)于用戶交互事件的處理,另外一個需要注意的地方在于,使用UIResponder監(jiān)測的用戶交互事件和SKScene的事件循環(huán)是相互獨立的。如果像我們的demo中那樣直接處理用戶點擊并和SpriteKit交互的話,我們并不能確定這個執(zhí)行時機(jī)在SKScene循環(huán)中的狀態(tài)。比如點擊的相關(guān)代碼也許會在-update后執(zhí)行,也可能在-didSimulatePhysics后被調(diào)用,這引入了執(zhí)行順序的不確定性。對于上面的這個簡單的demo來說這沒有什么太大關(guān)系,但是在對于時間敏感的游戲邏輯或者帶有物理模擬的游戲中,也許時序會很關(guān)鍵。由于點擊事件的時序和精靈動畫和物理等的時序不確定,有可能造成奇怪的問題。對此現(xiàn)在暫時的解決方法是僅在點擊事件中設(shè)置一個標(biāo)志位記錄點擊狀態(tài),然后在接下來的-update:中進(jìn)行檢測并處理(蘋果給出的官方SpriteKit的“Adventure”是這樣處理的),以此來保證時序的正確性。代價是點擊事件會延遲一幀才會被處理,雖然在絕大多數(shù)情況下并不是什么問題,但是其實這點上并不優(yōu)雅,至少在現(xiàn)在的beta版中,算不上優(yōu)雅。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容