ARCore Sceneform SDK: Getting Started

In this tutorial, you’ll learn how to make augmented reality Android apps with ARCore using Sceneform. By Dario Coletto.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Animating

There are two ways to create an animation: animate an existing node or create a custom node capable of complex animations.

Animating a Node

Animating an existing node can be achieved using an ObjectAnimator and is as easy as setting a start and an end value for a property.

ObjectAnimator is an Android class, and it’s not specific to ARCore or Sceneform. If you don’t know how to use it, we have you covered! You can read more about it in the Android Animation Tutorial with Kotlin.

For your game, you’re going to use the animator to blink a light, so you’ll have to pass four parameters:

  • A target (the light object).
  • A property (the intensity of the light).
  • The start value of the property.
  • The end value of the same property.

There is an extension method in Extensions.kt that uses an ObjectAnimator to blink a light:

private fun failHit() {
  scoreboard.score -= 50
  scoreboard.life -= 1
  failLight?.blink()
  ...
}

Next, you need to animate the droid. Add the following code to the TranslatableNode class to create the up animation for the droid renderable:

class TranslatableNode : Node() {
  ...

  // 1
  var position: DroidPosition = DroidPosition.DOWN

  // 2
  fun pullUp() {
    // If not moving up or already moved up, start animation
    if (position != DroidPosition.MOVING_UP && position != DroidPosition.UP) {
      animatePullUp()
    }
  }

  // 3
  private fun localPositionAnimator(vararg values: Any?): ObjectAnimator {
    return ObjectAnimator().apply {
      target = this@TranslatableNode
      propertyName = "localPosition"
      duration = 250
      interpolator = LinearInterpolator()

      setAutoCancel(true)
      // * = Spread operator, this will pass N `Any?` values instead of a single list `List<Any?>`
      setObjectValues(*values)
      // Always apply evaluator AFTER object values or it will be overwritten by a default one
      setEvaluator(VectorEvaluator())
    }
  }

  // 4
  private fun animatePullUp() {
    // No matter where you start (i.e. start from .3 instead of 0F),
    // you will always arrive at .4F
    val low = Vector3(localPosition)
    val high = Vector3(localPosition).apply { y = +.4F }

    val animation = localPositionAnimator(low, high)

    animation.addListener(object : Animator.AnimatorListener {
      override fun onAnimationRepeat(animation: Animator?) {}
      override fun onAnimationCancel(animation: Animator?) {}

      override fun onAnimationEnd(animation: Animator?) {
        position = DroidPosition.UP
      }

      override fun onAnimationStart(animation: Animator?) {
        position = DroidPosition.MOVING_UP
      }

    })
    animation.start()
  }
}

Here, you have added:

  1. A property to track the position of the droid.
  2. A method to pull the droid up.
  3. An ObjectAnimator to animate the droid.
  4. A private method to perform the up animation.

Calling the start() method on the animator is enough to fire it, and the cancel() method will stop the animation. Setting auto cancel to true will stop an ongoing animation when a new one — with the same target and property — is started.

Try to write the pullDown method and associated code yourself. If you’re having trouble, open the spoiler below for the complete code.

[spoiler title=”Pull down animation code”]

fun pullDown() {
  // If not moving down or already moved down, start animation
  if (position != DroidPosition.MOVING_DOWN && position != DroidPosition.DOWN) {
    animatePullDown()
  }
}

private fun animatePullDown() {
  // No matter where you start,
  // you will always arrive at 0F
  val high = Vector3(localPosition)
  val low = Vector3(localPosition).apply { y = 0F }

  val animation = localPositionAnimator(high, low)

  animation.addListener(object : Animator.AnimatorListener {
      override fun onAnimationRepeat(animation: Animator?) {}
      override fun onAnimationCancel(animation: Animator?) {}

      override fun onAnimationEnd(animation: Animator?) {
        position = DroidPosition.DOWN
      }

      override fun onAnimationStart(animation: Animator?) {
        position = DroidPosition.MOVING_DOWN
      }

    })

  animation.start()
}

[/spoiler]

Creating an Animated Node

Custom animated nodes are a little bit harder to use than using ObjectAnimator as you have done above, but they are more powerful.

To use custom animation with a node, you need to extend the Node class and then override the onUpdate method.

WhacARDroid doesn’t need anything that customizable, so if you want to check out an example you can see Google’s Sceneform sample.

Completing the Project

You’re nearly done! The last part covers starting the real game.

Inside scoreboard.onStartTapped in the MainActivity initResources() method, add this code to start a Runnable:

scoreboard.onStartTapped = {
  ...
  // Start the game!
  gameHandler.post {
    repeat(MOVES_PER_TIME) {
      gameHandler.post(pullUpRunnable)
    }
  }
}

Next, create the pullUpRunnable Runnable property for MainActivity.

private val pullUpRunnable: Runnable by lazy {
  Runnable {
    // 1
    if (scoreboard.life > 0) {
      grid.flatMap { it.toList() }
          .filter { it?.position == DOWN }
          .run { takeIf { size > 0 }?.getOrNull((0..size).random()) }
          ?.apply {
            // 2
            pullUp()
            // 3
            val pullDownDelay = (MIN_PULL_DOWN_DELAY_MS..MAX_PULL_DOWN_DELAY_MS).random()
            gameHandler.postDelayed({ pullDown() }, pullDownDelay)
          }

      // 4
      // Delay between this move and the next one
      val nextMoveDelay = (MIN_MOVE_DELAY_MS..MAX_MOVE_DELAY_MS).random()
      gameHandler.postDelayed(pullUpRunnable, nextMoveDelay)
    }
  }
}

Its purposes are to:

  1. Check if the game is completed.
  2. Pull up a random droid renderable from the grid.
  3. Pull the same droid down after a random delay.
  4. If player has at least one life, start itself over again after a random delay.

Next, you need to handle what happens if the player hits the droid.

If the droid is down, it counts as a missed hit, so you remove 50 points and a life from the player. If it’s up, add 100 points to the player.

In MainActivity, in the code within arFragment.setOnTapArPlaneListener, there is a setOnTapListener that is currently just passed a TODO comment in it’s lambda. Replace the comment with this logic:

this.setOnTapListener { _, _ ->
  if (this.position != DOWN) {
    // Droid hit! assign 100 points
    scoreboard.score += 100
    this.pullDown()
  } else {
    // When player hits a droid that is not up
    // it's like a "miss", so remove 50 points
    failHit()
  }
}

As a final step, whenever the player fails a hit, if its life counter is equal to zero or less, reset every droid on the grid by calling the pullDown method. Update the failHit() method to be as follows:

private fun failHit() {
  scoreboard.score -= 50
  scoreboard.life -= 1
  failLight?.blink()
  if (scoreboard.life <= 0) {
    // Game over
    gameHandler.removeCallbacksAndMessages(null)
    grid.flatMap { it.toList() }
            .filterNotNull()
            .filter { it.position != DOWN && it.position != MOVING_DOWN }
            .forEach { it.pullDown() }
  }
}

Everything is ready! Run your brand new WhacARDroid game and challenge your friends to beat your score. :]