16 December 2011
Sound understanding of HTML and CSS. Also, basic knowledge of JavaScript and jQuery.
Intermediate
One of the main features of HTML5 is the new <canvas> element, which provides a blank space where you can draw lines and shapes dynamically, as well as add text and images. In this second part of a two-part tutorial series, you'll use the Canvas 2D Context API (application programming interface) to create the graphics for a Hangman word-guessing game. You'll also use HTML5-related web storage to keep record of the user's score, and add a manifest file to make the game usable offline in modern browsers.
The <canvas> element and web storage are supported by the most recent versions of all browsers, but not by older versions of Internet Explorer (IE). To ensure that the game works in IE 6–8, you'll add helper scripts (JavaScript "polyfills") for HTML5 canvas, canvas text, and local storage.
The instructions assume that you have completed Part 1 of this tutorial series. Continue working with your existing files. If you want to be sure you're starting with working code, use the files in hangman_pt2_start.zip (they're identical to hangman_pt1_finish.zip).
When the user selects a letter in the alphabetic keypad, the checkLetter() function tests whether it's in the word to be guessed. If it is, the letter replaces the appropriate underscore(s). Otherwise, it triggers another function to draw part of the gallows and victim. The checkLetter() function also has to keep track of the number of good and bad guesses.
checkLetter() function like this:// Check whether selected letter is in the word to be guessed
function checkLetter(letter) {
var placeholders = word.innerHTML,
wrongGuess = true;
}
<canvas> element. Finally, add the placeholders back to the screen. The complete checkLetter() function looks like this:// Check whether selected letter is in the word to be guessed
function checkLetter(letter) {
var placeholders = word.innerHTML,
wrongGuess = true;
// split the placeholders into an array
placeholders = placeholders.split('');
// loop through the array
for (var i = 0; i < wordLength; i++) {
// if the selected letter matches one in the word to guess,
// replace the underscore and increase the number of correct guesses
if (wordToGuess.charAt(i) == letter.toLowerCase()) {
placeholders[i] = letter;
wrongGuess = false;
correctGuesses++;
// redraw the canvas only if all letters have been guessed
if (correctGuesses == wordLength) {
drawCanvas();
}
}
}
// if the guess was incorrect, increment the number of bad
// guesses and redraw the canvas
if (wrongGuess) {
badGuesses++;
drawCanvas();
}
// convert the array to a string and display it again
word.innerHTML = placeholders.join('');
}
Each time the loop runs, the conditional statement examines the next letter in the word to guess using charAt() and compares it with the selected letter. The comparison is done using letter.toLowerCase() because all the words are in lowercase. If they match, the letter replaces the current placeholder, resets the local variable wrongGuess to false , and increments the global variable correctGuesses .
If correctGuesses equals the global variable wordLength , it means all the letters have been guessed, and the game has been won. When that happens, you need to redraw the <canvas> element to display the result.
If the selected letter doesn't match any of the letters in the word to guess, wrongGuess remains true, so the badGuesses global variable is incremented and the <canvas> element is redrawn.
Finally, the placeholders array is converted back to a string using the join() method and reinserted in the page.
drawCanvas() function shortly, but to test the code so far, create a dummy function:// Draw the canvas
function drawCanvas() {
}
The HTML5 <canvas> element creates a blank area within a web page that allows you to draw not only static shapes, text, and other graphic elements, but you can also animate them. In that sense, it's like Flash or Silverlight, except that it works natively in the browser without a plugin. However, using <canvas> is very different. For one thing, there are no dedicated programs—at least not yet—to help build the graphic content. Other important differences are that a <canvas> element doesn't have a timeline, nor can it retain a library of reusable symbols. Each time you update the graphic content, the entire <canvas> needs to be redrawn.
The advantage of using <canvas> is that it's very fast and lightweight. Although you can import images into a <canvas> element, all the shapes in the Hangman game are generated dynamically by JavaScript using the Canvas 2D Context API.
To draw on a <canvas> element, you need to create a context object using the getContext() method, which takes as its sole argument a string indicating the type of context you want. At the moment, the only type is 2d, but it's expected there'll eventually be 3d as well. This is how you obtain a context object for a <canvas> element with the ID stage :
var canvas = document.getElementById('stage'),
c = canvas.getContext('2d');
Once you have a context object, you can draw on the <canvas> element using the object's properties and methods. Table 1 lists the properties used in the Hangman game.
Table 1. Basic Canvas 2D Context properties
Property |
Description |
fillStyle |
The current color, pattern, or gradient used for filling paths. Colors can be specified using any CSS color format. The default is black. |
font |
Specifies how the text should look using the same syntax as the CSS shorthand |
lineWidth |
Specifies the width (in pixels) for lines. The default value is 1. Lines are centered over the path. |
strokeStyle |
Specifies the color, pattern, or gradient used for drawing lines (stroking paths). The default is black. |
textAlign |
Specifies the horizontal alignment of text. Valid values are |
Lines, shapes, and other objects are drawn on the <canvas> element using a grid system with its origin (0, 0) at the top-left corner. The X axis increases horizontally to the right, and the Y axis increases vertically downwards. By default, the units along each axis are equivalent to pixels, but this changes if the context is scaled. Table 2 lists the methods used in the Hangman game.
Table 2. Basic Canvas 2D Context methods
Method |
Description |
arc(x, y, radius, startAngle, endAngle, counterclockwise) |
Creates an arc centered at |
beginPath() |
Discards any current path and begins a new one. |
fillText(string, x, y, maxWidth) |
Displays the string specified as the first argument using the current |
lineTo(x, y) |
Draws a path in a straight line to the specified coordinates. |
moveTo(x, y) |
Moves the current position to the specified coordinates without drawing a path. |
scale(scaleX, scaleY) |
Scales the context object horizontally and vertically according to the specified scaling factors. |
stroke() |
Draws the outline of the current path using the current |
Drawing lines and shapes follows a linear process. You set the colors for stroke and fill, line width, and font using the properties in Table 1. Then you use the methods in Table 2 to move to where you want to start drawing, draw a path to outline the shape, and call the stroke() method to render it. You repeat the process to add further lines, shapes, or text. All elements inherit the same properties unless you change their values before adding a new path, shape, or text. One way to think of it is like an artist changing brushes or paint before moving onto the next stroke, except that it's done through a series of written commands rather than visually.
To draw a line on a <canvas> you need to do the following:
The gallows and victim in the Hangman game use a lot of straight lines, so it makes sense to create a custom function to perform these four steps instead of repeatedly typing out similar code.
Add the following function definition to hangman.js:
function drawLine(context, from, to) {
context.beginPath();
context.moveTo(from[0], from[1]);
context.lineTo(to[0], to[1]);
context.stroke();
}
The drawLine() function takes three arguments: the context object, and two arrays containing the coordinates of the start and end points. For example, to draw a straight line from the top-left corner to a point 100 pixels to the right and 50 pixels down, you call the function like this:
drawLine(c, [0,0], [100,50]);
This has the same effect as the following four lines of code:
c.beginPath();
c.moveTo(0, 0);
c.lineTo(100, 50);
c.stroke();
When drawing on a <canvas> element, you can't add to an existing design. You need to draw everything afresh each time you want to update it. To avoid a lot of repetitive code, the drawCanvas() function uses conditional statements to control how much to draw of the gallows and victim (see Figure 2) depending on the number of bad guesses.
When the canvas is first drawn, only the ground (the green line) is visible. If badGuesses is more than 0, the upright of the gallows is added. If badGuesses is more than 1, drawCanvas() continues to the cross spar of the gallows. If it's more than 2, it adds the rope and head, and so on.
Because everything needs to be drawn afresh each time, you need to clear the <canvas> element before drawing. Resetting the dimensions of a <canvas> element, even to the same size, automatically deletes its contents. The canvas global variable in hangman.js contains a reference to the <canvas> element, so clearing it is as simple as this:
canvas.width = canvas.width;
The drawCanvas() function is quite long, but the preceding explanation and inline comments should help you understand how it works. Here's what it looks like:
// Draw the canvas
function drawCanvas() {
var c = canvas.getContext('2d');
// reset the canvas and set basic styles
canvas.width = canvas.width;
c.lineWidth = 10;
c.strokeStyle = 'green';
c.font = 'bold 24px Optimer, Arial, Helvetica, sans-serif';
c.fillStyle = 'red';
// draw the ground
drawLine(c, [20,190], [180,190]);
// start building the gallows if there's been a bad guess
if (badGuesses > 0) {
// create the upright
c.strokeStyle = '#A52A2A';
drawLine(c, [30,185], [30,10]);
if (badGuesses > 1) {
// create the arm of the gallows
c.lineTo(150,10);
c.stroke();
}
if (badGuesses > 2) {
c.strokeStyle = 'black';
c.lineWidth = 3;
// draw rope
drawLine(c, [145,15], [145,30]);
// draw head
c.beginPath();
c.moveTo(160, 45);
c.arc(145, 45, 15, 0, (Math.PI/180)*360);
c.stroke();
}
if (badGuesses > 3) {
// draw body
drawLine(c, [145,60], [145,130]);
}
if (badGuesses > 4) {
// draw left arm
drawLine(c, [145,80], [110,90]);
}
if (badGuesses > 5) {
// draw right arm
drawLine(c, [145,80], [180,90]);
}
if (badGuesses > 6) {
// draw left leg
drawLine(c, [145,130], [130,170]);
}
if (badGuesses > 7) {
// draw right leg and end game
drawLine(c, [145,130], [160,170]);
c.fillText('Game over', 45, 110);
// remove the alphabet pad
letters.innerHTML = '';
// display the correct answer
// need to use setTimeout to prevent race condition
setTimeout(showResult, 200);
// increase score of lost games
// display the score after two seconds
// code to be added later
}
}
// if the word has been guessed correctly, display message,
// update score of games won, and then show score after 2 seconds
if (correctGuesses == wordLength) {
letters.innerHTML = '';
c.fillText('You won!', 45,110);
// increase score of won games
// display score
// code to be added later
}
}
Note the following points:
arc() method, which requires the start and end angles to be specified in radians. To convert degrees to radians, you divide π (pi) by 180 and multiply the result by the number of degrees. Setting the start to 0 and the end to 360 degrees is how you draw a complete circle. When drawing a partial arc, a start angle of 0 starts drawing from the 3 o'clock position.showResult() function—which you'll define next—displays the correct result. However, in testing, I found it necessary to delay its execution for 0.2 seconds using setTimeout() . Otherwise, the result fails to appear.Playing Hangman can be frustrating, so you need to take losers out of their misery by revealing the word they failed to guess. The showResult() function does just that. It's simply an adaptation of the loop in the checkLetter() function, so it needs little explanation. Here's what it looks like:
// When the game is over, display missing letters in red
function showResult() {
var placeholders = word.innerHTML;
placeholders = placeholders.split('');
for (i = 0; i < wordLength; i++) {
if (placeholders[i] == '_') {
placeholders[i] = '<span style="color:red">' + wordToGuess.charAt(i).toUpperCase() + '</span>';
}
}
word.innerHTML = placeholders.join('');
}
Each time the loop runs, it checks whether the current placeholder is an underscore. If it is, it replaces it with an uppercase version of the correct letter wrapped in <span> tags with an inline style that sets the color to red.
The Web Storage API offers a simple, yet effective way to store data related to a web page or app on the user's local computer. There are two types of storage:
The browser automatically allocates separate local and session storage areas for each domain, and stores data as key/value pairs. Data stored by one page is available to any other page from the same domain, although local and session values remain separate. You store and retrieve values using the localStorage and sessionStorage objects and the methods listed in Table 3.
Table 3. Web storage methods.
Method |
Description |
setItem(key, value) |
Stores a key/value pair. If the key already exists, the current value is replaced by the new one. Both key and value must be strings. |
getItem(key) |
Retrieves the value stored by the specified key. |
removeItem(key) |
Deletes the key and its value. |
clear() |
Clears all keys and their related values. Use with care because other pages might still need access to the key/value pairs. |
key(num) |
Retrieves the name of the key at the specified numeric position. |
The key() method is of limited value unless you know the order in which the key/value pairs have been listed. However, the localStorage and sessionStorage objects have a length property, so you can retrieve the name of the most recently stored key like this:
var latest = localStorage.key(localStorage.length - 1);
All browsers in widespread use, with the exception of IE 6 & 7, support web storage.
Thanks to Modernizr, you can test whether the browser supports local storage, and load a couple of JavaScript polyfills if it doesn't.
<script> block in the <head> of index.html like this:<script>
Modernizr.load({
test: Modernizr.localstorage,
nope: ['scripts/json2.js', 'scripts/storage_polyfill.js'],
both: ['scripts/jquery-1.7.min.js', 'scripts/hangman.js'],
complete: function() {
init();
}
});
</script>
This adds two properties to the object literal passed to Modernizr.load() . The first one ( test ) checks whether the browser supports local storage. If it doesn't, the second property ( nope ) loads the two polyfills. Note that json2.js is loaded first because storage_polyfill.js is dependent on it.
The name of the load property in the original script is also changed to both .
The object passed as an argument Modernizr.load() can have the following properties, all of which are optional:
test : The browser feature you want to test (see Tables 1 and 2 in Using Modernizr to test HTML5 and CSS3 browser support).yep : Files to be loaded conditionally if the browser passes the test.nope : Files to be loaded conditionally if the browser fails the test.both : Files to be loaded by all browsers.complete : Code to be executed after all files are loaded.The score needs to be set and displayed on three occasions: when the game first loads, when a game is lost, and when a game is won. This means adding code to the init() and drawCanvas() functions. A separate function displays the score. Because local storage is shared by all pages from the same domain, it's important to choose unique names for the keys to avoid overwriting each other's values. So, instead of just win and lose , I'll use hangmanWin and hangmanLose as the local storage keys.
init() function needs to set the score is the first time the game is loaded. It needs to check whether hangmanWin and hangmanLose exist. If they don't, they need to be set to 0. Otherwise, the function that displays the score uses the existing values. Add the following code just before the closing curly brace of init() :// Initialize the scores and store locally if not already stored
if (localStorage.getItem('hangmanWin') == null) {
localStorage.setItem('hangmanWin', '0');
}
if (localStorage.getItem('hangmanLose') == null) {
localStorage.setItem('hangmanLose', '0');
}
showScore();
You'll add the code for showScore() shortly.
hangmanLose and increment it by one, then display the score after a pause of two seconds. In drawCanvas() , insert the following code in the conditional statement that runs when badGuesses is greater than seven (it goes in place of the "code to be added later" comment):// increase score of lost games
// display the score after two seconds
localStorage.setItem('hangmanLose', 1 + parseInt(localStorage.getItem('hangmanLose')));
setTimeout(showScore, 2000);
The important point to note is that values are stored as strings. That's why localStorage.getItem('hangmanLose') is passed to parseInt() before it's added to 1. If you fail to convert hangmanLose to a number, the values will be concatenated as strings, resulting in 10, 110, 1110, and so on.
correctGuesses is the same as wordLength :// increase score of won games
// display score
localStorage.setItem('hangmanWin', 1 + parseInt(localStorage.getItem('hangmanWin')));
setTimeout(showScore, 2000);
// Display the score in the canvas
function showScore() {
var won = localStorage.getItem('hangmanWin'),
lost = localStorage.getItem('hangmanLose'),
c = canvas.getContext('2d');
// clear the canvas
canvas.width = canvas.width;
c.font = 'bold 24px Optimer, Arial, Helvetica, sans-serif';
c.fillStyle = 'red';
c.textAlign = 'center';
c.fillText('YOUR SCORE', 100, 50);
c.font = 'bold 18px Optimer, Arial, Helvetica, sans-serif';
c.fillText('Won: ' + won + ' Lost: ' + lost, 100, 80);
}
This is fairly straightforward. The getItem() method retrieves the numbers of wins and losses, which are stored as local variables. A local variable is also created for the <canvas> element's context. The score is then displayed using Canvas 2D Context properties and methods. Note that to change the size of the font for the second line, you need to declare the font weight and font families again. There is no property that sets the font size on its own.
resetScore() function that you created in Part 1 of this tutorial. Amend the function like this:// Reset stored scores to zero
function resetScore() {
localStorage.setItem('hangmanWin', '0');
localStorage.setItem('hangmanLose', '0');
showScore();
}
This seems OK, but if you're lucky enough to guess the first letter correctly, the score still remains onscreen, as shown in Figure 6.
drawCanvas() , I think it looks better if you remove the score as soon as a new game begins. Amend the newGame() function by adding the following line immediately before the closing curly brace:drawCanvas();
The Hangman game is now complete, but you can make it available offline by utilizing the application cache in all modern browsers (but not IE 6–8). Unlike the normal browser cache, which stores files temporarily, the application cache stores files indefinitely until they're deleted by the user or updated.
To instruct the browser to download the web app's files, you need to list all the files in a special file called a manifest and attach the manifest to the HTML page.
CACHE MANIFEST on a line of its own. Then you list the path and filename of each file you want the browser to store in the application cache. Each file goes on a separate line. It's also recommended to add a line indicating the version number of the manifest. Because IE 6–8 don't support application caches, don't list the JavaScript polyfills for local storage. Otherwise, they will be downloaded by browsers that don't need them. The contents of hangman.appcache should look like this:CACHE MANIFEST
# version 1
index.html
images/icons.png
scripts/hangman.js
scripts/jquery-1.7.min.js
scripts/modernizr.hangman.js
styles/hangman.css
<html> tag like this:<html class="no-js" manifest="hangman.appcache">
text/cache‑manifest MIME type. Because application caches are a new feature in HTML5, many web servers are unlikely to support this MIME type. If your site is running on an Apache web server and you have permission to configure it using an .htaccess file, add the following line to your existing .htaccess file or create a new file in your site root:AddType text/cache-manifest .appcache
If your website runs on a different server, or you can't use .htaccess files, contact your hosting company or server administrator and ask for .appcache files to be served with the text/cache-manifest MIME type.
Note: An earlier draft of the HTML5 specification used .manifest as the filename extension for manifest files, but this was later changed to .appcache . Both are acceptable, but .appcache is now the preferred version.
Browsers look only at the manifest when deciding whether a file needs to be stored in the application cache. They ignore the date individual files were created or modified. If you update any of the files listed in a manifest, you need to change the manifest's version number and upload the new manifest with the other files.
Dreamweaver doesn't currently recognize the .appcache or .manifest filename extensions. To edit a manifest, right-click the file in the Files panel and select Open With > Dreamweaver.
To make the Hangman game playable in older versions of Internet Explorer, you need a polyfill called explorercanvas, which emulates most aspects of HTML5 canvas. However, it doesn't support canvas text. So, you also need a polyfill called canvas-text and a font that has been encoded as JavaScript. All the necessary files are free and easy to use.
<!--[if lte IE 8]>
<script src="scripts/excanvas.js"></script>
<![endif]-->
<script src="scripts/modernizr.hangman.js"></script>
Modernizr.load() script like this:<script>
Modernizr.load([{
test: Modernizr.canvastext,
nope: ['scripts/canvas.text.js', 'scripts/optimer-bold-normal.js']
},
{
test: Modernizr.localstorage,
nope: ['scripts/json2.js', 'scripts/storage_polyfill.js'],
both: ['scripts/jquery-1.7.min.js', 'scripts/hangman.js'],
complete: function() {
init();
}
}]);
</script>
Instead of passing a single object as the argument to the load() method, this passes an array of two objects. The second object is the same as before. The first one tests for canvas text, and loads the polyfills if the browser doesn't support it.
The iPhone and iPod touch don't have as tall a screen as most Android phones, and the Mobile Safari chrome reduces the available screen even further. To compensate for this, you need to make some adjustments to the size and position of the alphabet keypad. You also need to take into account that iOS always regards width as referring to the horizontal size of the screen in portrait orientation. The iPhone and iPod touch always report height as 480px and width as 320px, regardless of orientation. As a result, the layout doesn't adjust properly when you turn the iPhone or iPod touch into landscape orientation.
To fix these problems, add the following media queries at the bottom of hangman.css:
@media only screen and (max-height: 480px) {
#letters {
margin-left: 6px;
}
#letters div {
font-size: 125%;
margin: 0 2px 2px 2px;
}
}
@media only screen and (max-height: 480px) and (orientation: landscape) {
#stage {
margin-top: 60px;
}
}
@media only screen and (min-width: 320px) and (orientation: landscape) {
.js #helptext {
width: 400px;
}
}
Because of the screen size on the iPhone and iPod touch, you need to manually scroll the bottom rows of the alphabetic keypad into view when you click the New Game button. Another minor tweak avoids this problem. Add the following line of code just before the closing curly brace in the newGame() function:
window.scrollBy(0, 200);
This scrolls the page up by 200 pixels on small screens. There’s no movement on larger screens because the bottom of the page is already in view.
You should now have a fully functioning Hangman game that works both online and offline in a wide range of devices. If you run into any problems, check your code against the version in hangman_pt2_finish.zip.
This simple web app has brought together several HTML5-related technologies: canvas, canvas text, local storage, and application cache. It makes extensive use of CSS3 media queries to serve different styles depending on the width and orientation of the screen. You've also seen how Modernizr and polyfills can fill in the cracks in older browsers that don't support all the latest features.
My book, Adobe Dreamweaver CS5.5 Studio Techniques: Designing and Developing for Mobile with jQuery, HTML5, and CSS, covers local storage and application cache in greater depth. You might also find the following resources helpful: