SpriteKitを使って一攫千金を狙うチュートリアル

iOS 7でSpriteKitという新しいゲーム作成用のフレームワークがでました。早速コレを使ってゲームを作って一攫千金を狙いたいと思います。
勢いだけで書いてしまったので、大分雑な感じになっていますが悪しからず。細かい部分はそのうち直していきます。。編集社って偉大。

SpriteKitとは

  • iOS 7 / Mac OS 10.9から追加された、2Dゲーム用のフレームワーク
  • 物理エンジンもついてる
  • あれ、なんかCocos2Dに似てね

iOS / Mac用の2Dゲームエンジンとしては、Cocos 2Dが有名ですが、実際のところSpriteKitはCocos 2Dとかなり似てます。
Cocos 2Dと比較した場合のSpriteKitのメリットとしては、

  1. Apple純正だから多分今後のOS対応も安心?
  2. インストールの必要がないので手軽
  3. UIKitの延長的な感覚で使える
  4. 物理エンジン使った時のコードもObj-Cで書けるので読みやすい

といったような所でしょうか。
逆に「iOS 6 / Mac OS 10.8以前では動かない」というデメリットもありますが、まあなにはともあれ、使ってみましょう。既にCocos 2D使ってる人がSpriteKitに乗り換えるメリットはどれくらいあるかわかりませんが

なにをつくろう?

2012年のパズドラの大ヒット、そして2013年現在は艦これが話題です。
というわけで、この二つのゲームの良い部分を組み合わせたゲームシステム、
 
はえたたきを作ろう。

プロジェクトの作成

spritekit-game-templatespritekit-proj-settings
SpriteKitを使ったゲームを作るには、SpriteKit Gameテンプレートを使ってプロジェクトを作るのが簡単です。プロジェクト名等は適当でOK。
とりあえずそのままビルドしてみましょう。
spritekit-helloworldspritekit-3nodesspritekit-9nodes
最初画面にはHello, World!という文字列だけが表示されていて、画面をタップするごとに回転する飛行機が増えていきます。

テンプレートを読む

では、このテンプレートの中身を読んでいきましょう。
プロジェクト内にはいくつかファイルがありますが、今回SpriteKitの使い方を理解する上では(Prefix)MySceneクラスだけを読めばOKです。

SpriteKitで登場する重要な概念について

SpriteKitを理解する上では、まずScene / Spriteの2つについて知っておく必要があります。これらの単語はCocos2D, Unity, 古くはFlash等でも登場している一般的な概念なので、既にそれらのツールを使ったことがある人であればお馴染みだと思います。
Sceneはその名の通り、ゲーム内の場面毎に一つ用意する要素で、「オープニング用にOpeningSceneクラス、バトル画面用にBattleSceneクラス」といったような作り方をします。
Spriteは「主人公」「敵キャラA」「敵キャラB」「背景画像」…といった各Sceneに登場する「絵」の単位です。
テンプレートのゲームには、(Prefix)MySceneというシーンがあり、画面をタッチする毎に飛行機のSpriteが追加されます。

スプライトの追加

画面がタッチされた時にスプライトを追加している処理を見てみましょう。
touchesBegan:の中でタッチされた位置にスプライトを追加している部分だけを抽出してコメントします。

// タッチされた座標を取得
CGPoint location = [touch locationInNode:self];
// Spaceship.pngを使ったSpriteのインスタンスを作成
SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"];
// タッチされた座標に移動する
sprite.position = location;
// アクション(今の段階では無視してよし)
SKAction *action = [SKAction rotateByAngle:M_PI duration:1];
[sprite runAction:[SKAction repeatActionForever:action]];
// シーンに追加
[self addChild:sprite];

スプライトの画像と場所を指定し、シーンに追加しているだけですね。アクションはくるくる回るアニメーションの設定なのですが、一旦ここでは存在を無視しましょう。

// タッチされた座標を取得
CGPoint location = [touch locationInNode:self];
NSLog(@"Location: %.2f, %.2f", location.x, location.y);

なお、locationの値をログに出してみるとわかりますが、座標系は左下が原点(0,0)で右に行くほどx値が、上に行くほどy値が大きくなる独特な座標系になっています。UIKitの座標系(画面左上が原点で、下に行くほどy値が大きくなる)ではないので、注意しましょう。
※y軸が逆転しているのは、三角関数を使った際に余計な計算をしなくてよいためだと思われます。(y軸が下向きだと、sinの結果が逆転してしまう)

updateメソッド

-(void)update:(CFTimeInterval)currentTime {
    /* Called before each frame is rendered */
}

シーンクラスには、update:メソッドが定義されています。
SpriteKit Gameテンプレートを使ったプロジェクトでは、最大60FPS(1秒間に60フレーム)で画面の更新が行われます。画面更新の直前にはupdate:メソッドが呼ばれるので、必要に応じてこの中でゲームロジックの処理やアニメーションの処理を行います。テンプレート内では特になにもしていないので空になっています。

スプライトとupdate:に慣れる

テンプレートの内容を改造して、スプライトとupdate:の扱いに慣れましょう。
スクリーンショット 2013-09-16 12.08.42画面がタッチされる度にスプライトを追加する処理をやめて、代わりにシーンが初期化されるタイミングで一つスプライトを追加するようにします。
シーンの初期化メソッドとしてinitWithSize:メソッドが定義されていますので、その中でスプライトを生成して(100, 100)の位置に出してみましょう。initWithSize:メソッド内には画面上にテキストを描画するコードが既にありますが、そこは消してしまいます。背景色を指定している部分は残しておきます。

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        /* Setup your scene here */
        self.backgroundColor = [SKColor colorWithRed:0.15 green:0.15 blue:0.3 alpha:1.0];
        // (100, 100)にスプライトを追加する
        SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"];
        sprite.position = CGPointMake(100.0f, 100.0f);
        [self addChild:sprite];
    }
    return self;
}
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    /* Called when a touch begins */
    // タッチされたらスプライトを追加する処理は一度消してしまう
}

スクリーンショット 2013-09-16 13.37.13スプライトには、座標を表すpositionの他に、左右・上下の拡大率を設定するxScale, yScaleプロパティ、回転を反時計回りのラジアン値で指定するzRotationプロパティがあります。試しに、サイズを1/10・向きを右向きにしてみましょう。

SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"];
sprite.position = CGPointMake(100.0f, 100.0f);
sprite.xScale = 1.0f / 10.0f;
sprite.yScale = 1.0f / 10.0f;
sprite.zRotation = -1.57f;
[self addChild:sprite];

では、update:に慣れるために1秒間に10pxずつこのスプライトを動かせるようにしていきましょう。まずは、update:メソッド内でスプライトのpositionを操作できるように、スプライトをクラスのインスタンス変数mySpriteとして保持するようにしておきます。

@interface HTMyScene() {
    SKSpriteNode *mySprite; // update:メソッドからも参照できるように、保持しておく
}@end
@implementation HTMyScene
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"];
        ...
        [self addChild:sprite];
        mySprite = sprite; // mySpriteとしてクラス内からアクセスできるようにする
    }
    return self;
}

そして、update:メソッド内でmySpriteのpositionを変化させていきます。
ここに最初の難関、というか不便な点があります。
上述した通り、このプロジェクトは最大60FPSで画面の更新が行われますが、動作させてみて画面下部のFPSを見ればわかる通り、実際にはFPSは処理の内容・デバイスの性能によって変動します。

-(void)update:(CFTimeInterval)currentTime {
    // 1秒で10px上に移動するようにスプライトを動かす(ダメな例)
    float moveAmount = 10.0f / 60.0f; // 10px を FPS(60)等分
    CGPoint positionBefore = mySprite.position;
    mySprite.position = CGPointMake(positionBefore.x,
                                    positionBefore.y + moveAmount);
}

1秒間に60FPS呼ばれる前提でこのようなコードを書いてしまうと、遅い端末で30FPSしかでないような場合には半分の5pxしか動かない状態になってしまいます。

const float speed = 10.0f; // 10px 毎秒のスピード
float moveAmount = speed * timeIntervalSinceLastUpdate;

このような場合、前回画面更新時からの経過時間分だけ移動する、というのが正しい実装になります。しかし、どういうわけかSpriteKitには前回画面更新時からの経過時間を取得するメソッドが用意されていないようです。(同じAppleが出しているGLKit、あるいはサードパーティのCocos2dにはそういったメソッドが用意されているのに)
従って、前回更新時からの経過時間を自分で計算しなければなりません。幸い、update:メソッドには「システム時刻」が引数で渡されるようになっています。(なぜ経過時刻ではなくシステム時刻にしたのか、実に謎ですが)。少し面倒ですが、前回更新時のシステム時刻を保存しておき、現在のシステム時刻を比較して経過時刻を取得するようにします。

@interface HTMyScene() {
    SKSpriteNode *mySprite;
    // 前回更新時刻を保持、初期化するために必要
    dispatch_once_t lastUpdatedAtInitToken;
    CFTimeInterval lastUpdatedAt;
}@end
...
-(void)update:(CFTimeInterval)currentTime {
    // 前回のフレームの更新時刻を記憶しておく
    dispatch_once(&lastUpdatedAtInitToken, ^{
        lastUpdatedAt = currentTime;
    });
    // 前回フレーム更新からの経過時刻を計算する
    CFTimeInterval timeSinceLastUpdate = currentTime - lastUpdatedAt;
    lastUpdatedAt = currentTime;
    // 1秒で10px上に移動するようにスプライトを動かす
    const float speed = 10.0f;
    float moveAmount = speed * timeSinceLastUpdate;
    CGPoint positionBefore = mySprite.position;
    mySprite.position = CGPointMake(positionBefore.x,
                                    positionBefore.y + moveAmount);
}

正直、ここはイケてないですが、実行してみてとりあえずスプライトが一定スピードで上に上がっていればOK。

課題: 等速で回転させてみる(アクションを使わずに)

zRotationプロパティを変化させて1秒に反時計回りに1回転するように実装してみましょう。
回答

-(void)update:(CFTimeInterval)currentTime {
    ...
    const float angularSpeed = 6.28f; // 1秒で2rad(6.28 = 360度)回る
    float angularAmount = angularSpeed * timeSinceLastUpdate;
    mySprite.zRotation = mySprite.zRotation + angularAmount;
}

スプライトを進行方向に動かす

現在の実装では、スプライトは回転しながら上方向に進みますが、これを進行方向に向かって移動ようにします。

const float speed = 10.0f; // 1秒で10px
float moveAmount = speed * timeSinceLastUpdate;
float moveAmountX = cos(theta) * moveAmount;
float moveAmountY = sin(theta) * moveAmount;

角度に応じたx, y軸それぞれの移動量は、cos, sinを使って求めることができます。三角関数の角度thetaは右向きの状態が0.0、反時計回りに一周が2π = 6.28となります。スプライトのzRotationプロパティも反時計回りで一周が2π = 6.28のラジアン値ですが、今回のプログラムの場合テクスチャが上を向いた画像なので、thetaはzRotationの値に反時計回りに90度(0.5π = 1.57)を追加した値にしなければいけません。
移動・回転のスピードを調整して、スプライトが円を描きながら進行方向に動くようにします。

const float speed = 30.0f;
const float angularSpeed = 6.28f / 10.0f;
// 回転させる
float angularAmount = angularSpeed * timeSinceLastUpdate;
mySprite.zRotation = mySprite.zRotation + angularAmount;
// 向いている方向に移動させる
float moveAmount = speed * timeSinceLastUpdate;
float theta = mySprite.zRotation + 1.57f;
float moveAmountX = cos(theta) * moveAmount;
float moveAmountY = sin(theta) * moveAmount;
CGPoint positionBefore = mySprite.position;
mySprite.position = CGPointMake(positionBefore.x + moveAmountX,
                                positionBefore.y + moveAmountY);

さてこの実装、実はFPSが安定しないと角度が変わるタイミングがずれるので円の中心がずれてきれいな円を描けないという問題があるのですが、厳密にやろうとすると面倒な上、今回のゲーム上はさほど問題ではないので放置します。

スプライトのタッチを検出

スプライトおよびupdate:の使い方がわかったところで、次はタッチイベントの処理です。標的(はえ)が叩かれたら点数を加えて、その標的を消す、そういう処理です。
生のOpenGLで自前で画面を描画してるような場合、ビューでタッチされた座標と画面上に描画した要素の座標を比較してタッチされたかどうかを判定する必要がありますが、SpriteKitではスプライトがタッチされたかどうかはフレームワーク側で勝手に判断して処理してくれます。
従って、UIViewにタッチイベントを実装するのと同じ要領でスプライトにtouchesBegan:…等を実装するだけで、スプライトのタッチを検出できます。

標的のサブクラス化

スクリーンショット 2013-09-16 14.35.24スクリーンショット 2013-09-16 14.35.42
やり方は完全にUIViewにタッチイベントを実装するのと同じ方法です。SKSpriteNodeクラスを継承したHTHaeクラスを作り、中にtouchesBegan:メソッドを定義します。

#import "HTHae.h"
@implementation HTHae
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    NSLog(@"はえたたかれた");
}
@end

シーン側の初期化コードでもHTHaeクラスのインスタンスを作るように変えます。

#import "HTHae.h"
...
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        // (100, 100)にスプライトを追加する
        HTHae *sprite = [HTHae spriteNodeWithImageNamed:@"Spaceship"];
        ...
    }
    return self;
}

最後に、追加したspriteがタッチイベントに反応することを示すため、userInteractionEnabledをYESにします。

sprite.userInteractionEnabled = YES;

実行してみて、スプライトをタップしたらログが表示されるか確認してください。

シーンへの通知

シーン側で点数を数えるには、標的がタップされたことをシーン側で検知する(標的側からコールバックしてやる)必要があります。一般的なデリゲートの仕組みを使って通知しましょう。

@class HTHae;
@protocol HTHaeDelegate 
- (void)haeTouched:(HTHae *)hae;
@end
@interface HTHae : SKSpriteNode
@property (weak) id delegate;
@end

さらに、実装側のコードも変更して、touchesBegan:が呼ばれたらデリゲートのhaeTouched:を呼んでやるようにします。

@implementation HTHae
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    if ([self.delegate respondsToSelector:@selector(haeTouched:)]) {
        [self.delegate haeTouched:self];
    }
}
@end

シーンがHTHaeDelegateに準拠するようにして、標的のインスタンス生成時にdelegateをシーンにしてやります。

@interface HTMyScene()  {
    ...
}@end
...
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        HTHae *sprite = [HTHae spriteNodeWithImageNamed:@"Spaceship"];
        sprite.delegate = self;
        ....
    }
    return self;
}
- (void)haeTouched:(HTHae *)hae {
    NSLog(@"はえたたかれた by シーン");
}

実行してログを確認。

課題: タッチされる毎に点数をカウントしていく

点数をシーン側で管理し、標的がタッチされる毎に点数を増やしていきましょう。
回答

@interface HTMyScene()  {
    ...
    int point;
}@end
@implementation HTMyScene
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        point = 0; // 一応初期化しておく
        ...
    }
    return self;
}
- (void)haeTouched:(HTHae *)hae {
    point += 100;
    NSLog(@"ポイント: %d", point);
}

叩かれたら場所を移動する

叩かれたら、標的を他の場所に移動させます。
インスタンスの場所を画面内のランダムな地点に飛ばすようにします。本来は画面の外側に飛ばした方が自然ですが、動作確認がわかりにくいので一旦画面内に飛ばします。

- (void)haeTouched:(HTHae *)hae {
    ...
    float rand1 = (float)arc4random() / (float)ULONG_MAX;
    float rand2 = (float)arc4random() / (float)ULONG_MAX;
    hae.position = CGPointMake(rand1 * self.size.width,
                               rand2 * self.size.height);
}

シーンの画面サイズはself.sizeで取得できるので、その範囲のランダムな箇所に飛ばします。乱数生成は初期化が面倒なのでarc4random()を使ってますが、rand()でもかまいません。

標的の数を増やす

リファクタリング

ここで一度コードを整理しておきます。
update:内で標的を動かすコードは、HTHaeクラスにmove:メソッドとして定義しましょう。その際引数で前回更新からの経過時間を渡してやります。

@interface HTHae : SKSpriteNode
...
- (void)move:(CFTimeInterval)timeSinceLastUpdate; // 追加
@end
---
@implementation HTHae
...
- (void)move:(CFTimeInterval)timeSinceLastUpdate {
    const float speed = 30.0f;
    const float angularSpeed = 6.28f / 10.0f;
    // 回転させる
    float angularAmount = angularSpeed * timeSinceLastUpdate;
    self.zRotation = self.zRotation + angularAmount;
    // 向いている方向に移動させる
    float moveAmount = speed * timeSinceLastUpdate;
    float theta = self.zRotation + 1.57f;
    float moveAmountX = cos(theta) * moveAmount;
    float moveAmountY = sin(theta) * moveAmount;
    CGPoint positionBefore = self.position;
    self.position = CGPointMake(positionBefore.x + moveAmountX,
                                positionBefore.y + moveAmountY);
}
@end

シーン側からはmove:メソッドを呼び出します。

@interface HTMyScene()  {
    HTHae *mySprite; // ここの扱いもHTHaeクラスにしておく
    ...
}@end
@implementation HTMyScene
...
-(void)update:(CFTimeInterval)currentTime {
    // 前回のフレームの更新時刻を記憶しておく
    dispatch_once(&lastUpdatedAtInitToken, ^{
        lastUpdatedAt = currentTime;
    });
    // 前回フレーム更新からの経過時刻を計算する
    CFTimeInterval timeSinceLastUpdate = currentTime - lastUpdatedAt;
    lastUpdatedAt = currentTime;
    [mySprite move:timeSinceLastUpdate];
}
@end

シーンが少し読みやすくなりました。

標的の数を増やす

スクリーンショット 2013-09-16 15.13.37標的をNSArrayで管理して、複数表示できるようにします。ただインスタンスをNSArrayで管理する変更だけで、特にSpriteKit固有の話はないので、結果だけ表示。

@interface HTMyScene() {
    NSArray *targets;
    ...
}@end
@implementation HTMyScene
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        self.backgroundColor = [SKColor colorWithRed:0.15 green:0.15 blue:0.3 alpha:1.0];
        // 標的を適当に追加する
        NSMutableArray *targets_ = @[].mutableCopy;
        for (int i = 0; i < 5; i++) {
            HTHae *sprite = [HTHae spriteNodeWithImageNamed:@"Spaceship"];
            sprite.position = CGPointMake(100.0f, 100.0f + i * 50.0f);
            ...
            [self addChild:sprite];
            [targets_ addObject:sprite];
        }
        targets = targets_;
    }
    return self;
}
...
-(void)update:(CFTimeInterval)currentTime {
    // 前回のフレームの更新時刻を記憶しておく
    dispatch_once(&lastUpdatedAtInitToken, ^{
        lastUpdatedAt = currentTime;
    });
    // 前回フレーム更新からの経過時刻を計算する
    CFTimeInterval timeSinceLastUpdate = currentTime - lastUpdatedAt;
    lastUpdatedAt = currentTime;
    for (HTHae *target in targets) {
        [target move:timeSinceLastUpdate];
    }
}
@end

標的の動きをランダムにする

円運動を繰り返すだけではなんの面白みもないので、適当なタイミングで回転の方向が変わるようにしてみましょう。
スクリーンショット 2013-09-16 16.20.01まずは、インスタンス毎の回転速度(angularSpeed)を定数ではなくプロパティにします。

@interface HTHae : SKSpriteNode
...
@property (assign) float angularSpeed; // 追加
- (void)move:(CFTimeInterval)timeSinceLastUpdate;
@end
---
@implementation HTHae
...
- (void)move:(CFTimeInterval)timeSinceLastUpdate {
    const float speed = 30.0f;
    // 回転させる
    float angularAmount = self.angularSpeed * timeSinceLastUpdate;
    ...
}
@end

シーン側でインスタンスを生成する際に、インスタンス毎に値がランダムになるようにしましょう。

HTHae *sprite = [HTHae spriteNodeWithImageNamed:@"Spaceship"];
// -3.14〜3.14の間でランダムに
sprite.angularSpeed = (float)arc4random() / (float)ULONG_MAX * 6.28f - 3.14f;
...

標的がランダムな方向に回るようになりましたが、相変わらず円運動なので、さらにupdate:メソッド内で一定の割合で方向転換を行うように実装します。

-(void)update:(CFTimeInterval)currentTime {
    ...
    for (HTHae *target in targets) {
        // 10%の確率で、方向転換させる
        if ((float)arc4random() / (float)ULONG_MAX < 0.1f) {
            target.angularSpeed = (float)arc4random() / (float)ULONG_MAX * 6.28f - 3.14f;
        }
        [target move:timeSinceLastUpdate];
    }
}

画面外に行ったときの処理

さて、このままプログラムを動かし続けると、標的はやがて画面の外側に消えていき最終的には二度と戻ってこなくなります。
それは少し困るので、update:メソッドの中で標的が画面の中にいるかチェックして、画面の外にいったら画面の反対側にワープさせます。

    for (HTHae *target in targets) {
        // 10%の確率で、方向転換させる
        if ((float)arc4random() / (float)ULONG_MAX < 0.1f) {
            target.angularSpeed = (float)arc4random() / (float)ULONG_MAX * 6.28f - 3.14f;
        }
        [target move:timeSinceLastUpdate];
        if (target.position.x < 0) { // 左端             target.position = CGPointMake(self.size.width, target.position.y);         }         if (target.position.x > self.size.width) { // 右端
            target.position = CGPointMake(0, target.position.y);
        }
        if (target.position.y < 0) { // 下端             target.position = CGPointMake(target.position.x, self.size.height);         }         if (target.position.y > self.size.height) { // 上端
            target.position = CGPointMake(target.position.x, 0);
        }
    }

画面の端にいった瞬間にすぐにワープさせてしまうと違和感があるので、適当にマージンを与えてやった方が自然になります。

    const float outerMargin = 50.0f;
    const float leftEnd = -outerMargin;
    const float rightEnd = self.size.width + outerMargin;
    const float topEnd = self.size.height + outerMargin;
    const float bottomEnd = -outerMargin;
    for (HTHae *target in targets) {
        // 10%の確率で、方向転換させる
        if ((float)arc4random() / (float)ULONG_MAX < 0.1f) {
            target.angularSpeed = (float)arc4random() / (float)ULONG_MAX * 6.28f - 3.14f;
        }
        [target move:timeSinceLastUpdate];
        if (target.position.x < leftEnd) { // 左端             target.position = CGPointMake(rightEnd, target.position.y);         }         if (target.position.x > rightEnd) { // 右端
            target.position = CGPointMake(leftEnd, target.position.y);
        }
        if (target.position.y < bottomEnd) { // 下端             target.position = CGPointMake(target.position.x, topEnd);         }         if (target.position.y > topEnd) { // 上端
            target.position = CGPointMake(target.position.x, bottomEnd);
        }
    }

課題1: 初期位置の最適化

ゲーム開始時に標的がでてくる位置をランダムにしましょう。
回答

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        for (int i = 0; i < 5; i++) {
            HTHae *sprite = [HTHae spriteNodeWithImageNamed:@"Spaceship"];
            sprite.position = CGPointMake((float)arc4random() / (float)ULONG_MAX * size.width,
                                          (float)arc4random() / (float)ULONG_MAX * size.height);
            ...
        }
        targets = targets_;
    }
    return self;
}

課題2: 標的を叩いた後に移動する先を画面の外側にする

画面内のランダムな場所に突然出現するのは違和感があるので、画面の外側に移動するようにしましょう。

ポイントを表示する

スクリーンショット 2013-09-16 18.34.03ポイントを画面上に表示させてみます。
OpenGLを使ってゲームを作った経験がある方はおわかりだと思いますが、文字の描画はOpenGL自体に機能が提供されておらず、自前で実装するのが面倒くさい実装の一つですが、幸いSpriteKitには簡単に画面上に文字描画を行うための機能が用意されています。
それが、プロジェクトの最初のタイミングでテンプレートから消してしまったSKLabelNodeというやつです。名前から予想がつくかもしれませんが、SKSpriteNodeと同じ「ノード」というやつで、SKSpriteNodeと同じ感覚で使えます。SKSpriteNodeがイメージを表示するためのノードであるのに対して、SKLabelNodeはUILabelのように、文字を表示するためのノードです。

@interface HTMyScene()  {
    ...
    SKLabelNode *pointLabel;
}@end
@implementation HTMyScene
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        // 標的を適当に追加する
        ...
        // ポイント表示用のラベルを追加する
        pointLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"];
        pointLabel.text = @"0";
        pointLabel.fontSize = 30;
        pointLabel.position = CGPointMake(CGRectGetMidX(self.frame),
                                          CGRectGetMidY(self.frame));
        [self addChild:pointLabel];
    }
    return self;
}

SKLabelNodeはシーンへの追加さえしてしまえば、後はUILabelと同じ感覚で使えます。

- (void)haeTouched:(HTHae *)hae {
    point += 100;
    ...
    pointLabel.text = [NSString stringWithFormat:@"%d", point];
}

制限時間の実装

もはやここらへんはSpriteKitが関係なくなってくるので、SpriteKitが関係ない部分は説明を端折っていきます。

残り時間の表示

スクリーンショット 2013-09-16 19.25.34スクリーンショット 2013-09-16 19.25.36
update:内でゲーム開始からの経過時間を記録していき、残り時間をログに表示してみます。

static const CFTimeInterval GameInterval = 30.0; // 1ゲーム30秒間
@interface HTMyScene() {
    ...
    CFTimeInterval gameTime; // ゲーム開始からの経過時間を記録しておくための変数
}@end
...
-(void)update:(CFTimeInterval)currentTime {
    ...
    // 前回フレーム更新からの経過時刻を計算する
    ...
    // ゲームの経過時間を更新する
    gameTime += timeSinceLastUpdate;
    CFTimeInterval timeRemaining = GameInterval - gameTime;
    NSLog(@"残り時間 %d", (int)ceil(timeRemaining));
    ...
}

動作が問題なさそうであれば、ポイントラベル同様の手順でSKLabelNodeを追加し、そこに残り時間を表示しましょう。制限時間オーバーの場合はGame over!と表示しましょう。

@interface HTMyScene() {
    ...
    SKLabelNode *timeLabel;
}@end
@implementation HTMyScene
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        // ポイントのラベルと同様の手順でSKLabelNodeを追加する
        timeLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"];
        timeLabel.text = @"";
        timeLabel.fontSize = 30;
        timeLabel.position = CGPointMake(CGRectGetMidX(self.frame), 30.0f);
        [self addChild:timeLabel];
    }
    return self;
}
...
-(void)update:(CFTimeInterval)currentTime {
    ...
    // ゲームの経過時間を更新する
    gameTime += timeSinceLastUpdate;
    CFTimeInterval timeRemaining = GameInterval - gameTime;
    if (timeRemaining >= 0.0) {
        timeLabel.text = [NSString stringWithFormat:@"%d", (int)ceil(timeRemaining)];
    } else {
        timeLabel.text = @"Game over!";
    }
    ...
}
@end

制限時間すぎたら叩けないようにする

そろそろ疲れてきた。俺の集中力の制限時間も近い。

- (void)haeTouched:(HTHae *)hae{
    CFTimeInterval timeRemaining = GameInterval - gameTime;
    if (timeRemaining < 0) return;
    point += 100;
    ...
}

リトライボタンを作る

最後です。制限時間が終わったら、画面上にリトライボタンを表示し、クリックされたらゲーム経過時間・ポイントをクリアして、ゲームをリスタートできるようにしましょう。
まず、リトライボタンが必要です。タッチされたらシーンに「リトライ」を通知する必要があるので、標的のHTHaeクラスと同様にノードのサブクラスを作ります。「リトライ」の画像を用意すれば、HTHaeクラスと同様にSKSpriteNodeのサブクラスでも良いですが、面倒であればSKLabelNodeのサブクラスで実装してもOK。

#import <SpriteKit/SpriteKit.h>
@protocol HTRetryButtonDelegate
- (void)retry;
@end
@interface HTRetryButton : SKLabelNode
@property (weak) id delegate;
@end

デリゲート、通知センター、コールバックブロックどの形でも良いのですが、今回もデリゲートで。

@implementation HTRetryButton
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    if ([self.delegate respondsToSelector:@selector(retry)]) {
        [self.delegate retry];
    }
}
@end

リトライボタンは制限時間をすぎてゲームオーバーになっている時のみ表示したいので、initWithSize:内ではインスタンス化はするけどシーンには追加しないでおきます。

#import "HTRetryButton.h"
...
// HTRetryButtonDelegateプロトコルに準拠させる
@interface HTMyScene()  {
    ...
    HTRetryButton *retryButton;
}@end
@implementation HTMyScene
-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        ...
        retryButton = [HTRetryButton labelNodeWithFontNamed:@"Chalkduster"];
        retryButton.text = @"Retry";
        retryButton.fontSize = 30;
        retryButton.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame) - 50);
        retryButton.userInteractionEnabled = YES;
        retryButton.delegate = self;
        // ここではaddしない。
    }
    return self;
}
@end

ゲームオーバーになったタイミングでシーンに追加しましょう。ゲームオーバーになったタイミングで一度だけ処理するのは少し面倒ですが、前回更新時にゲームオーバーだったかどうか(時間が残っていたかどうか)を覚えておくことで実現できます。

@interface HTMyScene()  {
    ...
    BOOL isTimeRemaining;
}@end
@implementation HTMyScene
...
-(void)update:(CFTimeInterval)currentTime {
    ...
    // ゲームの経過時間を更新する
    gameTime += timeSinceLastUpdate;
    CFTimeInterval timeRemaining = GameInterval - gameTime;
    if (timeRemaining >= 0) {
        timeLabel.text = [NSString stringWithFormat:@"%d", (int)ceil(timeRemaining)];
        isTimeRemaining = YES;
    } else {
        if (isTimeRemaining) {
            [self addChild:retryButton];
            isTimeRemaining = NO;
        }
        timeLabel.text = @"Game over!";
    }
    ...
}
...
@end

リトライボタンがおされたら、制限時間とポイントをリセットします。
リトライボタン自身はremoveFromParentメソッドを呼ぶことで、シーンから削除できます。(addChild:の逆)

- (void)retry {
    isTimeRemaining = YES;
    gameTime = 0.0;
    point = 0;
    [retryButton removeFromParent];
}

おわり

どうでしょうか、この文章が読まれている頃、僕は億万長者になっているでしょうか。
アクションだったり物理エンジンだったり、まだまだ学ぶべきことはたくさんありますが、なんだか口内炎が痛いので今回はここら辺で終わりにしておこうと思います。
気が向いたら続き書きます。

Pocket

「SpriteKitを使って一攫千金を狙うチュートリアル」への27件のフィードバック

  1. 初めまして。突然のコメント失礼いたします。
    素人ですがiPhoneアプリでRPG、ボードゲーム等を作りたく、
    cocos2dをはじめようと思ったのですが、spritekitの存在を知りました。
    spritekitがあればcocos2dで作るようなRPG、ボードゲーム等を作ることができるのでしょうか??

  2. すごく分りやすかったです。ありがとうございます。
    iphone5sのような64bit端末だと、ULONG_MAX が大き過ぎて、
    (float)arc4random() / (float)ULONG_MAX
    の値が0.0〜1.0にならないようです(いつも0.0になってしまう)

minty_opera へ返信する コメントをキャンセル

メールアドレスが公開されることはありません。 が付いている欄は必須項目です