横スクロールアクションゲームアプリ
これから横スクロールアクションゲームを開発します。ゲームの作りはシンプルだけど思わずハマってしまう、タップのみで障害物を避けていくゲームです。
アプリ概要
ゲームのルール
- キャラクターを操作して移動している障害物の隙間を通過させる
- 障害物の隙間を通過することでスコアが1加算される
- キャラクターは自然と画面の下へ落下する
- キャラクターは画面をタップすると上昇する
- 障害物は画面の右から左へ移動する
機能の要件
- SKActionを使って障害物を移動させる
- 画面タップによりプレイキャラを上昇させる
- 物理エンジンを使ってスプライトを加工させる
- プレイキャラと障害物との衝突判定させる
- 障害物と衝突すると画面を停止させる
キャラクターにアニメーションをつける
SpriteKitのSKActionを使うことによりアニメーションを簡単に実現する方法を学習します。
SKActionとは
SKActionとは対象ノードに動きを持たせたり、拡大・縮小などの簡単なエフェクトを実現するためのクラスです。このクラスには様々なメソッドが用意されており、それらを組み合わせることで複雑なアニメーションを実現する事が可能です。
ゲーム構成要素
今回のゲームアプリは下図のような構成要素で開発します。

プロジェクトの作成
それではプロジェクトを作成していきます。Xcodeを開きプロジェクトを作成します。
| 項目 |
値 |
| テンプレート |
Game |
| Product Name |
Swim |
| Organization Name |
(任意) |
| Organization Identifer |
(任意) |
| Language |
Swift |
| Game Techinology |
SpriteKit |
| Devices |
iPhone |


GameScene.swiftを開き、下記のとおりに余分なコードを削除しておいてください。
import SpriteKit
class GameScene: SKScene { // シーンが表示された時に呼ばれる override func didMoveToView(view: SKView) { } // ユーザーが画面をタッチした際に呼ばれる override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) { } // フレームごとに呼ばれる override func update(currentTime: NSTimeInterval) { } }
|
画像ファイルの取り込み
下記よりダウンロードしてください。
素材ダウンロード
ダウンロードしたらAssets.xcassetsの中にドラッグしてインポートしてください。

この画像素材を使ってゲームを開発していきます。
背景を動かす
今回は横スクロールアニメーションのゲームですので背景を横に動かしていきます。
次の3ステップで背景を動かします。
- 背景画像の必要枚数をディスプレイサイズから算出して並べます

- 各画像を1枚分移動させます

- 1枚分移動するとアニメーション開始時と同じ見え方になります

背景アニメーションの実装
背景画像をアニメーションさせるメソッドを追加します。
GameScene.swift
func setupBackgroundSea() { let texture = SKTexture(imageNamed: "background") texture.filteringMode = .Nearest let needNumber = 2.0 + (self.frame.size.width / texture.size().width) let moveAnim = SKAction.moveByX( -texture.size().width, y: 0.0, duration: NSTimeInterval(texture.size().width / 10.0) ) let resetAnim = SKAction.moveByX( texture.size().width, y: 0.0, duration: 0.0 ) let repeatForeverAnim = SKAction.repeatActionForever( SKAction.sequence([moveAnim, resetAnim]) ) for i in CGFloat(0.0).stride(to: needNumber, by: 1.0) { let sprite = SKSpriteNode(texture: texture) sprite.zPosition = -100.0 sprite.position = CGPoint( x: i * sprite.size.width, y: self.frame.size.height / 2.0 ) sprite.runAction(repeatForeverAnim) baseNode.addChild(sprite) } }
|
追加したメソッドを処理させるために下記を追加してください。
GameScene.swift
class GameScene: SKScene { var baseNode: SKNode! var coralNode: SKNode! override func didMoveToView(view: SKView) { baseNode = SKNode() baseNode.speed = 1.0 self.addChild(baseNode) coralNode = SKNode() baseNode.addChild(coralNode) self.setupBackgroundSea() } : }
|

奥行きの表現
続いて岩山画像を用いて背景画像の奥行きを出しましょう。
奥行きに関する位置は、プロパティzPositionとx座標、y座標の関係を図示すると下記のようになります。

zPositionの値が小さいほど奥に配置され、値が大きいほど手前に配置されます。
それではGameScene.swiftに実装してみます。
GameScene.swift
func setupBackgroundRock() { let under = SKTexture(imageNamed: "rock_under") under.filteringMode = .Nearest var needNumber = 2.0 + (self.frame.size.width / under.size().width) let moveUnderAnim = SKAction.moveByX( -under.size().width, y: 0.0, duration:NSTimeInterval(under.size().width / 20.0) ) let resetUnderAnim = SKAction.moveByX( under.size().width, y: 0.0, duration: 0.0 ) let repeatForeverUnderAnim = SKAction.repeatActionForever( SKAction.sequence([moveUnderAnim, resetUnderAnim]) ) for i in CGFloat(0.0).stride(to: needNumber, by: 1.0) { let sprite = SKSpriteNode(texture: under) sprite.zPosition = -50.0 sprite.position = CGPoint(x: i * sprite.size.width, y: sprite.size.height / 2.0 ) sprite.runAction(repeatForeverUnderAnim) baseNode.addChild(sprite) } let above = SKTexture(imageNamed: "rock_above") above.filteringMode = .Nearest needNumber = 2.0 + (self.frame.size.width / above.size().width) let moveAboveAnim = SKAction.moveByX( -above.size().width, y: 0.0, duration:NSTimeInterval(above.size().width / 20.0) ) let resetAboveAnim = SKAction.moveByX( above.size().width, y: 0.0, duration: 0.0 ) let repeatForeverAboveAnim = SKAction.repeatActionForever( SKAction.sequence([moveAboveAnim, resetAboveAnim]) ) for i in CGFloat(0.0).stride(to: needNumber, by: 1.0) { let sprite = SKSpriteNode(texture: above) sprite.zPosition = -50.0 sprite.position = CGPoint(x: i * sprite.size.width, y: self.frame.size.height - (sprite.size.height / 2.0)) sprite.runAction(repeatForeverAboveAnim) baseNode.addChild(sprite) } }
|
岩山(上)画像のaboveと岩山(下)画像のunderのSKTextureに対しアニメーションを設定しています。またsprite.zPosition = -50.0によって背景画像より手前に表示されるようにしています。
このメソッドをdidMoveToViewメソッドに追加して動作確認してみましょう。
GameScene.swift
override func didMoveToView(view: SKView) { : : self.setupBackgroundRock() }
|

地面の作成
次に地面を作成していきます。作成にあたりまず衝突判定に必要なビット値を用意します。
GameScene.swift
class GameScene: SKScene { var baseNode: SKNode! var coralNode: SKNode! struct ColliderType { static let Player: UInt32 = (1 << 0) static let World: UInt32 = (1 << 1) static let Coral: UInt32 = (1 << 2) static let Score: UInt32 = (1 << 3) static let None: UInt32 = (1 << 4) } : }
|
次にsetupCeilingAndLandメソッドを作って、地面と天井のアニメーションを実装します。
GameScene.swift
func setupCeilingAndLand() { let land = SKTexture(imageNamed: "land") land.filteringMode = .Nearest var needNumber = 2.0 + (self.frame.size.width / land.size().width) let moveLandAnim = SKAction.moveByX(-land.size().width, y: 0.0, duration:NSTimeInterval(land.size().width / 100.0)) let resetLandAnim = SKAction.moveByX(land.size().width, y: 0.0, duration: 0.0) let repeatForeverLandAnim = SKAction.repeatActionForever(SKAction.sequence([moveLandAnim, resetLandAnim])) for i in CGFloat(0.0).stride(to: needNumber, by: 1.0) { let sprite = SKSpriteNode(texture: land) sprite.position = CGPoint(x: i * sprite.size.width, y: sprite.size.height / 2.0) sprite.physicsBody = SKPhysicsBody(texture: land, size: land.size()) sprite.physicsBody?.dynamic = false sprite.physicsBody?.categoryBitMask = ColliderType.World sprite.runAction(repeatForeverLandAnim) baseNode.addChild(sprite) } let ceiling = SKTexture(imageNamed: "ceiling") ceiling.filteringMode = .Nearest needNumber = 2.0 + self.frame.size.width / ceiling.size().width for i in CGFloat(0.0).stride(to: needNumber, by: 1.0) { let sprite = SKSpriteNode(texture: ceiling) sprite.position = CGPoint(x: i * sprite.size.width, y: self.frame.size.height - sprite.size.height / 2.0) sprite.physicsBody = SKPhysicsBody(texture: ceiling, size: ceiling.size()) sprite.physicsBody?.dynamic = false sprite.physicsBody?.categoryBitMask = ColliderType.World sprite.runAction(repeatForeverLandAnim) baseNode.addChild(sprite) } }
|
いままでと同様に、このメソッドをdidMoveToViewメソッドに追加して動作確認してみましょう。
override func didMoveToView(view: SKView) { : : self.setupCeilingAndLand() }
|

キャラクターを設置
続いて、キャラクターを配置していきます。
キャラクターはパタパタするようなアニメーションをつけながら移動するようにします。

まずはGameSceneクラスにキャタクターのplayerというSKSpriteNodeの変数を定義します。
さらにキャラクターにアニメーションさせるためのスプライト画像を構造体(struct)として定義します。
GameScene.swift
class GameScene: SKScene { //////////////// ▼▼ 追加 ▼▼ //////////////// // キャラクター var player: SKSpriteNode! struct Constants { // キャラクタースプライト画像 static let PlayerImages = ["shrimp01", "shrimp02", "shrimp03", "shrimp04"] } //////////////// ▲▲ 追加 ▲▲ //////////////// : : }
|
次にキャラクターの動きを定義するsetupPlayerメソッドを追加します。
GameScene.swift
func setupPlayer() { var playerTexture = [SKTexture]() for imageName in Constants.PlayerImages { let texture = SKTexture(imageNamed: imageName) texture.filteringMode = .Linear playerTexture.append(texture) } let playerAnimation = SKAction.animateWithTextures(playerTexture, timePerFrame: 0.2) let loopAnimation = SKAction.repeatActionForever(playerAnimation) player = SKSpriteNode(texture: playerTexture[0]) player.position = CGPoint(x: self.frame.size.width * 0.35, y: self.frame.size.height * 0.6) player.runAction(loopAnimation)
self.addChild(player) }
|
パラパラアニメーションを実現するためにSKTextureクラスの配列を用意し、for構文で先ほど定義したConstantsの構造体から画像ファイル名を取得し、SKTextureの画像をセットして配列にしています。
var playerTexture = [SKTexture]()
for imageName in Constants.PlayerImages { let texture = SKTexture(imageNamed: imageName) texture.filteringMode = .Linear playerTexture.append(texture) }
|
そのSKTextureの配列を使ってSKActionを生成し、下記のプログラムでパラパラアニメーションのアクションを定義しています。
let playerAnimation = SKAction.animateWithTextures(playerTexture, timePerFrame: 0.2)
let loopAnimation = SKAction.repeatActionForever(playerAnimation)
|
いままでと同様に、このメソッドをdidMoveToViewメソッドに追加して動作確認してみましょう。
GameScene.swift
override func didMoveToView(view: SKView) { : : self.setupPlayer() }
|
これで実行してみましょう。キャラクターがパタパタ動いているのが確認できると思います。

キャラクターのタップ時アニメーション設定
画面をタップしたときにキャラクターがふわっと上昇して一定のとこでゆっくり下降する動きを物理シュミレーションを使って実装します。
GameScene.swift
func setupPlayer() { : : player.runAction(loopAnimation) player.physicsBody = SKPhysicsBody(texture: playerTexture[0], size: playerTexture[0].size()) player.physicsBody?.dynamic = true player.physicsBody?.allowsRotation = false player.physicsBody?.categoryBitMask = ColliderType.Player player.physicsBody?.collisionBitMask = ColliderType.World | ColliderType.Coral player.physicsBody?.contactTestBitMask = ColliderType.World | ColliderType.Coral self.addChild(player) }
|
物理シュミレーションを設定するには、SKNodeのphysicsBodyプロパティにSKPhysicsBodyクラスを追加します。SKPhysicsBodyを生成するときにSKTextureを渡す事により、SKTextureの形にそって衝突判定が実行されるようになります。
dynamicプロパティは重力の影響を受けるかどうかを制御します。trueにすることにより影響をうけるようになります。
player.physicsBody?.collisionBitMaskには衝突判定する対象を設定します。
didMoveToViewに下記を追加して実行してみましょう。
画面をタップするとキャラクターが上に上昇するのが確認できます。
override func didMoveToView(view: SKView) { self.physicsWorld.gravity = CGVector(dx: 0.0, dy: -2.0)
|

障害物の設置
次に障害物を設置していきます。画面外に生成し、画面内を移動させ、画面外にでたときに削除するようにします。



func setupCoral() { let coralUnder = SKTexture(imageNamed: "coral_under") coralUnder.filteringMode = .Linear let coralAbove = SKTexture(imageNamed: "coral_above") coralAbove.filteringMode = .Linear let distanceToMove = CGFloat(self.frame.size.width + 2.0 * coralUnder.size().width) let moveAnim = SKAction.moveByX(-distanceToMove, y: 0.0, duration:NSTimeInterval(distanceToMove / 100.0)) let removeAnim = SKAction.removeFromParent() let coralAnim = SKAction.sequence([moveAnim, removeAnim]) let newCoralAnim = SKAction.runBlock({ let coral = SKNode() coral.position = CGPoint(x: self.frame.size.width + coralUnder.size().width * 2, y: 0.0) coral.zPosition = -40.0 let height = UInt32(self.frame.size.height / 12) let y = CGFloat(arc4random_uniform(height * 2) + height) let under = SKSpriteNode(texture: coralUnder) under.position = CGPoint(x: 0.0, y: y) under.physicsBody = SKPhysicsBody(texture: coralUnder, size: under.size) under.physicsBody?.dynamic = false under.physicsBody?.categoryBitMask = ColliderType.Coral under.physicsBody?.contactTestBitMask = ColliderType.Player coral.addChild(under) let above = SKSpriteNode(texture: coralAbove) above.position = CGPoint(x: 0.0, y: y + (under.size.height / 2.0) + 160.0 + (above.size.height / 2.0)) above.physicsBody = SKPhysicsBody(texture: coralAbove, size: above.size) above.physicsBody?.dynamic = false above.physicsBody?.categoryBitMask = ColliderType.Coral above.physicsBody?.contactTestBitMask = ColliderType.Player coral.addChild(above) let scoreNode = SKNode() scoreNode.position = CGPoint(x: (above.size.width / 2.0) + 5.0, y: self.frame.height / 2.0) scoreNode.physicsBody = SKPhysicsBody(rectangleOfSize: CGSize(width: 10.0, height: self.frame.size.height)) scoreNode.physicsBody?.dynamic = false scoreNode.physicsBody?.categoryBitMask = ColliderType.Score scoreNode.physicsBody?.contactTestBitMask = ColliderType.Player coral.addChild(scoreNode) coral.runAction(coralAnim) self.coralNode.addChild(coral) }) let delayAnim = SKAction.waitForDuration(2.5) let repeatForeverAnim = SKAction.repeatActionForever(SKAction.sequence([newCoralAnim, delayAnim])) self.runAction(repeatForeverAnim) }
|
障害物の表示は下図の仕組みで作成します。

画面の高さを12分割したうちの1つを基準として、その基準点に上下のズレの分を足しこみます。
arc4random_uniformメソッドで、引数を超えないランダムな値を取得します。
引数には画面高さの12分の2をブレ幅として渡しています。

またスコアをカウントアップするためのSKNodeを設定しています。目には見えませんが、存在する位置は先ほどのサンゴの位置関係で示したとおりです。
let scoreNode = SKNode() scoreNode.position = CGPoint(x: (above.size.width / 2.0) + 5.0, y: self.frame.height / 2.0)
scoreNode.physicsBody = SKPhysicsBody(rectangleOfSize: CGSize(width: 10.0, height: self.frame.size.height)) scoreNode.physicsBody?.dynamic = false scoreNode.physicsBody?.categoryBitMask = ColliderType.Score scoreNode.physicsBody?.contactTestBitMask = ColliderType.Player coral.addChild(scoreNode)
|
いままでと同様に、このメソッドをdidMoveToViewメソッドに追加して動作確認してみましょう。
GameScene.swift
override func didMoveToView(view: SKView) { : : self.setupCoral() }
|

スコア情報の設置
スコアに関する部分を実装していきます。
まずメンバ変数としてスコアを表示させるためのSKLabelNodeとUInt32型のscoreを定義します。
GameScene.swift
class GameScene: SKScene { //////////////// ▼▼ 追加 ▼▼ //////////////// // スコアを表示するラベル var scoreLabelNode: SKLabelNode! // スコアの内部変数 var score: UInt32! //////////////// ▲▲ 追加 ▲▲ ////////////////
|
次にスコアラベルを構築するためのsetupScoreLabelメソッドを定義します。
GameScene.swift
func setupScoreLabel() { scoreLabelNode = SKLabelNode(fontNamed: "Arial Bold") scoreLabelNode.fontColor = UIColor.blackColor() scoreLabelNode.position = CGPoint(x: self.frame.width / 2.0, y: self.frame.size.height * 0.9) scoreLabelNode.zPosition = 100.0 scoreLabelNode.text = String(score)
self.addChild(scoreLabelNode) }
|
いままでと同様に、このメソッドをdidMoveToViewメソッドに追加して動作確認してみましょう。
GameScene.swift
override func didMoveToView(view: SKView) { : : score = 0 self.setupScoreLabel() }
|

衝突処理の実装
今の状態では、物理シュミレーションを設定したSKNodeが衝突しても、このままでは衝突したイベントをハンドルできません。SpriteKitには、衝突しはじめたこと、衝突し終わったことを検知する仕組みがあります。
衝突イベントをハンドルできるようにGameSceneクラスにSKPhysicsContactDelegateプロトコルを追加します。
GameScene.swift
class GameScene: SKScene,SKPhysicsContactDelegate {
|
また、didMoveToViewメソッド内にself.physicsWorld.contactDelegate = selfを追加します。
GameScene.swift
// シーンが表示された時に呼ばれる override func didMoveToView(view: SKView) { // 物理シミュレーションを設定 self.physicsWorld.gravity = CGVector(dx: 0.0, dy: -2.0) //////////////// ▼▼ 追加 ▼▼ //////////////// self.physicsWorld.contactDelegate = self //////////////// ▲▲ 追加 ▲▲ //////////////// : : }
|
プロトコルを追加したら、didBeginContactメソッドを追加してください。
func didBeginContact(contact: SKPhysicsContact) { print("Contact!!!") }
|
このようにキャラクターが衝突したらContact!!!のログが表示されます。

衝突時の処理
GameScene.swift
func didBeginContact(contact: SKPhysicsContact) { print("Contact!!!") if baseNode.speed <= 0.0 { return } let rawScoreType = ColliderType.Score let rawNoneType = ColliderType.None
if (contact.bodyA.categoryBitMask & rawScoreType) == rawScoreType || (contact.bodyB.categoryBitMask & rawScoreType) == rawScoreType { score = score + 1 scoreLabelNode.text = String(score) let scaleUpAnim = SKAction.scaleTo(1.5, duration: 0.1) let scaleDownAnim = SKAction.scaleTo(1.0, duration: 0.1) scoreLabelNode.runAction(SKAction.sequence([scaleUpAnim, scaleDownAnim])) if (contact.bodyA.categoryBitMask & rawScoreType) == rawScoreType { contact.bodyA.categoryBitMask = ColliderType.None contact.bodyA.contactTestBitMask = ColliderType.None } else { contact.bodyB.categoryBitMask = ColliderType.None contact.bodyB.contactTestBitMask = ColliderType.None } } else if (contact.bodyA.categoryBitMask & rawNoneType) == rawNoneType || (contact.bodyB.categoryBitMask & rawNoneType) == rawNoneType { } else { baseNode.speed = 0.0 player.physicsBody?.collisionBitMask = ColliderType.World let rolling = SKAction.rotateByAngle(CGFloat(M_PI) * player.position.y * 0.01, duration: 1.0) player.runAction(rolling, completion:{ self.player.speed = 0.0 }) } }
|
まず、スコア(上下の障害物のところにある画面に表示されていないブロック)とNoneの衝突タイプを定義して下記の条件で処理を分けています。
- (画面に表示されていないが)スコア衝突タイプと衝突(つまり障害物を通過した)時
- None衝突タイプに衝突(つまりなにも衝突していない)時
- それ以外(つまり障害物、地面、天井に衝突)時
1の場合は、スコアを加算してアニメーションする処理を実装しています。
didBeginContactメソッドの引数contactの中には、bodyAプロパティとbodyBプロパティが存在し、衝突したノードが設定されています。
categoryBitMaskプロパティで、設定されているカテゴリがColliderType.Scoreなのかどうかを論理積をとって判断しています。
キャラクターの表面すべてが衝突面となrので、キャラクターがスコアカウントアップのノードを通過している間は、didBeginContactメソッドがなんども呼ばれます。それだと正しくスコアがカウントされないので、呼び出しを1回で済むようにスコアカウントアップのノードのcategoryBitMaskとcontactTestBitMaskをColliderType.Noneに変更しています。
3の条件のときには、障害物、地面、天井に衝突した場合なので、背景などのアニメーションを全て停止しています。
さらに、キャラクターを回転しながら落下させるアニメーションも実行しています。
リスタートの実装
最後にゲームオーバーになった状態で、画面をタップするとゲームをリスタートできるようにします。
GameScene.swift
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) { if 0.0 < baseNode.speed { for touch in touches { _ = touch.locationInNode(self) player.physicsBody?.velocity = CGVector.zero player.physicsBody?.applyImpulse(CGVector(dx: 0.0, dy: 23.0)) } } else if baseNode.speed == 0.0 && player.speed == 0.0 { coralNode.removeAllChildren() score = 0 scoreLabelNode.text = String(score) player.position = CGPoint(x: self.frame.size.width * 0.35, y: self.frame.size.height * 0.6) player.physicsBody?.velocity = CGVector.zero player.physicsBody?.collisionBitMask = ColliderType.World | ColliderType.Coral player.zRotation = 0.0 player.speed = 1.0 baseNode.speed = 1.0 } }
|
ゲームオーバになっているかどうかは、baseNodeのspeedが0かどうかで判断できるので、その条件で分岐しています。
ゲームオーバの場合は障害物を全て取り除き、スコアを0にリセットし、プレイキャラを再配置してから、キャラクターとbaseNodeのspeedを0から1.0に戻しています。
これでアプリは完成です。