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.