いものやま。

雑多な知識の寄せ集め

「BirdHead」のUIを作ってみた。(その10)

久々に。

前回はマイナス点一覧まで実装した。

今日はコントローラ(シーン)を実装して、SarsaAI同士が対戦する様子を見れるようにする。

GameScene

SpriteKitでは、シーン(SKScene)がコントローラの役割を果たす。
そこで、SKSceneを継承したGameSceneを実装していく。

//==============================
// BirdHead
//------------------------------
// GameScene.swift
//==============================

import SpriteKit

class GameScene: SKScene, GameInfoObserver, ButtonNodeDelegate {
  // 続く

このクラスはモデルからの通知を受け取るので、GameInfoObserverに準拠するようにしている。
また、ボタンからの通知も受けるので、ButtonNodeDelegateに準拠するようにもしている。

クラス定数

まずはクラス定数。

  // 続き

  private static let dealDirection: [DeckNode.DealDirection] = [
    .Bottom, .Left, .Top, .Right,
  ]

  // 続く

プレイヤーに対するカードを配る方向を定義しておく。

プロパティ

次にプロパティ。

  // 続き
  
  private let info: GameInfo
  private let players: [Player]
  
  // workaround for SpriteKit bug on iOS 8.x
  private let presentationLayer: SKShapeNode
  private let responseLayer: SKShapeNode
  
  private let deckNode: DeckNode
  private let handNodes: [HandNode]
  private let playAreaNodes: [PlayAreaNode]
  private let infoButtonNode: ButtonNode
  private let minusPointInfoNode: MinusPointInfoNode
  
  private var okButtonNodeCompletions: [() -> Void]
  
  private let sweepLocations: [CGPoint]
  
  private let actionQueue: ActionQueue
  private let interruptionQueue: ActionQueue
  private let selectQueue: NSOperationQueue

  // 続く

モデルであるゲーム情報とプレイヤー、ビューである各ノード、あと、必要なオペレーションキューなどを定義している。

interruptionQueueというのは、マイナス点一覧を表示させるためのボタン(infoボタン)が押されたときに、他のUIの処理に割り込んでUIの処理を行うためのキュー。
これを用意しておくことで、カードが配られたりプレイされている途中でも、infoボタンを押せばマイナス点一覧を表示するということが可能になる。

presentationLayerとresponseLayerについては、iOS8とiOS9でSpriteKitのタッチ判定の挙動に違いがあった話。 - いものやま。を参照。
この問題を解決するために、これらのレイヤーを用意している。

イニシャライザ

続いて、イニシャライザ。
長いけど、基本的にはノードを生成、配置する処理。

  // 続き
  
  init(size: CGSize, info: GameInfo) {
    self.info = info
    
    let sarsaParameterURL = NSBundle.mainBundle().URLForResource("SarsaComParameter", withExtension: "plist")!
    self.players = [
      SarsaCom.load(sarsaParameterURL),
      SarsaCom.load(sarsaParameterURL),
      SarsaCom.load(sarsaParameterURL),
      SarsaCom.load(sarsaParameterURL),
    ]
    
    self.presentationLayer = SKShapeNode(rectOfSize: size)
    self.responseLayer = SKShapeNode(rectOfSize: size)
    self.presentationLayer.lineWidth = 0.0
    self.responseLayer.lineWidth = 0.0
    
    let handNode0 = HandNode(isFaceUp: true, frameWidth: size.width)
    let handNode1 = HandNode(isFaceUp: false, frameWidth: size.width)
    let handNode2 = HandNode(isFaceUp: false, frameWidth: size.width)
    let handNode3 = HandNode(isFaceUp: false, frameWidth: size.width)

    let deckPosition = CGPoint(x: 0.0, y: 0.25*handNode0.frame.height)
    self.deckNode = DeckNode(deckPosition: deckPosition, frameSize: size)
    
    self.handNodes = [handNode0, handNode1, handNode2, handNode3]
    
    let playAreaHeight = (size.width - handNode0.frame.height - CardNode.size.width) / 2.0
    let playAreaWidth = size.height - handNode0.frame.height*1.5 - playAreaHeight*2.0
    let playAreaSize = CGSize(width: playAreaWidth, height: playAreaHeight)
    let playAreaNode0 = PlayAreaNode(size: playAreaSize)
    let playAreaNode1 = PlayAreaNode(size: playAreaSize)
    let playAreaNode2 = PlayAreaNode(size: playAreaSize)
    let playAreaNode3 = PlayAreaNode(size: playAreaSize)
    
    self.playAreaNodes = [playAreaNode0, playAreaNode1, playAreaNode2, playAreaNode3]
    
    let infoButtonTexture = SKTexture(imageNamed: "InfoButton")
    self.infoButtonNode = ButtonNode(texture: infoButtonTexture)
    
    self.minusPointInfoNode = MinusPointInfoNode(size: size)
    
    self.okButtonNodeCompletions = Array<() -> Void>()
    
    self.sweepLocations = [
      CGPoint(x: size.width/2.0, y: -CardNode.size.height),
      CGPoint(x: -CardNode.size.width, y: size.height/2.0 + 0.25*handNode0.frame.height),
      CGPoint(x: size.width/2.0, y: size.height + CardNode.size.height),
      CGPoint(x: size.width + CardNode.size.width, y: size.height/2.0 + 0.25*handNode0.frame.height),
    ]
    
    self.actionQueue = ActionQueue()
    self.interruptionQueue = ActionQueue()
    self.selectQueue = NSOperationQueue()
    self.selectQueue.maxConcurrentOperationCount = 1
    
    super.init(size: size)
    
    self.info.addObserver(self)
    
    let backgroundTexture = SKTexture(imageNamed: "Background")
    let background = SKSpriteNode(texture: backgroundTexture)
    background.position = CGPoint(x: size.width/2.0, y: size.height/2.0)
    self.addChild(background)
    
    self.presentationLayer.addChild(self.deckNode)
    
    self.handNodes[0].position.y = -size.height/2.0 + self.handNodes[0].frame.height/2.0
    self.handNodes[1].position.x = -size.width/2.0
    self.handNodes[1].position.y = deckPosition.y
    self.handNodes[2].position.y = size.height/2.0
    self.handNodes[3].position.x = size.width/2.0
    self.handNodes[3].position.y = deckPosition.y
    self.responseLayer.addChild(self.handNodes[0])
    for i in 1..<4 {
      self.handNodes[i].zRotation = -CGFloat(M_PI_2) * CGFloat(i)
      self.presentationLayer.addChild(self.handNodes[i])
    }
    
    self.playAreaNodes[0].position.y = (-size.height/2.0
                                          + self.handNodes[0].frame.height
                                          + self.playAreaNodes[0].frame.height/2.0)
    self.playAreaNodes[1].position.x = (-size.width/2.0
                                          + self.handNodes[1].frame.height/2.0
                                          + self.playAreaNodes[1].frame.height/2.0)
    self.playAreaNodes[1].position.y = deckPosition.y
    self.playAreaNodes[2].position.y = (size.height/2.0
                                          - self.handNodes[2].frame.height/2.0
                                          - self.playAreaNodes[2].frame.height/2.0)
    self.playAreaNodes[3].position.x = (size.width/2.0
                                          - self.handNodes[3].frame.height/2.0
                                          - self.playAreaNodes[3].frame.height/2.0)
    self.playAreaNodes[3].position.y = deckPosition.y
    for i in 0..<4 {
      self.playAreaNodes[i].zRotation = -CGFloat(M_PI_2) * CGFloat(i)
      self.presentationLayer.addChild(self.playAreaNodes[i])
    }
    
    self.infoButtonNode.position = CGPoint(x: size.width/2.0 - self.infoButtonNode.frame.width,
                                           y: size.height/2.0 - self.infoButtonNode.frame.height)
    self.responseLayer.addChild(self.infoButtonNode)
    
    self.infoButtonNode.buttonNodeDelegate = self
    self.minusPointInfoNode.okButtonNode.buttonNodeDelegate = self

    background.addChild(self.presentationLayer)
    self.presentationLayer.addChild(self.responseLayer)
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  // 続く

ところどころ、コードが完全に4人専用になってるけど・・・まぁ今回はこれで。

アクションの一時停止、停止、再開

アクションの合間にちょっとした一時停止が欲しいことが多々あるので、アクションを一定時間停止させるメソッドを用意しておく。
また、マイナス点一覧を表示するときに、通常のアクションは停止する必要があるので、それらを停止・再開させるためのメソッドを用意しておく。

  // 続き
  
  private func waitAction(sec: NSTimeInterval, completion: (() -> Void)! = nil) {
    self.actionQueue.addActionBlock { executor in
      let waitAction = SKAction.waitForDuration(sec)
      executor.executeAction(waitAction, forNode: self, completion: completion)
    }
  }
  
  private func suspend() {
    self.setPaused(true)
  }
  
  private func resume() {
    self.setPaused(false)
  }
  
  private func setPaused(paused: Bool) {
    self.deckNode.paused = paused
    for handNode in self.handNodes {
      handNode.paused = paused
    }
    for playAreaNode in self.playAreaNodes {
      playAreaNode.paused = paused
    }
  }

  // 続く

ここで、アクションの停止、再開では、SKNode#pausedというプロパティをいじってる。
このプロパティは、trueにするとアクションが停止し、falseにするとアクションが再開されるようになっている。

ビューへの追加/ビューからの削除

次は、ビューへの追加とビューからの削除。

  // 続き
  
  override func didMoveToView(view: SKView) {
    self.actionQueue.addActionBlock {executor in
      executor.startAction()
      self.deckNode.readyToDeal() {
        self.selectQueue.addOperationWithBlock {
          try! self.info.deal()
        }
        executor.endAction()
      }
    }
  }
  
  override func willMoveFromView(view: SKView) {
    self.selectQueue.addOperationWithBlock {
      self.info.removeAllObservers()
    }
  }

  // 続く

まず、ビューに追加されたあとは、カードを配る準備をして、その準備が出来たら、モデルに対してカードを配ることを要求している。
なお、モデルでカードが配られればそこで通知が来るので、シーンではその通知に対応してビューでもカードを配ることになる。

そして、ビューからの削除。
これはとりあえずの実装で、とりあえずモデルのオブザーバから自身を取り除く処理だけ行うようにしている。
(でないと、参照の循環が起こるので)

GameInfoObserverプロトコルへの準拠。

そしたら、GameInfoObserverプロトコルへの準拠。
これで、モデルから来る様々な通知に応じて、ビューをコントロールすることになる。

カードが配られたとき
  // 続き
  
  func gameInfoDealtCard(card: Int, toPlayer playerIndex: Int, deckRemain: Bool) {
    self.actionQueue.addActionBlock { executor in
      let direction = GameScene.dealDirection[playerIndex]
      
      executor.startAction()
      self.deckNode.deal(card, direction: direction, deckRemain: deckRemain) {
        dealtCardNode in
        self.handNodes[playerIndex].addCardNode(dealtCardNode)
        executor.endAction()
      }
    }
  }

  // 続く

カードが配られたときには、配られたプレイヤーの手札の方向に向かってカードを配り、手札に入れている。

カードが配り終わったとき
  // 続き
  
  func gameInfoDealtAllCard() {
    self.actionQueue.addActionBlock { executor in
      for hanNode in self.handNodes {
        executor.startAction()
        hanNode.sort {
          hanNode.disableAll()
          executor.endAction()
        }
      }
    }
    
    self.waitAction(NSTimeInterval(0.5)) {
      let turnPlayerIndex = self.info.turnPlayerIndex
      self.handNodes[turnPlayerIndex].enableAll()
      
      self.selectQueue.addOperationWithBlock {
        let playerView = try! self.info.playerViewFor(turnPlayerIndex)
        let action = try! self.players[turnPlayerIndex].select(playerView)
        try! self.info.doAction(action)
      }
    }
  }

  // 続く

カードが配り終わったら、手札のソートを行い、ちょっと待ったあと、プレイヤーに対してアクションの選択を行わせ、選ばれたアクションをモデルに対して実行している。

アクションが終わったとき
  // 続き

  func gameInfoDidAction(action: Action, byPlayer playerIndex: Int) {
    self.actionQueue.addActionBlock { executor in
      executor.startAction()
      self.handNodes[playerIndex].playCards(action.cards(),
                                            to: self.playAreaNodes[playerIndex])
      {
        self.handNodes[playerIndex].disableAll()
        executor.endAction()
      }
    }
    
    self.actionQueue.addBlock {
      switch action {
      case .Play:
        for playAreaNode in self.playAreaNodes {
          if playAreaNode != self.playAreaNodes[playerIndex] {
            playAreaNode.disableCardNodes()
          }
        }
      case .Discard:
        self.playAreaNodes[playerIndex].disableCardNodes()
      }
    }
    
    if self.info.actionsInTrick.count < self.info.playerCount {
      self.waitAction(NSTimeInterval(0.5)) {
        let turnPlayerIndex = self.info.turnPlayerIndex
        self.handNodes[turnPlayerIndex].enableAll()
        
        self.selectQueue.addOperationWithBlock {
          let playerView = try! self.info.playerViewFor(turnPlayerIndex)
          let action = try! self.players[turnPlayerIndex].select(playerView)
          try! self.info.doAction(action)
        }
      }
    }
  }

  // 続く

モデルでアクションが終わったという通知があったら、それをビューにも反映させるため、手札に対してカードをプレイするよう指示している。
そのあと、もしそのアクションがプレイなら、そのプレイされたカードが現状で一番強いカードなので、他のプレイされたカードを暗く表示するようにし、そうでなければ、今プレイされたカードを暗く表示するようにしている。

そして、まだアクションを実行していないプレイヤーがいるならそのプレイヤーにアクションの選択をさせ、選ばれたアクションをモデルに対して実行している。

1トリック終わったとき
  // 続き
  
  func gameInfoResolvedTrick(trickCount: Int, winner playerIndex: Int) {
    self.waitAction(NSTimeInterval(1.0))
    
    self.actionQueue.addActionBlock { executor in
      for playAreaNode in self.playAreaNodes {
        let location = self.convertPoint(self.sweepLocations[playerIndex], toNode: playAreaNode)
        
        executor.startAction()
        playAreaNode.sweepTo(location) {
          executor.endAction()
        }
      }
    }
    
    let playerView = try! self.info.playerViewFor(0)
    if playerView.hands.count > 1 {
      self.waitAction(NSTimeInterval(0.5)) {
        let turnPlayerIndex = self.info.turnPlayerIndex
        self.handNodes[turnPlayerIndex].enableAll()
        
        self.selectQueue.addOperationWithBlock {
          let playerView = try! self.info.playerViewFor(turnPlayerIndex)
          let action = try! self.players[turnPlayerIndex].select(playerView)
          try! self.info.doAction(action)
        }
      }
    }
  }

  // 続く

まずは、プレイされたカードを確認できるように、ちょっと一時停止。
そのあと、プレイされたカードをそのトリックで勝ったプレイヤーの方向に向かって掃き出している。

そのあとは、手札の枚数を確認。
もし、手札が1枚より多ければ、まだディールの途中なので、手番のプレイヤーに対してアクションの選択をさせ、選ばれたアクションをモデルに対して実行している。

1ディール終わったとき
  // 続き
  
  func gameInfoResolvedDeal(loserIndices: [Int], lastCards: [Int]) {
    self.waitAction(NSTimeInterval(0.5))
    
    // play last card
    self.actionQueue.addActionBlock { executor in
      for i in 0..<self.info.playerCount {
        executor.startAction()
        self.handNodes[i].enableAll()
        self.handNodes[i].playCards([lastCards[i]], to: self.playAreaNodes[i]) {
          executor.endAction()
        }
      }
    }
    
    self.waitAction(NSTimeInterval(2.0))
    
    // fade out last played card except loser
    self.actionQueue.addActionBlock { executor in
      for i in 0..<self.info.playerCount {
        if loserIndices.indexOf(i) == nil {
          executor.startAction()
          self.playAreaNodes[i].fadeOut() {
            executor.endAction()
          }
        }
      }
    }
    
    self.waitAction(NSTimeInterval(1.0))

    // remove remaining last played card
    self.actionQueue.addActionBlock { executor in
      for i in loserIndices {
        executor.startAction()
        self.playAreaNodes[i].removeAll() {
          executor.endAction()
        }
      }
    }
    
    // show minus point info
    self.actionQueue.addActionBlock { executor in
      if self.minusPointInfoNode.parent == nil {
        self.responseLayer.addChild(self.minusPointInfoNode)
      }
      executor.startAction()
      self.minusPointInfoNode.show {
        self.okButtonNodeCompletions.append {
          if !self.info.isEnd {
            self.actionQueue.addActionBlock { executor in
              executor.startAction()
              self.deckNode.readyToDeal() {
                self.selectQueue.addOperationWithBlock {
                  try! self.info.deal()
                }
                executor.endAction()
              }
            }
          }
        }
        executor.endAction()
      }
    }
    
    // add minus card into minus point info
    self.actionQueue.addActionBlock { executor in
      let minusCard = lastCards[loserIndices.first!]
      
      executor.startAction()
      self.minusPointInfoNode.addCard(minusCard, forPlayers: loserIndices) {
        executor.endAction()
      }
    }
  }

  // 続く

1ディール終わったときの処理はちょっと長い・・・
順番に書いていくと、

  1. 最後の手札のプレイ
  2. 負けなかったプレイヤーのプレイしたカードをフェードアウト
  3. 負けたプレイヤーのカードを削除
  4. マイナス点一覧を表示
  5. マイナス点一覧に負けたプレイヤーのカードを追加

となっている。

このとき、マイナス点一覧のOKボタンにコンプリーションを追加していて、OKボタンが押されたとき、もしゲームがまだ終わっていなければ、カードを配る準備を行って、そのあと、モデルに対してカードを配るようにしている。

ゲームが終わったとき
  // 続き
  
  func gameInfoEnded() {
    // not yet
  }

  // 続く

ここはまだ未実装。
実際には、結果を表示するような実装が必要になってくる。

ButtonNodeDelegateプロトコルへの準拠

最後に、ボタンが押されたときの処理の実装。

  // 続き
  
  func buttonNodeIsSelected(buttonNode: ButtonNode) {
    switch buttonNode {
    case self.infoButtonNode:
      self.suspend()
      self.interruptionQueue.addActionBlock { executor in
        if self.minusPointInfoNode.parent == nil {
          self.responseLayer.addChild(self.minusPointInfoNode)
        }
        executor.startAction()
        self.minusPointInfoNode.show {
          self.okButtonNodeCompletions.append {
            self.resume()
          }
          executor.endAction()
        }
      }
    case self.minusPointInfoNode.okButtonNode:
      self.interruptionQueue.addActionBlock { executor in
        executor.startAction()
        self.minusPointInfoNode.hide {
          self.minusPointInfoNode.removeFromParent()
          while !self.okButtonNodeCompletions.isEmpty {
            let completion = self.okButtonNodeCompletions.removeFirst()
            completion()
          }
          executor.endAction()
        }
      }
    default:
      break
    }
  }
}

通知が来る可能性のあるボタンとしては、マイナス点一覧を表示させるためのinfoボタンと、マイナス点一覧にあるOKボタンが今のところはある。
(実際にはマイナス点一覧にあるExitボタンにも対応する必要あり)

まず、infoボタンが押された場合には、現在のアクションを停止させ、interruptionQueueを使って、マイナス点一覧を表示させる処理を行っている。
このとき、OKボタンを押されたら停止させたアクションを再開させないといけないので、OKボタンのコンプリーションに、アクションを再開させる処理を追加している。

そして、OKボタンが押された場合には、同じくinterruptionQueueを使ってマイナス点一覧を隠し、OKボタンに登録されたコンプリーションをすべて実行するようにしている。
なんでこうしているのかというと、マイナス点一覧が表示されるタイミングが2つあって、そのそれぞれで必要となってくる処理が異なるから。
マイナス点一覧を表示させるときに、マイナス点一覧が隠されたときに必要となってくる処理を追加するようにしておけば、実際にマイナス点一覧が隠されるときには、その登録されている処理を何も考えずに実行するだけで済む。

動作確認

さて、これを動作確認させると、AIの動きはときどき怪しいけど、UI自体はちゃんと動作してることが分かると思う。

今日はここまで!