いものやま。

雑多な知識の寄せ集め

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

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

今日はAdventureSceneの更新について。

AdventureSceneの更新処理

SKSceneは、一定の間隔で更新が行われる。
その中で、キャラを動かしたりすることが出来る。

SKSceneの更新処理は、次の順番で行われる。

メソッド 説明
SKScene#update(_: NSTimeInterval) シーンの更新に必要な処理を行う。
(アクションの評価)
SKScene#didEvaluateActions() アクションを評価したあとに必要な処理を行う。
(物理シミュレーションの実施)
SKScene#didSimulatePhysics() 物理シミュレーションを行ったあとに必要な処理を行う。
(制約の適合)
SKScene#didApplyConstraints() 制約を適合させたあとに必要な処理を行う。
SKScene#didFinishUpdate() シーンが再描画される直前に必要な処理を行う。
(シーンの再描画)

ここで書かれているメソッドはすべて上書き可能なもので、これらを(必要なら)上書きすることで、シーンの更新の振る舞いを変えることが出来る。
(なお、これらのメソッドはシステムから適切なタイミングで呼び出されるので、自分で呼び出してはいけない)

AdventureSceneでは、このうち、update(_: NSTimeInterval)とdidSimulatePhysics()の上書きを行っている。
update(_:)で行っているのは、キャラを移動させるアクションの設定とか、アニメーションの設定とか。
そして、didSimulatePhysics()で行っているのは、表示する位置の調整など。

なお、表示する位置の調整をdidSimulatePhysics()で行っているのは、2つ理由がある。

  • まず、表示する位置は自キャラの位置を元に計算するので、アクションや物理シミュレーションが解決されて自キャラの位置が定まってからでないと出来ないから。
  • そして、以前は、シーンの再描画の直前に呼ばれたメソッドが、didSimulatePhysics()だったから。
    (以前はdidApplyConstraints()やdidFinishUpdate()がなかった)

今ならdidFinishUpdate()で行う方がいいのかもしれない。

アクションの設定

まずは、AdventureScene#update(_: NSTimeInterval)から。

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  var lastUpdateTimeInterval: NSTimeInterval = 0

  // 省略

  override func update(currentTime: NSTimeInterval) {
    if paused {
      lastUpdateTimeInterval = currentTime
      
      return
    }

    var timeSinceLast = currentTime - lastUpdateTimeInterval
    lastUpdateTimeInterval = currentTime

    if timeSinceLast > 1 {
      timeSinceLast = Constants.minimumUpdateInterval
      worldMovedForUpdate = true
    }

    updateWithTimeSinceLastUpdate(timeSinceLast)

    #if os(iOS)
    if defaultPlayer.hero == nil {
      return
    }

    let hero = defaultPlayer.hero!

    if hero.isDying {
      return
    }

    if defaultPlayer.targetLocation != CGPointZero {
      if defaultPlayer.fireAction {
        hero.faceToPosition(defaultPlayer.targetLocation)
      }

      if defaultPlayer.moveRequested {
        if defaultPlayer.targetLocation != hero.position {
          hero.moveTowardsPosition(defaultPlayer.targetLocation, withTimeInterval: timeSinceLast)
        }
        else {
          defaultPlayer.moveRequested = false
        }
      }
    }
    #endif

    for player in self.players {
      // If there is no player in this slot, move on.
      if player == nil {
        continue
      }

      // If the player has no assigned hero or their hero is isDying, move on.
      if player.hero == nil || player.hero!.isDying {
        continue
      }

      let hero = player.hero!

      /*
        Player movement input will be provided via `heroMoveDirection` or individual movements via
        `moveForward` or `moveBackward` and rotation via `moveLeft` and `moveRight`. `heroMoveDirection` 
        is populated when input is received from a controller. The individual movement details are 
        received when using keyboard input.
      */
      if let heroMoveDirection = player.heroMoveDirection {
        // ここの処理はiOSでは通らないので、省略
      }
      else {
        // ここも同上
      }

      if player.fireAction {
        hero.performAttackAction()
      }
    }
  }

  // 省略
}

ちょっと長い・・・けど、やっていることは、主に2つ。

  • 前回の更新からの経過時間を元にした各スプライトの更新。
  • タッチによるアクションの解決。

各スプライトの更新

各スプライトの更新を行ってるのは、AdventureScene#updateWithTimeSinceLastUpdate(_: NSTimeInterval)というメソッド。

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  func updateWithTimeSinceLastUpdate(timeSinceLast: NSTimeInterval) {
    for hero in heroes {
      hero.updateWithTimeSinceLastUpdate(timeSinceLast)
    }

    levelBoss?.updateWithTimeSinceLastUpdate(timeSinceLast)

    for cave in goblinCaves {
      cave.updateWithTimeSinceLastUpdate(timeSinceLast)
    }
  }

  // 省略
}

見ての通り、各キャラに対して、updateWithTimeSinceLastUpdate(_: NSTimeInterval)を呼んで、スプライトの更新を行っている。

例えば、heroに対する処理を追ってみると、

/* Character.swift */

class Character: ParallaxSprite {
  // 省略

  var requestedAnimation = AnimationState.Idle

  // 省略

  func updateWithTimeSinceLastUpdate(interval: NSTimeInterval) {
    shadowBlob.position = position

    if !animated {
      return
    }
    resolveRequestedAnimation()
  }

  // 省略

  func resolveRequestedAnimation() {
    var (frames, key) = animationFramesAndKeyForState(requestedAnimation)

    fireAnimationForState(requestedAnimation, usingTextures: frames, withKey: key)

    requestedAnimation = isDying ? .Death : .Idle
  }

  func animationFramesAndKeyForState(state: AnimationState) -> ([SKTexture], String) {
    switch state {
      case .Walk:
         return (self.dynamicType.walkAnimationFrames, "anim_walk")

      case .Attack:
        return (self.dynamicType.attackAnimationFrames, "anim_attack")

      case .GetHit:
        return (self.dynamicType.getHitAnimationFrames, "anim_gethit")

      case .Death:
        return (self.dynamicType.deathAnimationFrames, "anim_death")

      case .Idle:
        return (self.dynamicType.idleAnimationFrames, "anim_idle")
    }
  }

  func fireAnimationForState(animationState: AnimationState, usingTextures frames: [SKTexture], withKey key: String) {
    var animAction = actionForKey(key)

    if animAction != nil || frames.count < 1 {
      return
    }

    let animationAction = SKAction.animateWithTextures(frames, timePerFrame: NSTimeInterval(animationSpeed), resize: true, restore: false)
    let blockAction = SKAction.runBlock {
      self.animationHasCompleted(animationState)
    }

    runAction(SKAction.sequence([animationAction, blockAction]), withKey: key)
  }

  func animationHasCompleted(animationState: AnimationState) {
    if isDying {
      animated = false
      shadowBlob.runAction(SKAction.fadeOutWithDuration(1.5))
    }

    animationDidComplete(animationState)

    if isAttacking {
      isAttacking = false
    }
  }

  // 省略
}

となっていて、ざっと流れを追うと、

  1. 影の位置を移動
  2. アニメーションの解決
    1. 現在の状態に対するアニメーションのフレームと(あとで検索できるようにするための)キーを取得
    2. 取得したフレームとキーで、アニメーションを実行させる
      1. キーでアニメーションを検索
        (すでに同じキーのアニメーションを実行していたら、アニメーションを実行しない)
      2. テクスチャを変化させるSKActionを生成
      3. アニメーションが完了したときに行う処理のSKActionを生成
      4. この2つを連続で行うSKActionを生成して、ノードに実行させる

という感じ。

なお、自キャラ、ボス、ゴブリンの洞穴に対してしかupdateWithTimeSinceLastUpdate()が呼ばれていないけど、ゴブリンについては以下のように、ゴブリンの洞穴の更新処理の中からこのメソッドが呼ばれるようになっている。

/* Cave.swift */

final class Cave: EnemyCharacter, SharedAssetProvider {
  // 省略

  override func updateWithTimeSinceLastUpdate(interval: NSTimeInterval) {
    super.updateWithTimeSinceLastUpdate(interval) // this triggers the update in the SpawnAI

    for goblin in activeGoblins {
      goblin.updateWithTimeSinceLastUpdate(interval)
    }
  }

  // 省略
}

タッチによるアクションの解決

タッチによるアクションの解決をしているのは、AdventureScene#update(_: NSTimeInterval)で、ディレクティブ#if os(iOS)#endifに挟まれている部分(と、その後ろの一部)。

  • 攻撃がリクエストされていたら、自キャラの向きをその方向へ変える
  • 移動がリクエストされていたら、その位置へ向けて移動する
  • 攻撃がリクエストされていたら、攻撃を行う

例えば、向きを変えるのだと、次のような処理が行われている。

/* Character.swift */

class Character: ParallaxSprite {
  // 省略

  func faceToPosition(position: CGPoint) -> CGFloat {
    var angle = adjustAssetOrientation(position.radiansToPoint(self.position))

    var action = SKAction.rotateToAngle(angle, duration: 0)

    runAction(action)

    return angle
  }

  // 省略
}

移動だとSKActionを作らないで直接positionを変えたりもしている。

表示する位置の調整など

そして、AdventureScene#didSimulatePhysics()。

class AdventureScene: SKScene, SKPhysicsContactDelegate {
  // 省略

  struct Constants {
    // 省略
    static let minimumDistanceFromHeroToVisibleEdge: CGFloat = 256.0
    // 省略
  }

  // 省略

  override func didSimulatePhysics() {
    if paused {
      return
    }
    
    for player in players {
      if let defaultHero = player?.hero {
        let heroPosition = defaultHero.position
        var worldPosition = world.position
        
        let yCoordinate = worldPosition.y + heroPosition.y
        if yCoordinate < Constants.minimumDistanceFromHeroToVisibleEdge {
          worldPosition.y = worldPosition.y - yCoordinate + Constants.minimumDistanceFromHeroToVisibleEdge
          worldMovedForUpdate = true
        } else if yCoordinate > (frame.size.height - Constants.minimumDistanceFromHeroToVisibleEdge) {
          worldPosition.y = worldPosition.y + (frame.size.height - yCoordinate) - Constants.minimumDistanceFromHeroToVisibleEdge
          worldMovedForUpdate = true
        }
        
        let xCoordinate = worldPosition.x + heroPosition.x
        if xCoordinate < Constants.minimumDistanceFromHeroToVisibleEdge {
          worldPosition.x = worldPosition.x - xCoordinate + Constants.minimumDistanceFromHeroToVisibleEdge
          worldMovedForUpdate = true
        } else if xCoordinate > (frame.size.width - Constants.minimumDistanceFromHeroToVisibleEdge) {
          worldPosition.x = worldPosition.x + (frame.size.width - xCoordinate) - Constants.minimumDistanceFromHeroToVisibleEdge
          worldMovedForUpdate = true
        }
        
        world.position = worldPosition
        
        updateAfterSimulatingPhysics()
        
        worldMovedForUpdate = false
      }
    }
  }

  // 省略
}

やっているのは、自キャラが画面の外枠から一定の距離(AdventureScene.Constants.minimumDistanceFromHeroToVisibleEdge)以内にいる場合、self.worldの位置を移動させて、自キャラが画面の外枠から一定の距離以上離れるようにしている。

f:id:yamaimo0625:20150708004940p:plain

そのあと、AdventureScene#updateAfterSimulatingPhysics()で、木のスプライトのアルファ値の更新や、視覚効果を出すパーティクルの実行/停止の更新、それと、視差効果を出すスプライトのオフセットの更新を行っている。
(詳細は省略)

サンプルコードを読むのは、こんなところで。
SpriteKitを使うときの感覚がだいぶ掴めた気がする。

今日はここまで!