いものやま。

雑多な知識の寄せ集め

SpriteKitのサンプルコードを読んでみた。(その4)

昨日はAdventureSceneの生成を追ってみた。

今日はタッチ処理について。

タイトル画面でのタッチ

タイトル画面のボタンをタッチすると、接続されたアクションであるViewController#chooseArcher(_: AnyObject)、ViewController#chooseWarrior(_: AnyObject)が呼ばれる。
そして、そこからAdventureScene#startLevel(_: Player.HeroType)が呼ばれて、ゲームが始まるようになっている。

ゲームの開始

ということで、AdventureScene#startLevel(_: Player.HeroType)。

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  func startLevel(heroType: Player.HeroType) {
    defaultPlayer.heroType = heroType
    addHeroForPlayer(defaultPlayer)
    
    if shouldCheat {
      var bossPosition = levelBoss.position
      bossPosition.x += 128
      bossPosition.y += 512
      defaultPlayer.hero!.position = bossPosition
    }

    // Setup the HUD for the default player.
    loadHUDForPlayer(defaultPlayer, atIndex: 0)

    configureGameControllers()
  }

  // 省略
}

まずは選択されたキャラの種類を保持。
そして、マップにキャラを登場させる。
そのあと、チートコードが入っていて、ビルド前にshoudCheatというプロパティの値をtrueにしておくと、ボスがすぐに現れるようになってる。
そうしたら、画面左上にステータスを表示させ、ゲームコントローラの設定を行っている。
ゲームコントローラの設定の詳細については省略。NSNotificationCenterにゲームコントローラの接続/切断のイベントを通知させるようにしているっぽい)

キャラの登場

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  func addHeroForPlayer(player: Player) -> HeroCharacter {
    if let hero = player.hero {
      if !hero.isDying {
        hero.removeFromParent()
      }
    }

    var spawnPosition = defaultSpawnPoint

    for aHero in heroes {
      if !aHero.isDying {
        spawnPosition = aHero.position
      }
    }

    var hero: HeroCharacter
    switch player.heroType! {
      case .Warrior:
        hero = Warrior(atPosition: spawnPosition, withPlayer: player)

      case .Archer:
        hero = Archer(atPosition: spawnPosition, withPlayer: player)
    }

    let emitter = spawnEmitterTemplate.copy() as! SKEmitterNode
    emitter.position = spawnPosition
    addNode(emitter, atWorldLayer: .AboveCharacter)
    runOneShotEmitter(emitter, withDuration: 0.15)

    hero.fadeIn(2.0)
    hero.addToScene(self)
    heroes.append(hero)

    player.hero = hero

    return hero
  }

  // 省略
}

このコード、ちょっと分かりにくいのだけど、デバッガで確認すると実際に通過するのは、登場位置をデフォルトの位置にし、選択したキャラを生成して、SKEmitterNodeによる効果を追加しつつ、キャラを登場させるところだけ。
(一番最初はplayer.heroがnilだし、2回目以降はplayer.hero.isDyingがtrue。それと、heroesは空の配列。おそらく、複数人でプレイしている場合には、そうじゃない状態で呼ばれることもあるのだろうけど)

ステータスの表示

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  struct Constants {
    // 省略
    static let hudNodeName = "AdventureHUD"
    static let hudAvatarName = "hudAvatar"
    static let hudScoreName = "hudScore"
    static let hudHeartName = "hudHeart"
    // 省略
  }

  // 省略

  func loadHUDForPlayer(player: Player, atIndex index: Int) {
    let hudScene: SKScene = SKScene(fileNamed: Constants.hudNodeName)
    let hud = hudScene.children.first!.copy() as! SKNode
    hud.name = Constants.hudNodeName
    hud.position = CGPoint(x: CGFloat(0 + Constants.hudWidth * index), y: frame.size.height)
    addChild(hud)
    player.hudAvatar = hud.childNodeWithName(Constants.hudAvatarName) as! SKSpriteNode
    player.hudScore = hud.childNodeWithName(Constants.hudScoreName) as! SKLabelNode
    hud.enumerateChildNodesWithName(Constants.hudHeartName) { node, stop in
      player.hudLifeHearts.append(node as! SKSpriteNode)
    }

    updateHUDForPlayer(player)
  }

  func updateHUDForPlayer(player: Player) {
    player.hudScore.text = String.localizedStringWithFormat(NSLocalizedString("SCORE: %d", comment: ""), player.score)
  }

  // 省略
}

ここもAdventureSceneの構築のときと同様に、"AdventureHUD.sks"からSKSceneを生成し、それを雛形としてステータスの表示を行っている。

ゲーム画面でのタッチ

あとは、実際のゲーム中のタッチ処理。

ゲーム中の入力の処理はMacの場合とiOSの場合で異なるので、AdventureSceneの拡張として、それぞれの依存部に用意されている。

/* AdventureSceneiOSEvents.swift */

import SpriteKit

extension AdventureScene {
  // MARK: Touch Handling
  
  // 続く

SKSceneはUIResponderを継承しているので、タッチイベントに対してtouchesBegan(_: Set<UITouch>, withEvent: UIEvent?)、touchesMoved(_: Set<UITouch>, withEvent: UIEvent?)、touchesEnded(_: Set<UITouch>, withEvent: UIEvent?)が呼ばれる。
呼ばれるタイミングはそれぞれ、タッチが始まった瞬間、タッチしている指が動いた瞬間、タッチが終わった瞬間。
なので、AdventureSceneの拡張ではそれらを上書きして、タッチの処理を行っている。

タッチの開始

まずは、タッチが開始したときの処理から。

  // 続き

  override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) {
    // If we have no hero, we don't need to update the user interface at all.
    if heroes.isEmpty || touches.count <= 0 {
      return
    }

    // If a touch has already been processed on the default player (i.e. the only player),
    // don't process another one until the next event loop.
    if defaultPlayer.movementTouch != nil {
      return
    }

    let touch = touches.first as! UITouch

    defaultPlayer.targetLocation = touch.locationInNode(defaultPlayer.hero!.parent)

    let nodes = nodesAtPoint(touch.locationInNode(self)) as! [SKNode]

    let enemyBitmask = ColliderType.GoblinOrBoss.rawValue | ColliderType.Cave.rawValue

    var heroWantsToAttack = false

    for node in nodes {
      // There are multiple values for `ColliderType`. We need to check if we should attack.
      if let body = node.physicsBody {
        if body.categoryBitMask & enemyBitmask > 0 {
          heroWantsToAttack = true
        }
      }
    }

    defaultPlayer.fireAction = heroWantsToAttack
    defaultPlayer.moveRequested = !heroWantsToAttack
    defaultPlayer.movementTouch = touch
  }

  // 続く

まず、まだキャラが選択されていない状態だったり、タッチの数が0以下の場合(あるの?)、何もしない。
それに、すでにタッチが開始されているときも、何もしない。

そうでない場合、タッチの位置を目標の座標に設定。
そして、その位置のノードを取得して、衝突判定を行っている。
衝突判定で、もしタッチした座標が敵キャラの物理体に衝突していると判断されたら、そのタッチは攻撃と解釈される。
そうでない場合、移動と解釈される。
あとは、それらの情報を保持して、処理は終了。

なお、実際の処理は、これらの情報を元にして、AdventureSceneの更新で行われる。

タッチの移動

次に、タッチが移動したときの処理。

  // 続き

  override func touchesMoved(touches: Set<NSObject>, withEvent event: UIEvent) {
    // If we have no hero, we don't need to update the user interface at all.
    if heroes.isEmpty || touches.count <= 0 {
      return
    }

    // If a touch has been previously recorded, move the player in the direction of the previous
    // touch.
    if let touch = defaultPlayer.movementTouch {
      if touches.contains(touch) {
        defaultPlayer.targetLocation = touch.locationInNode(defaultPlayer.hero!.parent)

        if !defaultPlayer.fireAction {
          defaultPlayer.moveRequested = true
        }
      }
    }
  }

  // 続く

キャラが選択されていなかったり、タッチの数が0のときに、何もせずに戻るのは先程と同じ。

問題は、そのあとのコード。

正直、最初、このコードの意味が分からなかった。
というのも、タッチというのが「タッチに関する情報」が入った構造体だと思っていたから。
touches.contains(touch)としているけど、もしこれがtrueなら、さっき保持していたtouchを処理するのだからタッチの座標は変わらないだろうし、falseならそもそも何も処理が行われないことになる。
なんじゃこりゃ?、と。

これは、UITouchというのが構造体ではなくクラスであり、状態が変化するオブジェクトであるというのが答え。
なので、同じオブジェクトであっても、参照されるタイミングで状態が変化していることがあって、座標の情報も変わっていることがある。
どちらかというと、「タッチしている指」が抽象化されたものだと考えると、分かりやすいと思う。

したがって、ここでやっている処理を言葉にすると、タッチしている指(複数の場合もある)の中に、先程タッチした指が含まれているなら、その指が現在タッチしている座標をキャラの移動先にして、移動をリクエストする、という感じになる。

タッチの終了

最後に、タッチが終了したときの処理。

  // 続き

  override func touchesEnded(touches: Set<NSObject>, withEvent event: UIEvent) {
    // If we have no hero, we don't need to update the user interface at all.
    if heroes.isEmpty || touches.count <= 0 {
      return
    }

    // If there was a touch being tracked, stop tracking it. Don't move the player anywhere.
    if let touch = defaultPlayer.movementTouch {
      if touches.contains(touch) {
        defaultPlayer.movementTouch = nil
        defaultPlayer.fireAction = false
      }
    }
  }
}

タッチが移動したときの処理が理解できれば、これは簡単。
タッチしていた指への参照をなくし、攻撃を止めるようにしている。
(移動のリクエストは止めていないことに注意。なので、指を離しても、目標の座標に行くまでは移動が続く)

今日はここまで!