いものやま。

雑多な知識の寄せ集め

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

昨日はコントローラを実装してSarsaAI同士が対戦できるようにした。

今日は手札のタッチ処理を実装していく。

手札選択/プレイのUI設計

まずは手札をどうやって選択、プレイ出来るようにするかを考える。

最初に考えていたのは、タップして選択/選択解除。
そして、選択が決まったら、その状態でドラッグすることでプレイ、というもの。

なんでこのようなUIを考えていたかというと、BirdHeadでは普通のトリテと違って複数枚出しがあるから。
出すカードが1枚だけなら、単にカードをドラッグしてプレイするのでいいんだけど(大抵のアプリはこのUI)、複数枚出しがある場合、これだと困ってしまう。
というのも、トリックの途中なら、プレイに必要な枚数になるまで待てばいいだけなんだけど、リードのときには枚数が確定していないので、プレイが終わったのかどうかが分からない場合が出てくるから。

なお、シュナプセンのアプリの場合、マリッジをするときには、K/QのカードをQ/Kのカードの上に一度ドラッグして、その状態でプレイするというUIが取られてたけど、特殊すぎる感じは否めない。

そこで、最初に述べたようなUIを考えたんだけど、やっぱり普通のUIとはちょっと違うので、違和感が。
特に、普通にカードを1枚プレイする場合、わざわざタップして選択してからドラッグというのは、だいぶ煩わしい。

最終的に考えたのは、次のようなUI。

  • 手札のカードをドラッグ(もしくはタップ)すると、カードが選択エリアに移動。
  • 選択エリアのカードをドラッグ(もしくはタップ)すると、カードは手札に戻る。
  • 選択エリアのカードが合法手になったら、選択エリアに重ねるようにPlayボタンを出す。
  • Playボタンが押されると、カードがプレイされる。

このUIの場合も、Playボタンを押すという追加の1ステップが必要となってしまっているんだけど、かわりに、出すカードを間違えてしまったときに選択をやり直すことも出来るようになっているので、±0といった感じ。
もちろん、最初に考えてた方法でも選択のやり直しは出来るので、そういった意味では最初の方法と同じなんだけど、選択を行うときにカードをタップするのではなく、カードをドラッグして行うようになっているので、普通のUIと操作感が近くなっていて、違和感が減っている。

なお、選択できるカードは当然プレイ可能なカードのみで、プレイ可能なカードは明るく、そしてプレイ不可能なカードは暗く表示するようにする。

PlayableHandNode

さて、上記のようにUIを設計したので、その実装。
HandNodeを継承した、PlayableHandNodeを作っていく。

なお、HandNodeのprivateなプロパティにもアクセスする必要があるので、HandNodeと同じファイルに定義していく。
(Swiftのアクセス制御はファイル/モジュール単位で、Javaのクラス/パッケージ単位のアクセス制御、Rubyのオブジェクト単位のアクセス制御とは、また違ったものになってる)

//==============================
// 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
  
  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: ActionQueue
  
  override var frame: CGRect {
    var frame = self.frameNode.frame
    frame.origin.x += self.position.x
    frame.origin.y += self.position.y
    return frame
  }
  
  // 省略
}

class PlayableHandNode: HandNode {
  // 続く

上記のprivateなプロパティにも、PlayableHandNodeからはアクセス出来る。

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

  // 続き

  private let selectAreaNode: SKShapeNode
  private let playButtonNode: ButtonNode
  
  private var legalActions: [Action]
  private var selectedCardNodes: [CardNode]
  
  private var touch: UITouch!
  
  private var targetCardNode: CardNode!
  private var previousLocation: CGPoint!
  
  private var targetButtonNode: ButtonNode!
  
  weak var playableHandNodeDelegate: PlayableHandNodeDelegate?
  
  init(frameWidth: CGFloat) {
    let selectAreaSize = CGSize(width: frameWidth, height: CardNode.size.height)
    self.selectAreaNode = SKShapeNode(rectOfSize: selectAreaSize)
    self.selectAreaNode.lineWidth = 0.0
    
    self.playButtonNode = ButtonNode(texture: SKTexture(imageNamed: "PlayButton"))
    self.playButtonNode.userInteractionEnabled = false  // handle event by PlayableHandNode

    self.legalActions = [Action]()
    self.selectedCardNodes = [CardNode]()
    
    self.touch = nil
    
    self.targetCardNode = nil
    self.previousLocation = nil
    
    self.targetButtonNode = nil
    
    self.playableHandNodeDelegate = nil
    
    super.init(isFaceUp: true, frameWidth: frameWidth)
    
    let selectAreaPositionY = self.frameNode.frame.height/2.0 + self.selectAreaNode.frame.height/2.0
    self.selectAreaNode.position.y = selectAreaPositionY
    self.addChild(self.selectAreaNode)
    
    self.playButtonNode.position.y = selectAreaPositionY
  }

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

  // 続く

ノードとして、選択エリアとPlayボタンを追加している。

それと、タッチ処理用のプロパティをいろいろ追加。

あと、playableHandNodeDelegateは、Playボタンが押されたときに、選択されたカードの処理を委譲するためのもの。

なお、Playボタンに対するタッチイベントの処理は、自前で行うようにした。
というのも、重ねて表示するので、iOS8とiOS9でSpriteKitのタッチ判定の挙動に違いがあった話。 - いものやま。で書いたような問題が起こらないとも限らないので。

合法手のセット/削除

続いて、合法手のセットと削除。

合法手がセットされたらタッチ処理を有効にしてカードの選択が可能になるようにし、逆に、合法手が削除されたらタッチ処理は無効にしてカードの選択も出来ないようにする。

  // 続き
  
  func setLegalActions(actions: [Action]) {
    self.actionQueue.addBlock {
      self.legalActions = actions
    
      self.updateLegalCardNodes()
      
      self.userInteractionEnabled = true
    }
  }
  
  func removeLegalActions() {
    self.actionQueue.addBlock {
      self.legalActions.removeAll()
      self.userInteractionEnabled = false
    }
  }

  // 続く

カードのプレイ

カードのプレイについては、選択されているカードをプレイする必要があるので、オーバーライド。

  // 続き
  
  override func playCards(cards: [Int], to playAreaNode: PlayAreaNode,
                          completion: (() -> Void)!)
  {
    if self.selectedCardNodes.isEmpty {
      super.playCards(cards, to: playAreaNode, completion: completion)
    } else {
      self.actionQueue.addActionBlock { executor in
        executor.startAction()
        playAreaNode.addCardNodes(self.selectedCardNodes) {
          executor.endAction()
        }
        
        self.selectedCardNodes.removeAll()
      }
      
      if completion != nil {
        self.actionQueue.addBlock(completion)
      }
    }
  }

  // 続く

ただし、何も選択されていない状態で指定されたカードをプレイしろと言われる可能性もある(というか、実際に一番最後のカードのプレイはそうなる)ので、その場合には元の処理を呼び出すようにしている。

タッチ処理

さて、肝心のタッチ処理。

  // 続き
  
  override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
    self.touch = touches.first!
    let location = self.touch.locationInNode(self)
    
    if let buttonNode = self.findButtonNode(location) {
      self.targetButtonNode = buttonNode
    } else if let targetCardNode = self.findCardNode(location) {
      if !targetCardNode.isDisabled {
        self.targetCardNode = targetCardNode
        self.previousLocation = location
      }
    }
  }
  
  override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if self.touch != nil && touches.contains(self.touch) {
      let location = self.touch.locationInNode(self)
      
      if self.targetCardNode != nil {
        self.targetCardNode.position.x += location.x - self.previousLocation.x
        self.targetCardNode.position.y += location.y - self.previousLocation.y
        self.previousLocation = location
      }
    }
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if self.touch != nil && touches.contains(self.touch) {
      let location = self.touch.locationInNode(self)
      
      if self.targetButtonNode != nil {
        if self.findButtonNode(location) != nil {
          self.playButtonNode.removeFromParent()
          self.playableHandNodeDelegate?.playableHandNodeSelectCardNodes(self.selectedCardNodes)
          
          self.targetButtonNode = nil
        }
      } else if self.targetCardNode != nil {
        if self.cardNodes.contains(self.targetCardNode) {
          self.selectCardNode(self.targetCardNode)
        } else if self.selectedCardNodes.contains(self.targetCardNode) {
          self.deselectCardNode(self.targetCardNode)
        }
        
        self.targetCardNode = nil
        self.previousLocation = nil
      }
      self.touch = nil
    }
  }

  // 続く

内容は、大きく分けて「カードがタッチされたときの処理」と「ボタンがタッチされたときの処理」に分かれている。

カードがタッチされたときには、

  • カードを操作対象のカードとする
  • ドラッグされたら、ドラッグの動きにしたがってカードを移動
  • タッチ(やドラッグ)が終わったら、
    • 手札にあったカードは、選択エリアへ移動させる
    • 選択エリアにあったカードは、手札へ戻す

としている。

そして、ボタンがタッチされたときには、

  • ボタンを操作対象のボタンとする
  • タッチが終わったら、その位置がボタン内かどうかをチェックし、ボタン内であれば、委譲先に通知を行う。

という感じ。

タッチ処理を実現するための各メソッド

ここからは、タッチ処理を実現するための個々のメソッド。

カードの検索/ボタンの検索

まずは、タッチされた位置にカードやボタンがあるかを検索するためのメソッド。
あったならそのノードを、なければnilを返す。

  // 続き
  
  private func findCardNode(location: CGPoint) -> CardNode? {
    var foundCardNode: CardNode! = nil
    let children = self.children
    for node in self.nodesAtPoint(location) {
      switch node {
      case let cardNode as CardNode:
        let convertedPoint = cardNode.convertPoint(location, fromNode: cardNode.parent!)
        if (-CardNode.size.width/2.0 <= convertedPoint.x) &&
           (convertedPoint.x <= CardNode.size.width/2.0) &&
           (-CardNode.size.height/2.0 <= convertedPoint.y) &&
           (convertedPoint.y <= CardNode.size.height/2.0)
        {
          if foundCardNode == nil {
            foundCardNode = cardNode
          } else {
            if children.indexOf(foundCardNode)! < children.indexOf(cardNode)! {
              foundCardNode = cardNode
            }
          }
        }
      default:
        break
      }
    }
    return foundCardNode
  }
  
  private func findButtonNode(location: CGPoint) -> ButtonNode? {
    for node in self.nodesAtPoint(location) {
      switch node {
      case let buttonNode as ButtonNode:
        return buttonNode
      default:
        break
      }
    }
    return nil
  }

  // 続く

findCardNode()の中で、座標の変換(convertPoint())を行っているけど、これはカードが傾いているために必要な処理。

SKNode#nodesAtPoint(_: CGPoint)は、「ノードを含む矩形」が指定された座標を含む場合、そのノードを戻り値の配列に追加するようになっている。
なので、カードが傾いている場合、「カードを含む矩形」はカードよりも大きくなってしまうことがあり、指定した座標にカードが含まれていなくても「カードを含む矩形」が含まれているならば、それが戻り値の配列に入ってきてしまう。

そこで、タッチの座標をカードの座標系に変換してやり(そうすると、カードの向きに沿った傾いた座標系での座標になる)、その座標がカードのサイズ内に収まっているかをチェックするようにしている。

あと、カードは重なり合っているので、複数のカードが見つかる可能性がある。
なので、見つかったカードの中で、一番手前にあるものを返すようにしている。

カードの選択エリアへの移動

次に、カードを選択エリアへ移動させる処理。

  // 続き
  
  private func selectCardNode(cardNode: CardNode) {
    var needUpdate = false
    
    self.actionQueue.addBlock {
      if let index = self.cardNodes.indexOf(cardNode) {
        needUpdate = true
        
        self.cardNodes.removeAtIndex(index)
        self.selectedCardNodes.append(cardNode)
      }
    }
    
    self.actionQueue.addBlock {
      if needUpdate {
        self.updateLegalCardNodes()
      }
    }
    
    self.actionQueue.addActionBlock { executor in
      if needUpdate {
        self.updateCardNodePosition(executor)
      }
    }
    
    self.actionQueue.addBlock {
      if needUpdate {
        self.updatePlayButtonNode()
      }
    }
  }

  // 続く

いくつかの処理を順番に行っている。

  1. 内部状態の更新(対象のカードを手札から削除し、選択されたカードに追加)
  2. プレイ可能な手札の更新
  3. カードの位置の更新
  4. Playボタンの更新

それぞれの詳細は後で。

カードの手札への移動

逆に、カードを手札へ戻す処理。

  // 続き
  
  private func deselectCardNode(cardNode: CardNode) {
    var needUpdate = false
    
    self.actionQueue.addBlock {
      if let index = self.selectedCardNodes.indexOf(cardNode) {
        needUpdate = true
        
        self.selectedCardNodes.removeAtIndex(index)
        
        let children = self.children
        let indexInChildren = children.indexOf(cardNode)!
        var insertIndex = 0
        for i in 0..<self.cardNodes.count {
          if children.indexOf(self.cardNodes[i])! < indexInChildren {
            insertIndex += 1
          } else {
            break
          }
        }
        self.cardNodes.insert(cardNode, atIndex: insertIndex)
      }
    }
    
    self.actionQueue.addBlock {
      if needUpdate {
        self.updatePlayButtonNode()
      }
    }
    
    self.actionQueue.addActionBlock { executor in
      if needUpdate {
        self.updateCardNodePosition(executor)
      }
    }
    
    self.actionQueue.addBlock {
      if needUpdate {
        self.updateLegalCardNodes()
      }
    }
  }

  // 続く

こちらもいくつかの処理を順番に行っている。

  1. 内部状態の更新(対象のカードを選択されたカードから削除し、手札に追加)
  2. Playボタンの更新
  3. カードの位置の更新
  4. プレイ可能な手札の更新

それぞれの詳細は後で。

カードの位置の更新

まずはカードの位置の更新から。

  // 続き
  
  private func updateCardNodePosition(executor: ActionQueue.Executor) {
    let width = CardNode.size.width * CGFloat(self.selectedCardNodes.count - 1)
    var x = -width / 2.0
    let y = self.selectAreaNode.position.y
    for selectedCardNode in self.selectedCardNodes {
      let moveAction = SKAction.moveTo(CGPoint(x: x, y: y), duration: HandNode.actionDuration)
      let rotateAction = SKAction.rotateToAngle(0.0, duration: HandNode.actionDuration)
      let arrangeAction = SKAction.group([moveAction, rotateAction])
      executor.executeAction(arrangeAction, forNode: selectedCardNode)
      
      x += CardNode.size.width
    }
    
    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 moveAction = SKAction.moveTo(position, duration: HandNode.actionDuration)
      let rotateAction = SKAction.rotateToAngle(angle, duration: HandNode.actionDuration)
      let arrangeAction = SKAction.group([moveAction, rotateAction])
      executor.executeAction(arrangeAction, forNode: cardNode)
      
      angle -= HandNode.angle
    }
  }

  // 続く

選択されたカード、手札の両方について、適切な位置、角度になるように移動を行っている。

Playボタンの更新

次にPlayボタンの更新。

  // 続き
  
  private func updatePlayButtonNode() {
    var selectedCards = [Int]()
    for cardNode in self.selectedCardNodes {
      selectedCards.append(cardNode.card)
    }
    let playAction = Action.play(selectedCards)
    let discardAction = Action.discard(selectedCards)
    if self.legalActions.contains(playAction) || self.legalActions.contains(discardAction) {
      if self.playButtonNode.parent == nil {
        self.addChild(self.playButtonNode)
      }
    } else {
      if self.playButtonNode.parent != nil {
        self.playButtonNode.removeFromParent()
      }
    }
  }

  // 続く

選択されたカードが合法手になっているかどうかをチェックし、合法手になっていればPlayボタンを表示させ、そうでなければPlayボタンを表示させないようにしている。

プレイ可能な手札の更新

最後に、プレイ可能な手札の更新。

  // 続き
  
  private func updateLegalCardNodes() {
    var selectedCards = [Int]()
    for cardNode in self.selectedCardNodes {
      selectedCards.append(cardNode.card)
    }
    
    var playableCards = Set<Int>()
    for legalAction in self.legalActions {
      var isSubset = true
      var legalActionCards = legalAction.cards()
      for selectedCard in selectedCards {
        if let index = legalActionCards.indexOf(selectedCard) {
          legalActionCards.removeAtIndex(index)
        } else {
          isSubset = false
          break
        }
      }
      if isSubset {
        for legalActionCard in legalActionCards {
          playableCards.insert(legalActionCard)
        }
      }
    }
    
    for cardNode in self.cardNodes {
      if playableCards.contains(cardNode.card) {
        cardNode.isDisabled = false
      } else {
        cardNode.isDisabled = true
      }
    }
  }
}

  // 続く

現在選択されているカードと、各合法手との差分をチェックし、プレイ可能なカードを洗い出している。
そして、プレイ可能な手札は有効状態(明るく表示される)にし、逆にプレイ不可能な手札は無効状態(暗く表示される)にしている。

これでPlayableHandNodeの実装は終わり!

PlayableHandNodeDelegate

あとは、委譲先への通知のインタフェースを定義するだけ。

次のように、PlayableHandNodeDelegateプロトコルを定義した。

  // 続き

protocol PlayableHandNodeDelegate: class {
  func playableHandNodeSelectCardNodes(cardNodes: [CardNode])
}

これで完了!


正直、このタッチ処理の実装がめっちゃ大変だった・・・(^^;
プログラムしない人は、こういったちょっとしたUIってすごく簡単に実現されてそうだと思うだろうけど、実際には大変なんだよね。
だから、仕様変更で死ねるわけで・・・
(しかも、直接目に見えて触れられるところだけに、仕様変更に関する意見も出て来やすい・・・)

何はともあれ、これで手札のタッチ処理も実装できたので、あとは人間プレイヤーを用意して、コントローラを修正すれば、いよいよ遊べるようになる!

今日はここまで!