SceneKitにおけるパーティクルシステムの利用
SceneKitにはパーティクルシステムが用意されており、炎、煙、爆発、雨などのエフェクトを手軽に美しく表現することが可能です。本記事ではその利用法の例について簡単にメモします。
パーティクルファイルを利用する方法
ファイルをプロジェクトへ追加する
(2020/08/02追記)現行の最新バージョンであるXcode 11では、以下に記述するXcode 6時代と異なり、リソースから「SceneKit Particle System」が無くなってしまいました。代わりにリソースで「SceneKIt Scene File」を選択して、ファイル名を付ける際に拡張子をデフォルトの「.scn」から「.scnp」に書き換えることで、パーティクルファイルが作成出来るようです。ただし、これで生成されるファイルはほとんど設定がカラに近いものであり、テンプレート相当のものは得られないようです。Xcode 11でもテンプレートを使用したいのですが、その方法は見つけられていません…。
- Xcode(本記事執筆時点:Version 6.1.1)には、下図のようにリソースとして「SceneKit Particle System」というファイルが用意されています。これを利用すると、比較的簡単にパーティクルシステムを利用することが可能になります。まずは新規ファイルとして、これをSceneKitのプロジェクトに追加して行きます。
- まず下図のダイアログが表示されます。SceneKitではパーティクルシステムの様々なテンプレートが用意されており、ここではそれらを選択することが出来ます。本記事では「Fire」を選択します。なお、テンプレートの一覧については本記事の末尾にまとめてあります。
- 続いて、ファイル名を入力するダイアログが表示されますので、分かりやすい名前を付けます(本記事ではデフォルトの「MyParticleSystem」という名前のままにします)。これでプロジェクトに「〜.scnp」というパーティクルファイル、およびパーティクル表示用の画像ファイル(テンプレートにより異なる。Fireの場合は「Spark.png」)が追加されます。
- パーティクルファイルをプロジェクト上で選択すると、下図のようなエディタが起動します。右側のパネルで様々なプロパティを設定し、その効果を視覚的に確認しながら、パーティクルシステムにより望みのエフェクトが得られるよう調節することが出来ます(本記事ではデフォルトのままにします)。
実際にパーティクルを表示させる
先程のリソースファイルを用いて、実際にパーティクルを表示させる簡単なサンプルコードを以下に示します。
//GameViewController.swiftの中身を書き換え import UIKit import QuartzCore import SceneKit class GameViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let scene = SCNScene() let cameraNode = SCNNode() cameraNode.camera = SCNCamera() scene.rootNode.addChildNode(cameraNode) cameraNode.position = SCNVector3(x: 0, y: 0, z: 20) let pyramid = SCNPyramid(width: 2.0, height: 2.0, length: 2.0) pyramid.firstMaterial!.diffuse.contents = UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1.0) let pyramidNode = SCNNode() pyramidNode.geometry = pyramid pyramidNode.position = SCNVector3(x: 0.0, y: 0, z: 0) scene.rootNode.addChildNode(pyramidNode) pyramidNode.runAction(SCNAction.repeatActionForever( SCNAction.rotateByX(0.1, y: 0.2, z: 0.0, duration: 2.0) )) //パーティクルシステムのオブジェクト生成、およびノードへの追加 let fire = SCNParticleSystem(named: "MyParticleSystem.scnp", inDirectory: "") pyramidNode.addParticleSystem(fire) let scnView = self.view as SCNView scnView.scene = scene scnView.allowsCameraControl = true scnView.autoenablesDefaultLighting = true scnView.backgroundColor = UIColor(red: 0.6, green: 0.6, blue: 0.8, alpha: 1.0) } override func shouldAutorotate() -> Bool { return true } override func prefersStatusBarHidden() -> Bool { return true } override func supportedInterfaceOrientations() -> Int { if UIDevice.currentDevice().userInterfaceIdiom == .Phone { return Int(UIInterfaceOrientationMask.AllButUpsideDown.rawValue) } else { return Int(UIInterfaceOrientationMask.All.rawValue) } } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() } }
ポイントとなるのはこの部分です。
let fire = SCNParticleSystem(named: "MyParticleSystem.scnp", inDirectory: "") pyramidNode.addParticleSystem(fire)
パーティクルを表示させるには、まずはSCNParticleSystemメソッドでファイルを読み込み、パーティクルシステムのオブジェクトを生成します。なお、このメソッドには「inDirectory」という引数がありますが、プロジェクトに追加したファイルを利用する場合は空欄(””)で問題ないようです。
そしてオブジェクトを、立体モデルなどのノードにaddParticleSystemメソッドで取り付けることにより、そのノードからパーティクルが放出されるようになります。SceneKitではこのような形でパーティクルシステムを利用します。
サンプルコードを実行すると、ピラミッド型の立体モデルから「Fire」テンプレートのパーティクルが放出され、あたかもピラミッドが炎上するようなシーンが表示されます。
コード上で設定を行う方法
パーティクルファイルを用いず、以下のようにコード上でオブジェクト生成、およびプロパティ設定を全て行うことも可能です。
let parSys = SCNParticleSystem() parSys.birthRate = 100 parSys.particleColor = UIColor.yellowColor() parSys.emissionDuration = 0.1 parSys.particleSize = 0.05 //…………以下省略
もちろんパーティクルファイルを用いる場合でも、同様にオブジェクトのプロパティ設定をコード上で変更することが出来ます。例えばパーティクルの放出をオフにしたい場合は、「birthRate」プロパティの値を0に変更するという方法が使えます。
パーティクルシステムのテンプレート一覧
最後にパーティクルシステムのテンプレート(XcodeのVersion 6.1.1時点)の概要についてまとめます。
- Bokeh
- 黄色い光の粒が、湧き上がって消えていくようなエフェクトです。
- パーティクル表示用の画像ファイル:bokeh.png
- Confetti
- 紙吹雪が舞い落ちるエフェクトです。なぜかパーティクルエディタ上ではグラフィックが表示されないようです(バグ?情報不足により不明)。ただし実際のプロジェクト上で用いると、下図のようにきちんと表示されます。
- パーティクル表示用の画像ファイル:confetti.png
- Fire
- 炎が燃え上がるエフェクトです。
- パーティクル表示用の画像ファイル:spark.png(Rain、Reactorテンプレートと共通)
- Leafs
- このテンプレートは選択しても、何のファイルも生成されないようです (バグ?情報不足により不明)
- Rain
- 雨が降り落ちるエフェクトです。
- パーティクル表示用の画像ファイル:spark.png(Fire、Reactorテンプレートと共通)
- Reactor
- 激しい閃光のようなエフェクトです。
- パーティクル表示用の画像ファイル:spark.png(Fire、Rainテンプレートと共通)
- Smoke
- 煙が立ち昇るエフェクトです。
- パーティクル表示用の画像ファイル:smoke.png
- Stars
- 画面奥から手前に向かって、光の粒(星?)が飛んでくるようなエフェクトです。
- パーティクル表示用の画像ファイル:star.png
SceneKitに触れてみる(2)
本記事はSceneKitに触れてみる(1)の続きとなります。こちらの記事で公開した拙作のSceneKitサンプルアプリについて、本記事ではソースコードの要点などをまとめてみます。
SceneKitのテンプレートプロジェクトについて
ソースコードの前に、まずはSpriteKitのテンプレート(Gameテンプレートで選択)で生成されるプロジェクトを眺めてみます。storyboardを見ると、あらかじめビューコントローラー(GameViewController)が用意されており、「SceneKit View」という種類のビューが貼り付けられています。
SceneKit Viewは下図のようにカスタムクラスが「SCNView」となっており、これがSceneKitのコンテンツを描画するための基本ビューとなります。SceneKitではこのSCNViewの上にシーン(SCNScene)を配置し、そこに立体モデル、カメラ、光源などの様々なノード(SCNNode)を配置することによって画面を作っていきます。この辺りの考え方はSpriteKitとほぼ同様です。
シーン(SCNScene)の作成
それでは順番に、ソースコードの要点についてまとめていきます。まずは冒頭の viewDidLoad メソッドの周辺です。
weak var scnView : SCNView? override func viewDidLoad() { super.viewDidLoad() let scene = SCNScene() scnView = self.view as? SCNView scnView?.scene = scene scnView?.backgroundColor = UIColor(red: 0.2, green: 0.2, blue: 0.6, alpha: 1.0) scnView?.allowsCameraControl = true; setObject() setCamera() setLight() }
ここではscnViewというプロパティを用意してビューへの参照を取得し、空のシーン(SCNScene)を作成してビューに配置しています(sceneプロパティへの代入)。また背景色の設定もここで行っています。
そしてビュー(SCNView)には「allowsCameraControl」というプロパティがあり、これをtrueにするだけで、画面タッチ操作でカメラ(視点)を色々操作する機能が備わるようになります(以下は操作の例です。他にもあるかもしれません)。
- 1本指ドラッグ:視点の3次元回転
- 2本指ドラッグ:パン(視点の上下左右への移動)
- ピンチ:ズームイン or ズームアウト
- 回転:視点の軸回転
- ダブルタップ:視点のリセット
細かい設定(特定の操作のみ無効にするなど)は残念ながら出来ないようですが、このような操作を自前実装するのは結構大変なので、かなり便利です。
そして、シーンへ各種ノードを配置する処理については、ソースを見やすくするために、それぞれ別々のメソッドにまとめています。以降で順番に触れていきます。
シーンへの立体図形の配置
シーンに初期の立体モデル(本アプリでは、半透明の箱および地面)を配置する処理は、setObjectメソッドで行っています。
func setObject(){ let generator = SCNBox(width: 6.0, height: 6.0, length: 6.0, chamferRadius: 0.0) generator.firstMaterial?.diffuse.contents = UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.7) let generatorNode = SCNNode() generatorNode.geometry = generator generatorNode.position = SCNVector3(x: 0, y: 30, z: 0) generatorNode.name = "generator" self.scnView?.scene?.rootNode.addChildNode(generatorNode) let ground = SCNBox(width: 200.0, height: 2.0, length: 200.0, chamferRadius: 0.0) ground.firstMaterial?.diffuse.contents = UIColor(red: 0.8, green: 0.8, blue: 0.3, alpha: 1.0) let groundNode = SCNNode() groundNode.geometry = ground groundNode.position = SCNVector3(x: 0, y: 0, z: 0) groundNode.name = "ground" groundNode.physicsBody = SCNPhysicsBody.staticBody() groundNode.physicsBody?.physicsShape = SCNPhysicsShape(node: groundNode, options: nil) self.scnView?.scene?.rootNode.addChildNode(groundNode) }
SpriteKitではSKSpriteNode、SKLabelNode、SKShapeNodeなど、用途に応じて様々な種類のノードを使い分けていました。しかしSceneKitでは、まず空のノード(SCNNode)を生成し、そこに立体モデル、カメラ、光源など必要なオブジェクトを「取り付ける」ようなイメージでノードを扱うようです。
ここではまず、箱型の立体モデルのオブジェクトをSCNBoxクラスにより生成します。SceneKitにはこのSCNBoxのように、箱、球、円柱などの汎用的な立体モデルを生成するクラスが用意されています(SCNGeometryクラスより派生、他の立体も後で登場します)。そしてfirstMaterialプロパティで色(厳密には拡散光の色)を設定します。ここで色のアルファ値を1.0未満にすると、立体モデルを半透明化することが出来ます。
あとはオブジェクトを各ノードのgeometryプロパティに代入して取り付け、ノードの必要なプロパティ(位置、名前など)を設定して行きます。
ここで、地面ノード(groundNode)に対しては物理ボディを設定し、物理エンジンによる制御が働くようにしています。地面用なので、重力や他ボディの衝突影響などを受けないよう、staticBodyメソッドで物理ボディを生成します。そして、SCNPhysicsShapeで物理ボディの形状を規定します(ここではgroundNodeの形状をそのまま適用しています)。
そしてビューに配置したシーンに対して、addChildNodeメソッドでノードを配置します。この辺りはSpriteKitと同じ要領です。
シーンへのカメラの配置
シーンに視点となるカメラを配置する処理は、setCameraメソッドで行っています。
func setCamera(){ let cameraNode = SCNNode() cameraNode.camera = SCNCamera() self.scnView?.scene?.rootNode.addChildNode(cameraNode) cameraNode.position = SCNVector3(x: 0, y: 50, z: 50) cameraNode.rotation = SCNVector4(x: 1, y: 0, z: 0, w: -0.5) }
先程と同様まずは空のノードを生成します。そして今度はSCNCameraクラスによってカメラオブジェクトを生成し、ノードのcameraプロパティに代入して取り付けて、シーンに配置します。そしてカメラはデフォルトではZ軸のマイナス方向を向いているので、カメラノードの位置および向きを調節して、先ほど配置した立体モデルがアプリ起動時にうまく画面に収まるようにしています。
シーンへの光源の配置
シーンに光源を配置する処理は、setLightメソッドで行っています。
func setLight(){ let ambientLightNode = SCNNode() ambientLightNode.light = SCNLight() ambientLightNode.light?.type = SCNLightTypeAmbient ambientLightNode.light?.color = UIColor.darkGrayColor() self.scnView?.scene?.rootNode.addChildNode(ambientLightNode) let lightNode = SCNNode() lightNode.light = SCNLight() lightNode.light?.type = SCNLightTypeSpot lightNode.light?.spotOuterAngle = 90 lightNode.light?.color = UIColor.whiteColor() lightNode.light?.castsShadow = true lightNode.position = SCNVector3(x: 0, y: 50, z: 10) if let ground = self.scnView?.scene?.rootNode.childNodeWithName("ground", recursively: false){ let cons = SCNLookAtConstraint(target: ground) cons.influenceFactor = 1.0 lightNode.constraints = [cons] } self.scnView?.scene?.rootNode.addChildNode(lightNode) }
まず空のノードを生成し、光源オブジェクトを生成(SCNLightクラス)してノードに取り付け、シーンに配置する…という要領はこれまで同様ですが、ここでは種類(typeプロパティ)の異なる光源2つを用いています。
まず、typeプロパティにSCNLightTypeAmbientを設定した光源は、環境光として働きます。これは空間全体に一定の明るさを与えるような光であり、位置や方向に依存しないので、光の色(colorプロパティ)のみを設定します。
また、typeプロパティにSCNLightTypeSpotを設定した光源は、スポットライトとして働きます。これは特定の方向へ円錐状に広がる光であり、図にすると以下のようになります(非常に雑ですが)。
spotOuterAngleプロパティで円錐の広がる角度を設定します。また、castsShadowプロパティをtrueにすると、これだけでスポットライトにより生じる立体モデルの影が、他の立体モデルに投影されるようになります(個人的には、この機能には結構感激しました)。
ここで、スポットライトもデフォルトではマイナスZ軸方向を向いていますが、この向きを変えて、光源の位置(x: 0, y: 50, z: 10)から地面の中央を照らすようにすることを考えます。カメラのときと同様にrotationプロパティで向きを調節しても良いのですが、実はSceneKitには、あるノードがターゲットとなるノードの方向を常に向くよう束縛する仕組みが用意されています。ソース中にあるSCNLookAtConstraintがそれです。
ここではシーンに配置された地面ノードへの参照を取得し、それをターゲットとする方向束縛をスポットライトノードに設定しています(constraintプロパティへの代入)。これでスポットライトが常に地面の中央を照らすようになります。
なお、今回ターゲットとした地面は固定されており動きませんが、動き回るようなノードをターゲットにすることも出来ます。例えばカメラに方向束縛を設定し、動き回るキャラクターをカメラが常に追いかけるような状況設定も可能です。influenceFactorはそのような場合に影響するプロパティであり、値が1.0から小さくなるのに従って、束縛されたノードがターゲットの移動方向へ向き直るまでにタイムラグが生じるようになります。
画面タップ時の処理
本アプリでは、配置された半透明の箱がタップされると、ランダムな種類・色の立体モデルを新たに生成します。また、この新たな立体モデルはタップされると、フェードアウトして消滅します。これらの処理はtouchesBeganメソッドを用いて実装しています。
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { let p = touches.anyObject()!.locationInView(self.scnView) if let hitResults = self.scnView?.hitTest(p, options: nil) { if hitResults.count > 0 { let result = hitResults.first?.node if result?.name? == "generator"{ let geom = generateRandomGeometry() geom.firstMaterial?.diffuse.contents = UIColor(red: colorValue(), green: colorValue(), blue: colorValue(), alpha: 1.0) let geomNode = SCNNode(geometry: geom) geomNode.position = SCNVector3(x: 0, y: 30, z: 0) geomNode.name = "geometry" geomNode.physicsBody = SCNPhysicsBody.dynamicBody() geomNode.physicsBody?.physicsShape = SCNPhysicsShape(node: geomNode, options: nil) self.scnView?.scene?.rootNode.addChildNode(geomNode) }else if result?.name? == "geometry"{ result!.runAction(SCNAction.sequence([ SCNAction.fadeOutWithDuration(0.5), SCNAction.removeFromParentNode() ])) } } } } func generateRandomGeometry() -> SCNGeometry{ var kind = arc4random() % 6 switch kind{ case 0: let pyramid = SCNPyramid(width: 3.0, height: 3.0, length: 3.0) return pyramid case 1: let cylinder = SCNCylinder(radius: 1.5, height: 3.0) return cylinder case 2: let sphere = SCNSphere(radius: 1.5) return sphere case 3: let torous = SCNTorus(ringRadius: 1.5, pipeRadius: 0.5) return torous case 4: let chamferBox = SCNBox(width: 3.0, height: 3.0, length: 3.0, chamferRadius: 0.8) return chamferBox default: let box = SCNBox(width: 3.0, height: 3.0, length: 3.0, chamferRadius: 0.0) return box } } func colorValue() -> CGFloat{ let rand = CGFloat(arc4random() % 10000) return rand / 20000.0 + 0.5 }
立体モデルへのタップ検出
iPhone画面(すなわちビュー)へのタッチイベントが発生し、touchesBeganメソッドが呼び出されたら、まずはビュー上のタップ位置(座標)を取得します。そして、このタップ位置をビューのhitTestメソッドに引数として与えると、タップ位置と重なった全ての立体モデル(SCNGeometryの派生クラスのオブジェクト)のノード一覧を、配列として取得することが出来ます。
hitTestメソッドの動作は、与えられた位置から画面内の3D空間へ垂直に光線を飛ばし、その光線が貫く立体モデルを順次検出するようなイメージです。一覧配列には、検出順にノードが格納される(すなわち、視点に一番近い立体モデルノードが先頭要素になる)ようです。また、実際は一覧配列の型はSCNNodeそのものではなく、SCNHitTestResultという他の様々な情報も含んだクラスであり、このnodeプロパティからノードへの参照を取得します。
そして一覧配列の要素数が0ではなく、タップ位置に立体モデルが存在する場合は、一覧配列の先頭のノード(前述のように、視点に一番近い立体モデル)をresult変数に取得しています。あとはこのノードの名前(nameプロパティ)によって、処理の場合分けを行います。
半透明の箱タップ時の処理(新規立体モデルの追加生成)
半透明の箱(名前:”generator”)へのタップが検出されたら、generateRandomGeometryという自作メソッドを呼び出して、SCNGeometry系列の様々な汎用立体モデルオブジェクト(ピラミッド、円柱、球、トーラス、箱)をランダムに選んで生成しています。なお箱(SCNBox)については、chamferRadius引数の値を大きくすると、それに応じて角を丸めることが可能です。また、立体モデルの色(拡散光の色)は、0.5〜1.0の実数値をランダムに返すcolorValueという自作メソッドを用いて設定しています。
そして、この立体モデルをノードに取り付けて必要なプロパティを設定します。初期位置は半透明の箱と同じ位置とし、名前は”geometry”とします。そして物理ボディを設定するのですが、ここでは重力や衝突の影響を受けるようdynamicBodyメソッドを用います。
新規立体モデルタップ時の処理(フェードアウト消滅するアクション)
生成された新規立体モデル(名前:”geometry”)へのタップが検出されたら、そのノードにアクション(SCNActionクラス)をrunActionメソッドで与えて実行させます。
ここではfadeOutwithDuration、removeFromParentNodeアクションをシーケンス化して「ノードを指定秒数でフェードアウトさせた後に、親(この場合はシーン)から除去する」というアクションを与えています。これによりタップされた新規立体モデルは消滅します。
SceneKitにおけるアクションは、ほとんどSpriteKitと同じような感覚で扱うことが出来るようです。
SceneKitに触れてみる(1)
iOS 8に対応したXcode 6では、プロジェクトテンプレートの種類が変わり、OpenGL ES2 や SpriteKit を用いたアプリ開発は「Game」テンプレートに統合され、下図のようにプロジェクト作成時に「Game Technology」欄でフレームワークを選択する方式となりました。
そして、ここには「SceneKit」「Metal」という、これまでに無かったフレームワークも新たに登場しています。このうちSceneKitは、言わば3Dグラフィックス版のSpriteKitであり、前々から「SpriteKitと同じような手軽さで、3Dゲームなどのアプリ作れるツールは無いかな…」と願っていた身としては、まさに待望のツールです。
早速色々と触れてみたのですが、SceneKit では 3D空間のシーンにカメラ、光源、立体図形などをノードとして配置することによって、比較的手軽に3Dグラフィックスを用いたアプリを作成することが可能です(少なくともOpenGL ES2 + GLKitなどよりもよっぽど)。
またSpriteKitと同様に、ノードへの様々なアクションの設定や、物理エンジンやパーティクルシステムの利用も可能であり、上手く使いこなせばかなり凝った3Dゲームを作成することも夢ではなさそうです。
SceneKitの自作サンプル公開
まだまだSceneKitはリリースされてからの日が浅く、ネットの参考情報も限られている状況(日本語は特に)のようです。そこで本記事では、どこかで誰かの役に立つこともあるかもと願い、拙いながらも自作のサンプルアプリを公開してみたいと思います(下図、言語はSwift)。
このアプリでは画面内に半透明の箱が浮かんでおり、これをタップすると様々な形状、色の立体モデルがランダムに生成されます。立体モデルは物理エンジンによって落下し、互いに接触して積み重なったり散らかったりします。そして立体モデルをタップするとフェードアウトして消滅します。また、斜め上方から地面中央を照らすスポットライトによって、各立体モデルの影が投影されます。ただこれだけのアプリですが、SceneKitの機能を色々と盛り込んでいます。
ソースコードを以下に示します。実際に動かすためには、まずXcode(本記事執筆時点:Version 6.1.1)で「Game」テンプレートを選択し、「Language」欄は「Swift」、「Game Technology」欄は「SceneKit」を選択してプロジェクトを作成します。あとは「GameViewController.swift」の中身をこのソースコードに書き換えればOKです。
import UIKit import QuartzCore import SceneKit class GameViewController: UIViewController { weak var scnView : SCNView? override func viewDidLoad() { super.viewDidLoad() let scene = SCNScene() scnView = self.view as? SCNView scnView?.scene = scene scnView?.backgroundColor = UIColor(red: 0.2, green: 0.2, blue: 0.6, alpha: 1.0) scnView?.allowsCameraControl = true; setObject() setCamera() setLight() } func setObject(){ let generator = SCNBox(width: 6.0, height: 6.0, length: 6.0, chamferRadius: 0.0) generator.firstMaterial?.diffuse.contents = UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.7) let generatorNode = SCNNode() generatorNode.geometry = generator generatorNode.position = SCNVector3(x: 0, y: 30, z: 0) generatorNode.name = "generator" self.scnView?.scene?.rootNode.addChildNode(generatorNode) let ground = SCNBox(width: 200.0, height: 2.0, length: 200.0, chamferRadius: 0.0) ground.firstMaterial?.diffuse.contents = UIColor(red: 0.8, green: 0.8, blue: 0.3, alpha: 1.0) let groundNode = SCNNode() groundNode.geometry = ground groundNode.position = SCNVector3(x: 0, y: 0, z: 0) groundNode.name = "ground" groundNode.physicsBody = SCNPhysicsBody.staticBody() groundNode.physicsBody?.physicsShape = SCNPhysicsShape(node: groundNode, options: nil) self.scnView?.scene?.rootNode.addChildNode(groundNode) } func setCamera(){ let cameraNode = SCNNode() cameraNode.camera = SCNCamera() self.scnView?.scene?.rootNode.addChildNode(cameraNode) cameraNode.position = SCNVector3(x: 0, y: 50, z: 50) cameraNode.rotation = SCNVector4(x: 1, y: 0, z: 0, w: -0.5) } func setLight(){ let ambientLightNode = SCNNode() ambientLightNode.light = SCNLight() ambientLightNode.light?.type = SCNLightTypeAmbient ambientLightNode.light?.color = UIColor.darkGrayColor() self.scnView?.scene?.rootNode.addChildNode(ambientLightNode) let lightNode = SCNNode() lightNode.light = SCNLight() lightNode.light?.type = SCNLightTypeSpot lightNode.light?.spotOuterAngle = 90 lightNode.light?.color = UIColor.whiteColor() lightNode.light?.castsShadow = true lightNode.position = SCNVector3(x: 0, y: 50, z: 10) if let ground = self.scnView?.scene?.rootNode.childNodeWithName("ground", recursively: false){ let cons = SCNLookAtConstraint(target: ground) cons.influenceFactor = 1.0 lightNode.constraints = [cons] } self.scnView?.scene?.rootNode.addChildNode(lightNode) } override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { let p = touches.anyObject()!.locationInView(self.scnView) if let hitResults = self.scnView?.hitTest(p, options: nil) { if hitResults.count > 0 { let result = hitResults.first?.node if result?.name? == "generator"{ let geom = generateRandomGeometry() geom.firstMaterial?.diffuse.contents = UIColor(red: colorValue(), green: colorValue(), blue: colorValue(), alpha: 1.0) let geomNode = SCNNode(geometry: geom) geomNode.position = SCNVector3(x: 0, y: 30, z: 0) geomNode.name = "geometry" geomNode.physicsBody = SCNPhysicsBody.dynamicBody() geomNode.physicsBody?.physicsShape = SCNPhysicsShape(node: geomNode, options: nil) self.scnView?.scene?.rootNode.addChildNode(geomNode) }else if result?.name? == "geometry"{ result!.runAction(SCNAction.sequence([ SCNAction.fadeOutWithDuration(0.5), SCNAction.removeFromParentNode() ])) } } } } func generateRandomGeometry() -> SCNGeometry{ var kind = arc4random() % 6 switch kind{ case 0: let pyramid = SCNPyramid(width: 3.0, height: 3.0, length: 3.0) return pyramid case 1: let cylinder = SCNCylinder(radius: 1.5, height: 3.0) return cylinder case 2: let sphere = SCNSphere(radius: 1.5) return sphere case 3: let torous = SCNTorus(ringRadius: 1.5, pipeRadius: 0.5) return torous case 4: let chamferBox = SCNBox(width: 3.0, height: 3.0, length: 3.0, chamferRadius: 0.8) return chamferBox default: let box = SCNBox(width: 3.0, height: 3.0, length: 3.0, chamferRadius: 0.0) return box } } func colorValue() -> CGFloat{ let rand = CGFloat(arc4random() % 10000) return rand / 20000.0 + 0.5 } override func shouldAutorotate() -> Bool { return true } override func prefersStatusBarHidden() -> Bool { return true } override func supportedInterfaceOrientations() -> Int { if UIDevice.currentDevice().userInterfaceIdiom == .Phone { return Int(UIInterfaceOrientationMask.AllButUpsideDown.rawValue) } else { return Int(UIInterfaceOrientationMask.All.rawValue) } } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Release any cached data, images, etc that aren't in use. } }
ソースコードの要点などについては、次回の記事にまとめたいと思います。
(2015/05/27 追記)
Xcode 6.3からSwiftのバージョンが1.2となり、それに伴ってtouchesBegansなどのタッチイベント系メソッドに変更が生じました。Swift 1.2以降を使用する場合は、上記ソースのtouchesBegansメソッド部分を以下のように書き換えてください。
//Swift 1.2に対応したtouchBegansメソッドの修正 override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) { if let touch = touches.first as? UITouch{ let p = touch.locationInView(self.scnView) if let hitResults = self.scnView?.hitTest(p, options: nil) { if hitResults.count > 0 { let result = hitResults.first?.node if result?.name == "generator"{ let geom = generateRandomGeometry() geom.firstMaterial?.diffuse.contents = UIColor(red: colorValue(), green: colorValue(), blue: colorValue(), alpha: 1.0) let geomNode = SCNNode(geometry: geom) geomNode.position = SCNVector3(x: 0, y: 30, z: 0) geomNode.name = "geometry" geomNode.physicsBody = SCNPhysicsBody.dynamicBody() geomNode.physicsBody?.physicsShape = SCNPhysicsShape(node: geomNode, options: nil) self.scnView?.scene?.rootNode.addChildNode(geomNode) }else if result?.name == "geometry"{ result!.runAction(SCNAction.sequence([ SCNAction.fadeOutWithDuration(0.5), SCNAction.removeFromParentNode() ])) } } } } }
ポップアップするビューの作り方(2、Swift編)
※本記事はポップアップするビューの作り方(1)の続きとなります。
Swift で MJPopupViewControllerを使ってみる
最近は Swift にも触ってみたのですが、Objective-C に比べると個人的には色々書きやすく、参考にしている情報サイトなどもソースコードをSwiftに移行している所がちらほらありますので、私も今後はSwift使いに転向しようと考えております。
ただしSwfitにはObjective-Cと互換性があり、Objective-Cで書かれたコードをSwiftから呼び出して利用することは容易に行えるそうです。そこで本記事では、前記事で紹介したMJPopupViewController(Objective-Cのコード)を、Swiftで利用してみたいと思います。
- 前記事と同様に、MJPopupViewController の「source」フォルダに入っていた4ファイルをプロジェクトに追加します。ここで、SwiftのプロジェクトにObjective-Cのファイル(.m)を初めて追加するときには、下図のようなダイアログが表示されて、YESを選択すると「(プロジェクト名)-Bridging-Header.h」という名前のヘッダファイルが自動生成されます。これは「ブリッジングヘッダ」といい、Objective-Cファイル側の情報をSwiftへ橋渡しするためのものです。
- ※プロジェクトにObjective-Cのファイルを「Add Files to 〜」のメニューから追加した場合、ダイアログが表示されずブリッジングヘッダが生成されないようです。この場合は自力でブリッジングヘッダを作成することになります。プロジェクトにヘッダファイル(名前は何でもOK。例えば「Bridging-Header.hなど」)を追加し、下図のようにプロジェクト設定の「Build Settings」の「Swift Compiler - Code Generation」にある「Objective-C Bridging Header」項目に、追加したヘッダファイルの名前を指定すればOKです。
- ブリッジングヘッダでは、Swiftから呼び出したいObjective-Cコード用のヘッダファイルをインポートします。MJPopupViewControllerの場合は、以下の2つが必要となります。これだけでコード呼び出しの前準備はほぼ完了です。
#import "MJPopupBackgroundView.h" #import "UIViewController+MJPopupViewController.h"
- あとはプロジェクトにxibファイル、および操作用ビューコントローラーのファイルを追加して、ポップアップさせるUIView関連の設定を行います。この手順は前記事と同様です(ただしビューコントローラーのファイルは、当然Swiftコードで作成することを忘れずに)
- これで、SwiftでもMJPopupViewControllerを利用する準備は全て整いました。あとはポップアップビューを呼び出したい場所で、以下のコードを実行するだけです。ブリッジングヘッダを用意さえしておけば、それ以上の特別な設定は必要なく、プロジェクト内のどのSwiftコードからでも呼び出すことが可能です。
//Swift版 MJPopupViewControllerの呼び出し let popupView = PopupViewController(nibName:"PopupView", bundle:nil) presentPopupViewController(popupView, animationType:MJPopupViewAnimationSlideBottomBottom)
- ここで、前記事のObjective-C版のコードと比較してみます。Objective-Cで定義されたクラスやメソッドを、そのままSwiftの文法で呼び出して利用出来ている様子が良く分かると思います。
//Objective-C版 MJPopupViewControllerの呼び出し PopupViewController *popUpView = [[PopupViewController alloc]initWithNibName:@"PopupView" bundle:nil]; [self presentPopupViewController:popUpView animationType:MJPopupViewAnimationSlideBottomBottom];
ポップアップするビューの作り方(1)
iPhoneアプリ上で、必要なときだけピッカー(UIPickerView)などを出現させてユーザー入力を受け付け、入力が済んだら引っ込ませる…という動作を実装することを考えます。
アクションシートを用いる方法(iOS 8では面倒)
実装法の1つとして、ピッカーなどをUIActionSheetに乗せるという方法があり、以前の記事でも触れたことがあります。しかし、iOS 8の環境ではUIActionSheet(ついでにUIAlertViewも)は deprecated となり、この方法は使えなくなってしまいました(アクションシート自体は表示されるようですが、addSubview で乗せたものは表示されなくなってしまうようです)。
代わりに、iOS 8ではUIAlertControllerという、UIActionSheetおよびUIAlertViewの後継となる新しいクラスが追加されています。UIAlertControllerにもサブビューの追加は可能であり、ピッカーなどを乗せることは可能なようですが、当然これはiOS 7以前の環境では動作しません。iOS 7以前とiOS 8の両方に対応する場合は、iOSのバージョン次第でUIActionSheetとUIAlertControllerのどちらを用いるか場合分けをする…というやや面倒な手順が必要となってしまいます。
ポップアップビューを自作する方法(MJPopupViewController使用)
現環境で目的のような動作を実装するためには、ポップアップビュー(必要に応じて、飛び出したり引っ込んだりするビュー)を作り、そこにピッカーなどを載せる方法の方が楽だと思います。
Xcode 6.1ではポップアップビュー作成用のライブラリなどは用意されておらず、基本的には自力実装となりますが、有志による高品質なサンプルコードなどもネット上では多数公開されているようです。中でも「MJPopupViewController」は、自分で設定したUIViewをポップアップさせることが可能であり、比較的容易に、見栄え良くカスタマイズ性の高いポップアップビューが作成可能です(下図は付属デモサンプルのスクリーンショット)。
ここでは、この使用法についてメモします。
- まず、「GitHub」からMJPopupViewControllerの一式をzipファイルとしてダウンロードします(リンク先の画面右側にある「Download ZIP」を選択)。zipファイルを解凍すると、中に様々なファイルが入っていますが、使うのは「source」フォルダに入っている以下の4ファイルです。
- MJPopupBackgroundView.h
- MJPopupBackgroundView.m
- UIViewController+MJPopupViewController.h
- UIViewController+MJPopupViewController.m
- 続いて、Xcodeで新しいプロジェクトを作成し(ここではSingle View Applicationとして作成)、先ほどの4ファイルをプロジェクトに追加します。
- 続いて、プロジェクトにxibファイルを追加します。以下のように「User Interface」の「View」を選択すると、あらかじめUIViewが配置されたxibファイルが追加されます(ここでは名前を「PopupView.xib」とします)。ここでデザインしたUIViewをポップアップさせます。
- また、このUIViewを操作するためのクラスをプロジェクトに追加します。UIViewControllerのサブクラスとして、新しいクラスファイル(.hおよび.m)を作成してプロジェクトに追加します(ここでは名前を「PopupViewController」とします)。
- 先ほどのPopupView.xibをクリックして表示させ、まず下図【1】のように「File’s Owner」をクリックします。そして、下図【2】のようにFile’s Ownerのカスタムクラスとして、先ほど作成したPopupViewControllerを設定します。
- 更に、File’s Ownerの立方体状アイコンを右クリックし、クリックしたまま、xibに配置されたUIViewへとドラッグ&ドロップします(ドラッグ中は青線が表示)。すると下図のように「Outlets / view」というポップアップが表示されるので、viewの部分をクリックします。
- ここまでで、xibファイル側で必要な下準備は完了しているのですが、このままでは大きく真っ白なポップアップが表示されるだけですので、多少UIViewのデザインをいじってみます。まず、xibに配置されたUIViewをクリックし、下図のようにサイズ設定を「Freeform」に変更すると、UIViewのサイズを自由に変更可能になります。
- ここではUIViewのサイズを 200 x 200 に変更し、背景色を薄青にし、ラベルを貼り付けた以下のようなUIViewをポップアップさせてみます。
- あとは、ポップアップビューを呼び出すクラス(ここでは UIViewController.m とします)に、「UIViewController+MJPopupViewController.h」および、xibのUIView操作用に追加したクラスのヘッダファイルをインポートします。
#import "UIViewController+MJPopupViewController.h" #import "PopupViewController.h" //こちらのヘッダファイル名は、人によって異なる
- そして、ポップアップビューを呼び出したい場所(例えばUIButtonタップ時のアクションなど)に、以下のコードを記述すればOKです。xibファイルからUIViewのインスタンスを生成し、それをpresentPopupViewController:メソッドでポップアップさせるだけです。またanimationType:引数を変更すると、ポップアップビューが飛び出してくる方向および引っ込む方向を色々変更することが出来ます。
PopupViewController *popUpView = [[PopupViewController alloc]initWithNibName:@"PopupView" bundle:nil]; [self presentPopupViewController:popUpView animationType:MJPopupViewAnimationSlideBottomBottom];
iTunes Connectにおける自作アプリ提出手続きの変更点メモ
以前の記事(自作アプリのApp Storeへのリリース手順メモ(2))で、自作アプリのリリース時にiTunes Connect上で行う提出手続きについてまとめました。しかし、あれから時間が経ち、現在(2014/11/20)ではiTunes Connectのレイアウトが当時と比べて大幅に変化しております(日本語表示にかなりの部分が対応しており、分かりやすさが改善しています)。
また、提出手続きの内容自体は基本的には当時と同様なのですが、細かな変更点が生じている箇所があります。そこで本記事では、以前の記事と比較したそれらの変更点についてメモします。
- まずiTunes Connectにサインインすると下図の画面が表示されます。ここでは「マイ App」を選択します。
- 「マイ App」のページに移動します。新規アプリの提出を行う場合は、下図の「+」ボタンをクリックし、表示されるポップアップから「新規 iOS App」を選択します(なお、このページにはリリース済アプリの一覧もあり、これらを選択すると登録情報の編集や、新規バージョンの提出手続きなどを行うことが出来ます)。
- 「新規 iOS App」ポップアップが表示されますので、各項目を埋めます(項目の内容は、以前の記事における「App Information」ページの項目とほぼ同様です。ただし、アプリのバージョン入力がここに追加されているので、新規であれば「1.0」とでも入力すれば良いと思います)。完了したら右下の「作成」ボタンをクリックします。
- ※アプリの名前は、他人に同じ名前を既に使われている場合には弾かれてしまいます。この場合は別名を考えたり、サブタイトルを付け足したりするしかないようです。
- 先程の項目入力に問題が無ければ、下図の画面(収まりきっていないですが)に移動します。ここで具体的なアプリの登録情報を入力します。以前はまず下図の(1)〜(3)について説明します。
- (1)の「保存」ボタンでは、これまでの入力内容を一時保存することが可能です。最終的にアプリをレビューへ提出するまでは、入力内容は何度でも書き直し可能ですので、こまめに利用した方が良いと思います。
- (2)ではローカライズの設定が可能です。先程の「新規 iOS App」ポップアップではプライマリ言語を英語(English)に設定しておき、ここで日本語(Japanese)を追加することで、日本のApp Storeではアプリの説明などが日本語で表示され、それ以外の国では英語で表示されるようにすることが可能です。以前の記事でも言及した手法ですが、より設定方法が分かりやすくなっていると思います。
- (3)ではスクリーンショットを追加します。2014/11/20現在、必要となるスクリーンショットの種類およびサイズは下表の通りであり、それぞれ最大5枚まで追加可能です。なお、アプリのプロモーション動画(Appビデオプレビュー)も登録可能であるようですが、こちらはオプションであり必須ではないようです。
- ※ローカライズを利用する場合は、設定した各言語それぞれにスクリーンショットを追加する必要があります。言語の切り替えは先程の(2)のタブで行います。
機種 | インチ | サイズ |
---|---|---|
iPhone 4/4s | 3.5 | 960 x 640 |
iPhone 5/5s | 4 | 1136 x 640 |
iPhone 6 | 4.7 | 1334 x 750 |
iPhone 6 Plus | 5.5 | 2208 x 1242 |
iPad | - | 1024 x 768 |
- 続いて、先程の画面を下へスクロールすると下図の項目があります。ここでは以前の記事と同様にApp Storeに表示されるアプリの説明文、キーワード、サポートサイトのURLを入力します。
- ※ローカライズを利用する場合は、設定した各言語それぞれの説明文およびキーワードを用意する必要があります。
- 先程の画面を更に下へスクロールすると、下図の項目があります。最低限入力する必要があるのは赤枠の(4)〜(7)であり、いずれも以前からある項目です。以下ではそれぞれのポイントについて説明します。
- (4)では1024 x 1024サイズの、アプリのアイコン画像を設定します。
- ※このときPNG画像を用いると「イメージにアルファチャンネルや透過を含めることはできません。」というエラーメッセージが出て弾かれることがあります。その場合はアイコン画像をMac上でダブルクリックしてプレビューを開き、メニューの「ファイル」→「書き出し」を選択します。そしてダイアログの「フォーマット:PNG」の下にある「アルファ」のチェックを外して保存したものを用いると回避出来るようです。
- (5)ではアプリのバージョン(既に入力済み)、カテゴリ、レーティングを設定します。レーティングでは「編集」をクリックするとチェック項目が表示されるので、当てはまるものがなければ、全て「なし or いいえ」を選択すればOKです。
- (7)はアプリ著作権所有者の情報(名前、住所、電話番号など)を入力します。もしかしたら日本語で入力しても構わないのかもしれませんが、念のため自分は全て英語入力しています。
- 更に、画面を下へスクロールすると以下の画面となります。以前は全ての登録情報を入力した後に「Ready to Upload Binary」でアプリ本体をアップロードする準備をしましたが、現在ではこの「ビルド」項目でアップロード済みのアプリを選択設定することが可能です。また、その下の「App レビュー審査に関する情報」では、Apple社が(必要があれば)アプリ作者に連絡を取るための情報を入力します。自分はここも念のため英語入力しています。
なお、アプリ本体のアップロード手順は自作アプリのApp Storeへのリリース手順メモ(3)と特に変更はありません。正しくアップロードしてしばらく待つと「ビルド」項目の中身が下図のように変化し「+」ボタンが表示されるので、クリックするとアップロードされたアプリを選択設定出来ます。
- 最後に「バージョンのリリース」という項目があります。ここは「自動的にリリースする」を選んでおいて問題はないと思います…が、この項目には分からない点が一つあります。これについては後述します。
- ページ最上部へ戻ると、下図のように他にも項目を選択するタブがあるのが分かります。このうち「価格」をクリックすると、アプリの価格を(無料を含め)設定する画面に移動します。設定内容は以前の記事とほぼ同様です。
↓
-※しかし以前の記事ではApp Storeの新着欄対策として、Availability Date(配信開始日) をかなり先の日付に設定しておくテクニックを紹介しましたが、これが現在にも当てはまる話であるのかは未確認です。また先程の「バージョンのリリース」項目で「自動的にリリースする」を選択した場合、この配信開始日の有効性はどうなるのか、などは分かっていません(調べたのですが、はっきりしませんでした)。自分は一応、相変わらずこのテクニックを使っていますが、もし新情報が得られたら追記したいと思います。
- 必要な登録情報の入力(アプリ本体のアップロードも含め)が全て完了したら、(1)の保存ボタンの横にある「レビューへ提出」ボタンをクリックします。問題がないようであれば下図の画面に移動します。いくつか質問がありますので回答し(特殊なアプリでなければ、全て「いいえ」で問題ないと思います)、「送信」ボタンが有効となったらクリックします。これで手続きは完了となります。
SpriteKitでのサウンド再生
ゲーム作成の際にはBGMや効果音などのサウンドも、ゲームを盛り上げるためには是非付けたいものです。本記事では、SpriteKitでサウンドを再生するための方法について調べたことをメモします。
SKActionによるサウンド再生
実はSpriteKitでは、アクションの一つとしてサウンドの再生機能が用意されています。これを利用すると、非常にお手軽に効果音などを鳴らすことが可能です。
具体的な利用法としては、まずプロジェクトにサウンドファイル(例えば”sound.caf”)を追加します。そして以下のようにplaySoundFileNamed:メソッドでサウンド再生アクションを生成し、実行させるだけです。対応しているサウンドファイルの形式については、SpriteKitの公式リファレンスにも記載が無かったのですが、自分が試した限りではcaf、mp3は問題なく再生可能であるようです。
SKAction *playSoundAction = [SKAction playSoundFileNamed:@"sound.caf" waitForCompletion:NO]; [self runAction:playSoundAction];
なお、メソッドのwaitForCompletion: 引数をNOにすると、 サウンドの再生が開始した瞬間にアクションは完了したとみなされます。引数をYESにすると、サウンドファイルの末尾までの再生が完了して、はじめてアクションは完了したとみなされます。
また、これもアクションの一種である以上、他のアクションと組み合わせることも可能です。例えばrepeatActionForever:と組み合わせると、同じサウンドを延々とループ再生させ続けることも可能です。
音量を調節したり、サウンドファイルの途中から再生したり…といった細かな設定は出来ないようですが、とにかくお手軽なのが良いところです。
通知センターを利用して、ViewController上の AVAudioPlayer をシーンから制御するサンプル
SKActionによるサウンド再生は便利なのですが、例えばアプリ起動中はずっと同じBGMをループ再生し続けて、シーン間の移動が起こる場合にもBGMが途切れないようにする……というケース(拙作のTricolorなど)では実装が困難で悩みました。
色々調べてみたところ、このようなケースは、例えばViewController上でサウンド再生機構(ここではSKActionは利用出来ないので、AVAudioPlayerなど)を用意して、各シーンの処理とは独立してBGMを再生するようにすれば実現出来ることが分かりました。
…しかし、実際にやってみると確かに目的は達せられたのですが、今度はシーン上からBGMのオン、オフを切り替える機能を付けたいと考えたときにまた悩みました。つまりシーンからViewController上のAVAudioPlayerを制御したいのですが、これが一筋縄では行きませんでした。シーンからViewControllerへの参照を取得するような方法が真っ先に浮かびましたが、それはMVCパターンの設計を壊すため推奨されないようです。
そこで、また色々調べてみたところ、通知センター(NSNotification)を利用して、シーンからViewController上の要素を参照なしで制御する方法というのが見つかりました。この方法がベストなのかは分かりませんが、同じようなケースで悩んでいる方の参考になるかもしれませんので、サンプルを作成してみました。
プロジェクトの作成
まず、Xcode(本記事執筆時点:Version 5.1.1)で「SpriteKit」テンプレートを選択してプロジェクトを作成します。そしてプロジェクトに適当なサウンドファイル(例えば”bgm.caf”)を追加します。
ViewControllerの書き換え
ViewController.m を以下のコードに書き換えます(ViewController.h は書き換え不要)
#import "ViewController.h" #import "MyScene.h" @import AVFoundation; //AVAudioPlayerを利用するために必要 @interface ViewController() @property(nonatomic)AVAudioPlayer *bgmPlayer; //プロパティとしてAVAudioPlayerを追加 @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //通知センターの追加 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(switchBGM:) name:@"BGMButton" object:nil]; //AVAudioPlayerによるBGM再生開始(サウンドファイル名は適宜書き換え) NSURL *bgmURL = [[NSBundle mainBundle] URLForResource:@“bgm" withExtension:@“caf"]; self.bgmPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:bgmURL error:nil]; self.bgmPlayer.volume = 0.5; self.bgmPlayer.numberOfLoops = -1; [self.bgmPlayer prepareToPlay]; [self.bgmPlayer play]; // Configure the view. SKView * skView = (SKView *)self.view; // Create and configure the scene. SKScene * scene = [MyScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene]; } //通知センターが呼び出すBGMのオンオフ切り替えメソッドの追加 -(void)switchBGM:(NSNotification *)notification{ NSDictionary *userInfo = [notification userInfo]; NSNumber *isBGMPlay = (NSNumber *)[userInfo objectForKey:@"isBGMPlay"]; //MyScene.m からの通知(userInfo に格納されたBOOL値)でBGMをオンオフ if([isBGMPlay boolValue] == YES){ self.bgmPlayer.currentTime = 0; [self.bgmPlayer play]; } else [self.bgmPlayer stop]; } - (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
MySceneの書き換え
MyScene.m を以下のコードに書き換えます(MyScene.h は書き換え不要)
#import "MyScene.h" @interface MyScene(){ BOOL isPlayBGM; //BGMのオン or オフの状態を表す変数 } @end @implementation MyScene -(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0]; isPlayBGM = YES; //BGMのオン or オフを切り替えるボタン(SKLabelNodeを利用して実装) SKLabelNode *BGMButton = [SKLabelNode labelNodeWithFontNamed:@"Gill Sans Light"]; BGMButton.name = @"BGM"; BGMButton.text = @"BGM : On"; BGMButton.fontSize = 50; BGMButton.fontColor = [SKColor blackColor]; BGMButton.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame)); [self addChild:BGMButton]; } return self; } -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { SKLabelNode *BGMButton = (SKLabelNode *)[self childNodeWithName:@"BGM"]; CGPoint point = [[touches anyObject] locationInNode:self]; //ボタンがタップされたことを検知したときの処理 if ([BGMButton containsPoint:point]) { if (isPlayBGM == YES) { isPlayBGM = NO; BGMButton.text = @"BGM : Off"; //ViewController.m の通知センターに、BGMのオフを通知(userInfo に格納したBOOL値で) NSDictionary *userInfo = [NSDictionary dictionaryWithObject: [NSNumber numberWithBool:NO] forKey:@"isPlayBGM"]; [[NSNotificationCenter defaultCenter] postNotificationName:@"BGMButton" object:self userInfo:userInfo]; }else{ isPlayBGM = YES; BGMButton.text = @"BGM : On"; //同様に、ViewController.m の通知センターに、BGMのオンを通知 NSDictionary *userInfo = [NSDictionary dictionaryWithObject: [NSNumber numberWithBool:YES] forKey:@"isBGMPlay"]; [[NSNotificationCenter defaultCenter] postNotificationName:@"BGMButton" object:self userInfo:userInfo]; } } } @end
サンプルの実行
ViewController.m、MyScene.mを書き換えてサンプルを実行すると下図左のようになり、BGMも再生されます。そして画面中央のボタン(文字ラベル)をタップするごとに、BGMのオン、オフが切り替わります。
ViewController上で通知センター(NSNotification)を用意し、MySceneでBGMのオン、オフの切り替え操作があったときには、userInfoという変数名のNSDictionaryを介してViewControllerに通知とBGMのオンオフ指示情報が送られるようにしています。そして通知を受け取るごとにswitchBGM:メソッドが実行されて、AVAudioPlayerのオンオフが切り替わります。
SpriteKitでビットマップフォント
※2020/03/07追記:本記事の続きとして、「BMGlyph」というソフトウェアを用いたビットマップフォントの扱い方についてまとめた記事も作成しております。
chemicalfactory.hatenablog.com
SpriteKit でビットマップフォントを扱う方法がないか模索していたのですが、実はSKLabelNode を用いて、通常の文字ラベルと(ほぼ)同じように扱えることが分かりました。以下ではその方法についてメモしていきます。
まずビットマップフォントの作成ですが、これは何らかのツールを用いる必要があります(自分は「GlyphDesigner」というソフトウェアを用いています。有料ですが、その分使い勝手は非常に良いと思います)。
今回は、↑のスクリーンショットにあるビットマップフォントを、そのまま「Myfont」という名前で出力して用います(ここでは「Myfont.fnt」「Myfont.png」という2つのファイルが出力されます)。
SpriteKitでこれを用いる方法ですが、まず先程出力された2つのファイル(「Myfont.fnt」および「Myfont.png」)をプロジェクトに追加します。後はSKLabelNode のインスタンスを初期化する際に、追加したビットマップフォントをフォント名として指定するだけです。これで通常の文字ラベルと(ほぼ)同じように、ビットマップフォントを扱うことが出来ます。
SKLabelNode *myLabel = [SKLabelNode labelNodeWithFontNamed:@"MyFont"]; myLabel.text = @"Hello, World!"; myLabel.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame)); [self addChild:myLabel];
試しに、SpriteKitのテンプレートで背景に「Hello, World!”」と白文字で表示する部分を、上記のコードに書き換えて実行してみます。
上図のように、文字がビットマップフォントによって見事に表示されました。…ただし、画面から文字が盛大にはみ出してしまっています。そこでフォントサイズを調整する必要があるのですが、通常の文字ラベルと違って、ビットマップフォントでは例えば以下のコードのようにfontSizeプロパティを設定しても効果が無いようです。
myLabel.fontSize = 30; //ビットマップフォントには効果なし
代わりに、ビットマップフォントでは xScale、yScale プロパティを用いることによって、フォントサイズを調節することが出来るようです。これらのプロパティは、名前通りに文字ラベル全体をx方向、y方向に拡大縮小します。
myLabel.xScale = 0.5; myLabel.yScale = 0.5;
例えば上記のコードを用いて、先程の「Hello, World!”」をxy方向にそれぞれ0.5倍に縮小してみると、今度は文字がきちんと画面内に収まるようになりました。
ゲームアプリの簡単な骨組み的なモノ(SpriteKit)
(※2020/01/12追記:本記事はObjective-Cを用いた、やや古い記事となります。同構成のアプリをSwiftで作成するための最新記事も公開しています)
chemicalfactory.hatenablog.com
SpriteKitではある画面(シーン)から別の画面へと、アニメーション付きで移動するような処理を、容易に実装可能な仕組みが備わっています。本記事ではそれらを活用して、下図のようにゲームアプリの簡単な骨組み的なモノを作成してみます。
このアプリでは最初タイトル画面が表示され、「情報」をタップすると説明画面に移動します(アプリの説明などを書くことを想定)。また「スタート」をタップするとゲーム画面に移動します。ゲーム画面では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)を表示するようにしただけです。これでこの骨組みアプリは完成です。