Skip to content
This repository has been archived by the owner on Mar 8, 2021. It is now read-only.

Timer and Tween Functions in Lua #48

Open
a327ex opened this issue May 6, 2019 · 6 comments
Open

Timer and Tween Functions in Lua #48

a327ex opened this issue May 6, 2019 · 6 comments

Comments

@a327ex
Copy link
Owner

a327ex commented May 6, 2019

Timer and tween functions are some of the most fundamental and useful functions for gameplay code in my opinion and so in this article I'll go over getting those properly implemented in Lua. If you use other languages you can probably use this article as a reference but some implementation details will likely have to be different.


Motivation

The standard way of doing something after n seconds in a game looks something like this:

timer_current = 0
timer_delay = 5

function update(dt)
    timer_current = timer_current + dt
    if timer_current > timer_delay then
        -- do the thing
    end
end

And so after 5 seconds from when timer_current was first set to 0 the action will be performed. Doing things this way is mostly fine but it's noisy and cumbersome and gets even more so when you want to do timers that are bit more involved, like chained actions. So the way we'll solve this is...


after

We'll use a function called after, which takes in a delay and a function, and then performs the function after the given delay. It looks like this:

after(2, function() print(1) end)

This simplifies the problem quite a bit because now we don't have to care about additional variables or updating things, we just say that we want this to happen and it will. In Lua this works fine because Lua allows for functions to be passed as arguments like this, but depending on your language this approach might be harder.

Either way, let's get into the details of implementing the after function. The high level way it will work is that we'll keep an internal table of all timers that have been registered (the after function could be called register too, and some timer libraries do call it something like that) and then we'll update all registered timers and perform their functions when appropriate.

timers = {}

function after(delay, action)
    table.insert(timers, {current_time = 0, delay = delay, action = action})
end

function update(dt)
    for _, timer in ipairs(timers) do
        -- update timer
    end
end

So here what we're doing is just that: adding a structure that contains the delay and the action to be performed to a timers table, and then updating each of those structures. What that update looks like would be something like this:

function update_timers(dt)
    for _, timer in ipairs(timers) do
        timer.current_time = timer.current_time + dt
        if timer.current_time > timer.delay then
            if timer.action then timer.action() end
        end
    end
end

And so in this way we have a simple after function that works. One simple example of it in use is this:

function Player:hit()
    self.just_hit = true
    after(1, function() self.just_hit = false end)
end

We have some kind of hit function that gets called when the player is hit, it sets some flag saying that the player was just hit to true, and then 1 second after that flag is set to false. This flag can then be used to make the player flash white, for instance.


Tags

One necessary thing that comes with this setup is a way to cancel existing timers. Think of what happens in the previous example when the player is hit once and then again after 0.3 seconds. The behavior we want is that just_hit is set to true again on the second hit, and 1 second after that second hit it's set to false, but what will actually happen is that as the 1 second is over for the first timer just_hit will be set to false, 0.3 seconds earlier than it should have been. Most of the time when I use timers in my game I actually want to cancel the previous timer that performs that same action.

We can solve this problem by identifying a timer by its tag (a string or number) and then whenever we add a new timer with a tag we automatically cancel any timer that already exists with the same tag:

function after(delay, action, tag)
    timers[tag] = {current_time = 0, delay = delay, action = action})
end

And so here we use the fact that tables in Lua can also be used as dictionaries and then we use the tag as the key, and the table we were creating previously as the value. Whenever we call this function with a tag defined we will overwrite the previous value set in timers[tag], and so we get our desired outcome. The way to call this now would be something like this:

function Player:hit()
    self.just_hit = true
    after(1, function() self.just_hit = false end, 'just_hit')

And so here this particular timer created in the player's hit function will always have the just_hit tag attached to it, meaning that any time a new timer is created here, a previous one that is currently running will be automatically cancelled.

There are all sorts of details to solve with this setup, like the fact that all tags have to be unique across multiple objects for this to work properly, or that tags can be optional so we have to change the after function slightly to deal with when the user doesn't provide a tag, and so on. I'm not sure if this is the ideal solution to this problem, but it's a problem that happens often enough and it's a solution that has worked well for me so far.


Repeatable after

Another type of behavior that's really useful is repeatable behavior. Say we want something to make the player flash really fast whenever he gets hit. One way to do it is being able to define repeatable behavior in the after function, something like this:

function Player:hit()
    self.just_hit = true
    after(0.04, function() self.just_hit = not self.just_hit end, true, 'just_hit')
end

And here the argument after the action is a boolean that will signal that the action should be repeated indefinitely. In this example, every 0.04 seconds just_hit will change between true and false, which means that in our draw function we can make the player appear and disappear for small amounts of time, which gives the desired "flash really fast" effect. In any case, the way this idea looks like implementation wise:

function after(delay, action, repeatable, tag)
    timers[tag] = {current_time = 0, delay = delay, action = action, repeatable = repeatable})
end

function update_timers(dt)
    for _, timer in pairs(timers) do
        timer.current_time = timer.current_time + dt
        if timer.current_time > timer.delay then
            if timer.action then timer.action() end
            if timer.repeatable then timer.current_time = 0 end    
        end
    end
end

Here the only differences are that we add the repeatable key to our timer structure, and then whenever we perform our action we check if this attribute is set to true, and if it is we set current_time to 0, which will restart the counter and make the action happen again after delay.

If we want to control how many times we should repeat an action we can make the repeatable attribute also be valid as a number, so, for instance, after(1, function() end, 5) would repeat the function every 1 second for a total of 5 times. The way this would look implemented would be something like this:

function update_timers(dt)
    for tag, timer in pairs(timers) do
        timer.current_time = timer.current_time + dt
        if timer.current_time > timer.delay then
            if timer.action then timer.action() end
            if timer.repeatable then 
                if type(timer.repeatable) == 'number' then
                    timer.repeatable = timer.repeatable - 1
                    if timer.repeatable >= 0 then timer.current_time = 0
                    else timers[tag] = nil end -- this timer is done, destroy it
                else timer.current_time = 0 end
            end    
        end
    end
end

Here the only additional thing we do is checking the type of the repeatable variable, if it is a number then we subtract 1 from it and then reset current_time, and if that number gets to 0 then we destroy this timer instead, since it has already repeated the action for the required amount of times. Destroying the timer can be as simple as doing timers[tag] = nil.

One last additional thing we can do is add an optional function that gets called when the repeatable timer is done. And so our function would have the following description after(delay, action, repeatable, after, tag), with the last 3 arguments being optional. One possible use of this, using the player hit example from above:

function Player:hit()
    self.just_hit = true
    after(0.04, function() self.just_hit = not self.just_hit end, 25, function() self.just_hit = false end, 'just_hit')
end

And so in this example the just_hit attribute will oscillate between true and false 25 times over 1 second (25*0.04), and then once that's done we'll make sure that it's set to false.


tween

Now for the next function which is the tween one. The way I've settled on this function generally is like this:

tween(2, self, {sx = 0, sy = 0}, 'cubic_in_out')

And so this will make the attributes self.sx and self.sy go to 0 over 2 seconds using the cubin_in_out tweening method. Because of the way Lua tables work we can very easily change attributes like this and so this became my preferred way of doing it. The implementation looks like this:

function tween(delay, target, source, method, after, tag)
    local initial_values = {}
    for k, _ in pairs(source) do initial_values[k] = target[k] end
    timers[tag] = {type = 'tween', current_time = 0, delay = delay, target = target, initial_values = initial_values, source = source, method = method, after = after, tag = tag}
end

The main difference here from the after function is that we need to get the initial values as they are on the source and store them in another table. So using the example above, initial_values will have the keys sx and sy as the values of self.sx and self.sy when the tween function was called. We need these initial values to perform the tween in the update function, which looks like this:

function update_timers(dt)
    for tag, timer in pairs(timers) do
        timer.current_time = timer.current_time + dt

        if timer.type == 'after' then
            ...
        elseif timer.type == 'tween' then
            local t = _G[timer.method](timer.current_time/timer.delay)
            for k, v in pairs(timer.source) do timer.target[k] = lerp(t, timer.initial_values[k], v) end
            if timer.current_time > timer.delay then
                if timer.after then timer.after() end
                timers[tag] = nil
            end
        end
    end
end

There are a number of things that are pretty Lua specific here so let's go step by step. The first line accesses _G[timer.method]. _G is Lua's global environment table, which holds a reference to all global variables. If you define a global function in Lua then you can also access it via the _G table. For instance, we just defined the tween function, and instead of calling tween(2, self, {sx = 0, sy = 0}, 'cubic_in_out') we could also do _G['tween'](2, self, {sx = 0, sy = 0}, 'cubic_in_out'). Those are exactly the same thing. Which means that the method attribute is being used as the name of some function we defined, in this case the tween method. In any tweening library there are numerous tween methods available, for instance, here's cubic_in_out:

function cubic_in_out(t)
    t = t*2
    if t < 1 then return 0.5*t*t*t
    else
        t = t - 2
        return 0.5*(t*t*t + 2)
    end
end

All these methods receive a value from 0 to 1 and return a value from 0 to 1 with some given curve being applied to it. The source code at the end of this article has all tween methods I defined, and you should be able to find these methods implemented in many languages. I got mine from this tween library which is written in Haxe, but this is pretty standard code that should be nearly the same across most tween library implementations.

In any case, we get the modified value from the function by passing in timer.current_time/timer.delay, which is how far along in our tween we are currently. For instance, if the delay is 5 and the current time is 2.5, then we currently are at 50%, which is 0.5 in a 0-1 range. After this we do the main work:

for k, v in pairs(timer.source) do timer.target[k] = lerp(t, timer.initial_values[k], v) end

Here we go over all keys and values in the source table, apply a lerp from the initial value of a particular key timer.initial_values[k] to the desired value v, and then apply that to timer.target[k].

So, for instance, in our previous example our source table was {sx = 0, sy = 0}, so first going through sx we'd be doing self.sx = lerp(t, initial value of sx, desired value of sx which is 0), and as t increases we get self.sx closer and closer to the desired value. We'd then repeat the same for sy given that we're going over all keys in timer.source. The code after this part simply runs a function after the tween is done if one is defined and that's it!


Examples

Having after and tween defined in this manner we can do lots of cool things with them. I'll go over some examples of how I've used them before.

Visualizations

In the previous article I wrote all visualizations are powered by the after function. This is what the visualization looks like:

And so, for instance, the code to generate the first part this (the physics circles) looks like this:

for i = 1, #radii do 
    create_physics_circle(game_width/2 + 4*(2*love.math.random()-1), game_height/2 + 4*(2*love.math.random()-1), table.remove(radii, love.math.random(1, #radii))) 
end

And if the code is left like this it simply generates all circles at the same time. But with a very small change like this:

for i = 1, #radii do 
    after((i-1)*0.05, function()
        create_physics_circle(game_width/2 + 4*(2*love.math.random()-1), game_height/2 + 4*(2*love.math.random()-1), table.remove(radii, love.math.random(1, #radii)))
    end) 
end

We're making it so that we generate one circle every 0.05 seconds. And that looks like this:

All the next steps follow the same idea, where I simply wrapped action being done into an after call that is offset properly based on the index of the for loop. The fact that it's so easy to do this also means it's a great way to debug these algorithms that can get a bit opaque and blackboxy after a while.


Event scale

These functions are very useful when juicing up your game in general but one particular use that's easy to get across is scaling entities on important enough events. The way it goes is roughly like this:

function Player:hit()
    self.sx, self.sy = 1.4, 1.4
    tween(0.15, self, {sx = 1, sy = 1}, 'cubic_in_out', nil, 'hit_scale')
end

The player's sx and sy variables are naturally at 1, since the player isn't being scaled in any way by default. But when he gets hit, they will go up to 1.4 and then decay down back to 1 over a small amount of time, like 0.15 seconds. This gives the hit a bit more oomph and makes it feel nicer. The same idea can be applied to when the player attack, or when enemies get hit, or when a new item is acquired, and so on.


Chained timers

Some more complicated uses involve lots of chained timers and cancelling of previous timers on weird conditions and so on. About 2 years ago I was playing around with doing some animations using timers only and one example of it can be seen here:

This looks fairly simple but the code for it looks something like this:

And it just keeps going and going. But as you can see, there's a fair bit of use of the after and tween functions to do various things. If I were to do this again today I'd probably not choose this route but it shows that these two functions alone can create some OK things with some creative use.


Source code

The source code for what was implemented in this article can be found here and it's implemented in Lua. Most of these ideas can carry over to most modern languages without significant drawbacks, but for some languages you might wanna choose something different altogether because the drawbacks outweigh the benefits. Good luck!

@a327ex a327ex changed the title Timer and tween functions in Lua Timer and Tween Functions in Lua May 6, 2019
@hfr4
Copy link

hfr4 commented May 7, 2019

One bug I had using tweens like this is that the tweening value never really reach the target value. It's problematic with cyclics behaviors :

local platform = {x = 1000}
after(6, function() 
    tween(3, platform,{x = platform.x +500}, 'in-out-cubic', function()
        tween(3, platform,{x = platform.x -500}, 'in-out-cubic')
    end)
end, true, "moving_p")

platform.x will shift unexpectedly .

What I did to prevent that is simply to make the tweening value equal to the target value when the tween end.

Also to prevent nesting functions for complicated behaviors you can use coroutines (
https://github.com/airstruck/knife/blob/master/readme/chain.md,
https://github.com/airstruck/knife/blob/master/readme/convoke.md
)

Thanks for the nice article :)

@nvcken
Copy link

nvcken commented May 9, 2019

�Would you please tell how to run this code ?

@volumia
Copy link

volumia commented May 17, 2019

@nvcken This is designed for Lua, typically in the Love2D environment.

@eliasdaler
Copy link

I think that my article about making cutscenes can be somewhat relevant here.
Basically, you can simplify your code even further if you use coroutines. With them, it's possible to write code like this:

delay(0.5)
cat:goTo(meetingPoint)
delay(0.25)
-- do something else

You can even have parallel actions happening like this (especially useful if you have multiple parts moving at once):

function cutscene(cat, girl, meetingPoint)
  local c1 = coroutine.create(
    function()
      cat:goTo(meetingPoint)
    end)

  local c2 = coroutine.create(
    function()
      girl:goTo(meetingPoint)
    end)

  c1.resume()
  c2.resume()

  -- synchronization
  waitForFinish(c1, c2)

  -- cutscene continues
  cat:say("meow")
  ...
end

@a327ex
Copy link
Owner Author

a327ex commented May 28, 2019

@eliasdaler hump.timer seems to handle this in a fairly straightforward manner (https://hump.readthedocs.io/en/latest/timer.html#Timer.script, https://github.com/vrld/hump/blob/master/timer.lua#L96) and something like that could be added here:

function Timer:script(f)
	local co = coroutine.wrap(f)
	co(function(t)
		self:after(t, co)
		coroutine.yield()
	end)
end

I just personally never ended up using this myself because the after and tween functions seemed enough, but I guess I could try to integrate this more into my habits because it seems like it would help with the more complicated examples (like the chained timers one).

@eliasdaler
Copy link

Yeah, it's a similar idea, I see.

I like coroutines, because they allow you to wrap even more complex actions in coroutines, e.g. "goTo" executes until the entity arrives at destination. "say" shows dialogue box until you press a button, and so on. The possibilities are endless. :)

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

5 participants