swift4で初心者向けゲームアプリ「Flappy Bird」作ってみた!
はじめに
今回はiPhoneのゲームアプリを作るための基本を学ぶために
有名な「Flappy Bird」を真似たアプリを作ってみました!
自分なりに学習のために各コードの処理を改めて振り返りたいと思うので
間違っているところなどあれば、ご指摘頂ければ嬉しいです。
ちなみにこちらの動画を参考にしました!
www.youtube.com
①新規プロジェクト立ち上げる
xcodeで新規プロジェクトを立ち上げます。
Gameを選ぶと初めからSpriteKitがimportされているファイルが立ち上がるので
Single View Appではなく、Gameを選ぶようにしましょう。
②GameScene.sksのNodeを削除
GameScene.sksに元からある「helloLabel」というNodeは必要ないので削除します。
③以下のコードを書く
import SpriteKit import GameplayKit class GameScene: SKScene, SKPhysicsContactDelegate { var bird = SKSpriteNode() var bg = SKSpriteNode() var score = 0 var scoreLabel = SKLabelNode() var gameOverLabel = SKLabelNode() var timer = Timer() enum ColliderType: UInt32 { case Bird = 1 case Object = 2 case Gap = 4 } var gameOver = false @objc func makePipes() { let movePipes = SKAction.move(by: CGVector(dx: -2 * self.frame.width, dy: 0), duration: TimeInterval(self.frame.width / 100)) let removePipes = SKAction.removeFromParent() let moveAndRemovePipes = SKAction.sequence([movePipes, removePipes]) let gapHeight = bird.size.height * 4 let movementAmount = arc4random() % UInt32(self.frame.height / 2) let pipeOffset = CGFloat(movementAmount) - self.frame.height / 4 let pipeTexture = SKTexture(imageNamed: "pipe1.png") let pipe1 = SKSpriteNode(texture: pipeTexture) pipe1.position = CGPoint(x: self.frame.midX + self.frame.width, y: self.frame.midY + pipeTexture.size().height / 2 + gapHeight / 2 + pipeOffset) pipe1.run(moveAndRemovePipes) pipe1.physicsBody = SKPhysicsBody(rectangleOf: pipeTexture.size()) pipe1.physicsBody!.isDynamic = false pipe1.physicsBody!.contactTestBitMask = ColliderType.Object.rawValue pipe1.physicsBody!.categoryBitMask = ColliderType.Object.rawValue pipe1.physicsBody!.collisionBitMask = ColliderType.Object.rawValue pipe1.zPosition = -1 self.addChild(pipe1) let pipe2Texture = SKTexture(imageNamed: "pipe2.png") let pipe2 = SKSpriteNode(texture: pipe2Texture) pipe2.position = CGPoint(x: self.frame.midX + self.frame.width, y: self.frame.midY - pipe2Texture.size().height / 2 - gapHeight / 2 + pipeOffset) pipe2.run(moveAndRemovePipes) pipe2.physicsBody = SKPhysicsBody(rectangleOf: pipeTexture.size()) pipe2.physicsBody!.isDynamic = false pipe2.physicsBody!.contactTestBitMask = ColliderType.Object.rawValue pipe2.physicsBody!.categoryBitMask = ColliderType.Object.rawValue pipe2.physicsBody!.collisionBitMask = ColliderType.Object.rawValue pipe2.zPosition = -1 self.addChild(pipe2) let gap = SKNode() gap.position = CGPoint(x: self.frame.midX + self.frame.width, y: self.frame.midY + pipeOffset) gap.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: pipeTexture.size().width, height: gapHeight)) gap.physicsBody!.isDynamic = false gap.run(moveAndRemovePipes) gap.physicsBody!.contactTestBitMask = ColliderType.Bird.rawValue gap.physicsBody!.categoryBitMask = ColliderType.Gap.rawValue gap.physicsBody!.collisionBitMask = ColliderType.Gap.rawValue self.addChild(gap) } func didBegin(_ contact: SKPhysicsContact) { if gameOver == false { if contact.bodyA.categoryBitMask == ColliderType.Gap.rawValue || contact.bodyB.categoryBitMask == ColliderType.Gap.rawValue { score += 1 scoreLabel.text = String(score) } else { self.speed = 0 gameOver = true timer.invalidate() gameOverLabel.fontName = "Helvetica" gameOverLabel.fontSize = 30 gameOverLabel.text = "Game Over! Tap to play again!" gameOverLabel.position = CGPoint(x: self.frame.midX, y: self.frame.midY) self.addChild(gameOverLabel) } } } override func didMove(to view: SKView) { self.physicsWorld.contactDelegate = self setupGame() } func setupGame() { timer = Timer.scheduledTimer(timeInterval: 3, target: self, selector: #selector(self.makePipes), userInfo: nil, repeats: true) let bgTexture = SKTexture(imageNamed: "bg.png") let moveBGAnimation = SKAction.move(by: CGVector(dx: -bgTexture.size().width, dy: 0), duration: 7) let shiftBGAnimation = SKAction.move(by: CGVector(dx: bgTexture.size().width, dy: 0), duration: 0) let moveBGForever = SKAction.repeatForever(SKAction.sequence([moveBGAnimation, shiftBGAnimation])) var i:CGFloat = 0 while i < 3 { bg = SKSpriteNode(texture: bgTexture) bg.position = CGPoint(x: bgTexture.size().width * i, y: self.frame.midY) bg.size.height = self.frame.height bg.run(moveBGForever) bg.zPosition = -2 self.addChild(bg) i += 1 } let birdTexture = SKTexture(imageNamed: "flappy1.png") let birdTexture2 = SKTexture(imageNamed: "flappy2.png") let animation = SKAction.animate(with: [birdTexture, birdTexture2], timePerFrame: 0.1) let makeBirdFlap = SKAction.repeatForever(animation) bird = SKSpriteNode(texture: birdTexture) bird.position = CGPoint(x: self.frame.midX, y: self.frame.midY) bird.run(makeBirdFlap) bird.physicsBody = SKPhysicsBody(circleOfRadius: birdTexture.size().height / 2) bird.physicsBody!.isDynamic = false bird.physicsBody!.contactTestBitMask = ColliderType.Object.rawValue bird.physicsBody!.categoryBitMask = ColliderType.Bird.rawValue bird.physicsBody!.collisionBitMask = ColliderType.Bird.rawValue self.addChild(bird) let ground = SKNode() ground.position = CGPoint(x: self.frame.midX, y: -self.frame.height / 2) ground.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: self.frame.width, height: 1)) ground.physicsBody!.isDynamic = false ground.physicsBody!.contactTestBitMask = ColliderType.Object.rawValue ground.physicsBody!.categoryBitMask = ColliderType.Object.rawValue ground.physicsBody!.collisionBitMask = ColliderType.Object.rawValue self.addChild(ground) scoreLabel.fontName = "Helvetica" scoreLabel.fontSize = 60 scoreLabel.text = "0" scoreLabel.position = CGPoint(x: self.frame.midX, y: self.frame.height / 2 - 70) self.addChild(scoreLabel) } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { if gameOver == false { bird.physicsBody!.isDynamic = true bird.physicsBody!.velocity = CGVector(dx: 0, dy: 0) bird.physicsBody!.applyImpulse(CGVector(dx: 0, dy: 50)) } else { gameOver = false score = 0 self.speed = 1 self.removeAllChildren() setupGame() } } override func update(_ currentTime: TimeInterval) { // Called before each frame is rendered } }
以上です。
(すいません、嘘です笑。ここから自分なりにアウトプットかねて解説してみたいと思います。間違ってたら、教えてください笑)
まず、makePipesメソッド内にあるSKActionのmovePipesです。
let movePipes = SKAction.move(by: CGVector(dx: -2 * self.frame.width, dy: 0), duration: TimeInterval(self.frame.width / 100))
このdxの値が-2 * self.frame.widthでないといけない理由としては、
その更に下の方でpipe1.positionのxをself.frame.midX + self.frame.widthとしているからです。
つまり、パイプが初めに表示されるx座標の位置は画面の中央から右に画面の幅分だけ移動した点になります。
そうせずに画面の右端すぐにパイプを表示してしまうと、
ゲームが始まった瞬間に右からパイプが現れてしまうので、そうならないように少し余裕を持たせているのですね。
dxの値を試しに-2 * self.frame.widthではなく、self.frame.widthだけにすると、当然ですが画面の中央でパイプが消えることになります。
durationをself.frame.width / 100にしている理由はデバイスの画面の大きさによってパイプが動く速度を変えたいからです。
そうしないと小さい画面は不利ですからね笑
次にmovementAmount変数とpipeOffset変数の説明ですが、
let movementAmount = arc4random() % UInt32(self.frame.height / 2) let pipeOffset = CGFloat(movementAmount) - self.frame.height / 4
この二つを設定しなければ常に右側から流れてくるパイプのy座標の位置はself.frame.midY + pipeTexture.size().height / 2 + gapHeight / 2(pipe1の場合)になってしまいます。
つまり、常に画面中央にパイプとパイプの隙間がくるようになるのでそれではおもしろくないです笑
よってランダムにパイプのy座標の位置を変化させるために設定しています。
movementAmountの値ですが arc4random() % UInt32(self.frame.height / 2)となっています。
arc4random()では0から、とても大きい数字(適当でごめんなさい笑)の間でランダムな数字を返してくれますがそのとても大きい数字が何かは関係ないです。
なぜなら、この計算式ではarc4random()をUInt32(self.frame.height / 2)で割った余りの数が帰るので
最小の値は
0 ÷ UInt32(self.frame.height / 2) = 0
最大の値は
UInt32(self.frame.height / 2)より余りが大きくなることはないので(もしそうなら、まだUInt32(self.frame.height / 2)で割れますからね。)、
それ以下になります。
つまり、0からUInt32(self.frame.height / 2)の間の数を返してくれます。
そして、pipeOffsetは CGFloat(movementAmount) - self.frame.height / 4となっています。
これは先ほどのmovementAmountの値の範囲から考えれば簡単ですが、
最小の値は
- self.frame.height / 4
最大の値は
約self.frame.height / 2 - self.frame.height / 4なので
約self.frame.height/ 4になりますね。
つまり、パイプの間の隙間は約self.frame.height/ 2の範囲でランダムに上下するということです。
そして、背景を表示するためのコードですが、
let bgTexture = SKTexture(imageNamed: "bg.png") let moveBGAnimation = SKAction.move(by: CGVector(dx: -bgTexture.size().width, dy: 0), duration: 7) let shiftBGAnimation = SKAction.move(by: CGVector(dx: bgTexture.size().width, dy: 0), duration: 0) let moveBGForever = SKAction.repeatForever(SKAction.sequence([moveBGAnimation, shiftBGAnimation])) var i:CGFloat = 0 while i < 3 { bg = SKSpriteNode(texture: bgTexture) bg.position = CGPoint(x: bgTexture.size().width * i, y: self.frame.midY) bg.size.height = self.frame.height bg.run(moveBGForever) bg.zPosition = -2 self.addChild(bg) i += 1
ここでは moveBGAnimation で7秒かけて背景の画像を右から左に動かしています。
そして、shiftBGAnimationで移動し終わるとその瞬間に背景をまた同じ位置に戻します。
それを、また左に動かしてもとの位置に戻してというのを繰り返しています。
そして、while内のコードですが、
iが0,1,2まで計3回、背景を作り出してそれを左に動かすという処理をループしています。
これはなぜかというと、例えば背景を一つしか表示せずに右から左に動かせば
背景は画面のframeのwidth分しかないので左に動くにつれ、右側から背景が無くなって黒い画面が見えてしまいます。
つまり、背景をループして表示し続けることで背景が無くなることを防いでいます。
ちなみにi=0の時は bg.positionのx座標が0なので
背景がちょうど画面に収まっている状態で表示され左に動いていきます。
そして、i=1の時は bg.positionのx座標は bg.Texture.size().width なので
i = 0 の背景の右隣に背景が表示されて、そのまま左に動いていきます。
よって背景がつながって表示されて、背景が無くなることはありません。
参考にした動画では i < 3 までループさせていますが、
i < 2 でも移動し終わった背景は元の位置に戻って、また左に流れていくので問題ないです。
まとめ
はじめての投稿なのでかなり読みづらいと思います、すみません。
ほぼ自分の理解の整理のために書いてきましたが、これが誰かの助けになったら嬉しいです!
やっぱり、なんとなく理解した気になってたこともいざ文章にしようとすると
「あれ?」ってことが多いですね笑
やはり、アウトプットすることは大事ということでこれからもがんばりたいと思います。