In this part of the tutorial we will talk about transitions and animations. Now that we have much of the core game functionality down, we can focus on making the play experience more pleasing and intuitive.
To accomplish the desired affect, we will make use of 3 types of transitions / animations:
- List Transitions:
- Tile Slide Movement
- Game Menu Points
- Conditional Rendering:
- End Game Overlay
- State transitions:
- Game Menu Score
- Tile Pop on Merge
To understand the basics of transitions and animations, here are two great tutorials I've written - they will take you through all the core concepts of Vue.js transitions and how to create them.
https://medium.com/babystep/vuejs-transitions-3842d8b633ae https://medium.com/babystep/vuejs-animations-javascript-md-fa496111d200
If you are unfamiliar with Vue.js transitions, it would be helpful to take a minute or two to skim through those guides. The first one will be the most helpful, it touches on the first 2 types of transitions we will be creating for the game. The new addition here will be state transitions.
So with the foundations of Vue.js transitions in mind, let's dive in. Let's first try to implement the tile sliding transition. This transition is the most important to the game, and without it the game is pretty unplayable.
Conceptually when we think about the board, we might invision it as a 2d array using the indices of each array to come up with an x, y coordinate of a tile. This is a great structure for doing tile manipulation, but when we try to apply transitions to this structure we will run into some problems. First, a group-transition
in Vue.js operates on a (flat) list. A 2d array is actually a list of lists. This would reflect in a group-transition
of group-transitions
's. That might not initially raise any flags, but if we think about how group-transition
's are triggered it becomes very obvious that doing any kind of vertical transition will be almost impossible with this structure. This is because the only ways to trigger the transition affect are through an enter, leave, or move. For a tile to move from board[0][0]
to board[1][0]
would mean that it would have to leave the board[0]
list and enter the board[1]
list. This can be done using javascript, but it will take some considerable computing to handle different sized boards, sliding of different lengths, and slide direction.
Don't worry though, there is a much easier way to handle transitions. Going back to an earlier point, group-transition
in Vue.js operates on a (flat) list. Let's think about the board again, does it really need to be a 2d array? For calculating the board on move, yes it makes sense to work with a 2d array, but for rendering the board, it doesn't matter. The grid structure can be accomplished through some fancy flexbox styling. In fact it would be easier and work within the Vue.js api if it were a flat array. So the strategy here can be 1 of 2 options:
- Store the board as a flat array, and chunk it into a 2d array anytime we need to calculate a move.
- Store the board as a nested array and flatten it into a 1d array for rendering.
The option I chose to move forward with was the first option. I feel this option is prefered because it feels more straigtforward.But because we are keying off each element with an id, both methods would work fine. Vue won't have a problem detecting changes in the array and elements new position.
At this point you might be asking yourself - sure, but all of the list transitions I've seen so far have been vertically organized lists. How can a single group-transition
handle both vertical and horizontal movement?
If you do some exploring on the Vue.js transitions documentation you'll end up running across this section:
https://vuejs.org/v2/guide/transitions.html#List-Move-Transitions
In this section they show an example called Lazy Sodoku
. Here the demonstrate that a single transition-group
element can handle multidimensional grid transitions. How? All you need to do is organize the grid how you like using css, and make sure you apply the v-move
class to each element in the group.
With these concepts in mind, let's look at the code that makes this happen:
// game.js
<transition-group name="tile" tag="div" class="board">
<tile v-for="tile in board" :tile="tile" :key="tile.id"></tile>
</transition-group>
/* styles.css */
.board {
display: flex;
flex-wrap: wrap;
width: 23em;
padding: 6px;
border-radius: 6px;
background-color: rgba(58, 41, 76, 0.5);
}
.tile-move {
transition: transform .09s ease;
}
It's as simple as that. We create a transition-group
named tile
. Using this, we can define our v-move
class - .tile-move
. This will create the sliding affect. We also define our board
class which is a flex box that will wrap. There are many other ways to get the grid layout to work, but this is a quick and simple example.
With the most important transition under our belt let's look at the other group-transition
on our list, the points increase transition, and the state transition of the score itself. The goal here is that anytime we score points in the game, we want to see our point total start ticking up by one until it reaches the new score, and also display a notice that fades in showing + <point_number>
over our score. Since we just dealt with a group-transition
let's handle that first. Here what we want to do is watch the score. Any time the score changes, we can observe its new and old value, and take the difference of the two to know how many points the player scored. Furthermore, knowing how much the points has increased we can display this number as points scored, and animate the score from its previous total to the new total. So let's look at the code to make this happen.
// game-menu.js
watch: {
score(newValue, oldValue) {
const self = this
if (newValue > 0) {
// must clone deep because when mutating (NOT replacing) an Array,
// the old value will be the same as new value because they reference the same Array
let oldPoints = _.cloneDeep(self.pointsIncrease)
oldPoints.push(newValue - oldValue)
self.pointsIncrease = oldPoints
}
},
pointsIncrease(newPoints, oldPoints) {
if (newPoints.length > oldPoints.length) {
setTimeout(() => {
this.pointsIncrease.pop()
}, 200)
}
}
},
// game-menu.js
// <template>
<transition-group name="points" tag="div" class="points">
<div v-for="(pointIncrease, index) in pointsIncrease" :key="index">+ {{ pointIncrease }}</div>
</transition-group>
/* styles.css */
.points-enter-active, .points-leave-active {
transition: all 100ms;
}
.points-leave-to {
opacity: 0;
transform: translateY(-30px);
}
.points-enter {
transform: translateY(30px);
}
The idea here is that we are keeping a list of points increases. When we observe a points increase, we push that onto the list so that we can render it. This is so we can handle any number of points increases streaming into the game menu. To accomplish this, we are doing two things inside our watched properties. First we are watching the value of score. When it changes, and the new value is a positive integer, we will add the difference onto the list - pointsIncrease
. One important gotcha when watching arrays in Vue, is that you must replace the array, or the watched value will not know what the old value was. This is because the reference in memory to the new and old arrays are the same. So this is why we use the cloneDeep
method to make sure we make a new copy of the array. Next, inside our pointsIncrease
watcher, we detect any time the length of the array increases - and set a timeout to remove whatever was added to the list. This will cause the enter / leave transitions to fire on our list, and make sure we don't leave any point increase notifications around.
Now for the next animation, the score itself. To reiterate, the affect we want to achieve, is to have the score tick up by 1 over some duration, ultimately landing on the final score. This is a fun animation and will give the game a sort of arcade'y feel. The strategy here will be to 'tween' the score from its current value to its desired value, updating and setting the inbe'tween' value along the way. Let's look at the code for this.
// game-menu.js
data() {
return {
animatedScore: 0,
pointsIncrease: [],
}
},
watch: {
score(newValue, oldValue) {
const self = this
if (newValue > 0) {
// must clone deep because when mutating (NOT replacing) an Array,
// the old value will be the same as new value because they reference the same Array
let oldPoints = _.cloneDeep(self.pointsIncrease)
oldPoints.push(newValue - oldValue)
self.pointsIncrease = oldPoints
}
function animate () {
if (TWEEN.update()) {
requestAnimationFrame(animate)
}
}
new TWEEN.Tween({ tweeningNumber: oldValue })
.easing(TWEEN.Easing.Quadratic.Out)
.to({ tweeningNumber: newValue }, 500)
.onUpdate(function () {
self.animatedScore = this.tweeningNumber.toFixed(0)
})
.start()
animate()
},
...
Here we see the same code as before but with the addition of two functions, the animate function, and the Tween function. You can see that the Tween function starts at oldValue
, and tweens to newValue
over the duration of 500ms. And, onUpdate
, or on each tick, we set a data attribute on the game-menu component animatedScore
equal to the tweened number rounded to an integer. That is the meat of the animation!
The last transition on this page is the game over screen. This one is the easiest of all the transitions. It is a conditional rendering of our game-over div. The idea here is that we will pass in the game-state as a prop to the game-menu component, a boolean saying whether or not the game is over. And, we will render the game-over menu whenever that boolean is true.
// game-menu.js
...
// <template>
<transition name="fade">
<div v-if="gameOver" class="modal">
<h1>Game Over!</h1>
<a class="button button-black" @click="newGame()">Try again</a>
</div>
</transition>
...
// <Component>
props: {
gameOver: {
type: Boolean,
required: true,
}
},
...
/* styles.css */
.fade-enter-active {
transition: opacity 2s
}
.fade-leave-active {
transition: opacity .5s
}
.fade-enter, .fade-leave-to {
opacity: 0
}
The last transition we will look at will be the tile pop animation whenever it increases in points. The affect we are looking to achieve is a quick scale up and down of the tile whenver another tile merges with it. We will do this the same way we handled the score animation, using a watched property. Let's see the code.
// tile.js
watch: {
value(newVal, oldVal) {
if (newVal > oldVal) {
setTimeout(() => {
Velocity(this.$el, {scale: 1.2}, {duration: 50, complete: () => {
Velocity(this.$el, {scale: 1}, {duration: 50})
}})
}, 50)
}
}
},
Here we are simply watching the value of any tile, and when the newValue
is greater than the oldValue
we will scale the element up and down by 20% over the course of 100ms.
That's all the animations! Now the game should look great and feel responsive!