An FP game engine for Scala.

Part 3: Timeline Animations

In the previous part of the animations series, we looked at encoding our animations as signals and signal functions, and concluded by identifying the need for an even higher level of abstraction for when things get really complicated.

Timelines allow you to describe complicated animations, with potentially many layers and things going on over time. They are essentially made up of SignalFunctions that are wrapped up in a nice DSL.

Timeline basics

Each timeline can animate one type of thing, but they and their sub components are reusable and composable.

Timeline animations are build up of 'layers' (terminology clash alert: animation layers, not visible layers) which each animate one property. If you wanted to add another animated value then you would add another layer. The two layers would then be squashed together automatically to produce the end result.

Timeline layers are each their own sequence of 'time slots', such as startAfter and animate. Time slots form a back-to-back chain of things to do. In the example below, there are two slots in use, but you can have as many as you like.

Animate everything!

Timelines do now know that they are for 'animation', and can 'animate' anything at all. We just need to change our idea of animation from something like 'moving a picture' to 'producing a value over time following a series of transformations'.

For example, another interesting use of animations is to cross-fade music by animating the volume of two tracks.

How to make a timeline animation

In this animation, we have two layers.

The first layer initially waits 2 seconds. Then over the next 5 seconds, it calculates a points position diagonally (lerp means linear interpolation) from one corner of the viewport to the other, and finally moves a circle to that position. All of this is performed using an 'ease-in-out' function that accelerates the movement up initially and slows it down towards the end.

The second layer also waits 2 seconds for consistency, then fades the circles fill color in, over time.

The function inside the animate block is built up using SignalFunctions (see below) to describe the value transformation that results in the animated movement. There are lots of helpful signal functions available on the SignalFunction companion object for you to make use of.

  def myTimelineAnimation(viewportSize: Size): Timeline[Shape.Circle] =
    timeline(
      layer(
        startAfter(2.seconds),
        animate(10.seconds) { circle =>
          easeInOut >>>
            lerp(Point(60), viewportSize.toPoint - Point(60)) >>>
            SignalFunction(pt => circle.moveTo(pt))
        }
      ),
      layer(
        startAfter(2.seconds),
        animate(10.seconds) { circle =>
          lerp >>>
            SignalFunction { alpha =>
              circle.withFill(Fill.Color(RGBA.Green.withAlpha(alpha)))
            }
        }
      )
    )

  def present(context: Context[Unit], model: Unit): Outcome[SceneUpdateFragment] =
    Outcome(
      SceneUpdateFragment(
        myTimelineAnimation(context.frame.viewport.bounds.size)
          .atOrLast(context.frame.time.running)(circle)
          .toBatch
      )
    )

Summary

Designed to ease the production of coordinated animations for menu and game over screens, timelines provide a powerful abstraction over signals and signal functions, to allow you to describe complicated animations and state transitions, with many moving parts.

Of course, all of this abstraction comes at a cost, namely: Allocations. While Timeline animations are great for a few complicated situations, they are not well suited to cases where you need many instances of small animations. For those, signals, imported animations and even handrolled animation code can offer a better performance vs ease of use trade off.

...but what is the lightest, fastest, most performant animation technique of all?

In part 4 we'll answer that question by diving into shaders!