Monday, 20 December 2021

Web Tutorial: VueJS Tile Slider Game (Part 1/2)

Christmas is coming!

As with the yearly tradition, TeochewThunder is proud to present the Christmas-themed web tutorial. This year, we will be making a game using VueJS.

Remember those puzzles where the pieces were scrambled and you had to shift those pieces around till you assembled the picture? Yep, that's what we are doing today. For starters, we will use this image. Note that this image is 500 by 500 pixels.

xmas0.jpg

We will then cut it up into 16 pieces, scramble the locations of the pieces within a 4 by 4 grid, and let the user click on these pieces to move them into the correct locations.

Let's begin with some HTML. Note that we are including a remote link to the VueJS script.
<!DOCTYPE html>
<html>
    <head>
        <title>Christmas Puzzle</title>

        <style>

        </style>
    </head>

    <body>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.18/vue.min.js"></script>

        <script>

        </script>
    </body>
</html>


Add a div with id xmasApp. This is what VueJS will use to perform binding.
<body>
    <div id="xmasApp">

    </div>


    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.18/vue.min.js"></script>

    <script>

    </script>
</body>


Style this. Give all divs a red outline so we can see what we are doing. xmasApp will be 500 pixels wide, and centered in the middle of the screen via the margin property. Font size has been set here as well, but that is optional.
<style>
    div { outline: 1px solid #FF0000;}

    #xmasApp
    {
        width: 500px;
        margin: 0 auto 0 auto;
        font-family: verdana;
        font-size: 12px;
    }
</style>


In here, add another div with id of labelContainer. Inside this div, add some text and controls for the user. The button will be useful later.
<div id="xmasApp">
    <div id="labelContainer">
        <h1>MERRY CHRISTMAS!</h1>

        <p>
            This picture has been scrambled. Click on the tiles to move them and unscramble the picture. Tip: The top right hand tile is always the blank tile.
        </p>

        <button></button>
    </div>
</div>


Not nearly pretty enough. Let's clean this up.


Here's some styling for labelContainer. We set the height and width, and center the text. The button is orange, and pretty much everything in the styling is optional.
#xmasApp
{
    width: 500px;
    margin: 0 auto 0 auto;
    font-family: verdana;
    font-size: 12px;
}

#labelContainer
{
    width: 100%;
    height: 150px;
    text-align: center;
}

#labelContainer button
{
    width: 8em;
    height: 2em;
    text-align: center;
    background-color: rgba(255, 100, 0, 1);
    color: rgba(255, 255, 255, 1);
    font-weight: bold;
    border-radius: 10px;
    border: 0px solid black;
}

#labelContainer button:hover
{
    background-color: rgba(255, 100, 0, 0.5);
    color: rgba(255, 0, 0, 1);
}


Much, much better.




Now let's add another div, puzzleContainer.
<div id="xmasApp">
    <div id="labelContainer">
        <h1>MERRY CHRISTMAS!</h1>

        <p>
            This picture has been scrambled. Click on the tiles to move them and unscramble the picture. Tip: The top right hand tile is always the blank tile.
        </p>

        <button></button>
    </div>

    <div id="puzzleContainer">

    </div>

</div>


Here with the styling for puzzleContainer, you see that the width is the whole of its parent, which is 500 pixels, and its height is also 500 pixels. Yes, it's a square.

#labelContainer button:hover
{
    background-color: rgba(255, 100, 0, 0.5);
    color: rgba(255, 0, 0, 1);
}

#puzzleContainer
{
    width: 100%;
    height: 500px;
}


And here's the square. We will turn it into a grid later on.



Let's do some scripting!

We first instantiate a new Vue object. And we pass in an object.
<script>
    var app = new Vue
    (
        {

        }
    );
</script>


The first property we put in, is el, the element. This is xmasApp. The next properties are data and methods. Both of these are, in turn, objects. created is another property, and it's a method that calls the reset() method, which we will define soon.
var app = new Vue
(
    {
        el: "#xmasApp",
        data:
        {

        },
        methods:
        {

        },
        created: function()
        {
            this.reset();
        }                 
       
    }
);


For data, we have the following properties.

btnText: this is a string which will be used as the button label.
pieces: an array that holds information about each piece of the picture.
arrangement: an array that tells us which piece is currently in which slot.
blankIndex: this defines the tile that is blank.
images: this is a number that defines the number of images we will be using.
imageNo: this is a number which defines which image is in use at the moment.

Below, pieces and arrangement are empty arrays by default. For blankIndex, I personally prefer the top right corner (3) but honestly, any number from 0 to 15 works. Currently we are only using one image, so we set images to 1 and we always use the first image by default, so imageNo is 0.
data:
{
    btnText: "RESET",
    pieces: [],
    arrangement: [],
    blankIndex: 3,
    images: 1,
    imageNo: 0

},


In methods, we declare reset(). We begin by setting the properties of data to their default values. For imageNo, we set it to a random number from 0 to (images - 1). In this case, since images is 1, the result is always 0.
methods:
{
    reset: function()
    {
        this.btnText = "RESET";
        this.pieces = [];
        this.arrangement = [];
        this.blankIndex = 3;
        this.imageNo = Math.floor(Math.random() * this.images);
    }
}


Then we create a nested For loop because this is where we define the grid. We have 5 rows and 5 columns.
methods:
{
    reset: function()
    {
        this.btnText = "RESET";
        this.pieces = [];
        this.arrangement = [];
        this.blankIndex = 3;
        this.imageNo = Math.floor(Math.random() * this.images);

        for (var row = 0; row < 4; row++)
        {
            for (var col = 0; col < 4; col++)
            {

            }
        }

    },
}


Here, we declare offsetX and offsetY so that the browser knows what to offset the image in each piece by. I've done some trial and error, and the optimum number is 33.
methods:
{
    reset: function()
    {
        this.btnText = "RESET";
        this.pieces = [];
        this.arrangement = [];
        this.blankIndex = 3;
        this.imageNo = Math.floor(Math.random() * this.images);

        for (var row = 0; row < 4; row++)
        {
            for (var col = 0; col < 4; col++)
            {
                var offsetX = col * 33;
                var offsetY = row * 33;

            }
        }
    },
}


Then we declare an object, piece. The id is a function of row and col, and we'll have the offsetX and offsetY properties. And then we will push each piece object into the pieces array.
methods:
{
    reset: function()
    {
        this.btnText = "RESET";
        this.pieces = [];
        this.arrangement = [];
        this.blankIndex = 3;
        this.imageNo = Math.floor(Math.random() * this.images);
        var tempArr = [];

        for (var row = 0; row < 4; row++)
        {
            for (var col = 0; col < 4; col++)
            {
                var offsetX = col * 33;
                var offsetY = row * 33;

                var piece =
                {
                    id: (row * 4) + col,
                    offsetX: offsetX,
                    offsetY: offsetY,
                }

                this.pieces.push(piece);                                       

            }
        }
    },
}


Now declare holder as an object. The id is also a function of row and col. If the current piece id is equal to blankIndex, then the value of piece is null. Otherwise, it is undefined.

And then push the piece into arrangement.
methods:
{
    reset: function()
    {
        this.btnText = "RESET";
        this.pieces = [];
        this.arrangement = [];
        this.blankIndex = 3;
        this.imageNo = Math.floor(Math.random() * this.images);
        var tempArr = [];

        for (var row = 0; row < 4; row++)
        {
            for (var col = 0; col < 4; col++)
            {
                var offsetX = col * 33;
                var offsetY = row * 33;

                var piece =
                {
                    id: (row * 4) + col,
                    offsetX: offsetX,
                    offsetY: offsetY,
                }

                this.pieces.push(piece);                                        

                var holder =
                {
                    id: (row * 4) + col,
                    piece: ((row * 4) + col == this.blankIndex ? null : undefined)
                }

                this.arrangement.push(holder);

            }
        }
    },
}


After that, we iterate through the arrangement array. If the index of the current element is not the same as blankIndex, set the piece property to the corresponding element in the pieces array.
reset: function()
{
    this.btnText = "RESET";
    this.pieces = [];
    this.arrangement = [];
    this.blankIndex = 3;
    this.imageNo = Math.floor(Math.random() * this.images);
    var tempArr = [];

    for (var row = 0; row < 4; row++)
    {
        for (var col = 0; col < 4; col++)
        {
            var offsetX = col * 33;
            var offsetY = row * 33;

            var piece =
            {
                id: (row * 4) + col,
                offsetX: offsetX,
                offsetY: offsetY,
            }

            this.pieces.push(piece);                                    

            var holder =
            {
                id: (row * 4) + col,
                piece: ((row * 4) + col == this.blankIndex ? null : undefined)
            }

            this.arrangement.push(holder);
        }
    }

    for (var i = 0; i < this.arrangement.length; i++)
    {
        if (i != this.blankIndex)
        {
            this.arrangement[i].piece = this.pieces[i];
        }
    }

}


Now let's put this code into the HTML. We have the pieces array and the arrangement array. So for every element in arrangement, we will have a div styled using pieceContainer. Within that div will be another div, and its class and style will be bound to the getClass() and getStyle() methods, respectively.
<div id="puzzleContainer">
    <div class="pieceContainer" v-for="holder in arrangement">
        <div v-bind:class="getClass(holder.id)" v-bind:style="getStyle(holder.piece)">

        </div>
    </div>

</div>


We'll also bind the id attribute to the id attribute of the current element. Not really necessary for anything to work, but it's a good practice.
<div id="puzzleContainer">
    <div class="pieceContainer" v-for="holder in arrangement">
        <div v-bind:class="getClass(holder.id)" v-bind:id="'piece'+holder.id" v-bind:style="getStyle(holder.piece)">

        </div>
    </div>
</div>


Here's the styling for pieceContainer. Since there are 4 rows and 4 columns of such "pieces", this stands to reason that each of these would take up 25% width and height of the parent. We set the float property to left so that they fall in line, and set the background color to black.
#puzzleContainer
{
    width: 100%;
    height: 500px;
}

.pieceContainer
{
    width: 25%;
    height: 25%;
    float: left;
    background-color: rgba(0, 0, 0, 1);
}


Now let's define the getClass() and getStyle() methods. Both of these have a parameter, id.
    for (var i = 0; i < this.arrangement.length; i++)
    {
        if (i != this.blankIndex)
        {
            this.arrangement[i].piece = i;
        }
    }
},
getClass: function(id)
{

},
getStyle: function(id)
{

}


For getClass(), we first declare baseClass. If the id passed in is equal to blankIndex, the class is blank. But otherwise, it is piece. Return the value of baseClass, and we're all set for this method, for now.
getClass: function(id)
{
    var baseClass = (id == this.blankIndex ? "blank" : "piece");

    return baseClass;

},


Here's the styling for piece and blank. Both piece and blank take up full height and width of the parent, which is pieceContainer. But while piece has background-repeat and margin properties (the latter will be relevant later), blank only has a black background.
.pieceContainer
{
    width: 25%;
    height: 25%;
    float: left;
    background-color: rgba(0, 0, 0, 1);
}

.piece
{
    width: 100%;
    height: 100%;
    background-repeat: no-repeat;
    margin: 0px;
}

.blank
{
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 1);
}


For getStyle(), if id is null, just return an empty string. Because the piece will be styled using blank and thus will not require any styling for the background image.
getStyle: function(id)
{
    if (id == null) return "";
}


But if there's a non-null value being passed in, we declare obj and set it to the element in pieces pointed to by id.
getStyle: function(id)
{
    if (id == null) return "";

    var obj = this.pieces[id];
}


Then we declare image and create a styling string value for it, using imageNo to set the background image. Remember that right now, the value of imageNo is always 0.
getStyle: function(id)
{
    if (id == null) return "";

    var obj = this.pieces[id];
    var image = "background-image: url(xmas" + this.imageNo + ".jpg);";
}


Then declare offset. For this, we use the offsetX and offsetY properties of obj. Each piece will thus use the same image background but with a different offset!
getStyle: function(id)
{
    if (id == null) return "";

    var obj = this.pieces[id];
    var image = "background-image: url(xmas" + this.imageNo + ".jpg);";
    var offset = "background-position:" + obj.offsetX + "% " + obj.offsetY + "%";
}


Finally, return both image and offset in concatenated form.
getStyle: function(id)
{
    if (id == null) return "";

    var obj = this.pieces[id];
    var image = "background-image: url(xmas" + this.imageNo + ".jpg);";
    var offset = "background-position:" + obj.offsetX + "% " + obj.offsetY + "%";

    return image + offset;
}


Also, let's fix this button so that the text appears, and when it gets clicked, it should run the reset() method.
<button v-on:click="reset">{{btnText}}</button>


Now, take a look at what we have. It's the picture, divided into 16 pieces, but the top right hand corner piece is blank beause blank has a black background!




Scrambling the tiles

The tiles are currently in the correct position. We want them to be randomly located. So let's begin by declaring tempArr, an empty array.
this.btnText = "RESET";
this.pieces = [];
this.arrangement = [];
this.blankIndex = 3;
this.imageNo = Math.floor(Math.random() * this.images);
var tempArr = [];


We need this array because right after we push the element into pieces, we need to push the id into tempArr.
var tempArr = [];

for (var row = 0; row < 4; row++)
{
    for (var col = 0; col < 4; col++)
    {
        var offsetX = col * 33;
        var offsetY = row * 33;

        var piece =
        {
            id: (row * 4) + col,
            offsetX: offsetX,
            offsetY: offsetY,
        }

        this.pieces.push(piece);
        tempArr.push((row * 4) + col);                                        

        var holder =
        {
            id: (row * 4) + col,
            piece: ((row * 4) + col == this.blankIndex ? null : undefined)
        }

        this.arrangement.push(holder);
    }
}


We're going to remove this line right here.
for (var i = 0; i < this.arrangement.length; i++)
{
    if (i != this.blankIndex)
    {
        //this.arrangement[i].piece = i;
    }
}


Here, we check if tempArr has only one element. That can happen - you'll see why in a minute.
for (var i = 0; i < this.arrangement.length; i++)
{
    if (i != this.blankIndex)
    {
        //this.arrangement[i].piece = i;
        if (tempArr.length == 1)
        {

        }
        else
        {
    
        }

    }
}


If there's only one element left in tempArr, we just assign the value of that element to the current element of arrangement's piece property.
for (var i = 0; i < this.arrangement.length; i++)
{
    if (i != this.blankIndex)
    {
        //this.arrangement[i].piece = i;
        if (tempArr.length == 1)
        {
            this.arrangement[i].piece = tempArr[0];
        }
        else
        {
    
        }
    }
}


If there's more than one element, then we need to randomly pick one and assign it to the current element of arrangement's piece property. For this we use a Do-while loop that will keep running if the index of the assigned piece is actually blankIndex. Eventually, all tiles except blankIndex will be randomly assigned.
for (var i = 0; i < this.arrangement.length; i++)
{
    if (i != this.blankIndex)
    {
        //this.arrangement[i].piece = i;
        if (tempArr.length == 1)
        {
            this.arrangement[i].piece = tempArr[0];
        }
        else
        {
            do
            {
                                        
            }
            while(this.arrangement[i].piece == this.blankIndex);  
 
        }
    }
}


Within the loop, declare index and set it to a random number between 0 and the length of tempArr. Set the piece property of the current element of arrangement to the element in tempArr pointed to by index. And then remove that element from tempArr using the splice() method.

Here's some info on the splice() method.
do
{
    var index = Math.floor(Math.random() * tempArr.length);
    this.arrangement[i].piece = tempArr[index];
    tempArr.splice(index, 1);                                            

}
while(this.arrangement[i].piece == this.blankIndex);


Now refresh your page. The tiles should be randomly arranged, though the top right corner should still be black. If you click the button, it should run the reset() method and give you a different random arrangement.




We don't want to go into too much at one go, so we'll stop right here.

Next

Making the tiles movable, and setting the win condition.

No comments:

Post a Comment