- Libgdx Cross/platform Game Development Cookbook
- David Saltares Márquez Alberto Cejas Sánchez
- 1971字
- 2021-04-09 23:26:38
Rendering sprite-sheet-based animations
So far, we have seen how to render textures and regions of an atlas using Libgdx. Obviously, you can move textures around over time to produce a sense of motion. However, your characters will not come to life until they are properly animated. Not only should they go from one side of the screen to the other, but they should also seem like they are walking, running, or jumping according to their behavior.
Typically, we refer to characters physically moving in the game world as external animation, while we use the term internal animation to talk about their body movement (for example, lifting an arm).
In this recipe, we will see how to implement sprite-sheet-based animation using mechanisms provided by Libgdx. We will do so by populating our previous jungle scene with animated versions of the same characters. A sprite sheet is nothing more than a texture atlas containing all the frames that conform a character's animation capabilities. Think of it as having a notebook with drawings in a corner. If you go through the pages really fast, you will perceive the illusion of motion.
Behold our caveman's jumping skills! In the following screenshot, you can find an example of sprite-sheet-based animation, more specifically, a complete jump cycle:
Getting ready
You will also need the sample projects in your Eclipse workspace as well as data/caveman.atlas
and data/trex.atlas
along with their corresponding PNG textures.
How to do it…
First things first, the code for this recipe is located inside the AnimatedSpriteSample.java
file, which uses the known ApplicationListener
pattern. We are going to use two texture atlases created with texturepacker-gui that contain a walk cycle animation for our caveman and the merciless Tyrannosaurus Rex respectively. Feel free to catch up with atlases in the More effective rendering with regions and atlases recipe.
The caveman walk cycle can be found in the caveman-sheet.png
and caveman.atlas
files while the dinosaur is stored in the trex-sheet.png
and trex.atlas
files. As usual, everything is located under the [cookbook]/samples/samples-android/assets/data
folder. Here is a simplified version of the dinosaur sprite sheet:
The FRAME_DURATION
determines for how long (in seconds) we should show each frame of our sprite sheet before advancing to the next one. Since our animations were tailored to be displayed at 30
frames per second, we set it to 1.0f
/ 30.0f
:
private static final float FRAME_DURATION = 1.0f / 30.0f;
To achieve this demo's goal, we will need a camera, a sprite batch, and two atlases, one for the caveman and another one for the dinosaur walk cycles. We will also need a background texture if we do not want to show a dull black background:
private TextureAtlas cavemanAtlas; private TextureAtlas dinosaurAtlas; private Texture background;
Texture atlases simply provide a collection of texture regions we can retrieve by name. We need a way of specifying how our animations are going to be played. To that end, Libgdx provides us with the Animation
class. Every distinct character animation should have its own Animation
object to represent it. Therefore, we will use a dinosaurWalk
instance and a cavemanWalk
instance. Finally, we will control how our animations advance through the animationTime
variable:
private Animation dinosaurWalk; private Animation cavemanWalk; private float animationTime;
Inside the create()
method, we build the orthographic camera, rendering area, and batch. We also initialize our animationTime
variable to 0.0f
to start counting from then onwards:
camera = new OrthographicCamera(); viewport = new FitViewport(SCENE_WIDTH, SCENE_HEIGHT, camera); batch = new SpriteBatch(); animationTime = 0.0f;
The next step is to load the atlases for the caveman and the dinosaur as well as the background texture:
cavemanAtlas = new TextureAtlas(Gdx.files.internal("data/caveman.atlas")); dinosaurAtlas = new TextureAtlas(Gdx.files.internal("data/trex.atlas")); background = new Texture(Gdx.files.internal("data/jungle-level.png"));
Later, we retrieve the collection of regions of both our atlases using the getRegions()
method and sort them alphabetically because that is how we have arranged the frames in our animation atlases:
Array<AtlasRegion> cavemanRegions = new Array<AtlasRegion>(cavemanAtlas.getRegions()); cavemanRegions.sort(new RegionComparator()); Array<AtlasRegion> dinosaurRegions = new Array<AtlasRegion>(dinosaurAtlas.getRegions()); dinosaurRegions.sort(new RegionComparator());
Note
The Array<T>
class is a Libgdx built-in container quite similar to the standard Java ArrayList<T>
; the difference is that the former was written with performance in mind. Libgdx comes with more containers such as dictionaries, sets, binary arrays, heaps, and more. Using them rather than their standard counterparts is more than advisable, especially if you are targeting mobile devices.
The RegionComparator
class is nothing more than a convenience inner class to sort our AtlasRegion
arrays. AtlasRegion
inherits from TextureRegion
featuring additional data related to its packaging. In this case, we are interested in its name member so as to be able to sort the images alphabetically:
private static class RegionComparator implements Comparator<AtlasRegion> { @Override public int compare(AtlasRegion region1, AtlasRegion region2) { return region1.name.compareTo(region2.name); } }
It is now time to create the Animation
instances. The constructor takes the frame duration, Array<TextureRegion>
, representing all the frames that form the animation and the playback mode. Just like the name hints, PlayMode.LOOP
makes an animation play over and over again. We will learn more about other play modes later on in this recipe. The code is as follows:
cavemanWalk = new Animation(FRAME_DURATION, cavemanRegions, PlayMode.LOOP); dinosaurWalk = new Animation(FRAME_DURATION, dinosaurRegions, PlayMode.LOOP);
Finally, we position the camera to be at half its width and height so we can see the background properly, which will be rendered with its bottom-left corner at the origin:
camera.position.set(VIRTUAL_WIDTH * 0.5f, VIRTUAL_HEIGHT * 0.5f, 0.0f);
We do not want to be leaking memory all over the place. That would be disgusting and unacceptable! That is why we make sure to dispose of all the resources in the conveniently named dispose()
method of our ApplicationListener
:
@Override public void dispose() { batch.dispose(); cavemanAtlas.dispose(); dinosaurAtlas.dispose(); background.dispose(); }
Finally, we get to the key bit, the render()
method. As usual, we first clear the screen with a black background color and set the viewport, that is, our rendering area in screen space. The next step is to increment our animationTime
variable with the time that has passed since our last game loop iteration. We can get such information from Gdx.graphics.getDeltaTime()
. To prepare the terrain for rendering, we update the camera matrices and frustum planes, set the sprite batch projection matrix, and then call begin()
.
Which frame shall we draw? That is a good question. Luckily enough, as long as we provide animationTime
, the Animation
class has all the information it needs to figure it out: the list of frames, the frame time, and the playback mode. We can call getKeyFrame()
passing in the time in seconds to obtain the frame to draw each frame and give it to the batch draw()
method. That way, we can draw our two animated characters along the background texture. Easy as pie. The code is as follows:
public void render() { ... animationTime += Gdx.graphics.getDeltaTime(); batch.begin(); ... TextureRegion cavemanFrame = cavemanWalk.getKeyFrame(animationTime); width = cavemanFrame.getRegionWidth(); height = cavemanFrame.getRegionHeight(); float originX = width * 0.5f; float originY = height * 0.5f; batch.draw(cavemanFrame, 1.0f - originX, 3.70f - originY, originX, originY, width, height, WORLD_TO_SCREEN, WORLD_TO_SCREEN, 0.0f); ... batch.end(); }
How it works…
The implementation of the Animation
class is extremely simple, but it does a great job in helping us introduce animated characters in our games using sprite sheets. See for yourself by reading its implementation in the Libgdx GitHub repository. You will find that reading the Libgdx source at https://github.com/libgdx/libgdx/blob/master/gdx/src/com/badlogic/gdx/graphics/g2d/Animation.java is actually a great way of learning how it works internally; do not let it scare you away.
It holds a low-level Java array of TextureRegion
references, the frame duration parameter, and caches the whole animation duration. The main part of the action takes place in the getKeyFrameIndex()
method. It simply grabs the current frame index by dividing stateTime
over frameDuration
. Then, it accesses the region's array depending on the animation PlayMode
. It makes sure not to pick an invalid frame and returns the appropriate index. Obviously, if you call the getKeyFrame()
method directly, you will obtain the region straightaway.
There's more…
So far, we have seen the most basic operation you can do using the Animation
class, simply retrieving the current frame on each iteration of the game loop for rendering purposes. However, if you want to use this system in slightly more complex situations, you will need further control. In this section, we will cover what different playback modes are at our disposal, how to check when a one-shot animation is done, and how to manage complex characters with tons of animations.
Animation supports different ways of sequencing frames, which can be set and retrieved on a per instance basis through the setPlayMode()
and getPlayMode()
methods respectively. These take and return one of the PlayMode
enumerated type values. Here is the complete list:
PlayMode.NORMAL
: This sequentially plays all the animation frames only oncePlayMode.REVERSED
: This plays all the animation frames once in reverse orderPlayMode.LOOP
: This continuously plays all the animation framesPlayMode.LOOP_REVERSED
: This continuously plays all the frames in reverse orderPlayMode.LOOP_RANDOM
: This picks a random frame every time from the available ones
Usually, we want to play certain animations in an infinite loop for as long as an action is being carried out, a character running for example. However, other animations should only be played once per trigger, such as a sword slash attack. It is very likely that we require to know when such animation has finished playing to allow the character to do something else. The isAnimationFinished()
method returns true
when the given animation is finished provided it is played in PlayMode.NORMAL
or PlayMode.REVERSED
mode and with the looping flag set to false
.
By now, you have probably figured out that having Animation
objects hanging around is not very scalable when implementing a full game with dozens of characters. Also, manually providing the frames for every animation in code could become a truly gargantuan task. There is a need for some sort of abstraction and data-driven approach.
You could define your animated characters in XML or JSON providing all the necessary data to build a set of Animation
objects. Here is a hypothetical example for our caveman:
<?xml version="1.0" encoding="UTF-8"?> <animatedCharacter atlas="data/caveman.atlas" frameDuration="0.03333" > <animation name="idle" mode="loop" > <frame region="caveman0001" /> <frame region="caveman0002" /> <frame region="caveman0003" /> ... </animation> <animation name="walk" mode="loop"> ... </animation> <animation name="jump" mode="normal"> ... </animation> </animatedCharacter>
Naturally, you would need some code to manage all this in the game. Here is a skeleton of what could be an AnimatedCharacter
class that reads the previous file format and loads the information into a dictionary of animation names to Animation
objects. It also contains information of the current animation and the play time. The only thing it really does is provide a way of setting the current animation by name, controlling its play state, and retrieving the frame that should be shown at a given point in time.
Implementation details are left to you, but it should be pretty straightforward. The code is as follows:
public class AnimatedCharacter { private ArrayMap<String, Animation> animations; private float time; private Animation currentAnimation; public AnimatedCharacter(FileHandle file); public void update(float deltaTime); public AtlasRegion getCurrentFrame(); public String getAnimation(); public void setAnimation(String name); public void setPlaybackScale(float scale); public void stop(); public void pause(); public void play(); }
See also
- To animate some game elements such as items and UI, you can also use interpolations. To know more, check out the Smooth animations with Universal Tween Engine recipe in Chapter 11, Third-party Libraries and Extras.
- Sprite sheet animations are often enough, but skeletal-based animations offer much cleaner results and a ton of other features worth considering. Go to the Skeletal animations with Spine recipe in Chapter 11, Third-party Libraries and Extras, to read more on the topic.