Sol's Graphics for Beginners

(ch12.cpp)

(prebuilt win32 exe)

12 - Start, Goal and Collectibles

(Last revised 9. August 2005)

Feel free to make a copy of the project, or continue from the last one.

This chapter adds a lot of the game logic, so it's a bit longer than most of the chapters so far. About half of this chapter is about collectibles alone.

To add the start and goal tiles and all the collectibles, we need to redefine the level data a bit. Paste this over the old level data:

// Tile values
enum leveldataenum
{
  LEVEL_DROP = 0,
  LEVEL_GROUND = 1,
  LEVEL_START = 2,
  LEVEL_END = 3,
  LEVEL_COLLECTIBLE = 5
};

// Level data
unsigned char gLevel[LEVELWIDTH * LEVELHEIGHT] =
{
  0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,
  0,0,0,1,1,1,5,1,1,5,1,1,1,3,0,
  0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,
  0,1,1,1,5,1,1,1,1,1,1,1,1,1,1,
  0,1,1,1,1,1,1,1,1,1,1,1,1,1,5,
  0,1,0,0,0,0,1,1,1,1,1,5,1,1,1,
  1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,
  1,1,1,1,1,0,1,1,5,1,1,0,1,0,1,
  1,1,1,1,1,0,1,1,1,1,1,0,1,1,1,
  1,1,2,1,1,0,1,0,1,1,1,0,1,1,1
};

We also need a couple of new constants for the tiles:

// Color of the start tile
#define STARTCOLOR 0x001f7f
// Color of the goal tile
#define ENDCOLOR 0x3f7f1f

Just resetting the player to the center won't do anymore. Since we need to adjust a lot of variables whenever the player dies (or restarts the level for some other reason), we'll split the init() into two functions. Replace the old init() with the following:

void reset()
{
  gXMov = 0;
  gYMov = 0;

  gKeyLeft = 0;
  gKeyRight = 0;
  gKeyUp = 0;
  gKeyDown = 0;

  gXPos = WIDTH / 2;
  gYPos = HEIGHT / 2;

  gLastTick = SDL_GetTicks(); 
}

void init()
{
  if (SDL_NumJoysticks() > 0)
  {
    gJoystick = SDL_JoystickOpen(0);    
    if (SDL_JoystickNumAxes(gJoystick) < 2)
    {
      // Not enough axes for our use; don't use the joystick.
      SDL_JoystickClose(gJoystick);
      gJoystick = NULL;
    }
  }

  reset();
}

Next, replace the old tile collision code (the one that causes player position to reset to center) with the following:

switch (gLevel[(((int)gYPos) / TILESIZE) * LEVELWIDTH + ((int)gXPos) / TILESIZE])
{
case LEVEL_DROP:
  // player fell off - reset position
  reset();
  break;
case LEVEL_END:
  // player reaches goal
  reset();
  break;
}

Finally, replace the background-filling code with the following:

// fill background
int i, j;
for (i = 0; i < LEVELHEIGHT; i++)
{
  for (j = 0; j < LEVELWIDTH; j++)
  {
    int color;
    switch (gLevel[i * LEVELWIDTH + j])
    {
    case LEVEL_DROP:
      color = FALLCOLOR;
      break;
    case LEVEL_GROUND:
    case LEVEL_COLLECTIBLE:
      color = BGCOLOR;
      break;
    case LEVEL_START:
      color = STARTCOLOR;
      break;
    case LEVEL_END:
      color = ENDCOLOR;
      break;
    }
    drawrect(j * TILESIZE, i * TILESIZE, TILESIZE, TILESIZE, color);
  }
}

Note that the ground and collectible tiles are handled in the same way. We'll add the collectibles in a slightly different manner later on; for now, collectible tiles are exactly the same as ground tiles.

Compile and run. You should now see the start and end tiles, and when you hit the end tile, the game should reset (just like when you hit the black tiles).

Let's move the player's start position to the start tile next. Add a couple of new variables (near the gXPos and gYPos variables):

// Player's start position
float gStartX;
float gStartY;

Next, in reset(), first add the following near the end (but before the setting gXPos and gYPos):

int i;
for (i = 0; i < LEVELWIDTH * LEVELHEIGHT; i++)
{
  if (gLevel[i] == LEVEL_START)
  {
    gStartX = (float)((i % LEVELWIDTH) * TILESIZE + TILESIZE / 2);
    gStartY = (float)((i / LEVELWIDTH) * TILESIZE + TILESIZE / 2);
  }
}

In order to find the start tile, we need to scan through the whole level. When the start tile is found, we calculate the X and Y coordinates of the tile's center. For example, if 'i' was 73, the X coordinate would be (73 modulo 15 = 13, 13 * 32 + 16 = 432) and Y would be (73 / 15 = 4, 4 * 32 + 16 = 144).

We could have done this using two nested for-loops, and saved ourself some of the awkward math. On the other hand, this is another example of the fact that everything can be implemented in different ways.

Also in the reset() function, change the gXPos and gYPos to start from gStartX and gStartY instead of WIDTH / 2 and HEIGHT / 2.

After this, when you compile and run, the ball should start from the start tile.

You might be wondering why we're doing this in reset() instead of init(). After all, since the level data doesn't change, the start tile will stay in one place however many times we call reset(). We're being a bit forward-thinking here, as the level data will be changing in later chapters.

Now, let's add the collectibles.

We'll implement collectibles as sprites that are drawn on top of the background, much the same way as the player's object. This will let us do a lot of interesting things with them in the future, should we want to - for instance, we could make them move.

New constants:

// Radius of a collectible item
#define COLLECTIBLERADIUS 8
// Color of a collectible item
#define COLLECTIBLECOLOR 0xffff00

Since we're creating the collectibles the way we are, we need a place to store the parameters for the collectible item. Let's define a structure to contain the parameters. Paste the following after the #define lines:

struct collectible
{
  float mX;
  float mY;
  int mColor;
  int mRadius;
  int mTaken;
};

(Note that since this is C++, we don't need to use 'typedef' to define 'collectible' as type).

The mX and mY members are the coordinates. Color and radius are defined as well; this way, if we want, we can give each collectible a different color, and radius. The mTaken member is a flag that tells us whether this collectible has been taken yet.

Next, a couple of new variables (paste after the structure definition):

// Total number of collectibles
int gCollectibleCount;
// Number of collectibles taken
int gCollectiblesTaken;
// Array of collectibles
collectible *gCollectible;

We're not defining the gCollectibles as a fixed-size array since we have no idea how many collectibles there will be.

To find out, we need to edit the reset() function a bit. Earlier, to find the start tile, we used a for loop to scan through the level data. Let's expand that a bit. Replace the loop with the following:

gCollectibleCount = 0;
int i;
for (i = 0; i < LEVELWIDTH * LEVELHEIGHT; i++)
{
  if (gLevel[i] == LEVEL_START)
  {
    gStartX = (float)((i % LEVELWIDTH) * TILESIZE + TILESIZE / 2);
    gStartY = (float)((i / LEVELWIDTH) * TILESIZE + TILESIZE / 2);
  }
  if (gLevel[i] == LEVEL_COLLECTIBLE)
    gCollectibleCount++;
}

After that we'll know how many collectibles there are, so we can allocate the correct amount of collectibles.

Note that we're doing this in reset(), a function which will be called several times, unlike init() which will only be called once. Because of this we first need to clean up the old allocated collectibles before allocating a new array. After this we can scan through the level data and fill out the collectible info, whenever we find one:

delete[] gCollectible;
gCollectible = new collectible[gCollectibleCount];
gCollectibleCount = 0;
for (i = 0; i < LEVELWIDTH * LEVELHEIGHT; i++)
{
  if (gLevel[i] == LEVEL_COLLECTIBLE)
  {
    gCollectible[gCollectibleCount].mX = 
        (float)((i % LEVELWIDTH) * TILESIZE + TILESIZE / 2);
    gCollectible[gCollectibleCount].mY = 
        (float)((i / LEVELWIDTH) * TILESIZE + TILESIZE / 2);
    gCollectible[gCollectibleCount].mColor = COLLECTIBLECOLOR;
    gCollectible[gCollectibleCount].mRadius = COLLECTIBLERADIUS;
    gCollectible[gCollectibleCount].mTaken = 0;
    gCollectibleCount++;
  }
}

gCollectiblesTaken = 0;

The above block comes right after the block we pasted earlier. First, we're deleting the old array, then we're allocating the new one. Next we're resetting the collectible count to zero (since we'll be using it to index the collectible list as we go through it).

Then we scan through the level, recording the collectible positions whenever we find them. After the loop we set the count of taken collectibles to zero.

We need to make sure that the gCollectible pointer is NULL when reset() is being called the first time. Otherwise its value may be just about anything, and calling delete[] on random values may cause just about anything to happen.

Add the following line somewhere in the init() function (before the call to reset()):

gCollectible = NULL;

Next we need to render the collectibles. Paste the following block in render() between the background and player object drawing code:

// draw the collectibles
for (i = 0; i < gCollectibleCount; i++)
{
  if (gCollectible[i].mTaken == 0)
  {
    drawcircle((int)gCollectible[i].mX,
                (int)gCollectible[i].mY,
                gCollectible[i].mRadius,
                gCollectible[i].mColor);
  }
}

The above loop calls drawcircle() for each collectible that hasn't been taken yet, much the same way as the player's object is rendered.

Now if you compile and run, the collectibles will be there, but you can't collect them. Reason being, we haven't defined any collision code.

Paste the following under the drawrect() function:

void collectiblecollision()
{
  int i;
  for (i = 0; i < gCollectibleCount; i++)
  {
    if (gCollectible[i].mTaken == 0)
    {
      if (sqrt((gCollectible[i].mX - gXPos) * 
               (gCollectible[i].mX - gXPos) +
               (gCollectible[i].mY - gYPos) * 
               (gCollectible[i].mY - gYPos)) <
          RADIUS + gCollectible[i].mRadius)
      {
        gCollectiblesTaken++;
        gCollectible[i].mTaken = 1;
      }
    }
  }
}

The above code loops through all of the collectibles. For every non-taken collectible, we calculate the distance between the collectible and player object using the pythagoras equation (a*a + b*b = c*c <=> c = +- sqrt(a*a + b*b)).

If the distance is smaller than the sum of the two objects' radiuses, the objects collide.

Add a call to collectiblecollision() near the end of the physics loop, before the gLastTick +=... line.

If you compile and run, you should be able to collect the collectibles now.

Only one change to go. We want the player to be able to reach the goal only if all of the collectibles have been collected. In the physics loop, replace the switch structure which checks what kind of tile you're on with the following:

switch (gLevel[(((int)gYPos) / TILESIZE) * LEVELWIDTH + ((int)gXPos) / TILESIZE])
{
case LEVEL_DROP:
  // player fell off - reset position
  reset();
  break;
case LEVEL_END:
  if (gCollectibleCount == gCollectiblesTaken)
  {
    // All collectibles taken and end reached. Player wins.
    reset();
  }
  break;
}

There; now the player can only reach goal when all of the collectibles are done.

Even after this ordeal there's some game logic that we need to do before we can call this a game, but let's take a detour to make the game a bit prettier.

Next, we'll be 13 - Loading Images.

Having problems? Improvement ideas? Just want to discuss this tutorial? Try the forums!

Any comments etc. can be emailed to me.