Tutorial: Using sprite sheet animations in cocos2d-x V3
This tutorial is for cocos2d-x V3.
We recently wanted to upgrade it to cocos2d-x V4 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.
If you want to get started with game development in c++, this is the right place: Using sprite sheet animations in Axmol Engine
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
In short: Develop once, make money on all platforms.
The source code for this tutorial is available on GitHub.
Basic Project
I'll describe how to set up Cocos2d-x for a build and create a basic project in this section. If you are already familiar with Cocos2d-x, you can skip ahead to the next chapter.
This section describes how you can get cocos2d-x running on a Mac with Xcode. Instruction for other operating systems are available from the cocos2d-x homepage.
Start by downloading Cocos2d-x from https://github.com/cocos2d/cocos2d-x/tree/v3. The tutorial is created using 3.16 - all parts except for the polygon packing should also work with older releases, too.
Unzip the downloaded file and move in a location where you want to keep your installation.
Open a command line interface and change the directory to the Cocos2d-x installation and run the setup
cd cocos2d-x-3.16
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: Cocos2D-x requires Python 2.7 which is outdated. macOS ships with Python 3 - and for that does not work. You have to download the old Python interpreter from here, both on macOS and Windows: Python 2.7 Download
Finally close and re-open the command line. You now have a new command available: cocos
You'll use this command to create new projects.
Creating your first game
Enter the following command to create a new empty project:
cocos new -l cpp -d ~/cocos2d-x-games Cocos2dx-SpriteSheetTutorial
Note : If you see the following error: ValueError: unknown local: UTF-8
set the locale variables before running cocos and try again:
export LC_ALL=en_US.UTF-8<br/> export LANG=en_US.UTF-8
This line creates a new cocos2d-x project in a subfolder of your home directory called cocos2d-x-games . Inside the folder, a project is created called Cocos2dx-SpriteSheetTutorial .
The selected language is c++ (cpp). You could also create games using Lua or Javascript - this tutorial's focus is on c++.
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.
The important thing is that you stay away from platform-specific code and libraries. Cocos2d-x should give you a good base for all kinds of games.
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. What you see is 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
cocos2d-x 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 of the 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.
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.
Creating sprite sheets with TexturePacker
Start by downloading and installing TexturePacker if you've not already done it.
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.
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 . This also enables polygon packing. 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.
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.png inside your project's 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:
- res
- HDR
- cityscene.plist
- cityscene.png
- HD
- cityscene.plist
- cityscene.png
- SD
- cityscene.plist
- cityscene.png
- HDR
That's it for now in TexturePacker.
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.
Replace the content of AppDelegate.h with this code block:
#ifndef _APP_DELEGATE_H_
#define _APP_DELEGATE_H_
#include "cocos2d.h"
class AppDelegate : private cocos2d::Application
{
public:
virtual void initGLContextAttrs();
virtual bool applicationDidFinishLaunching();
virtual void applicationDidEnterBackground();
virtual void applicationWillEnterForeground();
private:
void initOpenGL();
void initMultiResolution();
void initDirector();
void createAndRunScene();
};
#endif
The public virtual methods will be called on different occasions during the application's runtime. You have to implement
them because they are declared pure virtual in the ApplicationProtocol
.
The private methods help us to get some better structure in the startup process.
Clear the code in AppDelegate.cpp and start with this code:
#include "AppDelegate.h"
#include "HelloWorldScene.h"
USING_NS_CC;
static cocos2d::Size designResolutionSize = cocos2d::Size(2048, 1536);
static cocos2d::Size smallResolutionSize = cocos2d::Size(512, 384);
static cocos2d::Size mediumResolutionSize = cocos2d::Size(1024, 768);
static cocos2d::Size largeResolutionSize = cocos2d::Size(2048, 1536);
The designResolutionSize
is the size you used for designing the game. You can set it to whatever
size you want but there are 2 values that make the most sense:
- (512, 384): 1 unit in your game corresponds with 1 pixel in the lowest resolution.
- (2048x1536): 1 unit in your game corresponds with 1 pixel in your art.
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 devices. 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.
Add the following method, it is responsible for loading the pre-scaled versions of the graphics:
void AppDelegate::initMultiResolution()
{
auto director = Director::getInstance();
auto glview = director->getOpenGLView();
glview->setDesignResolutionSize(
designResolutionSize.width,
designResolutionSize.height,
ResolutionPolicy::NO_BORDER
);
...
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.
Finish initMultiResolution()
with the following lines:
std::vector<std::string> searchPaths;
float scaleFactor = 1.0f;
Size frameSize = glview->getFrameSize();
if (frameSize.height > mediumResolutionSize.height)
{
searchPaths.push_back("res/HDR");
scaleFactor = largeResolutionSize.height/designResolutionSize.height;
}
else if (frameSize.height > smallResolutionSize.height)
{
searchPaths.push_back("res/HD");
scaleFactor = mediumResolutionSize.height/designResolutionSize.height;
}
else
{
searchPaths.push_back("res/SD");
scaleFactor = smallResolutionSize.height/designResolutionSize.height;
}
director->setContentScaleFactor(scaleFactor);
FileUtils::getInstance()->setSearchPaths(searchPaths);
}
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.
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 cocos2d itself.
Some mode code is required to implement the application. Simply copy this code block — it's required to create the OpenGL view:
Additional code required in the AppDelegate
These code blocks are also required in the AppDelegate
. They are
responsible for the setup and for sending your game to the background / foreground.
void AppDelegate::initOpenGL()
{
auto director = Director::getInstance();
auto glview = director->getOpenGLView();
if(!glview)
{
#if (CC_TARGET_PLATFORM == CC_PLATFORM_WIN32) ||
(CC_TARGET_PLATFORM == CC_PLATFORM_MAC) ||
(CC_TARGET_PLATFORM == CC_PLATFORM_LINUX)
glview = GLViewImpl::createWithRect("Cocos2dx-SpriteSheetTutorial",
Rect(0, 0, designResolutionSize.width, designResolutionSize.height));
#else
glview = GLViewImpl::create("Cocos2dx-SpriteSheetTutorial");
#endif
director->setOpenGLView(glview);
}
}
The following code block initialises the Director
— setting the
frame rate for animations. It also enables some stats which are printed
in the bottom-left corner of the screen.
void AppDelegate::initDirector()
{
auto director = Director::getInstance();
director->setAnimationInterval(1.0 / 60);
director->setDisplayStats(true);
}
The following method creates your game scene and activates it in the Director
.
You'll create the HelloWorld
scene later.
void AppDelegate::createAndRunScene()
{
auto scene = HelloWorld::createScene();
Director::getInstance()->runWithScene(scene);
}
This method calls all the previous methods when the application is ready to run.
bool AppDelegate::applicationDidFinishLaunching()
{
initOpenGL();
initDirector();
initMultiResolution();
createAndRunScene();
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();
// SimpleAudioEngine::getInstance()->pauseBackgroundMusic();
}
void AppDelegate::applicationWillEnterForeground()
{
Director::getInstance()->startAnimation();
// SimpleAudioEngine::getInstance()->resumeBackgroundMusic();
}
The final method required is the initGLContextAttrs()
— it prepares
the OpenGL context for rendering. Just leave it like this if you don't have an
important reason to reduce the amount of colors.
void AppDelegate::initGLContextAttrs()
{
//set OpenGL context attributions,now can only set six attributions:
//red,green,blue,alpha,depth,stencil
GLContextAttrs glContextAttrs = {8, 8, 8, 8, 24, 8};
GLView::setGLContextAttrs(glContextAttrs);
}
Using the sprite sheets
After the less interesting tasks of initializing the app you'll now get the sprite sheets to work.
Start with HelloWorldScene.h using the following code block:
#ifndef __HELLOWORLD_SCENE_H__
#define __HELLOWORLD_SCENE_H__
#include "cocos2d.h"
class HelloWorld : public cocos2d::Layer
{
public:
static cocos2d::Scene* createScene();
virtual bool init();
CREATE_FUNC(HelloWorld);
private:
cocos2d::Vector<cocos2d::SpriteFrame*> getAnimation(const char *format, int count);
};
#endif
It contains 4 method declarations:
createScene()
Creates aScene
object that contains the HelloWorld layer. AScene
is a container class holding the layers and game objects. It's required by theDirector
. You've used this method withrunWithScene()
inAppDelegate::createAndRunScene
.init()
Init initialises theHelloWorld
object, creates the game objects and animations.CREATE_FUNC(HelloWorld)
CREATE_FUNC
is a macro that creates a static methodHelloWorld::create()
. This method creates a newHelloWorld
object, initialises the memory management and callsinit()
.getAnimation()
This method will help your creating animations from the sprite sheet.
Now switch to HelloWorldScene.cpp .
#include "HelloWorldScene.h"
USING_NS_CC;
Scene* HelloWorld::createScene()
{
auto scene = Scene::create();
auto layer = HelloWorld::create();
scene->addChild(layer);
return scene;
}
As already mentioned above: This method wraps the HelloWorld
Layer
object into Scene
object that you can pass to the Director.
Adding a static background
The next method you have to add is init()
:
bool HelloWorld::init()
{
if ( !Layer::init() )
{
return false;
}
SpriteFrameCache::getInstance()->addSpriteFramesWithFile("cityscene.plist");
Vec2 origin = Director::getInstance()->getVisibleOrigin();
Vec2 visibleSize = Director::getInstance()->getVisibleSize();
// background
auto background = Sprite::createWithSpriteFrameName("background.png");
background->setPosition(origin.x + visibleSize.x / 2,origin.y + visibleSize.y/2);
this->addChild(background);
return true;
}
The first block is required to get the Layer
super class initialized.
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.
The next 2 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.
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 on 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
Playing animations
You have to tell cocos2d-x which sprite frames should be used in an animation. The easiest way for this is to create a helper method that creates a list:
Vector<SpriteFrame*> HelloWorld::getAnimation(const char *format, int count)
{
auto spritecache = SpriteFrameCache::getInstance();
Vector<SpriteFrame*> animFrames;
char str[100];
for(int i = 1; i <= count; i++)
{
sprintf(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/8);
sprite->runAction(RepeatForever::create(Animate::create(animation)));
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/8
is the frame rate.
It's playing 8 frames in 1 second.
Make sure to write 1.0f/8
and not 1/8
.
The latter is an integer division resulting in 0 — so the animation will never play!
The last line of the block chains 3 function calls:
- 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 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));
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.