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
cmake -S . -B build -GXcode -DCMAKE_TOOLCHAIN_FILE=$AX_ROOT/cmake/ios.toolchain.cmake -DPLATFORM=OS64
cmake -S . -B build -GXcode -DCMAKE_TOOLCHAIN_FILE=$AX_ROOT/cmake/ios.toolchain.cmake -DPLATFORM=TVOS
cmake -S . -B build -GXcode -DCMAKE_TOOLCHAIN_FILE=$AX_ROOT/cmake/ios.toolchain.cmake -DPLATFORM=SIMULATOR_TVOS
cmake -S . -B build -GXcode -DCMAKE_OSX_ARCHITECTURES=x86_64
cmake -S . -B build -GXcode -DCMAKE_OSX_ARCHITECTURES=arm64
cmake -S . -B build -G "Visual Studio 16 2019" -A Win32
cmake -S . -B build -G "Visual Studio 16 2019" -A x64
cmake -S . -B build -G "Visual Studio 17 2022" -A Win32
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
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 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:
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.
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.
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
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.
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.
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.
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.
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
- HDR
- res
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:
Value | Description |
---|---|
EXACT_FIT | Matches 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_BORDER | Preserves the aspect ratio of the application, some parts of the game scene might be outside of the screen and not be visible. |
SHOW_ALL | Preserves 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_HEIGHT | Uses the height of the design resolution size and adjusts the width to fit the aspect ratio of the screen. |
FIXED_WIDTH | Uses 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 height | Sprite sheet |
---|---|
h < 512 | SD |
513 < h < 1024 | HD |
h > 1024 | HDR |
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:
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 thesprite
Compile and run - you should now see Capguy walking in place.
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.
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