Friday, 2 April 2021

Web Tutorial: VueJS Easter Puzzle

What's up, you glorious geeks? Easter approaches!

Traditionally, at this time of year, we have our Easter-themed web tutorial. This year, I shall not disappoint... and there's a special treat because I will be using VueJS for this one.

My usage of the framework will be a very rudimentary one, just to get beginners off on the right foot. What we will be doing is a very simple puzzle game. We will use an Easter-themed image, like this one. It's a 500 by 500 pixels square image.


easter.jpg

Now imagine if the image was scrambled and you had to unscramble it within a time limit. That will be what our game does!

The setup

As usual, it all begins with some HTML.
<!DOCTYPE html>
<html>
    <head>
        <title>Easter Puzzle</title>

        <style>

        </style>
    </head>

    <body>
        <script>

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


Here, let's add something to the styling - a red outline to all divs for dev purposes.
<!DOCTYPE html>
<html>
    <head>
        <title>Easter Puzzle</title>

        <style>
            div { outline: 0px solid #FF0000;}
        </style>
    </head>

    <body>
        <script>

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


The next few items are specific to VueJS. We need a div with an id of easterApp. And then we need to link the VueJS library. Finally, we begin scripting by declaring a variable app, and setting it to a new instance of the Vue object.
<html>
    <head>
        <title>Easter Puzzle</title>

        <style>
            div { outline: 1px solid #FF0000;}
        </style>
    </head>

    <body>
        <div id="easterApp">

        </div>


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

        <script>
            var app = new Vue
            (

            );
        </script>

    </body>
</html>


We then fill in app with an object within the new Vue object. It will have the properties el (which we will set to the id easterApp to let Vue know which DOM element to reference), methods to hold the different methods for this app, and created to define what gets run as soon as the app loads.
<script>
    var app = new Vue
    (
        {
            el: "#easterApp",
            data:
            {

            },
            methods:
            {

            },
            created: function()
            {

            }
        }

    );
</script>


Inside easterApp, add a div with an id of timeContainer. Inside that, put some HTML. These are elements that will be populated later. And then we'll also have a div with an id of puzzleContainer right under timeContainer.
<div id="easterApp">
    <div id="timeContainer">
        <h2>Message</h2>
        <h1>Seconds</h1>
        <button>ButtonText</button>
    </div>

    <div id="puzzleContainer">

    </div>

</div>


Let's style! easterApp will be 500 pixels wide. We'll use the margin property to set it center of the screen, and then set some font styling.
<style>
    div { outline: 1px solid #FF0000;}

    #easterApp
    {
        width: 500px;
        margin: 0 auto 0 auto;
        font-family: verdana;
        font-size: 12px;
    }

</style>


Next is timeContainer. It will take up full width of easterApp. We give it 150 pixels in height, and align text center.
<style>
    div { outline: 1px solid #FF0000;}

    #easterApp
    {
        width: 500px;
        margin: 0 auto 0 auto;
        font-family: verdana;
        font-size: 12px;
    }

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

</style>


Here, I'm going to style the button. Use whatever fancy CSS you like, but I'm gonna go with orange background and white text.
<style>
    div { outline: 1px solid #FF0000;}

    #easterApp
    {
        width: 500px;
        margin: 0 auto 0 auto;
        font-family: verdana;
        font-size: 12px;
    }

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

    #timeContainer 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;
    }

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

</style>


And puzzleContainer will be a square, so we give it a height of 500 pixels.
<style>
    div { outline: 1px solid #FF0000;}

    #easterApp
    {
        width: 500px;
        margin: 0 auto 0 auto;
        font-family: verdana;
        font-size: 12px;
    }

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

    #timeContainer 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;
    }

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

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

</style>


This looks like a good start, wouldn't you say?


In the data object, fill in these properties. timer is set to undefined by default. We have seconds with a value of 100 (which is perhaps a bit too much, but you can tweak this later) and btnText, whose default value is "RESET". For message, set it to "Time elapsed". pieces will be an empty array.
data:
{
    timer: undefined,
    seconds: 100,
    btnText: "RESET",
    message: "Time elapsed",
    pieces: []

},


Within the created() method, make a call to the reset() method. This means that as soon as the app loads, reset() will run.
created: function()
{
    this.reset();
}


You'll notice that reset() is prefixed by a reference to this. That's because reset() is a method within the same object that created() is a child of. We will create reset() now, under methods.
methods:
{
    reset: function()
    {

    },

},


In here, we set the properties in data to their default values.
methods:
{
    reset: function()
    {
        this.seconds = 100;
        this.btnText = "RESET";
        this.message = "Time elapsed";
        this.pieces = [];

    },
},


Then we insert calls to stopTimer() and startTimer().
methods:
{
    reset: function()
    {
        this.stopTimer();
        this.seconds = 100;
        this.btnText = "RESET";
        this.message = "Time elapsed";
        this.startTimer();
        this.pieces = [];
    },
},


Next, we create these methods.
reset: function()
{
    this.stopTimer();
    this.seconds = 100;
    this.btnText = "RESET";
    this.message = "Time elapsed";
    this.startTimer();
    this.pieces = [];
},
startTimer: function()
{

},
stopTimer: function()
{

},


For stopTimer(), we use timer as an argument in the clearInterval() function. This means, basically, that we stop the timer, and then we set timer to undefined.
stopTimer: function()
{
    clearInterval(this.timer);
    this.timer = undefined;

},


For startTimer(), this only fires off if timer is undefined. And it would be if stopTimer() was run.
startTimer: function()
{
    if (this.timer == undefined)
    {

    }

},


If timer is undefined, set timer by running setInterval with an interval of 1 second.
startTimer: function()
{
    if (this.timer == undefined)
    {
        this.timer = setInterval
        (
            () =>
            {

            },
            1000
        );

    }
},


In here, decrement seconds.
startTimer: function()
{
    if (this.timer == undefined)
    {
        this.timer = setInterval
        (
            () =>
            {
                this.seconds = this.seconds - 1;
            },
            1000
        );
    }
},


Now check for the value of seconds. If it's reached the value of 0, run stopTimer(). Then set btnText to "REPLAY" and set message.
startTimer: function()
{
    if (this.timer == undefined)
    {
        this.timer = setInterval
        (
            () =>
            {
                this.seconds = this.seconds - 1;

                if (this.seconds == 0)
                {
                    this.stopTimer();
                    this.btnText = "REPLAY";
                    this.message = "Better luck next time!";
                }

            },
            1000
        );
    }
},


Now in the HTML, replace the text we put there, with template strings.
<div id="timeContainer">
    <h2>{{message}}</h2>
    <h1>{{seconds}}</h1>
    <button>{{btnText}}</button>
</div>


Add a click event handler using v-on, and make it call reset().
<div id="timeContainer">
    <h2>{{message}}</h2>
    <h1>{{seconds}}</h1>
    <button v-on:click="reset">{{btnText}}</button>
</div>


Now you'll see that there's a number there where "seconds" was, and it's counting down each second! Also, there's a "REPLAY" button now. If you click it, reset() will be called and the seconds display will go back to 100, then start counting down again.


And once it reaches 0, the message changes!


Next, we're going to populate the puzzle. It will be a 5 by 5 square grid. So create a nested For loop like this. 5 rows, 5 columns.
reset: function()
{
    this.stopTimer();
    this.seconds = 100;
    this.btnText = "RESET";
    this.message = "Time elapsed";
    this.startTimer();
    this.pieces = [];

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

        }
    }

},


Because each square is going to be 25 by 25 pixels, declare offsetX as the product of col by 25. offsetY will be the product of row by 25.
for (var row = 0; row < 5; row++)
{
    for (var col = 0; col < 5; col++)
    {
        var offsetX = col * 25;
        var offsetY = row * 25;
    }
}


Declare a temporary object, piece. The id property will be unique and going by that formula, it will be from 0 to 24. Set the rotation property to 0, for now. And then set the offsetX and offsetY properties to the current value of offsetX and offsetY, respectively.
for (var row = 0; row < 5; row++)
{
    for (var col = 0; col < 5; col++)
    {
        var offsetX = col * 25;
        var offsetY = row * 25;

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


And finally, push piece into the pieces array. So by the time the nested For loop is done, you should have 25 elements in the pieces array, each with a different offsetX and offsetY value.
for (var row = 0; row < 5; row++)
{
    for (var col = 0; col < 5; col++)
    {
        var offsetX = col * 25;
        var offsetY = row * 25;

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

        this.pieces.push(piece);
    }
}


In the HTML, inside puzzleContainer, create a div with a class of pieceContainer, and use v-for to ensure that this repeats for every element in pieces.
<div id="puzzleContainer">
    <div class="pieceContainer" v-for="piece in pieces">

    </div>

</div>


Now in the CSS, add the pieceContainer CSS class. The width and height will be 20% of puzzleContainer, which is 25 pixels. And we set the float property to left.
#puzzleContainer
{
    width: 100%;
    height: 500px;
}

.pieceContainer
{
    width: 20%;
    height: 20%;
    float: left;
}


At this point, you should have a 5 by 5 square grid, each square 25 pixels in height and width.


Back to the HTML, within each div styled using pieceContainer, insert a div styled using piece.
<div id="puzzleContainer">
    <div class="pieceContainer" v-for="piece in pieces">
        <div class="piece">

        </div>

    </div>
</div>


Use v-bind to set the data-id property to the id property of piece.
<div id="puzzleContainer">
    <div class="pieceContainer" v-for="piece in pieces">
        <div class="piece" v-bind:data-id="piece.id">

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


Let's add something to the CSS - the piece class. Each of these will fill full width and height of its parent, and its background image will be easter.jpg.
.pieceContainer
{
    width: 20%;
    height: 20%;
    float: left;
}

.piece
{
    width: 100%;
    height: 100%;
    background-image: url(easter.jpg);
    background-repeat: no-repeat;
}


And there it is...


Now use v-bind on the style attribute. The value will be what's returned from the getStyle() method with piece passed in as an argument. We'll create that next.
<div id="puzzleContainer">
    <div class="pieceContainer" v-for="piece in pieces">
        <div class="piece" v-bind:data-id="piece.id" v-bind:style="getStyle(piece)">

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


Inside the methods object, create the getStyle() method. It will accept obj as a parameter.
stopTimer: function()
{
    clearInterval(this.timer);
    this.timer = undefined;
},
getStyle: function(obj)
{

},


In here, we will generate CSS. Declare rotate and set it to some CSS code. We use the rotation property (which is currently 0 for all pieces).
getStyle: function(obj)
{
    var rotate = "transform: rotate(" + obj.rotation + "deg)";
},


Then we declare offset and do something similar, only this time it's for the background-position property, and we use the offsetX and offsetY properties of obj to generate the CSS.
getStyle: function(obj)
{
    var rotate = "transform: rotate(" + obj.rotation + "deg)";
    var offset = "background-position:" + obj.offsetX + "% " + obj.offsetY + "%";
},


And finally, we return the full CSS style string.
getStyle: function(obj)
{
    var rotate = "transform: rotate(" + obj.rotation + "deg)";
    var offset = "background-position:" + obj.offsetX + "% " + obj.offsetY + "%";

    return rotate + ";" + offset;
},


And now we have a full picture.


Let's randomly rotate all the pieces now. Get back to the reset() method. Inside the inner loop of the nested For loop, declare randomRotation. There are 4 possibilities - from 0 to 3. Then multiply by 90 to get the degree of rotation. Finally, change rotation within the temporary piece object to randomRotation.
for (var row = 0; row < 5; row++)
{
    for (var col = 0; col < 5; col++)
    {
        var randomRotation = Math.floor(Math.random() * 3) * 90;
        var offsetX = col * 25;
        var offsetY = row * 25;

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

        this.pieces.push(piece);
    }
}


Now this is what it should look like, with pieces all rotated.


Sweet! Now we will add something for the user to be able to rotate the pieces on their own. Use v-on to add a click event, and set it to call the rotatePiece() method.
<div class="piece" v-bind:data-id="piece.id" v-bind:style="getStyle(piece)" v-on:click="rotatePiece">

</div>


Obviously, we will then add this method under methods.
getStyle: function(obj)
{
    var rotate = "transform: rotate(" + obj.rotation + "deg)";
    var offset = "background-position:" + obj.offsetX + "% " + obj.offsetY + "%";

    return rotate + ";" + offset;
},
rotatePiece: function(e)
{

},


This will only fire off if timer is undefined, which means the game is in progress.
rotatePiece: function(e)
{
    if (this.timer != undefined)
    {
                                
    }

},


Declare piece. Set it to the element in pieces pointed to by the id property in the event's element. Remember we set the data-id property? Yep, so we're going to make some changes to piece, then reassign the value back to the element of the pieces array.
rotatePiece: function(e)
{
    if (this.timer != undefined)
    {
        var piece = this.pieces[e.target.dataset.id];

        this.pieces[piece.id] = piece; 
                               
    }
},


Now bear in mind that we can only rotate 90 degrees clockwise. That will be a limit we set. Use an If-else block. Check if the rotation property is 270.

rotatePiece: function(e)
{
    if (this.timer != undefined)
    {
        var piece = this.pieces[e.target.dataset.id];

        if (piece.rotation == 270)
        {

        }
        else
        {

        }


        this.pieces[piece.id] = piece;                                
    }
},


So if the rotation is at 270 degrees, one more rotation would put it back at 0 degrees. Otherwise, just add 90 to the current value of the rotation property.
rotatePiece: function(e)
{
    if (this.timer != undefined)
    {
        var piece = this.pieces[e.target.dataset.id];

        if (piece.rotation == 270)
        {
            piece.rotation = 0;
        }
        else
        {
            piece.rotation = piece.rotation + 90;
        }

        this.pieces[piece.id] = piece;                                
    }
},


Now add a new method - checkIncorrectPieces().
rotatePiece: function(e)
{
    if (this.timer != undefined)
    {
        var piece = this.pieces[e.target.dataset.id];

        if (piece.rotation == 270)
        {
            piece.rotation = 0;
        }
        else
        {
            piece.rotation = piece.rotation + 90;
        }

        this.pieces[piece.id] = piece;                                
    }
},
checkIncorrectPieces: function()
{

}


Basically, we declare incorrect, then set it to a new array derived from running pieces through the filter() method, returning all those elements where the rotation property is not 0. Because 0 is the correct value. After that, we return the number of incorrect values.
checkIncorrectPieces: function()
{
    var incorrect = this.pieces.filter((x) => { return x.rotation != 0;});

    return incorrect.length;

}


So every time the timer runs, it should check if the number of incorrect pieces is 0, which means that all the pieces have been correctly rotated. When that happens, we run the stopTimer() method, set btnText and message, and this effectively ends the game.
startTimer: function()
{
    if (this.timer == undefined)
    {
        this.timer = setInterval
        (
            () =>
            {
                this.seconds = this.seconds - 1;

                if (this.seconds == 0)
                {
                    this.stopTimer();
                    this.btnText = "REPLAY";
                    this.message = "Better luck next time!";
                }

                if (this.checkIncorrectPieces() == 0)
                {
                    this.stopTimer();
                    this.btnText = "REPLAY";
                    this.message = "Congratulations!";
                }

            },
            1000
        );
    }
},


Now when you rotate all the pieces correctly, the timer stops moving and you get a congratulatory message!


A few final touches!

Add this to the CSS so that the animation we're about to add, comes out nicely.
#puzzleContainer
{
    width: 100%;
    height: 500px;
    transition: all 1s;
}


Bind the class attribute using v-bind. If checkIncorrectPieces() is 0, then the class is win.
<div id="puzzleContainer" v-bind:class="checkIncorrectPieces() == 0 ? 'win' : ''">
    <div class="pieceContainer" v-for="piece in pieces">
        <div class="piece" v-bind:data-id="piece.id" v-bind:style="getStyle(piece)" v-on:click="rotatePiece">

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


Add win to the CSS. It will be a nice thick orange outline.
.piece
{
    width: 100%;
    height: 100%;
    background-image: url(easter.jpg);
    background-repeat: no-repeat;
}

.win
{
    outline: 5px solid rgba(255, 100, 0, 1);
}


Now when you win the game, you get this orange outline fading in! If you click the REPLAY button, it should fade out again.


Finally, remove the red outline.
div { outline: 0px solid #FF0000;}


And this is your final product.


Final Notes

I do like VueJS, or at least, I like the version we're using and how we're using it. The structure is pretty much how I usually write my JavaScript anyway, and it combines the best features of AngularJS and ReactJS without being a pain in the ass about it.

Piece be with you!
T___T

No comments:

Post a Comment