Tutorial: Using sprite sheet animations in Axmol Engine

Andreas Löw, Joachim Grill
GitHub
Tutorial: Using sprite sheet animations in Axmol Engine

This tutorial is for Axmol Engine.

We recently wanted to upgrade our to cocos2d-x tutorials but found out, that the framework is not supported anymore. The company behind cocos2d sets more and more focus on Cocos Creator and totally neglects the c++ community.

While researching options for our readers, we found a cocos2d-x fork called Axmol Engine that gets regular updates and fixed.

This tutorial is the right place for you to start with this cocos2d-x fork. The tutorial should also work with cocos2d-x V4 if you mange to get it running on your computer.

What you are going to learn from this tutorial

In this tutorial you are going to learn:

  • Why you should use sprite sheets
  • Designing and running your game on different devices and screen sizes
  • Creating optimized sprite sheets
  • Creating animations
  • Playing character animations

The source code for this tutorial is available on GitHub.

I'll describe how to set up Axmol Engine for a build and create a basic project. If you are already familiar with Axmol, you can skip ahead to the next chapter.

How to install Axmol Engine

This section describes how you can get Axmol running on a Mac with Xcode. Running it on Windows is almost identical.

Requirements on macOS, iOS, tvOS:

  • CMake 3.21+
  • Xcode 12+

Requirements on Window:

  • Python 2.7+ or 3.7+
  • CMake 3.21+
  • Visual Studio 2019/2022

Requirements for Android:

  • Python 2.7+ or 3.7+ (already installed when building on macOS)
  • CMake 3.21+
  • Android Studio or command line tools

Start by cloning Axmol's GitHub repository from https://github.com/axmolengine/axmol. The tutorial is created using commit c44c84a. There are currently no defined releases or tags - so it's beste to note the commit you are developing on.

On the command line, run the following commands:

git clone git@github.com:axmolengine/axmol.git
cd axmol
python setup.py

The command asks you about the locations of your different build environments. Enter the paths if you want to build for Android. You can press Enter if you are on a Mac and just want to build for macOS or iOS.

Note: Axmol requires Python 2 or 3. You already should have a current version if you are using macOS. On Windows you can download it from python.org.

Finally close and re-open the command line. You now have a new command available: axmol

You'll use this command to create new projects.

Creating your first game

Enter the following command to create a new empty project called MyProject, replace the package name with something you like - preferred would be the URL of your webpage in reversed order.

axmol new --package com.example.myproject --language cpp MyProject
cd MyProject

This creates the basic files and the project structure. The setup is for c++ (c++) as we use it in this tutorial. You could also use lua as the main development language.

The next step is to use cmake to create the platform specific build files.

cmake . -B build-ios -GXcode -DCMAKE_TOOLCHAIN_FILE=$AX_ROOT/cmake/ios.toolchain.cmake -DPLATFORM=SIMULATOR64

The result of these commands is a new directory called build or build-… that contains the files required to compile the project. E.g. when running the command for macOS you'll get a directory called build with a MyProject.xcodeproj that you can open in Xcode.

You find a bunch of folders, all starting with the prefix "proj." These folders contain files for the different supported development environments - which includes Visual Studio and Xcode - and some platform specific code required to start the App.

It does not matter which platform you choose for development. Choose the environment you like most. Just build on the other platforms you want to support from time to time to make sure it runs on all of them.

Do not use platform specific code

The important thing is that you stay away from platform-specific code and libraries. Axmol should give you a good base for all kinds of games and abstracts the underlying OS and hardware.

Using platform specific libraries makes it hard to port the game to other platforms later.

I am writing this tutorial on a Mac, using Xcode, with the focus on keeping things portable. It should be easy for you to follow the tutorial on other systems. The only difference is how you add the files to the project and how you start the simulator.

Running the project on a Mac opens the iOS Simulator. That means I start the cmake line from above, and open the Xcode project in the build-ios folder.

To start the project, select the MyProject target and click on the play button. Select "iPhone 14 Pro" as target device. After the compilation, the iOS simulator starts with the standard Hello World application:

The hello world application in Axmol
The hello world application in Axmol

The application contains 2 classes: The AppDelegate and the HelloWorldScene .

Why you should use sprite sheets

You want to use different sprites and animations in your game. The naive approach is to add them all to your project and create a loop that loads them one after the other.

That's a bad idea for several reasons:

  • loading each file takes time
  • each sprite has to be loaded into the graphics memory separately
  • switching between the textures is expensive
  • sprites can't be optimized to reduce overdraw

Take a look at this sprite. I've taken it from a walk animation of the character you are going to use later in this tutorial. The transparency around the sprite is required to align the animation phases:

Sprites aligned in the animation frames for a jitter-less animation
CapGuy sprites, aligned within their frame for a jitter-less animation

Transparency seems to consist of nothing but that's not the case for the graphics processor (GPU). It has to do the blending for each pixel — no matter if it's visible or not. Take a look at the checkered regions. The total overhead coming from the transparency for this animation is 68%.

The trick is preventing the GPU from working on these transparent pixels. A simple way is to reduce the area to the rectangle containing all non-transparent pixels. This reduces the amount of transparency by 24%.

The even better solution is to create polygons matching the sprite's non-transparent areas.

The good news is: You don't have to care about it when you are using TexturePacker — it can create rectangular and polygon trimmed shapes for your sprites in a format that cocos2d-x can read out of the box.

Trimming increases the performance because it removes obsolete transparency around the sprites
Trimming increases the performance because it removes obsolete transparency around the sprites

Designing for different resolutions

Axmol is a highly portable game development framework - which takes a lot of work from you when you want to support different devices. The same code should run on all of them.

But you have to be careful about the design. Some devices will come with a 4:3 aspect ratio (like iPad), some come with 16:9 like several mobile phones and some come with strange sizes in between.

Mobile phone stats

Newer phones come with a higher screen resolution — but there are still many old phones in use. Take a look at this chart: It shows screen resolutions from mobile phones and tablets, start of 2023.

Screen view port sizes: Desktop, January 2023
Screen view port sizes: Desktop, January 2023
Screen view port sizes: Tablet, January 2023
Screen view port sizes: Tablet, January 2023
Screen view port sizes: Mobile, January 2023
Screen view port sizes: Mobile, January 2023

These numbers might seem low in some cases. This is because the values in the charts do not take the device pixel ration in account.

E.g. an iPhone 13 reports the view port size as 390 x 844 with a 3x density. That means that the "real" resolution of the screen is 1170 x 2532.

Different resolutions

Different resolutions is not a problem as long as your games does not require many detailed objects and small fonts. You can simply design your game at the highest resolution and scale the images down for the smaller devices.

Memory is an issue on smaller devices combined with low performance. For these devices it's a good idea to create scaled down versions of your sprite sheets and only load the sheets with the appropriate resolutions. A true color 2048x2048 sprite sheet requires 16MB of RAM — scaled down to 50% it's just 4MB.

TexturePacker can create scaled versions of the sprite sheets on the fly - so every device gets the sprite sheet it needs.

Different aspect ratios

Aspect ratio is a bit more trouble since it has an effect on game play. You have 3 choices:

  • Design separate screens for 4:3 and 16:9
  • Design for 16:9 or 4:3 and add black bars
  • Design for 16:9 and extend your art to fill 4:3
Using black bars - score moved outside of screen
Using black bars - score moved outside of the game scene
Extending the scene
Extending the scene - the tablet has more sky and ground

The first option might be the best - but it's the one with the most work on your side. It also changes the game - e.g. more enemies are visible.

The second one feels a bit like in the 90ies.

I prefer the third - it gives all players the same gameplay. The assets for this tutorial are created for 4:3 but the main content will be focused on 16:9.

Keep in mind that the game may still be slightly different. For example, when objects fall from the top of the screen, players on a 4:3 screen may have an advantage because they can see the object earlier than if they were playing on a 16:9 screen.

Creating sprite sheets with TexturePacker

Start by downloading and installing TexturePacker if you've not already done it.

Also download the assets for this tutorial from cityscene.zip. Create a folder called Assets inside the project's root folder. Extract the content of the zip file there.

There's a folder called Resources - do not use this one for the sprites. We use TexturePacker to build the sprite sheets and this is where they will be stored.

Run TexturePacker and drag & drop the cityscene folder onto the left panel. TexturePacker adds all contained sprites to your sheet. This preserves the file name structure inside the folder. It also updates the sprite sheet when you add or remove files from the folder.

Drop the folder containing the sprites onto TexturePacker
Drop the folder named cityscene onto TexturePacker

Don't worry if the sprite are rotated to the right. Axmol knows about this and handles the sprites properly.

Select cocos2d-x as Data Format. Don't use the cocos2d format — it does not contain polygon packing.

Select cocos2d-x as data format for Axmol Engine
Select cocos2d-x as data format

Enable the polygon mode: Change the Trim mode to polygon. You can adjust the Tracer tolerance to change the amount of vertices created for the sprites. Tighter fitting polygons create less overdraw but have a higher vertex count. Try to keep the vertex count low because each vertex required CPU time for calculating its position.

Sprites are still packed as rectangles using TexturePacker's MaxRects algorithm. If you have many sprites — and especially smaller sprites — or sprite that have irregular shapes with a lot of transparency or even holes - you can switch to the polygon packing algorithm.

The sprite sheet here comes still with high overdraw of 95% — this is because of the background image. It's rectangular and has no transparency to trim.

PolygonSprites for Axmol are more efficient than rectangular sprites
Polygon sprites have less overdraw and can increase the performance of the game.

Add scaling variants for the different device sizes. Click on Scaling variants in the right panel. Select cocos2d-x HDR/HD/SD from the drop-down menu in the dialog. Press Apply . You see 3 scaling variants: /HDR/ with a scaling factor of 1, /HD/ with a factor of 0.5 and /SD/ with 0.25. You can add or remove scaling variants if you don't want them. Press Close to return to the sprite sheet.

You should now see 3 tabs in the center screen. Each one is a preview of the sprite sheet created for that scaling variant.

Enable the scaling variants to create the assets in 3 variants: 3x, 2x, and 1x
Enable the scaling variants to create the assets in 3 variants: 3x, 2x, and 1x

Click the small folder next to Data file and set it to save a file names cityscene.plist inside your project's Resources/res folder.

This will give you an error, informing you that you have to add a placeholder {v} as part of your file name. Edit the file name and change it from .../res/cityscene.plist to .../res/{v}/cityscene.plist . The placeholder is replaced with the scaling variant name when writing the sprite sheets.

Press Publish sprite sheet in the toolbar. You should now see 6 files in your project folder:

  • Resources
    • res
      • HDR
        • cityscene.plist
        • cityscene.png
      • HD
        • cityscene.plist
        • cityscene.png
      • SD
        • cityscene.plist
        • cityscene.png

That's it for now in TexturePacker.

Go back to the command line and run the cmake command again to import the references into the project.

Don't make changes to the project in your IDE

This is a very important point! The idea of using CMake is that it handles the updates for all project files for you. With this, all settings are updated on all platforms.

To add new files to the project - code, images, music,... simply run the cmake command again to include them into your project configuration.

Application startup and device resolutions

Let's now start with the code. Before you can use the sprite sheets you now have to lay the foundation for a solid game. Your game code should be free of resolution dependent code if you get the setup right in the AppDelegate.

Start by adjusting the resolution definitions in AppDelegate.cpp.

...
USING_NS_AX;

static ax::Size largeResolutionSize  = ax::Size(2048, 1536); // scale factor 1
static ax::Size mediumResolutionSize = ax::Size(1024, 768);  // scale factor 0.5
static ax::Size smallResolutionSize  = ax::Size(512, 384);   // scale factor 0.25

static ax::Size designResolutionSize = largeResolutionSize;

AppDelegate::AppDelegate() {}
...

Getting pixel perfect designs working with cocos2d-x is hard — simply because you'll have to deal with a bunch of different target sizes — especially if you want to run your game on Android. But even the iOS devices come with all different sizes and screen ratios.

I personally prefer using the higher resolution as designResolutionSize because it allows you to measure sizes and positions in your graphics program. But: An object placed on a coordinate that is not divisible by the scaling factor 4 will be placed between pixels and trigger sub-pixel rendering on lower resolution devices. This might sound like a problem but since there is additional scaling from the design resolution to the device resolution it's not a real restriction.

The following 3 values smallResolutionSize , mediumResolutionSize and largeResolutionSize represent the screen sizes scaled down for different devices. The sizes have to correspond to the scaling variants set up in TexturePacker.

Then your game is started, applicationDidFinishLaunching() is called first. This function is used to set up Axmol:

bool AppDelegate::applicationDidFinishLaunching()
{
    // initialize director
    auto director = Director::getInstance();
    auto glView   = director->getOpenGLView();
    if (!glView)
    {
#if (AX_TARGET_PLATFORM == AX_PLATFORM_WIN32) || (AX_TARGET_PLATFORM == AX_PLATFORM_MAC) || \
    (AX_TARGET_PLATFORM == AX_PLATFORM_LINUX)
        glView = GLViewImpl::createWithRect(
            "MyProject", ax::Rect(0, 0, designResolutionSize.width, designResolutionSize.height));
#else
        glView = GLViewImpl::create("MyProject");
#endif
        director->setOpenGLView(glView);
    }

The director is the main component that handles rendering the scene, updating animations and many more. It's initialized with an OpenGL view. The desktop applications open a window in the designResolution size, the mobile devices go full-screen.

    // turn on display FPS
    director->setStatsDisplay(true);

    // set FPS. the default value is 1.0/60 if you don't call this
    director->setAnimationInterval(1.0f / 60);

The next block turns on the FPS display. This is helpful to see some stats during development. This includes the number of vertices and draw calls - 2 important measures for optimizing your game. You also see the frame rate. Don't forget to disable this in production.

The 2nd value is the target framerate. Usually it's 60fps. In some cases - e.g. when your game is super graphics-heavy or targeting slow devices - it might be also ok to reduce the FPS. You can also reduce the FPS to decrease the power usage of your game if you don't need fast screen updates.

In applicationDidFinishLaunching() there is already some resolution-specific code, we set our spritesheet search paths here:

...
    // Set the design resolution
    glView->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height,
                                    ResolutionPolicy::NO_BORDER);
    auto frameSize = glView->getFrameSize();

    // if the frame's height is larger than the height of medium size.
    if (frameSize.height > mediumResolutionSize.height)
    {
        FileUtils::getInstance()->setSearchPaths({ "res/HDR" });
        director->setContentScaleFactor(MIN(largeResolutionSize.height / designResolutionSize.height,
                                            largeResolutionSize.width / designResolutionSize.width));
    }
    // if the frame's height is larger than the height of small size.
    else if (frameSize.height > smallResolutionSize.height)
    {
        FileUtils::getInstance()->setSearchPaths({ "res/HD" });
        director->setContentScaleFactor(MIN(mediumResolutionSize.height / designResolutionSize.height,
                                            mediumResolutionSize.width / designResolutionSize.width));
    }
    // if the frame's height is smaller than the height of medium size.
    else
    {
        FileUtils::getInstance()->setSearchPaths({ "res/SD" });
        director->setContentScaleFactor(MIN(smallResolutionSize.height / designResolutionSize.height,
                                            smallResolutionSize.width / designResolutionSize.width));
    }
...

The first part of this code setDesignResolutionSize sets the mapping of the defined design resolution size on the display. The interesting parameter is the last one: ResolutionPolicy::NO_BORDER . This means that the complete screen will be filled with our game — no borders. There are other options you can use:

ValueDescription
EXACT_FITMatches the design resolution to the screen but does not keep the aspect ratio. The result might be that the whole game screen is distorted.
NO_BORDERPreserves the aspect ratio of the application, some parts of the game scene might be outside of the screen and not be visible.
SHOW_ALLPreserves the aspect ratio of the application and scales the design resolution size to completely fit on the screen. Black bars are added on 2 sides if the aspect ratio of the screen does not match the design.
FIXED_HEIGHTUses the height of the design resolution size and adjusts the width to fit the aspect ratio of the screen.
FIXED_WIDTHUses the width of the design resolution size and adjusts the height to fit the aspect ratio of the screen.

EXACT_FIT might not really be an option when using sprites. NO_BORDER might be the easiest to use.

getFrameSize() delivers the size of the display. The following if block evaluates our pre-scaled resolutions against the display height, always using the higher resolution and scaling down. With this you should get a better quality than scaling up from a lower resolution.

Display heightSprite sheet
h < 512SD
513 < h < 1024HD
h > 1024HDR

The code also sets the search path for the resources. This is a big help because the application can now always load the cityscene.plist — the decision which one is loaded is handled by Axmol itself.

The last part of the applicationDidFinishLaunching() creates an instance of the HelloWorld scene and tells the Director to display it:


    // create a scene. it's an autorelease object
    auto scene = utils::createInstance<HelloWorld>();

    // run
    director->runWithScene(scene);

    return true;
}

The following 2 functions are called when your application goes into background or re-gains focus. Use it to stop all animations and the music. You can also use it in your game to pause all other calculations etc.

void AppDelegate::applicationDidEnterBackground()
{
    Director::getInstance()->stopAnimation();

#if USE_AUDIO_ENGINE
    AudioEngine::pauseAll();
#endif
}

// this function will be called when the app is active again
void AppDelegate::applicationWillEnterForeground()
{
    Director::getInstance()->startAnimation();

#if USE_AUDIO_ENGINE
    AudioEngine::resumeAll();
#endif
}

Adding a static background

After the less interesting tasks of initializing the app you'll now get the sprite sheets to work.

Start with HelloWorldScene.cpp using the following code block:

bool HelloWorld::init()
{
    if (!Scene::init())
    {
        return false;
    }

    auto visibleSize = _director->getVisibleSize();
    auto origin      = _director->getVisibleOrigin();
    auto safeArea    = _director->getSafeAreaRect();
    auto safeOrigin  = safeArea.origin;

    SpriteFrameCache::getInstance()->addSpriteFramesWithFile("cityscene.plist");

    auto background = Sprite::createWithSpriteFrameName("background.png");
    background->setPosition(origin.x + visibleSize.x / 2,origin.y + visibleSize.y/2);
    addChild(background);

    // scheduleUpdate() is required to ensure update(float) is called on every loop
    scheduleUpdate();

    return true;
}

The 4 lines return the bottom left corner of the screen as origin and the size of the display as visibleSize. All coordinates are returned in the design resolution. You'll use these 2 variables to center the background in the next code block.

You have to load the sprite sheet before you can use it. The addSpriteFramesWithFile does this for you. The plist file contains a reference to the sprite sheet texture file — which is loaded automatically.

Note that this code does not contain anything that is resolution dependent. The code in AppDelegate ensures that addSpriteFramesWithFile loads the resources of the required size.

Call Sprite::createWithSpriteFrameName() to create a new sprite. The sprite name parameter is the name of the sprite added to TexturePacker.

The calculation performed for setPosition centers the background image on the screen. On 4:3 displays you'll see more sky and street — on 16:9 the game will be more focused in the center.

The final line in this block adds the background object as child of the game layer.

Compile and run, you should now see the following scene:

Axmol game scene with the static background sprite
Running the game scene with the static background sprite.

If you get this error:

axmol: fullPathForFilename: No file found at cityscene.plist. Possible missing file.
axmol: SpriteFrameCache: can not find cityscene.plist
axmol: SpriteFrameCache: Frame 'background.png' isn't found

it's most likely that you forgot to run the cmake command to rebuild the project file. You have to run it each time you add new files (sourcecode or images) to your project.

Also make sure that you have the exact layout as described in the creating sprite sheets section.

Playing animations

You have to tell Axmol which sprite frames should be used in an animation. The easiest way for this is to create a helper method that creates a list from a format string and the number of frames of the animation:

For this tutorial, place it at the start of HelloWorldScene.cpp. In you game project, you would of course create a separate file to re-use the function.

static Vector<SpriteFrame*> getAnimation(const char *format, int count)
{
    auto spritecache = SpriteFrameCache::getInstance();
    Vector<SpriteFrame*> animFrames;
    char str[100];
    for(int i = 1; i <= count; i++)
    {
        snprintf(str, sizeof(str), format, i);
        animFrames.pushBack(spritecache->getSpriteFrameByName(str));
    }
    return animFrames;
}

This method retrieves the animation frames from the sprite sheet that follow a given format. You can call it in the following way:

Vector<SpriteFrame*> frames = getAnimation("capguy/walk/%04d.png", 8);

The format string %04d creates 4-digit numbers prefixed with 0: 0001 , 0002 , 0003 ,...

The call returns a list of 8 sprite frames: capguy/walk/0001.png ,... capguy/walk/0008.png

Add the following code block to the bottom of the init() method in HelloWorldScene.cpp before the return true :

...
    // sprite
    auto frames = getAnimation("capguy/walk/%04d.png", 8);
    auto sprite = Sprite::createWithSpriteFrame(frames.front());
    background->addChild(sprite);
    sprite->setPosition(100,620);

    auto animation = Animation::createWithSpriteFrames(frames, 1.0f/12);
    sprite->runAction(RepeatForever::create(Animate::create(animation)));

    // scheduleUpdate() is required to ensure update(float) is called on every loop
    scheduleUpdate();

    return true;
}

The first line is the one you already know from above — to create the animation frames. The second creates a sprite using the first frame from the list.

You add the sprite as child of the background, not as child of the scene. This ensures that the sprite is always positioned in the right spot on the background.

The second block creates an Animation object with the sprite frames. The 1.0f/12 is the frame rate. It's playing 8 frames in 1 second. Make sure to write 1.0f/12 and not 1/12 . The latter is an integer division resulting in 0 — so the animation will never play!

The last line of the block does 3 things at once:

  • creating an Animate object, responsible for playing the animation
  • a RepeatForever object that repeats the animation
  • the assignment of the RepeatForever to the sprite

Compile and run - you should now see Capguy walking in place.

The game scene with the background and a character animation
The game scene with the background and a character animation

Moving the sprite

Let's now make Capguy move from left to right. cocos2d-x comes with a MoveTo action. It takes 2 parameters: The duration in seconds, and the target to move to.

Add the following code block at the end of init() in HelloWorldScene.cpp before the return true :

...
    auto animation = Animation::createWithSpriteFrames(frames, 1.0f/12);
    sprite->runAction(RepeatForever::create(Animate::create(animation)));

    auto movement = MoveTo::create(10, Vec2(2148,620));
    auto resetPosition = MoveTo::create(0, Vec2(-150,620));
    auto sequence = Sequence::create(movement, resetPosition, NULL);
    sprite->runAction(RepeatForever::create(sequence));

    // scheduleUpdate() is required to ensure update(float) is called on every loop
    scheduleUpdate();

    return true;
}

The resetPosition sets Capguy's position to the left of the screen. The time is 0 to perform this action immediately.

The Sequence runs a bunch of actions in sequence, starting the next one after the previous is finished. You can add several actions at once - just make sure to terminate the list with NULL .

Finally, assign the actions to the sprite - again using the RepeatForever .

Compile and run - you now see Capguy walking down the street.

The complete game scene.
The complete game scene.

Conclusion

Create a game scene with sprite sheets is really easy in Axmol Engine.

What do you want to do next? Maybe add some game physics? Read our tutorial How to create a physics enabled game in Axmol Engine


References