「たった1日で基本が身に付く! Swift アプリ開発 超入門」の感想・備忘録3

スポンサーリンク
「たった1日で基本が身に付く! Swift アプリ開発 超入門」の感想・忘備録2の続き

SpriteKitとは

  • ゲームを作成するには、SpriteKitというフレームワークを使用する。
  • 背景やキャラクターなどの画像はスプライトという単位で扱われる。
  • スプライトに対する衝突判定、アニメーション、タッチ操作、重力設定、などを簡単に行うことができる。
  • SpriteKitは以下のの4つから構成されている
    ビュー:シーンの土台
    シーン:ノードの土台
    ノード:キャラクターや文字
    アクション:ノードに動きを付ける
  • SpritKitはUIViewではなくSKViewというクラスを使用する。
  • SKViewでの座標の扱いは、画面中央を原点とする(UIViewは左上が原点)

シューティングゲームの作成

プロジェクト作成〜初期画面の表示まで

  1. プロジェクト作成⇒Game
  2. LanchScreen.storyboardを選択⇒Labelなどでスプラッシュ画面を作成
  3. Main.storyboardを選択⇒既にある画面の左側にViewControllerを追加
  4. 矢印を左に移動し、追加したViewを最初の画面にする
  5. 追加したViewにButtonを追加
  6. Controlを押しながらButtonを右側のViewにドロップしShow Detailを選択
  7. Gimpなどで画像を作成。背景は透過色にする。
  8. Assets.xcassetsをクリックし、AppIconに画像をドロップ
  9. 1xから2xにドラッグ&ドロップ
  10. シーン(GameScene.sks)をクリックし、デフォルトのLabel(Hello, World!)を削除
  11. GameScene.swiftをクリックし、didMove以外のメソッドを削除
  12. 以下のように、メンバ変数とdidMoveメソッドを追加(didMoveは画面が表示された時に最初に実行される)
// ======================
// メンバ変数を追加
// ======================
var node1 = SKSpriteNode()

// 〜省略〜

// ==========================
// didMoveメソッドを追加
// ==========================
override func didMove(to view: SKView) {
    var sizeRate : CGFloat = 0.0
    var node1Size = CGSize(width: 0.0, height: 0.0)
    let offsetY = frame.height / 20
    
    // 画像ファイルの読み込み
    self.node1 = SKSpriteNode(imageNamed: "画像名")
    // 自機を幅の1/5にするための倍率を求める
    sizeRate = (frame.width / 5) / self.node1.size.width
    // 自機のサイズを計算する
    node1Size = CGSize(width: self.node1.size.width * sizeRate,
                        height: self.node1.size.height * sizeRate)
    // 自機のサイズを設定する
    self.node1.scale(to: node1Size)
    // 自機の表示位置を設定する
    self.node1.position = CGPoint(x: 0, y: (-frame.height / 2) + offsetY + node1Size.height / 2)
    // シーンに自機を追加(表示)する
    addChild(self.node1)
}

※Y座標計算は以下のような処理になっている。

  • -frame.height / 2
    画面の一番下のy座標
  • + offsetY
    一番下から少し離す分を加算
  • + node1Size.height / 2
    自機の高さの半分を加算

敵の表示

  1. メンバ変数とdidMoveメソッドの処理を追加
// ==========================
// メンバ変数を追加
// ==========================
var enemyRate : CGFloat = 0.0  // 敵の表示倍率用変数の追加
var enemySize = CGSize(width: 0.0, height: 0.0)  // 敵の表示サイズ用変数の追加
var timer: Timer?

// 〜省略〜

// ==========================
// didMoveメソッドに処理を追加
// ==========================
// 敵の画像ファイルの読み込み
let tempEnemy = SKSpriteNode(imageNamed: "画像名")
// 敵を幅の1/5にするための倍率を求める
enemyRate = (frame.width / 10) / tempEnemy.size.width
// 敵のサイズを計算する
enemySize = CGSize(width: tempEnemy.size.width * enemyRate, height: tempEnemy.size.height * enemyRate)

// 敵を表示するメソッドmoveEnemyを1秒ごとに呼び出し
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { _ in
    self.moveEnemy()
})

Timer.scheduledTimerは引数blockの処理を繰り返し呼び出す

  1. moveEnemyメソッドを追加
// ==========================
// moveEnemyメソッドを追加
// ==========================
// 敵を表示するメソッド
func moveEnemy() {
    let enemyNames = ["画像名", "画像名", "画像名"]
    let idx = Int.random(in: 0 ..< 3)
    let selectedEnemy = enemyNames[idx]
    let enemy = SKSpriteNode(imageNamed: selectedEnemy)
    
    // 敵のサイズを設定する
    enemy.scale(to: enemySize)
    // 敵のx方向の位置を生成する
    let xPos = (frame.width / CGFloat.random(in: 1...5)) - frame.width / 2
    // 敵の位置を設定する
    enemy.position = CGPoint(x: xPos, y: frame.height / 2)
    // シーンに敵を表示する
    addChild(enemy)
    
    // 指定した位置まで2.0秒で移動させる
    let move = SKAction.moveTo(y: -frame.height / 2, duration: 2.0)
    // 親からノードを削除する
    let remove = SKAction.removeFromParent()
    // アクションを連続して実行する
    enemy.run(SKAction.sequence([move, remove]))
}
  • frame.width / CGFloat.random(in: 1...5)
    画面幅を5分割
  • frame.height / 2
    画面の一番上のy座標
  • SKAction.moveTo(y: -frame.height / 2, duration: 2.0)
    二秒かけて一番下まで移動
  • SKSpriteNodeのrunメソッドでSKActionを実行
  • SKAction.sequenceで連続したSKActionを生成

加速度センサーの追加

  1. import文、メンバ変数、didMoveの処理、を追加
// ==========================
// import文を追加
// ==========================
import CoreMotion

// 〜省略〜

// ==========================
// メンバ変数を追加
// ==========================
let motionMgr = CMMotionManager()
var accelarationX: CGFloat = 0.0

// 〜省略〜

// ==========================
// didMoveメソッドに処理を追加
// ==========================
// 加速度センサーの取得間隔を設定取得処理
motionMgr.accelerometerUpdateInterval = 0.05
// 加速度センサーの変更値取得
motionMgr.startAccelerometerUpdates(to: OperationQueue.current!) { (val, _) in
    guard let unwrapVal = val else {
        return
    }
    let acc = unwrapVal.acceleration
    self.accelarationX = CGFloat(acc.x)
}
  • 加速度、ジャイロスコープ、歩数計、気圧計などのセンサーを使用するにはCoreMotionというフレームワークを使用する。
  • CMMotionManagerインスタンスのstartAccelerometerUpdatesメソッドで加速度センサーの値を取得することができる。
  • startAccelerometerUpdatesメソッドのハンドラの第1引数はオプショナル型なのでgurad文でアンラップしている。
  • accelerationプロパティに3軸分の値が格納されている。
  1. didSimulatePhysicsメソッドを追加
// ===============================
// didSimulatePhysicsメソッドを追加
// ===============================
// シーンの更新
override func didSimulatePhysics() {
    let pos = self.node1.position.x + self.accelarationX * 30
    if pos > frame.width / 2 - self.node1.frame.width / 2 { return }
    if pos < -frame.width / 2 + self.node1.frame.width / 2 { return }
    self.node1.position.x = pos
}
  • GameSceneクラスが継承しているSKSceneクラスのdidSimulatePhysicsメソッドは、物理シミュレーションやセンサーの値が更新されたときにコールされる。
  • 加速度センサーの値は0〜1なので30倍している。
  • 「右端-自機の半分の幅」より大きい、「左端+自機の半分の幅」より小さい、場合は画面からはみ出してしまうのでreturnで処理を抜けている。

ミサイル発射処理を追加

  1. touchesBeganメソッドを追加
// ===============================
// touchesBeganメソッドを追加
// ===============================
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    // 画像ファイルの読み込み
    let missile = SKSpriteNode(imageNamed: "missile")
    // ミサイルの発射位置の作成
    let missilePos = CGPoint(x: self.node1.position.x,
                             y: self.node1.position.y +
                                (self.node1.size.height / 2) -
                                (missile.size.height / 2))
    // ミサイル発射位置の設定
    missile.position = missilePos
    // シーンにミサイルを表示する
    addChild(missile)
    
    // 指定した位置まで0.5秒で移動する
    let move = SKAction.moveTo(y: frame.height / 2 - missile.size.height, duration: 0.5)
    // 親からノードを削除する
    let remove = SKAction.removeFromParent()
    // アクションを連続して実行する
    missile.run(SKAction.sequence([move, remove]))
}
  • ミサイル発射位置のY座標は「自機の位置+自機の半分の高さ-ミサイルの半分の高さ」で自機の先端になる

衝突判定を追加

  1. メンバ変数、didMoveの処理、moveEnemyの処理、touchesBeganの処理、を追加
// ===============================
// メンバ変数を追加
// ===============================
// カテゴリビットマスクの定義
let myCategory : UInt32 = 0b0001
let missileCategory : UInt32 = 0b0010
let enemyCategory : UInt32 = 0b0100

// 〜省略〜

// ===============================
// didMoveメソッドに処理を追加
// ===============================
// 画面への重力設定
physicsWorld.gravity = CGVector(dx: 0, dy: 0)
// 自機への物理ボディ、カテゴリビットマスク、衝突ビットマスクの設定
self.node1.physicsBody = SKPhysicsBody(rectangleOf: self.node1.size)
self.node1.physicsBody?.categoryBitMask = self.myCategory
self.node1.physicsBody?.collisionBitMask = self.enemyCategory
self.node1.physicsBody?.isDynamic = true

// 〜省略〜

// ===============================
// moveEnemyメソッドに処理を追加
// ===============================
// 敵への物理ボディ、カテゴリビットマスクの設定
enemy.physicsBody = SKPhysicsBody(rectangleOf: enemy.size)
enemy.physicsBody?.categoryBitMask = self.enemyCategory
enemy.physicsBody?.isDynamic = true

// 〜省略〜

// ===============================
// touchesBeganメソッドに処理を追加
// ===============================
// ミサイルの物理ボディ、カテゴリビットマスク、衝突ビットマスクの設定
missile.physicsBody = SKPhysicsBody(rectangleOf: missile.size)
missile.physicsBody?.categoryBitMask = self.missileCategory
missile.physicsBody?.collisionBitMask = self.enemyCategory
missile.physicsBody?.isDynamic = true
  • ノードを衝突させるには、シーンに物理シミュレーションが行われる空間physicsWorldを作成する必要がある。
  • physicsWorldにはgravityとspeedのプロパティがある。
    gravity:CGVector(x方向, y方向)で指定する。デフォルトは(0.0, -9.8)。
    speed:gravityのスピードを変更できる。デフォルトは1.0。
  • 本ゲームではgravityは0、speedは設定しない。(コードで設定しているため)
  • スプライトを物体として認識させるには、スプライトに「物理ボディ」を作成して与える必要がある。物理ボディを与えられたスプライトは衝突、摩擦、重力を表現できるようになる。
  • 特定のスプライトにだけ衝突させたい場合もあるため、物理ボディにはカテゴリビットマスクと衝突ビットマスクというプロパティがあり、2進数(0b0000)で設定する。
  • カテゴリビットマスクは自分が属するグループ、衝突ビットマスクは衝突したい相手を表す。
  • physicsWorld.gravityで重力設定をすることで物理シミュレーション空間が作成される。
  • 物理ボディはSKPhysicsBodyのイニシャライザで作成できる。
  • isDynamicプロパティは、衝突したときに物体が動く物理シミュレーションの有効化。

衝突時の処理を追加

  1. SKPhysicsContactDelegateを継承し、didMoveの処理、touchesBeganの処理、didBeginメソッド、を追加
// ===============================
// SKPhysicsContactDelegateを継承
// ===============================
class GameScene: SKScene, SKPhysicsContactDelegate {

// 〜省略〜

// ===============================
// didMoveメソッドに処理を追加
// ===============================
// 以下のコードを追加することでdidBeginメソッドをGameSceneクラス内で呼び出せるようになる
physicsWorld.contactDelegate = self

// 〜省略〜

self.node1.physicsBody?.contactTestBitMask = self.enemyCategory

// 〜省略〜

// ===============================
// touchesBeganメソッドに処理を追加
// ===============================
missile.physicsBody?.contactTestBitMask = self.enemyCategory

// 〜省略〜

// ===============================
// didBeginメソッドを追加
// ===============================
/// 衝突時のメソッド
func didBegin(_ contact: SKPhysicsContact) {
    
    // 炎のパーティクルの読み込みと表示
    let explosion = SKEmitterNode(fileNamed: "パーティクルの名前")
    explosion?.position = contact.bodyA.node?.position ?? CGPoint(x: 0, y: 0)
    addChild(explosion!)
    
    // 炎のパーティクルアニメーションを0.5秒表示して削除
    self.run(SKAction.wait(forDuration: 0.5)) {
        explosion?.removeFromParent()
    }
    
    // 衝突したノードを削除する
    contact.bodyA.node?.removeFromParent()
    contact.bodyB.node?.removeFromParent()
}
  • 雨や雪、炎、煙などの表現は難しいが、Xcodeのパーティクルシステムを使うと簡単に作成することができる。
    File⇒New⇒File⇒SpriteKit Particle File
  • GemeSceneクラスには衝突検知機能がないため、SKPhysicsContacyDelegateクラスを継承し、衝突検知時にコールされるdidBeginメソッドをオーバーライドする。
  • didBeginメソッドがコールされるようにするには、physicsWorld.contactDelegate = selfとし、さらに物理ボディのcontaceTestBitMaskに衝突相手のビットマスクを設定する必要がある。
  • didBeginメソッドは引数contactのプロパティbodyA, bodyBでで衝突したノードを受け取る。
  • パーティクルはSKEmitterNodeイニシャライザで生成&表示される。
  • 書籍内では、衝突したノードを削除してからpositionを取得しているがそれだとnilになってしまったので、ノード削除は最後に移動した

ライフとスコアを表示する

  1. メンバ変数、didMoveの処理、を追加
// ===============================
// メンバ変数を追加
// ===============================
var lifeLabelNode = SKLabelNode()   // LIFE表示用ラベル
var scoreLabelNode = SKLabelNode()   // SCORE表示用ラベル
// LIFE用プロパティ
var life : Int = 0 {
    didSet {
        self.lifeLabelNode.text = "LIFE : \(life)"
    }
}
// SCORE用プロパティ
var score : Int = 0 {
    didSet {
        self.scoreLabelNode.text = "SCORE : \(score)"
    }
}

// 〜省略〜

// ===============================
// didMoveメソッドに処理を追加
// ===============================
// ライフの作成
self.life = 3
self.lifeLabelNode.fontName = "HelveticaNeue-Bold"
self.lifeLabelNode.fontColor = UIColor.white
self.lifeLabelNode.fontSize = 30
self.lifeLabelNode.position = CGPoint(
    x: frame.width / 2  - (self.lifeLabelNode.frame.width + 20),
    y: frame.height / 2 - self.lifeLabelNode.frame.height * 3)
addChild(self.lifeLabelNode)

// スコアの表示
self.score = 0
self.scoreLabelNode.fontName = "HelveticaNeue-Bold"
self.scoreLabelNode.fontColor = UIColor.white
self.scoreLabelNode.fontSize = 30
self.scoreLabelNode.position = CGPoint(
    x: -frame.width / 2  + self.scoreLabelNode.frame.width ,
    y: frame.height / 2 - self.scoreLabelNode.frame.height * 3)
addChild(self.scoreLabelNode)
  • シーンにテキストを表示するにはSKLabelNodeクラスを使用する。

ライフとスコアの増減処理を追加する

  1. メンバ変数、didBeginの処理、restartメソッド、を追加する
// ===============================
// メンバ変数を追加
// ===============================
var vc: GameViewController!

// 〜省略〜

// ===============================
// didBeginメソッドに処理を追加
// ===============================
// ミサイルが敵に当たった時の処理
if contact.bodyA.categoryBitMask == missileCategory ||
    contact.bodyB.categoryBitMask == missileCategory {
    self.score += 10
}

// 自機が爆発した時の処理
if contact.bodyA.categoryBitMask == myShipCategory ||
    contact.bodyB.categoryBitMask == myShipCategory {
    // ライフを1つ減らす
    self.life -= 1
    
    // 1秒後に restart を実行
    self.run(SKAction.wait(forDuration: 1)) {
        self.restart()
    }
}

// 〜省略〜

// ===============================
// restartメソッドを追加
// ===============================
// リスタート処理
func restart() {
    // ライフが0以下の場合
    if self.life <= 0 {
        // START画面に戻る
        vc.dismiss(animated: true, completion: nil)
    }
    
    // ライフが1以上なら自機を再表示
    addChild(self.node1)
}
  • 衝突した物理ボディのcategoryBitMaskを参照することで、敵と衝突したのがミサイルか自機かを判定する。
  • 画面を破棄してスタート画面に戻るにはGameViewControllerインスタンスのdismissメソッドをコールする。
  • GameViewControllerインスタンスはGameViewController.swiftで変数に代入する。
  1. GameViewController.swiftのviewDidLoadメソッドに処理を追加する
(scene as! GameScene).vc = self

画面の向きを縦固定にする

プロジェクト名をクリック⇒Target⇒General⇒Device Orientation⇒Portraitのみチェック

アプリのアイコンを設定する

180×180の画像を用意し、Assets.xcassetsのAppIconの中のiOS 7-13 60ptのx3にドロップする。
AppStoreに出す場合は1024×1024が必要。

コメント