Image Manipulation with HTML5 Canvas: A Sliding Puzzle
HTML5 includes many features to integrate multimedia natively into web pages. Among these features is the canvas element, a blank slate that can be filled with line drawings, image files, or animations.
In this tutorial, I’m going to demonstrate HTML5 canvas’s image manipulation capabilities by creating a sliding puzzle game.
To embed a canvas in the web page, use the <canvas>
tag.
<canvas width="480px" height="480px"></canvas>
The width and height attributes set the canvas size in pixels. If these attributes are not specified, they default to 300px for width and 150px for height.
Drawing on the canvas is done via a context, which is initialized through the JavaScript function getContext()
. The two-dimensional context specified by the W3C is called, appropriately, “2d”. So, to initialize the context for a canvas with an ID of “canvas” we simply call:
document.getElementById("canvas").getContext("2d");
The next step is to display the image. JavaScript has only one function for this, drawImage()
, but there are three ways to call this function. In its most basic form, this function takes three arguments: the image object and the x and y offset from the top left corner of the canvas.
drawImage(image, x, y);
It’s also possible to add two more arguments, width and height, to resize the image.
drawImage(image, x, y, width, height);
The most complex form of drawImage()
takes nine arguments. The first is the image object. The next four are, in order, the source x, y, width, and height. The remaining four are, in order, the destination x, y, width, and height. This function extracts a portion of the image to draw on the canvas, resizing it if necessary. This allows us to treat the image as a sprite sheet.
drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
A few caveats are in order with all forms of drawImage()
. If the image is null, or the horizontal or vertical dimension is zero, or the source height or width is zero, drawImage()
will throw an exception. If the image can’t be decoded by the browser, or has not finished loading when the function is called, drawImage()
will not display anything.
That’s all there is to image manipulation with HTML5 canvas. Now let’s see it in practice.
<div id="slider"> <form> <label>Easy</label> <input type="range" id="scale" value="4" min="3" max="5" step="1"> <label>Hard</label> </form> <br> </div> <div id="main" class="main"> <canvas id="puzzle" width="480px" height="480px"></canvas> </div>
This HTML block includes another HTML5 feature, the range input, that lets the user select a number with a slider. We’ll see a little later how the range input interacts with the puzzle. Be forewarned, though: Although most browsers support range input, two of the more popular ones—Internet Explorer and Firefox—still do not at the time of this writing.
Now, as I mentioned above, to draw on the canvas we need a context.
var context = document.getElementById("puzzle").getContext("2d");
We will also need an image. You can use the one referenced below or any other square image that fits (or can be resized to fit) the canvas.
var img = new Image(); img.src = 'http://www.brucealderman.info/Images/dimetrodon.jpg'; img.addEventListener('load', drawTiles, false);
The event listener is there to guarantee the image is finished loading before the browser attempts to draw it. The canvas won’t display the image if it’s not ready to be drawn.
We’ll get the board size from the puzzle canvas, and the tile count from the range input. This slider has a range from 3 to 5, with the numeric value indicating the number of rows and columns.
var boardSize = document.getElementById('puzzle').width; var tileCount = document.getElementById('scale').value;
With these two numbers, we can calculate the tile size.
var tileSize = boardSize / tileCount;
Now we can create the board.
var boardParts = new Object; setBoard();
The setBoard()
function is where we’ll define and initialize the virtual board. The natural way to represent the board is with a two-dimensional array. In JavaScript creating such an array is not an elegant process. We first declare a flat array, then declare each of the array’s elements as an array. These elements can then be accessed as though they are a multi-dimensional array.
For the sliding puzzle game, each element will be an object with x and y coordinates that define its location within the puzzle grid. Each object will therefore have two sets of coordinates. The first will be its position within the array. This represents its location on the board, so I’ll refer to this as the board square. Each board square has an object with x and y properties that represent its location in the puzzle image. I’ll refer to this location as the puzzle tile. When the coordinates of the board square match those of its puzzle tile, the tile is in the right place for solving the puzzle.
For the purpose of this tutorial we will initialize each puzzle tile to the board square opposite its correct position in the puzzle. The tile for the upper right corner, for example, will be in the board sqaure of the lower left corner.
function setBoard() { boardParts = new Array(tileCount); for (var i = 0; i < tileCount; ++i) { boardParts[i] = new Array(tileCount); for (var j = 0; j < tileCount; ++j) { boardParts[i][j] = new Object; boardParts[i][j].x = (tileCount - 1) - i; boardParts[i][j].y = (tileCount - 1) - j; } } emptyLoc.x = boardParts[tileCount - 1][tileCount - 1].x; emptyLoc.y = boardParts[tileCount - 1][tileCount - 1].y; solved = false; }
Those last three statements in setBoard()
introduce variables we haven’t yet defined.
We’ll need to track the location of the empty tile, and to record where the user clicks.
var clickLoc = new Object; clickLoc.x = 0; clickLoc.y = 0; var emptyLoc = new Object; emptyLoc.x = 0; emptyLoc.y = 0;
The final variable is a boolean indicating whether the puzzle has been solved.
var solved = false;
We’ll set this to true once all the puzzle tiles match their respective board squares.
Now we just need the functions related to solving the puzzle.
First we’ll set the functions triggered by user input events. If the range input is changed, we need to recalculate the number and size of tiles before redrawing the board.
document.getElementById('scale').onchange = function() { tileCount = this.value; tileSize = boardSize / tileCount; setBoard(); drawTiles(); };
We need to track mouse movement to know which tiles the user clicks.
document.getElementById('puzzle').onmousemove = function(e) { clickLoc.x = Math.floor((e.pageX - this.offsetLeft) / tileSize); clickLoc.y = Math.floor((e.pageY - this.offsetTop) / tileSize); }; document.getElementById('puzzle').onclick = function() { if (distance(clickLoc.x, clickLoc.y, emptyLoc.x, emptyLoc.y) == 1) { slideTile(emptyLoc, clickLoc); drawTiles(); } if (solved) { alert("You solved it!"); } };
In some browsers, the solved alert may be triggered before the board finishes redrawing. To prevent this, give the alert a short delay.
if (solved) { setTimeout(function() {alert("You solved it!");}, 500); }
When a tile is clicked, we need to know whether it is next to the open square. This is true if and only if the total distance from the clicked tile to the open square is 1, in other words, if the difference of the x-coordinates of the clicked tile and the empty tile plus the difference of the y-coordinates of the clicked tile and the empty tile is 1. It’s easier to implement than to describe.
function distance(x1, y1, x2, y2) { return Math.abs(x1 - x2) + Math.abs(y1 - y2); }
The distance()
function calculates this distance by taking the absolute value of difference between the x-coordinates and the absolute value of the difference between the y-coordinates, and adding them. If this value is 1, the clicked tile can be moved into the open square. If this value is anything other than 1, the tile should not be moved.
To move the tile, we simply copy the tile coordinates for that board square into the empty square. Then copy the tile coordinates for the removed tile into the clicked tile.
function slideTile(toLoc, fromLoc) { if (!solved) { boardParts[toLoc.x][toLoc.y].x = boardParts[fromLoc.x][fromLoc.y].x; boardParts[toLoc.x][toLoc.y].y = boardParts[fromLoc.x][fromLoc.y].y; boardParts[fromLoc.x][fromLoc.y].x = tileCount - 1; boardParts[fromLoc.x][fromLoc.y].y = tileCount - 1; toLoc.x = fromLoc.x; toLoc.y = fromLoc.y; checkSolved(); } }
Once the tile is moved, we need to check whether the puzzle is solved. We’ll scan the tiles to see if they are all in the correct board squares.
function checkSolved() { var flag = true; for (var i = 0; i < tileCount; ++i) { for (var j = 0; j < tileCount; ++j) { if (boardParts[i][j].x != i || boardParts[i][j].y != j) { flag = false; } } } solved = flag; }
If any tiles are out of place, the function returns false. Otherwise it defaults to true.
Finally, redraw the board with the clicked tile in its new position.
function drawTiles() { context.clearRect ( 0 , 0 , boardSize , boardSize ); for (var i = 0; i < tileCount; ++i) { for (var j = 0; j < tileCount; ++j) { var x = boardParts[i][j].x; var y = boardParts[i][j].y; if(i != emptyLoc.x || j != emptyLoc.y || solved == true) { context.drawImage(img, x * tileSize, y * tileSize, tileSize, tileSize, i * tileSize, j * tileSize, tileSize, tileSize); } } } }
When drawing the puzzle tiles, this function prevents filling the board square that matches the coordinates of emptyLoc until the solved flag has been set. Incidentally, because the board re-initializes whenever the range slider is moved, the user can try another difficulty level after solving the puzzle without refreshing the page.
That’s all there is to it! The canvas element, along with a little JavaScript and a little math, brings powerful native image manipulation to HTML5.
You’ll find a live demo of the sliding puzzle at http://html5.brucealderman.info/sliding.html.