Chapter 9. Combining Bitmaps and Sound

Geo Blaster Basic was constructed using pure paths for drawing. In its creation, we began to cover some game-application-related topics, such as basic collision detection and state machines. In this chapter, we will focus on using bitmaps and tile sheets for our game graphics, and we will also add sound using techniques introduced in Chapter 7.

Along the way, we will update the FrameRateCounter from Chapter 8 by adding in a “step timer.” We will also examine how we can eliminate the use of a tile sheet for rotations by precreating an array of imageData instances using the getImageData() and putImageData() Canvas functions.

In the second half of this chapter, we will create another small turn-based strategy game using bitmaps. This game will be roughly based on the classic computer game Daleks.

Geo Blaster Extended

We will create a new game, Geo Blaster Extended, by adding bitmaps and sound to the Geo Blaster Basic game from Chapter 8. Much of the game logic will be the same, but adding bitmaps to replace paths will enable us to optimize the game for rendering. Optimized rendering is very important when you are targeting limited-processor devices, such as mobile phones. We will also add sound to Geo Blaster Extended, and apply an object pool to the particles used for game explosions. Figure 9-1 shows an example screen of the finished game.

Geo Blaster Extended
Figure 9-1. Geo Blaster Extended

First, let’s look at the tile sheets we will use for our new game.

Geo Blaster Tile Sheet

In Chapter 4, we examined applying bitmap graphics to the canvas, and we explored using tile sheet methods to render images. In Chapter 8, we drew all our game graphics as paths and transformed them on the fly. In this chapter, we will apply the concepts from Chapter 4 to optimizing the rendering of the Geo Blaster Basic game. We will do this by prerendering all of our game graphics and transformations as bitmaps. We will then use these bitmaps instead of paths and the immediate-mode transformations that were necessary in Chapter 8 to create Geo Blaster Extended.

Figure 9-2 shows one of the tile sheets we will use for this game (ship_tiles.png).

These tiles are the 36 rotations for our player ship. We are “canning” the rotations in a tile sheet to avoid spending processor cycles transforming them on each frame tick as we draw them to the canvas.

The ship_tiles.png tile sheet
Figure 9-2. The ship_tiles.png tile sheet

Figure 9-3 shows a second set of tiles for the ship with the “thruster” firing (ship_tiles2.png). We will use this set to depict the ship when the user is pressing the up arrow key.

The ship_tiles2.png tile sheet
Figure 9-3. The ship_tiles2.png tile sheet

The next three sets of tiles are for the rocks that the player will destroy. We have three sheets for these: largerocks.png (Figure 9-4), mediumrocks.png (Figure 9-5), and smallrocks.png (Figure 9-6).

The largerocks.png tile sheet
Figure 9-4. The largerocks.png tile sheet
The mediumrocks.png tile sheet
Figure 9-5. The mediumrocks.png tile sheet
The smallrocks.png tile sheet
Figure 9-6. The smallrocks.png tile sheet

These three tile sheets only need to be five tiles each. Since the rock is a square, we can simply repeat the five frames to simulate rotation in either the clockwise or counterclockwise direction.

The saucer that attempts to shoot the player is a single tile, saucer.png, shown in Figure 9-7.

The saucer.png tile
Figure 9-7. The saucer.png tile

Finally, parts.png (Figure 9-8), is a tiny 8×2 tile sheet that contains four 2×2 “particle” tiles. These will be used for the explosions and missiles fired by the player and the saucer.

The parts.png tile sheet
Figure 9-8. The parts.png tile sheet

You cannot see the colors in a black-and-white printed book, but you can view them by downloading the files from this book’s website. The first tile is green, and it will be used for the small rock and saucer explosions. The second tile is light blue, and it will depict the player’s missiles and the player explosion. The third tile is reddish pink (salmon, if you will), and it will illustrate the large rock explosions. The final, purple tile will be used for the medium rock explosions.

Now that we have our tiles in place, let’s look at the methods we will use to transform Geo Blaster Basic’s immediate-mode path, rendering it to Geo Blaster Extended’s tile-based bitmap.

Refresher: Calculating the tile source location

In Chapter 4, we examined the method to calculate a tile’s location on a tile sheet if we know the single-dimension id of that tile. Let’s briefly look back at this, as it will be reused to render all the tiles for the games in this chapter.

Given that we have a tile sheet such as ship_tiles.png, we can locate the tile we want to display with a simple math trick.

ship_tiles.png is a 36-tile animation with the player ship starting in the 0-degree angle, or “pointing right” direction. Each of the remaining 35 tiles displays the ship rotating in 10-degree increments.

If we would like to display tile 19 (the ship pointing to the left, or in the 190-degree angle), we first need to find the x and y coordinates for the top-left corner of the tile, by calculating sourceX and sourceY.

Here is pseudocode for the sourceX calculation:

sourceX = integer(current_frame_index modulo
the_number_columns_in_the_tilesheet) * tile_width

The modulo (%) operator will return the remainder of the division calculation. Below is the actual code (with variables replaced with literals) we will use for this calculation:

var sourceX = Math.floor(19 % 10) *32;

The result is x = 9*32 = 288;.

The calculation for the sourceY value is similar except we divide rather than use the modulo operator:

sourceY = integer(current_frame_index divided by
the_number_columns_in_the_tilesheet) *tile_height

Here’s the actual code we will use for this calculation:

var sourceY = Math.floor(19 / 10) *32;

This works out to y = 1*32 = 32;. So, the top-left location on the ship_tiles.png from which to start copying pixels is 288,32.

To actually copy this to the canvas, we will use this statement:

context.drawImage(shipTiles, sourceX, sourceY,32,32,player.x,player.y,32,32);

In Chapter 8, we needed quite a lot of code to draw and translate the player ship at the current rotation. When we use a tile sheet, this code is reduced considerably.

Here is the code we will use to render the player ship. It will replace the renderPlayer() function in Example 8-12 in Chapter 8:

function renderPlayerShip(x,y,rotation, scale) {
    //transformation
    context.save(); //save current state in stack
    context.globalAlpha = parseFloat(player.alpha);
    var angleInRadians = rotation * Math.PI / 180;
    var sourceX = Math.floor((player.rotation/10) % 10) * 32;
    var sourceY = Math.floor((player.rotation/10) /10) *32;
    if (player.thrust){
       context.drawImage(shipTiles2, sourceX, sourceY, 32, 32,
          player.x,player.y,32,32);
    }else{
       context.drawImage(shipTiles, sourceX, sourceY, 32, 32,
       player.x,player.y,32,32);
    }

    //restore context
    context.restore(); //pop old state on to screen

    context.globalAlpha = 1;

 }

Note

You will find the entire source code for Geo Blaster Extended (Example 9-1) later, in Geo Blaster Extended Full Source.

The renderPlayer() function divides the player.rotation by 10 to determine which of the 36 tiles in the shipTiles image instance to display on the canvas. If the player is in “thrust” mode, the shipTiles2 image is used instead of shipTiles.

This works because we have set the ship to rotate by 10 degrees with each press of the left or right arrow key. In Chapter 8’s version of the game, we set this to 5 degrees. If we had created a 72-frame tile sheet, with the player ship rotated in 5-degree increments, we could have kept the player.rotationalVelocity at 5. For Geo Blaster Extended, we only drew 36 tiles for the player ship, so we are using the value 10 for the rotational velocity. There certainly is no reason why we could not use 72 or even 360 frames for the player ship rotation tiles. This is only limited by creative imagination (and patience with a drawing tool).

Let’s look at the rotationalVelocity value assigned earlier in the gameStateNewGame() function:

function gameStateNewGame(){
   ConsoleLog.log("gameStateNewGame")
   //setup new game
   level = 0;
   score = 0;
   playerShips = 3;
   player.maxVelocity = 5;
   player.width = 32;
   player.height = 32;
   player.halfWidth = 16;
   player.halfHeight = 16;
   player.hitWidth = 24;
   player.hitHeight = 24;
   player.rotationalVelocity = 10; //how many degrees to turn the ship
   player.thrustAcceleration = .05;
   player.missileFrameDelay = 5;
   player.thrust = false;
   player.alpha = 1;
   player.rotation = 0;
   player.x = 0;
   player.y = 0;

   fillBackground();
   renderScoreBoard();
   switchGameState(GAME_STATE_NEW_LEVEL)

}

Other new player attributes

Along with the change in the rotational velocity, we have also modified the player’s width and height attributes. These are both now 32, which is the same as the tile width and height. If you look at the first frame of the ship_tiles.png tile sheet, you will see that the player ship does not fill the entire 32×32 tile. It is centered in the middle, taking up roughly 24×24 of the tile, which leaves enough space around the edges of the tile to eliminate clipping when the ship is rotated. We also used this concept when we created the rock rotations.

The extra pixels of padding added to eliminate clipping during frame rotation poses a small problem for collision detection. In the Chapter 8 version of the game, we used the width and height values for bounding box collision detection. We will not use those values in this new version because we have created two new variables to use for collision detection: hitWidth and hitHeight. Instead of setting these values to 32, they are 24. This new smaller value makes our collision detection more accurate than if we used the entire tile width and height.

The new boundingBoxCollide() algorithm

All the other game objects will also have new hitWidth and hitHeight attributes. We will modify the boundingBoxCollide() function from Geo Blaster Basic to use these new values for all collision testing:

function boundingBoxCollide(object1, object2) {

   var left1 = object1.x;
   var left2 = object2.x;
   var right1 = object1.x + object1.hitWidth;
   var right2 = object2.x + object2.hitWidth;
   var top1 = object1.y;
   var top2 = object2.y;
   var bottom1 = object1.y + object1.hitHeight;
   var bottom2 = object2.y + object2.hitHeight;

   if (bottom1 < top2) return(false);
   if (top1 > bottom2) return(false);

   if (right1 < left2) return(false);
   if (left1 > right2) return(false);

   return(true);

   }

Next, we will take a quick look at how we will use these same ideas to render the rest of the game objects with the new tile sheets.

Rendering the Other Game Objects

The rocks, saucers, missiles, and particles will all be rendered in a manner similar to the method implemented for the player ship. Let’s first look at the code for the saucer’s render function.

Rendering the saucers

The saucers do not have a multiple-cell tile sheet, but to be consistent, we will render them as though they do. This will allow us to add more animation tiles for the saucers later:

function renderSaucers() {
   var tempSaucer = {};
   var saucerLength = saucers.length-1;
   for (var saucerCtr=saucerLength;saucerCtr>=0;saucerCtr--){
      //ConsoleLog.log("saucer: " + saucerCtr);
      tempSaucer = saucers[saucerCtr];

      context.save(); //save current state in stack
      var sourceX = 0;
      var sourceY = 0;
      context.drawImage(saucerTiles, sourceX, sourceY, 30, 15,
      tempSaucer.x,tempSaucer.y,30,15);
      context.restore(); //pop old state on to screen
   }
}

There is no need to actually calculate the sourceX and sourceY values for the saucer because the saucer is only a single tile. In this instance, we can just set them to 0. We have hardcoded the saucer.width (30) and saucer.height (15) as an example, but with all the rest of the game objects, we will use the object width and height attributes rather than literals.

Next, let’s look at the rock rendering, which varies slightly from both the player ship and the saucers.

Rendering the rocks

The rock tiles are contained inside three separate tile sheets based on their size (large, medium, and small), and we have used only five tiles for each rock. The rocks are square with a symmetrical pattern, so we only need to precreate a single quarter-turn rotation for each of the three sizes.

Here is the renderRocks() function. Notice that we must “switch” based on the scale of the rock (1=large, 2=medium, 3=small) to choose the right tile sheet to render:

function renderRocks() {
    var tempRock = {};
    var rocksLength = rocks.length-1;
    for (var rockCtr=rocksLength;rockCtr>=0;rockCtr--){
    context.save(); //save current state in stack
    tempRock = rocks[rockCtr];
    var sourceX = Math.floor((tempRock.rotation) % 5) * tempRock.width;
    var sourceY = Math.floor((tempRock.rotation) /5) *tempRock.height;

    switch(tempRock.scale){
       case 1:
       context.drawImage(largeRockTiles, sourceX, sourceY, 
        tempRock.width,tempRock.height,tempRock.x,tempRock.y,
        tempRock.width,tempRock.height);
       break;
       case 2:
       context.drawImage(mediumRockTiles, sourceX, 
        sourceY,tempRock.width,tempRock.height,tempRock.x,tempRock.y,
        tempRock.width,tempRock.height);
       break;
       case 3:
       context.drawImage(smallRockTiles, sourceX, 
        sourceY,tempRock.width,tempRock.height,tempRock.x,tempRock.y,
        tempRock.width,tempRock.height);
       break;

    }

    context.restore(); //pop old state on to screen

    }
 }

In the renderRocks() function, we are no longer using the rock.rotation attribute as the angle of rotation as we did in Geo Blaster Basic. Instead, we have repurposed the rotation attribute to represent the tile id (0–4) of the current tile on the tile sheet to render.

In the Chapter 8 version, we were able to simulate faster or slower speeds for the rock rotations by simply giving each rock a random rotationInc value. This value, either negative for counterclockwise or positive for clockwise, was added to the rotation attribute on each frame. In this new tilesheet-based version, we only have five frames of animation, so we don’t want to skip frames because it will look choppy. Instead, we are going to add two new attributes to each rock: animationCount and animationDelay.

The animationDelay will represent the number of frames between each tile change for a given rock. The animationCount variable will restart at 0 after each tile frame change and will increase by 1 on each subsequent frame tick. When animationCount is greater than animationDelay, the rock.rotation value will be increased (clockwise) or decreased (counterclockwise). Here is the new code that we will have in our updateRocks() function:

tempRock.animationCount++;
   if (tempRock.animationCount > tempRock.animationDelay){
      tempRock.animationCount = 0;
      tempRock.rotation += tempRock.rotationInc;

      if (tempRock.rotation > 4){
         tempRock.rotation = 0;
      }else if (tempRock.rotation <0){
         tempRock.rotation = 4;
      }
   }

You will notice that we have hardcoded the values 4 and 0 into the tile id maximum and minimum checks. We could have just as easily used a constant or two variables for this purpose.

Rendering the missiles

Both the player missiles and saucer missiles are rendered in the same manner. For each, we simply need to know the tile id on the four-tile particleTiles image representing the tile we want to display. For the player missiles, this tile id is 1; for the saucer missile, the tile id is 0.

Let’s take a quick look at both of these functions:

 function renderPlayerMissiles() {
    var tempPlayerMissile = {};
    var playerMissileLength = playerMissiles.length-1;
    //ConsoleLog.log("render playerMissileLength=" + playerMissileLength);
    for (var playerMissileCtr=playerMissileLength; playerMissileCtr>=0;
     playerMissileCtr--){

    //ConsoleLog.log("draw player missile " + playerMissileCtr)
    tempPlayerMissile = playerMissiles[playerMissileCtr];
    context.save(); //save current state in stack
    var sourceX = Math.floor(1 % 4) * tempPlayerMissile.width;
    var sourceY = Math.floor(1 / 4) * tempPlayerMissile.height;

    context.drawImage(particleTiles, sourceX, sourceY,
     tempPlayerMissile.width,tempPlayerMissile.height,
     tempPlayerMissile.x,tempPlayerMissile.y,tempPlayerMissile.width,
     tempPlayerMissile.height);

    context.restore(); //pop old state on to screen
    }
    }

function renderSaucerMissiles() {
    var tempSaucerMissile = {};
    var saucerMissileLength = saucerMissiles.length-1;
    //ConsoleLog.log("saucerMissiles= " + saucerMissiles.length)
    for (var saucerMissileCtr=saucerMissileLength;
    saucerMissileCtr >= 0;saucerMissileCtr--){
    //ConsoleLog.log("draw player missile " + playerMissileCtr)
    tempSaucerMissile = saucerMissiles[saucerMissileCtr];
    context.save(); //save current state in stack
    var sourceX = Math.floor(0 % 4) * tempSaucerMissile.width;
    var sourceY = Math.floor(0 / 4) * tempSaucerMissile.height;

    context.drawImage(particleTiles, sourceX, sourceY,
     tempSaucerMissile.width,tempSaucerMissile.height,
     tempSaucerMissile.x,tempSaucerMissile.y,tempSaucerMissile.width,
     tempSaucerMissile.height);

    context.restore(); //pop old state on to screen

    }
    }

The particle explosion will also be rendered using a bitmap tile sheet, and its code will be very similar to the code for the projectiles. Let’s examine the particles next.

Rendering the particles

The particles will use the same four-tile parts.png file (as shown in Figure 9-8) that rendered the projectiles. The Geo Blaster Basic game from Chapter 8 used only a single white particle for all explosions. We replace the createExplode() function from this previous game with a new one that will be able to use a different-colored particle for each type of explosion. This way the rocks, saucers, and player ship can all have unique colored explosions.

The new createExplode() function will handle this by adding a final type parameter to its parameter list. Let’s look at the code:

function createExplode(x,y,num,type) {

   playSound(SOUND_EXPLODE,.5);
   for (var partCtr=0;partCtr<num;partCtr++){
      if (particlePool.length > 0){
        newParticle = particlePool.pop();
      newParticle.dx = Math.random()*3;
      if (Math.random()<.5){
         newParticle.dx *= -1;
      }
      newParticle.dy = Math.random()*3;
      if (Math.random()<.5){
      newParticle.dy *= -1;
      }

      newParticle.life = Math.floor(Math.random()*30+30);
      newParticle.lifeCtr = 0;
      newParticle.x = x;
      newParticle.width = 2;
      newParticle.height = 2;
      newParticle.y = y;
      newParticle.type = type;
      //ConsoleLog.log("newParticle.life=" + newParticle.life);
      particles.push(newParticle);
      }

   }

}

As the particle objects are created in createExplode(), we added a new type attribute to them. When an explosion is triggered in the checkCollisions() function, the call to createExplode() will now include this type value based on the object that was destroyed. Each rock already has a scale parameter that varies from 1 to 3 based on its size. We will use those as our base type value to pass in for the rocks. Now we only need type values for the player and the saucer. For the saucer we will use 0, and for the player we will use 4. We pulled these id values out of the air. We very well could have used 99 for the saucer and 200 for the player. We just could not use 1, 2, or 3 because those values are used for the rocks. The type breakdown looks like this:

  • Saucer: type=0

  • Large rock: type=1

  • Medium rock: type=2

  • Small rock: type=3

  • Player: type=4

This type value will need to be used in a switch statement inside the renderParticles() function to determine which of the four tiles to render for a given particle. Let’s examine this function now:

function renderParticles() {

   var tempParticle = {};
   var particleLength = particles.length-1;
   for (var particleCtr=particleLength;particleCtr>=0;particleCtr--){
      tempParticle = particles[particleCtr];
      context.save(); //save current state in stack

      var tile;

      console.log("part type=" + tempParticle.type)
      switch(tempParticle.type){
          case 0: // saucer
            tile = 0;
            break;
          case 1: //large rock
            tile = 2
            break;
          case 2: //medium rock
            tile = 3;
            break;
          case 3: //small rock
            tile = 0;
            break;
          case 4: //player
            tile = 1;
            break;

      }

      var sourceX = Math.floor(tile % 4) * tempParticle.width;
      var sourceY = Math.floor(tile / 4) * tempParticle.height;

      context.drawImage(particleTiles, sourceX, sourceY,
      tempParticle.width, tempParticle.height, tempParticle.x,
      tempParticle.y,tempParticle.width,tempParticle.height);

      context.restore(); //pop old state on to screen


 }

In checkCollisions(), we will need to pass the type parameter to the createExplode() function so the type can be assigned to the particles in the explosion. Here is an example of a createExplode() function call used for a rock instance:

createExplode(tempRock.x+tempRock.halfWidth,tempRock.y+tempRock.halfHeight,
 10,tempRock.scale);

We pass the tempRock.scale as the final parameter because we are using the rock’s scale as the type.

For a saucer:

createExplode(tempSaucer.x+tempSaucer.halfWidth,
 tempSaucer.y+tempSaucer.halfHeight,10,0);

For the saucers and the player, we will pass a number literal into the createExplode() function. In the saucer’s case, we pass in a 1. For the player ship, we pass in a 4:

createExplode(player.x+player.halfWidth, player.y+player.halfWidth,50,4);

Note that the createExplode() function call for the player is in the playerDie() function, which is called from checkCollisions().

Note

After we discuss adding sound and a particle pool to this game, we will present the entire set of code (Example 9-1), replacing the Geo Blaster Basic code. There will be no need to make the changes to the individual functions.

Adding Sound

In Chapter 7, we covered everything we need to know to add robust sound management to our canvas applications. If you are unfamiliar with the concepts presented in Chapter 7, please review that chapter first. In this chapter, we will cover only the code necessary to include sound in our game.

Arcade games need to play many sounds simultaneously, and sometimes those sounds play very rapidly in succession. In Chapter 7, we used the HTML5 <audio> tag to create a pool of sounds, solving the problems associated with playing the same sound instance multiple times.

Note

As of this writing, the Opera browser in Windows offers the best support for playing sounds. If you are having trouble with the sound in this game, any other sound example in the book, or in your own games, please test them out in the Opera browser.

The sounds for our game

We will be adding three sounds to our game:

  • A sound for when the player shoots a projectile (shoot1.mp3, .ogg, .wav)

  • A sound for explosions (explode1.mp3, .ogg, .wav)

  • A sound for when the saucer shoots a projectile (saucershoot.mp3, .ogg, .wav)

In the file download for this chapter, we have provided each of the three sounds in three different formats: .wav, .ogg, and .mp3.

Adding sound instances and management variables to the game

In the variable definition section of our game code, we will create variables to work with the sound manager code from Chapter 7. We will create three instances of each sound that goes into our pool:

var explodeSound;
var explodeSound2;
var explodeSound3;
var shootSound;
var shootSound2;
var shootSound3;
var saucershootSound;
var saucershootSound2;
var saucershootSound3;

We also need to create an array to hold our pool of sounds:

var soundPool = new Array();

To control which sound we want to play, we will assign a constant string to each, and to play the sound, we only ever need to use the constant. This way, we can change the sound names easily, which will help in refactoring code if we want to modify the sounds at a later time:

 const SOUND_EXPLODE = "explode1";
     const SOUND_SHOOT = "shoot1";
     const SOUND_SAUCER_SHOOT = "saucershoot"

Finally, we need a variable called audioType, which we will use to reference the current file type (.ogg, .mp3, or .wav) by the sound manager code.

Loading in sounds and tile sheet assets

In Chapter 7, we used a function to load all of the game assets while our state machine waited in an idle state. We will add this code to our game in a function called gameStateInit():

function gameStateInit() {
   loadCount = 0;
   itemsToLoad  = 16;

   explodeSound = document.createElement("audio");
   document.body.appendChild(explodeSound);
   audioType = supportedAudioFormat(explodeSound);
   explodeSound.setAttribute("src", "explode1." + audioType);
   explodeSound.addEventListener("canplaythrough",itemLoaded,false);

   explodeSound2 = document.createElement("audio");
   document.body.appendChild(explodeSound2);
   explodeSound2.setAttribute("src", "explode1." + audioType);
   explodeSound2.addEventListener("canplaythrough",itemLoaded,false);

   explodeSound3 = document.createElement("audio");
   document.body.appendChild(explodeSound3);
   explodeSound3.setAttribute("src", "explode1." + audioType);
   explodeSound3.addEventListener("canplaythrough",itemLoaded,false);

   shootSound = document.createElement("audio");
   audioType = supportedAudioFormat(shootSound);
   document.body.appendChild(shootSound);
   shootSound.setAttribute("src", "shoot1." + audioType);
   shootSound.addEventListener("canplaythrough",itemLoaded,false);

   shootSound2 = document.createElement("audio");
   document.body.appendChild(shootSound2);
   shootSound2.setAttribute("src", "shoot1." + audioType);
   shootSound2.addEventListener("canplaythrough",itemLoaded,false);

   shootSound3 = document.createElement("audio");
   document.body.appendChild(shootSound3);
   shootSound3.setAttribute("src", "shoot1." + audioType);
   shootSound3.addEventListener("canplaythrough",itemLoaded,false);

   saucershootSound = document.createElement("audio");
   audioType = supportedAudioFormat(saucershootSound);
   document.body.appendChild(saucershootSound);
   saucershootSound.setAttribute("src", "saucershoot." + audioType);
   saucershootSound.addEventListener("canplaythrough",itemLoaded,false);

   saucershootSound2 = document.createElement("audio");
   document.body.appendChild(saucershootSound2);
   saucershootSound2.setAttribute("src", "saucershoot." + audioType);
   saucershootSound2.addEventListener("canplaythrough",itemLoaded,false);

   saucershootSound3 = document.createElement("audio");
   document.body.appendChild(saucershootSound3);
   saucershootSound3.setAttribute("src", "saucershoot." + audioType);
   saucershootSound3.addEventListener("canplaythrough",itemLoaded,false);

   shipTiles = new Image();
   shipTiles.src = "ship_tiles.png";
   shipTiles.onload = itemLoaded;

   shipTiles2 = new Image();
   shipTiles2.src = "ship_tiles2.png";
   shipTiles2.onload = itemLoaded;

   saucerTiles= new Image();
   saucerTiles.src = "saucer.png";
   saucerTiles.onload = itemLoaded;

   largeRockTiles = new Image();
   largeRockTiles.src = "largerocks.png";
   largeRockTiles.onload = itemLoaded;

   mediumRockTiles = new Image();
   mediumRockTiles.src = "mediumrocks.png";
   mediumRockTiles.onload = itemLoaded;

   smallRockTiles = new Image();
   smallRockTiles.src = "smallrocks.png";
   smallRockTiles.onload = itemLoaded;

   particleTiles = new Image();
   particleTiles.src = "parts.png";
   particleTiles.onload = itemLoaded;

   switchGameState(GAME_STATE_WAIT_FOR_LOAD);

}

Notice that we must create and preload three separate instances of each sound, even though they share the same sound file (or files). In this function, we also load in our tile sheets. The application scope itemsToLoad variable will be used to check against the application scope loadCount variable in the load event callback itemLoaded() function, which is shared by all assets to be loaded. This will make it easy for the application to change state so that it can start playing the game when all assets have loaded. Let’s briefly look at the itemLoaded() function now:

function itemLoaded(event) {

   loadCount++;
   //console.log("loading:" + loadCount)
   if (loadCount >= itemsToLoad) {

       shootSound.removeEventListener("canplaythrough",itemLoaded, false);
       shootSound2.removeEventListener("canplaythrough",itemLoaded, false);
       shootSound3.removeEventListener("canplaythrough",itemLoaded, false);
       explodeSound.removeEventListener("canplaythrough",itemLoaded,false);
       explodeSound2.removeEventListener("canplaythrough",itemLoaded,false);
       explodeSound3.removeEventListener("canplaythrough",itemLoaded,false);
       saucershootSound.removeEventListener("canplaythrough",itemLoaded,false);
       saucershootSound2.removeEventListener("canplaythrough",itemLoaded,false);
       saucershootSound3.removeEventListener("canplaythrough",itemLoaded,false);

       soundPool.push({name:"explode1", element:explodeSound, played:false});
       soundPool.push({name:"explode1", element:explodeSound2, played:false});
       soundPool.push({name:"explode1", element:explodeSound3, played:false});
       soundPool.push({name:"shoot1", element:shootSound, played:false});
       soundPool.push({name:"shoot1", element:shootSound2, played:false});
       soundPool.push({name:"shoot1", element:shootSound3, played:false});
       soundPool.push({name:"saucershoot", element:saucershootSound, 
        played:false});
       soundPool.push({name:"saucershoot", element:saucershootSound2,
        played:false});
       soundPool.push({name:"saucershoot", element:saucershootSound3,
        played:false});

       switchGameState(GAME_STATE_TITLE)
    }

}

In this function, we first remove the event listener from each loaded item, then add the sounds to our sound pool. Finally, we call the switchGameState() to send the game to the title screen.

Playing sounds

Sounds will be played using the playSound() function from Chapter 7. We will not reprint that function here, but it will be in Example 9-1 where we give the entire set of code for the game. We will call the playSound() function at various instances in our code to play the needed sounds. For example, the createExplode() function presented earlier in this chapter included this line:

playSound(SOUND_EXPLODE,.5);

When we want to play a sound instance from the pool, we call the playSound() function and pass in the constants representing the sound and the volume for the sound. If an instance of the sound is available in the pool, it will be used and the sound will play.

Now, let’s move on to another type of application pool—the object pool.

Pooling Object Instances

We have looked at object pools as they relate to sounds, but we have not applied this concept to our game objects. Object pooling is a technique designed to save processing time, so it is very applicable to an arcade game application such as the one we are building. By pooling object instances, we avoid the sometimes processor-intensive task of creating object instances on the fly during game execution. This is especially applicable to our particle explosions, as we create multiple objects on the same frame tick. On a lower-powered platform, such as a handheld device, object pooling can help increase frame rate.

Object pooling in Geo Blaster Extended

In our game, we will apply the pooling concept to the explosion particles. Of course, we can extend this concept to rocks, projectiles, saucers, and any other type of object that requires multiple instances. For this example, though, let’s focus on the particles. As we will see, adding pooling in JavaScript is a relatively simple but powerful technique.

Adding pooling variables to our game

We will need to add four application scope variables to our game to make use of pooling for our game particle:

 var particlePool = [];
     var maxParticles = 200;
     var newParticle;
     var tempParticle;

The particlePool array will hold the list of particle object instances that are waiting to be used. When createExplode() needs to use a particle, it will first look to see whether any are available in this array. If one is available, it will be “popped” off the top of the particlePool stack and placed in the application scope newParticle variable—which is a reference to the pooled particle. The createExplode() function will set the properties of the newParticle, and then “push” it to the end of the existing particles array.

Once a particle’s life has been exhausted, the updateParticles() function will splice the particle from the particles array and push it back into the particlePool array. We have created the tempParticle reference to alleviate the updateParticles() function’s need to create this instance on each frame tick.

The maxParticles value will be used in a new function called createObjectPools(). We will call this function in the gameStateInit() state function before we create the sound and tile sheet loading events.

Let’s take a look at the createObjectPools() function now:

function createObjectPools(){
   for (var ctr=0;ctr<maxParticles;ctr++){
      var newParticle = {};
      particlePool.push(newParticle)
   }
   console.log("particlePool=" + particlePool.length)
}

As you can see, we simply iterate from 0 to 1 less than the maxParticles value, and place a generic object instance at each element in the pool. When a particle is needed, the createExplode() function will look to see whether particlePool.length is greater than 0. If a particle is available, it will be added to the particles array after its attributes are set. If no particle is available, none will be used.

Note

This functionality can be extended to add a particle as needed to the pool when none is available. We have not added that functionality to our example, but it is common in some pooling algorithms.

Here is the newly modified createExplode() function in its entirety:

function createExplode(x,y,num,type) {

   playSound(SOUND_EXPLODE,.5);
   for (var partCtr=0;partCtr<num;partCtr++){
      if (particlePool.length > 0){

      newParticle = particlePool.pop();
      newParticle.dx = Math.random()*3;
      if (Math.random()<.5){
         newParticle.dx* = -1;
      }
      newParticle.dy = Math.random()*3;
      if (Math.random()<.5){
      newParticle.dy* = -1;
      }

      newParticle.life = Math.floor(Math.random()*30+30);
      newParticle.lifeCtr = 0;
      newParticle.x = x;
      newParticle.width = 2;
      newParticle.height = 2;
      newParticle.y = y;
      newParticle.type = type;
      //ConsoleLog.log("newParticle.life=" + newParticle.life);
      particles.push(newParticle);
      }

   }

}

The updateParticles() function will loop through the particles instances, update the attributes of each, and then check to see whether the particle’s life has been exhausted. If it has, the function will place the particle back in the pool. Here is the code we will add to updateParticles() to replenish the pool:

if (remove) {
   particlePool.push(tempParticle)
   particles.splice(particleCtr,1)

}

Adding in a Step Timer

In Chapter 8, we created a simple FrameRateCounter object prototype that was used to display the current frame rate as the game was running. We are going to extend the functionality of this counter to add in a “step timer.” The step timer will use the time difference calculated between frames to create a “step factor.” This step factor will be used when updating the positions of the objects on the canvas. The result will be smoother rendering of the game objects when there are drops in frame rate, as well as keeping relatively consistent game play on browsers and systems that cannot maintain the frame rate needed to play the game effectively.

How the step timer works

We will update the constructor function for FrameRateCounter to accept in a new single parameter called fps. This value will represent the frames per second that we want our game to run:

function FrameRateCounter(fps) {
   if (fps == undefined){
      this.fps = 40
   }else{
      this.fps = fps
   }

If no fps value is passed in, the value 40 will be used.

We will also add in two new object-level scope variables to calculate the step in our step timer:

this.lastTime = dateTemp.getTime();
this.step = 1;

The lastTime variable will contain the time in which the previous frame completed its work.

We calculate the step by comparing the current time value with the lastTime value on each frame tick. This calculation will occur in the FrameRateCounter countFrames() function:

FrameRateCounter.prototype.countFrames=function() {

   var dateTemp = new Date();

   var timeDifference = dateTemp.getTime()-this.lastTime;
   this.step = (timeDifference/1000)*this.fps;
   this.lastTime = dateTemp.getTime();

The local timeDifference value is calculated by subtracting the lastTime value from the current time (represented by the dateTemp.getTime() return value).

To calculate the step value, divide the timeDifference by 1000 (the number of milliseconds in a single second), and multiply the result by the desired frame rate. If the game is running with no surplus or no deficit in time between frame ticks, the step value will be 1. If the current frame tick took longer than a single frame to finish, the value will be greater than 1 (a deficit). If the current frame took less time than a single frame, the step value will be less than 1 (a surplus).

For example, if the last frame took too long to process, the current frame will compensate by moving each object a little bit more than the step value of 1. Let’s illustrate this with a simple example.

Let’s say we want the saucer to move five pixels to the right on each frame tick. This would be a dx value of 5.

For this example, we will also say that our desired frame rate is 40 FPS. This means that we want each frame tick to use up 25 milliseconds (1000/40 = 25).

Let’s also suppose that the timeDifference between the current frame and the last frame is 26 milliseconds. Our game is running at a deficit of 1 millisecond per frame—this means that the game processing is taking more time than we want it to.

To calculate the step value, divide the timeDifference by 1000: 26/1000 = .026.

We multiply this value by the desired fps for our game: .026 * 40 = 1.04

Our step value is 1.04 for the current frame. Because of the deficit in processing time, we want to move each game object slightly more than a frame so there is no surplus or deficit. In the case of no surplus or deficit, the step value would be 1. If there is a surplus, the step value would be less than 1.

This step value will be multiplied to the changes in movement vectors for each object in the update functions. This allows the game to keep a relatively smooth look even when there are fluctuations in the frame rate. In addition, the game will update the screen in a relatively consistent manner across the various browsers and systems, resulting in game play that is relatively consistent for each user.

Here are the new movement vector calculations for each object:

player

player.x += player.movingX*frameRateCounter.step;
player.y += player.movingY*frameRateCounter.step;
playerMissiles

tempPlayerMissile.x += tempPlayerMissile.dx*frameRateCounter.step;
tempPlayerMissile.y += tempPlayerMissile.dy*frameRateCounter.step;
rocks

tempRock.x += tempRock.dx*frameRateCounter.step;
tempRock.y += tempRock.dy*frameRateCounter.step;
saucers

tempSaucer.x += tempSaucer.dx*frameRateCounter.step;
tempSaucer.y += tempSaucer.dy*frameRateCounter.step;
saucerMissiles

tempSaucerMissile.x += tempSaucerMissile.dx*frameRateCounter.step;
tempSaucerMissile.y += tempSaucerMissile.dy*frameRateCounter.step;
particles

tempParticle.x += tempParticle.dx*frameRateCounter.step;
tempParticle.y += tempParticle.dy*frameRateCounter.step;

We have now covered all of the major changes to turn Geo Blaster Basic into Geo Blaster Extended. Let’s look at Example 9-1, which has the entire code for the final game.

Geo Blaster Extended Full Source

Example 9-1. Geo Blaster Extended full source code listing
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CH9EX1: Geo Blaster Extended</title>
<script src="modernizr-1.6.min.js"></script>
<script type="text/javascript">
window.addEventListener('load', eventWindowLoaded, false);
function eventWindowLoaded() {

    canvasApp();

}

function canvasSupport () {
   return Modernizr.canvas;
}

function supportedAudioFormat(audio) {
   var returnExtension = "";
   if (audio.canPlayType("audio/ogg") =="probably" || 
       audio.canPlayType("audio/ogg") == "maybe") {
         returnExtension = "ogg";
   } else if(audio.canPlayType("audio/wav") =="probably" || 
       audio.canPlayType("audio/wav") == "maybe") {
         returnExtension = "wav";
   } else if(audio.canPlayType("audio/wav") == "probably" || 
       audio.canPlayType("audio/wav") == "maybe") {
         returnExtension = "mp3";
   }

   return returnExtension;

}

function canvasApp(){

   if (!canvasSupport()) {
          return;
     }else{
       theCanvas = document.getElementById("canvas");
       context = theCanvas.getContext("2d");
   }

   //sounds
   const SOUND_EXPLODE = "explode1";
   const SOUND_SHOOT = "shoot1";
   const SOUND_SAUCER_SHOOT = "saucershoot"
   const MAX_SOUNDS = 9;
   var soundPool = new Array();
   var explodeSound;
   var explodeSound2;
   var explodeSound3;
   var shootSound;
   var shootSound2;
   var shootSound3;
   var saucershootSound;
   var saucershootSound2;
   var saucershootSound3;
   var audioType;

   //application states
   const GAME_STATE_INIT = 0;
   const GAME_STATE_WAIT_FOR_LOAD = 5;
   const GAME_STATE_TITLE = 10;
   const GAME_STATE_NEW_GAME = 20;
   const GAME_STATE_NEW_LEVEL = 30;
   const GAME_STATE_PLAYER_START = 40;
   const GAME_STATE_PLAY_LEVEL = 50;
   const GAME_STATE_PLAYER_DIE = 60;
   const GAME_STATE_GAME_OVER = 70;
   var currentGameState = 0;
   var currentGameStateFunction = null;

   //title screen
   var titleStarted = false;

   //game over screen
   var gameOverStarted = false;

   //objects for game play

   //game environment
   var score = 0;
   var level = 0;
   var extraShipAtEach = 10000;
   var extraShipsEarned = 0;
   var playerShips = 3;

   //playfield
   var xMin = 0;
   var xMax = 400;
   var yMin = 0;
   var yMax = 400;

   //score values
   var bigRockScore = 50;
   var medRockScore = 75;
   var smlRockScore = 100;
   var saucerScore = 300;

   //rock scale constants
   const ROCK_SCALE_LARGE = 1;
   const ROCK_SCALE_MEDIUM = 2;
   const ROCK_SCALE_SMALL = 3;

   //create game objects and arrays
   var player = {};
   var rocks = [];
   var saucers = [];
   var playerMissiles = [];
   var particles = [];
   var saucerMissiles = [];
   var particlePool = [];
   var maxParticles = 200;
   var newParticle;
   var tempParticle;

   //level specific
   var levelRockMaxSpeedAdjust = 1;
   var levelSaucerMax = 1;
   var levelSaucerOccurrenceRate = 25;
   var levelSaucerSpeed = 1;
   var levelSaucerFireDelay = 300;
   var levelSaucerFireRate = 30;
   var levelSaucerMissileSpeed = 1;

   //keyPresses
   var keyPressList=[];

   //tile sheets
   var shipTiles;
   var shipTiles2;
   var saucerTiles;
   var largeRockTiles;
   var mediumRockTiles;
   var smallRockTiles;
   var particleTiles;

    function itemLoaded(event) {

      loadCount++;
      //console.log("loading:" + loadCount)
      if (loadCount >= itemsToLoad) {

         shootSound.removeEventListener("canplaythrough",itemLoaded, false);
         shootSound2.removeEventListener("canplaythrough",itemLoaded, false);
         shootSound3.removeEventListener("canplaythrough",itemLoaded, false);
         explodeSound.removeEventListener("canplaythrough",itemLoaded,false);
         explodeSound2.removeEventListener("canplaythrough",itemLoaded,false);
         explodeSound3.removeEventListener("canplaythrough",itemLoaded,false);
         saucershootSound.removeEventListener("canplaythrough",itemLoaded,false);
         saucershootSound2.removeEventListener("canplaythrough",itemLoaded, 
          false);
         saucershootSound3.removeEventListener("canplaythrough",itemLoaded,
          false);

         soundPool.push({name:"explode1", element:explodeSound, played:false});
         soundPool.push({name:"explode1", element:explodeSound2, played:false});
         soundPool.push({name:"explode1", element:explodeSound3, played:false});
         soundPool.push({name:"shoot1", element:shootSound, played:false});
         soundPool.push({name:"shoot1", element:shootSound2, played:false});
         soundPool.push({name:"shoot1", element:shootSound3, played:false});
         soundPool.push({name:"saucershoot", element:saucershootSound, 
          played:false});
         soundPool.push({name:"saucershoot", element:saucershootSound2,
          played:false});
         soundPool.push({name:"saucershoot", element:saucershootSound3,
          played:false});

         switchGameState(GAME_STATE_TITLE)

      }

   }
   function playSound(sound,volume) {
      ConsoleLog.log("play sound" + sound);
      var soundFound = false;
      var soundIndex = 0;
      var tempSound;

      if (soundPool.length> 0) {
         while (!soundFound && soundIndex < soundPool.length) {

            var tSound = soundPool[soundIndex];
            if ((tSound.element.ended || !tSound.played) && tSound.name == sound) {
               soundFound = true;
               tSound.played = true;
            } else {
               soundIndex++;
            }

         }
      }      if (soundFound) {
         ConsoleLog.log("sound found");
         tempSound = soundPool[soundIndex].element;
         //tempSound.setAttribute("src", sound + "." + audioType);
         //tempSound.loop = false;
         //tempSound.volume = volume;
         tempSound.play();

      } else if (soundPool.length < MAX_SOUNDS){
         ConsoleLog.log("sound not found");
         tempSound = document.createElement("audio");
         tempSound.setAttribute("src", sound + "." + audioType);
         tempSound.volume = volume;
         tempSound.play();
         soundPool.push({name:sound, element:tempSound, type:audioType, 
          played:true});
      }

   }
   function runGame(){
      currentGameStateFunction();
   }

   function switchGameState(newState) {
      currentGameState = newState;
      switch (currentGameState) {

         case GAME_STATE_INIT:
            currentGameStateFunction = gameStateInit;
            break;
         case GAME_STATE_WAIT_FOR_LOAD:
            currentGameStateFunction = gameStateWaitForLoad;
            break;
         case GAME_STATE_TITLE:
             currentGameStateFunction = gameStateTitle;
             break;
         case GAME_STATE_NEW_GAME:
             currentGameStateFunction = gameStateNewGame;
             break;
         case GAME_STATE_NEW_LEVEL:
             currentGameStateFunction = gameStateNewLevel;
             break;
         case GAME_STATE_PLAYER_START:
             currentGameStateFunction = gameStatePlayerStart;
             break;
         case GAME_STATE_PLAY_LEVEL:
             currentGameStateFunction = gameStatePlayLevel;
             break;
         case GAME_STATE_PLAYER_DIE:
             currentGameStateFunction = gameStatePlayerDie;
             break;
         case GAME_STATE_GAME_OVER:
             currentGameStateFunction = gameStateGameOver;
             break;

      }

   }

   function gameStateWaitForLoad(){
      //do nothing while loading events occur
      console.log("doing nothing...")
   }

   function createObjectPools(){
      for (var ctr=0;ctr<maxParticles;ctr++){
         var newParticle = {};
         particlePool.push(newParticle)
      }
      console.log("particlePool=" + particlePool.length)
   }

   function gameStateInit() {
      createObjectPools();

      loadCount = 0;
      itemsToLoad = 16;

      explodeSound = document.createElement("audio");
      document.body.appendChild(explodeSound);
      audioType = supportedAudioFormat(explodeSound);
      explodeSound.setAttribute("src", "explode1." + audioType);
      explodeSound.addEventListener("canplaythrough",itemLoaded,false);

      explodeSound2 = document.createElement("audio");
      document.body.appendChild(explodeSound2);
      explodeSound2.setAttribute("src", "explode1." + audioType);
      explodeSound2.addEventListener("canplaythrough",itemLoaded,false);

      explodeSound3 = document.createElement("audio");
      document.body.appendChild(explodeSound3);
      explodeSound3.setAttribute("src", "explode1." + audioType);
      explodeSound3.addEventListener("canplaythrough",itemLoaded,false);

      shootSound = document.createElement("audio");
      audioType = supportedAudioFormat(shootSound);
      document.body.appendChild(shootSound);
      shootSound.setAttribute("src", "shoot1." + audioType);
      shootSound.addEventListener("canplaythrough",itemLoaded,false);

      shootSound2 = document.createElement("audio");
      document.body.appendChild(shootSound2);
      shootSound2.setAttribute("src", "shoot1." + audioType);
      shootSound2.addEventListener("canplaythrough",itemLoaded,false);

      shootSound3 = document.createElement("audio");
      document.body.appendChild(shootSound3);
      shootSound3.setAttribute("src", "shoot1." + audioType);
      shootSound3.addEventListener("canplaythrough",itemLoaded,false);

      saucershootSound = document.createElement("audio");
      audioType = supportedAudioFormat(saucershootSound);
      document.body.appendChild(saucershootSound);
      saucershootSound.setAttribute("src", "saucershoot." + audioType);
      saucershootSound.addEventListener("canplaythrough",itemLoaded,false);

      saucershootSound2 = document.createElement("audio");
      document.body.appendChild(saucershootSound2);
      saucershootSound2.setAttribute("src", "saucershoot." + audioType);
      saucershootSound2.addEventListener("canplaythrough",itemLoaded,false);

      saucershootSound3 = document.createElement("audio");
      document.body.appendChild(saucershootSound3);
      saucershootSound3.setAttribute("src", "saucershoot." + audioType);
      saucershootSound3.addEventListener("canplaythrough",itemLoaded,false);

      shipTiles = new Image();
      shipTiles.src = "ship_tiles.png";
      shipTiles.onload = itemLoaded;

      shipTiles2 = new Image();
      shipTiles2.src = "ship_tiles2.png";
      shipTiles2.onload = itemLoaded;

      saucerTiles= new Image();
      saucerTiles.src = "saucer.png";
      saucerTiles.onload = itemLoaded;

      largeRockTiles = new Image();
      largeRockTiles.src = "largerocks.png";
      largeRockTiles.onload = itemLoaded;

      mediumRockTiles = new Image();
      mediumRockTiles.src = "mediumrocks.png";
      mediumRockTiles.onload = itemLoaded;

      smallRockTiles = new Image();
      smallRockTiles.src = "smallrocks.png";
      smallRockTiles.onload = itemLoaded;

      particleTiles = new Image();
      particleTiles.src = "parts.png";
      particleTiles.onload = itemLoaded;

      switchGameState(GAME_STATE_WAIT_FOR_LOAD);
   }

   function gameStateTitle() {
      if (titleStarted !=true){
         fillBackground();
         setTextStyleTitle();
         context.fillText  ("Geo Blaster X-ten-d", 120, 70);

         setTextStyle();
         context.fillText  ("Press Space To Play", 130, 140);

         setTextStyleCredits();
         context.fillText  ("An HTML5 Example Game", 125, 200);
         context.fillText  ("From our upcoming HTML5 Canvas", 100, 215);
         context.fillText  ("book on O'Reilly Press", 130, 230);

         context.fillText  ("Game Code - Jeff Fulton", 130, 260);
         context.fillText  ("Sound Manager - Steve Fulton", 120, 275);


         titleStarted = true;
      }else{
         //wait for space key click
         if (keyPressList[32]==true){
            ConsoleLog.log("space pressed");
            switchGameState(GAME_STATE_NEW_GAME);
            titleStarted = false;

         }

      }

   }

   function gameStateNewGame(){
      ConsoleLog.log("gameStateNewGame")
      //set up new game
      level = 0;
      score = 0;
      playerShips = 3;
      player.maxVelocity = 5;
      player.width = 32;
      player.height = 32;
      player.halfWidth = 16;
      player.halfHeight = 16;
      player.hitWidth = 24;
      player.hitHeight = 24;
      player.rotationalVelocity = 10; //how many degrees to turn the ship
      player.thrustAcceleration = .05;
      player.missileFrameDelay = 5;
      player.thrust = false;
      player.alpha = 1;
      player.rotation = 0;
      player.x = 0;
      player.y = 0;

      fillBackground();
      renderScoreBoard();
      switchGameState(GAME_STATE_NEW_LEVEL)

   }

   function gameStateNewLevel(){
      rocks = [];
      saucers = [];
      playerMissiles = [];
      particles = [];
      saucerMissiles = [];
      level++;
      levelRockMaxSpeedAdjust = level*.25;
      if (levelRockMaxSpeedAdjust > 3){
         levelRockMaxSpeed = 3;
      }

      levelSaucerMax = 1+Math.floor(level/10);
      if (levelSaucerMax > 5){
         levelSaucerMax = 5;
      }
      levelSaucerOccurrenceRate = 10+3*level;
      if (levelSaucerOccurrenceRate > 35){
         levelSaucerOccurrenceRate = 35;
      }
      levelSaucerSpeed = 1+.5*level;
      if (levelSaucerSpeed>5){
         levelSaucerSpeed = 5;
      }
      levelSaucerFireDelay = 120-10*level;
      if (levelSaucerFireDelay<20) {
         levelSaucerFireDelay = 20;
      }

      levelSaucerFireRate = 20 + 3*level;
      if (levelSaucerFireRate<50) {
         levelSaucerFireRate = 50;
      }

      levelSaucerMissileSpeed = 1+.2*level;
      if (levelSaucerMissileSpeed > 4){
         levelSaucerMissileSpeed = 4;
      }
      //create level rocks
      for (var newRockctr=0;newRockctr<level+3;newRockctr++){
            var newRock = {};

            newRock.scale = 1;
            //scale
            //1 = large
            //2 = medium
            //3 = small
            //these will be used as the divisor for the new size
            //50/1 = 50
            //50/2 = 25
            //50/3 = 16
            newRock.width = 64;
            newRock.height = 64;
            newRock.halfWidth = 32;
            newRock.halfHeight = 32;

            newRock.hitWidth = 48;
            newRock.hitHeight = 48;

            //start all new rocks in upper left for ship safety
            newRock.x = Math.floor(Math.random()*50);
            //ConsoleLog.log("newRock.x=" + newRock.x);
            newRock.y = Math.floor(Math.random()*50);
            //ConsoleLog.log("newRock.y=" + newRock.y);
            newRock.dx = (Math.random()*2)+levelRockMaxSpeedAdjust;
            if (Math.random()<.5){
               newRock.dx *= -1;
            }
            newRock.dy=(Math.random()*2)+levelRockMaxSpeedAdjust;
            if (Math.random()<.5){
               newRock.dy *= -1;
            }
            //rotation speed and direction

            if (Math.random()<.5){
               newRock.rotationInc = -1;
            }else{
               newRock.rotationInc = 1;
            }

            newRock.animationDelay = Math.floor(Math.random()*3+1);
            newRock.animationCount = 0;

            newRock.scoreValue = bigRockScore;
            newRock.rotation = 0;

            rocks.push(newRock);
            //ConsoleLog.log("rock created rotationInc=" + newRock.rotationInc);
         }
      resetPlayer();
      switchGameState(GAME_STATE_PLAYER_START);

   }

   function gameStatePlayerStart(){

      fillBackground();
      renderScoreBoard();
      if (player.alpha < 1){
         player.alpha += .01;

         ConsoleLog.log("player.alpha=" + context.globalAlpha)
      }else{
         switchGameState(GAME_STATE_PLAY_LEVEL);
         player.safe = false; // added chapter 9

      }

      //renderPlayerShip(player.x, player.y,270,1);
      context.globalAlpha = 1;
      //new in chapter 9
      checkKeys();
      update();
      render(); //added chapter 9
      checkCollisions();
      checkForExtraShip();
      checkForEndOfLevel();
      frameRateCounter.countFrames();

   }

   function gameStatePlayLevel(){
      checkKeys();
      update();
      render();
      checkCollisions();
      checkForExtraShip();
      checkForEndOfLevel();
      frameRateCounter.countFrames();

   }

   function resetPlayer() {
      player.rotation = 270;
      player.x = .5*xMax;
      player.y = .5*yMax;
      player.facingX = 0;
      player.facingY = 0;
      player.movingX = 0;
      player.movingY = 0;
      player.alpha = 0;
      player.missileFrameCount = 0;
      //added chapter 9
      player.safe = true;
   }

   function checkForExtraShip() {
      if (Math.floor(score/extraShipAtEach) > extraShipsEarned) {
         playerShips++
         extraShipsEarned++;
      }
   }

   function checkForEndOfLevel(){
      if (rocks.length==0) {
         switchGameState(GAME_STATE_NEW_LEVEL);
      }
   }

   function gameStatePlayerDie(){
      if (particles.length >0 || playerMissiles.length>0) {
         fillBackground();
         renderScoreBoard();
         updateRocks();
         updateSaucers();
         updateParticles();
         updateSaucerMissiles();
         updatePlayerMissiles();
         renderRocks();
         renderSaucers();
         renderParticles();
         renderSaucerMissiles();
         renderPlayerMissiles();
         frameRateCounter.countFrames();

      }else{
         playerShips--;
         if (playerShips<1) {
            switchGameState(GAME_STATE_GAME_OVER);
         }else{
            //resetPlayer();
            switchGameState(GAME_STATE_PLAYER_START);
         }
      }
   }

   function gameStateGameOver() {
      //ConsoleLog.log("Game Over State");
      if (gameOverStarted !=true){
         fillBackground();
         renderScoreBoard();
         setTextStyle();
         context.fillText  ("Game Over!", 160, 70);
         context.fillText  ("Press Space To Play", 130, 140);

         gameOverStarted = true;
      }else{
         //wait for space key click
         if (keyPressList[32]==true){
            ConsoleLog.log("space pressed");
            switchGameState(GAME_STATE_TITLE);
            gameOverStarted = false;

         }

      }
   }

   function fillBackground() {
      // draw background and text
      context.fillStyle = '#000000';
      context.fillRect(xMin, yMin, xMax, yMax);

   }

   function setTextStyle() {
      context.fillStyle = '#ffffff';
      context.font = '15px _sans';
      context.textBaseline = 'top';
   }

   function setTextStyleTitle() {
      context.fillStyle  = '#54ebeb';
      context.font = '20px _sans';
      context.textBaseline = 'top';
   }

   function setTextStyleCredits() {
      context.fillStyle = '#ffffff';
      context.font = '12px _sans';
      context.textBaseline = 'top';
   }

   function renderScoreBoard() {

      context.fillStyle = "#ffffff";
      context.fillText('Score ' + score, 10, 20);
      renderPlayerShip(200,16,270,.75)
      context.fillText('X ' + playerShips, 220, 20);

      context.fillText('FPS: ' + frameRateCounter.lastFrameCount, 300,20)

   }

   function checkKeys() {
      //check keys

      if (keyPressList[38]==true){
      //thrust
         var angleInRadians = player.rotation * Math.PI / 180;
         player.facingX = Math.cos(angleInRadians);
         player.facingY = Math.sin(angleInRadians);

         var movingXNew = player.movingX+player.thrustAcceleration*player.facingX;
         var movingYNew = player.movingY+player.thrustAcceleration*player.facingY;

         var currentVelocity = Math.sqrt ((movingXNew*movingXNew) +
         (movingXNew*movingXNew));

         if (currentVelocity < player.maxVelocity) {
            player.movingX = movingXNew;
            player.movingY = movingYNew;
         }
         player.thrust = true;

      }else{
         player.thrust = false;
      }

      if (keyPressList[37]==true) {
         //rotate counterclockwise
         player.rotation -= player.rotationalVelocity;
         if (player.rotation <0) {
            player.rotation = 350
         }

      }

      if (keyPressList[39]==true) {
         //rotate clockwise
         player.rotation += player.rotationalVelocity;
         if (player.rotation >350) {
            player.rotation = 10
         }
      }

      if (keyPressList[32]==true) {
            if (player.missileFrameCount>player.missileFrameDelay){
            playSound(SOUND_SHOOT,.5);
            firePlayerMissile();
            player.missileFrameCount = 0;

         }
      }
   }

   function update() {
      updatePlayer();
      updatePlayerMissiles();
      updateRocks();
      updateSaucers();
      updateSaucerMissiles();
      updateParticles();
   }

   function render() {
      fillBackground();
      renderScoreBoard();
      renderPlayerShip(player.x,player.y,player.rotation,1);
      renderPlayerMissiles();
      renderRocks();
      renderSaucers();
      renderSaucerMissiles();
      renderParticles();
   }

   function updatePlayer() {
      player.missileFrameCount++;

      player.x += player.movingX*frameRateCounter.step;
      player.y += player.movingY*frameRateCounter.step;

      if (player.x > xMax) {
         player.x =- player.width;
      }else if (player.x<-player.width){
         player.x = xMax;
      }

      if (player.y > yMax) {
         player.y =- player.height;
      }else if (player.y<-player.height){
         player.y = yMax;
      }
   }

   function updatePlayerMissiles() {
      var tempPlayerMissile = {};
      var playerMissileLength=playerMissiles.length-1;
      //ConsoleLog.log("update playerMissileLength=" + playerMissileLength);
      for (var playerMissileCtr=playerMissileLength;playerMissileCtr>=0; 
       playerMissileCtr--){
         //ConsoleLog.log("update player missile" + playerMissileCtr)
         tempPlayerMissile = playerMissiles[playerMissileCtr];
         tempPlayerMissile.x += tempPlayerMissile.dx*frameRateCounter.step;;
         tempPlayerMissile.y += tempPlayerMissile.dy*frameRateCounter.step;;
         if (tempPlayerMissile.x > xMax) {
            tempPlayerMissile.x =- tempPlayerMissile.width;
         }else if (tempPlayerMissile.x<-tempPlayerMissile.width){
            tempPlayerMissile.x = xMax;
         }

         if (tempPlayerMissile.y > yMax) {
            tempPlayerMissile.y =- tempPlayerMissile.height;
         }else if (tempPlayerMissile.y<-tempPlayerMissile.height){
            tempPlayerMissile.y = yMax;
         }

         tempPlayerMissile.lifeCtr++;
         if (tempPlayerMissile.lifeCtr > tempPlayerMissile.life){
            //ConsoleLog.log("removing player missile");
            playerMissiles.splice(playerMissileCtr,1)
            tempPlayerMissile = null;
         }
      }
   }

   function updateRocks(){

      var tempRock = {};
      var rocksLength = rocks.length-1;
      //ConsoleLog.log("update rocks length=" + rocksLength);
      for (var rockCtr=rocksLength;rockCtr>=0;rockCtr--){
         tempRock = rocks[rockCtr]
         tempRock.x += tempRock.dx*frameRateCounter.step;
         tempRock.y += tempRock.dy*frameRateCounter.step;

         tempRock.animationCount++;
         if (tempRock.animationCount > tempRock.animationDelay){
            tempRock.animationCount = 0;
            tempRock.rotation += tempRock.rotationInc;

            if (tempRock.rotation > 4){
               tempRock.rotation = 0;
            }else if (tempRock.rotation <0){
               tempRock.rotation = 4;
            }
         }

         if (tempRock.x > xMax) {
            tempRock.x = xMin-tempRock.width;
         }else if (tempRock.x<xMin-tempRock.width){
            tempRock.x = xMax;
         }

         if (tempRock.y > yMax) {
            tempRock.y = yMin-tempRock.width;
         }else if (tempRock.y<yMin-tempRock.width){
            tempRock.y = yMax;
         }

         //ConsoleLog.log("update rock "+ rockCtr)
      }
   }

   function updateSaucers() {
      //first check to see if we want to add a saucer

      if (saucers.length< levelSaucerMax){
         if (Math.floor(Math.random()*100)<=levelSaucerOccurrenceRate){
            //ConsoleLog.log("create saucer")
            var newSaucer = {};

            newSaucer.width = 30;
            newSaucer.height = 13;
            newSaucer.halfHeight = 6.5;
            newSaucer.halfWidth = 15;
            newSaucer.hitWidth = 30;
            newSaucer.hitHeight = 13;
            newSaucer.scoreValue = saucerScore;
            newSaucer.fireRate = levelSaucerFireRate;
            newSaucer.fireDelay = levelSaucerFireDelay;
            newSaucer.fireDelayCount = 0;
            newSaucer.missileSpeed = levelSaucerMissileSpeed;
            newSaucer.dy = (Math.random()*2);
            if (Math.floor(Math.random)*2==1){
               newSaucer.dy *= -1;
            }

            //choose betweeen left or right edge to start
            if (Math.floor(Math.random()*2)==1){
               //start on right and go left
               newSaucer.x = 450;
               newSaucer.dx = -1*levelSaucerSpeed;

            }else{
               //left to right
               newSaucer.x = -50;
               newSaucer.dx = levelSaucerSpeed;
            }

            newSaucer.missileSpeed = levelSaucerMissileSpeed;
            newSaucer.fireDelay = levelSaucerFireDelay;
            newSaucer.fireRate = levelSaucerFireRate;
            newSaucer.y = Math.floor(Math.random()*400);

            saucers.push(newSaucer);
         }

      }

      var tempSaucer = {};
      var saucerLength = saucers.length-1;
      //ConsoleLog.log("update rocks length=" + rocksLength);
      for (var saucerCtr=saucerLength;saucerCtr>=0;saucerCtr--){
         tempSaucer = saucers[saucerCtr];

         //should saucer fire
         tempSaucer.fireDelayCount++;
         if (Math.floor(Math.random()*100) <=tempSaucer.fireRate && 
          tempSaucer.fireDelayCount>tempSaucer.fireDelay ){
            playSound(SOUND_SAUCER_SHOOT,.5);
            fireSaucerMissile(tempSaucer)
            tempSaucer.fireDelayCount=0;
         }

         var remove = false;
         tempSaucer.x += tempSaucer.dx*frameRateCounter.step;
         tempSaucer.y += tempSaucer.dy*frameRateCounter.step;

         //remove saucers on left and right edges
         if (tempSaucer.dx > 0 && tempSaucer.x >xMax){
            remove = true;
         }else if (tempSaucer.dx <0 &&tempSaucer.x<xMin-tempSaucer.width){
            remove = true;
         }

         //bounce saucers off over vertical edges
         if (tempSaucer.y > yMax || tempSaucer.y<yMin-tempSaucer.width) {
            tempSaucer.dy *= -1
         }


         if (remove==true) {
            //remove the saucer
            ConsoleLog.log("saucer removed")
            saucers.splice(saucerCtr,1);
            tempSaucer = null;
         }

      }

   }

   function updateSaucerMissiles() {
      var tempSaucerMissile = {};
      var saucerMissileLength = saucerMissiles.length-1;
      for (var saucerMissileCtr = saucerMissileLength;saucerMissileCtr>=0;
       saucerMissileCtr--){
         //ConsoleLog.log("update player missile" + playerMissileCtr)
         tempSaucerMissile = saucerMissiles[saucerMissileCtr];
         tempSaucerMissile.x += tempSaucerMissile.dx*frameRateCounter.step;
         tempSaucerMissile.y += tempSaucerMissile.dy*frameRateCounter.step;
         if (tempSaucerMissile.x > xMax) {
            tempSaucerMissile.x =- tempSaucerMissile.width;
         }else if (tempSaucerMissile.x<-tempSaucerMissile.width){
            tempSaucerMissile.x = xMax;
         }

         if (tempSaucerMissile.y > yMax) {
            tempSaucerMissile.y =- tempSaucerMissile.height;
         }else if (tempSaucerMissile.y<-tempSaucerMissile.height){
            tempSaucerMissile.y = yMax;
         }

         tempSaucerMissile.lifeCtr++;
         if (tempSaucerMissile.lifeCtr > tempSaucerMissile.life){
            //remove
            saucerMissiles.splice(saucerMissileCtr,1)
            tempSaucerMissile = null;
         }
      }
   }

   function updateParticles() {

      var particleLength=particles.length-1;
      ConsoleLog.log("particle=" + particleLength)
      ConsoleLog.log("particlePool=" + particlePool.length)
      for (var particleCtr=particleLength;particleCtr>=0;particleCtr--){
         var remove = false;
         tempParticle = particles[particleCtr];
         tempParticle.x += tempParticle.dx*frameRateCounter.step;
         tempParticle.y += tempParticle.dy*frameRateCounter.step;

         tempParticle.lifeCtr++;
         
         if (tempParticle.lifeCtr > tempParticle.life){
            remove = true;

         } else if ((tempParticle.x > xMax) || (tempParticle.x<xMin)
           || (tempParticle.y > yMax) || (tempParticle.y<yMin)){
            remove=true;

         }

         if (remove) {
            particlePool.push(tempParticle)
            particles.splice(particleCtr,1)

         }

      }

   }

   function renderPlayerShip(x,y,rotation, scale) {
      //transformation
      context.save(); //save current state in stack
      context.globalAlpha = parseFloat(player.alpha);
      var angleInRadians = rotation * Math.PI / 180;
      var sourceX = Math.floor((player.rotation/10) % 10) * 32;
      var sourceY = Math.floor((player.rotation/10) /10) *32;
      if (player.thrust){
         context.drawImage(shipTiles2, sourceX, sourceY, 32,32, 
          player.x,player.y,32,32);
      }else{
         context.drawImage(shipTiles, sourceX, sourceY, 32,32,
          player.x,player.y,32,32);
      }

      //restore context
      context.restore(); //pop old state on to screen

      context.globalAlpha = 1;

   }

   function renderPlayerMissiles() {
      var tempPlayerMissile = {};
      var playerMissileLength=playerMissiles.length-1;
      //ConsoleLog.log("render playerMissileLength=" + playerMissileLength);
      for (var playerMissileCtr=playerMissileLength;playerMissileCtr>=0;
       playerMissileCtr--){
         //ConsoleLog.log("draw player missile " + playerMissileCtr)
         tempPlayerMissile = playerMissiles[playerMissileCtr];
         context.save(); //save current state in stack
         var sourceX=Math.floor(1 % 4) * tempPlayerMissile.width;
         var sourceY=Math.floor(1 / 4) * tempPlayerMissile.height;

         context.drawImage(particleTiles, sourceX, sourceY,
          tempPlayerMissile.width,tempPlayerMissile.height, 
          tempPlayerMissile.x,tempPlayerMissile.y, 
          tempPlayerMissile.width,tempPlayerMissile.height);

         context.restore(); //pop old state on to screen
      }
   }

   function renderRocks() {
      var tempRock = {};
      var rocksLength = rocks.length-1;
      for (var rockCtr = rocksLength;rockCtr>=0;rockCtr--){
         context.save(); //save current state in stack
         tempRock = rocks[rockCtr];
         var sourceX = Math.floor((tempRock.rotation) % 5) * tempRock.width;
         var sourceY = Math.floor((tempRock.rotation) /5) *tempRock.height;

         switch(tempRock.scale){
            case 1:
               context.drawImage(largeRockTiles, sourceX, sourceY, 
                tempRock.width,tempRock.height,tempRock.x,tempRock.y,
                tempRock.width,tempRock.height);
               break;
            case 2:
               context.drawImage(mediumRockTiles, sourceX,
                sourceY,tempRock.width,tempRock.height,tempRock.x,tempRock.y,
                tempRock.width,tempRock.height);
               break;
            case 3:
               context.drawImage(smallRockTiles, sourceX,
                sourceY,tempRock.width,tempRock.height,tempRock.x,tempRock.y,
                tempRock.width,tempRock.height);
               break;

         }
         context.restore(); //pop old state on to screen

      }
   }

   function renderSaucers() {
      var tempSaucer = {};
      var saucerLength = saucers.length-1;
      for (var saucerCtr = saucerLength;saucerCtr>=0;saucerCtr--){
         //ConsoleLog.log("saucer: " + saucerCtr);
         tempSaucer = saucers[saucerCtr];

         context.save(); //save current state in stack
         var sourceX = 0;
         var sourceY = 0;
         context.drawImage(saucerTiles, sourceX, sourceY, 30,15, 
          tempSaucer.x,tempSaucer.y,30,15);
         context.restore(); //pop old state on to screen
      }
   }
   function renderSaucerMissiles() {
      var tempSaucerMissile = {};
      var saucerMissileLength = saucerMissiles.length-1;
      //ConsoleLog.log("saucerMissiles= " + saucerMissiles.length)
      for (var saucerMissileCtr=saucerMissileLength;saucerMissileCtr>=0;
       saucerMissileCtr--){
         //ConsoleLog.log("draw player missile " + playerMissileCtr)
         tempSaucerMissile = saucerMissiles[saucerMissileCtr];
         context.save(); //save current state in stack
         var sourceX = Math.floor(0 % 4) * tempSaucerMissile.width;
         var sourceY = Math.floor(0 / 4) * tempSaucerMissile.height;

         context.drawImage(particleTiles, sourceX, sourceY, 
          tempSaucerMissile.width,tempSaucerMissile.height,
          tempSaucerMissile.x,tempSaucerMissile.y,tempSaucerMissile.width,
          tempSaucerMissile.height);

         context.restore(); //pop old state on to screen

      }
   }

   function renderParticles() {

      var tempParticle = {};
      var particleLength = particles.length-1;
      for (var particleCtr=particleLength;particleCtr>=0;particleCtr--){
         tempParticle = particles[particleCtr];
         context.save(); //save current state in stack

         var tile;

         //console.log("part type=" + tempParticle.type)
         switch(tempParticle.type){
            case 0: // saucer
               tile = 0;
               break;
            case 1: //large rock
               tile = 2
               break;
            case 2: //medium rock
               tile = 3;
               break;
            case 3: //small rock
               tile = 0;
               break;
            case 4: //player
               tile = 1;
               break;

         }

         var sourceX = Math.floor(tile % 4) * tempParticle.width;
         var sourceY = Math.floor(tile / 4) * tempParticle.height;

         context.drawImage(particleTiles, sourceX, sourceY, 
          tempParticle.width,tempParticle.height,tempParticle.x,
          tempParticle.y,tempParticle.width,tempParticle.height);

         context.restore(); //pop old state on to screen

      }
   }
   
   function checkCollisions() {

      //loop through rocks then missiles.
      //There will always be rocks and a ship,
      //but there will not always be missiles.
      var tempRock = {};
      var rocksLength = rocks.length-1;
      var tempPlayerMissile = {};
      var playerMissileLength = playerMissiles.length-1;
      var saucerLength = saucers.length-1;
      var tempSaucer = {};
      var saucerMissileLength = saucerMissiles.length-1;

      rocks: for (var rockCtr=rocksLength;rockCtr>=0;rockCtr--){
         tempRock = rocks[rockCtr];

         missiles:for (var playerMissileCtr=playerMissileLength;
          playerMissileCtr>=0;playerMissileCtr--){
            tempPlayerMissile = playerMissiles[playerMissileCtr];

            if (boundingBoxCollide(tempRock,tempPlayerMissile)){
                  //ConsoleLog.log("hit rock");
                  createExplode(tempRock.x+tempRock.halfWidth,
                   tempRock.y+tempRock.halfHeight,10,tempRock.scale);
                  if (tempRock.scale<3) {
                     splitRock(tempRock.scale+1, tempRock.x, tempRock.y);
                  }
                  addToScore(tempRock.scoreValue);
                  playerMissiles.splice(playerMissileCtr,1);
                  tempPlayerMissile = null;


                  rocks.splice(rockCtr,1);
                  tempRock = null;

                  break rocks;
                  break missiles;
               }
            }

         saucers:for (var saucerCtr=saucerLength;saucerCtr>=0;saucerCtr--){
            tempSaucer = saucers[saucerCtr];

            if (boundingBoxCollide(tempRock,tempSaucer)){
                  //ConsoleLog.log("hit rock");
                  createExplode(tempSaucer.x+tempSaucer.halfWidth,
                   tempSaucer.y+tempSaucer.halfHeight,10,0);
                  createExplode(tempRock.x+tempRock.halfWidth,
                   tempRock.y+tempRock.halfHeight,10,tempRock.scale);

                  if (tempRock.scale<3) {
                     splitRock(tempRock.scale+1, tempRock.x, tempRock.y);
                  }

                  saucers.splice(saucerCtr,1);
                  tempSaucer = null;

                  rocks.splice(rockCtr,1);
                  tempRock = null;

                  break rocks;
                  break saucers;
               }
            }
         //saucer missiles against rocks
         //this is done here so we don't have to loop through
         //rocks again as it would probably
         //be the biggest array
         saucerMissiles:for (var saucerMissileCtr=saucerMissileLength;
                          saucerMissileCtr>=0;saucerMissileCtr--){

            tempSaucerMissile = saucerMissiles[saucerMissileCtr];
            if (boundingBoxCollide(tempRock,tempSaucerMissile)){
                  //ConsoleLog.log("hit rock");

                  createExplode(tempRock.x+tempRock.halfWidth,
                   tempRock.y+tempRock.halfHeight,10,tempRock.scale);
                  if (tempRock.scale<3) {
                     splitRock(tempRock.scale+1, tempRock.x, tempRock.y);
                  }

                  saucerMissiles.splice(saucerCtr,1);
                  tempSaucerMissile = null;

                  rocks.splice(rockCtr,1);
                  tempRock = null;

                  break rocks;
                  break saucerMissiles;
               }
            }

         //check player against rocks

         if (boundingBoxCollide(tempRock,player) && player.safe==false){
            //ConsoleLog.log("hit player");
            createExplode(tempRock.x+tempRock.halfWidth,
             tempRock.halfHeight,10,tempRock.scale);
            addToScore(tempRock.scoreValue);
            if (tempRock.scale<3) {
               splitRock(tempRock.scale+1, tempRock.x, tempRock.y);
            }
            rocks.splice(rockCtr,1);
            tempRock=null;

            playerDie();
         }

      }

      //now check player against saucers and then saucers against player missiles
      //and finally player against saucer missiles
      playerMissileLength = playerMissiles.length-1;
      saucerLength = saucers.length-1;
      saucers:for (var saucerCtr=saucerLength;saucerCtr>=0;saucerCtr--){
         tempSaucer = saucers[saucerCtr];

         missiles:for (var playerMissileCtr=playerMissileLength;
          playerMissileCtr>=0;playerMissileCtr--){

         tempPlayerMissile = playerMissiles[playerMissileCtr];

            if (boundingBoxCollide(tempSaucer,tempPlayerMissile)){
               //ConsoleLog.log("hit rock");
               createExplode(tempSaucer.x+tempSaucer.halfWidth,
                tempSaucer.y+tempSaucer.halfHeight,10,0);
               addToScore(tempSaucer.scoreValue);

               playerMissiles.splice(playerMissileCtr,1);
               tempPlayerMissile = null;

               saucers.splice(saucerCtr,1);
               tempSaucer = null;

               break saucers;
               break missiles;
            }
         }

         //player against saucers
         if (boundingBoxCollide(tempSaucer,player) & player.safe==false){
            ConsoleLog.log("hit player");
            createExplode(tempSaucer.x+16,tempSaucer.y+16,10,tempRock.scale);
            addToScore(tempSaucer.scoreValue);

            saucers.splice(rockCtr,1);
            tempSaucer = null;

            playerDie();
         }
      }

      //saucerMissiles against player
      saucerMissileLength = saucerMissiles.length-1;

      saucerMissiles:for (var saucerMissileCtr=saucerMissileLength;
                      saucerMissileCtr>=0;saucerMissileCtr--){

         tempSaucerMissile = saucerMissiles[saucerMissileCtr];

         if (boundingBoxCollide(player,tempSaucerMissile) & player.safe==false){
            ConsoleLog.log("saucer missile hit player");

            playerDie();
            saucerMissiles.splice(saucerCtr,1);
            tempSaucerMissile = null;

            break saucerMissiles;
         }
      }

   }

   function firePlayerMissile(){

      //ConsoleLog.log("fire playerMissile");
      var newPlayerMissile = {};
      newPlayerMissile.dx = 5*Math.cos(Math.PI*(player.rotation)/180);
      newPlayerMissile.dy = 5*Math.sin(Math.PI*(player.rotation)/180);
      newPlayerMissile.x = player.x+player.halfWidth;
      newPlayerMissile.y = player.y+player.halfHeight;
      newPlayerMissile.life = 60;
      newPlayerMissile.lifeCtr = 0;
      newPlayerMissile.width = 2;
      newPlayerMissile.height = 2;
      newPlayerMissile.hitHeight = 2;
      newPlayerMissile.hitWidth = 2;
      playerMissiles.push(newPlayerMissile);
   }

   function fireSaucerMissile(saucer) {
      var newSaucerMissile = {};
      newSaucerMissile.x = saucer.x+.5*saucer.width;
      newSaucerMissile.y = saucer.y+.5*saucer.height;
      newSaucerMissile.width = 2;
      newSaucerMissile.height = 2;
      newSaucerMissile.hitHeight = 2;
      newSaucerMissile.hitWidth = 2;
      newSaucerMissile.speed = saucer.missileSpeed;

      //ConsoleLog.log("saucer fire");
      //fire at player from small saucer
      var diffx = player.x-saucer.x;
      var diffy = player.y-saucer.y;
      var radians = Math.atan2(diffy, diffx);
      var degrees = 360 * radians / (2 * Math.PI);
      newSaucerMissile.dx = saucer.missileSpeed*Math.cos(Math.PI*(degrees)/180);
      newSaucerMissile.dy = saucer.missileSpeed*Math.sin(Math.PI*(degrees)/180);
      newSaucerMissile.life = 160;
      newSaucerMissile.lifeCtr = 0;
      saucerMissiles.push(newSaucerMissile);
   }

   function playerDie() {

      ConsoleLog.log("player die");
      createExplode(player.x+player.halfWidth, player.y+player.halfWidth,50,4);
      resetPlayer();
      switchGameState(GAME_STATE_PLAYER_DIE);

   }

   function createExplode(x,y,num,type) {

      playSound(SOUND_EXPLODE,.5);
      for (var partCtr=0;partCtr<num;partCtr++){
         if (particlePool.length > 0){

              newParticle = particlePool.pop();
         newParticle.dx = Math.random()*3;
            if (Math.random()<.5){
               newParticle.dx *= -1;
            }
         newParticle.dy = Math.random()*3;
         if (Math.random()<.5){
            newParticle.dy *= -1;
         }

         newParticle.life = Math.floor(Math.random()*30+30);
         newParticle.lifeCtr = 0;
         newParticle.x = x;
         newParticle.width = 2;
         newParticle.height = 2;
         newParticle.y = y;
         newParticle.type = type;
         //ConsoleLog.log("newParticle.life=" + newParticle.life);
         particles.push(newParticle);
         }

      }

   }

   function boundingBoxCollide(object1, object2) {

      var left1 = object1.x;
      var left2 = object2.x;
      var right1 = object1.x + object1.hitWidth;
      var right2 = object2.x + object2.hitWidth;
      var top1 = object1.y;
      var top2 = object2.y;
      var bottom1 = object1.y + object1.hitHeight;
      var bottom2 = object2.y + object2.hitHeight;

      if (bottom1 < top2) return(false);
      if (top1 > bottom2) return(false);

      if (right1 < left2) return(false);
      if (left1 > right2) return(false);

      return(true);

   };

   function splitRock(scale,x,y){
      for (var newRockctr=0;newRockctr<2;newRockctr++){
         var newRock = {};
         //ConsoleLog.log("split rock");

         if (scale==2){
            newRock.scoreValue = medRockScore;
            newRock.width = 32;
            newRock.height = 32;
            newRock.halfWidth = 16;
            newRock.halfHeight = 16;
            newRock.hitWidth = 24;
            newRock.hitHeight = 24;

         }else {
            newRock.scoreValue = smlRockScore;
            newRock.width = 24;
            newRock.height = 24;
            newRock.halfWidth = 12;
            newRock.halfHeight = 12;
            newRock.hitWidth = 16;
            newRock.hitHeight = 16;
         }

         newRock.scale = scale;
         newRock.x = x;
         newRock.y = y;
         newRock.dx = Math.random()*3;
         if (Math.random()<.5){
            newRock.dx *= -1;
         }
         newRock.dy = Math.random()*3;
         if (Math.random()<.5){
            newRock.dy *= -1;
         }
         if (Math.random()<.5){
               newRock.rotationInc = -1;
         }else{
            newRock.rotationInc = 1;
         }

         newRock.animationDelay = Math.floor(Math.random()*3+1);
         newRock.animationCount = 0;

         newRock.rotation = 0;
         ConsoleLog.log("new rock scale"+(newRock.scale));
         rocks.push(newRock);

      }

   }

   function addToScore(value){
      score += value;
   }

   document.onkeydown = function(e){

      e = e?e:window.event;
      //ConsoleLog.log(e.keyCode + "down");
      keyPressList[e.keyCode] = true;
   }

   document.onkeyup = function(e){
   //document.body.onkeyup = function(e){
      e = e?e:window.event;
      //ConsoleLog.log(e.keyCode + "up");
      keyPressList[e.keyCode] =  false;
   };



   //*** application start

    switchGameState(GAME_STATE_INIT);
    const FRAME_RATE = 40;
    frameRateCounter = new FrameRateCounter(FRAME_RATE);
    //**** application loop
    var intervalTime = 1000/FRAME_RATE;
    setInterval(runGame, intervalTime );

}

//***** object prototypes *****

//*** consoleLog util object
//create constructor
function ConsoleLog(){

}

//create function that will be added to the class
console_log = function(message) {
   if(typeof(console) !== 'undefined' && console != null) {
      console.log(message);
   }
}
//add class/static function to class by assignment
ConsoleLog.log = console_log;

//*** end console log object

//***  new FrameRateCounter object prototype

function FrameRateCounter(fps) {
   if (fps == undefined){

      this.fps = 40
   }else{
      this.fps = fps
   }

   this.lastFrameCount = 0;
   var dateTemp = new Date();
   this.frameLast = dateTemp.getTime();
   delete dateTemp;
   this.frameCtr = 0;
   this.lastTime = dateTemp.getTime();

   this.step = 1;

}

FrameRateCounter.prototype.countFrames = function() {

   var dateTemp = new Date();
   var timeDifference = dateTemp.getTime()-this.lastTime;
   this.step = (timeDifference/1000)*this.fps;
   this.lastTime = dateTemp.getTime();
   this.frameCtr++;

   if (dateTemp.getTime() >=this.frameLast+1000) {
      ConsoleLog.log("frame event");
      this.lastFrameCount = this.frameCtr;
      this.frameCtr = 0;
      this.frameLast = dateTemp.getTime();
   }
   delete dateTemp;

}
</script>
</head>
<body>
<div style="position: absolute; top: 50px; left: 50px;">

<canvas id="canvas" width="400" height="400">
 Your browser does not support HTML5 Canvas.
</canvas>

</div>
</body>
</html>

Creating a Dynamic Tile Sheet at Runtime

In Chapter 4, we briefly examined two principles we can use to help eliminate the need to precreate rotations of objects in tile sheets. Creating these types of tile sheets can be cumbersome and use up valuable time that’s better spent elsewhere in the project.

The idea will be to take a single image of a game object (e.g., the first tile in the medium rock tile sheet), create a “dynamic tile sheet” at runtime, and store it in an array rather than using the prerendered image rotation tiles.

To accomplish this, we need to make use of a second canvas, as well as the getImageData() and putImageData() Canvas functions. If you recall from Chapter 4, getImageData() will throw a security error if the HTML page using it is not on a web server.

Currently, only the Safari browser will not throw this error if the file is used on a local filesystem. For this reason, we have separated this functionality from the Geo Blaster Extended game and will simply demonstrate how it could be used instead of replacing all the tile sheets in the game with this type of prerendering.

We will start by creating two <canvas> elements on our HTML page:

<body>
<div>
<canvas id="canvas" width="256" height="256" style="position: absolute; top: 
50px; left: 50px;">
 Your browser does not support HTML5 Canvas.
</canvas>

<canvas id="canvas2" width="32" height="32"  style="position: absolute; top: 
 256px; left: 50px;">
 Your browser does not support HTML5 Canvas.
</canvas>
</div>
</body>

The first <canvas>, named canvas, will represent our hypothetical game screen, which will be used to display the precached dynamic tile sheet animation.

The second <canvas>, named canvas2, will be used as a drawing surface to create the dynamic tile frames for our tile sheet.

We will need to separate context instances in the JavaScript, one for each <canvas>:

var theCanvas = document.getElementById("canvas");
var context = theCanvas.getContext("2d");
var theCanvas2 = document.getElementById("canvas2");
var context2= theCanvas2.getContext("2d");

We will use the mediumrocks.png file (Figure 9-9) from the Geo Blaster Extended game as our source for the dynamic tile sheet. Don’t let this confuse you. We are not going to use all five tiles on this tile sheet—only the first tile.

The mediumrocks.png tile sheet
Figure 9-9. The mediumrocks.png tile sheet

In Geo Blaster Extended, we used all five tiles to create a simulated rotation animation. Here, we will only use the first tile. We will draw this first tile and rotate it on theCanvas2 by 10 degrees, and then copy the current imageData pixels from this canvas to an array of imageData instances, called rotationImageArray.

We will then repeat this process by rotating theCanvas2 by 10 more degrees and in a loop until we have 36 individual frames of imageData representing the rotation animation for our medium rock in an array:

var rotationImageArray = [];
var animationFrame = 0;
var tileSheet = new Image();
tileSheet.addEventListener('load', eventSheetLoaded , false);
tileSheet.src = "mediumrocks.png";

The rotationImageArray variable will hold the generated imageData instances, which we will create by using a rotation transformation on theCanvas2.

The animationFrame is used when redisplaying the rotation animation frames in rotationImageArray back to the first theCanvas to demo the animation.

When the tileSheet is loaded, the eventSheetLoaded() function is called, which in turn calls the startup() function. The startup() function will use a loop to create the 36 frames of animation:

function startUp(){

   for (var ctr=0;ctr<360;ctr+=10){
     context2.fillStyle = "#ffffff";
     context2.fillRect(0,0,32,32);
     context2.save();
     context2.setTransform(1,0,0,1,0,0)
     var angleInRadians = ctr * Math.PI / 180;
     context2.translate(16, 16);
     context2.rotate(angleInRadians);
     context2.drawImage(tileSheet, 0, 0,32,32,-16,-16,32,32);
     context2.restore();
     var imagedata = context2.getImageData(0, 0, 32, 32)
     rotationImageArray.push(imagedata);
   }
   setInterval(drawScreen, 100 );
}

This loop first clears theCanvas2 with a white color, and then saves it to the stack. We then translate to the center of our object and rotate the canvas by the current ctr value (an increment of 10). Next, we draw the first tile from mediumrocks.png and save the result into a new local imageData instance using the getImageData() function.

Note

This is the place where the security error will be thrown if the domain of the image and the domain of the HTML file are not the same. On a local machine (not running on a local web server, but from the filesystem), this error will be thrown on all browsers but Safari (currently).

Finally, the new imageData is pushed into the rotationImageArray. When the loop is complete, we set up an interval to run and call the drawScreen() function every 100 milliseconds.

To display the animation on the first canvas, we use this timer loop interval and call putImageData() to draw each frame in succession, creating the simulation of animation. As with the tile sheet, we didn’t have to use 36 frames of animation, we could use just five. Naturally, the animation is much smoother with more frames. But this process shows how easy it is to create simple transformation animations “on the fly” rather than precreating them in image files:

function drawScreen() {

      //context.fillStyle = "#ffffff";
      //context.fillRect(50,50,32,32);
      context.putImageData(rotationImageArray[animationFrame],50,50);
      animationFrame++;
      if (animationFrame ==rotationImageArray.length){
         animationFrame=0;
      }
}

Example 9-2 shows the entire code.

Example 9-2. A dynamic tile sheet example
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CH9EX2: Canvas Copy</title>
<script src="modernizr-1.6.min.js"></script>
<script type="text/javascript">
window.addEventListener('load', eventWindowLoaded, false);
function eventWindowLoaded() {
   canvasApp();
}

function canvasSupport () {
    return Modernizr.canvas;
}

function canvasApp(){

   if (!canvasSupport()) {
          return;     }else{
      var theCanvas = document.getElementById("canvas");
      var context = theCanvas.getContext("2d");

      var theCanvas2 = document.getElementById("canvas2");
      var context2= theCanvas2.getContext("2d");
   }
   var rotationImageArray = []
   var tileSheet = new Image();
   var animationFrame = 0;
   tileSheet.addEventListener('load', eventSheetLoaded , false);
   tileSheet.src = "mediumrocks.png";
   function eventSheetLoaded() {
      startUp();
   }

   function startUp(){
      //context.drawImage(tileSheet, 0, 0);
      //context2.drawImage(theCanvas, 0, 0,32,32,0,0,32,32);

      for (var ctr=0;ctr<360;ctr+=10){
         context2.fillStyle="#ffffff";
         context2.fillRect(0,0,32,32);

         context2.save();
         context2.setTransform(1,0,0,1,0,0)
         var angleInRadians = ctr * Math.PI / 180;
         context2.translate(16, 16)
         context2.rotate(angleInRadians);
         context2.drawImage(tileSheet, 0, 0,32,32,-16,-16,32,32);
         context2.restore();

         var imagedata = context2.getImageData(0, 0, 32, 32);

         rotationImageArray.push(imagedata);
      }
      setInterval(drawScreen, 100 );
   }

   function drawScreen() {
      //context.fillStyle="#ffffff";
      //context.fillRect(50,50,32,32);
      context.putImageData(rotationImageArray[animationFrame],50,50);
      animationFrame++;
      if (animationFrame ==rotationImageArray.length){
         animationFrame = 0;
      }
   }

}

</script>
</head>
<body>
<div>
<canvas id="canvas" width="256" height="256" style="position: absolute; top: 
 50px; left: 50px;">
 Your browser does not support the HTML 5 Canvas.
</canvas>

<canvas id="canvas2" width="32" height="32" style="position: absolute; top: 
 256px; left: 50px;">
 Your browser does not support HTML5 Canvas.
</canvas>

</div>
</body>
</html>

In the rest of the chapter, we will look at creating a simple tile-based game using some of the techniques first discussed in Chapter 4.

A Simple Tile-Based Game

Let’s move from Asteroids to another classic game genre, the tile-based maze-chase game. When you’re discussing early tile-based games, undoubtedly Pac-Man enters the conversation. Pac-Man was one of the first commercially successful tile-based games, although it certainly was not the first of its kind. The maze-chase genre was actually well covered by budding game developers before microcomputers were even thought possible. Many minicomputer and mainframe tile-based games, such as Daleks, were crafted in the ’60s and ’70s. In this section, we will create a simple turn-based maze-chase game. Our game, Micro Tank Maze, will be based loosely on Daleks, but we will use the tank sprites from Chapter 4. Figure 9-10 is a screenshot from the finished game.

Micro Tank Maze in action
Figure 9-10. Micro Tank Maze in action

Micro Tank Maze Description

Micro Tank Maze is a simple turn-based strategy game played on a 15×15 tile-based grid. At the beginning of each game, the player (the green tank), 20 enemy tanks (the blue tanks), 25 wall tiles, and a single goal tile (the phoenix) are randomly placed on the grid. The rest of the grid is simply “road” tiles on which the tanks move. The player is tasked with getting to the goal object without running into any walls or any of the enemy tanks. On each turn, the player and all enemy tanks will move a single space (tile) on the grid. Neither the player nor the enemy tanks can move off the grid edges. If the player runs into a wall tile or an enemy tank, his game is over. If an enemy tank runs into a wall or another tank, it is destroyed and removed from the game board. If an enemy tank runs into the player tank, it and the player are destroyed. If the player hits the goal tile without an enemy tank also hitting the tile on the same turn, the player wins.

Game progression

Each time the player collects the goal object and wins the game, the next game will start with one more enemy tank (up to 50 enemy tanks). The ultimate goal of the game is to see how many times you (the player) can win before your tank is finally destroyed. The game will keep a session-based high score, and even if you lose, you always start from the last completed level.

This is a simple game, and much more can be added to it to enhance the gaming experience. In this chapter, though, we want to cover the basics of creating a tile-based game on HTML5 Canvas. By combining what we have learned throughout this book, you should have enough skill and knowledge to extend this simple contest into a much more robust game-play experience.

Game strategy

The player must try to reach the goal while avoiding the enemy tanks. The enemy will follow or chase the player to a fault. Most of the time (75%), each enemy tank will stupidly follow the player, even if that means moving into a wall and destroying itself. The player then has the advantage of intelligence to compensate for the large number of tanks the enemy employs. The other 25% of the time, an enemy tank will randomly choose a direction to move in.

Now, let’s get into the game by looking at the tile sheet we will be using.

The Tile Sheet for Our Game

Make sure you’ve read Chapter 4 and the Chapter 8 section A Basic Game Framework before moving on. Even though Micro Tank Maze is a relatively simple game, it is still quite a few lines of code. We’ll hit the major points, but we don’t have space to discuss every detail.

The tile sheet (tanks_sheet.png) we are going to use will look very familiar if you’ve read Chapter 4. Figure 9-11 shows tanks_sheet.png.

The Micro Tank Maze tile sheet
Figure 9-11. The Micro Tank Maze tile sheet

We will be using only a very small portion of these tiles for Micro Tank Maze.

Road tile

This is the tile on which the player and the enemy tanks can move. Tile 0, the road tile, is in the top-left corner.

Wall tile

The wall tile will cause any tank moving on it to be destroyed. Tile 30, the second to last tile on the sheet, will be the wall tile.

Goal tile

This is the tile the player must reach to win the game. It is the last tile in the second to last row (the phoenix).

Player tiles

The player will be made up of the first eight green tank tiles. Each tile will be used to simulate the tank treads moving from tile to tile.

Enemy tiles

The enemy will be made up of the second eight blue tank tiles. These tiles will be used to animate the tank treads as it moves from tile to tile.

Our game code will store the tile ids needed for each of these game objects in application scope variables:

var playerTiles = [1,2,3,4,5,6,7,8];
var enemyTiles = [9,10,11,12,13,14,15,16];
var roadTile = 0;
var wallTile = 30;
var goalTile = 23;
var explodeTiles = [17,18,19,18,17];

The tile sheet will be loaded into an application scope Image instance and given the name tileSheet:

var tileSheet;

In the application’s initialization state, we will load in and assign the Image instance:

tileSheet = new Image();
tileSheet.src = "tanks_sheet.png";

Next, we will examine the setup of the game playfield.

The Playfield

The game playfield will be a 15×15 grid of 32×32 tiles. This is a total of 225 tiles with a width and height of 480 pixels each. Every time we start a new game, all the objects will be placed randomly onto the grid. The playField[] array will hold 15 row arrays each with 15 columns. This gives us 225 tiles that can be easily accessed with the simple playField[row][col] syntax.

Creating the board

We will first place a road tile on each of the 225 playField array locations. We then randomly place all of the wall tiles (these will actually replace some of the road tiles at locations in the playField array).

Next, we randomly place all of the enemy tank tiles. Unlike the wall tiles, the tank tiles will not replace road tiles in the playField array. Instead, they will be placed into an array of their own called enemy. To ensure that neither the player nor the goal object occupies the same tile space as the enemy tanks, we will create another array called items.

The items array will also be a 15×15 two-dimensional array of rows and columns, which can be considered the “second” layer of playfield data. Unlike the playField array, it will only be used to make sure no two objects (player, enemy, or goal) occupy the same space while building the playfield. We must do this because the player and enemy objects are not added to the playField array.

Once we have placed the enemy, we will randomly place the player at a spot that is not currently occupied by an enemy or a wall. Finally, we will place the goal tile in a spot not taken by the player, a wall, or an enemy tank.

The code for this will be in the createPlayField() function. If you would like to review it now, go to the section Micro Tank Maze Complete Game Code (Example 9-3).

All the data about the playField will be stored in application scope variables:

//playfield
var playField = [];
var items = [];
var xMin = 0;
var xMax = 480;
var yMin = 0;
var yMax = 480;

To create the playField, the game code will need to know the maximum number of each type of tile. These will also be application scope variables:

var wallMax = 20;
var playerMax = 1;
var enemyMax = 20;
var goalMax = 1;

The Player

The player and all of its current attributes will be contained in the player object. Even a game as simple as Micro Tank Maze requires quite a few attributes. Here is a list and description of each:

player.row

The current row on the 15×15 playField grid where the player resides.

player.col

The current column on the 15×15 playField grid where the player resides.

player.nextRow

The row the player will move to next, after a successful key press in that direction.

player.nextCol

The column the player will move to next, after a successful key press in that direction.

player.currentTile

The id of the current tile used to display the player from the playerTiles array.

player.rotation

The player starts pointed up, so this will be the 0 rotation. When the player moves in one of the four basic directions, this rotation will change and will be used to move the player in the direction it is facing.

player.speed

The number of pixels the player object will move on each frame tick.

player.destinationX

The final x location for the 32×32 player object while it is moving to a new tile. It represents the top-left corner x location for this new location. During the player movement and animation phase of the game, this value determines when the player has arrived at its new x-axis location.

player.destinationY

The final y location for the 32×32 player object while it is moving to a new tile. It represents the top-left corner y location for this new location. During the player movement and animation phase of the game, this value determines when the player has arrived at its new y-axis location.

player.x

The current x location of the top-left corner of the 32×32 player object.

player.y

The current y location of the top-left corner of the 32×32 player object.

player.dx

The player’s change in x direction on each frame tick while it is animating. This will be -1, 0, or 1, depending on the direction in which the player is moving.

player.dy

The player’s change in y direction on each frame tick while it is animating. This will be -1, 0, or 1, depending on the direction in which the player is moving.

player.hit

Set to true when the player moves to a new square that is occupied by an enemy tank or a wall.

player.dead

When player.hit is true, it will be replaced on the playField by an explosion sprite. With dead set to true, it will not be rendered to the game screen.

player.win

Set to true if the player collects the goal object.

The enemy and the player share many of the same attributes, as they both use the same type of calculations to move about the grid. Now let’s examine how the enemy object is constructed.

The Enemy

Each enemy object will have its own set of attributes that are very similar to those of the player. Like the player, each enemy will be an object instance.

Here is the code from the createPlayField() function that sets up the attributes for a new enemy object:

EnemyLocationFound = true;
var tempEnemy = {};
tempEnemy.row = randRow;
tempEnemy.col = randCol;
tempEnemy.nextRow = 0;
tempEnemy.nextCol = 0;
tempEnemy.currentTile = 0;
tempEnemy.rotation = 0;
tempEnemy.x = tempEnemy.col*32;
tempEnemy.y = tempEnemy.row*32;
tempEnemy.speed = 2;
tempEnemy.destinationX = 0;
tempEnemy.destinationY = 0;
tempEnemy.dx = 0;
tempEnemy.dy = 0;
tempEnemy.hit = false;
tempEnemy.dead = false;
tempEnemy.moveComplete = false;
enemy.push(tempEnemy);
items[randRow][randCol] = 1;

There are a couple extra things worth pointing out in this code. The first is that each enemy object needs an attribute called moveComplete. This is used in the animateEnemy() game state function. When the entire enemy battalion has moved to its new location, the game will transition to the next game state. This is discussed in detail in the next section .

Also, notice that the new enemy objects are added to the enemy array, as well as to the items multidimensional array. This ensures that the player and the goal cannot be placed on to an enemy location. Once the enemy moves from its initial location, the playField array will still have a road tile to show in its place. We call the player and the enemy “moving object” tiles because they can move about the game board. When they move, they must “uncover” the road tile in the spot they were in before moving.

Now let’s take a quick look at the goal tile to solidify your understanding of the difference between the playField and the moving object tiles.

The Goal

The tile id of the goal tile will be stored in the playField array along with the road and wall tiles. It is not considered a separate item because, unlike the player and enemy objects, it does not need to move. As we have described previously, since the enemy and player tiles move on top of the playfield, they are considered moving items and not part of the playfield.

The Explosions

The explosion tiles are unique. They will be rendered on top of the playfield when an enemy tank or the player’s hit attribute has been set to true. The explosion tiles will animate through a list of five tiles and then be removed from the game screen. Again, tiles for the explosion are set in the explodeTiles array:

var explodeTiles = [17,18,19,18,17];

Next, we will examine the entire game flow and state machine to give you an overall look at how the game logic is designed.

Turn-Based Game Flow and the State Machine

Our game logic and flow is separated into 16 discrete states. The entire application runs on a 40 frames per second interval timer:

switchGameState(GAME_STATE_INIT);
const FRAME_RATE = 40;
var intervalTime = 1000/FRAME_RATE;
setInterval(runGame, intervalTime )

As with the other games, in Chapter 8 and earlier in this chapter, we will use a function reference state machine to run our current game state. The switchGameState() function will be used to transition to a new game state. Let’s begin by discussing this function briefly, and then moving through the rest of the game functions.

Note

We will not reprint each line of code or dissect it in detail here. Use this section as a guide for perusing the entire set of game code included at the end of this chapter (in Example 9-3). By now, you have seen most of the code and ideas used to create this game logic. We will break out the new ideas and code in the sections that follow.

GAME_STATE_INIT

This state loads in the assets we need for our game. We are loading in only a single tile sheet and no sounds for Micro Tank Maze.

After the initial load, it sends the state machine to the GAME_STATE_WAIT_FOR_LOAD state until the load event has occurred.

GAME_STATE_WAIT_FOR_LOAD

This state simply makes sure that all the items in GAME_STATE_INIT have loaded properly. It then sends the state machine to the GAME_STATE_TITLE state.

GAME_STATE_TITLE

This state shows the title screen and then waits for the space bar to be pressed. When this happens, it sends the state machine to GAME_STATE_NEW_GAME.

GAME_STATE_NEW_GAME

This state resets all of the game arrays and objects and then calls the createPlayField() function. The createPlayField() function creates the playField and enemy arrays for the new game, as well as sets the player object’s starting location. Once it has finished, it calls the renderPlayField() function a single time to display the initial board on the game screen.

Once this completes, the state machine is now ready to start the real game loop. This is done by moving the game state machine to the GAME_STATE_WAIT_FOR_PLAYER_MOVE state.

GAME_STATE_WAIT_FOR_PLAYER_MOVE

This state waits for the player to press one of the four arrow buttons. Once the player has done so, the switch statement checks to see which arrow was pressed. Based on the direction pressed, the checkBounds() function is called.

Note

This state contains a bit of the new code for tile movement logic that we have not seen previously in this book. See the upcoming section Simple Tile Movement Logic Overview for more details on these concepts.

The checkBounds() function accepts in three parameters:

  • The number to increment the row the player is currently in

  • The number to increment the column the player is currently in

  • The object being tested (either the player or one of the enemy tanks)

The sole purpose of this function is to determine whether the object being tested can move in the desired direction. In this game, the only illegal moves are off the side of the screen. In games such as Pac-Man, this would check to make sure that the tile was not a wall tile. Our game does not do this because we want the player and the enemy objects to be able to move mistakenly onto the wall tiles (and be destroyed).

If a valid move is found for the player in the direction pressed, the setPlayerDestination() function is called. This function simply sets the player.destinationX and player.destinationY attributes based on the new tile location.

checkBounds() sets the player.nextRow and player.nextCol attributes. The setPlayerDestination() function multiplies the player.nextRow and the player.nextCol by the tile size (32) to determine the player.destinationX and player.destinationY attributes. These will be used to move the player to its new location.

GAME_STATE_ANIMATE_PLAYER is then set as the current game state.

GAME_STATE_ANIMATE_PLAYER

This function moves the player to its destinationX and destinationY locations. Since this is a turn-based game, we don’t have to do any other processing while this movement is occurring.

On each iteration, the player.currentTile is incremented by 1. This will change the tile that is rendered to be the next tile in the playerTiles array. When destinationX and destinationY are equal to the x and y values for the player, the movement and animation stop, and the game state is changed to the GAME_STATE_EVALUATE_PLAYER_MOVE state.

GAME_STATE_EVALUATE_PLAYER_MOVE

Now that the player has been moved to the next tile, the player.row and player.col attributes are set to player.nextRow and player.nextCol, respectively.

Next, if the player is on a goal tile, the player.win attribute will be set to true. If the player is on a wall tile, the player.hit will be set to true.

We then loop though all of the enemy objects and see whether any occupy the same tile as the player. If they do, both the player and the enemy hit attributes are set to true.

Next, we move the game to the GAME_STATE_ENEMY_MOVE state.

GAME_STATE_ENEMY_MOVE

This state uses the homegrown chase AI—discussed in Simple Homegrown AI Overview—to choose a direction in which to move each enemy tank. It does this by looping through all the tanks and applying the logic to them individually.

This function first uses a little tile-based math to determine where the player is in relation to an enemy tank. It then creates an array of directions to test based on these calculations. It stores these as string values in a variable called directionsToTest.

Next, it uses the chanceRandomMovement value (25%) to determine whether it will use the list of directions it just compiled, or whether it will throw them out and simply choose a random direction to move in.

In either case, it must check all of the available directions (either in the list of directionsToMove or in all four directions for random movement) to see which is the first that will not move the tank off the side of the screen.

Once it has the direction to move in, it sets the destinationX and destinationY values of the enemy tank using the same tile size * x and tile size * y trick used for the player.

Finally, it sets the game state to GAME_STATE_ANIMATE_ENEMY.

GAME_STATE_ANIMATE_ENEMY

Like GAME_STATE_ANIMATE_PLAYER, this state moves and animates the tank to its new location represented by its destinationX and destinationY values. It must do this for each of the enemy tanks, so it uses the enemyMoveCompleteCount variable to keep count of how many of the enemy tanks have finished their moves.

When all the enemy tanks have completed their moves, the game state is changed to the GAME_STATE_EVALUATE_ENEMY_MOVE state.

GAME_STATE_EVALUATE_ENEMY_MOVE

Like GAME_STATE_EVALUATE_PLAYER_MOVE, this state looks at the location of each tank to determine which ones need to be destroyed.

If a tank occupies the same tile as the player, a wall, or another tank, the tank is “to be destroyed”. If the player and enemy tank occupy the same tile, the player is also “to be destroyed”. This “to be destroyed” state is set by placing true in the hit attribute of the enemy tank or the player.

The game is then moved to the GAME_STATE_EVALUATE_OUTCOME state.

GAME_STATE_EVALUATE_OUTCOME

This function looks at each of the enemy tanks and the player tank to determine which have a hit attribute set to true. If any do, that tank’s dead attribute is set to true, and an explosion is created by calling createExplode() and passing in the object instance (player or enemy tank). In the case of the enemy, a dead enemy is also removed from the enemy array.

The GAME_STATE_ANIMATE_EXPLODE state is called next.

GAME_STATE_ANIMATE_EXPLODE

If the explosions array length is greater than 0, this function loops through each instance and animates them using the explodeTiles array. Each explosion instance is removed from the explosions array after it finishes its animation. When the explosions array length is 0, the game moves to the GAME_STATE_CHECK_FOR_GAME_OVER state.

GAME_STATE_CHECK_FOR_GAME_OVER

This state will first check to see whether the player is dead, and then check to see whether he has won. That means that the player cannot win if an enemy tank makes it to the goal on the same try as the player.

If the player has lost, the state changes to GAME_STATE_PLAYER_LOSE; if the player has won, it moves to the GAME_STATE_PLAYER_WIN state. If neither of those has occurred, the game is set to GAME_STATE_WAIT_FOR_PLAYER_MOVE. This starts the game loop iteration over, and the player begins her next turn.

GAME_STATE_PLAYER_WIN

If the player wins, the maxEnemy is increased for the next game. The player’s score is also checked against the current session high score to determine whether a new high score has been achieved. This state waits for a space bar press and then moves to the GAME_STATE_NEW_GAME state.

GAME_STATE_PLAYER_LOSE

The player’s score is checked against the current session high score to determine whether a new high score has been achieved. This state waits for a space bar press and then moves to the GAME_STATE_NEW_GAME state.

Simple Tile Movement Logic Overview

Micro Tank Maze employs simple tile-to-tile movement using the “center of a tile” logic. This logic relies on making calculations once the game character has reached the center of a tile. The origin point of our game character tiles is the top-left corner. Because of this, we can easily calculate that a game character is in the center of a tile when its x and y coordinates are equal to the destination tile’s x and y coordinates.

When the user presses a movement key (up, down, right, or left arrow), we first must check whether the player is trying to move to a “legal” tile on the playField. In Micro Tank Maze, all tiles are legal. The only illegal moves are off the edges of the board. So, if the player wants to move up, down, left, or right, we must first check the tile in that direction based on the key pressed in the gameStateWaitForPlayerMove() function. Here is the switch statement used to determine whether the player pressed an arrow key:

if (keyPressList[38]==true){
       //up
       if (checkBounds(-1,0, player)){
       setPlayerDestination();
       }
    }else if (keyPressList[37]==true) {
       //left
       if (checkBounds(0,-1, player)){
       setPlayerDestination();
       }
    }else if (keyPressList[39]==true) {
       //right
       if (checkBounds(0,1, player)){
       setPlayerDestination();
       }
    }else if  (keyPressList[40]==true){
       //down
       if (checkBounds(1,0, player)){
       setPlayerDestination();
       }
    }

Notice that the checkBounds() function takes a row increment and then a column increment to test. It is important to note that we don’t access tiles in the same manner that we would access pixels on the screen. Tiles in the playField array are accessed by addressing the vertical (row) and then the horizontal (column) (using [row][column], not [column][row]). This is because a simple array is organized into a set of rows. Each row has a set of 15 columns. Therefore, we do not access a tile in the playField using the [horizontal][vertical] coordinates. Instead, we use the [row][column] syntax that simple arrays use to powerful and elegant effect.

In the checkBounds() function, enter the row increment, then the column increment, and then the object to be tested. If this is a legal move, the checkBounds() function sets the nextRow and nextCol to be row+rowInc and col+colInc, respectively:

function checkBounds(rowInc, colInc, object){
   object.nextRow = object.row+rowInc;
   object.nextCol = object.col+colInc;

   if (object.nextCol >=0 && object.nextCol<15 && 
    object.nextRow>=0 && object.nextRow<15){
      object.dx = colInc;
      object.dy = rowInc;

      if (colInc==1){
      object.rotation = 90;
      }else if (colInc==-1){
      object.rotation = 270;
      }else if (rowInc==-1){
      object.rotation = 0;
      }else if (rowInc==1){
      object.rotation = 180;
      }

      return(true);

   }else{
      object.nextRow = object.row;
      object.nextCol = object.col;
      return(false);

   }

}

If the move is legal, the dx (delta, or change in x) and dy (delta, or change in y) are set to the colInc and rowInc, respectively.

The animatePlayer() function is called next. Its job is to move the player object to its new location while running through its animation frames. Here is the code from the animatePlayer() function:

player.x += player.dx*player.speed;
player.currentTile++;if (player.currentTile==playerTiles.length){
   player.currentTile = 0;
}
renderPlayField();
if (player.x==player.destinationX && player.y==player.destinationY){
   switchGameState(GAME_STATE_EVALUATE_PLAYER_MOVE);
}

First, the player object’s x and y locations are increased by the player.speed * player.dx (or dy). The tile size is 32, so we must use a speed value that is evenly divided into 32. The values 1, 2, 4, 8, 16, and 32 are all valid.

This function also runs though the playerTiles array on each game loop iteration. This will render the tank tracks moving, simulating a smooth ride from one tile to the next.

Next, let’s take a closer look at how we render the playField.

Rendering Logic Overview

Each time the game renders objects to the screen, it runs through the entire render() function. It does this to ensure that even the nonmoving objects are rendered back to the game screen. The render() function looks like this:

function renderPlayField() {
   fillBackground();
   drawPlayField();
   drawPlayer();
   drawEnemy();
   drawExplosions();
}

First, we draw the plain black background, then we draw the playField, and after that we draw the game objects. drawPlayField() draws the map of tiles to the game screen. This function is similar to the functions in Chapter 4, but with some additions for our game. Let’s review how it is organized:

function drawPlayField(){
   for (rowCtr=0;rowCtr<15;rowCtr++){

      for (colCtr=0;colCtr<15;colCtr++) {
      var sourceX = Math.floor((playField[rowCtr][colCtr]) % 8) * 32;
      var sourceY = Math.floor((playField[rowCtr][colCtr]) /8) *32;

      if (playField[rowCtr][colCtr] != roadTile){
          context.drawImage(tileSheet, 0, 0,32,32,colCtr*32,rowCtr*32,32,32);
      }
      context.drawImage(tileSheet, sourceX, sourceY, 32,32, 
       colCtr*32,rowCtr*32,32,32);
       }
    }
 }

The drawPlayField() function loops through the rows in the playField array, and then through each column inside each row. If the tile id number at playField[rowCtr][colCtr] is a road tile, it simply paints that tile at the correct location on the playField. If the tile id is a game object (not a road tile), it first paints a road tile in that spot and then paints the object tile in that spot.

Simple Homegrown AI Overview

The enemy tanks chase the player object based on a set of simple rules. We have coded those rules into the gameStateEnemyMove() function, which is one of the longest and most complicated functions in this book. Let’s first step through the logic used to create the function, and then you can examine it in Example 9-3.

This function starts by looping through the enemy array. It must determine a new tile location on which to move each enemy. To do so, it follows some simple rules that determine the order in which the testBounds() function will test the movement directions:

  1. First, it tests to see whether the player is closer to the enemy vertically or horizontally.

  2. If vertically, and the player is above the enemy, it places up and then down into the directionsToTest array.

  3. If vertically, and the player is below the enemy, it places down and then up into the directionsToTest array.

    Note

    The up and then down, or down and then up, directions are pushed into the directionsTest array to simplify the AI. The logic here is if the player is “up” from the enemy, but the enemy is blocked by an object, the enemy will try the opposite direction first. In our game, there will be no instance where an object blocks the direction the enemy tank wants to move in. This is because the only illegal direction is trying to move off the bounds of the screen. If we add tiles to our playfield that “block” the enemy, this entire set of AI code suddenly becomes very useful and necessary. We have included this entire “homegrown chase AI” in our game in case more of these tile types are added.

  4. It then looks to see where to add the left and right directions. It does this based on which way will put it closer to the player.

  5. If the horizontal direction and not the vertical direction is the shortest, it runs through the same type of logic, but this time using left and then right, then up and then down.

  6. When this is complete, all four directions will be in the directionsToTest array.

Next, the logic finds a number between 0 and 99, and checks to see whether it is less than the chanceRandomEnemyMovement value. If it is, it will ignore the directionsToTest array and simply try to find a random direction to move in. In either case, all the directions (either in the directionsToTest array or in order up, down, left, and right) are tested until the testBounds() function returns true.

That’s all there is to this code. In Example 9-3, you will find the entire set of code for this game.

Micro Tank Maze Complete Game Code

Example 9-3. Micro Tank Maze full source code listing
 <!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CH(EX3: Micro Tank Maze Game</title>
<script src="modernizr-1.6.min.js"></script>
<script type="text/javascript">
window.addEventListener('load', eventWindowLoaded, false);
function eventWindowLoaded() {

    canvasApp();
}

function canvasSupport () {
return Modernizr.canvas;
}

function canvasApp(){

   if (!canvasSupport()) {
          return;
     }else{
       theCanvas = document.getElementById("canvas");
       context = theCanvas.getContext("2d");
   }

   //application states
   const GAME_STATE_INIT = 0;
   const GAME_STATE_WAIT_FOR_LOAD = 10;
   const GAME_STATE_TITLE = 20;
   const GAME_STATE_NEW_GAME = 30;
   const GAME_STATE_WAIT_FOR_PLAYER_MOVE = 40;
   const GAME_STATE_ANIMATE_PLAYER = 50;
   const GAME_STATE_EVALUATE_PLAYER_MOVE = 60;
   const GAME_STATE_ENEMY_MOVE = 70;
   const GAME_STATE_ANIMATE_ENEMY = 80;
   const GAME_STATE_EVALUATE_ENEMY_MOVE = 90;
   const GAME_STATE_EVALUATE_OUTCOME = 100;
   const GAME_STATE_ANIMATE_EXPLODE = 110;
   const GAME_STATE_CHECK_FOR_GAME_OVER = 120;
   const GAME_STATE_PLAYER_WIN = 130;
   const GAME_STATE_PLAYER_LOSE = 140;
   const GAME_STATE_GAME_OVER = 150;

   var currentGameState = 0;
   var currentGameStateFunction = null;

   //loading
   var loadCount = 0;
   var itemsToLoad  = 1;

   //keyPresses
   var keyPressList = [];

   var tileSheet;

   var mapRows = 15;
   var mapCols = 15;

   //playfield
   var playField = [];
   var items = [];
   var xMin = 0;
   var xMax = 480;
   var yMin = 0;
   var yMax = 480;

   //tiles
   var playerTiles = [1,2,3,4,5,6,7,8];
   var enemyTiles = [9,10,11,12,13,14,15,16];
   var roadTile = 0;
   var wallTile = 30;
   var goalTile = 23;
   var explodeTiles = [17,18,19,18,17];

   var wallMax = 20;
   var playerMax = 1;
   var enemyMax = 20;
   var goalMax = 1;

   var enemyMoveCompleteCount=0;

   //objects
   var player = {};
   var enemy = [];
   var explosions = [];

   //screens
   var screenStarted = false;
   var score = 0;
   var enemyScore = 10;
   var goalScore = 50;
   var highScore = 0;

   var chanceRandomEnemyMovement = 25;

   function runGame(){
      currentGameStateFunction();
   }

   function switchGameState(newState) {
      currentGameState = newState;
      switch (currentGameState) {

         case GAME_STATE_INIT:
            currentGameStateFunction = gameStateInit;
            break;
         case GAME_STATE_WAIT_FOR_LOAD:
            currentGameStateFunction = gameStateWaitForLoad;
            break;
         case GAME_STATE_TITLE:
             currentGameStateFunction = gameStateTitle;
             break;
         case GAME_STATE_NEW_GAME:
             currentGameStateFunction = gameStateNewGame;
             break;
         case GAME_STATE_WAIT_FOR_PLAYER_MOVE:
             currentGameStateFunction = gameStateWaitForPlayerMove;
             break;
         case GAME_STATE_ANIMATE_PLAYER:
             currentGameStateFunction = gameStateAnimatePlayer;
             break;
         case GAME_STATE_EVALUATE_PLAYER_MOVE:
             currentGameStateFunction = gameStateEvaluatePlayerMove;
             break;
         case GAME_STATE_ENEMY_MOVE:
             currentGameStateFunction = gameStateEnemyMove;
             break;
         case GAME_STATE_ANIMATE_ENEMY:
             currentGameStateFunction = gameStateAnimateEnemy;
             break;
         case GAME_STATE_EVALUATE_ENEMY_MOVE:
             currentGameStateFunction = gameStateEvaluateEnemyMove;
             break;
         case GAME_STATE_EVALUATE_OUTCOME:
            currentGameStateFunction = gameStateEvaluateOutcome;
            break;
         case GAME_STATE_ANIMATE_EXPLODE:
             currentGameStateFunction = gameStateAnimateExplode;
             break;
         case GAME_STATE_CHECK_FOR_GAME_OVER:
            currentGameStateFunction = gameStateCheckForGameOver;
            break;
         case GAME_STATE_PLAYER_WIN:
             currentGameStateFunction = gameStatePlayerWin;
             break;
         case GAME_STATE_PLAYER_LOSE:
             currentGameStateFunction = gameStatePlayerLose;
             break;

      }

   }

   function gameStateWaitForLoad(){
      //do nothing while loading events occur
      //console.log("doing nothing...")
   }

   function gameStateInit() {

      tileSheet = new Image();
      tileSheet.src = "tanks_sheet.png";
      tileSheet.onload = itemLoaded;

      switchGameState(GAME_STATE_WAIT_FOR_LOAD);

   }

    function itemLoaded(event) {

      loadCount++;
      ////console.log("loading:" + loadCount)
      if (loadCount >= itemsToLoad) {

         switchGameState(GAME_STATE_TITLE)

      }

   }

   function gameStateTitle() {
      if (screenStarted !=true){
         fillBackground();
         setTextStyleTitle();
         context.fillText  ("Micro Tank Maze", 160, 70);
         context.fillText  ("Press Space To Play", 150, 140);

         screenStarted = true;
      }else{
         //wait for space key click
         if (keyPressList[32]==true){
            //console.log("space pressed");
            switchGameState(GAME_STATE_NEW_GAME);
            screenStarted = false;

         }

      }

   }

   function gameStatePlayerWin(){

      if (!screenStarted){

         score += goalScore;
         fillBackground();
         setTextStyleTitle();
         context.fillText  ("YOU WON THE GAME!", 135, 70);
         context.fillText  ("Final Score: " + score, 150, 100);
         context.fillText  ("Number of enemy: " + enemyMax, 150,130);

         if (score > highScore){
            highScore = score;
            context.fillText  ("NEW HIGH SCORE!", 150,160);
         }

         context.fillText  ("High Score: " + score, 150, 190);

         screenStarted = true;

         enemyMax++;
         if (enemyMax >50){
            enemyMax = 50;
         }
         context.fillText  ("Number of enemy for next game: " +
           enemyMax, 100,220);

         context.fillText  ("Press Space To Play", 150, 300);

      }else{
         //wait for space key click
         if (keyPressList[32]==true){
            //console.log("space pressed");
            switchGameState(GAME_STATE_NEW_GAME);
            screenStarted = false;

         }

      }

   }

   function gameStatePlayerLose(){
      if (!screenStarted){
         fillBackground();
         setTextStyleTitle();
         context.fillText  ("SORRY, YOU LOST THE GAME!", 100, 70);
         context.fillText  ("Final Score: " + score, 150, 100);
         context.fillText  ("Number of enemy: " + enemyMax, 150,130);

         if (score > highScore){
            highScore = score;
            context.fillText  ("NEW HIGH SCORE!", 150,160);
         }

         context.fillText  ("High Score: " + score, 150, 190);

         screenStarted = true;

         context.fillText  ("Number of enemy for next game: " + 
          enemyMax, 100,220);
         context.fillText  ("Press Space To Play", 150, 300);
      }else{
         //wait for space key click
         if (keyPressList[32]==true){
            //console.log("space pressed");
            switchGameState(GAME_STATE_NEW_GAME);
            screenStarted = false;

         }

      }

   }

   function gameStateNewGame(){
      score = 0;
      enemy = [];
      explosions = [];
      playField = [];
      items = [];
      resetPlayer();
      createPlayField();
      renderPlayField();

      switchGameState(GAME_STATE_WAIT_FOR_PLAYER_MOVE);

   }

   function createPlayField(){
      var wallCount = 0;
      var playerCount = 0;
      var enemyCount = 0;
      var goalCount = 0;
      var roadCount = 0;

      //fill with road
      for (var rowCtr=0;rowCtr<15;rowCtr++){
         var tempRow = [];
         for (colCtr=0;colCtr<15;colCtr++) {
            tempRow.push(roadTile)
         }

         playField.push(tempRow);

      }
      //console.log("playField=" + playField);

      //create items array
      for (rowCtr=0;rowCtr<15;rowCtr++){
         var tempRow = [];
         for (colCtr=0;colCtr<15;colCtr++) {
            tempRow.push(0);
         }

         items.push(tempRow);

      }

      var randRow;
      var randCol;
      //placewalls
      for (var wallCtr=0;wallCtr<wallMax;wallCtr++){
         var wallLocationFound = false;
         while(!wallLocationFound){
            randRow = Math.floor(Math.random()*15);
            randCol = Math.floor(Math.random()*15);
            if (playField[randRow][randCol]==roadTile){
               playField[randRow][randCol] = wallTile;
               wallLocationFound = true;
            }
         }
      }

      //place enemy
      for (var enemyCtr=0;enemyCtr<enemyMax;enemyCtr++){
         var enemyLocationFound = false;
         while(!enemyLocationFound){
            randRow = Math.floor(Math.random()*15);
            randCol = Math.floor(Math.random()*15);
            if (playField[randRow][randCol]==roadTile){
               enemyLocationFound = true;
               var tempEnemy = {};
               tempEnemy.row = randRow;
               tempEnemy.col = randCol;
               tempEnemy.nextRow = 0;
               tempEnemy.nextCol = 0;
               tempEnemy.currentTile = 0;
               tempEnemy.rotation = 0;
               tempEnemy.x = tempEnemy.col*32;
               tempEnemy.y = tempEnemy.row*32;
               tempEnemy.speed = 2;
               tempEnemy.destinationX = 0;
               tempEnemy.destinationY = 0;
               tempEnemy.dx = 0;
               tempEnemy.dy = 0;
               tempEnemy.hit = false;
               tempEnemy.dead = false;
               tempEnemy.moveComplete = false;
               enemy.push(tempEnemy);
               items[randRow][randCol] = 1;
            }
         }
      }

      //place player
      var playerLocationFound = false;
      while(!playerLocationFound){
         randRow = Math.floor(Math.random()*15);
         randCol = Math.floor(Math.random()*15);
         if (playField[randRow][randCol]==roadTile && 
          items[randRow][randCol]==0){
            playerLocationFound = true;
            player.col = randCol;
            player.row = randRow;
            player.x = player.col*32;
            player.y = player.row*32;
            items[randRow][randCol] = 1;
         }
      }

      //place goal
      var goalLocationFound = false;
      while(!goalLocationFound){
         randRow = Math.floor(Math.random()*15);
         randCol = Math.floor(Math.random()*15);
         if (playField[randRow][randCol]==roadTile && 
          items[randRow][randCol]==0){
            playField[randRow][randCol] = goalTile;
            goalLocationFound = true;
         }
      }

      //console.log("playField=" + playField);

   }

   function resetPlayer(){
      player.row = 0;
      player.col = 0;
      player.nextRow = 0;
      player.nextCol = 0;
      player.currentTile = 0;
      player.rotation = 0;
      player.speed = 2;
      player.destinationX = 0;
      player.destinationY = 0;
      player.x = 0;
      player.y = 0;
      player.dx = 0;
      player.dy = 0;
      player.hit = false;
      player.dead = false;
      player.win = false;
   }

   function gameStateWaitForPlayerMove() {
      if (keyPressList[38]==true){
         //up
         if (checkBounds(-1,0, player)){
            setPlayerDestination();

         }
      }else if (keyPressList[37]==true) {
         //left
         if (checkBounds(0,-1, player)){
            setPlayerDestination();
         }
      }else if (keyPressList[39]==true) {
         //right
         if (checkBounds(0,1, player)){
            setPlayerDestination();
         }
      }else if  (keyPressList[40]==true){
         //down
         if (checkBounds(1,0, player)){
            setPlayerDestination();
         }
      }
   }

   function setPlayerDestination(){
      player.destinationX = player.nextCol*32;
      player.destinationY = player.nextRow*32;
      switchGameState(GAME_STATE_ANIMATE_PLAYER);
   }

   function checkBounds(rowInc, colInc, object){
      object.nextRow = object.row+rowInc;
      object.nextCol = object.col+colInc;

      if (object.nextCol >=0 && object.nextCol<15 && 
       object.nextRow>=0 && object.nextRow<15){
         object.dx = colInc;
         object.dy = rowInc;

         if (colInc==1){
            object.rotation = 90;
         }else if (colInc==-1){
            object.rotation = 270;
         }else if (rowInc==-1){
            object.rotation = 0;
         }else if (rowInc==1){
            object.rotation = 180;
         }

         return(true);

      }else{
         object.nextRow = object.row;
         object.nextCol = object.col;
         return(false);

      }

   }

   function gameStateAnimatePlayer(){
      player.x += player.dx*player.speed;
      player.y += player.dy*player.speed;
      player.currentTile++;
      if (player.currentTile==playerTiles.length){
         player.currentTile = 0;
      }
      renderPlayField();
      if (player.x==player.destinationX && player.y==player.destinationY){
         switchGameState(GAME_STATE_EVALUATE_PLAYER_MOVE);
      }

   }

   function gameStateEvaluatePlayerMove(){
      player.row = player.nextRow;
      player.col = player.nextCol;

      if (playField[player.row][player.col]==wallTile){
         player.hit = true;
      }else if (playField[player.row][player.col]==goalTile){
         player.win = true;
      }

      for (var eCtr=enemy.length-1;eCtr>=0;eCtr--){
         if (player.row==enemy[eCtr].row && player.col==enemy[eCtr].col){
            enemy[eCtr].hit = true;
            player.hit = true;
         }
      }

      switchGameState(GAME_STATE_ENEMY_MOVE);

   }

   function gameStateEnemyMove(){
      for (var eCtr=0;eCtr<enemy.length;eCtr++){
         var tempEnemy = enemy[eCtr];
         if (!tempEnemy.hit){

            var directionsToTest=[];
            var hDiff = tempEnemy.col - player.col;
            var vDiff = tempEnemy.row - player.row;

            if (Math.abs(vDiff) < Math.abs(hDiff)){
               if (vDiff > 0){
                  directionsToTest.push("up");
                  directionsToTest.push("down");
               }else if (vDiff <0){
                  directionsToTest.push("down");
                  directionsToTest.push("up");
               }

               if (hDiff >0){
                  directionsToTest.push("left");
                  directionsToTest.push("right");
               }else if (hDiff <0){
                  directionsToTest.push("right");
                  directionsToTest.push("left");
               }
            }else if (Math.abs(hDiff) < Math.abs(vDiff)) {
               if (hDiff >0){
                  directionsToTest.push("left");
                  directionsToTest.push("right");
               }else if (hDiff<0){
                  directionsToTest.push("right");
                  directionsToTest.push("left");
               }else if (vDiff > 0){
                  directionsToTest.push("up");
                  directionsToTest.push("down");
               }else if (vDiff <0){
                  directionsToTest.push("down");
                  directionsToTest.push("up");
               }
            }else if (Math.abs(hDiff) == Math.abs(vDiff)) {
               //make an educated random guess
               if (Math.floor(Math.random()*2)==0){
                  //try vertical first
                  if (vDiff >0){
                     directionsToTest.push("up");
                     directionsToTest.push("down");
                  }else if (vDiff<0){
                     directionsToTest.push("down");
                     directionsToTest.push("up");
                  }
               }else{
                  //try vertical first
                  if (hDiff >0){
                     directionsToTest.push("left");
                     directionsToTest.push("right");
                  }else if (hDiff<0){
                     directionsToTest.push("right");
                     directionsToTest.push("left");
                  }
               }
            }
            var chooseRandom = false;
            var moveFound = false;
            var movePtr = 0;
            var move;

            if (Math.floor(Math.random()*100)> chanceRandomEnemyMovement){

               //not random movement

               while(!moveFound){

                  move = directionsToTest[movePtr];

                  switch(move){
                     case "up":
                        if (checkBounds(-1,0,tempEnemy)){
                           moveFound = true;

                        }
                        break;

                     case "down":
                        if (checkBounds(1,0,tempEnemy)){
                           moveFound = true;

                        }
                        break;

                     case "left":
                        if (checkBounds(0,-1, tempEnemy)){
                           moveFound = true;

                        }
                        break;
                     case "right":
                        if (checkBounds(0,1,tempEnemy)){
                           moveFound = true;

                        }
                        break
                  }

                  movePtr++
                  if (movePtr==directionsToTest.length){
                     //do not move if no move found
                     //this should be impossible
                     chooseRandom = true;

                  }
               }

            }else{
               chooseRandom = true;
            }
               //pick random direction to test;
            if (chooseRandom) {

               while(!moveFound){
                  switch(Math.floor(Math.random()*4)){
                     case 0:
                        if (checkBounds(-1,0,tempEnemy)){
                           moveFound = true;

                        }else{

                        }
                        break;

                     case 1:
                        if (checkBounds(1,0,tempEnemy)){
                           moveFound = true;

                        }else{

                        }
                        break;

                     case 2:
                        if (checkBounds(0,-1, tempEnemy)){
                           moveFound = true;

                        }else{

                        }
                        break;
                     case 3:
                        if (checkBounds(0,1,tempEnemy)){
                           moveFound = true;

                        }else{

                        }
                        break
                  }
               }

            }


            tempEnemy.destinationX = tempEnemy.nextCol*32;
            tempEnemy.destinationY = tempEnemy.nextRow*32;


         }else{
            tempEnemy.nextCol = tempEnemy.col;
            tempEnemy.nextRow = tempEnemy.row;
            tempEnemy.destinationX = tempEnemy.nextCol*32;
            tempEnemy.destinationY = tempEnemy.nextRow*32;
         }


      }
      switchGameState(GAME_STATE_ANIMATE_ENEMY);
   }

   function gameStateAnimateEnemy(){
      for (var eCtr=enemy.length-1;eCtr>=0;eCtr--){
         var tempEnemy = enemy[eCtr];

         if (!tempEnemy.moveComplete){

            tempEnemy.x += tempEnemy.dx*tempEnemy.speed;
            tempEnemy.y += tempEnemy.dy*tempEnemy.speed;
            tempEnemy.currentTile++;
            if (tempEnemy.currentTile==enemyTiles.length){
               tempEnemy.currentTile = 0;
            }
            renderPlayField();
            if (tempEnemy.x==tempEnemy.destinationX &&
             tempEnemy.y==tempEnemy.destinationY){
               tempEnemy.moveComplete = true;
               enemyMoveCompleteCount++;
            }
         }
      }

      if (enemyMoveCompleteCount >= enemy.length){
         enemyMoveCompleteCount = 0;
         for (var eCtr=0;eCtr<enemy.length;eCtr++){
            var tempEnemy = enemy[eCtr];
            tempEnemy.moveComplete = false;
         }
         switchGameState(GAME_STATE_EVALUATE_ENEMY_MOVE);

      }

   }

   function gameStateEvaluateEnemyMove(){
      for (var eCtr=enemy.length-1;eCtr>=0;eCtr--){
         var tempEnemy = enemy[eCtr];
         tempEnemy.row = tempEnemy.nextRow;
         tempEnemy.col = tempEnemy.nextCol;

         if (playField[tempEnemy.row][tempEnemy.col]==wallTile){
            tempEnemy.hit = true;
         }

         if (player.row==tempEnemy.row && player.col==tempEnemy.col){
            tempEnemy.hit = true;
            player.hit = true;

         }

         //check against other enemy
         for (var eCtr2=enemy.length-1;eCtr2>=0;eCtr2--){
            var tempEnemy2 = enemy[eCtr2];

            if (tempEnemy.row==tempEnemy2.row && 
             tempEnemy.col==tempEnemy2.col && eCtr != eCtr2){
               tempEnemy.hit = true;
               tempEnemy2.hit = true;
            }

         }

      }
      switchGameState(GAME_STATE_EVALUATE_OUTCOME);
   }

   function gameStateEvaluateOutcome(){
      if (player.hit){
         player.dead = true;
         createExplode(player);
      }

      for (var eCtr=enemy.length-1;eCtr>=0;eCtr--){
         var tempEnemy = enemy[eCtr];
         if (tempEnemy.hit){
            score += enemyScore;
            tempEnemy.dead  = true;
            createExplode(tempEnemy)
            enemy.splice(eCtr,1);
            tempEnemy = null;
         }
      }

      switchGameState(GAME_STATE_ANIMATE_EXPLODE);
   }

   function createExplode(object){
      var newExplode = {};
      newExplode.currentTile = 0;
      newExplode.row = object.row;
      newExplode.col = object.com;
      newExplode.x = object.x;
      newExplode.y = object.y;
        newExplode.rotation = 0;
      explosions.push(newExplode);
   }


   function gameStateAnimateExplode(){
      for (var eCtr=explosions.length-1;eCtr>=0;eCtr--){
         var tempExplosion = explosions[eCtr];
         renderPlayField();
         tempExplosion.currentTile++;
         if (tempExplosion.currentTile == explodeTiles.length){
            explosions.splice(eCtr,1);
            tempExplode = null;
         }
      }

      if (explosions.length==0){
         switchGameState(GAME_STATE_CHECK_FOR_GAME_OVER);
      }
   }

   function gameStateCheckForGameOver() {
      if (player.dead){
         switchGameState(GAME_STATE_PLAYER_LOSE);
      }else if (player.win){
         switchGameState(GAME_STATE_PLAYER_WIN)
      }else{
         switchGameState(GAME_STATE_WAIT_FOR_PLAYER_MOVE);
      }
   }

   function drawPlayField(){
      for (rowCtr=0;rowCtr<15;rowCtr++){

         for (colCtr=0;colCtr<15;colCtr++) {
            var sourceX = Math.floor((playField[rowCtr][colCtr]) % 8) * 32;
            var sourceY = Math.floor((playField[rowCtr][colCtr]) /8) *32;

            if (playField[rowCtr][colCtr] != roadTile){
               context.drawImage(tileSheet, 0, 0,32,32, colCtr*32, 
                rowCtr*32,32,32);
            }
            context.drawImage(tileSheet, sourceX, sourceY,32,32,
             colCtr*32,rowCtr*32,32,32);
         }
      }
   }

   function drawPlayer(){
      if (!player.dead){
         context.save();

         context.setTransform(1,0,0,1,0,0);
         context.translate(player.x+16, player.y+16);
         var angleInRadians = player.rotation * Math.PI / 180;
         context.rotate(angleInRadians);

         var sourceX = Math.floor(playerTiles[player.currentTile] % 8) * 32;
         var sourceY = Math.floor(playerTiles[player.currentTile] /8) *32;

         context.drawImage(tileSheet, sourceX, sourceY,32,32,-16,-16,32,32);

         context.restore();
      }

   }

   function drawEnemy(){
      for (var eCtr=enemy.length-1;eCtr>=0;eCtr--){
         tempEnemy = enemy[eCtr];
         if (!tempEnemy.dead){
            context.save();

            context.setTransform(1,0,0,1,0,0);
            context.translate(tempEnemy.x+16, tempEnemy.y+16);
            var angleInRadians = tempEnemy.rotation * Math.PI / 180;
            context.rotate(angleInRadians);

            var sourceX = Math.floor(enemyTiles[tempEnemy.currentTile] % 8) * 32;
            var sourceY = Math.floor(enemyTiles[tempEnemy.currentTile] /8) *32;

            context.drawImage(tileSheet, sourceX, sourceY,32,32,-16,-16,32,32);

            context.restore();
         }
      }
   }

   function drawExplosions(){
      for (var eCtr=explosions.length-1;eCtr>=0;eCtr--){
         tempExplosion = explosions[eCtr];

         context.save();

         var sourceX = Math.floor(explodeTiles[tempExplosion.currentTile]
          % 8) * 32;
         var sourceY = Math.floor(explodeTiles[tempExplosion.currentTile] /8) *32;

         context.drawImage(tileSheet, sourceX, sourceY,32,32, 
          tempExplosion.x,tempExplosion.y,32,32);

         context.restore();

      }
   }

   function fillBackground() {
      // draw background and text
      context.fillStyle = '#000000';
      context.fillRect(xMin, yMin, xMax, yMax);

   }

   function setTextStyleTitle() {
      context.fillStyle = '#54ebeb';
      context.font = '20px _sans';
      context.textBaseline = 'top';
   }

   function renderPlayField() {
      fillBackground();
      drawPlayField();
      drawPlayer();
      drawEnemy();
      drawExplosions();

   }

   document.onkeydown = function(e){

      e = e?e:window.event;
      keyPressList[e.keyCode]=true;
   }

   document.onkeyup = function(e){
   //document.body.onkeyup = function(e){
      e = e?e:window.event;
      keyPressList[e.keyCode] = false;
   };

//*** application start
   switchGameState(GAME_STATE_INIT);
   const FRAME_RATE = 40;
   frameRateCounter = new FrameRateCounter(FRAME_RATE);
   //**** application loop
   var intervalTime = 1000/FRAME_RATE;
   setInterval(runGame, intervalTime );

}

//***  new FrameRateCounter   object prototype

function FrameRateCounter(fps) {
   if (fps == undefined){
      this.fps = 40
   }else{
      this.fps = fps
   }
   this.lastFrameCount = 0;
   var dateTemp = new Date();

   this.frameLast = dateTemp.getTime();
   delete dateTemp;
   this.frameCtr = 0;
   this.lastTime = dateTemp.getTime();
   this.step = 1;
}
FrameRateCounter.prototype.countFrames=function() {

   var dateTemp = new Date();
   var timeDifference = dateTemp.getTime()-this.lastTime;
   this.step = (timeDifference/1000)*this.fps;
   this.lastTime = dateTemp.getTime();
   //console.log("step=",this.step)
   this.frameCtr++;

   if (dateTemp.getTime() >=this.frameLast+1000) {
      ConsoleLog.log("frame event");
      this.lastFrameCount = this.frameCtr;
      this.frameCtr = 0;
      this.frameLast = dateTemp.getTime();
   }
   delete dateTemp;

}

</script>
</head>
<body>
<div style="position: absolute; top: 50px; left: 50px;">
<canvas id="canvas" width="480" height="480">
 Your browser does not support HTML5 Canvas.
</canvas>
</body>
</html>

What’s Next

Throughout this entire book, we have used game- and entertainment-related subjects to demonstrate canvas application building concepts. Over these last two chapters, we’ve sped up the game discussion and covered many game concepts directly by creating two unique games and optimizing a third with bitmaps and object pooling. In doing so, we have applied many of the concepts from the earlier chapters in full-blown game applications. The techniques used to create a game on Canvas can be applied to almost any canvas application from image viewers to stock charting. The sky is really the limit, as the canvas allows the developer a full suite of powerful low-level capabilities that can be molded into any application.

In Chapter 10, we will look at porting a simple game from the canvas into a native iPhone application.

Get HTML5 Canvas now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.