Case Study... Jigsaw Game

The aim of this game is to construct the correct word from the letter pieces.

This game shows:

 

Play game

Download .zip file

jigsaw_game.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland.             */
/* There should always be a javaScript file with the same name as the html file. */
/* This file always holds the playGame function().                               */
/* It also holds game specific code, which will be different for each game       */





/******************** Declare game specific global data and functions *****************/
/* images must be declared as global, so that they will load before the game starts  */
let background = new Image();
background.src = "images/sunshine.png";

let jigsawPieceImage = new Image();
jigsawPieceImage.src = "images/jigsaw_piece.png";

const BACKGROUND = 0;
const ANCHOR_PIECE = 1;
const BUTTON = 2;
const MESSAGE = 3;

const JIGSAW_Y = 200;  // the y-position of the jigsaw on the canvas
const JIGSAW_PIECE_SIZE = 100; // width and height of each jigsaw piece

/******************* END OF Declare game specific data and functions *****************/







/* Always have a playGame() function                                     */
/* However, the content of this function will be different for each game */
function playGame()
{
    /* We need to initialise the game objects outside of the Game class */
    /* This function does this initialisation.                          */
    /* This function will:                                              */
    /* 1. create the various game game gameObjects                   */
    /* 2. store the game gameObjects in an array                     */
    /* 3. create a new Game to display the game gameObjects          */
    /* 4. start the Game                                                */


    /* Create the various gameObjects for this game. */
    /* This is game specific code. It will be different for each game, as each game will have it own gameObjects */
    gameObjects[BACKGROUND] = new StaticImage(background, 0, 0, canvas.width, canvas.height);
    gameObjects[ANCHOR_PIECE] = new StaticImage(jigsawPieceImage, 0, JIGSAW_Y, JIGSAW_PIECE_SIZE, JIGSAW_PIECE_SIZE);
    gameObjects[BUTTON] = new Button(BUTTON_CENTRE, 400, TEXT_WIDTH, TEXT_HEIGHT, "Continue");
    gameObjects[MESSAGE] = new StaticText("Well Done!", STATIC_TEXT_CENTRE, 450, "Times Roman", 100, "black");
    
    let selectedPiece = 0; // default to any piece initially being selected
    
    /* END OF game specific code. */

    /* Always create a game that uses the gameObject array */
    let game = new JigsawCanvasGame(["one", "two"], jigsawPieceImage, JIGSAW_PIECE_SIZE, JIGSAW_Y);

    /* Always play the game */
    game.start();

    /* Hide the "Continue" button and win message until they are needed */
    gameObjects[BUTTON].stopAndHide();
    gameObjects[MESSAGE].stopAndHide();

    /* If they are needed, then include any game-specific mouse and keyboard listners */
    document.getElementById("gameCanvas").addEventListener("mousedown", function (e)
    {
        if (e.which === 1)  // left mouse button
        {
            let canvasBoundingRectangle = document.getElementById("gameCanvas").getBoundingClientRect();
            let mouseX = e.clientX - canvasBoundingRectangle.left;
            let mouseY = e.clientY - canvasBoundingRectangle.top;

            if (gameObjects[BUTTON].pointIsInsideBoundingRectangle(mouseX, mouseY))
            {
                gameObjects[BUTTON].stopAndHide();
                game.createNewWordJigsaw();
            }
            for (let i = pieces.length - 1; i >= 0; i--)
            {
                if (pieces[i].pointIsInsideBoundingRectangle(mouseX, mouseY))
                {
                    pieces[i].setOffsetX(mouseX);
                    pieces[i].setOffsetY(mouseY);
                    selectedPiece = i;
                    break;
                }
            }
        }
    });

    document.getElementById("gameCanvas").addEventListener("mousemove", function (e)
    {
        if (e.which === 1)  // left mouse button
        {
            let canvasBoundingRectangle = document.getElementById("gameCanvas").getBoundingClientRect();
            let mouseX = e.clientX - canvasBoundingRectangle.left;
            let mouseY = e.clientY - canvasBoundingRectangle.top;

            for (let i = 0; i < pieces.length; i++)
            {
                if (selectedPiece === i)
                {
                    if (pieces[i].pointIsInsideBoundingRectangle(mouseX, mouseY))
                    {
                        pieces[i].setX(mouseX);
                        pieces[i].setY(mouseY);
                        break;
                    }
                }
            }
        }
        else if (e.which === 0) // no button selected
        {
            let canvasBoundingRectangle = document.getElementById("gameCanvas").getBoundingClientRect();
            let mouseX = e.clientX - canvasBoundingRectangle.left;
            let mouseY = e.clientY - canvasBoundingRectangle.top;
            gameObjects[BUTTON].pointIsInsideBoundingRectangle(mouseX, mouseY);
        }
    });
}

There are six different jigsaw colours. Each colour has its own image file.

let jigsawPiece = new Image();
redPiece.src = "images/jigsaw_piece.png";

The ANCHOR_PIECE is the jigsaw piece that that is placed at the start position of where the jigsaw word is placed.

const BACKGROUND = 0;
const ANCHOR_PIECE = 1;
const BUTTON = 2;
const MESSAGE = 3;

JIGSAW_Y is the vertical position where the jigsaw word will be placed.
JIGSAW_PIECE_SIZE is the width and height of each jigsaw piece.

const JIGSAW_Y = 200;  // the y-position of the jigsaw on the canvas
const JIGSAW_PIECE_SIZE = 100; // width and height of each jigsaw piece

JigsawCanvasGame takes in an array of words. The game will allow the user to complete one word jigsaw at a time. Once all of the jigsaw have been completed, the player wins the game.

    let game = new JigsawCanvasGame(["one", "two"], , jigsawPieceImage, JIGSAW_PIECE_SIZE, JIGSAW_Y);

After each word is completed, the user will be presented with a "Continue" BUTTON. When the user clicks the "Continue" BUTTON, the next jigsaw word will be created, as highlighted in red.
Whenever the user selects a jigsaw piece, then selectedPiece is set to hold the number of that jigsaw piece, as shown in blue.

/* If they are needed, then include any game-specific mouse and keyboard listners */
    document.getElementById("gameCanvas").addEventListener("mousedown", function (e)
    {
        if (e.which === 1)  // left mouse button
        {
            let canvasBoundingRectangle = document.getElementById("gameCanvas").getBoundingClientRect();
            let mouseX = e.clientX - canvasBoundingRectangle.left;
            let mouseY = e.clientY - canvasBoundingRectangle.top;

            if (gameObjects[BUTTON].pointIsInsideBoundingRectangle(mouseX, mouseY))
            {
                gameObjects[BUTTON].stopAndHide();
                game.createNewWordJigsaw();
            }
            for (let i = pieces.length - 1; i >= 0; i--)
            {
                if (pieces[i].pointIsInsideBoundingRectangle(mouseX, mouseY))
                {
                    pieces[i].setOffsetX(mouseX);
                    pieces[i].setOffsetY(mouseY);
                    selectedPiece = i;
                    break;
                }
            }
        }
    });

As the mouse is moved, the selected jigsaw piece's x and y positions are updated, as shown in red.
If no mouse button is selected (e.which === 0) and the mouse hovers over the "Continue" BUTTON, then the button will change colour. The changing of its colour takes place inside the Button object. However, we need to call gameObjects[BUTTON].pointIsInsideBoundingRectangle(mouseX, mouseY) to fire the colour change event.

 document.getElementById("gameCanvas").addEventListener("mousemove", function (e)
    {
        if (e.which === 1)  // left mouse button
        {
            let canvasBoundingRectangle = document.getElementById("gameCanvas").getBoundingClientRect();
            let mouseX = e.clientX - canvasBoundingRectangle.left;
            let mouseY = e.clientY - canvasBoundingRectangle.top;

            for (let i = 0; i < pieces.length; i++)
            {
                if (selectedPiece === i)
                {
                    if (pieces[i].pointIsInsideBoundingRectangle(mouseX, mouseY))
                    {
                        pieces[i].setX(mouseX);
                        pieces[i].setY(mouseY);
                        break;
                    }
                }
            }
        }
        else if (e.which === 0) // no button selected
        {
            let canvasBoundingRectangle = document.getElementById("gameCanvas").getBoundingClientRect();
            let mouseX = e.clientX - canvasBoundingRectangle.left;
            let mouseY = e.clientY - canvasBoundingRectangle.top;
            gameObjects[BUTTON].pointIsInsideBoundingRectangle(mouseX, mouseY);
        }
    });

JigsawCanvasGame.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland.       */
/* A CanvasGame that implements collision detection.                       */


let pieces = [];       /* These two variables are used in both this class and in the JigsawPiece class */
let currentPiece = 0;  /* Therefore, they need to be global to both classes.                           */

class JigsawCanvasGame extends CanvasGame
{
    constructor(wordList, jigsawPieceImage, pieceSize, wordY)
    {
        super();

        this.wordList = wordList;
        this.jigsawPieceImage = jigsawPieceImage;
        this.pieceSize = pieceSize;        
        this.wordY = wordY;

        this.currentWord = 0;
        this.createNewWordJigsaw();
    }

    createNewWordJigsaw()
    {
        currentPiece = 0;

        let colours = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 0, 255], [255,255,0], [255, 100, 0]];
        let currentColour = -1;  // the fixed jigsaw start piece is not contained in the colours array 
        let newColour = null; // newColour is randomly assigned below
        
        for (let letter = 0; letter < this.wordList[this.currentWord].length; letter++)
        {
            do
            {
                newColour = Math.floor(Math.random() * colours.length);
            } while (newColour === currentColour); // make sure that pieces beside each other are different colours
            currentColour = newColour;
            pieces[letter] = new JigsawPiece(this.jigsawPieceImage, colours[newColour], this.wordList[this.currentWord][letter], letter, this.pieceSize, 10, Math.random() * (canvas.width - this.pieceSize), Math.random() * (canvas.height - this.pieceSize), (this.pieceSize * 0.8) * (letter + 1), this.wordY);
        }
        this.currentWord++;
    }

    render()
    {
        super.render();
        for (let i = 0; i < pieces.length; i++)
        {
            pieces[i].render();
        }
    }

    collisionDetection()
    {
        if ((pieces.length === 0) || (this.wordList.length === 0))
        {
            return;
        }

        if (currentPiece === pieces.length)
        {
            if (this.currentWord < this.wordList.length)
            {
                gameObjects[BUTTON].start(); /* render "Continue" message */
            }
            else
            {
                for (let i = 0; i < pieces.length; i++) /* stop all gameObjects from animating */
                {
                    pieces[i].stop();
                }

                gameObjects[MESSAGE].start(); /* render "Well Done!" message */
            }
        }
    }
}

The pieces[] array holds the various letter jigsaw pieces that make up the current word.
currentPiece is the piece that is currently able to be anchored to build up the jigsaw word. The player can move any piece, but only the currentPiece can be clicked into place in the jigsaw word. Once a piece has been clicked into place, it can no longer be moved.
selectedPiece is the currently selected piece.

let pieces = [];
let currentPiece = 0;

The wordList holds the array of words in this game.
This word will be placed vertically on the canvas at postion 'wordY'.
The game will build one word at a time. The word being built at any time is 'this.currentWord'.

    constructor(wordList, jigsawPieceImage, pieceSize, wordY)
    {
        super();

        this.wordList = wordList;
        this.jigsawPieceImage = jigsawPieceImage;
        this.pieceSize = pieceSize;        
        this.wordY = wordY;

        this.currentWord = 0;
        this.createNewWordJigsaw();
    }

The createNewWordJigsaw() method creates the pieces that are needed for the currentWord in the jigsaw's list of words. This method uses a for loop to fill the pieces[] array with the individual jigsaw pieces that contain each of the letters in a jigsaw word.
A jigsaw piece can be any of the colours in the colours[] array.
The do...while loop is used to assign a colour to each jigsaw piece. The do...while loop is used to ensure that no two piece beside each other have the same colour.

    createNewWordJigsaw()
    {
        currentPiece = 0;

        let colours = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 0, 255], [255,255,0], [255, 100, 0]];
        let currentColour = -1;  // the fixed jigsaw start piece is not contained in the colours array 
        let newColour = null; // newColour is randomly assigned below
        
        for (let letter = 0; letter < this.wordList[this.currentWord].length; letter++)
        {
            do
            {
                newColour = Math.floor(Math.random() * colours.length);
            } while (newColour === currentColour); // make sure that pieces beside each other are different colours
            currentColour = newColour;
            pieces[letter] = new JigsawPiece(this.jigsawPieceImage, colours[newColour], this.wordList[this.currentWord][letter], letter, this.pieceSize, 10, Math.random() * (canvas.width - this.pieceSize), Math.random() * (canvas.height - this.pieceSize), (this.pieceSize * 0.8) * (letter + 1), this.wordY);
        }
        this.currentWord++;
    }  

The jigsaw pieces are not contained in the gameObjects[] array. Instead, they are contained in an array called pieces[]. Therefore, we must override the render() method, so that the jigsaw pieces (in the pieces[] array) can be drawn.

    render()
    {
        super.render();
        for (let i = 0; i < pieces.length; i++)
        {
            pieces[i].render();
        }
    }

The collisionDetection() method is used to detect the end of each round and the end of the game.

    collisionDetection()
    {
        if ((pieces.length === 0) || (this.wordList.length === 0))
        {
            return;
        }

        if (currentPiece === pieces.length)
        {
            if (this.currentWord < this.wordList.length)
            {
                gameObjects[BUTTON].start(); /* render "Continue" message */
            }
            else
            {
                for (let i = 0; i < pieces.length; i++) /* stop all gameObjects from animating */
                {
                    pieces[i].stop();
                }

                gameObjects[MESSAGE].start(); /* render "Well Done!" message */
            }
        }
    }

JigsawPiece.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland. */

class JigsawPiece extends GameObject
{
    /* Each gameObject MUST have a constructor() and a render() method.        */
    /* If the object animates, then it must also have an updateState() method. */

    constructor(jigsawPieceImage, colour, letter, id, size, granulatity, startX, startY, finalX, finalY)
    {
        super(null); /* as this class extends from GameObject, you must always call super() */

        /* These variables depend on the object */
        this.letter = letter;
        this.id = id;
        this.width = size;
        this.height = size;
        this.x = startX;
        this.y = startY;
        this.offsetX = 0;
        this.offsetY = 0;

        this.finalX = finalX; // this is the position where the jigsaw piece needs to end up at
        this.finalY = finalY;
        this.granulatity = granulatity; // the +/- resolution of the accuracy of where the piece needs to end up
        this.isLocked = false; // set to true when the piece is at its final place


        this.jigsawCanvas = document.createElement('canvas');
        this.jigsawCanvasCtx = this.jigsawCanvas.getContext("2d");
        this.jigsawCanvas.width = jigsawPieceImage.width;
        this.jigsawCanvas.height = jigsawPieceImage.height;
        this.jigsawCanvasCtx.drawImage(jigsawPieceImage, 0, 0, jigsawPieceImage.width, jigsawPieceImage.height); /* As all jigsaw pieces are the same size, we can use any one for the collision detection */

        let imageData = this.jigsawCanvasCtx.getImageData(0, 0, this.jigsawCanvas.width, this.jigsawCanvas.height);
        let data = imageData.data;

        // Manipulate the pixel data
        for (let i = 0; i < data.length; i += 4)
        {
            if (data[i + 3] !== 0)
            {
                data[i + 0] = colour[0];
                data[i + 1] = colour[1];
                data[i + 2] = colour[2];
            }
        }

        this.jigsawCanvasCtx.putImageData(imageData, 0, 0);

        this.jigsawCanvasCtx.strokeStyle = "black";
        this.jigsawCanvasCtx.font = this.height * 0.6 + "px Arial";  // scale the font to match the size of the jigsaw piece
        this.jigsawCanvasCtx.fillText(this.letter, this.height * 0.35, this.height * 0.70);   // position the letter in the jigsaw piece
    }

    render()
    {
        ctx.drawImage(this.jigsawCanvas, this.x, this.y, this.width, this.height);
    }

    isPieceAtFinalPosition()
    {
        if (this.id !== currentPiece)
        {
            return false;
        }
        if (this.isLocked)
        {
            return false;
        }
        if ((this.x > this.finalX - this.granulatity) &&
                (this.x < this.finalX + this.granulatity) &&
                (this.y > this.finalY - this.granulatity) &&
                (this.y < this.finalY + this.granulatity))
        {

            this.x = this.finalX;
            this.y = this.finalY;
            this.isLocked = true;

            currentPiece++; // allow the next jigsaw piece to be locked

            return true;
        }
        return false;
    }

    setX(newMouseX)
    {
        if (this.isLocked)
        {
            return;
        }
        if (!this.isPieceAtFinalPosition())
        {
            this.x = newMouseX - this.offsetX;
        }
    }

    setY(newMouseY)
    {
        if (this.isLocked)
        {
            return;
        }
        this.y = newMouseY - this.offsetY;
    }

    setOffsetX(newMouseX)
    {
        if (this.isLocked)
        {
            return;
        }
        this.offsetX = newMouseX - this.x;
    }

    setOffsetY(newMouseY)
    {
        if (this.isLocked)
        {
            return;
        }
        this.offsetY = newMouseY - this.y;
    }

    pointIsInsideBoundingRectangle(pointX, pointY)
    {
        if (this.isLocked)
        {
            return;
        }
        if ((pointX > this.x) && (pointY > this.y))
        {
            if (pointX > this.x)
            {
                if ((pointX - this.x) > this.width)
                {
                    return false; // to the right of this gameObject
                }
            }

            if (pointY > this.y)
            {
                if ((pointY - this.y) > this.height)
                {
                    return false; // below this gameObject
                }
            }
        }
        else // above or to the left of this gameObject
        {
            return false;
        }

        // passed basic bounding test
        // now test for the transparent part of the jigsaz piece
        let imageData = this.jigsawCanvasCtx.getImageData(pointX - this.x, pointY - this.y, 1, 1);
        let data = imageData.data;

        // Check the pixel data for transparancy
        if (data[3] === 0)
        {
            return false;
        }

        // mouse is on top of jigsaw piece
        return true;
    }
}

An offscreen canvas is used to hold the jigsaw piece. The jigsaw piece colour is determined by the colour that is passed into the constructor. The offscreen buffer jigsaw piece is filled with 'colour'. The jigsaw piece will also used for tansparancy testing when the user tries to click the mouse on the jigsaw piece.

Each jigsaw piece has various data, such as width and startX associated with it. The id of the piece (this.id) is its position within the word array. The id is used to give the order that the letters need to be put together in order to create the jigsaw word.

The granularity of the jigsaw piece (this.granularity) allows for the piece to be detected as hitting the target pixel when it is close to the target. A higher granularity will mean that the piece is more easily placed at its target. The granularity is the number of pixels around the final x,y position of the jigsaw piece that the user needs to place the jigsaw piece.

Once a jigsaw piece has been locked in place, the this.isLocked flag is set. This will be used to stop the jigsaw piece from being moved again.

    constructor(jigsawPieceImage, colour, letter, id, size, granulatity, startX, startY, finalX, finalY)
    {
        super(null); /* as this class extends from GameObject, you must always call super() */

        /* These variables depend on the object */
        this.letter = letter;
        this.id = id;
        this.width = size;
        this.height = size;
        this.x = startX;
        this.y = startY;
        this.offsetX = 0;
        this.offsetY = 0;

        this.finalX = finalX; // this is the position where the jigsaw piece needs to end up at
        this.finalY = finalY;
        this.granulatity = granulatity; // the +/- resolution of the accuracy of where the piece needs to end up
        this.isLocked = false; // set to true when the piece is at its final place


        this.jigsawCanvas = document.createElement('canvas');
        this.jigsawCanvasCtx = this.jigsawCanvas.getContext("2d");
        this.jigsawCanvas.width = jigsawPieceImage.width;
        this.jigsawCanvas.height = jigsawPieceImage.height;
        this.jigsawCanvasCtx.drawImage(jigsawPieceImage, 0, 0, jigsawPieceImage.width, jigsawPieceImage.height); /* As all jigsaw pieces are the same size, we can use any one for the collision detection */

        let imageData = this.jigsawCanvasCtx.getImageData(0, 0, this.jigsawCanvas.width, this.jigsawCanvas.height);
        let data = imageData.data;
        
        // Manipulate the pixel data
        for (let i = 0; i < data.length; i += 4)
        {
            if (data[i + 3] !== 0)
            {
                data[i + 0] = colour[0];
                data[i + 1] = colour[1];
                data[i + 2] = colour[2];
            }
        }

        this.jigsawCanvasCtx.putImageData(imageData, 0, 0);

        this.jigsawCanvasCtx.strokeStyle = "black";
        this.jigsawCanvasCtx.font = this.height * 0.6 + "px Arial";  // scale the font to match the size of the jigsaw piece
        this.jigsawCanvasCtx.fillText(this.letter, this.height * 0.35, this.height * 0.70);   // position the letter in the jigsaw piece
    }

The isPieceAtFinalPosition() method test if the current x and y are within the granulatity of the final x and y positions, as shown in red.

If the jigsaw piece is within the granularity, then the jigsaw piece is set to the final x and y position, the isLocked flag is set and the currentPiece is incremented to the next jigsaw piece.

    isPieceAtFinalPosition()
    {
        if (this.id !== currentPiece)
        {
            return false;
        }
        if (this.isLocked)
        {
            return false;
        }
        if ((this.x > this.finalX - this.granulatity) &&
                (this.x < this.finalX + this.granulatity) &&
                (this.y > this.finalY - this.granulatity) &&
                (this.y < this.finalY + this.granulatity))
        {

            this.x = this.finalX;
            this.y = this.finalY;
            this.isLocked = true;

            currentPiece++; // allow the next jigsaw piece to be locked

            return true;
        }
        return false;
    } 

The pointIsInsideBoundingRectangle() method does the same rectangular bounding test that we have seen in previous examples. If this test is passed, then a second test is done to check if the mouse is on a transparent pixel within the jigsaw piece. The transparency test code is highlighted in red.

    pointIsInsideBoundingRectangle(pointX, pointY)
    {
        if (this.isLocked)
        {
            return;
        }
        if ((pointX > this.x) && (pointY > this.y))
        {
            if (pointX > this.x)
            {
                if ((pointX - this.x) > this.width)
                {
                    return false; // to the right of this gameObject
                }
            }

            if (pointY > this.y)
            {
                if ((pointY - this.y) > this.height)
                {
                    return false; // below this gameObject
                }
            }
        }
        else // above or to the left of this gameObject
        {
            return false;
        }

        // passed basic bounding test
        // now test for the transparent part of the jigsaz piece
        let imageData = this.jigsawCanvasCtx.getImageData(pointX - this.x, pointY - this.y, 1, 1);
        let data = imageData.data;

        // Check the pixel data for transparancy
        if (data[3] === 0)
        {
            return false;
        }

        // mouse is on top of jigsaw piece
        return true;
    }