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

いものやま。

雑多な知識の寄せ集め

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

ゲーム開発 Swift BirdHead

昨日はデッキの表示を実装した。

今日はプレイエリアの表示を実装していく。

PlayAreaNode

プレイエリアは、SKNodeを継承したPlayAreaNodeとして実装していく。

//==============================
// BirdHead
//------------------------------
// PlayAreaNode.swift
//==============================

import SpriteKit

class PlayAreaNode: SKNode {
  // 続く

なお、手札の実装の前にプレイエリアの実装をしているのは、手札からカードをプレイするときに、プレイエリアのメソッド(後述するPlayAreaNode#addCardNodes(_: [CardNode], completion: (() -> Void)!))を呼び出す必要があるから。

クラス定数

まずはクラス定数から。

  // 続き

  private static let actionDuration = NSTimeInterval(0.3)
  private static let margin: CGFloat = 10.0

  // 続く

なお、PlayAreaNode.marginは余白で、カードの上下左右に余白をとるようにしている。

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

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

  // 続き
  
  private let scale: 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(size: CGSize) {
    let maxHeight = CardNode.size.height + PlayAreaNode.margin * 2.0
    let maxWidth = CardNode.size.width * CGFloat(GameInfo.PlayMax) +
                     PlayAreaNode.margin * CGFloat(GameInfo.PlayMax - 1)
    self.scale = min(1.0,
                     size.height / maxHeight,
                     size.width / maxWidth)
    
    self.frameNode = SKShapeNode(rectOfSize: size)
    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")
  }

  // 続く

デッキと同様に、こちらもプレイエリア全体をフレームとしている。
そして、SKNode#frameのプロパティを上書きして、フレームのframeプロパティを適切なものに変換して返すようにしている。
(フレームのframeプロパティはSKNodeの座標系でのものなので、SKNodeの原点の位置(positionプロパティ)を足しこむことで、適切な値になる)

そして、PlayAreaNode#scaleというプロパティ。
これはカードを適切なサイズに縮小して表示するためのもの。
プレイエリアが十分に広ければカードを元のサイズのまま表示すればいいのだけど、実際には広さが足りないので、縮小する必要が出てくる。
そこで、カードを表示するのに必要な高さと幅を計算して、それを自身のサイズと比較することで、適切なスケールを算出している。

あとは、PlayAreaNode#actionQueue。
これはデッキと同じく、アクションを確実に1つずつ実行するためのもの。
詳細はSpriteKitでアクションを確実に1つずつ実行する方法について。 - いものやま。を参照。

カードの追加

次はカードの追加。

  // 続き
  
  func addCardNodes(cardNodes: [CardNode], completion: (() -> Void)! = nil) {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        for cardNode in cardNodes {
          cardNode.faceUp()
          let positionInScene = self.scene!.convertPoint(cardNode.position, fromNode: cardNode.parent!)
          let positionInNode = self.scene!.convertPoint(positionInScene, toNode: self)
          cardNode.position = positionInNode
          cardNode.removeFromParent()
          self.addChild(cardNode)
          self.cardNodes.append(cardNode)
        }
        
        self.cardNodes.sortInPlace { $0.card < $1.card }
        
        let width = CardNode.size.width * self.scale * CGFloat(self.cardNodes.count - 1)
                      + PlayAreaNode.margin * CGFloat(self.cardNodes.count - 1)
        var x = -width / 2.0
        for i in 0..<self.cardNodes.count {
          let moveAction = SKAction.moveTo(CGPoint(x: x, y: 0.0), duration: PlayAreaNode.actionDuration)
          let rotateAction = SKAction.rotateToAngle(0.0, duration: PlayAreaNode.actionDuration)
          let scaleAction = SKAction.scaleTo(self.scale, duration: PlayAreaNode.actionDuration)
          let arrangeAction = SKAction.group([moveAction, rotateAction, scaleAction])
          self.cardNodes[i].runAction(arrangeAction) {
            self.actionQueue.suspended = false
          }
          x += CardNode.size.width * self.scale + PlayAreaNode.margin
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }

  // 続く

カードをプレイエリアに出すので、まずはカードを表面に。

そして、カードをまずはプレイエリアの子ノードとして追加する。
このとき、カードの位置はシーンの中でそのままである必要があるので、座標の変換をしている。
(最初は単にPlayAreaNode#convertPoint(_: CGPoint, fromNode: SKNode)で変換できると思ったのだけど、なんかうまくいかなかったので、シーン経由で変換してる。変換するノード間に親子関係がないとダメなのかも)

そのあと、カードをソートし、プレイエリアに追加する。
適切な位置を計算し、各々のカードがその位置に移動し、カードの向きも正しい向きに変える(手札の中だと傾いてることがある)。
さらに、プレイエリア内にカードが収まるように、あらかじめ計算しておいたスケールに縮小もさせる。
(これらを並列して行う)

あと、コンプリーションが指定されていた場合、これらのアクションが終わったあとに呼び出されるようにしている。

プレイされたカードの無効化

次はプレイされたカードの無効化。

これは何のために必要かというと、現在プレイされているカードの中で最大なもの以外を無効化状態で表示する(=暗く表示される)ことで、最大なものがどれなのかを分かりやすくするため。

  // 続き
  
  func disableCardNodes(completion: (() -> Void)! = nil) {
    if self.cardNodes.count > 0 {
      self.actionQueue.addOperationWithBlock {
        self.actionQueue.suspended = true
        
        NSOperationQueue.mainQueue().addOperationWithBlock {
          for cardNode in self.cardNodes {
            cardNode.isDisabled = true
            self.actionQueue.suspended = false
          }
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }

  // 続く

やっていることは簡単で、それぞれのカードを無効化しているだけ。
なお、カードが追加されていない状態でこのメソッドが呼ばれる可能性もあるので、その場合は(指定されていれば)単にコンプリーションが実行されるだけになるようにしている。

プレイされたカードの掃き出し

次はプレイされたカードの掃き出し。
プレイされたカードを指定された位置に向かって掃き出す。

これは何に使うのかというと、トリックを取った人がプレイされたカードをすべて取得するという様子を表現するのに使う。

  // 続き
  
  func sweepTo(location: CGPoint, completion: (() -> Void)! = nil) {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      // remove self CardNodes, but
      // keep reference until CardNode is swept.
      let sweptCardNodes = self.cardNodes
      self.cardNodes.removeAll()
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        for cardNode in sweptCardNodes {
          cardNode.faceDown()
          let moveAction = SKAction.moveTo(location, duration: PlayAreaNode.actionDuration)
          let rotateAction = SKAction.rotateByAngle(CGFloat(M_PI_2), duration: PlayAreaNode.actionDuration)
          let sweepAction = SKAction.group([moveAction, rotateAction])
          cardNode.runAction(sweepAction) {
            cardNode.removeFromParent()
            self.actionQueue.suspended = false
          }
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }

  // 続く

まずはカードを裏向きに。

そのあと、指定された位置に向かって、180度回転しながら移動するようにしている。

他と同様に、コンプリーションが指定されていた場合、アクションが終わったあとに実行されるようにしている。

プレイされたカードのフェードアウト、削除

最後に、プレイされたカードのフェードアウトと削除。

これらは、最後のカードがプレイされたときの様子を表現するのに使う。
(最後のトリックは、トリックをとる/とらないではなく、マイナス点になる/ならない、というものなので)

  // 続き
  
  func fadeOut(completion: (() -> Void)! = nil) {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        for cardNode in self.cardNodes {
          let fadeOutAction = SKAction.fadeOutWithDuration(PlayAreaNode.actionDuration)
          cardNode.runAction(fadeOutAction) {
            cardNode.removeFromParent()
            self.cardNodes.removeAll()
            self.actionQueue.suspended = false
          }
        }
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }
  
  func removeAll(completion: (() -> Void)! = nil) {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        for cardNode in self.cardNodes {
          cardNode.removeFromParent()
        }
        self.cardNodes.removeAll()
        self.actionQueue.suspended = false
      }
    }
    
    if completion != nil {
      self.actionQueue.addOperationWithBlock {
        NSOperationQueue.mainQueue().addOperationWithBlock {
          completion()
        }
      }
    }
  }
}

まぁ、特に難しいことはなく。

フェードアウトの方はフェードアウトのアクションを実行し、削除の方は単にカードを取り除くだけ。

今日はここまで!