SpriteKitでスペースインベーダーを作ってみた

前回の 15パズルに続いて SpriteKit の習作。
今回はインベーダーゲーム(Space Invaders)を作ってみた。
細かいところは色々と違っているけど、難しくない部分はなるべく本物に近い感じになるようにしてみた。

と言うわけで、これから何回かに分けてコードの解説。
実際のコードは GitHub: SwiftInvader

仕様の確認

仕様っていうほど大げさなものではないけど、仕様確認の参考にしたサイトの一覧。

懐かしい!というか当時は覚えていた 300点ゲットの弾数とか忘れててビックリした。一番の収穫はインベーダーが1フレーム毎に1匹づつ動いていたということ。全然知らなかった。それであのゾロゾロって感じの動きになってたのか〜

とりあえず、オリジナルとの大きな違いは以下の通り

  • キャラクタが微妙に違う
  • 音が出ない(.wav ファイルを用意すれば音を出せます)
  • トーチカがドット単位で崩れない
  • レインボーができない

他にも細かい違いは多いと思うけど、とりあえずはこんな感じの仕様でスタート!

ゲームを構成するパーツ

ゲーム画面の中で SpriteKit を使って動かしたり当たり判定を行なっているパーツは以下のような構成になっています。

  1. 自機
  2. 自機の弾丸
  3. インベーダー
  4. インベーダーの弾丸
  5. UFO
  6. トーチカ
  7. 地面(の線)

これらは地面を除いて全て SKSpriteNode の subclass になっており、地面は線そのものが SKShapeNode になっています。そしてこれらが動くと SpriteKit の当たり判定(SKPhysics)システムによって得点になったり、自機がやられたり、弾丸がトーチカや地面に当たったりしています。

以下はそれぞれのパーツの contactTestBitMask の値です。

enum CategoriesBitMask: UInt32 {
    case Wall = 0x01
    case Invader = 0x02
    case Player = 0x04
    case UFO = 0x08
    case InvaderBullet = 0x10
    case PlayerBullet = 0x20
    case StageBorder = 0x40
}

そしてこれが自機(Player)の SKPhysicsBody の設定部分です。

        self.physicsBody = SKPhysicsBody(texture: texture, size: self.size)
        self.physicsBody?.isDynamic = true
        self.physicsBody?.usesPreciseCollisionDetection = false
        self.physicsBody?.categoryBitMask = CategoriesBitMask.Player.rawValue
        self.physicsBody?.contactTestBitMask = CategoriesBitMask.InvaderBullet.rawValue | CategoriesBitMask.Invader.rawValue
        self.physicsBody?.allowsRotation = false
        self.physicsBody?.collisionBitMask = 0x0

categoryBitMask で自機の値(0x02)を設定して、contactTestBitMask で自分と衝突する他のオブジェクトを指定しています。この場合は衝突する(当たり判定がある)のは InvaderBullet と Invader です。他のパーツ(例えば UFO)は自機と衝突することはないので設定しません。このように全てのパーツに設定をして、面倒な当たり判定は全て SpriteKit に任せています。

これ以外のスコアや残機、クレジットなどの情報のパーツは、文字列や画像を CGImage にして SKSpriteNode のテクスチャとして貼り付けています。スコアや残機は適時描き換えていますが、それ以外は最初に描いたらそのままです。

それぞれのパーツの動き(Animation)

ゲーム画面で動く(移動する)パーツは以下の5つです。

  • 自機
  • 自機の弾丸
  • インベーダー
  • インベーダーの弾丸
  • UFO

これらは更に動き方で2種類に分かれます

  • SKAction.move でアニメーションして移動する
  • SKSprite.position で表示場所を変更

基本的には SKAnimation.move で移動のアニメーションを SpriteKit に任せています。しかし、インベーダーと自機はフレーム毎に動かす必要があるので SKSprite.position を変更して表示場所を変えることで動いているように見せています。

アニメーションするパーツの中で UFO と自機の弾丸は SKAction.move(to: duration:) で行き先の場所と動きにかかる時間を指定して動かしています。これは何事もなく進んだ場合のスタートからゴールまでの距離が常に同じだからです。UFO は画面の端から端へ、自機の弾丸は自機の Y 軸値から(見えないけど)天井の Y 軸値までを常に同じ時間かけて進んでいます。

以下は UFO の移動アニメーション部分です。SKAction.sequence で移動した後で. UFO を画面から remove しています。(completion 内は音を止めたり UFO が出ているかどうかのフラグの設定です。)

        let ufoAction = SKAction.sequence([SKAction.move(to: endPoint, duration: UFOMoveDuration), SKAction.removeFromParent()])
        run(ufoAction, completion: {
            self.ufoFlyingSoundNode.run(SKAction.stop())
            self.ufoFlyingSoundNode.removeFromParent()
            self.running = false
        })

これに対してインベーダーの弾丸は、ゴール(地面)の Y 軸値はいつも同じですがスタートするインベーダーの Y 軸値が一定ではありません。なので距離が違うのに一定の時間をかけて進むようにするとスピードが変わってしまいます。インベーダーの弾丸のスピードが発射するインベーダーが近づくにつれて遅くなるのはおかしいので、CGVector で x, y軸方向のスピードを設定して SKAction.move(by: duration:) で進ませています。何もない画面でこれをするとゴール地点がなくて画面の外まで進んでしまうので、地面のパーツを置いてインベーダーの弾丸がそこでぶつかるようにしています。(地面のパーツはこの為だけに置かれています!)

        let moveBulletAction = SKAction.move(by: InvaderBulletSpeed, duration: InvaderBulletDuration)
        let removeBulletAction = SKAction.removeFromParent()
        run(SKAction.sequence([moveBulletAction, removeBulletAction]), completion: {
            self.completion?()
            self.completion = nil
        })

パーツの動き(自機)

自機の動きはフレーム毎に update(_ scene: keys:) が SKScene とキー入力の値のリストと共に呼ばれるのでここで行います。キー入力があった場合はそれに合わせて左右へ PlayerSpeed 分動かした値を SKSpriteNode.position に入れて移動させています。

    func update(_ scene: SKScene, keys: [Bool]) {
        for (index, key) in keys.enumerated() {
            if key {
                switch index {
                case .leftKey:
                    let newX = position.x - PlayerSpeed
                    if newX < ScreenMargineWidth + size.width / 2.0 { return }
                    position = CGPoint(x: newX, y: position.y)
                case .rightKey:
                    let newX = position.x + PlayerSpeed
                    if newX > scene.size.width - ScreenMargineWidth - (size.width / 2.0) { return }
                    position = CGPoint(x: newX, y: position.y)
                case .aKey:
                    aKeyPushed = true
                default:
                    break
                }
            }
        }
        if aKeyPushed && (keys[.aKey] == false) {
            aKeyPushed = false
            fireBullet(scene)
        }
    }

ちなみに aKey や leftKey, rightKey はファミコンエミュレータ StNesEmu でファミコンのコントローラーの値を読むときに使っていたコードを流用しています。PPGameInputController.swift がコントローラーとキーボードの入力をいい感じに受け付けてくれています。

パーツの動き(インベーダー)

インベーダーはフレーム毎に左下から順番に1匹づつ動いています。左下から右下へ、そして左下から2番目から右下から2番目へ、という具合に 1/60 に1匹づつ 55匹のインベーダーが移動します。全てのインベーダーが移動し終わったらまた左下から同じことの繰り返しです。

実際にはフレーム毎に呼ばれる InvaderGameController の moveInvader() でインベーダーを動かしています。ここで nextInvader() を読んで次に移動するインベーダーを取得。invader.update() でそのインベーダーを動かして、もしもインベーダーが移動範囲を超えていたら次の移動方向に変更するフラグ(needToChangeMoveType)を立てます。

nextInvader() では移動するインベーダーを返しますが、インベーダーの番号(currentInvaderNumber)が 0番(左下)の場合には移動する方向の変更のチェックもさせてしまっています。全部のインベーダーが移動し終わってからでないと、インベーダーの移動方向を変更はできないからです。(ここはコードがごちゃごちゃしてわかりにくいです)

    private func moveInvader(_ scene: SKScene) {
        if let invader = nextInvader() {
            invader.update({ newPosition in
                if (newPosition.x < ScreenMargineWidth + InvaderSize.width / 2) ||
                    (newPosition.x > scene.size.width - ScreenMargineWidth - InvaderSize.width / 2) {
                    needToChangeMoveType = true
                }
                if (newPosition.y <= InvaderSize.height * CGFloat(ScreenCanonRow)) {
                    player.canonCount = 0
                    removePlayer(scene, player: player)
                }
            })
        }
    }
    
    private func nextInvader() -> Invader? {
        while invaders.count != 0 {
            if currentInvaderNumber == 0 && needToChangeMoveType {
                invaders.forEach({ $0.nextState() })
                needToChangeMoveType = false
            }
            else if currentInvaderNumber == 0 {
                invaders.forEach({ $0.checkMoveState() })
            }
            let invader = invaders[currentInvaderNumber]
            currentInvaderNumber += 1
            if currentInvaderNumber >= invaders.count {
                currentInvaderNumber = 0
            }
            if invader.alive { return invader }
        }
        
        return nil
    }

最後に

以上、今回は仕様、ゲームのパーツ構成、それぞれのパーツの移動についてでした。いきなり細かい話でしたが「SpriteKit で当たり判定楽チンで良い!」と思ったので最初に書きました。

もともとゲームを作ったりはしていなかったので SpriteKit を今まで使っていませんでしたが、使ってみると便利な機能がたくさんあるものだな〜と感心しきりです。

次回はゲームのステートとそれぞれのパーツについての予定です。

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)