いものやま。

雑多な知識の寄せ集め

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

昨日は手札の表示の設計を行った。

今日はそれを元に実装を行っていく。

HandNode

手札はSKNodeを継承したHandNodeとして実装する。

//==============================
// BirdHead
//------------------------------
// HandNode.swift
//==============================

import SpriteKit

class HandNode: SKNode {

  // 続く

クラス定数

最初にクラス定数。

  // 続き

  private static let actionDuration = NSTimeInterval(0.3)
  private static let angle: CGFloat = 10.0 * CGFloat(M_PI) / 180.0
  private static let selectMargin: CGFloat = 30.0

  // 続く

HandNode.angleは、各カード間の角度。
単位がラジアンなので、 \frac{\pi}{180}を掛けている。

プロパティとイニシャライザ

続いて、プロパティとイニシャライザ。

 // 続き
  
  let playerIndex: Int
  let isFaceUp: Bool
  
  private let frameWidth: CGFloat
  private let radius: CGFloat
  
  // to calculate card y position
  private let baseY: CGFloat
  
  private let frameNode: SKShapeNode
  private var cardNodes: [CardNode]
  
  private let actionQueue: NSOperationQueue
  
  override var frame: CGRect {
    var frame = self.frameNode.frame
    frame.origin.x += self.position.x
    frame.origin.y += self.position.y
    return frame
  }
  
  init(playerIndex: Int, isFaceUp: Bool, frameWidth: CGFloat) {
    self.playerIndex = playerIndex
    self.isFaceUp = isFaceUp
    
    let centerAngle = HandNode.angle * CGFloat(GameInfo.HandMax - 1)
    let halfCenterAngle = centerAngle / 2.0
    
    self.frameWidth = frameWidth
    self.radius = ((self.frameWidth/2.0) - CardNode.size.height) / sin(halfCenterAngle)
    
    let frameTop = self.radius + CardNode.size.height/2.0 + HandNode.selectMargin
    let frameBottom = self.radius * cos(halfCenterAngle) - CardNode.size.height/2.0
    let frameHeight = frameTop - frameBottom
    
    self.baseY = (frameTop + frameBottom) / 2.0
    
    self.frameNode = SKShapeNode(rectOfSize: CGSize(width: self.frameWidth, height: frameHeight))
    self.frameNode.lineWidth = 0.0
    self.cardNodes = [CardNode]()
    
    self.actionQueue = NSOperationQueue()
    self.actionQueue.maxConcurrentOperationCount = 1
    
    super.init()
    
    self.addChild(self.frameNode)
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  // 続く

プロパティのradiusは円の半径で、baseYというのは、円の中心から原点までの距離。
これらは、昨日書いたとおり、イニシャライザの中で指定されたフレーム幅から計算して求めている。

カードの追加

次は手札にカードを追加する処理。

  // 続き
  
  func addCardNode(cardNode: CardNode, completion: (() -> Void)! = nil) {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        var angle = HandNode.angle * CGFloat(self.cardNodes.count) / 2.0
        for cardNode in self.cardNodes {
          let position = CGPoint(x: -self.radius * sin(angle),
                                 y: self.radius * cos(angle) - self.baseY)
          
          let rotateAction = SKAction.rotateToAngle(angle, duration: HandNode.actionDuration)
          let moveAction = SKAction.moveTo(position, duration: HandNode.actionDuration)
          let arrangeAction = SKAction.group([rotateAction, moveAction])
          cardNode.runAction(arrangeAction)
          
          angle -= HandNode.angle
        }
        
        self.cardNodes.append(cardNode)
        
        cardNode.removeFromParent()
        if self.isFaceUp {
          cardNode.faceUp()
        } else {
          cardNode.faceDown()
        }
        cardNode.zRotation = angle
        cardNode.position.x = 0.0
        cardNode.position.y = -self.baseY
        self.addChild(cardNode)
        
        let position = CGPoint(x: -self.radius * sin(angle),
                               y: self.radius * cos(angle) - self.baseY)
        let moveAction = SKAction.moveTo(position, duration: HandNode.actionDuration)
        cardNode.runAction(moveAction) {
          self.actionQueue.suspended = false
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }
  
  // 続く

処理をオペレーションキューに追加し、その中でメインキューに処理を投げるのは、SpriteKitでアクションを確実に1つずつ実行する方法について。 - いものやま。で書いたとおり。

肝心の処理の方は、最初に各カードの新しい位置を計算しつつ、

  • 追加済みのカードは、新しい位置に回転&移動するアクションを実行する。
  • 新たなカードは、中心の位置に適切な向き、角度で追加し、新しい位置に移動するアクションを実行する。

そして、コンプリーションが指定されていた場合、アクションが終わった後に実行されるようになっている。

ソート

次は手札のソート。

  // 続き

  func sort(completion: (() -> Void)! = nil) {
    if self.isFaceUp {
      var angles = [CGFloat]()
      var positions = [CGPoint]()
      
      self.actionQueue.addOperationWithBlock {
        self.actionQueue.suspended = true

        NSOperationQueue.mainQueue().addOperationWithBlock {
          let basePosition = CGPoint(x: 0.0, y: self.radius - self.baseY)
          for cardNode in self.cardNodes {
            angles.append(cardNode.zRotation)
            positions.append(cardNode.position)
            let rotateAction = SKAction.rotateToAngle(0.0, duration: HandNode.actionDuration/2.0)
            let moveAction = SKAction.moveTo(basePosition, duration: HandNode.actionDuration/2.0)
            let collectAction = SKAction.group([rotateAction, moveAction])
            cardNode.runAction(collectAction) {
              self.actionQueue.suspended = false
            }
          }
        }
      }
      
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          self.cardNodes.sortInPlace { $0.card < $1.card }
          for card in self.cardNodes {
            card.removeFromParent()
            self.addChild(card)
          }
        }
      }
      
      self.actionQueue.addOperationWithBlock {
        self.actionQueue.suspended = true
        
        NSOperationQueue.mainQueue().addOperationWithBlock {
          for i in 0..<self.cardNodes.count {
            let rotateAction = SKAction.rotateToAngle(angles[i], duration: HandNode.actionDuration/2.0)
            let moveAction = SKAction.moveTo(positions[i], duration: HandNode.actionDuration/2.0)
            let arrangeAction = SKAction.group([rotateAction, moveAction])
            self.cardNodes[i].runAction(arrangeAction) {
              self.actionQueue.suspended = false
            }
          }
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }

  // 続く

まず、カードが裏向きの場合にはソートする意味もない(というか、ソートされてしまうと、カードの出てくる位置がプレイのヒントになってしまう可能性がある)ので、(指定されていれば)コンプリーションしか呼ばれないようにしている。

カードのソート処理は、以下のように実現している:

  1. 各カードの位置を配列に保持し(これはカードの新しい位置を新たに計算しなくて済むようにするため)、まずは中央にすべて集める。
  2. カードをソートし、重ねる順番を変える。
  3. ソート後の新しい位置にカードを移動させる。

こうすることで、実際には直線で移動しているんだけど、あたかも円弧を描くように移動しているかのように見せることが出来る。
(なお、もちろん実際に円弧を描くように移動させることも可能。その場合、円弧のCGPathを作成し、SKAction.followPath(_: CGPath, duration: NSTimeInterval)でアクションを生成する)

手札の有効化/無効化

次は手札の有効化/無効化。
手番のときには手札を有効化し(明るく表示される)、そうでないときには無効化する(暗く表示される)。

  // 続き
  
  func enableAll(completion: (() -> Void)! = nil) {
    self.setAllDisabled(false, completion: completion)
  }
  
  func disableAll(completion: (() -> Void)! = nil) {
    self.setAllDisabled(true, completion: completion)
  }

  private func setAllDisabled(isDisabled: Bool, completion: (() -> Void)! = nil) {
    self.actionQueue.addOperationWithBlock {
      NSOperationQueue.mainQueue().addOperationWithBlock {
        for cardNode in self.cardNodes {
          cardNode.isDisabled = isDisabled
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }
  
  // 続く

カードのプレイ

最後にカードのプレイ。

  // 続き
  
  func playCards(cards: [Int], to playAreaNode: PlayAreaNode,
                 completion: (() -> Void)! = nil)
  {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        var actionCards = cards
        var actionCardNodes = [CardNode]()
      
        var cardNodeIndex = 0
        while cardNodeIndex < self.cardNodes.count {
          let cardNode = self.cardNodes[cardNodeIndex]
        
          var found = false
          var cardIndex = 0
          while cardIndex < actionCards.count {
            let card = actionCards[cardIndex]
            if cardNode.card == card {
              found = true
              actionCardNodes.append(cardNode)
              self.cardNodes.removeAtIndex(cardNodeIndex)
              actionCards.removeAtIndex(cardIndex)
              break
            } else {
              cardIndex += 1
            }
          }
        
          if found {
            if actionCards.count == 0 {
              break
            }
          } else {
            cardNodeIndex += 1
          }
        }
        
        var lockCount = 0
      
        lockCount += 1
        playAreaNode.addCardNodes(actionCardNodes) {
          lockCount -= 1
          if lockCount == 0 {
            self.actionQueue.suspended = false
          }
        }
      
        var angle = HandNode.angle * CGFloat(self.cardNodes.count - 1) / 2.0
        for cardNode in self.cardNodes {
          let position = CGPoint(x: -self.radius * sin(angle),
                                 y: self.radius * cos(angle) - self.baseY)
          
          let rotateAction = SKAction.rotateToAngle(angle, duration: HandNode.actionDuration)
          let moveAction = SKAction.moveTo(position, duration: HandNode.actionDuration)
          let arrangeAction = SKAction.group([rotateAction, moveAction])
          
          lockCount += 1
          cardNode.runAction(arrangeAction) {
            lockCount -= 1
            if lockCount == 0 {
              self.actionQueue.suspended = false
            }
          }
          
          angle -= HandNode.angle
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }
}

最初にやっているのは、プレイするカードの検索。
もうちょいいい検索の仕方がありそうなんだけど・・・(^^;

プレイするカードを見つけたら、プレイエリアにプレイするカードを追加する。

そして、残った手札について、位置と角度を調整。

これらは並行して行われるので、どちらが先に終わるのかは明確ではない。
なので、ロックカウントを使ってアクションの終了判定を行っている。

具体的には、アクションを行うたびにロックカウントを増やし、そしてアクションが終わったらロックカウントを減らす。
そのとき、もしロックカウントが0ならすべてのアクションが終わったことになるので、キューを再開させる。

注意すべき点としては、ロックカウントを増やす処理と減らす処理が並行して行われると、すべてのアクションが終了する前にロックカウントが0になってしまうことがあるということ。
例えば、カウントを増やす→カウントを減らす(0になる)→カウントを増やす→カウントを減らす(また0になる)、ということが起きると、期待した動きにならない可能性がある。
ただし、今回の場合、カウントを増やす/減らす処理はすべてメインキューで行われて、カウントを増やす処理は最初のブロック内でアトミックに行われるので、このような問題は発生しないようになっている。

コンプリーションについては、これまで通り。

今日はここまで!