Making a Pong Game in Cocos2d-x Part 3

In part 1, we set up Cocos2d-x on our computer. In part 2, we created a functional pong game. This tutorial will begin where part 2 left off. Please visit part 1 and part 2 of this blog post to get your project to this point if it is not already.

Image Sizing

In part 2, we only used images designed for the iPad in our project. Those images were scaled and skewed to an exact fit for any size or aspect ratio. Although this is functional, it is far from ideal. Images on the iPhone, which has a much wider aspect ratio in landscape mode, are too flattened. Our pong ball doesn’t look enough like a circle. To remedy this, we will include an image set for the iPhone as well. At certain aspect ratios, we will use the iPad image set, and at others, we will use the iPhone images.

I made our new iPhone images in Inkscape by resizing the images that we already created. The new images are designed for a 2208×1242 screen size—the iPhone 7 plus. You can download all of the images here. As you can see, I renamed our iPad images to have a suffix of “-ipad”. All of the iPhone images have the same name, but without the suffix. This is so that our code can simply name one image, and the string can be appended based on the device type. Now that we have our images, let’s add them to our game and adjust our imaging code.

Adding the images to the code

First, highlight the 3 images that we already have in the project. Right click and press “Delete”. On the popup, click “Move to Trash”. This completely removes the previous images from our project. Next, add the six new images that you just downloaded (or created on your own) to the project. Please refer to part 2 of this tutorial if you have any questions about how to add images to a project. Alternatively, we could have just renamed the images in the project with a “-ipad” suffix and then added the three new images designed for the iPhone aspect ratio. Either way is appropriate.

Adding code to deal with multiple image sets

We are going to keep track of the device type and make our image string adjustment in a new singleton class. Since our current project is a single scene that doesn’t get replaced or restarted, we could do everything in our GameScene class, but our singleton class is crucial for dealing with multi-scene projects. It allows us to keep track of variables and use the same methods across all scenes and classes in the project.

In Xcode, press File->New->File…. Then, under the iOS options, click on “C++ File” inside “Source”, and click the “Next” button. Next, we name the file “GlobalVariables”. Make sure that “Also create a header file” is checked and press next. Press “Create” to finish creating the file. Let’s start with setting up the GlobalVariables.hpp file.

#ifndef GlobalVariables_hpp
#define GlobalVariables_hpp

#include <stdio.h>
#include "cocos2d.h"

USING_NS_CC;

class GlobalVariables : public Node {

public:

    CC_SYNTHESIZE(int, deviceType, DeviceType); // 1 is iphone, 2 is ipad

    static GlobalVariables * globalVariables();
    virtual bool init();

    std::string addDeviceSuffix(std::string string);

};

#endif /* GlobalVariables_hpp */

Aside from our typical create and init methods, we declared a property that will keep track of our device type and a method that will append a suffix to the string depending on the device type. Now, we will move to GlobalVariables.cpp to define and implement the methods.

#include "GlobalVariables.hpp"

static GlobalVariables *instanceOfGlobalVariables = NULL;

GlobalVariables * GlobalVariables::globalVariables()
{
    if (instanceOfGlobalVariables == NULL) {
        instanceOfGlobalVariables = new GlobalVariables();
        instanceOfGlobalVariables->init();

        instanceOfGlobalVariables->deviceType = 1;
    }
    return instanceOfGlobalVariables;
}

bool GlobalVariables::init()
{
    if ( !Node::init() ) {
        return false;
    }

    return true;
}

std::string GlobalVariables::addDeviceSuffix(std::string string)
{
    switch (deviceType) {
        case 1:
            string += ".png";
            break;
        case 2:
            string += "-ipad.png";
            break;

        default:
            break;
    }

    return string;
}

At the top of the class, you can see that we create a static variable of type GameScene called instanceOfGlobalVariables. This is a variable that allows us to always call the same instance of this class. This makes it where we can store and retrieve global values that we want to keep track of for the project. For this app, deviceType is the primary variable to save and track. In our create method, GlobalVariables::globalVariables(), if the class has not already been created, we create instanceOfGlobalVariables, call the init, and set deviceType to a default value of 1. We will always set deviceType at the start of the project, so this default value will never actually come into play. In the method “addDeviceSuffix”, you can see that we either add “.png” or “-ipad.png” to the end of a string depending on deviceType. This way, we can simply type in the name of the image (ie. “ball”), and run this method to get the appropriate image name.

Now that GlobalVariables is setup, let’s move to the AppDelegate.cpp to adjust how the app sets up scaling, skewing, and setting deviceType. First, we need to include our new GlobalVariables class at the top.

#include "GlobalVariables.hpp"

Before, we had designResolution and ipadProResolutionSize set at the top of the class. Delete both of these.

static cocos2d::Size designResolutionSize = cocos2d::Size(1024, 768);
static cocos2d::Size iPadProResolutionSize = cocos2d::Size(2732, 2048);

In applicationDidFinishLaunching(), our approach is slightly different from before. We determine the aspect ratio of the device that is running the app by looking at the width of the screen divided by the height. From that ratio, we decide whether the iPhone or the iPad images are a better fit for the device. That allows us to set the deviceType and designResolutionSize for the app. See the entire applicationDidFinishLaunching method below.

bool AppDelegate::applicationDidFinishLaunching() {
    // initialize director
    auto director = Director::getInstance();

    Size screenSize = director->getVisibleSize();
    Vec2 origin = director->getVisibleOrigin();
    Size designResolutionSize;

    // determine device type
    float aspectRatio = screenSize.width / screenSize.height;
    CCLOG("aspectRatio: %f", aspectRatio);
    if (aspectRatio >= 1.5) {
        // iphone
        CCLOG("no skew regular");
        GlobalVariables::globalVariables()->setDeviceType(1);
        Director *pDirector = Director::getInstance();
        GLView* pEGLView = pDirector->getOpenGLView();
        pDirector->setOpenGLView(pEGLView);
        designResolutionSize = Size(2208, 1242);
        pEGLView->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height, ResolutionPolicy::EXACT_FIT);
    } else {
        // ipad
        CCLOG("no skew plus");
        GlobalVariables::globalVariables()->setDeviceType(2);
        Director *pDirector = Director::getInstance();
        GLView* pEGLView = pDirector->getOpenGLView();
        pDirector->setOpenGLView(pEGLView);
        designResolutionSize = Size(2732, 2048);
        pEGLView->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height, ResolutionPolicy::EXACT_FIT);
    }

    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("PongTutorial", cocos2d::Rect(0, 0,         designResolutionSize.width, designResolutionSize.height));
#else
        glview = GLViewImpl::create("PongTutorial");
#endif
        director->setOpenGLView(glview);
    }

    // turn on display FPS
    director->setDisplayStats(false);

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

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

    CCLOG("frameSize height: %f", frameSize.height);

    register_all_packages();

    // create a scene. it's an autorelease object
    auto scene = GameScene::createScene();

    // run
    director->runWithScene(scene);

    return true;
}

All that’s left image-wise is to change the way that images are called. Every time an image is named, it needs to use our GlobalVariables method to add the correct suffix. First, we need to include the GlobalVariables class in the PlayerPaddle.cpp, Ball.cpp, and GameScene.cpp. Add this line to those classes.

#include "GlobalVariables.hpp"

Now we can adjust the image names. See the changes that need to be made below.

In PlayerPaddle.cpp:

std::string imageName = GlobalVariables::globalVariables()->addDeviceSuffix("paddle");

if (self && self->initWithFile(imageName)) {
if (self && self->initWithFile(“paddle.png”)) {

In Ball.cpp:

std::string imageName = GlobalVariables::globalVariables()->addDeviceSuffix("ball");

if (self && self->initWithFile(imageName)) {
if (self && self->initWithFile(“ball.png”)) {

In GameScene.cpp, in the init():

std::string bgImageName = GlobalVariables::globalVariables()->addDeviceSuffix("pongBG");
auto *bg = Sprite::create(bgImageName);
auto *bg = Sprite::create(“pongBG”);

std::string paddleImageName = GlobalVariables::globalVariables()->addDeviceSuffix("paddle");
opponentPaddle = Sprite::create(paddleImageName);
opponentPaddle = Sprite::create(“paddle.png”);

Great job! Now the images should work perfectly for any type of device. Unfortunately, we’re not quite done with our sizing changes. We also need to change the label sizes for the two different device types that we are targeting. All of the labels will be changed in the course of this tutorial so we’ll just wait until then to make the changes.

Changing the Start of the Game

The start of the game is way too simple. The ball starts with the same movement every time. It’s boring and predictable. We want the ball to start each turn in a random direction. This code takes place in onTouchBegan(). The variable ballDirection, which tracks the direction the ball is currently moving, is simply hardcoded to (1, 1). We want the direction to be random, but it can’t be just any direction. If the movement is too vertical, the ball would take too long or possibly never make it to the paddles. That wouldn’t be fun at all. Because of this, we will restrict the possible ball movement degree to the middle 90 degrees in each direction.

To determine the random direction, we will create a random number to first decide if the ball should begin moving to the left or right. Then, we will take another random number between 0 and 1, and multiply it by 90 to get a random degree for the ball to move. We subtract 45 from this number so that this random degree spread is centered to either the left or right. Then, we add 180 to the result if ball is moving to the left. Finally, the degree is translated into x- and y-coordinates. I hope you remember geometry class! See the code below.

int startLeft = 0;
float randStart = CCRANDOM_0_1();
if (randStart < 0.5) { 
    startLeft = 1; 
} 
float randStartPercentage = CCRANDOM_0_1() * 90 - 45; 
float startAngleInDegrees = startLeft * 180 + randStartPercentage; 
float xDirectionPos = cosf(CC_DEGREES_TO_RADIANS(startAngleInDegrees)); 
float yDirectionPos = sinf(CC_DEGREES_TO_RADIANS(startAngleInDegrees)); 
ballDirection = Vec2(xDirectionPos, yDirectionPos); 
ballDirection = Vec2(1, 1);

Changing the Opponent’s Artificial Intelligence

Now that we’ve made the start more interesting, let’s make the gameplay more exciting as well. That starts with changing the AI of the opponent’s paddle. Currently, the opponent returns every ball with no issues. That hardly makes for a game, so we need to devise a movement plan for the opponent that doesn’t move their paddle in perfect unison with the ball. In Update(float delta), we need to set a max distance that the opponent’s paddle can move when this method is called each frame. This will prevent the opponent from reaching every ball. See the change that we made below.

float paddleY = MIN(MAX(MAX(MIN(pongBall->getPosition().y, opponentPaddle->getPosition().y + screenSize.height * 0.01), opponentPaddle->getPosition().y - screenSize.height * 0.01), origin.y + opponentPaddle->getContentSize().height * 0.5), origin.y + screenSize.height - opponentPaddle->getContentSize().height * 0.5 );
float paddleY = MIN(MAX(pongBall->getPosition().y, origin.y + opponentPaddle->getContentSize().height * 0.5), origin.y + screenSize.height - opponentPaddle->getContentSize().height * 0.5 );

While we’re improving the paddle positioning, let’s also take an opportunity to improve the collision detection for the ball and paddles. Instead of using containsPoint to see if the ball in touching one of the paddles, let’s use intersectsRect with the bounding box of the ball. This allows collision detection to work better, especially as we speed up the ball. This code goes hand in hand with our next section, so we will add it there.

Changing the Ball Movement

The game is definitely move exciting. A random start direction and a potentially beatable opponent are great additions, but there’s still little going on with the game. It would be nice if the player felt like they had a little more control. Let’s make it where the direction that the ball moves after hitting the paddle is determined by where the ball hits on the paddle. If the ball is high on the paddle, it will go upward, and vice versa, regardless of the direction that ball was previously moving.

We make this change in Update(float delta). Let’s just delete our old code.

if (playerPaddle‑>getBoundingBox().containsPoint(newPosition) || opponentPaddle‑>getBoundingBox().containsPoint(newPosition)) {
    //reverse x direction of the ball & y direction based on paddle hit later
    ballDirection = Vec2(-ballDirection.x, ballDirection.y);
    newPosition = Vec2(pongBall‑>getPosition().x + ballDirection.x * ballVelocity * delta, newPosition.y);
}

And insert the new code in the same spot.

pongBall->setPosition(newPosition);
if ((playerPaddle->getBoundingBox().intersectsRect(pongBall->getBoundingBox()) && ballDirection.x < 0) || (opponentPaddle->getBoundingBox().intersectsRect(pongBall->getBoundingBox()) && ballDirection.x > 0)) {
    //reverse x direction of the ball & y direction based on paddle hit later
    Sprite *contactPaddle = playerPaddle;
    float newDirectionIsLeft = false;
    if (opponentPaddle->getBoundingBox().intersectsRect(pongBall->getBoundingBox())) {
        contactPaddle = opponentPaddle;
        newDirectionIsLeft = true;
    }
    float percentageHeightOfBallOnPaddle = (pongBall->getPosition().y - contactPaddle->getPosition().y + contactPaddle->getContentSize().height * 0.5) / contactPaddle->getContentSize().height;
    float ballDirectionDegree = 0;
    if (newDirectionIsLeft) {
        ballDirectionDegree = 225 - (percentageHeightOfBallOnPaddle * 90);
    } else {
        ballDirectionDegree = -45 + (percentageHeightOfBallOnPaddle * 90);
    }
    float xDirectionPos = cosf(CC_DEGREES_TO_RADIANS(ballDirectionDegree));
    float yDirectionPos = sinf(CC_DEGREES_TO_RADIANS(ballDirectionDegree));
    ballDirection = Vec2(xDirectionPos, yDirectionPos);
    ballVelocity = MIN(ballVelocity * 1.05, screenSize.width);
    newPosition = Vec2(pongBall->getPosition().x + ballDirection.x * ballVelocity * delta, newPosition.y);
}

This code starts by setting the new position for the ball. This position was calculated earlier in this method. After setting the position, we look to see if the ball intersects with either of the paddles. We must also check which direction the ball is going. We only want to register the collision if the ball is moving towards the paddle and intersecting it. Otherwise, we could have an issue with registering multiple collisions on a single hit. If the ball is hitting a paddle, we look to see which paddle the ball is touching and at what height on the paddle the ball is located. Now that we’ve found the height of the ball on the paddle, we determine a new angle for the ball to move. This angle is converted to x- and y-coordinates, and it is set in the variable ballDirection. We will also increase ballVelocity with every hit to add a little more fun to the game. We use a max speed of screenSize.width to keep the game from going too fast! The ball will be set using its new ballDirection variable and velocity on the next call of the update method.

Make the Game First to 5 Wins

Let’s add some more excitement to the game. Instead of just playing single points against the opponent, let’s play first to 5 points. This will require us to keep track of points, change all of our labels, and add a couple of new labels. We will start in the init(). This code begins immediately after “this->addChild(opponentPaddle);” and is followed by “auto eventListener = EventListenerTouchOneByOne::create();”. Replace the existing code with the following.

int textSize = 0;
if (GlobalVariables::globalVariables()->getDeviceType() == 1) {
    // iphone
    textSize = 100;
} else {
    // ipad
    textSize = 140;
}
startText = Label::createWithTTF("First to 5 Wins", "fonts/arial.ttf", textSize);
startText->setPosition(Vec2(origin.x + screenSize.width * 0.5, origin.y + screenSize.height * 0.8));
this->addChild(startText);

int scoreLabelSize = 0;
if (GlobalVariables::globalVariables()->getDeviceType() == 1) {
    // iphone
    scoreLabelSize = 60;
} else {
    // ipad
    scoreLabelSize = 100;
}
playerScoreLabel = Label::createWithTTF("Player Score: 0", "fonts/arial.ttf", scoreLabelSize);
playerScoreLabel->setPosition(Vec2(origin.x + screenSize.width * 0.2, origin.y + screenSize.height * 0.95));
this->addChild(playerScoreLabel);

opponentScoreLabel = Label::createWithTTF("Opponent Score: 0", "fonts/arial.ttf", scoreLabelSize);
opponentScoreLabel->setPosition(Vec2(origin.x + screenSize.width * 0.8, origin.y + screenSize.height * 0.95));
this->addChild(opponentScoreLabel);

playerScore = 0;
opponentScore = 0;

gameHasStarted = false;
ballDirection = Vec2(0, 0);
ballVelocity = screenSize.width * 0.5;

As you can see with each of our labels, we first set a size depending on the device type. Then, we change our initial text to say “First to 5 Wins”. After that, we add new labels to display the score for the player and the opponent. These will be updated after each point to display the current score. New instance variables for playerScore and opponentScore are added to keep track of the score. We also update the starting ballVelocity speed. The new variables will need to be added to the GameScene.hpp.

Label *playerScoreLabel;
Label *opponentScoreLabel;
int playerScore;
int opponentScore;

That should handle the init. Now let’s take a look at how to handle the end of points. This takes place in the end of update(). The new code is in bold, remaining code is in standard font, and deleted code has a strikethrough.

if (newPosition.x < origin.x + 0) { 
    //game over--enemy wins
    //point over--enemy wins int textSize = 0;
    int textSize = 0;
    if (GlobalVariables::globalVariables()->getDeviceType() == 1) {
        // iphone
        textSize = 100;
    } else {
        // ipad
        textSize = 140;
    }
    opponentScore++;
    std::string middleText;
    if (opponentScore < 5) {
        middleText = "Tap to Start";
    } else {
        middleText = "You Lose";
        playerScore = 0;
        opponentScore = 0;
    }
    startText = Label::createWithTTF(middleText, "fonts/arial.ttf", textSize); 
    startText = Label::createWithTTF("You Lose", "fonts/arial.ttf", 50); startText->
    setPosition(Vec2(origin.x + screenSize.width * 0.5, origin.y + screenSize.height * 0.8));
    this->addChild(startText);

    std::stringstream ss;
    ss << playerScore;
    std::string playerScoreString = ss.str();
    std::string playerString = "Player Score: " + playerScoreString;
    playerScoreLabel->setString(playerString.c_str());

    std::stringstream ss2;
    ss2 << opponentScore;
    std::string opponentScoreString = ss2.str();
    std::string opponentString = "Opponent Score: " + opponentScoreString; 
    opponentScoreLabel->setString(opponentString.c_str());

    gameHasStarted = false;
    ballDirection = Vec2(0, 0);
    ballVelocity = 300;
    ballVelocity = screenSize.width * 0.5;

    this->unscheduleUpdate();

} else if (newPosition.x > origin.x + screenSize.width) {
    //game over--player wins
    //point over--player wins
    int textSize = 0;
    if (GlobalVariables::globalVariables()->getDeviceType() == 1) {
        // iphone
        textSize = 100;
    } else {
        // ipad
        textSize = 140;
    }
    playerScore++;
    std::string middleText;
    if (playerScore < 5) {
        middleText = "Tap to Start";
    } else {
        middleText = "You Win";
        playerScore = 0;
        opponentScore = 0;
    }
    startText = Label::createWithTTF(middleText, "fonts/arial.ttf", textSize); 
    startText = Label::createWithTTF("You Win", "fonts/arial.ttf", 50); 
    startText->setPosition(Vec2(origin.x + screenSize.width * 0.5, origin.y + screenSize.height * 0.8));
    this->addChild(startText);

    std::stringstream ss;
    ss << playerScore;
    std::string playerScoreString = ss.str();
    std::string playerString = "Player Score: " + playerScoreString;
    playerScoreLabel->setString(playerString.c_str());

    std::stringstream ss2;
    ss2 << opponentScore;
    std::string opponentScoreString = ss2.str(); 
    std::string opponentString = "Opponent Score: " + opponentScoreString; 
    opponentScoreLabel->setString(opponentString.c_str());

    gameHasStarted = false;
    ballDirection = Vec2(0, 0);
    ballVelocity = 300
    ballVelocity = screenSize.width * 0.5;

    this->unscheduleUpdate();
}
pongBall‑>setPosition(newPosition);

All that we changed are the sizing of labels, the label texts to correspond to playing first to 5 wins, and the starting ballVelocity. We also don’t update the pongBall at the end of the method. We discussed our new ball movement system in the previous section. The game is now played where the first player to 5 points wins. Good luck beating your opponent in these grueling battles.

Conclusion

And there you have it! Our project is now getting to be a pretty functional game. We improved our image sizing, made our ball movement much better, created a reasonable opponent AI, and added more structure to the game by playing to 5 points. Nice work! From here, we could add icons and splash screens, add social sharing, or give the game a menu or modes to add to the fun! Feel free to explore and take the project new places on your own. Stay tuned for part 4 of this blog post, where we will create an Android version of the app! Thanks for following along with the HangZone blog!