Chapter 8. Canvas Game Essentials
Games are the reason why many of us initially became interested in computers, and they continue to be a major driving force that pushes computer technology to new heights. In this chapter, we will examine how to build a mini game framework that can be used to create games on the canvas. We will explore many of the core building blocks associated with game development and apply them to HTML5 Canvas with the JavaScript API.
We don’t have the space to cover every type of game you might want to create, but we will discuss many elementary and intermediate topics necessary for most games. At the end of this chapter, we will have a basic clone of Atari’s classic Asteroids game. We will step through the creation of this game by first applying some of the techniques for drawing and transformations specific to our game’s visual objects. This will help get our feet wet by taking some of the techniques we covered in previous chapters and applying them to an arcade game application. Next, we will create a basic game framework that can be applied to any game we want to make on the canvas. Following this, we will dive into some game techniques and algorithms, and finally, we will apply everything we have covered to create the finished product.
Why Games in HTML5?
Playing games in a browser has become one of the most popular activities for Internet users. HTML5 Canvas gives web developers an API to directly manage drawing to a specific area of the browser. This functionality makes game development in JavaScript much more powerful than ever before.
Canvas Compared to Flash
We’ve covered this topic in earlier chapters, but we expect that a large portion of readers might have previously developed games in Flash. If so, you will find that Canvas offers similar functionality in certain areas, but lacks some of the more refined features of Flash.
- No Flash timeline
There is no frame-based timeline for animation intrinsic to Canvas. This means that we will need to code all of our animations using images and/or paths, and apply our own frame-based updates.
- No display list
Flash AS3 offers the very powerful idea of an object display list; a developer can add hundreds of individual physical display objects to the game screen. HTML5 Canvas has only a single display object (the canvas itself).
What Does Canvas Offer?
Even though Canvas lacks some of the features that make the Flash platform very nice for game development, it also has some strengths.
- A powerful single stage
HTML5 Canvas is closely akin to the Flash Stage. It is a rectangular piece of screen real estate that can be manipulated programmatically. Advanced Flash developers might recognize the canvas as a close cousin to both the
BitmapData
andShape
objects in ActionScript. We can draw directly to the canvas with paths and images, and transform them on the fly.- Logical display objects
Canvas gives us a single physical display object, but we can create any number of logical display objects. We will use JavaScript objects to hold all of the logical data and methods we need to draw and transform our logical game objects to the physical canvas.
Our Basic Game HTML5 File
Before we start to develop our arcade game, let’s look at Example 8-1, the most basic HTML file we will use in this chapter (CH8EX1.html). We’ll start by using the basic HTML5 template we defined in Chapter 1. Our canvas will be a 200×200 square.
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <title>CH8EX1: Filled Screen With Some Text</title> <script type="text/javascript"> window.addEventListener('load', eventWindowLoaded, false); function eventWindowLoaded() { canvasApp(); } function canvasApp(){ var theCanvas = document.getElementById("canvas"); if (!theCanvas || !theCanvas.getContext) { return; } var context = theCanvas.getContext("2d"); if (!context) { return; } drawScreen(); function drawScreen() { context.fillStyle = '#ffaaaa'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#000000'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Canvas!", 0, 0); } } </script> </head> <body> <div style="position: absolute; top: 50px; left: 50px;"> <canvas id="canvas" width="200" height="200"> Your browser does not support HTML5 Canvas. </canvas> </div> </body> </html>
This example will do nothing more than place a 200×200 gray box on
the canvas and write “Canvas!” starting at 0
,0
. We will
be replacing the drawScreen()
function
for most of the next few examples. Figure 8-1 illustrates Example 8-1.
Next, we will begin to make our Asteroids-like game, which we’ve named Geo Blaster Basic. See Figure 8-7 for an example of the final game in action.
Our Game’s Design
We are not going to assume that everyone who reads this chapter knows of or understands Atari’s classic arcade game Asteroids. So, let’s start by taking a peek at Asteroids’ game-play elements.
Asteroids, designed by Ed Logg and Lyle Rains, was released by Atari in 1979. The game pitted a lone triangular two-dimensional vector spaceship (the player ship) against screen after screen of asteroid rocks that needed to be dodged and destroyed. Every so often a space saucer would enter the screen attempting to destroy the player ship.
All asteroids started the game as large rocks; once they were hit, they would split into two medium-sized rocks. When hit by a player missile, these medium-sized rocks would then split into two small rocks. The small rocks would simply be destroyed when hit (small was the final size for all asteroids).
When the player destroyed all the asteroids, a new screen of more and slightly faster asteroids would appear. This went on until the player exhausted his three ships. At each 10,000-point score mark, the player was awarded an extra ship.
All of the game objects moved (thrusting, rotating, and/or floating) freely across the entire screen, which represented a slice of space as a flat plane. When an object went off the side of the screen, it would reappear on the opposite side, in warp-like fashion.
Game Graphics: Drawing with Paths
Let’s jump into game development on Canvas by first taking a look at some of the graphics we will use in our game. This will help give us a visual feel for what type of code we will need to implement.
Needed Assets
For our Asteroids-like game, Geo Blaster Basic, we will need some very simple game graphics, including:
A solid black background.
A player ship that will rotate and thrust (move on a vector) across the game screen. There will be two frames of animation for this ship: a “static” frame and a “thrust” frame.
A saucer that flies across the screen and shoots at the player.
Some “rocks” for the player to shoot. We will use a simple square as our rock.
There are two different methods we can employ to draw the graphics for our game: bitmap images or paths. For the game in this chapter, we will focus on using paths. In Chapter 9, we will explore how to manipulate bitmap images for our game graphics.
Using Paths to Draw the Game’s Main Character
Paths offer us a very simple but powerful way to mimic the vector look of the classic Asteroids game. We could use bitmap images for this purpose, but in this chapter we are going to focus on creating our game in code with no external assets. Let’s take a look at the two frames of animation we will create for our player ship.
The static player ship (frame 1)
The main frame of the player ship will be drawn with paths on a 20×20 grid, as shown in Figure 8-2.
Using the basic HTML file presented in Example 8-1, we can simply swap the
drawScreen()
function with the code
in Example 8-2 to draw the ship.
function drawScreen() { // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Player Ship - Static", 0, 180); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); context.moveTo(10,0); context.lineTo(19,19); context.lineTo(10,9); context.moveTo(9,9); context.lineTo(0,19); context.lineTo(9,0); context.stroke(); context.closePath(); }
We are drawing to the upper-left corner of the screen starting
at 0
,0
. Figure 8-3 shows what this will look
like.
The ship with thrust engaged (frame 2)
Now let’s take a look at the second frame of animation for the player ship, which is shown in Figure 8-4.
The drawScreen()
function
code to add this extra “thrust” graphic is very simple; see Example 8-3.
function drawScreen() { // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Player Ship - Thrust", 0, 180); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); context.moveTo(10,0); context.lineTo(19,19); context.lineTo(10,9); context.moveTo(9,9); context.lineTo(0,19); context.lineTo(9,0); //draw thrust context.moveTo(8,13); context.lineTo(11,13); context.moveTo(9,14); context.lineTo(9,18); context.moveTo(10,14); context.lineTo(10,18); context.stroke(); context.closePath(); }
Animating on the Canvas
The player ship we just created has two frames (static and thrust), but we can only display a single frame at a time. Our game will need to switch out the frame of animation based on the state of the player ship, and it will need to run on a timer so this animation can occur. Let’s take a quick look at the code necessary to create our game timer.
Game Timer Loop
Games on HTML5 Canvas require the use of the repeated
update/render loop to simulate animation. We do this by using the
setInterval()
JavaScript function,
which will repeatedly call a function of our choosing at millisecond
intervals. Each second of game/animation time is made up of 1,000
milliseconds. If we want our game to run at 30 update/render cycles per
second, we call this a 30 frames per second (FPS) rate. To run our
interval at 30 FPS, we first need to divide 1,000 by 30. The result is
the number of milliseconds in each interval:
const FRAME_RATE = 30; var intervalTime = 1000/FRAME_RATE; setInterval(drawScreen, intervalTime );
By calling the drawScreen()
function repeatedly on each interval, we can simulate animation.
Note
Sometimes we will refer to each of the frame intervals as a frame tick.
The Player Ship State Changes
We simply need to switch between the static and thrust states to
simulate the animation. Let’s take a look at the full HTML file to do
this. In Example 8-4, we
will start to place canvasApp
class-level variables in a new section just above the drawScreen()
function. This will be the
location going forward for all variables needing a global scope inside
the canvasApp()
object.
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <title>CH8EX4: Ship Animation Loop</title> <script type="text/javascript"> window.addEventListener('load', eventWindowLoaded, false); function eventWindowLoaded() { canvasApp(); } function canvasApp(){ var theCanvas = document.getElementById("canvas"); if (!theCanvas || !theCanvas.getContext) { return; } var context = theCanvas.getContext("2d"); if (!context) { return; } //canvasApp level variables var shipState = 0; //0 = static, 1 = thrust function drawScreen() { //update the shipState shipState++; if (shipState >1) { shipState=0; } // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Player Ship - animate", 0, 180); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); context.moveTo(10,0); context.lineTo(19,19); context.lineTo(10,9); context.moveTo(9,9); context.lineTo(0,19); context.lineTo(9,0); if (shipState==1) { //draw thrust context.moveTo(8,13); context.lineTo(11,13); context.moveTo(9,14); context.lineTo(9,18); context.moveTo(10,14); context.lineTo(10,18); } context.stroke(); context.closePath(); } const FRAME_RATE = 40; var intervalTime = 1000/FRAME_RATE; setInterval(drawScreen, intervalTime ); } </script> </head> <body> <div style="position: absolute; top: 50px; left: 50px;"> <canvas id="canvas" width="200" height="200"> Your browser does not support HTML5 Canvas. </canvas> </div> </body> </html>
When we run Example 8-4 we will see the player ship in the upper-left corner of the canvas. The static and thrust states will alternate on each frame.
Applying Transformations to Game Graphics
Our game will probably have many individual logical display objects
that need to be updated on a single frame tick. We can make use of the
Canvas stack (save()
and restore()
functions), and use the transformation
matrix to ensure the final output affects only the current object we are
working on—not the entire canvas.
The Canvas Stack
The Canvas state can be saved to a stack and retrieved. This is important when we are transforming and animating game objects because we want our transformations to affect only the current game object and not the entire canvas. The basic workflow for using the Canvas stack in a game looks like this:
Save the current canvas to the stack.
Transform and draw the game object.
Retrieve the saved canvas from the stack.
As an example, let’s set up a basic rotation for our player ship.
We will rotate it by 1 degree on each frame. Since we are currently
drawing the player ship in the top-left corner of the canvas, we are
going to move it to a new location. We do this because the basic
rotation will use the top-left corner of the ship as the registration point: the axis location used
for rotation and scale operations. So, if we kept the ship at the
0
,0
location and rotated it by its top-left
corner, you would not see it half the time because its location would be
off the top and left edges of the canvas. Instead, we will place the
ship at 50
,50
.
We will be using the same HTML code as in Example 8-4, changing out only
the drawCanvas()
function. To
simplify this example, we will remove the shipState
variable and concentrate on the
static state only. We will be adding in three new variables above the
drawCanvas()
function:
var rotation = 0; - holds the current rotation of the player ship var x = 50; - holds the x location to start drawing the player ship var y = 50; - holds the y location to start drawing the player ship
Example 8-5 gives the full code.
//canvasApp level variables var rotation = 0; var x = 50; var y = 50; function drawScreen() { // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Player Ship - rotate", 0, 180); //transformation var angleInRadians = rotation * Math.PI / 180; context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(x,y); context.rotate(angleInRadians); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); context.moveTo(10,0); context.lineTo(19,19); context.lineTo(10,9); context.moveTo(9,9); context.lineTo(0,19); context.lineTo(9,0); context.stroke(); context.closePath(); //restore context context.restore(); //pop old state on to screen //add to rotation rotation++; }
As you can see, the player ship rotates clockwise one degree at a
time. As we’ve mentioned many times already, we must convert from
degrees to radians because the context.rotate()
transformations use
radians for calculations. In the next section, we’ll take a deeper look
at some of the transformations we will use in our
Geo Blaster Basic game.
Game Graphic Transformations
As we saw in the previous section, we can easily rotate a game
graphic at the top-left corner by using the context.rotate()
transformation. However, our
game will need to rotate objects at the center rather than the top-left
corner. To do this, we must change the transformation point to the center
of our game graphic object.
Rotating the Player Ship from the Center
The code to rotate the player ship from its center point is almost
exactly like the code used to rotate it at the top-left corner. What we
need to modify is the point of the translation. In Example 8-5, we placed the immediate-mode drawing
context at the x
and y
coordinates of our game object (50
,50
).
This had the effect of rotating the object from the top-left corner. Now
we must move the translation to the center of our object:
context.translate(x+.5*width,y+.5*height);
Note
The width
and height
variables represent attributes of our
drawn player ship. We will create these attributes in Example 8-6.
This is not the only change we need to make; we also need to draw
our ship as though it is the center point. To do this, we will subtract
half the width
from each x
attribute in our path draw sequence, and
half the height
from each y
attribute:
context.moveTo(10-.5*width,0-.5*height); context.lineTo(19-.5*width,19-.5*height);
As you can see, it might get a little confusing trying to draw
coordinates in this manner. It is also slightly more processor-intensive
than using constants. In that case, we would simply hardcode in the
needed values. Remember, the width
and height
attributes of our ship are
both 20
. The hardcoded version would
look something like this:
context.moveTo(0,−10); //10-10, 0-10 context.lineTo(9,9); //19-10, 19-10
The method where we use the calculated values (using the width
and height
variables) is much more flexible, while
the hardcoded method is much less processor-intensive. Example 8-6 contains all the
code to use either method. We have commented out the calculated version
of the code.
//canvasApp level variables var rotation = 0; var x = 50; var y = 50; var width = 20; var height = 20; function drawScreen() { // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Player Ship - rotate", 0, 180); //transformation var angleInRadians = rotation * Math.PI / 180; context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(x+.5*width,y+.5*height); context.rotate(angleInRadians); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); //hardcoding in locations context.moveTo(0,-10); context.lineTo(9,9); context.lineTo(0,-1); context.moveTo(-1,-1); context.lineTo(-10,9); context.lineTo(-1,-10); /* //using the width and height to calculate context.moveTo(10-.5*width,0-.5*height); context.lineTo(19-.5*width,19-.5*height); context.lineTo(10-.5*width,9-.5*height); context.moveTo(9-.5*width,9-.5*height); context.lineTo(0-.5*width,19-.5*height); context.lineTo(9-.5*width,0-.5*height); */ context.stroke(); context.closePath(); //restore context context.restore(); //pop old state on to screen //add to rotation rotation++; }
Alpha Fading the Player Ship
When a new player ship in Geo Blaster Basic enters the game screen, we will have it fade from transparent to opaque. Example 8-7 shows how we will create this transformation in our game.
Using the context.globalAlpha attribute
To use the context.globalAlpha
attribute of the canvas,
we simply set it to a number between 0
and 1
before we draw the game graphics. We will create a new variable in our
code called alpha
, which will hold
the current alpha value for our player ship. We will increase it by
.01
until it reaches 1
. When we actually create our game we will
stop it at 1
and then start the
game level. However, for this demo, we will just repeat it over and
over.
//canvasApp level variables var x = 50; var y = 50; var width = 20; var height = 20; var alpha = 0; context.globalAlpha = 1; function drawScreen() { context.globalAlpha = 1; context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Player Ship - alpha", 0, 180); context.globalAlpha = alpha; context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(x+.5*width,y+.5*height); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); //hardcoding in locations context.moveTo(0,-10); context.lineTo(9,9); context.lineTo(0,-1); context.moveTo(-1,-1); context.lineTo(-10,9); context.lineTo(-1,-10); context.stroke(); context.closePath(); //restore context context.restore(); //pop old state on to screen //add to rotation alpha+=.01; if (alpha > 1) { alpha=0; } }
Game Object Physics and Animation
All of our game objects will move on a two-dimensional plane. We
will use basic directional movement vectors to calculate the change in the
x
and y
coordinates for each game object. At its very
basic level, we will be updating the delta x (dx
) and delta y (dy
) of each of our game objects on each frame to
simulate movement. These dx
and
dy
values will be based on the angle
and direction in which we want the object to move. All of our logical
display objects will add their respective dx
and dy
values to their x
and y
values on each frame of animation. The player
ship will not use strict dx
and
dy
because it needs to be able to float
and turn independently. Let’s take a closer look at the player movement
now.
How Our Player Ship Will Move
Our player ship will change its angle of center axis rotation when the game player presses the left and right arrow keys. When the game player presses the up arrow key, the player ship will accelerate (thrust) in the angle it is facing. Because there is no friction applied to the ship, it will continue to float in the current accelerated angle until a different angle of acceleration is applied. This happens when the game player rotates to a new angle and presses the up (thrust) key once again.
The difference between facing and moving
Our player ship can rotate to the direction it is facing while
it is moving in a different direction. For this reason, we cannot
simply use classic dx
and dy
values to represent the movement vector
on the x and y axes. We must keep both sets of values for the ship at
all times. When the player rotates the ship but does not thrust it, we
need to draw the ship in the new rotated angle. All missile
projectiles the ship fires must also move in the direction the ship is
facing. On the x-axis, we will name this value facingX
; on the y-axis, it’s facingY
. movingX
and movingY
values will handle moving the ship
in the direction it was pointed in when the thrust was applied. All
four values are needed to thrust the ship in a new direction. Let’s
take a look at this next.
Thrusting in the rotated direction
Once the ship is rotated to the desired direction, the player
can thrust it forward by pressing the up arrow key. This thrust will
accelerate the player ship only while the key is pressed. Since we
know the rotation of the ship, we can easily calculate the angle of
the rotation. We will then add new movingX
and movingY
values to the ship’s x
and y
attributes to move it forward.
First, we must change the rotation value from degrees to radians:
var angleInRadians = rotation * Math.PI / 180;
You have seen this before—it’s identical to how we calculated the rotation transformation before it was applied to the player ship.
Once we have the angle of the ship’s rotation, we must calculate
the facingX
and facingY
values for this current direction.
We only do this when we are going to thrust because it is an expensive
calculation, processor-wise. We could calculate these each time the
player changes the ship’s rotation, but doing so would add unnecessary
processor overhead:
facingX = Math.cos(angleInRadians); facingY = Math.sin(angleInRadians);
Once we have values on the x and y axes that represent the
direction the player ship is currently facing, we can calculate the
new movingX
and movingY
values for the player:
movingX = movingX+thrustAcceleration*facingX; movingY = movingY+thrustAcceleration*facingY;
To apply these new values to the player ship’s current position,
we need to add them to its current x
and y
positions. This does not occur only when the player presses
the up key. If it did, the player ship would not float; it
would only move when the key was pressed. We must modify the x
and y
values on each frame with the movingX
and movingY
values:
x = x+movingX; y = y+movingY;
Redrawing the player ship to start at angle 0
As you may recall, when we first drew the image for our player
ship, we had the point end (the top) of the ship pointing up. We did
this for ease of drawing, but it’s not really the best direction in
which to draw our ship when we intend to apply calculations for
rotational thrust. The pointing-up direction is actually the -90
(or 270
) degree angle. If we want to leave
everything the way it currently is, we will need to modify the
angleInRadians
calculation to look
like this:
var angleInRadians = (Math.PI * (player.rotation -90 ))/ 180;
This is some ugly code, but it works fine if we want our player
ship to be pointing up before we apply rotation transformations. A
better method is to keep the current angleInRadians
calculation but draw the ship
pointing in the actual angle 0
direction (to the right). Figure 8-5 shows how we
would draw this.
The drawing code for this direction would be modified to look like this:
//facing right context.moveTo(−10,−10); context.lineTo(10,0); context.moveTo(10,1); context.lineTo(−10,10); context.lineTo(1,1); context.moveTo(1,−1); context.lineTo(−10,−10);
Controlling the Player Ship with the Keyboard
We will add in two keyboard events and an array object to hold the state of each key press. This will allow the player to hold down a key and have it repeat without a pause. Arcade games require this type of key-press response.
The array to hold our key presses
An array will hold the true
or false
value for each keyCode
associated with key events. The
keyCode
will be the index of the
array that will receive the true
or
false
value:
var keyPressList = [];
The key events
We will use separate events for both key down and key up. The
key down
event will put a true
value in the keyPressList
array at the index associated
with the event’s keyCode
. Conversely, the key up
event will place a false
in that array index:
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; };
Evaluating key presses
Our game will need to include code to look for true
(or false
) values in the keyPressList
array, and use those values to
apply game logic:
if (keyPressList[38]==true){ //thrust var angleInRadians = player.rotation * Math.PI / 180; facingX = Math.cos(angleInRadians); facingY = Math.sin(angleInRadians); movingX = movingX+thurstAcceleration*facingX; movingY = movingY+thurstAcceleration*facingY; } if (keyPressList[37]==true) { //rotate counterclockwise rotation-=rotationalVelocity; } if (keyPressList[39]==true) { //rotate clockwise rotation+=rotationalVelocity;; }
Let’s add this code to our current set of rotation examples and test it out. We have made some major changes, so Example 8-8 presents the entire HTML file once again.
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <title>CH8EX8: Ship Turn With Keys</title> <script type="text/javascript"> window.addEventListener('load', eventWindowLoaded, false); function eventWindowLoaded() { canvasApp(); } function canvasApp(){ var theCanvas = document.getElementById("canvas"); if (!theCanvas || !theCanvas.getContext) { return; } var context = theCanvas.getContext("2d"); if (!context) { return; } //canvasApp level variables var rotation = 0; var x = 50; var y = 50; var facingX = 0; var facingY = 0; var movingX = 0; var movingY = 0; var width = 20; var height = 20; var rotationalVelocity = 5; //how many degrees to turn the ship var thrustAcceleration = .03; var keyPressList = []; function drawScreen() { //check keys if (keyPressList[38]==true){ //thrust var angleInRadians = rotation * Math.PI / 180; facingX = Math.cos(angleInRadians); facingY = Math.sin(angleInRadians); movingX = movingX+thrustAcceleration*facingX; movingY = movingY+thrustAcceleration*facingY; } if (keyPressList[37]==true) { //rotate counterclockwise rotation −= rotationalVelocity; } if (keyPressList[39]==true) { //rotate clockwise rotation += rotationalVelocity;; } x = x+movingX; y = y+movingY; // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Player Ship - key turn", 0, 180); //transformation var angleInRadians = rotation * Math.PI / 180; context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(x+.5*width,y+.5*height); context.rotate(angleInRadians); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); //hardcoding in locations //facing right context.moveTo(-10,-10); context.lineTo(10,0); context.moveTo(10,1); context.lineTo(-10,10); context.lineTo(1,1); context.moveTo(1,-1); context.lineTo(-10,-10); context.stroke(); context.closePath(); //restore context context.restore(); //pop old state on to screen } const FRAME_RATE = 40; var intervalTime = 1000/FRAME_RATE; setInterval(drawScreen, intervalTime ); 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; }; } </script> </head> <body> <div style="position: absolute; top: 50px; left: 50px;"> <canvas id="canvas" width="200" height="200"> Your browser does not support HTML5 Canvas. </canvas> </div> </body> </html>
Once this file is run in a browser, you should be able to press the left and right keys to rotate the ship on its center axis. If you press the up key, the ship will move in the direction it is facing.
Giving the Player Ship a Maximum Velocity
If you play with the code in Example 8-8, you will notice two problems:
The ship can go off the sides of the screen and get lost.
The ship has no maximum speed.
We’ll resolve the first issue when we start to code the complete
game, but for now, let’s look at how to apply a maximum velocity to our
current movement code. Suppose we give our player ship a maximum
acceleration of 2
pixels per frame.
It’s easy to calculate the current velocity if we are only moving in the
four primary directions: up, down, right, left. When we are moving left
or right, the movingY
value will
always be 0
. If we are moving up or
down, the movingX
value will always
be 0
. The current velocity we are
moving on one axis would be easy to compare to the maximum
velocity.
But in our game, we are almost always moving in the x and y directions at the same time. To calculate the current velocity and compare it to a maximum velocity, we must use a bit more math.
First, let’s assume that we will add a maximum velocity variable to our game:
var maxVelocity = 2;
Next, we must make sure to calculate and compare the maxVelocity
to the current velocity
before we calculate the new movingX
and movingY
values. We will do this with local
variables used to store the new values for movingX
and movingY
before they are applied:
var movingXNew = movingX+thrustAcceleration*facingX; var movingYNew = movingY+thrustAcceleration*facingY;
The current velocity of our ship is the square root of movingXNew^2 + movingYNew^2
:
var currentVelocity = Math.sqrt ((movingXNew*movingXNew) + (movingXNew*movingXNew));
If the currentVelocity
is less
than the maxVelocity
, we set the
movingX
and movingY
values:
if (currentVelocity < maxVelocity) { movingX = movingXNew; movingY = movingYNew; }
A Basic Game Framework
Now that we have gotten our feet wet (so to speak) by taking a peek at some of the graphics, transformations, and basic physics we will use in our game, let’s look at how we will structure a simple framework for all games we might want to create on HTML5 Canvas. We will begin by creating a simple state machine using constant variables. Next, we will introduce our game timer interval function to this structure, and finally, we will create a simple reusable object that will display the current frame rate our game is running in. Let’s get started.
The Game State Machine
A state machine is a programming construct that allows for our game to be in only a single application state at any one time. We will create a state machine for our game, called application state, which will include seven basic states (we will use constants to refer to these states):
GAME_STATE_TITLE
GAME_STATE_NEW_GAME
GAME_STATE_NEW_LEVEL
GAME_STATE_PLAYER_START
GAME_STATE_PLAY_LEVEL
GAME_STATE_PLAYER_DIE
GAME_STATE_GAME_OVER
We will create a function
object for each state that will contain game logic necessary for the
state to function and to change to a new state when appropriate. By
doing this, we can use the same structure for each game we create by
simply changing out the content of each state
function (as we will refer to them).
Let’s take a look at a very basic version of this in action. We
will use a function reference variable called currentGameStateFunction
, as well as an
integer variable called currentGameState
that will hold the current
application state constant value:
var currentGameState = 0; var currentGameStateFunction = null;
We will create a function called switchAppState()
that will be called only when
we want to switch to a new state:
function switchGameState(newState) { currentGameState = newState; switch (currentState) { case GAME_STATE_TITLE: currentGameStateFunction = gameStateTitle; break; case GAME_STATE_PLAY_LEVEL: currentGameStateFunctionappStatePlayeLevel; break; case GAME_STATE_GAME_OVER: currentGameStateFunction = gameStateGameOver; break; } }
We will call the runGame()
function repeatedly in the setInterval()
method. runGame()
will call the currentGameStateFunction
reference variable on
each frame tick. This allows us to easily change the function called by
runGame()
based on changes in the
application state:
setInterval(runGame, intervalTime ); function runGame(){ currentGameStateFunction(); }
Let’s look at the complete code. We will create some shell
functions for the various application state
functions. Before the application starts, we will call the switchGame
State()
function, and pass in the
constant value for the new function we want as the currentGameStateFunction
:
//*** application start switchGameState(GAME_STATE_TITLE);
In Example 8-9, we will use the
GAME_STATE_TITLE
state to draw a
simple title screen that will be redrawn on each frame tick.
<script type="text/javascript"> window.addEventListener('load', eventWindowLoaded, false); function eventWindowLoaded() { canvasApp(); } function canvasApp(){ var theCanvas = document.getElementById("canvas"); if (!theCanvas || !theCanvas.getContext) { return; } var context = theCanvas.getContext("2d"); if (!context) { return; } //application states const GAME_STATE_TITLE = 0; const GAME_STATE_NEW_LEVEL = 1; const GAME_STATE_GAME_OVER = 2; var currentGameState = 0; var currentGameStateFunction = null; function switchGameState(newState) { currentGameState = newState; switch (currentGameState) { case GAME_STATE_TITLE: currentGameStateFunction = gameStateTitle; break; case GAME_STATE_PLAY_LEVEL: currentGameStateFunctionappStatePlayeLevel; break; case GAME_STATE_GAME_OVER: currentGameStateFunction = gameStateGameOver; break; } } function gameStateTitle() { ConsoleLog.log("appStateTitle"); // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("Title Screen", 50, 90); } function gameStatePlayLevel() { ConsoleLog.log("appStateGamePlay"); } function gameStateGameOver() { ConsoleLog.log("appStateGameOver"); } function runGame(){ currentGameStateFunction(); } //*** application start switchGameState(GAME_STATE_TITLE); //**** application loop const FRAME_RATE = 40; 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 </script>
Note
Example 8-9 added in the ConsoleLog
object from the previous
chapters. We will continue to use this utility to create helpful debug
messages in the JavaScript log window of the browser.
We will continue to explore the application state machine, and then create one for our game logic states in the upcoming section, Putting It All Together.
The Update/Render (Repeat) Cycle
In any of our application states, we might need to employ
animation and screen updates. We will handle these updates by separating
our code into distinct update()
and
render()
operations. For example, as you might recall, the player ship can move
around the game screen, and when the player presses the up arrow key,
the ship’s thrust frame of animation will be displayed rather than its
static frame. In the previous examples, we contained all the code that
updates the properties of the ship, as well as the code that actually
draws the ship, in a single function called drawScreen()
. Starting with Example 8-10, we will rid
ourselves of this simple drawScreen()
function and instead employ update()
and render()
functions separately. We
will also separate out the code that checks for the game-specific key
presses into a checkKeys()
function.
Let’s reexamine the contents of the drawScreen()
function from Example 8-8, but this time break the
function up into separate functions for each set of tasks, as shown in
Example 8-10.
function gameStatePlayLevel() { checkKeys(); update(); render(); } function checkKeys() { //check keys if (keyPressList[38]==true){ //thrust var angleInRadians = rotation * Math.PI / 180; facingX = Math.cos(angleInRadians); facingY = Math.sin(angleInRadians); movingX = movingX+thrustAcceleration*facingX; movingY = movingY+thrustAcceleration*facingY; } if (keyPressList[37]==true) { //rotate counterclockwise rotation−=rotationalVelocity; } if (keyPressList[39]==true) { //rotate clockwise rotation+=rotationalVelocity;; } } function update() { x = x+movingX; y = y+movingY; } function render() { //draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("render/update", 0, 180); //transformation var angleInRadians = rotation * Math.PI / 180; context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(x+.5*width,y+.5*height); context.rotate(angleInRadians); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); //hardcoding in locations //facing right context.moveTo(−10,−10); context.lineTo(10,0); context.moveTo(10,1); context.lineTo(−10,10); context.lineTo(1,1); context.moveTo(1,−1); context.lineTo(−10,−10); context.stroke(); context.closePath(); //restore context context.restore(); //pop old state on to screen } const FRAME_RATE = 40; var intervalTime = 1000/FRAME_RATE; setInterval(appStateGamePlay, intervalTime );
We left out the entire application state machine from Example 8-9 to save space. In Example 8-10, we are simply
showing what the gameStatePlayLevel()
function might look like.
In the section Putting It All Together, we will go into this in greater detail as we start to build out the entire application.
The FrameRateCounter Object Prototype
Arcade games such as Asteroids and Geo Blaster Basic rely on fast processing and screen updates to ensure all game-object rendering and game-play logic are delivered to the player at a reliable rate. One way to tell whether your game is performing up to par is to employ the use of a frame rate per second (FPS) counter. Below is a simple one that can be reused in any game you create on the canvas:
//*** FrameRateCounter object prototype function FrameRateCounter() { this.lastFrameCount = 0; var dateTemp = new Date(); this.frameLast = dateTemp.getTime(); delete dateTemp; this.frameCtr = 0; } FrameRateCounter.prototype.countFrames=function() { var dateTemp = new Date(); this.frameCtr++; if (dateTemp.getTime() >=this.frameLast+1000) { ConsoleLog.log("frame event"); this.lastFrameCount = this.frameCtr; this.frameLast = dateTemp.getTime(); this.frameCtr = 0; } delete dateTemp; }
Our game will create an instance of this object and call the
countFrames()
function on each frame
tick in our update()
function. We
will write out the current frame rate in our render()
function.
Example 8-11 shows these
functions by adding code to Example 8-10. Make sure you add
the definition of the FrameRateCounter
prototype object to the code
in Example 8-10 under the
canvasApp()
function but before the
final <script>
tag.
Alternatively, you can place it in its own <script\>
tags, or in a separate
.js file and set the URL as the
src=
value of a <script>
tag. For simplicity’s sake, we
will keep all our code in a single file.
Example 8-11 contains the
definition for our FrameRateCounter
object prototype, as well as the code changes to Example 8-10 that are necessary
to implement it.
function update() { x = x+movingX; y = y+movingY; frameRateCounter.countFrames(); } function render() { // draw background and text context.fillStyle = '#000000'; context.fillRect(0, 0, 200, 200); context.fillStyle = '#ffffff'; context.font = '20px _sans'; context.textBaseline = 'top'; context.fillText ("FPS:" + frameRateCounter.lastFrameCount, 0, 180); //...Leave everything else from Example 8-10 intact here } frameRateCounter = new FrameRateCounter(); const FRAME_RATE = 40; var intervalTime = 1000/FRAME_RATE; setInterval(runGame, intervalTime );
Putting It All Together
We are now ready to start coding our game. First, we will look at the structure of the game and some of the ideas behind the various algorithms we will employ to create it. After that, we will present the full source code for Geo Blaster Basic.
Geo Blaster Game Structure
The structure of the game application is very similar to the structure we started to build earlier in this chapter. Let’s take a closer look at the state functions and how they will work together.
Game application states
Our game will have seven distinct game application states. We will store these in constants:
const GAME_STATE_TITLE = 0; const GAME_STATE_NEW_GAME = 1; const GAME_STATE_NEW_LEVEL = 2; const GAME_STATE_PLAYER_START = 3; const GAME_STATE_PLAY_LEVEL = 4; const GAME_STATE_PLAYER_DIE = 5; const GAME_STATE_GAME_OVER = 6;
Game application state functions
Each individual state will have an associated function that will be called on each frame tick. Let’s look at the functionality for each:
gameStateTitle()
Displays the title screen text and waits for the space bar to be pressed before the game starts.
gameStateNewGame()
Sets up all the defaults for a new game. All of the arrays for holding display objects are reinitialized—the game
level
is reset to0
, and the gamescore
is set to0
.gameStateNewLevel()
Increases the
level
value by one, and then sets the “game knob” values to control the level difficulty. See the upcoming section Level Knobs for details.gameStatePlayerStart()
Fades the player graphic onto the screen from
0
alpha to1
. Once this is complete, level play will start.gameStatePlayLevel()
Controls the play of the game level. It calls the
update()
andrender()
functions, as well as the functions for evaluating keyboard input for player ship control.gameStatePlayerDie()
Starts up an explosion at the location where the player ship was when it was hit by a rock, saucer, or saucer missile. Once the explosion is complete (all particles in the explosion have exhausted their individual life values), it sets the move to the
GAME_STATE_PLAYER_START
state.gameStateGameOver()
Displays the “Game Over” screen, and starts a new game when the space bar is pressed.
Game application functions
Aside from the game application state functions, there are a number of functions we need for the game to run. Each state function will call these functions as needed:
resetPlayer()
Resets the player to the center of the game screen and readies it for game play.
checkForExtraShip()
Checks to see whether the player should be awarded an extra ship. See the section Awarding the Player Extra Ships for details on this algorithm.
checkForEndOfLevel()
Checks to see whether all the rocks have been destroyed on a given level and, if so, starts up a new level. See the section Level and Game End for details on this algorithm.
fillBackground()
Fills the canvas with the background color on each frame tick.
setTextStyle()
Sets the base text style before text is written to the game screen.
renderScoreBoard()
Is called on each frame tick. It displays the updated score, number of ships remaining, and the current FPS for the game.
checkKeys()
Checks the
keyPressList
array, and then modifies the player ship attributes based on the values found to betrue
.update()
Is called from
GAME_STATE_PLAY_LEVEL
. It in turn calls theupdate()
function for each individual display object array.- Individual display object
update()
functions The unique functions listed below update each different type of display object. These functions (with the exception of
updatePlayer()
) will loop through the respective array of objects associated with its type of display object, and update thex
andy
values withdx
anddy
values. TheupdateSaucer()
function contains the logic necessary to check whether to create a new saucer, and whether any current saucers on the screen should fire a missile at the player.updatePlayer()
updatePlayerMissiles()
updateRocks()
updateSaucers()
updateSaucerMissiles()
updateParticles()
- Individual display object
render()
Is called from
GAME_STATE_PLAY_LEVEL
. It in turn calls therender()
function for each individual display object array.- Individual display object
render()
functions Like the
update()
functions, the unique functions listed below render each different type of display object. Again, with the exception of therenderPlayer()
object (because there is only a single player ship), each of these functions will loop through the array of objects associated with its type and draw them to the game screen. As we saw when drawing the player ship earlier in this chapter, we will draw each object by moving and translating the canvas to the point at which we want to draw our logical object. We will then transform our object (if necessary) and paint the paths to the game screen.renderPlayer()
renderPlayerMissiles()
renderRocks()
renderSaucers()
renderSaucerMissiles()
renderParticles()
- Individual display object
checkCollisions()
Loops through the individual game display objects and checks them for collisions. See the section Applying Collision Detection for a detailed discussion of this topic.
firePlayerMissile()
Creates a
playerMissile
object at the center of the player ship and fires it in the direction the player ship is facing.fireSaucerMissile()
Creates a
saucerMissile
object at the center of the saucer and fires it in the direction of the player ship.playerDie()
Creates an explosion for the player by calling
createExplode()
, as well as changing the game application state toGAME_STATE_PLAYER_DIE
.createExplode()
Accepts in the location for the explosion to start and the number of particles for the explosion.
boundingBoxCollide()
Determines whether the rectangular box that encompasses an object’s width and height is overlapping the bounding box of another object. It takes in two logical display objects as parameters, and returns
true
if they are overlapping andfalse
if they are not. See the section Applying Collision Detection for details on this function.splitRock()
Accepts in the scale and
x
andy
starting points for two new rocks that will be created if a large or medium rock is destroyed.addToScore()
Geo Blaster Global Game Variables
Now let’s look at the entire set of game application scope variables needed for our game.
- Variables that control screen flow
These variables will be used when the title and “Game Over” screens first appear. They will be set to
true
once the screen is drawn. When these variables aretrue
, the screens will look for the space bar to be pressed before moving on to the next application state:var titleStarted = false; var gameOverStarted = false;
- Game environment variables
These variables set up the necessary defaults for a new game. We will discuss the
extraShipAtEach
andextraShipsEarned
in the section, Awarding the Player Extra Ships:var score = 0; var level = 0; var extraShipAtEach = 10000; var extraShipsEarned = 0; var playerShips = 3;
- Playfield variables
These variables set up the maximum and minimum
x
andy
coordinates for the game stage:var xMin = 0; var xMax = 400; var yMin = 0; var yMax = 400;
- Score value variables
These variables set the score value for each of the objects the player can destroy:
var bigRockScore = 50; var medRockScore = 75; var smlRockScore = 100; var saucerScore = 300;
- Rock size constants
These variables set up some human-readable values for the three rock sizes, allowing us to simply use the constant instead of a literal value. We can then change the literal value if needed:
const ROCK_SCALE_LARGE = 1; const ROCK_SCALE_MEDIUM = 2; const ROCK_SCALE_SMALL = 3;
- Logical display objects
These variables set up the single player object and arrays to hold the various other logical display objects for our game. See the upcoming sections and Arrays of Logical Display Objects for further details on each:
var player = {}; var rocks = []; var saucers = []; var playerMissiles = []; var particles = [] var saucerMissiles = [];
- Level-specific variables
The level-specific variables handle the difficulty settings when the game level increases. See the section Level Knobs for more details on how these are used:
var levelRockMaxSpeedAdjust = 1; var levelSaucerMax = 1; var levelSaucerOccurrenceRate = 25 var levelSaucerSpeed = 1; var levelSaucerFireDelay = 300; var levelSaucerFireRate = 30; var levelSaucerMissileSpeed = 1;
The player Object
The player
object contains many
of the variables we encountered earlier in this chapter when we discussed
animating, rotating, and moving the player ship about the game screen. We
have also added in three new variables that you have not seen
before:
player.maxVelocity = 5; player.width = 20; player.height = 20; player.halfWidth = 10; player.halfHeight = 10; player.rotationalVelocity = 5 player.thrustAcceleration = .05; player.missileFrameDelay = 5; player.thrust = false;
The new variables are halfWidth
,
halfHeight
, and missileFrameDelay
. halfWidth
and halfHeight
simply store half the width and half
the height values, so these need not be calculated on each frame tick in
multiple locations inside the code. The missileFrameDelay
variable contains the number
of frame ticks the game will count between firing player missiles. This
way, the player cannot simply fire a steady stream of ordnance and destroy
everything with little difficulty.
The player.thrust
variable will
be set to true
when the player presses
the up key.
Geo Blaster Game Algorithms
The game source code covers a lot of ground that we did not touch on earlier in this chapter. Let’s discuss some of those topics now; the rest will be covered in detail in Chapter 9.
Arrays of Logical Display Objects
We have used arrays to hold all our logical display objects, and
we have an array for each type of object (rocks
, saucers
, playerMissiles
, saucerMissiles
, and particles
). Each logical display object is a
simple object instance. We have created a separate function to draw and
update each of our objects.
Note
The use of an object class prototype similar to FrameRateCounter
can be implemented easily
for the various display object types. To conserve space, we have not
implemented them in this game. However, these objects would allow us
to separate the update and draw code from the current common
functions, and then place that code into the individual object
prototypes. We have included a Rock prototype at the end of this
chapter as an example (see Example 8-13).
You will notice that saucers and rocks are drawn with points in the same manner as the player ship.
Rocks
The rocks will be simple squares that rotate clockwise or
counterclockwise. The rock instances will be in the rocks
array. When a new level starts, these
will all be created in the upper-right corner of the game
screen.
Here are the variable attributes of a rock
object:
newRock.scale = 1; newRock.width = 50; newRock.height = 50; newRock.halfWidth = 25; newRock.halfHeight = 25; newRock.x newRock.y newRock.dx newRock.dy newRock.scoreValue = bigRockScore; newRock.rotation = 0;
The rock scale will be set to one of the three rock-scale
constants discussed earlier. halfWidth
and halfHeight
will be set based on the scale,
and they will be used in calculations in the same manner as the player
object versions. The dx
and
dy
values represent the values to
apply to the x and y axes when updating the rock on each frame
tick.
Saucers
Unlike Atari’s Asteroids
game, which has both small and large saucers, we are only going to
have one size in Geo Blaster Basic. It will be
stored in the saucers
array. On a
28×13 grid (using paths), it looks like Figure 8-6.
The variable attributes of the saucer
object are very similar to the
attributes of a rock
object,
although without the rock scale
attribute. Also, saucers don’t have a rotation; it is always set at
0
. The saucer also contains
variables that are updated on each new level to make the game more
challenging for the player. Here are those variables, which will be
discussed in more detail in the upcoming section :
newSaucer.fireRate = levelSaucerFireRate; newSaucer.fireDelay = levelSaucerFireDelay; newSaucer.fireDelayCount = 0; newSaucer.missileSpeed = levelSaucerMissileSpeed;
Missiles
Both the player missiles and saucer missiles will be 2×2-pixel
blocks. They will be stored in the playerMissiles
and saucerMissiles
arrays,
respectively.
The objects are very simple. They contain enough attributes to move them across the game screen and to calculate life values:
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;
Explosions and particles
When a rock, saucer, or the player ship is destroyed, that
object explodes into a series of particles. The createExplode()
function creates this
so-called particle explosion. Particles are simply individual logical
display objects with their own life
, dx
,
and dy
values. Randomly generating
these values makes each explosion appear to be unique. Particles will
be stored in the particles
array.
Like missiles, particle
objects are rather simple. They also contain enough information to
move them across the screen and to calculate their life span in frame
ticks:
newParticle.dx = Math.random()*3; newParticle.dy = Math.random()*3; newParticle.life = Math.floor(Math.random()*30+30); newParticle.lifeCtr = 0; newParticle.x = x; newParticle.y = y;
Level Knobs
Even though we never show the level number to the game player, we
are adjusting the difficulty every time a screen of rocks is cleared. We
do this by increasing the level variable by 1
and then recalculating these values before
the level begins. We refer to the variance in level difficulty as
knobs, which refers to dials or switches. Here are
the variables we will use for these knobs:
level+3
Number of rocks
levelRockMaxSpeedAdjust = level*.25;
Rock max speed
levelSaucerMax = 1+Math.floor(level/10);
Number of simultaneous saucers
levelSaucerOccurrenceRate = 10+3*level;
Percent chance a saucer will appear
levelSaucerSpeed = 1+.5*level;
Saucer speed
levelSaucerFireDelay = 120-10*level;
Delay between saucer missiles
levelSaucerFireRate = 20+3*level;
Percent chance a saucer will fire at the player
levelSaucerMissileSpeed = 1+.2*level;
Speed of saucer missiles
Level and Game End
We need to check for game and level end so we can transition to either a new game or to the next level.
Level end
We will check for level end on each frame tick. The function to do so will look like this:
function checkForEndOfLevel(){ if (rocks.length==0) { switchGameState(GAME_STATE_NEW_LEVEL); } }
Once the rocks
array length
is 0
, we switch the state to
GAME_STATE_NEW_LEVEL
.
Game end
We do not need to check for the end of the game on each frame
tick. We only need to check when the player loses a ship. We do this
inside the gameStatePlayerDie()
function:
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); } } }
This is the state function that is called on each frame tick
during the GAME_STATE_PLAYER_DIE
state. First, it checks to see that there are no longer any particles
on the screen. This ensures that the game will not end until the
player ship has finished exploding. We also check to make sure that
all the player’s missiles have finished their lives. We do this so we
can check for collisions between the playerMissiles
, and for rocks
against saucers
. This way the player might earn an
extra ship before playerShips--
is
called.
Once the particles
and
missiles
have all left the game
screen, we subtract 1
from the
playerShips
variable and then
switch to GAME_STATE_GAME_OVER
if
the playerShips
number is less than
1
.
Awarding the Player Extra Ships
We want to award the player extra ships at regular intervals based on her score. We do this by setting an amount of points that the game player must achieve to earn a new ship—this also helps us keep a count of the number of ships earned:
function checkForExtraShip() { if (Math.floor(score/extraShipAtEach) > extraShipsEarned) { playerShips++ extraShipsEarned++; } }
We call this function on each frame tick. The player earns an
extra ship if the score/extraShipAtEach
variable (with the
decimals stripped off) is greater than the number of ships earned. In
our game, we have set the extraShipAtEach
value to 10000
. When the game starts, extraShipsEarned
is 0
. When the player’s score is 10000
or more, score/extraShipAtEach
will equal 1
, which is greater than the extraShipsEarned
value of 0
. An extra ship is given to the player, and
the extraShipsEarned
value is
increased by 1
.
Applying Collision Detection
We will be checking the bounding box around each object when we do our collision detection. A bounding box is the smallest rectangle that will encompass all four corners of a game logic object. We have created a function for this purpose:
function boundingBoxCollide(object1, object2) { var left1 = object1.x; var left2 = object2.x; var right1 = object1.x + object1.width; var right2 = object2.x + object2.width; var top1 = object1.y; var top2 = object2.y; var bottom1 = object1.y + object1.height; var bottom2 = object2.y + object2.height; if (bottom1 < top2) return(false); if (top1 > bottom2) return(false); if (right1 < left2) return(false); if (left1 > right2) return(false); return(true); };
We can pass any two of our game objects into this function as long
as each contains x
, y
, width
,
and height
attributes. If the two
objects are overlapping, the function will return true
. If not, it will return false
.
The checkCollision()
function
for Geo Blaster Basic is quite involved. The full
code listing is given in Example 8-12. Rather than
reprint it here, let’s examine some of the basic concepts.
One thing you will notice is the use of “labels” next to the
for
loop constructs. Using labels
such as in the following line can help streamline collision
detection:
rocks: for (var rockCtr=rocksLength;rockCtr>=0;rockCtr--){
We will need to loop through each of the various object types that
must be checked against one another. But we do not want to check an
object that was previously destroyed against other objects. To ensure we
do the fewest amount of collision checks necessary, we have implemented
a routine that employs label and break
statements.
Here is the logic behind the routine:
Create a
rocks:
label and then start to loop through therocks
array.Create a
missiles:
label inside therocks
iteration, and loop through theplayerMissiles
array.Do a bounding box collision detection between the last rock and the last missile. Notice that we loop starting at the end of each array so that we can remove elements (when collisions occur) in the array without affecting array members that have not been checked yet.
If a rock and a missile collide, remove them from their respective arrays, and then call
break rocks
and thenbreak missiles
. We must break back to the next element in an array for any object type that is removed.Continue looping through the missiles until they have all been checked against the current rock (unless
break rocks
was fired off for a rock/missile collision).Check each saucer, each saucer missile, and the player against each of the rocks. The player does not need a label because there is only a single instance of the player. The
saucers
andsaucerMissiles
will follow the same logic asmissiles
. If there is a collision between one and a rock, break back to their respective labels after removing the objects from their respective arrays.Once we have checked the rocks against all the other game objects, check the
playerMissiles
against the saucers using the same basic logic of loop labels, looping backward through the arrays, and breaking back to the labels once objects are removed.Check the
saucerMissiles
against the player in the same manner.
Over the years, we have found this to be a powerful way to check multiple objects’ arrays against one another. It certainly is not the only way to do so. If you are not comfortable using loop labels, you can employ a method such as the following:
Add a Boolean
hit
attribute to each object and set it tofalse
when an object is created.Loop through the
rocks
and check them against the other game objects. This time the direction (forward or backward) through the loops does not matter.Before calling the
boundingBoxCollide()
function, be sure that each object’shit
attribute isfalse
. If not, skip the collision check.If the two objects collide, set each object’s
hit
attribute totrue
. There is no need to remove objects from the arrays at this time.Loop though
playerMissiles
and check against thesaucers
, and then loop through thesaucers
to check against theplayer
.When all the collision-detection routines are complete, reloop through each object array (backward this time) and remove all the objects with
true
as ahit
attribute.
We have used both methods—and variations—on each. While the second method is a little cleaner, this final loop through all of the objects might add more processor overhead when dealing with a large number of objects. We will leave the implementation of this second method to you as an exercise, in case you wish to test it.
The Geo Blaster Basic Full Source
Example 8-12 shows the entire set of code for our game. You can download this and the entire set of example files from the book’s website.
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Geo Blaster Basic Game</title> <script type="text/javascript"> window.addEventListener('load', eventWindowLoaded, false); function eventWindowLoaded() { canvasApp(); } function canvasApp(){ var theCanvas = document.getElementById("canvas"); if (!theCanvas || !theCanvas.getContext) { return; } var context = theCanvas.getContext("2d"); if (!context) { return; } //application states const GAME_STATE_TITLE = 0; const GAME_STATE_NEW_GAME = 1; const GAME_STATE_NEW_LEVEL = 2; const GAME_STATE_PLAYER_START = 3; const GAME_STATE_PLAY_LEVEL = 4; const GAME_STATE_PLAYER_DIE = 5; const GAME_STATE_GAME_OVER = 6; var currentGameState = 0; var currentGameStateFunction = null; //title screen var titleStarted = false; //gameover 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 = []; //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 = []; function runGame(){ currentGameStateFunction(); } function switchGameState(newState) { currentGameState = newState; switch (currentGameState) { 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 gameStateTitle() { if (titleStarted !=true){ fillBackground(); setTextStyle(); context.fillText ("Geo Blaster Basic", 130, 70); context.fillText ("Press Space To Play", 120, 140); 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 = 20; player.height = 20; player.halfWidth = 10; player.halfHeight = 10; player.rotationalVelocity = 5; //how many degrees to turn the ship player.thrustAcceleration = .05; player.missileFrameDelay = 5; player.thrust = false; 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 = 50; newRock.height = 50; newRock.halfWidth = 25; newRock.halfHeight = 25; //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 newRock.rotationInc = (Math.random()*5)+1; if (Math.random()<.5){ newRock.rotationInc*=-1; } 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 += .02; context.globalAlpha = player.alpha; }else{ switchGameState(GAME_STATE_PLAY_LEVEL); } renderPlayerShip(player.x, player.y,270,1); context.globalAlpha = 1; updateRocks(); renderRocks(); } 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; } 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!", 150, 70); context.fillText ("Press Space To Play", 120, 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 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 (keyPressList[39]==true) { //rotate clockwise player.rotation += player.rotationalVelocity;; } if (keyPressList[32]==true) { //ConsoleLog.log("player.missileFrameCount=" + player.missileFrameCount); //ConsoleLog.log("player.missileFrameDelay=" + player.missileFrameDelay); if (player.missileFrameCount>player.missileFrameDelay){ 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; player.y += player.movingY; 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; tempPlayerMissile.y += tempPlayerMissile.dy; 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; tempRock.y += tempRock.dy; tempRock.rotation += tempRock.rotationInc; //ConsoleLog.log("rock rotationInc="+ tempRock.rotationInc) //ConsoleLog.log("rock rotation="+ tempRock.rotation) 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 = 28; newSaucer.height = 13; newSaucer.halfHeight = 6.5; newSaucer.halfWidth = 14; 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 ){ fireSaucerMissile(tempSaucer) tempSaucer.fireDelayCount= 0; } var remove = false; tempSaucer.x += tempSaucer.dx; tempSaucer.y += tempSaucer.dy; //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; tempSaucerMissile.y += tempSaucerMissile.dy; 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 tempParticle = {}; var particleLength = particles.length-1; //ConsoleLog.log("particle=" + particleLength) for (var particleCtr=particleLength;particleCtr>=0;particleCtr--){ var remove = false; tempParticle = particles[particleCtr]; tempParticle.x += tempParticle.dx; tempParticle.y += tempParticle.dy; tempParticle.lifeCtr++; //ConsoleLog.log("particle.lifeCtr=" + tempParticle.lifeCtr); //try{ if (tempParticle.lifeCtr > tempParticle.life){ remove = true; } else if ((tempParticle.x > xMax) || (tempParticle.x<xMin) || (tempParticle.y > yMax) || (tempParticle.y<yMin)){ remove = true; } //} //catch(err) { // ConsoleLog.log ("error in particle"); // ConsoleLog.log("particle:" + particleCtr); //} if (remove) { particles.splice(particleCtr,1) tempParticle = null; } } } function renderPlayerShip(x,y,rotation, scale) { //transformation var angleInRadians = rotation * Math.PI / 180; context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(x+player.halfWidth,y+player.halfHeight); context.rotate(angleInRadians); context.scale(scale,scale); //drawShip context.strokeStyle = '#ffffff'; context.beginPath(); //hardcoding in locations //facing right context.moveTo(-10,-10); context.lineTo(10,0); context.moveTo(10,1); context.lineTo(-10,10); context.lineTo(1,1); context.moveTo(1,-1); context.lineTo(-10,-10); if (player.thrust==true && scale==1) { //check for scale==1 for ship indicator does not display with thrust context.moveTo(-4,-2); context.lineTo(-4,1); context.moveTo(-5,-1); context.lineTo(-10,-1); context.moveTo(-5,0); context.lineTo(-10,0); } context.stroke(); context.closePath(); //restore context context.restore(); //pop old state on to screen } 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 context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(tempPlayerMissile.x+1,tempPlayerMissile.y+1); context.strokeStyle = '#ffffff'; context.beginPath(); //draw everything offset by 1/2. Zero Relative 1/2 is 15 context.moveTo(-1,-1); context.lineTo(1,-1); context.lineTo(1,1); context.lineTo(-1,1); context.lineTo(-1,-1); context.stroke(); context.closePath(); context.restore(); //pop old state on to screen } } function renderRocks() { var tempRock = {}; var rocksLength = rocks.length-1; for (var rockCtr=rocksLength;rockCtr>=0;rockCtr--){ tempRock = rocks[rockCtr]; var angleInRadians = tempRock.rotation * Math.PI / 180; //ConsoleLog.log("render rock rotation"+(tempRock.rotation)); context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(tempRock.x+tempRock.halfWidth, tempRock.y+tempRock.halfHeight); //ConsoleLog.log("render rock x"+(tempRock.x+tempRock.halfWidth)); //ConsoleLog.log("render rock y"+(tempRock.y+tempRock.halfHeight)); context.rotate(angleInRadians); context.strokeStyle = '#ffffff'; context.beginPath(); //draw everything offset by 1/2. //Zero Relative 1/2 is if .5*width -1. Same for height context.moveTo(-(tempRock.halfWidth-1),-(tempRock.halfHeight-1)); context.lineTo((tempRock.halfWidth-1),-(tempRock.halfHeight-1)); context.lineTo((tempRock.halfWidth-1),(tempRock.halfHeight-1)); context.lineTo(-(tempRock.halfWidth-1),(tempRock.halfHeight-1)); context.lineTo(-(tempRock.halfWidth-1),-(tempRock.halfHeight-1)); context.stroke(); context.closePath(); 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 context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player //context.translate(this.x+halfWidth,this.y+halfHeight); context.translate(tempSaucer.x,tempSaucer.y); context.strokeStyle = '#ffffff'; context.beginPath(); //did not move to middle because it is drawn in exact space context.moveTo(4,0); context.lineTo(9,0); context.lineTo(12,3); context.lineTo(13,3); context.moveTo(13,4); context.lineTo(10,7); context.lineTo(3,7); context.lineTo(1,5); context.lineTo(12,5); context.moveTo(0,4); context.lineTo(0,3); context.lineTo(13,3); context.moveTo(5,1); context.lineTo(5,2); context.moveTo(8,1); context.lineTo(8,2); context.moveTo(2,2); context.lineTo(4,0); context.stroke(); context.closePath(); 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 context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(tempSaucerMissile.x+1,tempSaucerMissile.y+1); context.strokeStyle = '#ffffff'; context.beginPath(); //draw everything offset by 1/2. Zero Relative 1/2 is 15 context.moveTo(-1,-1); context.lineTo(1,-1); context.lineTo(1,1); context.lineTo(-1,1); context.lineTo(-1,-1); context.stroke(); context.closePath(); 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 context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(tempParticle.x,tempParticle.y); context.strokeStyle = '#ffffff'; context.beginPath(); //draw everything offset by 1/2. Zero Relative 1/2 is 15 context.moveTo(0,0); context.lineTo(1,1); context.stroke(); context.closePath(); 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); 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); createExplode(tempRock.x+tempRock.halfWidth, tempRock.y+tempRock.halfHeight,10); 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); 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 aginst rocks if (boundingBoxCollide(tempRock,player)){ //ConsoleLog.log("hit player"); createExplode(tempRock.x+tempRock.halfWidth,tempRock.halfHeight,10); 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); 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)){ ConsoleLog.log("hit player"); createExplode(tempSaucer.x+16,tempSaucer.y+16,10); 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)){ 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; 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.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); switchGameState(GAME_STATE_PLAYER_DIE); } function createExplode(x,y,num) { //create 10 particles for (var partCtr=0;partCtr<num;partCtr++){ var newParticle = new Object(); 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.y = y; //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.width; var right2 = object2.x + object2.width; var top1 = object1.y; var top2 = object2.y; var bottom1 = object1.y + object1.height; var bottom2 = object2.y + object2.height; 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 = 25; newRock.height = 25; newRock.halfWidth = 12.5; newRock.halfHeight = 12.5; }else { newRock.scoreValue = smlRockScore; newRock.width = 16; newRock.height = 16; newRock.halfWidth = 8; newRock.halfHeight = 8; } 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; } newRock.rotationInc = (Math.random()*5)+1; if (Math.random()<.5){ newRock.rotationInc*=-1; } 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_TITLE); frameRateCounter = new FrameRateCounter(); //**** application loop const FRAME_RATE = 40; 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 //*** FrameRateCounter object prototype function FrameRateCounter() { this.lastFrameCount = 0; var dateTemp = new Date(); this.frameLast = dateTemp.getTime(); delete dateTemp; this.frameCtr = 0; } FrameRateCounter.prototype.countFrames = function() { var dateTemp = new Date(); this.frameCtr++; if (dateTemp.getTime() >=this.frameLast+1000) { ConsoleLog.log("frame event"); this.lastFrameCount = this.frameCtr; this.frameLast = dateTemp.getTime(); this.frameCtr = 0; } 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>
Figure 8-7 shows a screenshot of the game in action.
Rock Object Prototype
To conserve space, we did not create separate object prototypes for the various display objects in this game. However, Example 8-13 is a Rock prototype object that can be used in a game such as Geo Blaster Basic.
//*** Rock Object Prototype function Rock(scale, type) { //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 this.scale = scale; if (this.scale <1 || this.scale >3){ this.scale=1; } this.type = type; this.dx = 0; this.dy = 0; this.x = 0; this.y = 0; this.rotation = 0; this.rotationInc = 0; this.scoreValue = 0; //ConsoleLog.log("create rock. Scale=" + this.scale); switch(this.scale){ case 1: this.width = 50; this.height = 50; break; case 2: this.width = 25; this.height = 25; break; case 3: this.width = 16; this.height = 16; break; } } Rock.prototype.update = function(xmin,xmax,ymin,ymax) { this.x += this.dx; this.y += this.dy; this.rotation += this.rotationInc; if (this.x > xmax) { this.x = xmin-this.width; }else if (this.x<xmin-this.width){ this.x = xmax; } if (this.y > ymax) { this.y = ymin-this.width; }else if (this.y<ymin-this.width){ this.y = ymax; } } Rock.prototype.draw = function(context) { var angleInRadians = this.rotation * Math.PI / 180; var halfWidth = Math.floor(this.width*.5); //used to find center of object var halfHeight = Math.floor(this.height*.5)// used to find center of object context.save(); //save current state in stack context.setTransform(1,0,0,1,0,0); // reset to identity //translate the canvas origin to the center of the player context.translate(this.x+halfWidth,this.y+halfHeight); context.rotate(angleInRadians); context.strokeStyle = '#ffffff'; context.beginPath(); //draw everything offset by 1/2. Zero Relative 1/2 is if .5*width -1. Same for height context.moveTo(-(halfWidth-1),-(halfHeight-1)); context.lineTo((halfWidth-1),-(halfHeight-1)); context.lineTo((halfWidth-1),(halfHeight-1)); context.lineTo(-(halfWidth-1),(halfHeight-1)); context.lineTo(-(halfWidth-1),-(halfHeight-1)); context.stroke(); context.closePath(); context.restore(); //pop old state on to screen } //*** end Rock Class
What’s Next
We covered quite a bit in this chapter. HTML5 Canvas might lack some of the more refined features common to web game development platforms such as Flash, but it contains powerful tools for manipulating the screen in immediate mode. These features allow us to create a game application with many individual logical display objects—even though each canvas can support only a single physical display object (the canvas itself).
In Chapter 9 we will explore some more advanced game topics, such as replacing paths with bitmap images, creating object pools, and adding a sound manager. We’ll extend the game we built in this chapter and create a new turn-based strategy game.
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.