いものやま。

雑多な知識の寄せ集め

SpriteKitのサンプルコードを読んでみた。(その3)

昨日は、ファイル構成、ビューの構成、そして、ビューコントローラの内容を見ていった。

今日は主にAdventureSceneの生成について。

AdventureSceneのロード

さっそく、ViewControllerから呼ばれていた、AdventureSceneをロードするコードを見てみる。

import SpriteKit
import GameController

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略
  
  struct Constants {
    // 省略
    static let backgroundQueue = dispatch_queue_create("com.example.apple-samplecode.Adventure.backgroundQueue", DISPATCH_QUEUE_SERIAL)
  }

  // 省略

  class func loadSceneAssetsWithCompletionHandler(completionHandler: AdventureScene -> Void) {
    dispatch_async(Constants.backgroundQueue) {
      Tree.loadSharedAssets()
      Warrior.loadSharedAssets()
      Archer.loadSharedAssets()
      Cave.loadSharedAssets()
      Goblin.loadSharedAssets()
      Boss.loadSharedAssets()
      
      let loadedScene = AdventureScene(size: CGSize(width: 1024, height: 768))
      loadedScene.loadBackgroundTiles()
      
      dispatch_async(dispatch_get_main_queue()) { completionHandler(loadedScene) }
    }
  }

  // 省略
}

見てみると、ディスパッチキューを使って、処理をバックグラウンドで実行している。
やっている処理は、まず各スプライトで使うリソースをロードし、雛形を作成している。
そして、AdventureSceneの生成。
それに、タイルのロード。 最後に、メインディスパッチキューに、引数で渡されたクロージャを投げている。

リソースのロードと雛形の作成

ここで呼ばれているloadSharedAssets()というメソッドは、SharedAssetProviderというプロトコルのタイプメソッドとなっていて、各スプライトはこのプロトコルに準拠するようになっている。

/* SharedAssetManagement.swift */

protocol SharedAssetProvider {
  static func loadSharedAssets()
}
/* Tree.swift */

final class Tree: ParallaxSprite, SharedAssetProvider {
  // MARK: Types
    
  struct Shared {
    // These templates will be populated when `loadSharedAssets()` is called on the class.
    static var smallTemplate: Tree!
    static var largeTemplate: Tree!
  }

  // 省略

  // MARK: Asset Pre-loading
  
  class func loadSharedAssets() {
    // Load Trees
    let atlas = SKTextureAtlas(named: "Environment")
    var sprites = [
      SKSpriteNode(texture: atlas.textureNamed("small_tree_base.png")),
      SKSpriteNode(texture: atlas.textureNamed("small_tree_middle.png")),
      SKSpriteNode(texture: atlas.textureNamed("small_tree_top.png"))
    ]
    Shared.smallTemplate = Tree(sprites: sprites, usingOffset: 25.0)
    Shared.smallTemplate.name = "smallTree"
    
    sprites = [
      SKSpriteNode(texture: atlas.textureNamed("big_tree_base.png")),
      SKSpriteNode(texture: atlas.textureNamed("big_tree_middle.png")),
      SKSpriteNode(texture: atlas.textureNamed("big_tree_top.png"))
    ]
    Shared.largeTemplate = Tree(sprites: sprites, usingOffset: 150.0)
    Shared.largeTemplate.name = "bigTree"
    Shared.largeTemplate.fadeAlpha = true
  }
}

上では一例としてTreeのコードを載せたけど、他のスプライトについても同様。

処理としては、リソースとなる画像をSKTextureAtlasとしてロードして、そこから必要な画像データを使ってSKSpriteNodeを作り、それらを使ってTreeのインスタンスの雛形を用意している。
その雛形をSharedという内部構造体のsmallTemplete、largeTempleteというタイププロパティに保持させておくことで、実際に使う時には、これらをコピーするだけで使えるようにしている。

例えば、次のような感じで、Treeのインスタンスを簡単に得ることが出来る。

let tree = Tree.Shared.smallTemplate.copy() as! Tree

AdventureSceneの初期化

AdventureSceneのイニシャライザは、以下のような感じ。

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  var defaultPlayer = Player()
  var players: [Player!] = [nil, nil, nil, nil]

  // 省略

  override init(size: CGSize) {
    leafEmitterATemplate = SKEmitterNode(fileNamed: "Leaves_01")
    leafEmitterBTemplate = SKEmitterNode(fileNamed: "Leaves_02")
    projectileSparkEmitterTemplate = SKEmitterNode(fileNamed: "ProjectileSplat")
    spawnEmitterTemplate = SKEmitterNode(fileNamed: "Spawn")
    
    super.init(size: size)

    players[0] = defaultPlayer
  }

  // 省略
}

ぶっちゃけ、大したことはしてないw
パーティクルをロードして、それをテンプレートとして保持しているだけ。
なので、この時点ではまだゲームを構成するノードの生成や配置などはまだ行われていない。

一つ補足をしておくと、このゲームは複数人(最大4人)でプレイ出来るようになってるっぽい。
(具体的にどうすればいいのかはよく分からないのだけど・・・)
そこで、プレイヤーの情報をplayersという配列で持っていて、その最初の要素をデフォルトのプレイヤーという扱いにしている。

AdventureSceneの構築

では、ゲームを構築するノードの生成や配置がどのタイミングで行われているのかというと、AdventureSceneがSKViewにセットされた直後。

SKSceneがSKView#presentScene(_: SKScene?)でSKViewにセットされると、直後にSKScene#didMoveToView(_: SKView)が呼ばれるようになっている。
AdventureScene#didMoveToView(_: SKView)を見てみると、次のようになってる。

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  override func didMoveToView(view: SKView) {
    // Complete the loading on a background queue to not take up the main queue's resources.
    dispatch_async(Constants.backgroundQueue) {
      self.loadWorld()
      
      self.centerWorldOnPosition(self.defaultSpawnPoint)

      dispatch_async(dispatch_get_main_queue(), self.finishedMovingToView)
    }
  }

  // 省略
}

ここでは、バックグラウンドでAdventureSceneの構築を行い、表示される位置の調整を行って、そのあと、ViewControllerでAdventureSceneがSKViewにセットされた後で呼ばれるように設定したクロージャを、メインディスパッチキューに投げている。

AdventureScene#loadWorld()

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  var world = SKNode()

  // 省略

  func loadWorld() {
    physicsWorld.gravity = CGVector(dx: 0, dy: 0)
    physicsWorld.contactDelegate = self
    
    let scene = SKScene(fileNamed: "AdventureWorld")
    let templateWorld = scene.children.first!.copy() as! SKNode

    world.name = "world"
    addChild(world)

    populateLayersFromWorld(templateWorld)
    populateBackgroundTiles()
    populateWallsFromWorld(templateWorld)
    populateCharactersFromWorld(templateWorld)
    populateTreesFromWorld(templateWorld)
  }

  func populateLayersFromWorld(fromWorld: SKNode) {
    fromWorld.enumerateChildNodesWithName("layer*") { node, stop in
      let layer = SKNode()
      layer.name = node.name
      self.world.addChild(layer)
      self.layers.append(layer)
    }
  }

  func populateBackgroundTiles() {
    for tileNode in backgroundTiles {
      addNode(tileNode, atWorldLayer: .Ground)
    }
  }
  
  // 省略

  func addNode(node: SKNode, atWorldLayer layer: WorldLayer) {
    let layerNode = layers[layer.rawValue]

    layerNode.addChild(node)
  }
  
  // 省略
}

ここでまずやってるのは、"AdventureWorld.sks"からのSKSceneの生成。

このAdventureWorld.sksは、次のような階層構造になっている。

  • SKScene
    • SKNode: world
      • SKNode: layerGound
        • SKSpriteNode: tile(※マップの画像)
        • SKNode: wall
        • ...
      • SKNode: layerBelowCharacter
      • SKNode: layerCharacter
        • SKNode: defaultSpawnPoint(※スタート地点)
        • SKNode: boss(※ボスの位置)
        • SKSpriteNode: cave
        • ...
      • SKNode: layerAboveCharacter
      • SKNode: layerTop
        • SKSpriteNode: smallTree
        • ...
        • SKSpriteNode: bigTree
        • ...

このように、マップの情報が詰め込められているので、これを元にAdventureWorldの構築を行っていく。

例えば、AdventureScene#populateLayersFromWorld(_: SKNode)では、引数で渡されたtempleteWorld(AdventureWrold.sksから生成したSKSceneの一番最初の子ノード、すなわち、上の"world"という名前のついたSKNode)から、"layer*"という名前の各SKNodeについて、新たにSKNodeを生成して、その新しいSKNodeをself.worldに子ノードとして追加している。

他の、AdventureScene#populateXXX()も同様にノードの生成、追加を行って、シーンの構築を行っている。
(ノードは基本的にレイヤーに追加していってる)

こうやって作られた階層構造を図で表すと、次のような感じ。
(実際のレイヤー名などは簡単にしてる)

f:id:yamaimo0625:20150708001953p:plain

一番奥にAdventureSceneがあり、その手前に(AdventureSceneよりサイズの大きい)worldが、さらにその手前にlayerA、layerB、...がある。
layerA、layerBにはノードが載っていて、それぞれ内容を描画する。
そして、それらを重ね合わせた内容が最終的なAdventureSceneの内容になり、そして(必要なら縮小されて)SKViewに表示されることになる。

重要な点としては、実際に画面に表示されるのは、AdventureSceneの内側に入っている部分だけということ。
なので、これが一種のカメラとして働くことになる。
これを使って、AdventureSceneに対するself.worldの位置を変更することで、表示される内容を変えていくことが出来る。

AdventureScene#centerWorldOnPosition(_: CGPoint)

そこで、最初に表示する位置を設定しているのが、このメソッド。

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  func centerWorldOnPosition(position: CGPoint) {
    world.position = CGPoint(x: -position.x + CGRectGetMidX(frame),
                             y: -position.y + CGRectGetMidY(frame))
    worldMovedForUpdate = true
  }

  // 省略
}

これは、

(指定された点のAdventureSceneでの座標) = (AdventureSceneの中心座標)

になって欲しくて、ここで

(指定された点のAdventureSceneでの座標)
  = (worldのAdventureSceneでの座標) + (指定された点のworldでの座標)

という関係があることから、

(worldのAdventureSceneでの座標) + (指定された点のworldでの座標)
  = (AdventureSceneの中心座標)

という式が立ち、これを移項することで、

(worldのAdventureSceneでの座標)
= -(指定された点のworldでの座標) + (AdventureSceneの中心座標)

という式が得られることから。
(もっとシンプルに考える方法もありそう)

ここまでいくとAdventureSceneの生成は完了して、ゲームを始める準備が出来たことになる。

AdventureScene生成の流れのまとめ

ディスパッチキューを使って流れがあっちこっちに飛ぶのでちょっと分かりにくいけど、ここまで読んだ内容をまとめると、次のような流れになっている。

システム メインスレッド バックグランド
Main.storyboardのロード
coverViewに仮の背景画像をセット
viewを表示する直前
loadingProgressIndicatorのアニメーションを開始
viewの表示
各スプライトのリソースのロード、雛形の作成
AdventureSceneの初期化(リソースのロード)
タイル画像のロード
AdventureSceneのサイズの設定
AdventureSceneをSKViewにセット
AdventureSceneの各ノードの構築
表示する位置の設定
ボタンのアルファを1に
coverViewのアルファを0に
loadingProgressIndicatorのアニメーションを停止
coverViewの除去

このあと、アーチャーかウォーリアーのボタンが押されると、ゲームが始まることになる。

なお、一つポイントとなるのは、各スプライトのリリースのロードなどはバックグラウンドのディスパッチキューに投げられているということ。
これにより、実際の処理が終わる前に制御が呼び出し元に戻って、ViewController#viewWillAppear()はすぐに終わり、viewがすぐに表示されるようになっている。

今日はここまで!