Swiftで初めて動きのあるゲームを開発する人向けチュートリアル(なんとかストライクを例にStep by Step解説)


なんとかストライクとは

ひっぱって敵にぶつけて倒す例のゲームもどきです。
f:id:knj4484:20140921233735p:plain
これの作り方を通して、SpriteKitを利用したiOSアプリでの画面要素の移動/回転/拡大縮小、衝突、接触処理、ドラッグ(panジェスチャー)への対応、画面遷移を含む開発の第一歩を踏み出すためのチュートリアルです。



iPhoneアプリ開発環境であるXcodeの準備とSwiftのとっかかりとしては、こちらのチュートリアルをご覧下さい。

プロジェクト開始〜

前回のSwiftゲーム開発チュートリアル

こちらを参考に、「新しいゲームプロジェクトの開始」から「よけいなコードを消す」までの作業を行いましょう。
ただし、Project Nameのところは、NantokaStrikeにしておきましょう。

ひっぱり(ドラッグ、panジェスチャー)の動作に対応する

ひっぱり的な操作に対応するには、Pan Gesture Recognizerを使います。
f:id:knj4484:20140921223432p:plain

  1. Project NavigatorでMain.storyboardをクリックして開く
  2. 右下の検索ボックスにpanと入力
  3. Pan Gesture Recognizerが表示されるので、ドラッグ&ドロップする


f:id:knj4484:20140921223535p:plain

  1. タキシードに蝶ネクタイっぽいアイコンをクリックしてAssistan Editorを開く
  2. controlを押しながらTap Gesture Recognizerをドラッグして、コード中のGameViewControllerの中にドロップする
  3. 出てきたダイアログで下記の通り入力して、connectボタンを押す
    • Connection: Action
    • Name: didPan
    • Type: UIPanGestureRecognizer
  4. Assistant Editorを使い終わったら閉じておきます

うまく行くとdidPanのコードが生成されます

    @IBAction func didPan(sender: UIPanGestureRecognizer) {
    }

f:id:knj4484:20140921223610p:plain

  1. Pan Gesture Recognizerを右クリック
  2. 出てきたダイアログで、Outletsの下にあるdelegateの右の○をドラッグして、Game View Controllerにドロップ

今の操作で、
UIPanGestureRecognizerとGameViewControllerにdelegateの関係を追加した
ので、コードにもこれを反映します。

  • GameViewController.swiftを開きます
  • クラスのプロトコルにUIGestureRecognizerDelegateを追加します
class GameViewController: UIViewController, UIGestureRecognizerDelegate {


ひっぱり操作に対して、delegeteしたクラスのdidPanメソッドが呼び出されるので、この中に処理を書きます

    var panPointReference: CGPoint?

    @IBAction func didPan(sender: UIPanGestureRecognizer) {
        let currentPoint = sender.translationInView(self.view)
        if let originalPoint = panPointReference {
            println("currentPoint: \(currentPoint)")
        } else if sender.state == .Began {
            println(".Began")
            panPointReference = currentPoint
        }
        if sender.state == .Ended {
            println(".Ended")
            panPointReference = nil
        }
    }
  • didPanの中では、sender.stateの値を調べることで、ひっぱり操作の状態を確認することが出来ます。
  • 引っぱり中であることの確認と引っぱり開始位置との相対位置を計算するために、ひっぱり開始位置をクラス変数panPointReferenceに入れておくことにします。
  • このような基本構造にすることにして、まずは、三つの状態ごとにログを出力するようにしてみます。

ここで一旦、⌘+Rでアプリを動かして、Xcodeにログが出力されることを確認しましょう

  • 左上の再生ボタンを押す
  • あるいは、⌘ + R (コマンドキーを押しながらRキーを押す)

シミューレーター上で何回かドラッグしてみてログが想定通りに出力されましたか?

※Xcodeのログ出力はShift+⌘+Yでshow/hideができます

Xcodeに戻って、アプリを停止しましょう

  • 左上の停止ボタンを押す
  • あるいは、⌘+.(コマンドキーを押しながらピリオドキーを押す)

引っぱり操作を表す矢印と自キャラを追加する

画面上でひっぱり操作を表示する矢印を追加します

  1. この画像f:id:knj4484:20140921225122p:plainを、arrow.pngという名前で保存しましょう
  2. 保存した場所をFinderで開いてProject NavigatorのSupporting Filesにドラッグ&ドロップする
  3. Choose options for adding these filesダイアログf:id:knj4484:20140907134006p:plain
  4. Copy items if necessaryにチェック
  5. finishをクリック

ひっぱる対象のキャラクターも追加しておきます

  1. この画像f:id:knj4484:20140921225232p:plainを、nantoka.pngという名前で保存しましょう
  2. 残りはarrow.pngと同じ要領で。

コード上でこの二つの画像を表示するようにします。
GameScene.swiftを開いて、GameSceneクラスを次のように書き換えましょう。

class GameScene: SKScene {
    var arrow: SKSpriteNode
    var nantoka: SKSpriteNode
    
    required init(coder aDecoder: NSCoder) {
        fatalError("NSCoder not supported")
    }

    override init(size: CGSize) {
        println("\(size)")
        
        arrow = SKSpriteNode(imageNamed: "arrow")
        arrow.alpha = 1
        arrow.position = CGPoint(x: 160, y: 160)
        arrow.xScale = 0.2
        
        nantoka = SKSpriteNode(imageNamed: "nantoka")
        nantoka.size = CGSizeMake(40, 40)
        nantoka.position = CGPoint(x: 120, y: 180)
        
        super.init(size: size)
        backgroundColor = SKColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0)
        anchorPoint = CGPoint(x: 0, y: 0)
        
        addChild(arrow)
        addChild(nantoka)
    }

    override func update(currentTime: CFTimeInterval) {
        /* Called before each frame is rendered */
    }
}

ついでに、SKSceneクラスのbackgroundColorを指定することで背景色も変更しました。

ここで一旦、動作を確認しておきましょう。
f:id:knj4484:20140921225653p:plain

ひっぱり操作の処理を実装する

矢印が目的の角度と大きさになるようにアニメーションさせるメソッドturnArrowを定義しましょう。

GameScene.swiftを開いてGameSceneクラスにこのコードを追加します。

    func turnArrow(angle: CGFloat, scale: CGFloat) {
        let rotateAction = SKAction.rotateToAngle(angle, duration: 0.1)
        let scaleAction = SKAction.scaleXTo(scale, y: 1, duration: 0.1)
        
        arrow.runAction(SKAction.group([rotateAction, scaleAction]))
    }
  • SKAction.scaleXToを使って、ひっぱりの大きさに合わせて矢印画像を拡大します
    • scaleは拡大率を指定します。
      • 1が原寸大
      • 大きくしたい場合は、1より大きい値
      • 小さくしたい場合は、1より小さい値
    • durationは動作にかける時間を指定します
  • SKAction.rotateToAngleを使って、ひっぱりの向きに合わせて矢印画像を回転します
    • angleはX軸から反時計回りの角度をラジアン(一回転が2πです)で指定します
    • 角度angleは度ではなく、ラジアンで指定します
    • durationは動作にかける時間を指定します

次に、GameViewController.swiftを開いて、didPan中からturnArrowを呼び出すようにします。

    @IBAction func didPan(sender: UIPanGestureRecognizer) {
        let currentPoint = sender.translationInView(self.view)
        if let originalPoint = panPointReference {
            println("currentPoint: \(currentPoint)")
            let v = sub(currentPoint, p1: originalPoint)
            let len = length(v)
            scene.arrow.alpha = 1
            scene.arrow.position = scene.nantoka.position
            let scale = 0.2 * len / CGFloat(5.0)
            scene.turnArrow(vector2radian(v), scale: scale)
        } else if sender.state == .Began {
            println(".Began")
            panPointReference = currentPoint
        }
        if sender.state == .Ended {
            println(".Ended")
            panPointReference = nil
            scene.arrow.alpha = 0
        }
    }

didPanメソッドの中では、

  • ひっぱり中の位置と引っぱり開始の位置から矢印の向きと大きさを計算します(subメソッド)
  • ひっぱり開始で矢印が表示され、引っぱり終了で矢印が見えなくなるようにalpha値を変更します
  • ひっぱり距離を計算(lengthメソッド)し、それに比例した矢印の拡大率をscaleとします
  • ベクトルの向きをvector2radianで角度(ラジアン)に変換したものとscaleでturnArrowを呼び出します

ここで使った三つのメソッドも追加しておきます。
(内容を解説すると物理や数学のお勉強になってしまうので、説明は省略します。大学の物理を勉強し直しましょう→大学生のための 力学入門大学生のための 力学入門

    func sub(p0: CGPoint, p1: CGPoint) -> CGPoint {
        return CGPoint(x: p0.x - p1.x, y: p0.y - p1.y)
    }

    func length(v: CGPoint) -> CGFloat {
        return sqrt(v.x * v.x + v.y * v.y)
    }
    
    func vector2radian(v: CGPoint) -> CGFloat {
        let len = length(v)
        let t = -v.y / v.x
        let c = v.x / len
        if v.x == 0 {
            return acos(c)
        } else {
            var angle = CGFloat(atan(t))
            return angle + CGFloat(0 < v.x ? M_PI : 0.0)
        }
    }


最後にGameViewControllerからこのシーンを表示するようにします。

    var scene: GameScene!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let skView = view as SKView
        skView.multipleTouchEnabled = false
        scene = GameScene(size: skView.bounds.size)
        scene.scaleMode = .AspectFill
        skView.presentScene(scene)
    }

ここで一旦、ゲームを動かしてみて、矢印の動きを確認しましょう。
矢印の出始めや、回転が12時の位置を通過する時の動作が変ですが、余裕があれば改修してみて下さい。

飛ばす

SpriteKitで画面要素を動かすためには、アニメーションを使う方法の他に、物理演算を使うこともできます。
物理演算で物体を動かすと、衝突判定などが簡単になるので、キャラクターの動作には、物理演算を使うことにします。

では、GameScene.swiftを修正しましょう。

次に、キャラクターの物体としての特徴を設定するために、
super.init呼び出しの上にこのコードを追加します。

        nantoka.physicsBody = SKPhysicsBody(circleOfRadius: 16)
        nantoka.physicsBody?.dynamic = true
  • 物理演算の対象にするSKSpriteNodeのphysicsBodyプロパティを設定します
    • physicsBody: プロパティを物体としての形を指定したSKPhysicsBodyにします
    • dynamic: 重力や衝突によって物体が動くかどうかを指定します
      • true: 動く
      • false: 固定される

シーンのphysicsWorldの設定をするために、
super.init呼び出しの次にこのコードを追加します。

        physicsWorld.gravity = CGVectorMake(0, 0)
  • 今回のゲームでは、キャラクターの動きに重力を考慮する必要はないので、gravityにはゼロベクトルを設定しておきます。


GameViewController.swiftを開き、didPanの中のひっぱり終了のコードブロックの中に、このコードを追加します。

            scene.nantoka.physicsBody?.velocity = acceleration(currentPoint, point: panPointReference!)
  • 速度を与えるために、velocityプロパティにCGVectorを設定しています

ここで使ったaccelerationメソッドも追加しておきましょう。

    func acceleration(o: CGPoint, point: CGPoint) -> CGVector {
        let leverage = CGFloat(50)
        return CGVectorMake(leverage * (point.x - o.x), leverage * (o.y - point.y))
    }
  • ここでは、引っぱりを表すベクトルの大きさを50倍にしたものを速度として与えるようにしました。


では、ここで一旦、飛ばす処理の動作確認をしましょう

ぶつかる対象の追加

ぶつかる対象として壁を追加します。

壁は同じ特徴を持った四つの物体を上下左右に配置するだけなので、一つの壁を追加するメソッドを用意します。
このコードをGameSceneクラスに追加しましょう。

    func addWall(size: CGSize, position: CGPoint) {
        var wall: SKSpriteNode
        wall = SKSpriteNode(color: UIColor.brownColor(), size: size)
        wall.position = position
        wall.anchorPoint = CGPoint(x: 0, y: 0)
        
        wall.physicsBody = SKPhysicsBody(rectangleOfSize: CGSizeMake(size.width * 2, size.height * 2))
        wall.physicsBody?.dynamic = false
        addChild(wall)
    }


上下左右の壁の大きさと位置を指定して、このメソッドを呼び出せば壁の追加は終了です。

ここで、ついでに、後ほど利用する敵キャラのライフ値ゲージも追加しておきます

    var enemyLifeGuage: SKSpriteNode
    let enemyLifeGuageBase: SKSpriteNode
  • GameSceneのクラス変数としてライフ値の表示になるenemyLifeGuageを定義します。
  • 敵ライフ値が減ったときに黒塗りが残るようにするための画面要素としてenemyLifeGuageBaseを追加します


initの中で、super.init(size: size)の上にこのコードを追加します。

        nantoka.physicsBody?.usesPreciseCollisionDetection = true
        nantoka.physicsBody?.friction = 0

        enemyLifeGuage = SKSpriteNode(color: UIColor.yellowColor(), size: CGSizeMake(size.width, 20))
        enemyLifeGuage.position = CGPoint(x: 0, y: size.height - enemyLifeGuage.size.height)
        enemyLifeGuage.anchorPoint = CGPoint(x: 0, y: 0)

        let enemyLifeGuageBase = SKSpriteNode(color: UIColor.blackColor(), size: CGSizeMake(size.width, 20))
        enemyLifeGuageBase.position = CGPoint(x: 0, y: size.height - enemyLifeGuage.size.height)
        enemyLifeGuageBase.anchorPoint = CGPoint(x: 0, y: 0)        
  • クラス変数/定数は super.init呼び出しより先に初期化する必要があります
    • usesPreciseCollisionDetectionをtrueにしておくと、精度の高い衝突判定が行われるようになります
  • 自キャラの摩擦係数frictionをゼロにしておきます。衝突や接触したときに自キャラがくるくるしてしまわないようにするためです。
  • sizeはこのシーンの大きさが入っているので、画面の上下左右からつめてゲージや壁を配置するために利用します

initの中で、super.init(size: size)の下にこのコードを追加します

        let wallThickness: CGFloat = 10
        let fieldSize = CGSizeMake(size.width - wallThickness * 2, 400)

        let fieldBottomY = size.height - (enemyLifeGuage.size.height + wallThickness + fieldSize.height)

        // upper wall
        addWall(CGSize(width: fieldSize.width, height: wallThickness),
            position: CGPoint(x: wallThickness, y: enemyLifeGuage.position.y - wallThickness))
        
        // bottom wall
        addWall(CGSizeMake(fieldSize.width, wallThickness),
            position: CGPoint(x: wallThickness, y: fieldBottomY - wallThickness))
        
        // right wall
        addWall(CGSizeMake(wallThickness, fieldSize.height),
            position: CGPoint(x: wallThickness + fieldSize.width, y: fieldBottomY))
        
        // left wall
        addWall(CGSizeMake(wallThickness, fieldSize.height),
            position: CGPoint(x: 0, y: fieldBottomY))

        addChild(enemyLifeGuageBase)
        addChild(enemyLifeGuage)
  • 親クラスのプロパティにアクセスするためには(ここではaddChild)、super.init呼び出し後に書く必要があります
  • 壁の厚さwallThicknessと壁が納まる領域の大きさfieldSizeを定義します
    • 領域の幅は画面ぴったりにして、高さは適当に選びました
  • SpriteKitでは画面左下が座標の原点(0,0)なので、それに合わせたpositionでそれぞれの壁を配置しています

では、ゲームらしくなってきたところで、動作確認をしてみましょう。

動いている途中でもまたひっぱれちゃったりしますが、やる気のある人はその辺の制御も入れてみて下さい。

跳ね返る位置も若干おかしいですが、各自で調整してみて下さい。
GameViewControllerのviewDidLoadメソッドの中にこのコードを追加しておくと、物体としての輪郭が表示されます。

        skView.showsPhysics = true

他にも、デバッグに役立つ情報を表示するために、このようなプロパティ設定も追加しておいてもよいでしょう。

        skView.showsDrawCount = true
        skView.showsFPS = true
        skView.showsNodeCount = true
        skView.showsFields = true
        skView.showsQuadCount = true

敵キャラと接触時の処理を追加する

壁にぶつかったときには単に反射するだけで良いのに対して、
敵にぶつかったときには敵のライフ値を減らす処理も入れる必要があります。
ここでは接触判定を利用して、その実装をします。

接触判定と衝突は独立した概念なので、
物体同士がすり抜けたときに接触判定を行うことも出来ます。
たとえば、フィールドに配置されたアイテムをゲットする時にも使えます。

ここで、敵キャラを配置の準備をしましょう

  1. 敵キャラ画像としてこの画像f:id:knj4484:20140922214102j:plainをenemy.pngという名前で保存して下さい
  2. 自キャラ画像の取り込みと同じ手順で取り込みます

接触判定では、まず、Spriteのカテゴリを設定することになります。
カテゴリは接触の相手ごとに処理を切り替えるためのUInt32型の値です。

このコードをGameSceneのクラス変数として追加しましょう。

    let categoryS: UInt32 = 0x1 << 0
    let categoryE: UInt32 = 0x1 << 1

    var enemyLifePoint = 100
  • 自キャラをcategoryS、敵キャラをcategoryEというカテゴリ分けにしました
  • ビット演算をするので、各ビットが各クラスを表すようにします
  • ついでに、敵のライフ値を表すクラス変数も追加しておきます

クラスが接触に関する処理を受け持つことを示すために、次のように、クラスにSKPhysicsContactDelegateプロトコルを追加します。

class GameScene: SKScene, SKPhysicsContactDelegate {

このコードをsuper.init呼び出しの下に入れましょう。

        physicsWorld.contactDelegate = self

        nantoka.physicsBody?.categoryBitMask = categoryS
        nantoka.physicsBody?.contactTestBitMask = categoryE
        
        let enemy = SKSpriteNode(imageNamed: "enemy")
        enemy.position = CGPoint(x: 160, y: 400)
        enemy.physicsBody = SKPhysicsBody(rectangleOfSize: CGSizeMake(80, 80))
        enemy.physicsBody?.dynamic = false
        enemy.physicsBody?.categoryBitMask = categoryE
        enemy.physicsBody?.contactTestBitMask = categoryS
        enemy.physicsBody?.usesPreciseCollisionDetection = true
        addChild(enemy)
  • contactDelegateをselfに設定することで、接触が判定されたときにGameSceneのdidBeginContactが呼び出されるようになります
  • categoryBitMaskには自分のカテゴリを表す
  • collisionBitMaskに衝突の相手となるカテゴリを示す値を設定します。
    • 複数ある場合は、|(ビットOR演算子)区切りで並べます


contactTestBitMaskを設定したSKSpriteNodeが接触すると呼び出されるdidBeginContactを定義します。
このコードをGameSceneクラスに追加しましょう。

    func didBeginContact(contact: SKPhysicsContact) {
        println("didBeginContact")
        let (starBody, other) = contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask ? (contact.bodyA, contact.bodyB) : (contact.bodyB, contact.bodyA)
        switch other.categoryBitMask {
        case categoryE:
            enemyLifePoint -= 10
            enemyLifeGuage.size = CGSizeMake(enemyLifeGuageBase.size.width * CGFloat(enemyLifePoint) / CGFloat(100.0), enemyLifeGuageBase.size.height)
        default:
            break // nothing to do
        }
    }
  • 引数のSKPhysicsContactには接触した二つの物体を表すSKPhysicsBodyのbodyAとbodyBとして渡されます。
  • どちらがどのカテゴリの物体なのかは予め分かりませんので、それぞれのcategoryBitMaskを利用して、それぞれ適切な変数に入れ直します。
    • 今回は接触する片方が必ず自キャラであることと、自キャラのcategoryBitMaskが一番小さい数値に対応していることを利用しています。
      • 自キャラをnantokaBodyに、接触相手をotherに入れ直しています。
  • 接触相手の種類に応じた処理にするために、other.categoryBitMaskでswitchしています。
    • 例えば、アイテムを導入してゲットの処理を追加する場合などには、ここのcaseを増やして行くことになります。
  • 敵に接触した場合は、とりあえず敵ライフ値を10減らすことにしました
  • ライフ値の減少に合わせて、ライフ値ゲージの表示も変更します

完成です!

おめでとうございます!
では、さっそく⌘+Rでゲームを起動して、遊んでみましょう


おわりに

お疲れ様でした!
皆様が面白いゲームを開発して、楽しい人生をおくるきっかけ作りにこの記事が役立てば、これに勝る喜びはありません。

Swiftの記事のまた次のネタも仕込み中です。

あとがき

土日に作業をしていて疲れたら、ふらっと電車に乗って適当な街に散歩に行きます。
最初から散歩行くつもりなら散歩ガイドでも持って行くんですが、ふらっと行くときも多いので、こんなアプリを使っています。
https://itunes.apple.com/jp/app/supotto-jiantsukaru!fotopot/id813779149?mt=8&uo=4&at=10l8JW&ct=hatenablog
自分の居る場所周辺で話題になっているモノや店が分かって超便利です。

散歩ガイドは英語の勉強もかねて洋書を使います。楽しいです。