読者です 読者をやめる 読者になる 読者になる

いものやま。

雑多な知識の寄せ集め

SKSceneの画面遷移に関して。

昨日はゲームでBGMやSEを流す方法について説明した。

ただ、最後に

実際には単に上のようにすると、シーンの遷移のときにちょっとした問題も出てくるんだけど・・・

と書いた通り、実はちょっとした問題が。

今日はそれについて。

問題の具体的な内容

さて、具体的にどんな問題が出てくるのか。

実は、昨日のようなコードを遷移前のシーンと遷移後のシーンの両方に書いて実行すると、

  1. シーンを遷移しようとする
  2. 再生していたBGMが先頭に巻き戻される
  3. 再生していたBGMが止まる
  4. シーンが遷移後のシーンになる

といった、不思議な挙動を示すことになる。

なんでこんなことが起きるのかというと、SKSceneのメソッドの呼ばれるタイミングに問題があるから。

SKSceneのメソッドの呼ばれるタイミング

SKSceneには、画面遷移のときに呼ばれるためのフックとして、次の2つのメソッドが用意されている:

  • SKScene#didMoveToView(_: SKView)
  • SKScnee#willMoveFromView(_: SKView)

前者は、シーンがビューに追加されたときに、後者はシーンがビューから取り除かれるときに呼ばれるんだけど、呼ばれるタイミングが、

  1. シーンの遷移が始まる
  2. 遷移先のシーンがビューに追加され、didMoveToView()が呼ばれる
  3. シーンの遷移が行われる(アニメーションなど)
  4. 遷移元のシーンがビューから取り除かれる前に、willMoveFromView()が呼ばれる
  5. シーンの遷移が終わる

となっているから。

このせいで、

  1. シーンの遷移が始まる
  2. 遷移先のシーンがビューに追加され、didMoveToView()が呼ばれる
    • BGMがすでに流れているけど、再び先頭から流される
  3. シーンの遷移が行われる
  4. 遷移元のシーンがビューから取り除かれる前に、willMoveFromView()が呼ばれる
    • BGMが止められる
  5. シーンの遷移が終わる

となって、最初に述べたような挙動になるみたい。

willMoveFromView()が先に呼ばれて、それから遷移が行われて、最後にdidMoveToView()が呼ばれれば、何も問題はないのだけど・・・

TransitionableScene

ところで、今回はBGMに関する問題だったけど、シーンの遷移が終わってから何かを行いたい、ということは普通にあること。
なので、シーンの遷移が終わった後に何かが行えるクラスとして、SKSceneを継承したTransitionableSceneというのを作ってみた。
(Transitionableなんて英単語ないけどw)

//==============================
// SoloXmas
//------------------------------
// TransitionableScene.swift
//==============================

import SpriteKit

class TransitionableScene: SKScene {
  var willTransition: Bool
  var afterTransitionBlock: (() -> Void)?
  
  weak var destinationScene: TransitionableScene?
  
  override init(size: CGSize) {
    self.willTransition = false
    super.init(size: size)
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override func willMoveFromView(view: SKView) {
    self.destinationScene?.afterTransitionBlock?()
    self.destinationScene?.willTransition = false
    self.destinationScene = nil
  }
}

遷移元、遷移先のシーンとも、このクラスを継承するようにして、

  1. 遷移先のシーンでは、didMoveToView()の中で、遷移が終わった後に行いたい処理をafterTransitionBlockプロパティに登録する
  2. 遷移元のシーンでは、wilMoveFromView()の最後で、スーパークラス(つまりTransitionableScene)のwillMoveFromView()を呼び出す

としておいて、遷移を行うときには、

  1. 遷移先のシーンのwillTransitionプロパティをtrueにする
  2. 遷移元のシーンのdestinationSceneとして遷移先のシーンを登録する

こうすることで、遷移が終わる直前、遷移元のシーンがビューから取り除かれるタイミングで、afterTransitionBlockプロパティに登録されていた処理が(あれば)実行されるようになる。

今回のBGMの場合、次のような感じ:

// GameScene.swift

// SKSceneでなく、TransitionableSceneを継承する
class GameScene: TransitionableScene {
  // 省略

  override func didMoveToView(view: SKView) {
    // 省略

    // ビューに追加されたら、設定に応じてBGMを再生するブロックを登録する
    // 遷移が行われる場合、遷移が終わってから再生されるようにする
    let soundManager = SoundManager.getInstance()
    if soundManager.config == .On {
      if self.willTransition {
        self.afterTransitionBlock = {
          soundManager.playSoundForKey("BGM", loop: true)
        }
      } else {
        soundManager.playSoundForKey("BGM", loop: true)
      }
    }

    // 省略
  }

  override func willMoveFromView(view: SKView) {
    // 省略
    
    // ビューから取り除かれるとき、
    // 音を停止させて先頭に巻き戻す
    let soundManager = SoundManager.getInstance()
    soundManager.stopAllSounds()
    soundManager.rewindAllSounds()
    
    // 省略
    
    // 最後にスーパークラス(TransitionableScene)のメソッドを呼び出す
    super.willMoveFromView(view)
  }

  // 省略

  private func exitGame() {
    let startScene = StartScene(size: self.size)
    // 遷移先のシーンもTransitionableSceneを継承させておき、
    // プロパティを適切に設定してから遷移を行う
    startScene.willTransition = true
    self.destinationScene = startScene
    let transition = SKTransition.fadeWithDuration(NSTimeInterval(1.0))
    self.view?.presentScene(startScene, transition: transition)
  }

  // 省略
}

これで画面遷移のときのBGMの再生が期待通りに動くようになった。

今日はここまで!