Class 11 - Cutscenes and Coroutines
Play homework
Topics
- Animations in Pico-8
- Loading scenes, cutscenes
- Coroutines
Description
By this point we’ve covered a lot. We’ve even made a few games. Now let’s take our knowledge and spice up our games a bit, through creating opening screens and cutscenes.
Class notes
Cutscenes and Coroutines
Want to spice up your game with animated scene between bouts of gameplay? Cutscenes are uncommon on small platforms like PICO-8. There’s no room for full motion video, so a scene must be described in code or with some kind of cutscene engine. This is made much easier with a Lua feature added in PICO-8 v0.1.6: coroutines.
We’ll take a look at coroutines, see how they simplify the code behind animated events, and design an intuitive way to implement cutscenes using this feature.
Animation in a nutshell
Animation is a series of images displayed over time to give the illusion of movement or change in a scene. With full motion video, each image shows the entire scene at a moment in time, and displaying the animation just means showing the right image at the right time. In a game, especially on a small platform, a scene is better represented as a collection of objects, each with their own positions and image sequences. This uses less memory and allows each object to be defined and drawn separately. And of course in a game, each object may have interactive or dynamic behaviors that change the scene based on player input or other patterns.
Here is a trivial example of a planet orbiting a flickering sun:
t = 0 -- planet arc pos
dt = 1/72 -- arc change per frame
r = 32 -- orbit radius
function _update()
-- update planet position
t += dt
if (t > 1) t = 0
-- update sun color
sun_color = 9+flr(rnd(2))
end
function _draw()
cls()
-- draw sun
circfill(63, 63, 7, sun_color)
-- draw planet
x = cos(t) * r + 60
y = sin(t) * r + 60
circfill(x, y, 3, 8)
end
Simple animations like these require storing minimal state for each object. We calculate the planet’s position based on its previous position, stored in the variable t
. The sun’s color is determined randomly with no relationship to the previous frame, and so maintains no state between frames at all.
A space invaders game may have more objects on the screen, but it can use similarly simple techniques for animating each object. For example, you might have a Lua table for each alien with properties for its position, movement (such as sideways velocity), and which sprite to use to draw it. As with this example, the _update()
function updates all of the tables based on a simple set of rules, and the _draw()
function draws the aliens based on their table values, once for each frame of animation.
What is a cutscene?
A cutscene is an extended sequence of animated effects that takes place during a break in gameplay. It typically does not involve user interaction, except perhaps to advance or terminate the scene by pressing a button. It may involve multiple objects doing various things at various points in the sequence. At a given moment, an avatar of a character may be moving its mouth or emoting, while dialog text is displayed a few letters at a time, with looping animations in the background. The full scene consists of a sequence of these moments.
To implement a cutscene system, we must answer two important questions:
- How do we represent the state of the cutscene at a given moment in time?
- How do we describe the sequence of states in our code?
This is going to be more complicated than calculating each object’s next state from its state in the previous frame. What we need is a mechanism that manages the general flow of events as well as executes the events themselves over multiple frames.
One way to do this is with a sequence table with one value for each state and an index variable that tracks which state we’re in. Each value describes a primitive event, such as moving an object across the screen or displaying a line of dialog. A separate set of code and variables for each event type figures out what needs to be done for each frame.
scene_one = {
{'actor_walk', 'boris', -10, 40, 30, 40},
{'actor_walk', 'natasha', 138, 40, 70, 40},
{'dialog', 'boris', 'look natasha, moose and squirrel have the bag of money!'},
{'dialog', 'natasha', 'yes! you hit moose with mallet and i will grab the bag.'},
{'dialog', 'boris', 'first i will turn off the light switch.'},
{'actor_walk', 'boris', 30, 40, 40, 10},
{'lightswitch_toggle', 'boris'}
}
scene_pos = 1
Notice that each event needs to maintain its own state information between frames, such as the position and velocity of an object on the screen. The event table itself is effectively a small and rather arcane programming language of our own design, and the cutscene engine is its interpreter.
There’s another way to describe events and maintain the event state we need. But first, we must take a closer look at functions.
Functions and memory
We mostly think of functions as a way to group instructions into named units. When a program calls a function, those instructions are executed as if you typed them all at the place in the code where the function is called. We can call the function from multiple places in our program. We can also adjust the behavior of the function for each call by passing in arguments. When the instructions are complete, the function returns control (and possibly a result value) back to the program.
When the program calls a function, Lua allocates some memory to keep track of the function’s local variables while it is running. When the function is done executing and its return value (if any) is calculated, Lua deallocates this memory, and no trace of the function call remains.
The PICO-8 game loop requires that the _update()
and _draw()
functions return before the frame can be drawn. Naturally, this means that any functions called by _update()
or _draw()
return as well. If a function wants to update the game state, that state must be stored in a global variable for it to persist between frames—or even between the calls to _update()
and _draw()
. A function call can’t last longer than a frame.
Here is an excerpt of a simple cutscene engine that uses global variables to implement the 'actor_walk'
events from the previous example. walk_actor()
is called once for each _update()
, and it uses a global frame counter to measure each walk action.
walk_count = 0
walk_total = 30
actors = {
boris={x=-8, y=-8, base_sprite=16, cur_sprite=16},
natasha={x=-8, y=-8, base_sprite=18, cur_sprite=18}
}
function walk_actor(event)
local actor = actors[event[2]]
local from_x = event[3]
local from_y = event[4]
local to_x = event[5]
local to_y = event[6]
actor.x = from_x + (to_x - from_x) * (walk_count / walk_total)
actor.y = from_y + (to_y - from_y) * (walk_count / walk_total)
actor.cur_sprite = actor.base_sprite + (walk_count % 2)
end
function _update()
if scene_one[scene_pos][1] == 'actor_walk' then
walk_actor(scene_one[scene_pos])
walk_count += 1
if walk_count == walk_total then
scene_pos += 1
walk_count = 0
end
-- ...
end
end
function _draw()
cls()
for name,actor in pairs(actors) do
spr(actor.cur_sprite, actor.x, actor.y)
end
end
There’s plenty more to say about how functions can interact with data, such as object methods and lexical scoping. For now, it’s sufficient to say that functions aren’t expected to hold on to their own state: they update state outside of themselves, then get out of the way.
Coroutines
A coroutine is a function with the ability to pause itself and yield control back to the caller. While paused, all of the coroutine’s local state remains in memory. The caller can do other work then resume the coroutine to pick up where it left off. The coroutine and the caller can bounce control between each other as many times as they like until the coroutine terminates.
To define a coroutine, simply define a function. In the place where you want the function to yield, call the yield()
function.
In the main code where you want to use the coroutine, call the built-in function cocreate()
, passing in the function as its sole argument. This returns an object that keeps track of this invocation of the coroutine.
To invoke or resume the coroutine, call coresume()
with the coroutine object. You can pass additional arguments to coresume()
and they will be passed as arguments to the function on first invocation. On subsequent invocations, the additional arguments are passed to the coroutine function as the return value from the call to the yield()
function. (You can ignore them if you don’t need them.)
The costatus()
method returns the status of the coroutine. It takes the coroutine object as its argument and returns a string, either 'suspended'
or 'dead'
. A coroutine dies when its function returns instead of yields.
The following example moves a ball across the screen when you press button 5. The ball stops at the end of its path.
b = {x=4, y=4}
cor = nil
function anim(ball)
for i = 4,124,4 do
ball.x = i
ball.y = i
yield()
end
end
function _update()
if btnp(5) then
cor = cocreate(anim)
end
if cor and costatus(cor) != 'dead' then
coresume(cor, b)
else
cor = nil
end
end
function _draw()
cls()
circfill(b.x, b.y, 4, 7)
end
When you press button 5, _update()
creates a new coroutine based on the anim()
function and stores it in a global variable. As long as the coroutine is active, _update()
resumes it once per frame. The coroutine updates the position of the ball by one step, then yields. After the last update, anim()
’s for loop exits, and the function returns, killing the coroutine.
Notice that the state of the ball animation is maintained in the paused coroutine, in the local variable i
. It also keeps its own reference the ball location object, passed to it by the initial call to coresume()
. Even though this is also stored in the global variable b
so that _update()
and _draw()
can share it, the coroutine doesn’t need to access the global, because it keeps its own local reference (ball
).
One more thing about coroutines that will prove useful: if a coroutine calls another function, that function can call yield()
, and the entire coroutine will yield at that point. Yielding preserves the entire call stack and all local variables for functions in progress. This lets you write functions that yield that are used by coroutines but are not necessarily coroutines themselves. We will use this later.
Caveat: coroutines hide runtime errors
A word of caution before we proceed. As of Pico-8 v0.1.10, if a runtime error occurs during a coroutine, the coroutine exits but execution continues normally from the call to coresume()
instead of halting the program. This is problematic because some kinds of coding errors, such as incorrect nil
values, manifest as runtime errors. If your coroutine code has such an error, you won’t see the usual error report, and instead will see weird behavior.
Take care when writing code used inside coroutines. If possible, test the code outside of the coroutine before relying on it.
Building a cutscene engine
Our coroutine-based cutscene engine has one master coroutine used by _update()
and _draw()
to indicate that a scene is taking place. This is stored in a global. If this is nil
, then there is no cutscene in progress.
scene_update_cor = nil
The coroutine updates a data structure describing everything that needs to be drawn to the screen. _update()
drives the master coroutine, and _draw()
renders the draw list. Each drawobj
object can have its sprite number, tile width and height, position, and color palette modified during a cutscene.
-- a list of drawobj
scene_draw_list = nil
-- initial values for drawobj properties
drawobj = {
n = 0, -- the sprite number (upper left)
w = 1, -- the width in sprites
h = 1, -- the height in sprites
x = 0, -- the x coord
y = 0, -- the y coord
pals = {} -- a list of lists of arguments to pal() to use before drawing
}
-- creates a new drawobj
function drawobj:new(o)
o = o or {}
setmetatable(o, self)
self.__index = self
return o
end
-- draws the drawobj (called by _draw() when in scene mode)
function drawobj:draw()
foreach(pals, pal)
spr(self.n, self.x, self.y, self.w, self.h)
pal()
end
For this demo, the game loop starts a cutscene, then allows the player to restart it by pressing a button. Normally this is where the main game would go.
function _init()
start_scene(sc_opening)
cur_level = 0
end
function _update()
if scene_update_cor then
if costatus(scene_update_cor) != 'dead' then
coresume(scene_update_cor)
else
scene_update_cor = nil
end
else
if btnp(5) then
start_scene(sc_opening)
end
end
end
function _draw()
cls()
if scene_update_cor then
for i=1,#scene_draw_list do
scene_draw_list[i]:draw()
end
else
print('press x to replay', 0, 0, 7)
end
end
The start_scene()
function is where things get interesting. It takes as an argument a function (such as sc_opening
) that defines a cutscene. start_scene()
creates a new coroutine for this function and assigns it to the scene_update_cor
global. It also clears the draw list.
function start_scene(f)
scene_update_cor = cocreate(f)
scene_draw_list = {}
end
You can now see what a scene function does: it creates new drawobj
objects, puts them in the scene_draw_list
, manipulates their parameters, and yields once per frame. When the scene is over, it just returns.
Remember that I mentioned that a coroutine can call other functions that yield? This allows us to write a library of animation primitives, then call them from the scene function.
-- waits t frames
function sch_delay(t)
for c=1,t do
yield()
end
end
-- places a drawobj at a location, adding it to the scene if needed
function sch_place_obj(obj, x, y)
del(scene_draw_list, obj)
obj.x = x
obj.y = y
add(scene_draw_list, obj)
end
-- moves a drawobj linearly to a new location over t frames
-- obj must be placed first
function sch_move_obj_lin(obj, destx, desty, t)
local incx = (destx - obj.x) / t
local incy = (desty - obj.y) / t
for i=1,t do
obj.x += incx
obj.y += incy
yield()
end
end
-- removes a drawobj from the scene, if it is present
function sch_remove_obj(obj)
del(scene_draw_list, obj)
end
function sc_opening()
o1 = drawobj:new({n=1})
sch_place_obj(o1, 0, 0)
sch_move_obj_lin(o1, 120, 120, 60)
sch_move_obj_lin(o1, 0, 0, 60)
sch_delay(20)
sch_remove_obj(o1, 0, 0)
end
The scene function now looks like a movie script! Even though the actual control flow returns to _update()
once per frame, we can use simple Lua function calls to describe every event in the scene, in sequence.
These helper functions can get much more sophisticated. They can loop animations, move objects along paths or with easing, play sounds or start music. A helper function could do animated dialog, and could even listen for a button press to display text faster ala RPG-style dialog.
Multi-track animations
That scene function is nice and all, but it can only animate one object per helper call. What if we want multiple objects going at the same time, possibly on different scripts running simultaneously? Coroutines to the rescue!
function do_scene(tracks)
local cors = {}
for tfunc in all(tracks) do
add(cors, cocreate(tfunc))
end
while #cors > 0 do
for t in all(cors) do
if costatus(t) != 'dead' then
coresume(t)
else
del(cors, t)
end
end
yield()
end
end
function sc_opening()
o1 = drawobj:new({n=1})
o2 = drawobj:new({n=2})
do_scene(
{
function()
sch_place_obj(o1, 0, 0)
sch_move_obj_lin(o1, 120, 120, 60)
sch_move_obj_lin(o1, 0, 0, 60)
sch_delay(20)
sch_remove_obj(o1, 0, 0)
end,
function()
sch_place_obj(o2, 120, 0)
sch_move_obj_lin(o2, 0, 120, 90)
sch_move_obj_lin(o2, 120, 0, 90)
sch_delay(60)
end
})
end
The do_scene()
driver takes a list of inner scene functions, one per “track,” then executes them in tandem. For each frame of the master scene, it updates each track by one frame. It accomplishes this using an inner list of coroutines based on the track functions. Each track is allowed to finish on its own schedule. The scene is over when all tracks have completed.
As we’ve seen, coroutines let us think about control flow in a new way that works especially well for animated sequences like cutscenes. They are similarly useful in gameplay situations for one-off multi-frame animation effects such as explosions and particle effects, or as a driver for background action.
P.S. Check out geckojsc’s RPG dialog demo, which uses coroutines in a similar way: http://www.lexaloffle.com/bbs/?tid=3833
Links
Code homework
For homework this week you will begin working on your final class project
- Start in your notebook with sketching.
- What is the genre(s)?
- What is the goal?
- What are the game mechanics? (movement, controls, special functions, lose state, attacks, etc)
- Sketch sprites and the character
- Draw a flowchart of your basic game loop
- If you have thoughts on the title, write it down
- Note down any questions
Credits
- Cutscenes and Coroutines (c) Dan Sanderson 2015 CC BY SA
- Pico-8 wiki CC BY SA
- Flags for Friends by brook.p8
- Poom by freds72