Tweens++: Timelines in Janet
My last post was about Easing functions, which are a great way to map the change of numbers in a pleasing way. However, astute readers may have noticed that I left anything related to actually managing the "percent" of a lerp in your game! Let's design a system that not only cleanly tracks a tween to completion over time, but is also generic enough to do any sort of sequence of events!
We’ll start from where we left off and build the naive solution. Just have a percent
variable that will slowly increment from 0 to 1. All the other arguments to our lerp
function can be constants!
(var percent 0)
(var speed 0.1)
(var lerped-value 0)
(defn TIC []
(+= percent speed)
(set lerped-value (lerp 0 100 percent in-quad)))
SIDENOTE: All of my examples here will be frame rate dependent. In TIC-80, the TIC
function is defined by the spec to execute 60 times a second. Of Course that can't be really enforced on every computer with every horrible nested for loop written, but I write simple 2D games running at a low resolution. That means things are usually "good enough" to run at 60fps.
There are a couple of drawbacks to this approach. We're not doing anything to handle the completion of the ease. What should we do when percent reaches 1? Also, what if we want to ease a bunch of values at once? We would need to define a unique percent
variable for each value... that feels wrong. What I really want to be able to do is tell the computer "ease this variable at this speed", and not have to worry about any ad-hoc variables.
What if we could create a function that let us stop where we were in a function, wait for the next frame of our game, and resume where we left off? If we had that we could re-write the example above to look like this..
(var lerped-value 0)
(defn TIC []
(set lerped-value (lerp 0 100 0.1 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.2 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.3 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.4 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.5 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.6 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.7 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.8 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 0.9 in-quad))
(wait-for-next-frame)
(set lerped-value (lerp 0 100 1.0 in-quad)))
That looks really gross, but we were able to remove the need for both the percent
and speed
variables. That's kinda neat! Plus with a loop things start to look ok again.
# The core API "range" only counts by integers.. which is unfortunate
(defn drange [start stop step]
(seq [i :range [start (/ stop step)]]
(* i step)))
(defn TIC []
(each percent (drange 0 1 0.1)
(set lerped-value (lerp 0 100 percent in-quad))
(wait-for-next-frame)))
Well it just so happens Janet has a wait-for-next-frame
function, it's just called yield
! What we have been dancing around is the idea of "asynchronous" programming, where we can stop and resume the execution of code! Janet provides this through its Fiber API. A fiber
is a core datatype of Janet, and (among other things) holds all the state related to where you last stopped, where you should resume, and whether there's anything else left to do.
Let's rewrite our code to use Janet fibers.
# a fiber wraps a function
(def lerping-fiber
(fiber/new
(fn []
(each percent (drange 0 1 0.1)
(set lerped-value (lerp 0 100 percent in-quad))
(yield)))))
(defn TIC []
# only resume the fiber if it has more to do
(when (fiber/can-resume? lerping-fiber)
(resume lerping-fiber)))
With this we've basically solved all our problems! When the fiber is done we know the lerping of our value is all done. We also don't need to track independent percent
variables all over the place!
Ugh but wait... Now we have a new variable lerping-fiber
! That's ok, we can clean that up by keeping a list of all the active fibers. We’ll name this list *TIMELINES*
since as you’ll see we can use fibers to really sequence any sort of timeline of events in our game, not just tweens. Also, because I like to live dangerously I'm going to make this list a global variable.
SIDENOTE: I have a personal convention of naming global variables in all caps. I also by convention wrap mutable global variables in *
's. There's nothing special about the name.
We'll also define a function that loops through all our fibers, resumes incomplete ones, and deletes complete ones from the list. I learned a clever trick from Daniel Shiffman's book the Nature of Code that by iterating through the list backwards you can delete entries from the list without worry about accidentally skipping entries.
(def *TIMELINES* @[])
(defn push-timeline [fb] (array/push *TIMELINES* fb))
(defn update-timelines []
# iterate backwards for stable deletion
(loop [i :in (range (length *TIMELINES*) 0 -1)
:let [fb (*TIMELINES* (dec i))]]
(if (fiber/can-resume? fb)
(resume fb)
(array/remove *TIMELINES* (dec i)))))
Here's our example using the timeline system.
(push-timeline
(coro # just a shorthand for (fiber/new (fn [] ...))
(each percent (drange 0 1 0.1)
(set lerped-value (lerp 0 100 percent in-quad))
(yield))))
(defn TIC-80
(update-timelines))
I tend to use lerps in my games a lot, so it's worth going one step further and creating a shorthand for the pattern above. interpolate
isn't the best name, but it's what I'm currently using. Also up until this point we've been using "percent" as our loop variable. However now I'm going to switch to specifying the number of seconds I want the interpolation to take. As stated above I assume 60fps.
(defmacro interpolate [accessor start end seconds func]
(def end-i (* 60 seconds))
(with-syms [$start $end]
~(let [,$start ,start ,$end ,end]
(for i 0 ,end-i
(set ,accessor (lerp ,$start ,$end (/ i ,end-i) ,func))
(yield)))))
I chose to use a macro since I wanted to be able to use object accessors like (my-obj :key)
in the definition. The macro inlines the accessor s-expression into the fiber function definition, instead of evaluating it. I also take care to "lock in" the start and stop in case those come from accessors. All of that means you can do stuff like this without problems.
(push-timeline (coro (interpolate (pizza :pos) # we're lerping the pos key
(pizza :pos) # starting from wherever pizza is
[100 100] # to the position [100 100]
2.2 # duration in seconds
in-quad)))
Fibers can also be nested, which means you can easily define complex cutscenes. Go Crazy!
# do nothing for x number of ticks. useful for cutscenes
(defn wait [seconds]
(repeat (* 60 seconds) (yield)))
(push-timeline
(coro
(interpolate (pizza :hotdog) 0 5 0.1 in-linear)
(wait 1)
(tic80/sfx 1)
(wait 0.5)
(interpolate (pizza :hotdog) 5 2 0.1 in-linear)))
This may have seemed like a lot, but the end result is a tight ~30 lines of code.
Complete code for copy/paste pleasure.
# Timeline Sequencer, using fibers
(defn wait [seconds]
(repeat (* 60 seconds) (yield)))
(defn drange [start stop step]
(seq [i :range [start (/ stop step)]]
(* i step)))
(def *TIMELINES* @[])
(defn push-timeline [fb]
(array/push *TIMELINES* fb))
(defn update-timelines []
# iterate backwards for stable deletion
(loop [i :in (range (length *TIMELINES*) 0 -1)
:let [fb (*TIMELINES* (dec i))]]
(if (fiber/can-resume? fb)
(resume fb)
(array/remove *TIMELINES* (dec i)))))
(defmacro interpolate [accessor start end seconds tween-fn]
(def end-i (* 60 seconds))
(with-syms [$start $end]
~(let [,$start ,start ,$end ,end]
(for i 0 ,end-i
(set ,accessor (lerp ,$start ,$end (/ i ,end-i) ,tween-fn))
(yield)))))
And here's an example in TIC-80 showcasing a simple repeating timeline.
Source here
# ... insert tweens and timelines code here ...
(defn round [v] (tuple ;(map math/round v))) # stolen from vectors library
(def ball @{:pos [10 10] :color 8 :radius 8})
(defn start []
(push-timeline
(coro
(put ball :color 8)
(interpolate (ball :pos) (ball :pos) [200 10] 2 out-quad)
(push-timeline (coro (interpolate (ball :radius) 8 12 3 in-out-sine)))
(put ball :color 9)
(interpolate (ball :pos) (ball :pos) [200 100] 1 in-linear)
(wait 1)
(put ball :color 10)
(interpolate (ball :pos) (ball :pos) [10 100] 1.5 in-out-quad)
(push-timeline (coro (interpolate (ball :radius) 12 8 3 in-out-linear)))
(wait 0.5)
(put ball :color 11)
(interpolate (ball :pos) (ball :pos) [10 10] 0.5 in-sine)
(wait 1.5))))
(defn INIT []
(start))
(defn TIC []
(update-timelines)
(when (empty? *TIMELINES*) (start))
(tic80/cls 1)
(tic80/circ ;(round (ball :pos)) (math/round (ball :radius)) (ball :color)))
Fibers are a super powerful way to write your code sequentially but execute it asynchronously. I'm glad to finally have a solid grasp of how they work, and can already think of other places they will be useful. I may even attempt a refactor of my TIC-80 textbox.
UPDATE: Sep 9, 2024
: I fixed a typo in the first interpolate example, which needed to be wrapped in coro
.