SpriteKitに触れてみる(2)
SpriteKit Game テンプレートの内容
まずはXcode 5で、SpriteKit Game テンプレートを選んでプロジェクトを作成してみます。
作成されたプロジェクトをビルドして実行してみると、最初は下図左のように、深青色の背景に「Hello, World!」という語句がラベルにより表示されています。そして画面をタップすると、下図右のように戦闘機(?)の画像(スプライト)がその位置に出現して一定速度で回転を続けます。そして画面下部にはノード(文字ラベルやスプライトなど、SpriteKitで描画される要素の総体)の数、およびFPSが表示されています(下図はシミュレータからのキャプチャなのでFPSの値が低いですが、iPhone 5sの実機では、もっと多数のスプライトを表示させてもFPS 60でスムーズに動作します)。
このテンプレートの中身を読み解いてみます。
(参考:SpriteKit の公式リファレンス)
SpriteKit Game テンプレートの中身の解読
プロジェクトの設定周りについて
まずテンプレートで生成されたプロジェクトを眺めてみると、「SpriteKit.framework」というフレームワークがリンクされています。名前の通りSpriteKitを利用時に必要なフレームワークです。例えばテンプレートを用いずに作成した他プロジェクトでSpriteKitを利用したい場合は、これをリンクする必要があるようです。
またstoryboardを見ると、View のクラスとして「SKView」が設定されています。SKView は UIView の派生クラスであり、SpriteKit を用いたコンテンツを表示するための部品です。
プロジェクトの設定周りで、SpriteKit Game テンプレートの他と違う大きな特徴はこのぐらいです。
ViewController の内容について
いよいよソースコードの中身を見てみます。AppDelegate には、特にSpriteKit特有の記述は無いようですので、まずは ViewController.h に目を向けます。
//ViewController.h #import <UIKit/UIKit.h> #import <SpriteKit/SpriteKit.h> @interface ViewController : UIViewController @end
「SpriteKit.h」というヘッダをインポートしていますが、それ以外は、ごく標準的に UIViewController を継承した ViewController となっています。特別な派生クラスなどを継承しているわけではありません。続いて ViewController.m に目を向けます。
//ViewController.m #import "ViewController.h" #import "MyScene.h" @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; // Configure the view. SKView * skView = (SKView *)self.view; skView.showsFPS = YES; skView.showsNodeCount = YES; // Create and configure the scene. SKScene * scene = [MyScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [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
いくつかメソッドがありますが、SpriteKit に関連する部分は「MyScene.h」というヘッダのインポートと、viewDidLoad メソッドの中身だけです。このメソッドの中身を少しずつ見ていくと、まず SKView* 型の変数 skView を宣言して、self.view をキャストして代入しています。
SKView * skView = (SKView *)self.view;
skView.showsFPS = YES;
skView.showsNodeCount = YES;
このようなキャストおよび代入が可能なのは、前述のようにstoryboardで、View のクラスとして SKView があらかじめ設定されているためです。以降は skView 変数を介して、self.view を SKView として取り扱うことが可能になります。そして showsFPS および showsNodeCount プロパティでは、アプリ画面下部にノードの数、およびFPSを表示するか否か設定出来ます(試しに NO を代入して実行すると、当然これらの表示は消えます)。
続きを見てみると、今度は MyScene というクラスのインスタンスを生成しています。
// Create and configure the scene.
SKScene * scene = [MyScene sceneWithSize:skView.bounds.size];
scene.scaleMode = SKSceneScaleModeAspectFill;
このクラスは、プロジェクトにデフォルトで存在しているMyScene.h/MyScene.m で 定義されています(「MyScene.h」というヘッダがインポートされている所以)。詳しくは後述しますが、このクラスは SKScene というクラスから派生しており、1つの「シーン」を定義しています。ゲームで言えば「タイトル画面」「実際のゲーム画面」「ゲームオーバー画面」など、それぞれの画面(場面)に当たるのがシーンです。
そして前述したように、各シーンで表示される文字ラベルや画像(スプライト)などの要素に当たるのがノード(SKNode クラスから派生)です。SpriteKit では主にこれらの「シーン」「ノード」という概念によって描画内容を構築して行きます。
そして scaleMode というプロパティでは、シーンと View のサイズが異なるときに、シーンをどのように拡大縮小して View に貼付けるのかを設定出来るようです。ここでは 貼付け先の View(縦画面時)と同じサイズのシーンを生成しているため、そのままでは設定による違いは見えませんが、例えば横画面にしたとき(Viewの幅と高さが入れ替わり、サイズが変わる)には違いが生じます。
そして最後の行では、presentScene メソッドによって、実際に View 上にシーンを表示します。
// Present the scene.
[skView presentScene:scene];
まとめると、 ViewController がやっているのは、MyScene クラスで定義されたシーンを View(SKViewとして扱い) に表示させることだけです。
MySceneの内容について
今度は MyScene クラスの中身に目を向けて、シーンの定義がどのようになっているか見ていきます。まずは MyScene.h から。
//MyScene.h #import <SpriteKit/SpriteKit.h> @interface MyScene : SKScene @end
前述のように、MySceneクラスは SKScene クラスから派生しています。続いて MyScene.m を見てみます。
//MyScene.m #import "MyScene.h" @implementation MyScene -(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]; SKLabelNode *myLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"]; myLabel.text = @"Hello, World!"; myLabel.fontSize = 30; myLabel.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame)); [self addChild:myLabel]; } return self; } -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { /* Called when a touch begins */ for (UITouch *touch in touches) { CGPoint location = [touch locationInNode:self]; SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"]; sprite.position = location; SKAction *action = [SKAction rotateByAngle:M_PI duration:1]; [sprite runAction:[SKAction repeatActionForever:action]]; [self addChild:sprite]; } } -(void)update:(CFTimeInterval)currentTime { /* Called before each frame is rendered */ }
まずは初期化メソッド(initWithSize:)の中身を見ていきます。ここではまず backgroundColor プロパティによってシーンの背景色(あの深青色)を設定しています。SKColor なるものが色指定に用いられていますが、これは開発対象(iOS向けかどうか)によって UIColor と NSColor を切り替えるだけのマクロのようです。試しに UIColor に書き換えても、全く何事もなく動作します。
self.backgroundColor = [SKColor colorWithRed:0.15 green:0.15 blue:0.3 alpha:1.0];
その続きの部分では、文字ラベル(画面中央に表示されていた「Hello, World!」)を生成しています。
SKLabelNode *myLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"]; myLabel.text = @"Hello, World!"; myLabel.fontSize = 30; myLabel.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame));
文字ラベルもノードの一種であり、SKLabelNode というクラス(もちろん SKNode から派生)を用いて扱います。初期化のときにフォントの種類を選択し、プロパティでテキストの内容、フォントサイズ、表示位置を設定し…と、やっていることは分かり易いと思います。ここで用いられている以外にも様々なプロパティがあり、たとえば fontColor というプロパティでは文字の色を変えることが出来ます。
あとは addChild: メソッドによって、ノードをシーンに追加します。シーンを表示した際に、追加した各ノードが設定に従って描画されるようになります。
[self addChild:myLabel];
続いて、touchesBegan: メソッドの中身を見ていきます。ここでは、画面をタップした位置に戦闘機の画像(スプライト)が出現し、回転する動作を定義しています。
SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"];
sprite.position = location;
スプライトは SKSpriteNode というクラス(もちろん SKNode から派生)を用いて扱います。あらかじめプロジェクトに追加されている「Spaceship.png」をスプライト画像として初期化し、position プロパティによってその表示位置を、画面のタップされた箇所(変数 location)に設定しています。
そしてその続きでは SKAction というクラスが新登場しています。これはSpriteKitにおいて、各ノードに様々なアクション(移動、回転、拡大縮小、アニメーションなどなど)を行わせるための仕組みとなります。
SKAction *action = [SKAction rotateByAngle:M_PI duration:1]; [sprite runAction:[SKAction repeatActionForever:action]]; [self addChild:sprite];
ここでは rotateByAngle: メソッドで「1秒間かけて、ノードを180°(πラジアン)回転する」というアクションを生成しています。メソッドの第1引数で角度を、第2引数(durationラベル)で一度のアクションにかける秒数を指定します。
そして、先程生成したスプライトに runAction: メソッドを用いてアクションを設定します。ここでは repeatActionForever: というクラスメソッドを介していますが、これによりアクションが何度も繰り返し行われる(ここでは回転し続ける)ようになります(runAction: の引数にアクションを直接指定した場合は、一度だけ行われます)。
あとは先程同様に、スプライトをシーンに追加するだけです。
最後に、update: メソッドです。
-(void)update:(CFTimeInterval)currentTime { /* Called before each frame is rendered */ }
これは、シーンの毎フレームごとに自動的に呼び出されるメソッドです。毎フレームごとに何らかの共通処理を行う必要などがある場合には、このメソッドに中身を追加します(テンプレートでは特に何もしていないため中身がカラとなっています)。
テンプレートの解読は以上となります。そこまで難解な点はなく、比較的中身が理解し易いテンプレートであると思います。
テンプレートを少し改造
テンプレートをただ解読するだけでは面白くないので、少しだけ改造して遊んでみます。MyScene.m の touchesBegan: メソッドの中身を少しだけ書き換えてみます。
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { /* Called when a touch begins */ for (UITouch *touch in touches) { CGPoint location = [touch locationInNode:self]; SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"]; sprite.position = location; SKAction *rotate = [SKAction rotateByAngle:M_PI * 2 duration:1]; SKAction *fadeOut = [SKAction fadeOutWithDuration:1]; SKAction *remove = [SKAction removeFromParent]; SKAction *rotateAndfade = [SKAction group:@[rotate, fadeOut]]; SKAction *sequence = [SKAction sequence:@[rotateAndfade, remove]]; [sprite runAction:[SKAction repeatActionForever:sequence]]; [self addChild:sprite]; } }
ここでは、 まず fadeOutWithDuration: メソッドで「指定した秒数でノードをフェードアウトさせる」というアクション、そして removeFromParent: メソッドで「親(この場合はシーン)からノードを除去する」というアクションを生成しています。
そして、その直後で group: メソッドを用いていますが、このメソッドは「引数(配列)として与えられたアクションをまとめて、同時に行わせる」というアクションを生成します。ここでは「1秒間かけて、ノードを回転させながらフェードアウトさせる」というアクション(変数 rotateAndFade)を生成していることになります。
続いて sequence: メソッドを用いていますが、このメソッドは「引数(配列)として与えられたアクションを順番に行わせる」というアクションを生成します。すなわち、最終的には「1秒間かけてノードを回転させながらフェードアウトさせた後に、そのノードをシーンから除去する」というアクション(変数 sequence)を生成していることになります。
これをビルドして実行してみると、画面をタップした位置に戦闘機が出現しますが、それらは回転しながらフェードアウトして最終的には消滅します。消滅時にはノードがシーンから除去されるので、画面下部に表示されるノード数はきちんと減少します。
group: メソッド、sequence: メソッドを用いて様々なアクションを組み合わせることで、かなり複雑な動きやアニメーションも、各ノードに設定出来るようになると思います。