Animations
In this section we are going to look at adding animations to our game, we'll start with some basic ones but feel free to add more complex ones given the ideas in this tutorial. We'll add two animations: making the player blink and making the boxes jiggle slightly in place.
What is an animation?
An animation is simply a set of frames played at a specific time interval that gives the illusion of movement. Think of it like a video (a video is just a set of images played in sequence), but much lower framerate.
For example, to get our player blinking we'll have three animation frames:
- our current player with the eyes open
- player with eyes a little bit closed
- player with eyes completely closed
If we play these three frames in sequence you'll notice it looks like the player is blinking. You can try this out by opening the images and shifting between them quickly on the image preview.
There are a few gotchas on this:
- the assets need to be done with a specific framerate in mind - for us we will go with 250 milliseconds, meaning we will play a new animation frame every 250ms, so we will have 4 frames per second
- the assets need to be consistent with each other - imagine we had two types of players which had different assets and different looking eyes, we would have to make sure that when we create the three frames mentioned above they would be consistent, otherwise the two players would blink at different rates
- designing assets for a lot of frames is a lot of work, so we'll try to keep our animations quite simple and stick to the key frames
How will it work?
So how is this going to work in our existing Sokoban game? We'll have to:
- Change our renderable component to allow multiple frames - we could also create a new renderable component that handles animated renderables and keep the one we have for static renderables, but it feels a bit cleaner to keep them together for now
- Modify the player entity construction to take multiple frames
- Keep track of time in our rendering loop - we'll discuss this one in more detail so don't worry if it's not obvious why we need to do this
- Change the rendering system taking into account the number of frames, the time and the frame that is supposed to be rendered at a given time
Assets
Let's add the new assets for the player, it should then look like this. Notice we created a convention to name the frames sequentially, this is not strictly necessary, but it will help us keep track of the order easily.
├── resources
│ └── images
│ ├── box_blue.png
│ ├── box_red.png
│ ├── box_spot_blue.png
│ ├── box_spot_red.png
│ ├── floor.png
│ ├── player_1.png
│ ├── player_2.png
│ ├── player_3.png
│ └── wall.png
Renderable
Now let's update our renderable component to receive multiple frames, instead of having a single path, we'll have a list of paths, this should be pretty straightforward.
Let's also add two new functions to construct the two types of renderables, either with a single path or with multiple paths. These two functions are associated functions, because they are associated with the struct Renderable
, but they are the equivalent of static functions in other languages since they don't operate on instances (notice they don't receive &self
or &mut self
as the first argument, which means we can call them in the context of the struct not an instance of the struct). They are also similar to factory functions, since they encapsulate the logic and validation required before actually constructing an object.
MORE: Read more about associated functions here.
#![allow(unused)] fn main() { // components.rs pub struct Renderable { paths: Vec<String>, } }
Next, we need a way of telling if a renderable is animated or static, which we will use in the rendering system. We could leave the paths member variable public and allow the rendering system to get the length of the paths and infer based on the length, but there is a more idiomatic way. We can add an enum for the kind of renderable, and add a method on the renderable to get that kind, in this way we encapsulate the logic of the kind within the renderable, and we can keep paths private. You can put the kind declaration anywhere in the components.rs, but ideally next to the renderable declaration.
#![allow(unused)] fn main() { // components.rs pub enum RenderableKind { Static, Animated, } }
Now let's add a function to tell us the kind of a renderable based on the internal paths.
#![allow(unused)] fn main() { // components.rs pub fn kind(&self) -> RenderableKind { match self.paths.len() { 0 => panic!("invalid renderable"), 1 => RenderableKind::Static, _ => RenderableKind::Animated, } } }
And finally, because we made paths private, we need to allow users of renderable to get a specific path from our list. For static renderables, this will be the 0th path (the only one) and for animated paths we'll let the rendering system decide which path should be rendered based on the time. The only tricky bit here is if we get asked for a frame bigger than what we have, we will wrap that around by modding with the length.
#![allow(unused)] fn main() { // components.rs pub fn path(&self, path_index: usize) -> String { // If we get asked for a path that is larger than the // number of paths we actually have, we simply mod the index // with the length to get an index that is in range. self.paths[path_index % self.paths.len()].clone() } }
Finally, we add a way to construct renderables based on one or more paths.
#![allow(unused)] fn main() { // components.rs pub fn new_static(path: &str) -> Self { Self { paths: vec![path.to_string()], } } pub fn new_animated(paths: Vec<&str>) -> Self { Self { paths: paths.iter().map(|p| p.to_string()).collect(), } } }
Entity creation
Next up, let's update our player entity creation to account for multiple paths. Notice now we are using the new_animated
function to construct the renderable.
#![allow(unused)] fn main() { // entities.rs pub fn create_box(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_animated(vec![ &format!("/images/box_{}_1.png", colour), &format!("/images/box_{}_2.png", colour), ]), Box { colour }, Movable {}, )) } pub fn create_box_spot(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable::new_static(&format!("/images/box_spot_{}.png", colour)), BoxSpot { colour }, )) } }
And let's update everything else to use the new_static
function - here is how we are doing it for the wall entity creation, feel free to go ahead and apply this to the other static entities.
#![allow(unused)] fn main() { // entities.rs use crate::components::*; use hecs::{Entity, World}; pub fn create_wall(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_static("/images/wall.png"), Wall {}, Immovable {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable::new_static("/images/floor.png"), )) } // ANCHOR: create_box pub fn create_box(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_animated(vec![ &format!("/images/box_{}_1.png", colour), &format!("/images/box_{}_2.png", colour), ]), Box { colour }, Movable {}, )) } pub fn create_box_spot(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable::new_static(&format!("/images/box_spot_{}.png", colour)), BoxSpot { colour }, )) } // ANCHOR_END: create_box pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_animated(vec![ "/images/player_1.png", "/images/player_2.png", "/images/player_3.png", ]), Player {}, Movable {}, )) } pub fn create_gameplay(world: &mut World) -> Entity { world.spawn((Gameplay::default(),)) } // ANCHOR: create_time pub fn create_time(world: &mut World) -> Entity { world.spawn((Time::default(),)) } // ANCHOR_END: create_time }
Time
Another component we will need for this is keeping track of time. What does time have to do with this and how does this connect with frame rate? The basic idea is this: ggez controls how often the rendering system gets called, and this depends on the frame rate which in turn depends on how much work we are doing on every iteration of the game loop. Because we don't control this, in the span of a second we could get called 60 times or 57 times or maybe even 30 times. This means we cannot base our animation system on the framerate, and instead we need to keep it based on time.
Because of this we need to keep track of the delta time - or how much time passes between the previous loop and the current loop. And because the delta time is much smaller than our animation frame interval (which we have decided on 250ms), we need to keep the cumulative delta - or how much time has passed since the beginning of the game being launched.
MORE: Read more about delta time, frame rate and game loops here, here or here .
Let's now add a resource for time, this doesn't fit into our component model since time is just some global state that needs to be kept.
#![allow(unused)] fn main() { // components.rs }
And now let's update this time in our main loop. Luckily ggez provides a function to get the delta, so all we have to do is accumulate it.
#![allow(unused)] fn main() { // main.rs fn update(&mut self, context: &mut Context) -> GameResult { // Run input system { systems::input::run_input(&self.world, context); } // Run gameplay state { systems::gameplay::run_gameplay_state(&mut self.world); } // Get and update time resource { let mut query = self.world.query::<&mut crate::components::Time>(); let mut time = query.iter().next().unwrap().1; time.delta += timer::delta(context); } Ok(()) } }
Rendering system
Now let's update our rendering system. We will get the kind from the renderable, if it's static we simply use the first frame, otherwise we figure out which frame to get based on the delta time.
Let's first add a function to enapsulate this logic of getting the correct image.
#![allow(unused)] fn main() { // rendering_system.rs pub fn get_image(context: &mut Context, renderable: &Renderable, delta: Duration) -> Image { let path_index = match renderable.kind() { RenderableKind::Static => { // We only have one image, so we just return that 0 } RenderableKind::Animated => { // If we have multiple, we want to select the right one based on the delta time. // First we get the delta in milliseconds, we % by 1000 to get the milliseconds // only and finally we divide by 250 to get a number between 0 and 4. If it's 4 // we technically are on the next iteration of the loop (or on 0), but we will let // the renderable handle this logic of wrapping frames. ((delta.as_millis() % 1000) / 250) as usize } }; let image_path = renderable.path(path_index); Image::from_path(context, image_path).unwrap() } }
And finally, let's use the new get_image
function inside the run function (we will also have to add time to the SystemData
definition and a couple of imports, but that should be pretty much it).
#![allow(unused)] fn main() { // rendering.rs pub fn run_rendering(world: &World, context: &mut Context) { // Clearing the screen (this gives us the background colour) let mut canvas = graphics::Canvas::from_frame(context, graphics::Color::from([0.95, 0.95, 0.95, 1.0])); // Get time let mut query = world.query::<&Time>(); let time = query.iter().next().unwrap().1; // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut query = world.query::<(&Position, &Renderable)>(); let mut rendering_data: Vec<(Entity, (&Position, &Renderable))> = query.into_iter().collect(); rendering_data.sort_by_key(|&k| k.1 .0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (_, (position, renderable)) in rendering_data.iter() { // Load the image let image = get_image(context, renderable, time.delta); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; // draw let draw_params = DrawParam::new().dest(Vec2::new(x, y)); canvas.draw(&image, draw_params); } // Render any text let mut query = world.query::<&Gameplay>(); let gameplay = query.iter().next().unwrap().1; draw_text(&mut canvas, &gameplay.state.to_string(), 525.0, 80.0); draw_text(&mut canvas, &gameplay.moves_count.to_string(), 525.0, 100.0); // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } }
Box animations
Now that we've learned how to do this, let's extend this to make the boxes animate as well. All we have to do is add new assets and fix the entity creation and everything should just work. Here are the assets I used, feel free to re-use them or create new ones!
Wrap up
That was a long section, but I hope you enjoyed it! Here is how the game should look now.
CODELINK: You can see the full code in this example here.