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

いものやま。

雑多な知識の寄せ集め

SpriteKitでアクションを確実に1つずつ実行する方法について。

技術 ゲーム開発 Swift

BirdHeadのUI作りは難航中・・・
簡単なようでいて、けっこう難しい(^^;

今日はUIを作ってる中で確立したテクニックを一つ紹介したい。

なお、NSOperationQueueを使うので、それについては以下を参照。

画像を追加し、整列させる

今回題材として考えるのは、次のようなもの:

  • ノードに画像(SKSpriteNode)を追加し、アニメーションを伴って整列させたい
    • 使いやすくするために、ノードのメソッドとして用意する
    • 複数のスレッドから呼ばれても大丈夫なようにする
    • 整列のアニメーションが終わってから次の処理を実行する

(どう見てもカードを手札に追加する処理です。本当にありがとう(ry

何も考えてない実装

まずは何も考えず、画像を追加し、アニメーションで整列させるだけのメソッドを作ってみる。

import SpriteKit

class GalleryNode: SKNode {
  private var spriteNodes: [SKSpriteNode]
  private var totalWidth: CGFloat
  
  override init() {
    self.spriteNodes = [SKSpriteNode]()
    self.totalWidth = 0.0
    super.init()
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  func addSpriteNodeAndArrange(spriteNode: SKSpriteNode) {
    self.addChild(spriteNode)
    
    let width = spriteNode.frame.width
    for sprite in self.spriteNodes {
      let action = SKAction.moveByX(-width/CGFloat(2.0), y: 0.0, duration: NSTimeInterval(0.5))
      sprite.runAction(action)
    }
    let action = SKAction.moveToX(self.totalWidth/CGFloat(2.0), duration: NSTimeInterval(0.5))
    spriteNode.runAction(action)
    
    self.spriteNodes.append(spriteNode)
    self.totalWidth += width
  }
}

このクラスを実際に使ってみると、正しくは動いてくれない。

  • 複数のスレッドから同時に呼ばれると、内部の整合性が壊れておかしなことになる
  • 1つのスレッドから呼ばれた場合も、連続して呼び出すと正しく動かない

NSOperationQueueを使った実装

そこで、最低限のこととして、複数のスレッドから呼び出された場合にも内部の整合性が壊れないようにしてみる。

具体的には、以下のようにNSOperationQueueを使って、複数のスレッドから同時に呼ばれたとしても、メインキューで処理が1つずつ実行されるようにしてみる。

import SpriteKit

class GalleryNode: SKNode {
  private var spriteNodes: [SKSpriteNode]
  private var totalWidth: CGFloat
  
  override init() {
    self.spriteNodes = [SKSpriteNode]()
    self.totalWidth = 0.0
    super.init()
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  func addSpriteNodeAndArrange(spriteNode: SKSpriteNode) {
    NSOperationQueue.mainQueue().addOperationWithBlock {
      self.addChild(spriteNode)
      
      let width = spriteNode.frame.width
      for sprite in self.spriteNodes {
        let action = SKAction.moveByX(-width/CGFloat(2.0), y: 0.0, duration: NSTimeInterval(0.5))
        sprite.runAction(action)
      }
      let action = SKAction.moveToX(self.totalWidth/CGFloat(2.0), duration: NSTimeInterval(0.5))
      spriteNode.runAction(action)
      
      self.spriteNodes.append(spriteNode)
      self.totalWidth += width
    }
  }
}

これで複数のスレッドから同時に呼ばれたとしても、おかしな挙動はしなくなる・・・のだけど、そもそも1つのスレッドから呼ばれた場合も、連続して呼び出すと正しく動かないので、やっぱりまだ期待された動きにはなってくれない。

これはなぜかというと、SKNode#runAction(_: SKAction)は非同期呼び出しで、アクションの実行が終わる前に制御が呼び出し元に戻ってくるから。(このような呼び出しを「非同期呼び出し」という)

どういうことかというと、各ノードに対するSKNode#runAction(_: SKAction)はオペレーションキューのおかげで確かに順番に呼び出されているのだけど、その呼び出しは非同期でアクションが完了する前にブロックが終わって次のブロックが実行されてしまうので、以前に指定されたアクションがまだ終わってない状態で次のSKNode#runAction(_: SKAction)が呼ばれてしまい、期待した動きにならない、と。

これを解決するには、1つのアクションが完全に終了してから次のアクションを実行するようにしないといけない。
すなわち、アクションを確実に1つずつ実行しないといけないというわけ。

考えられる解決策

この問題を解決するには、いくつかの方法が考えられる。

まずは、コンプリーションを使う方法。
SKNode#runAction(_: SKAction, completion: () -> Void)を使うと、アクションが完了したときに指定されたコンプリーションを実行してくれる。
なので、次のような実行を考えることが出来る:

class GalleryNode: SKNode {
  // 省略
  func addSpriteNodeAndArrange(spriteNode: SKSpriteNode, completion: () -> Void) {
    // 省略
    spriteNode.runAction(aciton) {
      completion()
    }
    // 省略
}

こうしてあげると、連続した呼び出しは次のように書いてあげることで、確かに期待した動きにはなる。

galleryNode.addSpriteNodeAndArrange(spriteNode1) {
  galleryNode.addSpriteNodeAndArrange(spriteNode2) {
    galleryNode.addSpriteNodeAndArrange(spriteNode3) {}
  }
}

しかしまぁ・・・酷いコードよね(^^;
さらにいうと、複数のスレッドから同時に呼び出されるような場合には、かなり工夫をしてあげないとうまく動かすことが出来ない。
セマフォを使ったリソースの生産/消費みたいな実装が必要になってくる)
なので、この方法では不十分。

次に、NSOperationのサブクラスを自分で作る方法。
問題はアクションが完了する前にNSOperationが自身を終了したと判断してしまう(そして次のオペレーションの実行が始まってしまう)ことなので、NSOperationのサブクラスを自作して、アクションが完了するまでNSOperationが終わっていないと判断させる、というわけ。

けど、この方法はオススメしない。
というのも、いくつか問題があるから。

  • そもそも、NSOperationのサブクラスを問題なく作るのはそれなりに難しい。
  • 柔軟性なクラスを作るのが難しいーー例えば、これまでやっていた「画像を追加して整列する」というのだと、アクション以外にも処理を実行しているし、アクションも複数行っている。このような場合、どのような実装、インタフェースにすればいいのか・・・?
  • メインキューでそのようなオペレーションが実行されてしまうと、アクションが終わるまで次の処理が出来なくなり、タッチにも反応しなくなる。

解決策

じゃあ、どうすればいいのかというと、NSOperationQueue#suspendedというプロパティを使ってやるといい。
NSOperationQueue#suspendedというプロパティをtrueにすると、そのオペレーションキューは止められ、新たなオペレーションは開始されるなくなる。
(現在すでに実行されているオペレーションはそのまま問題なく実行される)

もちろん、これでメインキューを止めてしまったりするととんでもないことになるので、自分でキューを用意しておいて、そのキューを止めるようにする。

この方法を使ったコードは、次のようになる。

import SpriteKit

class GalleryNode: SKNode {
  private var spriteNodes: [SKSpriteNode]
  private var totalWidth: CGFloat
  
  private var actionQueue: NSOperationQueue
  
  override init() {
    self.spriteNodes = [SKSpriteNode]()
    self.totalWidth = 0.0
    
    self.actionQueue = NSOperationQueue()
    self.actionQueue.maxConcurrentOperationCount = 1
    
    super.init()
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  func addSpriteNodeAndArrange(spriteNode: SKSpriteNode) {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        self.addChild(spriteNode)
        
        let width = spriteNode.frame.width
        for sprite in self.spriteNodes {
          let action = SKAction.moveByX(-width/CGFloat(2.0), y: 0.0, duration: NSTimeInterval(0.5))
          sprite.runAction(action)
        }
        let action = SKAction.moveToX(self.totalWidth/CGFloat(2.0), duration: NSTimeInterval(0.5))
        spriteNode.runAction(action) {
          self.actionQueue.suspended = false
        }
        
        self.spriteNodes.append(spriteNode)
        self.totalWidth += width
      }
    }
  }
}

自前のオペレーションキューにオペレーションを追加するのだけど、その一番最初でそのキューを止めておくのがポイント。
これでそのオペレーションが終わったとしても、キューが再開されるまでは次のオペレーションが実行されなくなる。

そして、UIをいじる処理はメインキューへ投げておく。
(最初は処理がシリアライズされるからこのキューでいじっても大丈夫かなと思ったのだけど、SKNode#addChild(_: SKNode)やSKNode#removeFromParent()を呼び出していたりすると、他のノードとの関わり合いがあるので整合性が崩れてクラッシュするケースがあった)

最後に忘れてはいけないのが、キューの再開。
アクションを実行させるときに、コンプリーションでキューを再開させるようにする。
これでアクションが確実に終わってから次のオペレーションを開始させることが出来るようになる。

なお、このキューは、オペレーションを1つずつ処理しなければならないオブジェクトごとに用意しておくといい。
例えば、手札のオブジェクトとかを考えると、各手札にカードを追加する処理は、1つの手札を見た場合には1つずつ処理しないといけないけど、それぞれの手札は独立して並列に処理されても問題ない。
なので、各手札ごとにキューを1つずつ持つようにすればいいことが分かる。

今日はここまで!