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.
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.
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 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).
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.
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.
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 render
Particles()
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 game
StateInit()
:
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
<!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.
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 the
Canvas2
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.
<!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 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.
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×32player
object while it is moving to a new tile. It represents the top-left cornerx
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×32player
object while it is moving to a new tile. It represents the top-left cornery
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
, or1
, 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
, or1
, 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
istrue
, it will be replaced on theplayField
by an explosion sprite. Withdead
set totrue
, 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
animate
Enemy()
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.
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:
First, it tests to see whether the player is closer to the enemy vertically or horizontally.
If vertically, and the player is above the enemy, it places
up
and thendown
into thedirectionsToTest
array.If vertically, and the player is below the enemy, it places
down
and thenup
into thedirectionsToTest
array.Note
The
up
and thendown
, ordown
and thenup
, directions are pushed into thedirectionsTest
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.It then looks to see where to add the
left
andright
directions. It does this based on which way will put it closer to the player.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 thenright
, thenup
and thendown
.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
<!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.