Epsilon

Implementing Tweens With Haskell

Posted on November 9, 2013

One of my favorite things about making games is that there are a lot of aspects to game design. One of the big ones to consider is animation. You have to think about what frame of the animation you need to draw, as well as where to draw it on the display. If you’re using Haskell, people will usually point you to a functional reactive programming (frp) library that Haskell has, either Netwire or Reactive-Banana. When I was trying to use these libraries I ran into a couple snags though.

First I tried using Netwire because I was already pretty familiar with using it. Unfortunately, there were dependency conflicts with the other libraries I was using. If I wanted to use Netwire I’d have to downgrade other libraries, which wasn’t an option.

Then I tried out Reactive-Banana, and found that it had fewer dependencies. I had no trouble getting it working with the other libraries in my game, but I found it difficult to use. Reactive-Banana seems to be setup in a way that requires you to use it from the beginning. So if you have a framework already in place without it, it’s a little troublesome to get Reactive-Banana working with it.

So this lead me to think about implementing something of my own. Since tweens are a very simplified case of frp, I thought it might be a useful exercise to implement my own tweening library.

The Basic Type

In my head a tween is a function that translates a time to a position.

type BasicTween = Time -> Position

Unfortunately the graphics library I use passes a time delta, not the time, to me when I’m updating my game. This means I either need to keep track of time for each tween, or try to build it into the tween’s design. To keep the overhead lower in my head for the game, I’ll build it into the tween’s design.

type Position = (Float, Float)
type TimeDelta = Float
data Tween = Tween (Time -> (Position, Either TimeDelta Tween))

So with a Tween I can pass it a time and get a position, and either a Tween that is a continuation of this Tween, or the time remaining when the tween finished. Excellent! So I have a type to work with, let’s see what I get from it. First I’ll make a simple linear interpolation tween.

import Data.VectorSpace

type Position = (Float, Float)
type TimeDelta = Float
data Tween = Tween (Time -> (Position, Either TimeDelta Tween))

linear start end duration = Tween $ linear' 0
  where
    linear' current dt
        | current + dt >= duration = (lerp duration, Left $ current + dt - duration)
        | otherwise                = (lerp (current + dt), Right $ linear' (current + dt))
    lerp t = start ^+^ (end ^-^ start) ^* (t / duration)

Cool, I have a tween! Now to build some quick testing code to see the results of my work.

import Data.VectorSpace

type Position = (Float, Float)
type TimeDelta = Float
data Tween = Tween (Time -> (Position, Either TimeDelta Tween))

linear start end duration = Tween $ linear' 0
  where
    linear' current dt
        | current + dt >= duration = (lerp duration, Left $ current + dt - duration)
        | otherwise                = (lerp (current + dt), Right $ linear' (current + dt))
    lerp t = start ^+^ (end ^-^ start) ^* (t / duration)

runTween (Tween t) = t

--run a tween n times and print the positions it gives
run _   _ 0 = return ()
run tw dt n = do
	let (p, tw') = runTween tw dt
	print p

	case tw' of
		Left _ -> do
			print "Tween ended"
			return ()
		Right x -> run x dt (n-1)

main = do
    let x1 = linear (0,0) (5,0) 5
    run x1 1.0 5

When I run this, I see that the tween works! I have this Tween type that encapsulates all the information I need to move something

Combining Tweens

However, I can see a problem off in the horizon. It would be really neat to be able to combine tweens to form new tweens. How would we do that with the given Tween type?

combine t1 t2 = Tween $ \dt ->
    case runTween t1 dt of
        (pos, Left dt')  -> runTween t2 dt'
        (pos, Right t1') -> (pos, Right $ combine t1' t2)

This is one way it can be done. When one tween is over, jump into the next. The thing is though, I’d like to express my tweens as movements from a given start position. This current type for Tween fails to encapsulate this bit, as it just starts from where the second tween is defined to start. Let’s try changing out Tween type again.

type TimeDelta = Float
type Position = (Float, Float)
data Tween = Tween (Position -> Time -> (Position, Either Time Position))

So now we think of a tween as something that’s given a starting position, and then works the same as the previous version of the Tween type. Let’s try implementing linear again with this type.

import Data.VectorSpace

type Position = (Float, Float)
type TimeDelta = Float
data Tween = Tween (Position -> Time -> (Position, Either TimeDelta Tween))

linear end duration = Tween $ linear' 0
  where
    linear' current start dt
        | current + dt >= duration = (lerp duration, Left $ current + dt - duration)
        | otherwise                = (lerp (current + dt), Right $ Tween step)
      where
        step _ dt = linear' (current + dt) start nextDt
    lerp t = start ^+^ (end ^-^ start) ^* (t / duration)

So with this we’ve kick started the linear tween with a start position, and have it ignore all subsequent initial positions passed. The fun part of this is that we built in a way to create interesting dynamic tweens, like ones that work with the current position for example. To test this out we’d have to alter the run function.

import Data.VectorSpace

type Position = (Float, Float)
type TimeDelta = Float
data Tween = Tween (Position -> Time -> (Position, Either TimeDelta Tween))

linear end duration = Tween $ linear' 0
  where
    linear' current start dt
        | current + dt >= duration = (lerp duration, Left $ current + dt - duration)
        | otherwise                = (lerp (current + dt), Right $ Tween step)
      where
        step _ dt = linear' (current + dt) start nextDt
    lerp t = start ^+^ (end ^-^ start) ^* (t / duration)

--run a tween n times and print the positions it gives
run _ _ _ 0 = return ()
run tw start dt n = do
  let (p, tw') = runTween tw start dt
  print p

  case tw' of
    Left _ -> do
      print "Tween ended"
      return ()
    Right x -> run x start dt (n-1)

main = do
    let x1 = linear (5,0) 5
    run x1 (0,0) 1.0 5

With this we get back to where we started! But what about combining tweens as movements? Well now that we have the starting position encoded into the type, we just pass it along!

combine t1 t2 = Tween $ \start dt ->
    case runTween t1 start dt of
        (pos, Left dt')  -> runTween t2 pos dt'
        (pos, Right t1') -> (pos, Right $ combine t1' t2)

Now we could combine two linear movements if we wanted to, and get the kind of points we’d expect back.

main = do
    let x1 = combine (linear (5,0) 5) (linear (5,5) 5)
    run x1 (0,0) 1.0 10

Now we have tweens, that can be combined! Mind you, I still consider this incomplete. While I like this method of combining tweens, I do consider tweens as something of a complete movement. That is, I should be able to use it like a function over time only like we did in the beginning, but be able to compose it like we just did. That will have to be a post for another time though, as I haven’t managed to pull that off yet!

comments powered by Disqus