Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

Html5 Jigsaw Puzzle

4.94/5 (82 votes)
5 Jun 2012CPOL9 min read 300.8K   10.7K  
An online jigsaw puzzle using the stunning features of the Paper.js framework
Download Html5 Jigsaw Puzzle 

screenshot

Table of Contents

Introduction

Javascript programming has become more and more interesting since modern browsers started implementing HTML5 and CSS3 specifications. Some people have been hoping for the trio javascript/html5/css3 to free web development from plug-ins such as Flash and Silverlight. So far we've seen noticing a myriad of Html5 development tools and frameworks popping up here and there all the time, trying to catch up with the maturity and user experience of established Flash and Silverlight frameworks and tools.

This time I became particularly well impressed by the Paper.js framework. It's easy to use, very clean and intuitive to work with, and provides powerful and flexible set of classes and events. This, in addition to the flexibility of javascript language, enables a fast and productive development environment.

After some time playing with Paper.js, I decided to create a jigsaw puzzle game with that tool, and only then the real learning began. The results of hours of trial-and-error are in this article, and I hope to explain Paper.js by explaining the game development itself. And if you're not interested in the article nor in the javascript, at least you might end up playing with the puzzle game.

System Requirements

To use HTML5 Snooker Club application provided with this article, all you have to do is install or have a modern web browser: Chrome, Fire Fox, Maxthon, Opera, Safari or Internet Explorer (9 or superior).

The Paper.js Framework

Paper.js is, by their own words:

"... an open source vector graphics
scripting framework that runs on top of the HTML5
Canvas. It offers a clean Scene Graph / Document
Object Model and a lot of powerful functionality to
create and work with vector graphics and bezier
curves, all neatly wrapped up in a well designed,
consistent and clean programming interface."

So what's really behind that beautiful description? Let's say you want to user Paper.js to perform a simple task of displaying a simple image in the center of your browser. The next short segment shows the entire html needed for the task:

Image 2

JavaScript
<!DOCTYPE html>
<html>
<head>
    <script src="content/js/paper.js" type="text/javascript"></script>
    <script type="text/paperscript" canvas="canvas">
        var bob = new Raster('Bob');
        bob.position = view.center;
    </script>
</head>
<body>
    <canvas id="canvas" class="canvas" resize></canvas>
    <img id="Bob" src="content/images/Bob.png" style="display: none;">
</body>
</html>

Quite simple, isn't it? Now let's get back to the Paper.js description:

  • "... an open source vector graphics scripting framework that runs on top of the HTML5 Canvas..."

Paper.js only targets the canvas, so the canvas element is a requirement:

JavaScript
<canvas id="canvas" class="canvas" resize></canvas>

Notice the special element attribute, <class>resize. It tells the Paper.js to resize the canvas to fit the size of the parent html element (in our case, the body element).

  • "... It offers a clean Scene Graph / Document Object Model and a lot of powerful functionality to create and work with vector graphics and bezier curves, all neatly wrapped up in a well designed, consistent and clean programming interface..."

So true. You can see this cleanness in these 2 lines of javascript code:

JavaScript
<script type="text/paperscript" canvas="canvas">
    var bob = new Raster('Bob');
    bob.position = view.center;
</script>

The first line instantiates a <class>Raster, which in Paper.js framework is an item that represents an image in a project. So there you have a paramenter, "Bob", which is simply the id of the <img> tag from which the image source for the Raster object is taken.

So, since we're already using the <img> element, why not use it right away instead of the <class>Raster? The answer is, as explained before, the Paper.js renders graphics only in the context of a <class>Canvas element, so the <img> is outside the scope of it.

Another fact worth noticing is that strange script tag:

JavaScript
<script type="text/paperscript" canvas="canvas">

What is that "text/paperscript"? What does it do? Well, it is the good old javascript we are used to, but the difference is that, behind the scenes, it takes care of all that boring, plumbing job we would otherwise have to do by ourselves (such as creation of the basic objects of the framework, such as Project, View and Tool, and event handler attachments) and also provides support for overloading operators for vector operations (such as +, - and *) which we would have to implement by means of method calls, thus polluting the code. Also, all global browser objects (such as document and window) are still accessible within the <class>paperscript code.

If your project gets bigger, it is preferrable that you use javascript directly, instead of paperscript. In our sample jigsaw puzzle application, though, I used only paperscript. One clear disadvantage of paperscript is that when you set the script tag to type="text/paperscript" you automatically loose javascript intellisense. One workaround for this is to change the script type to "text/paperscript", do the code modifications you need and then change it back to "text/paperscript" once again.

Obviously, there is much more to Paper.js than just the <class>Raster objets. We will learn about other classes in the following sections.

The Jigsaw Puzzle

A jigsaw puzzle is a puzzle where the goal is to position correctly a set of interlocking, oddly shaped pieces. Each piece has part of the original picture, and when you finish the puzzle you can see the whole picture.

Image 3

A simple game like that may look simple at first, but can easily takes you many hours of work if you don't have a clear plan and you don't know how to begin with. Fortunately, I had a previous experience with creating this kind of puzzle, so this time I was able to avoid many of the pitfalls I fell into when developing the app for my first puzzle article.

Below there is a series of solutions for the most common problems found in a jigsaw puzzle application:

Creating the Image Tiles

When you first notice that you have to create all these oddly shaped tiles, like in the image below, you may find the task intimidating:

Image 4

In fact you should for now forget those curvy lines, and focus on the most basic tasks first, like dividing the source image into perfect little squares. Each piece in the puzzle will have a distinct part of the image, so that they all positioned together can make the whole image. So let's divide this image in squares:

Image 5

Now let's take a particular square tile and use it as a background for a puzzle piece. Here is the piece separated from the whole:

Image 6

And here is the piece with its image background:

Image 7

Now, do you see the problem? The image doesn't fit into the piece! There are areas near the borders of the piece where the background is empty. This happens because we haven't predicted that in a jigsaw puzzle the interlocking pieces "invade" spaces which should otherwise be preserved in a square puzzle.

Image 8

By the picture above, it becomes clear that each piece should have a slightly larger background image, so that the image portion could fit in. We do this by expanding the tile image for each piece:

Image 9

Clipping the Puzzle

The clipping part is a vital one in our application. We must take each piece and cut out the corresponding shape, so that each piece appears nice and curvy.

We draw the shapes by creating a set of bezier curves. Bezier curves are sexy and fit well in our application. Fortunately, I used these curves in my previous WPF article, and since the basic premises are the same, I reused the same logic for constructing the curves.

JavaScript
function getMask(tileRatio, topTab, rightTab, bottomTab, leftTab, tileWidth) {

    var curvyCoords = [
            0, 0, 35, 15, 37, 5,
            37, 5, 40, 0, 38, -5,
            38, -5, 20, -20, 50, -20,
            50, -20, 80, -20, 62, -5,
            62, -5, 60, 0, 63, 5,
            63, 5, 65, 15, 100, 0
    ];

    var mask = new Path();
    var tileCenter = view.center;

    var topLeftEdge = new Point(-4,4);

    mask.moveTo(topLeftEdge);

    //Top
    for (var i = 0; i < curvyCoords.length / 6; i++) {
        var p1 = topLeftEdge + new Point(curvyCoords[i * 6 + 0] * tileRatio,
        topTab * curvyCoords[i * 6 + 1] * tileRatio);
        var p2 = topLeftEdge + new Point(curvyCoords[i * 6 + 2] * tileRatio,
        topTab * curvyCoords[i * 6 + 3] * tileRatio);
        var p3 = topLeftEdge + new Point(curvyCoords[i * 6 + 4] * tileRatio,
        topTab * curvyCoords[i * 6 + 5] * tileRatio);

        mask.cubicCurveTo(p1, p2, p3);
    }
    //Right
    var topRightEdge = topLeftEdge + new Point(tileWidth, 0);
    for (var i = 0; i < curvyCoords.length / 6; i++) {
        var p1 = topRightEdge + new Point(-rightTab * curvyCoords[i * 6 + 1] * tileRatio,
        curvyCoords[i * 6 + 0] * tileRatio);
        var p2 = topRightEdge + new Point(-rightTab * curvyCoords[i * 6 + 3] * tileRatio,
        curvyCoords[i * 6 + 2] * tileRatio);
        var p3 = topRightEdge + new Point(-rightTab * curvyCoords[i * 6 + 5] * tileRatio,
        curvyCoords[i * 6 + 4] * tileRatio);

        mask.cubicCurveTo(p1, p2, p3);
    }
    //Bottom
    var bottomRightEdge = topRightEdge + new Point(0, tileWidth);
    for (var i = 0; i < curvyCoords.length / 6; i++) {
        var p1 = bottomRightEdge - new Point(curvyCoords[i * 6 + 0] * tileRatio,
        bottomTab * curvyCoords[i * 6 + 1] * tileRatio);
        var p2 = bottomRightEdge - new Point(curvyCoords[i * 6 + 2] * tileRatio,
        bottomTab * curvyCoords[i * 6 + 3] * tileRatio);
        var p3 = bottomRightEdge - new Point(curvyCoords[i * 6 + 4] * tileRatio,
        bottomTab * curvyCoords[i * 6 + 5] * tileRatio);

        mask.cubicCurveTo(p1, p2, p3);
    }
    //Left
    var bottomLeftEdge = bottomRightEdge - new Point(tileWidth, 0);
    for (var i = 0; i < curvyCoords.length / 6; i++) {
        var p1 = bottomLeftEdge - new Point(-leftTab * curvyCoords[i * 6 + 1] * tileRatio,
        curvyCoords[i * 6 + 0] * tileRatio);
        var p2 = bottomLeftEdge - new Point(-leftTab * curvyCoords[i * 6 + 3] * tileRatio,
        curvyCoords[i * 6 + 2] * tileRatio);
        var p3 = bottomLeftEdge - new Point(-leftTab * curvyCoords[i * 6 + 5] * tileRatio,
        curvyCoords[i * 6 + 4] * tileRatio);

        mask.cubicCurveTo(p1, p2, p3);
    }

    return mask;
}

Each piece has 4 sides (top, bottom, left, right) and each side can have one of 3 forms: flat, a mountain or a valley. All these forms are constructed basically by the same function. The only difference is the factor applied to the calculation: 0 for flat sides, 1 for mountain and -1 for valley.

The code below shows that each piece is a <class>Group object (this is from Paper.js framework). A Group object is a composition of two or more items. The first item (mask) define the shape of the clipping, while the img item define the image background. In short, the tile image is cut out according to the shape of the mask of each piece.

JavaScript
var cloneImg = instance.puzzleImage.clone();
var img = getTileRaster(
    cloneImg,
    new Size(instance.tileWidth, instance.tileWidth),
    new Point(instance.tileWidth * x, instance.tileWidth * y)
);

var border = mask.clone();
border.strokeColor = '#ccc';
border.strokeWidth = 5;

var tile = new Group(mask, border, img, border);
tile.clipped = true;
tile.opacity = 1;

tile.shape = shape;
tile.imagePosition = new Point(x, y);

Creating Random Shapes

Each of the 4 sides of the piece is given a random number between a range of values (-1, 0, 1), that defines the curve of that side.

Image 10

It's important to notice that inside the loop that generates the random shapes, for each piece iteration only the right and bottom sides are given values. When we define the right side value, we also have to define the opposite value for the left side of the piece at the right of that piece. The same occurs for the bottom side: if the bottom side of a piece is given a value of 1, then the top side of the piece at the bottom of that piece must be given a value of -1. In short, the neighboring sides must always have complimentary values.

Image 11

JavaScript
function getRandomShapes(width, height) {
    var shapeArray = new Array();

    for (var y = 0; y < height; y++) {
        for (var x = 0; x < width; x++) {

            var topTab = undefined;
            var rightTab = undefined;
            var bottomTab = undefined;
            var leftTab = undefined;

            if (y == 0)
                topTab = 0;

            if (y == height - 1)
                bottomTab = 0;

            if (x == 0)
                leftTab = 0;

            if (x == width - 1)
                rightTab = 0;

            shapeArray.push(
                ({
                    topTab: topTab,
                    rightTab: rightTab,
                    bottomTab: bottomTab,
                    leftTab: leftTab
                })
            );
        }
    }

    for (var y = 0; y < height; y++) {
        for (var x = 0; x < width; x++) {

            var shape = shapeArray[y * width + x];

            var shapeRight = (x < width - 1) ?
                shapeArray[y * width + (x + 1)] :
                undefined;

            var shapeBottom = (y < height - 1) ?
                shapeArray[(y + 1) * width + x] :
                undefined;

            shape.rightTab = (x < width - 1) ?
                getRandomTabValue() :
                shape.rightTab;

            if (shapeRight)
                shapeRight.leftTab = - shape.rightTab;

            shape.bottomTab = (y < height - 1) ?
                getRandomTabValue() :
                shape.bottomTab;

            if (shapeBottom)
                shapeBottom.topTab = - shape.bottomTab;
        }
    }
    return shapeArray;
}

Random Placement of Pieces

The pieces are initially positioned in a retangular central area, but with blank spots separating them. They are arranged by a function that picks a piece randomly from the array of pieces, place it into the board, and then picks another piece from the remaining pieces and places it in the square area, until no more pieces are left.

Image 12

JavaScript
for (var y = 0; y < yTileCount; y++) {
    for (var x = 0; x < xTileCount; x++) {

        var index1 = Math.floor(Math.random() * tileIndexes.length);
        var index2 = tileIndexes[index1];
        var tile = tiles[index2];
        tileIndexes.remove(index1, 1);

        var position = view.center -
                        new Point(instance.tileWidth, instance.tileWidth / 2) +
                        new Point(instance.tileWidth * (x * 2 + ((y % 2))), instance.tileWidth  * y) -
                        new Point(instance.puzzleImage.size.width, instance.puzzleImage.size.height / 2);

        var cellPosition = new Point(
            Math.round(position.x / instance.tileWidth) + 1,
            Math.round(position.y / instance.tileWidth) + 1);

        tile.position = cellPosition * instance.tileWidth;
        tile.cellPosition = cellPosition;
    }
}

Highlighting Pieces

As you pass the mouse over the pieces, there is a visual indicator of the selection: the opacity of the selected piece becomes half of the original

Image 13

JavaScript
this.mouseMove = function(point, delta) {
    if (!instance.selectionGroup) {
        project.activeLayer.selected = false;
        if (delta.x < 8 && delta.y < 8) {
            var tolerance = instance.tileWidth * .5;
            var hit = false;
            for (var index = 0; index < instance.tiles.length; index++) {
                var tile = instance.tiles[index];
                var row = parseInt(index / config.tilesPerRow);
                var col = index % config.tilesPerRow;

                var tileCenter = tile.position;

                var deltaPoint = tileCenter - point;
                hit = (deltaPoint.x * deltaPoint.x +
                            deltaPoint.y * deltaPoint.y) < tolerance * tolerance;

                if (hit) {
                    instance.selectedTile = tile;
                    instance.selectedTileIndex = index;
                    tile.opacity = .5;
                    project.activeLayer.addChild(tile);
                    return;
                }
                else {
                    tile.opacity = 1;
                }
            }
            if (!hit)
                instance.selectedTile = null;
        }
    }
    else {
        instance.dragTile(delta);
    }
}

Dragging and Dropping Pieces

Each time the left mouse button is down over a selected piece, it is "taken" from the board and is brought to front (by enlarging its size) and you can drag it all over the board, until you release the mouse button to drop the piece in a suitable location.

JavaScript
this.pickTile = function() {
    if (instance.selectedTile) {
        if (!instance.selectedTile.lastScale) {
            instance.selectedTile.lastScale = instance.zoomScaleOnDrag;
            instance.selectedTile.scale(instance.selectedTile.lastScale);
        }
        else {
            if (instance.selectedTile.lastScale > 1) {
                instance.releaseTile();
                return;
            }
        }

        instance.selectedTile.cellPosition = undefined;

        instance.selectionGroup = new Group(instance.selectedTile);

        var pos = new Point(instance.selectedTile.position.x,
        instance.selectedTile.position.y);
        instance.selectedTile.position = new Point(0, 0);

        instance.selectionGroup.position = pos;
    }
}

this.releaseTile = function() {
    if (instance.selectedTile) {

        var cellPosition = new Point(
            Math.round(instance.selectionGroup.position.x / instance.tileWidth),
            Math.round(instance.selectionGroup.position.y / instance.tileWidth));

        var roundPosition = cellPosition * instance.tileWidth;

        var hasConflict = false;

        var alreadyPlacedTile = getTileAtCellPosition(cellPosition);

        hasConflict = alreadyPlacedTile;

        var topTile = getTileAtCellPosition(cellPosition + new Point(0, -1));
        var rightTile = getTileAtCellPosition(cellPosition + new Point(1, 0));
        var bottomTile = getTileAtCellPosition(cellPosition + new Point(0, 1));
        var leftTile = getTileAtCellPosition(cellPosition + new Point(-1, 0));

        if (topTile) {
            hasConflict = hasConflict || !(topTile.shape.bottomTab +
            instance.selectedTile.shape.topTab == 0);
        }

        if (bottomTile) {
            hasConflict = hasConflict || !(bottomTile.shape.topTab +
            instance.selectedTile.shape.bottomTab == 0);
        }

        if (rightTile) {
            hasConflict = hasConflict || !(rightTile.shape.leftTab +
            instance.selectedTile.shape.rightTab == 0);
        }

        if (leftTile) {
            hasConflict = hasConflict || !(leftTile.shape.rightTab +
            instance.selectedTile.shape.leftTab == 0);
        }

        if (!hasConflict) {

            if (instance.selectedTile.lastScale) {
                instance.selectedTile.scale(1 / instance.selectedTile.lastScale);
                instance.selectedTile.lastScale = undefined;
            }

            instance.selectionGroup.remove();
            var tile = instance.tiles[instance.selectedTileIndex];
            tile.position = roundPosition;
            tile.cellPosition = cellPosition;
            instance.selectionGroup.remove();
            instance.selectedTile =
            instance.selectionGroup = null;
            project.activeLayer.addChild(tile);

            var errors = checkTiles();
            if (errors == 0) {
                alert('Congratulations!!!');
            }
        }
    }
}

Testing Piece Location

Not all locations are good for positioning a piece, though. First, we must check whether the place is empty.

And then we check if there are no neighbor pieces with conflicting sides. The values for neighbor sides must be always be complimentary.

JavaScript
var topTile = getTileAtCellPosition(cellPosition + new Point(0, -1));
var rightTile = getTileAtCellPosition(cellPosition + new Point(1, 0));
var bottomTile = getTileAtCellPosition(cellPosition + new Point(0, 1));
var leftTile = getTileAtCellPosition(cellPosition + new Point(-1, 0));

if (topTile) {
    hasConflict = hasConflict || !(topTile.shape.bottomTab +
    instance.selectedTile.shape.topTab == 0);
}

if (bottomTile) {
    hasConflict = hasConflict || !(bottomTile.shape.topTab +
    instance.selectedTile.shape.bottomTab == 0);
}

if (rightTile) {
    hasConflict = hasConflict || !(rightTile.shape.leftTab +
    instance.selectedTile.shape.rightTab == 0);
}

if (leftTile) {
    hasConflict = hasConflict || !(leftTile.shape.rightTab +
    instance.selectedTile.shape.leftTab == 0);
}

Testing for Puzzle Completion

Each time you place a piece on the board, the application tests for the puzzle completion. This is done by:

  • Getting the top left piece and determining its "cell position".
  • Iterating over the other pieces and checking if they are coherently positioned, using the top left piece position as reference.

JavaScript
function checkTiles() {
    var errors = 0;
    var firstTile = instance.tiles[0];
    var firstCellPosition = firstTile.cellPosition;

    for (var y = 0; y < instance.tilesPerColumn; y++) {
        for (var x = 0; x < instance.tilesPerRow; x++) {
            var index = y * instance.tilesPerRow + x;
            var cellPosition = instance.tiles[index].cellPosition;

            if (cellPosition != firstCellPosition + new Point(x, y)) {
                errors++;
            }
        }
    }

    return errors;
}

Once the puzzle is completed, a simple message is shown.

Image 14

May seem just a silly message, but it's enough for this article.

Final Considerations

That's it. As you can see, there is a lot of room for enhancement in the application. The Paper.js framework proved itself up to the task of handling complex graphic scripting and event handling, while providing a clean and simplified set of classes and events.

If you have any comments, complaints or suggestions, please leave a comment below. I'd like to hear from you and I'm willing to improve the app as new ideas arrive.

History

  • 2012-05-31: Initial version.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)