SwiftとSpriteKitによる、ゲームアプリの簡単な骨組み的なモノ
以下の過去記事では、SpriteKitを用いたゲームアプリの簡単な骨組みの作り方についてメモしました。しかし当時はXcode 5の時代で、まだコードもObjective-Cで書いていた頃であり、今になってSwiftでゲームアプリを作成する際には参考にしづらいと感じてしまいました。
chemicalfactory.hatenablog.com
そこで本記事ではSwift + SpriteKitで、SpriteKit Sceneファイル(以下SKSと表記)も活用して、過去記事同様のアプリ骨組みを作成する方法を改めてまとめたいと思います。
SpriteKitテンプレートの準備
- まずXcode(本記事執筆時点:Version 11.3)でSpriteKitのテンプレートを作成し、「GameScene.sks」「Actions.sks」「GameScene.swift」のファイル3つは不要なので削除します。ここまでの手順は以下の記事と同様です。
chemicalfactory.hatenablog.com
- テンプレートに「TitleScene」「InfoScene」「GameScene」「GameOverScene」という名前で、4つのSKSおよびクラスファイルを作成し、インスペクタで紐付けもしておきます。これも手順としては先程の記事とほとんど同様です。
各シーンのレイアウト作成
TitleSceneのレイアウト
- まずは「TitleScene」から作成します。SKSを表示して、Xcode右上の「+」ボタンからオブジェクト選択ウインドウを開き、ラベルノード(Label)をドラッグ&ドロップでシーンの適当な位置に配置します。そしてラベルのインスペクタでテキスト内容を「タイトル」に変更し、文字色やフォントサイズなどを好きに設定します(シーンの背景色も変えています)。更に、後にコードからインスタンスを呼び出す際の識別用に「Name」に適当な名前を与えておきます(ここでは「titeLabel」としました)。
- なおSpriteKitは本来、左下が座標系の原点(0, 0)であり過去記事でもそれを前提としたコードとしていますが、SKSファイルでは中心が原点(0, 0)となっています。これはSKSではデフォルトで、シーンのアンカーポイント(Anchor Point)がX=0.5, Y=0.5に設定されているからです。本記事ではこのままの設定としますが、もしアンカーポイントをX=0.0, Y=0.0に変更すれば、従来通りに左下原点となります。以下の参考図もご覧ください。
- 続いて、他画面に遷移するためのボタンとして用いるラベルも同様に配置します。これらも同様に識別用のNameを与えておきます。ここでは「スタート」のラベルを「startButton」、「情報」のラベルを「infoButton」としました。
InfoSceneのレイアウト
- 続いて「InfoScene」のSKSに同様の手順でラベルを配置します。ボタンとして用いる「戻る」のラベルには「backButton」のNameを与えています(「情報」のラベルは、特にコード上で操作しないのでNameは不要)。
GameSceneのレイアウト
- 「GameScene」のSKSにも同様の手順でLabelを配置します。ここではカウントダウンを表示するためのラベル(赤字の「0」)が必要であり、「timerLabel」のNameを与えています(「ゲーム画面」のラベルは、特にコード上で操作しないのでNameは不要)。
GameOverSceneのレイアウト
- 最後に「GameOverScene」のSKSにも同様の手順でラベルを配置します。「リトライ」のラベルには「retryButton」、「戻る」のラベルには「backButton」のNameを与えています(「ゲームオーバー」のラベルには、特にコード上で操作しないのでNameは不要)。
各シーンの実装
TitleSceneの実装
SKSによる各シーンのレイアウト作成が完了したら、あとはコードを書いて実装して行きます。最初に「TitleScene.swift」を以下のコードに書き換えます。
import SpriteKit class TitleScene: SKScene { override func didMove(to view: SKView) { //「タイトル」のLabelに拡大縮小のアクションを設定。 //特に必要性は無いけれど、アクション設定方法の例として。 if let titleLabel = self.childNode(withName: "//titleLabel") as? SKLabelNode{ let action1 = SKAction.scale(to: 1.5, duration:2.0) let action2 = SKAction.scale(to: 1.0, duration:2.0) let sequence = SKAction.sequence([action1, action2]) titleLabel.run(SKAction.repeatForever(sequence)) } } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { //タップ位置にLabelが存在するか、存在する場合には名前は何か、で場合分け for t in touches { let point = t.location(in: self) if let startButton = self.childNode(withName: "//startButton") as? SKLabelNode{ if startButton.contains(point){ //SKSで定義したシーンを行き先に設定して、画面遷移を行う。 if let scene = SKScene(fileNamed: "GameScene"){ let transition = SKTransition.crossFade(withDuration: 1.0) scene.scaleMode = .aspectFill self.view?.presentScene(scene, transition: transition) } } } if let infoButton = self.childNode(withName: "//infoButton") as? SKLabelNode{ if infoButton.contains(point){ if let scene = SKScene(fileNamed: "InfoScene"){ let transition = SKTransition.crossFade(withDuration: 1.0) scene.scaleMode = .aspectFill self.view?.presentScene(scene, transition: transition) } } } } } }
やっていることの内容は過去記事と同じです。ただし、過去記事ではラベルも全てコード上で生成していましたが、今回は既にSKSで生成済みなのでその必要はありません。必要に応じて、シーンのchildNode関数でラベルのインスタンスを呼び出せばOKです。
このときwithName:引数にはSKSで設定したNameを与えますが、その冒頭に”//“とスラッシュ2つを付与しています。こちらはSpriteKitのテンプレートにおけるchildNode関数の使用法に習っています…が、その意味合いについても把握したかったので調べてみたところ、Appleの公式ドキュメントに解説がありました。
どうやらchildNode関数などで子ノードを検索する際に、現在のノードの子ノードだけを検索するか、それともノードツリーの根元から全体を検索するかの違いが生じるようです。本アプリのように、シーンが子ノードとして各ラベルを持つだけの単純な構成であれば、”//“を付けても付けなくても見た目には変わらず動作するようですが、もっと複雑なノードの階層構造を有するようなアプリでは使い分けを意識する必要がありそうです。
ラベルのインスタンス変数が得られたら、あとは過去記事と同様にアクションを設定したり、ボタン代わりにして画面遷移をしたりすることが可能になります。ただし画面遷移を行う際には、行き先となるシーンはfileNamed:引数を用いてSKSからロードします。
なおchildNode関数にしてもシーンのロードにしても、引数に誤った名前を与えるとnilが返されますので、Optional型の変数を用いてif-let構文でアンラップしていますが、if文のネストが重なって若干読みづらくなってしまっているように感じます。もう少しスマートに記述したいのですが、ここは今後の課題です(以下のようにguard-let構文を用いれば、if文のネストが1つ減って若干読みやすくなる?)
guard let startButton = self.childNode(withName: "//startButton") as? SKLabelNode else{ return }
InfoSceneの実装
「InfoScene.swift」を以下のコードに書き換えます。内容は相変わらず、TitleSceneとほとんど同じです。
import SpriteKit class InfoScene: SKScene { override func didMove(to view: SKView) { //特に必要な処理はないので空欄 } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { for t in touches { let point = t.location(in: self) if let backButton = self.childNode(withName: "//backButton") as? SKLabelNode{ if backButton.contains(point){ if let scene = SKScene(fileNamed: "TitleScene"){ let transition = SKTransition.crossFade(withDuration: 1.0) scene.scaleMode = .aspectFill self.view?.presentScene(scene, transition: transition) } } } } } }
GameSceneの実装
「GameScene.swift」を以下のコードに書き換えます。
import SpriteKit class GameScene: SKScene { let gameInterval = 10.0 var isLastUpdateTimeInitialized : Bool = false var lastUpdateTime : TimeInterval = 0.0 var gameTime : TimeInterval = 0.0 override func didMove(to view: SKView) { //特に必要な処理はないので空欄 } override func update(_ currentTime: TimeInterval) { if !isLastUpdateTimeInitialized{ isLastUpdateTimeInitialized = true lastUpdateTime = currentTime } let timeSinceLast = currentTime - lastUpdateTime lastUpdateTime = currentTime gameTime += timeSinceLast let timeRemaining = gameInterval - gameTime if let timerLabel = self.childNode(withName: "//timerLabel") as? SKLabelNode{ timerLabel.text = "\(Int(ceil(timeRemaining)))" } if(timeRemaining <= 0.0){ if let scene = SKScene(fileNamed: "GameOverScene"){ let transition = SKTransition.crossFade(withDuration: 1.0) scene.scaleMode = .aspectFill self.view?.presentScene(scene, transition: transition) } } } }
こちらもやっていることの内容は過去記事と同じであり、update関数が呼び出される度に、前回と今回の呼び出し時刻の差分を取って、正確な経過時間をカウントしています。そして一定時間(ここでは10秒)が経過したら、強制的にゲームオーバー画面(GameOverScene)に遷移します。
なお過去記事では「isTransition」という名前のBool変数をフラグとして、画面遷移の処理が1度だけ行われるようにしていました(当時はこのようにしないと、画面遷移が上手く機能しなかったため)。しかし、現在ではこのような細工をしなくても問題なく画面遷移が行われるようです。
GameOverSceneの実装
「GameOverScene.swift」を以下のコードに書き換えます。こちらも内容は相変わらず、TitleSceneとほとんど同じです。
import SpriteKit class GameOverScene: SKScene { override func didMove(to view: SKView) { //特に必要な処理はないので空欄 } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { for t in touches { let point = t.location(in: self) if let retryButton = self.childNode(withName: "//retryButton") as? SKLabelNode{ if retryButton.contains(point){ if let scene = SKScene(fileNamed: "GameScene"){ let transition = SKTransition.crossFade(withDuration: 1.0) scene.scaleMode = .aspectFill self.view?.presentScene(scene, transition: transition) } } } if let backButton = self.childNode(withName: "//backButton") as? SKLabelNode{ if backButton.contains(point){ if let scene = SKScene(fileNamed: "TitleScene"){ let transition = SKTransition.crossFade(withDuration: 1.0) scene.scaleMode = .aspectFill self.view?.presentScene(scene, transition: transition) } } } } } }
GameViewControllerの書き換え
最後に「GameViewController.swift」を開いて、最初に表示されるシーンを”TitleScene”に書き換えます。これでSwiftでも、SKSを用いたゲームアプリの骨組みを完成させることが出来ました。
//シーンを表示する部分のコードのみ。前後は省略 if let view = self.view as! SKView? { if let scene = SKScene(fileNamed: "TitleScene") { // Set the scale mode to scale to fit the window scene.scaleMode = .aspectFill // Present the scene view.presentScene(scene) } view.ignoresSiblingOrder = true //view.showsFPS = true //view.showsNodeCount = true }