工場裏のアーカイブス

素人によるiPhoneアプリ開発の学習記 あと機械学習とかM5Stackとか

ゲームアプリの簡単な骨組み的なモノ(SpriteKit)

(※2020/01/12追記:本記事はObjective-Cを用いた、やや古い記事となります。同構成のアプリをSwiftで作成するための最新記事も公開しています)
chemicalfactory.hatenablog.com



SpriteKitではある画面(シーン)から別の画面へと、アニメーション付きで移動するような処理を、容易に実装可能な仕組みが備わっています。本記事ではそれらを活用して、下図のようにゲームアプリの簡単な骨組み的なモノを作成してみます。

f:id:fleron:20140426002350p:plain

このアプリでは最初タイトル画面が表示され、「情報」をタップすると説明画面に移動します(アプリの説明などを書くことを想定)。また「スタート」をタップするとゲーム画面に移動します。ゲーム画面では10秒のカウントダウンが赤字で表示され、0になるとゲームオーバー画面に強制移動します(制限時間のあるゲームを想定)。ゲームオーバー画面ではゲームをリトライする(ゲーム画面に再移動する)か、タイトル画面に戻るかを選べます。たったこれだけですが、これに肉付けをすれば単純なゲームなら作成出来ると思います。

アプリの実作

SpriteKitテンプレートの準備

まず、Xcode(本記事執筆時点:Version 5.1.1)で「SpriteKit」テンプレートを選択してプロジェクトを作成します。デフォルトで用意されている「MyScene.h/MyScene.m」および「Spaceship.png」は不要なので、削除してしまって構いません。まずプロジェクトにクラスを4つ追加します。名前はそれぞれ「TitleScene」「InfoScene」「GameScene」「GameOverScene」とし、全て SKScene のサブクラスとします。名前の通り、それぞれでタイトル画面、情報画面、ゲーム画面、ゲームオーバー画面を実装します。

タイトル画面(TitleScene)の実装

まず TitleScene.m を以下のコードに書き換えます(TitleScene.h は書き換え不要)

//TitleScene.m

#import "TitleScene.h"
#import "InfoScene.h"
#import "GameScene.h"

@implementation TitleScene

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        self.backgroundColor = [SKColor colorWithRed:0.15 green:0.15 blue:0.5 alpha:1.0];
        
        SKLabelNode *titleLabel = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        
        titleLabel.text = @"タイトル";
        titleLabel.fontSize = 50;
        titleLabel.fontColor = [UIColor yellowColor];
        titleLabel.position = CGPointMake(CGRectGetMidX(self.frame), self.frame.size.height - 200);
        
        SKAction *action1 = [SKAction scaleTo:1.5 duration:2.0];
        SKAction *action2 = [SKAction scaleTo:1.0 duration:2.0];
        SKAction *sequence = [SKAction sequence:@[action1, action2]];
        [titleLabel runAction:[SKAction repeatActionForever:sequence]];

        
        SKLabelNode *startButton = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        startButton.name = @"start";
        startButton.text = @"スタート";
        startButton.fontSize = 30;
        startButton.position = CGPointMake(CGRectGetMidX(self.frame), 200);
        
        SKLabelNode *infoButton = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        infoButton.name = @"info";
        infoButton.text = @"情報";
        infoButton.fontSize = 30;
        infoButton.position = CGPointMake(CGRectGetMidX(self.frame), 130);
        
        [self addChild:titleLabel];
        [self addChild:startButton];
        [self addChild:infoButton];
    }
    return self;
}

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    SKNode *startButton = [self childNodeWithName:@"start"];
    SKNode *infoButton = [self childNodeWithName:@"info"];
    
    CGPoint point = [[touches anyObject] locationInNode:self];
    
    if (startButton != nil && [startButton containsPoint:point]) {
        SKScene *scene = [[GameScene alloc] initWithSize:self.size];
        SKTransition *transition = [SKTransition crossFadeWithDuration:1.0];
        [self.view presentScene:scene transition:transition];
    }
    if (infoButton != nil && [infoButton containsPoint:point]) {
        SKScene *scene = [[InfoScene alloc] initWithSize:self.size];
        SKTransition *transition = [SKTransition pushWithDirection:SKTransitionDirectionRight duration:0.5];
        [self.view presentScene:scene transition:transition];
    }
}

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

@end

初期化時にはタイトルラベル(無駄に拡大縮小のアクションを付けています)、およびボタン代わりとなる2つのラベルを生成しています。SpriteKit ではボタン作成用の特別なクラスなどは用意されておらず、ラベルやスプライト画像へのタップをtouchesBegan: メソッドなどで検知し、ボタン代わりにするのがセオリーであるようです。ここでは単純に、タップ位置にラベルが存在するか、その名前(nameプロパティ)は何か、で場合分けをしています。

そして、画面移動の処理を実装しているのは以下のようなコードです。

SKScene *scene = [[GameScene alloc] initWithSize:self.size];
SKTransition *transition = [SKTransition crossFadeWithDuration:1.0];
[self.view presentScene:scene transition:transition];

最初に移動先となるシーンのインスタンスを生成します。また SKTransition クラスでは移動時のアニメーション(種類および時間)を設定します。ここでは crossFadeWithDuration: メソッド(クロスフェード)を設定していますが、それ以外にも様々な種類のアニメーションが用意されています(以下の外部サイト様なども参照)。
Developers.IO   Sprite Kit の画面遷移アニメーションまとめ
そして、presentScene:メソッドによって画面移動が実行されます。このとき、移動前のシーンのインスタンスに対する参照は外れて(特に別のオーナーが存在していない限り)破棄されるようです。

情報画面(InfoScene)の実装

InfoScene.m は以下のコードに書き換えます(InfoScene.h は書き換え不要)

//InfoScene.m

#import "InfoScene.h"
#import "TitleScene.h"

@implementation InfoScene

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        self.backgroundColor = [SKColor colorWithRed:0.15 green:0.15 blue:0.15 alpha:1.0];
        
        SKLabelNode *titleLabel = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        
        titleLabel.text = @"説明";
        titleLabel.fontSize = 50;
        titleLabel.fontColor = [UIColor greenColor];
        titleLabel.position = CGPointMake(CGRectGetMidX(self.frame), self.frame.size.height - 200);
        
        SKLabelNode *backButton = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        backButton.name = @"back";
        backButton.text = @"戻る";
        backButton.fontSize = 30;
        backButton.position = CGPointMake(CGRectGetMidX(self.frame), 200);
        
        [self addChild:titleLabel];
        [self addChild:backButton];
    }
    return self;
}

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    SKNode *backButton = [self childNodeWithName:@"back"];
    CGPoint point = [[touches anyObject] locationInNode:self];
    
    if (backButton != nil && [backButton containsPoint:point]) {
        SKScene *scene = [[TitleScene alloc] initWithSize:self.size];
        SKTransition *transition = [SKTransition pushWithDirection:SKTransitionDirectionLeft duration:0.5];
        [self.view presentScene:scene transition:transition];
    }
}
@end

やっていることはTitleScene.mとほとんど同じです。

ゲーム画面(GameScene)の実装

GameScene.m は以下のコードに書き換えます(GameScene.h は書き換え不要)

//GameScene.m

#import "GameScene.h"
#import "GameOverScene.h"

static const NSTimeInterval gameInterval = 10.0;

@implementation GameScene{
    bool isLastUpdateTimeInitialized;
    bool isTransition;
    NSTimeInterval lastUpdateTime;
    NSTimeInterval gameTime;
}

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {

        self.backgroundColor = [SKColor colorWithRed:0.8 green:0.8 blue:1.0 alpha:1.0];
        isLastUpdateTimeInitialized = NO;
        isTransition = NO;
        
        SKLabelNode *titleLabel = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        
        titleLabel.text = @"ゲーム画面";
        titleLabel.fontSize = 50;
        titleLabel.fontColor = [UIColor blueColor];
        titleLabel.position = CGPointMake(CGRectGetMidX(self.frame), self.frame.size.height - 200);
        
        SKLabelNode *timerLabel = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        timerLabel.name = @"timer";
        timerLabel.text = [NSString stringWithFormat:@"%d", (int)ceil(gameInterval)];
        timerLabel.fontColor = [UIColor redColor];
        timerLabel.fontSize = 30;
        timerLabel.position = CGPointMake(CGRectGetMidX(self.frame), 200);
        
        [self addChild:titleLabel];
        [self addChild:timerLabel];
    }
    return self;
}

-(void)update:(NSTimeInterval)currentTime{
    if(!isLastUpdateTimeInitialized){
        isLastUpdateTimeInitialized = YES;
        lastUpdateTime = currentTime;
    }
    
    NSTimeInterval timeSinceLast = currentTime - lastUpdateTime;
    lastUpdateTime = currentTime;
    
    
    gameTime += timeSinceLast;
    NSTimeInterval timeRemaining = gameInterval - gameTime;
    SKLabelNode *timerLabel = (SKLabelNode *)[self childNodeWithName:@"timer"];
    timerLabel.text = [NSString stringWithFormat:@"%d", (int)ceil(timeRemaining)];
    
    if(timeRemaining <= 0.0){
        if(!isTransition){
            isTransition = YES;
            SKScene *scene = [[GameOverScene alloc] initWithSize:self.size];
            SKTransition *transition = [SKTransition fadeWithDuration:1.0];
            [self.view presentScene:scene transition:transition];
        };
    }
}
@end

初期化時に各ラベル(ここでは「ゲーム画面」という文字、およびカウントダウン表示用)を生成するのはこれまでと同様です。そしてここではupdate:メソッドを用いて「10秒のカウントダウンを行い、0になったらゲームオーバー画面に強制移動する」という処理を実装していますが、その手続きは案外面倒です。以下では具体的なメソッドの中身について説明していきます。

update:メソッドはフレーム毎に呼ばれますが、処理の重さなどによってFPSは変動し得るため、その時間間隔は必ずしも一定になるとは限りません。そのため、単純にupdate:メソッドの呼び出し回数で経過秒数をカウントするような方法は使えません。しかしupdate:メソッドにはcurrentTime引数が存在し、ここにはメソッドが呼ばれた時点でのシステム時刻が渡されます。そこで、前回呼び出し時の時刻を記録しておき、今回呼び出し時の時刻との差分を求めれば、正確な経過秒数をカウントすることが可能になります。

具体的にこれらの処理を実装しているのが、update:メソッド内の以下の部分です。

if(!isLastUpdateTimeInitialized){
    isLastUpdateTimeInitialized = YES;
    lastUpdateTime = currentTime;
}
    
NSTimeInterval timeSinceLast = currentTime - lastUpdateTime;
lastUpdateTime = currentTime;

前回呼び出し時の時刻を記録する lastUpdateTime 変数を用意しておき、初回のupdate:メソッド呼び出し時のみ初期化します(currentTimeを代入)。そして時刻の差分、すなわち前回呼び出し時からの経過秒数を timeSinceLast 変数に代入し、あとは lastUpdateTime を currentTime で更新します。

update:メソッドの続く部分では、カウントダウンを表示する処理を実装しています。

gameTime += timeSinceLast;
NSTimeInterval timeRemaining = gameInterval - gameTime;
SKLabelNode *timerLabel = (SKLabelNode *)[self childNodeWithName:@"timer"];
timerLabel.text = [NSString stringWithFormat:@"%d", (int)ceil(timeRemaining)];

総経過秒数を記録する gameTime 変数を用意しておき、先程の timeSinceLast を加算します。あとは、あらかじめ定数として設定した10秒の制限時間(gameInterval)から、gameTimeを差し引いてラベルに(整数として)表示しているだけです。

update:メソッドの最後の部分では、カウントダウンが0になったときに、ゲームオーバー画面に強制移動する処理を実装しています。

if(timeRemaining <= 0.0){
    if(!isTransition){
        isTransition = YES;
        SKScene *scene = [[GameOverScene alloc] initWithSize:self.size];
        SKTransition *transition = [SKTransition fadeWithDuration:1.0];
        [self.view presentScene:scene transition:transition];
    };
}

ここでのポイントは、フラグ(isTransition)を用いて、カウントダウンが0になった後に presentScene: メソッドが「1回だけ」呼び出されるようにしていることです。こうしないと、意図した通りに画面移動が実行されません。

ゲームオーバー画面(GameOverScene)の実装

GameOverScene.m は以下のコードに書き換えます(GameOverScene.h は書き換え不要)

//GameOverScene.m
#import "GameOverScene.h"
#import "TitleScene.h"
#import "GameScene.h"

@implementation GameOverScene

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        self.backgroundColor = [SKColor colorWithRed:0.0 green:0.0 blue:0.0 alpha:1.0];
        
        SKLabelNode *titleLabel = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        
        titleLabel.text = @"ゲームオーバー";
        titleLabel.fontSize = 40;
        titleLabel.fontColor = [UIColor whiteColor];
        titleLabel.position = CGPointMake(CGRectGetMidX(self.frame), self.frame.size.height - 200);
        
        SKLabelNode *retryButton = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        retryButton.name = @"retry";
        retryButton.text = @"リトライ";
        retryButton.fontSize = 30;
        retryButton.position = CGPointMake(CGRectGetMidX(self.frame), 200);
        
        SKLabelNode *backButton = [SKLabelNode labelNodeWithFontNamed:@"Hiragino Kaku Gothic ProN W3"];
        backButton.name = @"back";
        backButton.text = @"タイトルへ戻る";
        backButton.fontSize = 30;
        backButton.position = CGPointMake(CGRectGetMidX(self.frame), 130);
        
        [self addChild:titleLabel];
        [self addChild:retryButton];
        [self addChild:backButton];
    }
    return self;
}

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    SKNode *retryButton = [self childNodeWithName:@"retry"];
    SKNode *backButton = [self childNodeWithName:@"back"];
    CGPoint point = [[touches anyObject] locationInNode:self];
    
    if (retryButton != nil && [retryButton containsPoint:point]) {
        SKScene *scene = [[GameScene alloc] initWithSize:self.size];
        SKTransition *transition = [SKTransition crossFadeWithDuration:1.0];
        [self.view presentScene:scene transition:transition];
    }
    if (backButton != nil && [backButton containsPoint:point]) {
        SKScene *scene = [[TitleScene alloc] initWithSize:self.size];
        SKTransition *transition = [SKTransition crossFadeWithDuration:1.0];
        [self.view presentScene:scene transition:transition];
    }
}
@end

ここでもやっていることは TitleScene.m とほとんど同じです。

ViewControllerの書き換え

最後に ViewController.m の中身を以下のように書き換えます(ViewController.h は書き換え不要)

//ViewController.m

#import "ViewController.h"
#import "TitleScene.h"

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    SKView * skView = (SKView *)self.view;

    SKScene * scene = [TitleScene sceneWithSize:skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;

    [skView presentScene:scene];
}

- (BOOL)shouldAutorotate
{
    return YES;
}

- (NSUInteger)supportedInterfaceOrientations
{
    if ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone) {
        return UIInterfaceOrientationMaskAllButUpsideDown;
    } else {
        return UIInterfaceOrientationMaskAll;
    }
}

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
    // Release any cached data, images, etc that aren't in use.
}
@end

書き換えといっても、ヘッダファイルを追加し、viewDidLoadメソッドで最初にタイトル画面(TitleScene)を表示するようにしただけです。これでこの骨組みアプリは完成です。