🃏 1: Build an Accessible Memory Game
Project Goal | Build A Memory Game with accessibility in mind! |
---|---|
What you’ll learn | How to build a memory game while applying accessibility principles. You can play with the finished project |
Tools you’ll need | A modern browser like Chrome. An account at CodeSandbox.io. If you get lost, import the starting point for this chapter here. Instructions on how to do this are in Appendix 1. Or you can go to the Memory Game Code Sandbox. |
Time needed to complete | 4 hours |
What You'll Build
Instructions
If you need to restart your project, clone this repo. To start this project, sign into your Code Sandbox account and fork this Memory Game Code Sandbox. You'll work in Code Sandbox to build out a memory game.
Getting Started
Add a Component
Let's start by adding a set of instructions to the game by editing Instructions.vue
inside the views folder. We want to create instructions for our users so they can access them via a link.
Let's first create a section for the rules. Edit views/Instructions.vue
by adding this text under the h2
's closing tag:
<section>
<h3>Rules</h3>
<p>This is a memory game. The cards are shuffled and laid face down. The point of the game is to find all the matches; there are a total of 16 cards to match.</p>
<ul class="list-instruct">
<li>rules...</li>
</ul>
</section>
2
3
4
5
6
7
Next create some data to loop inside the <li>
to build the instructions. Edit the data
block in the Instructions.vue
file to add the instructions
array inside the curly brackets:
data() {
return {
instructions: [
"Click on a card to flip it over.",
"If the two cards match, they will remain flipped.",
"If they don't match, they will be turned over.",
"Remember what was on each card and where it was.",
"The game is over when all the cards have been matched.",
"Every flipped card counts as a move."
]
};
}
2
3
4
5
6
7
8
9
10
11
12
Now, add a v-for
to the <li>
in views/Instructions.vue
to display the array of data. We can use the v-for directive to render a list of items based on an array. Learn about v-for
.
<ul class="list-instruct">
<li v-for="(instruction, index) in instructions" :key="index">{{instruction}}</li>
</ul>
2
3
You should see the rules listed. Let's do the same for the Score section. Add this <section>
block after the closing </section>
tag that lists the instructions:
<section>
<h3>Scoring</h3>
<ul class="list-instruct">
<li v-for="(score, index) in scores" :key="index">
<p class="star-category">{{score.value}} Stars</p>
<span>{{score.description}}</span>
</li>
</ul>
</section>
2
3
4
5
6
7
8
9
Now create the data for the scores by adding this array after the closing bracket of the instructions array in the <script>
block. Don't forget to add a comma after the close of the instructions array!
scores: [
{ value: 3, description: "30 moves or less" },
{ value: 2, description: "40 moves or less" },
{ value: 1, description: "50 moves or less" }
]
2
3
4
5
Finally add styles at the bottom of the document, after the closing </script>
tag. Feel free to update any of these styles!
<style lang='scss'>
.main-instruction {
width: 80vw;
max-width: 600px;
margin: auto;
}
.list-instruct {
list-style: none;
padding: 0;
}
.star-category {
font-weight: bold;
margin-bottom: 0;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Setting Up the Board
Controllers
Let's build out our game's home page!
We can start by building the section above the Memory game; the reset button, stars and number of moves. Switch to /views/Home.vue
and overwrite the content in <div class="home">
in the template:
<div class="home">
<main class="container">
<section>
<button class="restart buttonGray">
<i class="fa fa-repeat"></i>
<span class="reset">Reset</span>
</button>
<div>
<ul class="stars">
<li>
<i class="fa fa-star"></i>
</li>
</ul>
<p class="moves">Moves: 0</p>
</div>
</section>
</main>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
💡
We are importing font-awesome
in the index.html
file inside the public
folder. So we have access to all the icons from there. Check out the font-awesome icons!
Add styles to the bottom of this file:
<style lang="scss">
.gameController .stars {
padding: 0px;
display: inline-block;
margin: 2em auto 0;
}
.star {
list-style: none;
display: inline-block;
margin: 0 0.2em;
font-size: 1.5em;
}
.moves {
font-weight: bold;
}
.gameController .restart {
float: right;
cursor: pointer;
}
.container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-top: 1em;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Where's the data? We will be using data stored in a Vuex store.
💡
Vuex is a state management pattern and library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion. Learn more about Vuex
Inside the store
folder, open the index.js
file. This holds all the content of our store which we will use to build our game. Let's start by adding some data that we can use to make Game Controller section dynamic!
Add some data to the state by overwriting the state
object in /store/index.js
:
state: {
stars: 3,
numMoves: 0
},
2
3
4
Go back to Home.vue
and bring that data over by importing the store state and adding the state data you want to access in a computed property. Overwrite the entire <script>
block in /views/Home.vue
:
<script>
import { mapState } from "vuex";
export default {
name: "home",
computed: {
...mapState([
"stars",
"numMoves",
])
}
};
</script>
2
3
4
5
6
7
8
9
10
11
12
13
This will allow us to use the data from the store as you would use data from that component. Update the stars
and numMoves
in our content to reflect the data in our Vuex store by overwriting the <div>
under the closing </button>
tag in /views/Home.vue
:
<div>
<ul class="stars">
<li v-for="(star, index) in stars" :key="index" class="star">
<i :class="`${index} fa fa-star`"></i>
</li>
</ul>
<p class="moves">Moves: {{numMoves}}</p>
</div>
2
3
4
5
6
7
8
By using a v-for
for the stars, we can loop through the number of stars stored in our Vuex state. As people play the game and lose stars, it will automatically show the correct number. We will add that logic once the game is further along.
Game Board
It's time to start building out the game's interface!
Choose a couple of icons you'd like to use for this project (or use the ones listed below). You need a total of 8 icons; making 16 cards on the board. Font-awesome has a lot of icons to choose from!
Once you select the icons, add them to the state in the store index.js
file as types
by adding a comma after numMoves: 0
and adding this array:
types: ["car", "bug", "paw", "bomb", "gamepad", "diamond", "heart", "bell"]
Inside Home.vue
, add types
to the computed properties to import:
...mapState(["stars", "numMoves", "types"])
Let's display them in a new section which will hold a list of our cards. Add this section under the closing </section>
tag in /views/Home.vue
:
<section id="cards">
<ul class="cards">
<li v-for="(type, index) in types" :key="index">{{type}}</li>
</ul>
</section>
2
3
4
5
We need to double the number of cards to make the card layout work. We also need to capture some metadata from each card:
- Name
- Icon
- Is the card flipped?
- Was this card a match?
- Should we close the card?
Now we can start working on the game logic!
Game Logic
Let's start by creating a getter that grabs the types
and generates card metadata.
Add a Getter to the store and import it in /views/Home.vue
:
Store:
getters: {
deck: (state) => {
let deck = {
cards: []
};
for (let index = 0; index < state.types.length; index++) {
deck.cards.push({
name: state.types[index],
icon: "fa fa-" + state.types[index],
flipped: false,
match: false,
close: false
});
deck.cards.push({
name: state.types[index],
icon: "fa fa-" + state.types[index],
flipped: false,
match: false,
close: false
});
}
return deck;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Home.vue:
// Import mapGetters
import { mapState, mapGetters, mapActions } from "vuex";
// Add Getters to computed
computed: {
...mapState([
"gameAnnounce",
"win",
"stars",
"cardsFlipped",
"numCardsFlipped",
"numMoves",
"cardsMatched",
"types"
]),
...mapGetters(["deck"])
},
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
And update the display content by overwriting the card ul
in /views/Home.vue
:
<ul class="cards">
<li v-for="(card, index) in deck.cards" :key="index">{{card}}</li>
</ul>
2
3
You can see, that every card now has all this information displayed as a JSON object (we will fix the styles in a minute!):
- name
- icon
- flipped
- match
- close
Let's use this data to populate a hidden image for each card unless it is flipped. We can use v-if
to check if the card is flipped; let's show a question mark if it is. Otherwise, let's show the card's icon. To do this, we'll use conditional rendering.
In /views/Home.vue
, overwrite the current <ul class="cards">
with this markup:
<ul class="cards">
<li class="cardItem" v-for="(card, index) in deck.cards" :key="index">
{{card.name}} <!-- placeholder to show what is inside each card -->
<button
:class="[ card.match ? 'card match' : card.flipped ? 'card show' : card.close ? 'card close' : 'card']"
>
<span v-if="!card.flipped">?</span>
<div v-else :class="deck.cards[index].icon"></div>
</button>
</li>
</ul>
2
3
4
5
6
7
8
9
10
11
It's pretty ugly, so add some styles to the cards inside /views/Home.vue
's <style>
block:
// Cards
.cards {
margin: 2em auto;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 0.5em;
padding: 0;
.cardItem {
list-style: none;
}
.card {
height: 90px;
width: 90px;
font-size: 4em;
background: #061018 url(/img/fabric.5959b418.png);
background-blend-mode: soft-light;
border: 1px solid #acacac;
color: #ffffff;
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 5px 2px 20px 0 rgba(46, 61, 73, 0.5);
}
.show {
font-size: 33px;
background: #0b5891 url(/img/fabric.5959b418.png);
cursor: default;
}
.match {
cursor: default;
background: #0e4b5a url(/img/fabric.5959b418.png);
font-size: 33px;
animation-name: match-animation;
-webkit-animation-name: match-animation;
animation-duration: 1000ms;
-webkit-animation-duration: 1000ms;
transform-origin: 70% 70%;
animation-iteration-count: 1000ms;
animation-timing-function: linear;
}
.close {
cursor: default;
animation-name: close;
-webkit-animation-name: close;
animation-duration: 1000ms;
-webkit-animation-duration: 1000ms;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
&:hover,
&:focus {
background-blend-mode: hard-light;
color: #112c3e;
border: 2px solid #112c3e;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
Looking better!
In App.vue
, add more card styles so a nice contrasting background image shows up. Note, you might need to refresh your Code Sandbox to see these new styles.
.cards {
.card {
background: #061018 url("imgs/fabric.png");
}
.show {
background: #0b5891 url("imgs/fabric.png");
}
.match {
background: #0e4b5a url("imgs/fabric.png");
}
}
2
3
4
5
6
7
8
9
10
11
12
Alright! We have our hidden cards! You can see that we have duplicates of each, but they should really be shuffled so they are not in the same order every time. Let's make that happen!
💡
If you are stuck at this point, feel free to start from here
In Home.vue
, create a shuffle method which will go through all of the cards and change the order. Also, let's trigger that method on the created
lifecycle hook.
Add a new methods
object in the <script>
block of /views/Home.vue
under name: "home",
:
methods: {
shuffle(cards) {
this.deck.cards = [];
var currentIndex = cards.length,
temporaryValue,
randomIndex;
// While there remain elements to shuffle...
while (0 !== currentIndex) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
temporaryValue = cards[currentIndex];
cards[currentIndex] = cards[randomIndex];
cards[randomIndex] = temporaryValue;
}
this.deck.cards = cards;
}
},
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Then, call this new shuffle
method in a new lifecycle hook by adding a created()
method under the methods()
object you just pasted in:
created() {
this.shuffle(this.deck.cards);
},
2
3
You should see the order of the cards change as you refresh the page.
Adding Functionality
Now we can add a method that will allow users to flip cards over. Users also increase one move every time they flip over a card.
Let's start in Vuex. Add this flipping action by adding an Action and a Mutation to your Vuex store. Actions in Vuex respond to things happening and Mutations change state. In store/index.js
, add a new method to the actions
object by pasting the following between the curly brackets:
update_NumMoves({ commit }, { moves }) {
commit("UPDATE_NUMMOVES", moves);
},
2
3
Then, add a Mutation in a similar fashion:
UPDATE_NUMMOVES(state, payload) {
state.numMoves = payload;
},
2
3
Moving over to views/Home.vue
, we need to import mapActions
from Vuex. Working in /views/Home.vue
, overwrite the import line in the <script>
block:
import { mapState, mapActions } from "vuex";
Next, import update_NumMoves
from the store by adding the following line under methods: {
...mapActions([
"update_NumMoves",
]),
2
3
Now create a method to flip cards by adding this method under the line you just pasted into the methods
object:
flipCard(card) {
if (card.flipped) {
return;
} else {
this.update_NumMoves({ moves: this.numMoves + 1 });
card.flipped = true;
}
},
2
3
4
5
6
7
8
Now let's make those cards flip when you click them (@click
). Edit the card button markup in /views/Home.vue
by overwriting it:
<button
:class="[ card.match ? 'card match' : card.flipped ? 'card show' : card.close ? 'card close' : 'card']"
@click="flipCard(card)"
>
<span v-if="!card.flipped">?</span>
<div v-else :class="deck.cards[index].icon"></div>
</button>
2
3
4
5
6
7
You should see all your cards "flipping" over as you click on them. Notice that the CSS we added earlier is changing the styles as the class changes dynamically.
Let's make sure users can only open 2 cards at once. We will need to keep track of which and how many cards are open on the board.
Let's go to the Vuex store in /store/index.js
to add a new value to keep track of which cards are open: cardsFlipped
. Another to see how many cards are open: numCardsFlipped
. Add these values into the state
:
cardsFlipped: [],
numCardsFlipped: 0,
2
Now, we need a way to set the number of cards flipped; add new flipped cards, and clear cards that are flipped to start a new game.
Add some new mutations to that part of your Vuex store in /store/index.js
(add commas after mutations if needed):
CLEAR_CARDSFLIPPED(state, payload) {
state.cardsFlipped = payload;
},
UPDATE_CARDSFLIPPED(state, payload) {
state.cardsFlipped.push(payload);
},
UPDATE_NUMCARDSFLIPPED(state, payload) {
state.numCardsFlipped = payload;
},
2
3
4
5
6
7
8
9
Add some new actions to that area of store/index.js
:
clear_CardsFlipped({ commit }, { cards }) {
commit("CLEAR_CARDSFLIPPED", cards);
},
update_CardsFlipped({ commit }, { cards }) {
commit("UPDATE_CARDSFLIPPED", cards);
},
update_NumCardsFlipped({ commit }, { num }) {
commit("UPDATE_NUMCARDSFLIPPED", num);
},
2
3
4
5
6
7
8
9
Now add these new methods to the mapActions
array in /views/Home.vue
:
...mapActions([
"update_NumMoves",
"clear_CardsFlipped",
"update_CardsFlipped",
"update_NumCardsFlipped"
]),
2
3
4
5
6
Add these methods to your flipCard()
method:
flipCard(card) {
if (card.flipped) {
return;
} else {
this.update_NumMoves({ moves: this.numMoves + 1 });
}
// only allow flips if there are < or = 2 flipped cards
if (this.numCardsFlipped < 2) {
card.flipped = true;
this.update_NumCardsFlipped({ num: this.numCardsFlipped + 1 });
this.update_CardsFlipped({ cards: card });
}
}
2
3
4
5
6
7
8
9
10
11
12
13
At this point, you are keeping track of the card flips.
💡
Remember that each card has these properties:
- name
- icon
- flipped
- match
- close
We want to keep track of all matches to know when we win the game. So let's keep track of which cards have been matched in the store.
In store/index.js
add this array to state:
cardsMatched: [],
Add to mutations:
CLEAR_CARDSMATCHED(state, payload) {
state.cardsMatched = payload;
},
UPDATE_CARDSMATCHED(state, payload) {
state.cardsMatched.push(payload);
}
2
3
4
5
6
Add to actions:
clear_CardsMatched({ commit }, { cards }) {
commit("CLEAR_CARDSMATCHED", cards);
},
update_CardsMatched({ commit }, { cards }) {
commit("UPDATE_CARDSMATCHED", cards);
}
2
3
4
5
6
Going back to /store/Home.vue
, change the match
to true when flipped cards are the same. Edit the mapState
and mapActions
methods:
Import State
...mapState([
"stars",
"numMoves",
"types",
"cardsFlipped",
"numCardsFlipped",
"cardsMatched"
]),
2
3
4
5
6
7
8
Import Action
...mapActions([
"update_NumMoves",
"clear_CardsFlipped",
"update_CardsFlipped",
"update_NumCardsFlipped",
"clear_CardsMatched",
"update_CardsMatched"
]),
2
3
4
5
6
7
8
We will handle this new functionality inside our flipCard()
method. Under the line this.update_CardsFlipped({ cards: card });
, add the following check:
// MATCH
if (
this.numCardsFlipped === 2 &&
this.cardsFlipped[0].name === this.cardsFlipped[1].name
) {
for (let i = 0; i < this.deck.cards.length; i++) {
if (this.deck.cards[i].name === this.cardsFlipped[0].name) {
this.deck.cards[i].match = true;
}
}
this.update_CardsMatched({ cards: this.cardsFlipped });
this.clear_CardsFlipped({ cards: [] });
this.update_NumCardsFlipped({ num: 0 });
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
You should be able to match cards and see them stay on the board permanently. However, we are not handling what happens when flipped cards don't match, let's do that next!
Continuing the flipCard()
method, add this check after the closing bracket of the if statement you just added:
// NO MATCH
else if (
this.numCardsFlipped === 2 &&
this.cardsFlipped[0].name !== this.cardsFlipped[1].name
) {
// Wait before closing mismatched card
setTimeout(() => {
for (let i = 0; i < this.deck.cards.length; i++) {
if (this.deck.cards[i].flipped && !this.deck.cards[i].match) {
this.deck.cards[i].flipped = false;
this.deck.cards[i].close = true;
}
}
this.clear_CardsFlipped({ cards: [] });
this.update_NumCardsFlipped({ num: 0 });
return;
}, 500);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
You should be able to open all the cards. If they don't match, they will close when you continue playing. Matched cards will remain open.
Scoring
We still need to handle what happens when the game is won. And how do we want to handle the score?
In our store, let's add a way to keep track of winning the game and of how many stars we have left as we play.
In store/index.js
, add to State:
win: false,
Add to Mutations:
UPDATE_WIN(state, payload) {
state.win = payload;
},
UPDATE_STARS(state, payload) {
state.stars = payload;
},
2
3
4
5
6
Add to Actions:
update_Win({ commit }, { win }) {
commit("UPDATE_WIN", win);
},
update_Stars({ commit, dispatch }, { num }) {
commit("UPDATE_STARS", num);
},
2
3
4
5
6
Now that those are available, let's import them in /views/Home.vue
:
// In computed
...mapState([
"stars",
"numMoves",
"types",
"cardsFlipped",
"numCardsFlipped",
"cardsMatched",
"win"
]),
// In methods
...mapActions([
"update_NumMoves",
"clear_CardsFlipped",
"update_CardsFlipped",
"update_NumCardsFlipped",
"clear_CardsMatched",
"update_CardsMatched",
"update_Stars",
"update_Win"
]),
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Use these inside the flipCard()
method after we increase the number of moves. Overwrite the top if
statement:
if (card.flipped) {
return;
} else {
this.update_NumMoves({ moves: this.numMoves + 1 });
if (this.numMoves < 30) {
this.update_Stars({ num: 3 });
} else if (this.numMoves < 40) {
this.update_Stars({ num: 2 });
} else if (this.numMoves < 50) {
this.update_Stars({ num: 1 });
} else if (this.numMoves > 50) {
this.update_Stars({ num: 0 });
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
Let's add some logic when we find a match. Overwrite the // MATCH
logic:
// MATCH
if (
this.numCardsFlipped === 2 &&
this.cardsFlipped[0].name === this.cardsFlipped[1].name
) {
for (let i = 0; i < this.deck.cards.length; i++) {
if (this.deck.cards[i].name === this.cardsFlipped[0].name) {
this.deck.cards[i].match = true;
}
}
this.update_CardsMatched({ cards: this.cardsFlipped });
this.clear_CardsFlipped({ cards: [] });
this.update_NumCardsFlipped({ num: 0 });
// if number of cards matched = number or cards, then win the game
if (this.cardsMatched.length === this.deck.cards.length / 2) {
this.update_Win({ win: true });
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Now you should see the stars updating as you play.
- 3 stars = 30 moves or less
- 2 stars = 40 moves or less
- 1 star = 50 moves or less
Reset
Now, let's make sure we can start a new game and reset all of the game data when we press the reset button. Let's update our store first (/store/index.js
):
Add to Actions:
async clearGame({ commit, dispatch }) {
try {
await dispatch("update_Win", { win: false });
await dispatch("update_Stars", { num: 3 });
await dispatch("clear_CardsFlipped", { cards: [] });
await dispatch("update_NumCardsFlipped", { num: 0 });
await dispatch("update_NumMoves", { moves: 0 });
await dispatch("clear_CardsMatched", { cards: [] });
await dispatch("update_GameAnnounce", { message: "" });
} catch (error) {
commit("ERROR", error);
}
},
2
3
4
5
6
7
8
9
10
11
12
13
In /views/Home.vue
, add clearGame
to your mapActions:
...mapActions([
"update_NumMoves",
"clear_CardsFlipped",
"update_CardsFlipped",
"update_NumCardsFlipped",
"clear_CardsMatched",
"update_CardsMatched",
"update_Stars",
"update_Win",
"clearGame"
]),
2
3
4
5
6
7
8
9
10
11
Now add a newGame()
method inside /views/Home.vue
:
newGame() {
this.shuffle(this.deck.cards);
for (let i = 0; i < this.deck.cards.length; i++) {
this.deck.cards[i].flipped = false;
this.deck.cards[i].close = false;
this.deck.cards[i].match = false;
}
this.clearGame();
},
2
3
4
5
6
7
8
9
10
11
Add a newGame
to the reset button's @click
handler:
<button @click="newGame" class="restart buttonGray">
<i class="fa fa-repeat"></i>
<span class="reset">Reset</span>
</button>
2
3
4
Almost done!
Winning!
Let's add a congratulatory message when the game is finished. We will need the pre-built Winning
component. Let's import it in views/Home.vue
so we can use it a a child component:
// inside the script block
import Winning from "@/components/Winning.vue";
// under export default
components: {
Winning
},
2
3
4
5
6
Let's show it once the game is won and hide the board. Add this snippet at the top of the views/Home.vue
component, overwriting the current <main>
line:
<Winning v-if="win" :newGame="newGame"></Winning>
<main v-else class="container">
2
Note: We are passing
newGame
as a prop from the child to the parent to be able to restart the game while the board is hidden
Inside /components/Winning.vue
, add a congratulatory message and a button to restart the game by overwriting its current template markup:
<template>
<div class="win">
<div>
<h2>Congratulations!</h2>
<ul class="stars">
<li v-for="(star, index) in stars" :key="index">
<i :class="`${index} fa fa-star`"></i>
</li>
</ul>
<p>You won the game with {{stars}} stars left!</p>
<button class="buttonGray" @click="newGame()">Play again</button>
</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
Inside the script area in components/Winning.vue
, bring over the newGame
and stars
by overwriting its current script:
<script>
import { mapState } from "vuex";
export default {
name: "Winning",
props: {
newGame: { type: Function }
},
computed: {
...mapState(["stars"])
}
};
</script>
2
3
4
5
6
7
8
9
10
11
12
13
Don't forget to remove the card.name that shows the name of the card when you're ready to truly play the game!
Add UI finishing touches
In Home.vue
add the following styles:
.buttonGray {
background: #2e3d49;
font-size: 1em;
color: #ffffff;
border-radius: 8px;
cursor: pointer;
justify-content: center;
align-items: center;
padding: 0.5em;
box-shadow: 5px 2px 20px 0 rgba(46, 61, 73, 0.5);
&:hover,
&:focus {
background: #0b5891;
}
}
.reset {
padding-left: 1em;
}
@media (min-width: 450px) {
.cards {
grid-gap: 1em;
.card {
height: 125px;
width: 125px;
}
}
}
@media (min-width: 600px) {
.cards {
grid-template-columns: repeat(4, 1fr);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
💡
If you are stuck, feel free to start from here
Challenge
Let's add Assistive Technology support! You can announce route changes and inform screen readers about important actions. Here are some tasks you can tackle:
Update Metadata (titles)
In the router:
const routes = [
{
path: '/',
name: 'Home',
meta: {
title: 'Memory Game - Home Page',
metaTags: [
{
name: 'description',
content: 'This is the home page for the accessible Memory Game using Vue.js.'
}
]
},
component: Home
},
{
path: '/Instructions',
name: 'Instructions',
meta: {
title: 'Memory Game - Instructions Page',
metaTags: [
{
name: 'description',
content: 'This is the instructions page for the accessible Memory Game using Vue.js.'
}
]
},
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/Instructions.vue')
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
router.beforeEach((to, from, next) => {
const newMetaTitle = to.matched.slice().reverse().find(r => r.meta && r.meta.title);
if (newMetaTitle) document.title = newMetaTitle.meta.title;
next();
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Doing this will provide a summary of a web page. Search engines display the meta description in search results.
Announce Route Update
In the store, add the following to:
State:
routeAnnouncement: '',
Mutations:
UPDATE_ROUTE_ANNOUNCEMENT(state, payload) {
state.routeAnnouncement = payload
},
2
3
Actions:
update_routeAnnouncement({ commit }, { message }) {
commit('UPDATE_ROUTE_ANNOUNCEMENT', message)
},
2
3
In App.vue, lets bring in the announcement with role="status"
.
The aria live region role of status has an implicit aria-live value of polite, which allows a user to be notified via AT (such as a screen reader) when status messages are added. Learn about role="status".
<p role="status">{{routeAnnouncement}}</p>
methods: {
...mapActions(["update_routeAnnouncement"]),
announceRoute(message) {
this.update_routeAnnouncement(message);
}
}
2
3
4
5
6
In App.vue, lets keep track of when the route changes, and change the route announcement, as well as change the aria-current
to active link. Learn about aria-current.
watch: {
$route: function() {
this.announceRoute({ message: this.$route.name + " page loaded" });
this.$nextTick(function() {
let navLinks = this.$refs.nav
navLinks.querySelectorAll("[aria-current]")
.forEach(current => {
current.removeAttribute("aria-current");
});
navLinks.querySelectorAll(".router-link-exact-active")
.forEach(current => {
current.setAttribute("aria-current", "page");
});
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
By doing this, you are giving all users the ability to become aware of new content on the page and handling the focus accordingly.
Add Skip to Main Content link
Adding a Skip to Main Content link will allow users to focus on this when the new route is loaded. They can choose to navigate the site from the beginning or skip to main content.
Lets add this to the header in App.vue:
<ul class="skip-links">
<li>
<a href="#main" ref="skipLink">Skip to main content</a>
</li>
</ul>
2
3
4
5
Add the style for this: notice that this link will remain hidden unless users tab to it or it is focused on reroute (we will add this next)
/* Skip to Main */
.skip-links {
margin: 0;
list-style: none;
padding: 0;
position: absolute;
white-space: nowrap;
a {
background: #0e4b5a url(/img/fabric.5959b418.png);
background-blend-mode: color-burn;
display: block;
opacity: 0;
font-size: 1em;
font-weight: bold;
&:focus {
opacity: 1;
padding: 1em;
}
&:visited {
color: white;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
To add focus to this on route change, add the following to the watch inside App.vue:
this.$refs["skipLink"].focus();
Add id="main"
along with aria-label inside the <main>
tags in Home.vue and Instructions.vue:
<!-- Inside Home.vue -->
<main class="container" v-else id="main" tabindex="-1" aria-labelledby="gameTitle">
<h2 id="gameTitle">Game Board</h2>
<!-- Inside Home.vue -->
<main class="main-instruction" id="main" tabindex="-1" aria-labelledby="instructionsTitle">
<h2 id="instructionsTitle">Instructions</h2>
2
3
4
5
6
7
This will allow screen readers to pick up the content of the id as the accessible name for <main>
.
Announce gameplay
Lets make sure that we are letting users know what is happening in the game as they play. They need to know the content of the cards they flip, whether they got a match or not, how many moves and stars they have left, how many matches are left in the game.
Try to do this one yourself without looking ahead. Only look at the code if you get stuck. Feel free to ask questions!!
Lets make some changes inside Home.vue. We will be adding aria-labels
, aria-describedby
, gameAnnounce, and update the announcement in the gameplay:
<template>
<div class="home" aria-label="Memory Game Board">
<p role="status">{{gameAnnounce}}</p>
<Winning v-if="win" :newGame="newGame" :winningMessage="winningMessage"></Winning>
<main class="container" v-else id="main" tabindex="-1" aria-labelledby="gameTitle">
<h2 id="gameTitle">Game Board</h2>
<section aria-label="Memory Game Controller" class="gameController">
<button @click="newGame" class="restart buttonGray">
<i class="fa fa-repeat"></i>
<span class="reset">Reset</span>
</button>
<div>
<ul class="stars" :aria-label="stars + ' stars left'">
<li v-for="(star, index) in stars" :key="index" class="star">
<i :class="`${index} fa fa-star`"></i>
</li>
</ul>
<p class="moves">Moves: {{numMoves}}</p>
</div>
</section>
<section aria-label="Memory Game Board" id="cards">
<p id="gameUpdate">{{gameUpdate}}</p>
<ul class="cards">
<li
v-for="(card, index) in this.deck.cards"
:key="index"
:aria-label="[ card.flipped ? card.name : '']"
class="cardItem"
>
<button
aria-describedby="gameUpdate"
:aria-label="[ card.flipped ? card.name + ' flipped' : 'card ' + (index+1)]"
:class="[ card.match ? 'card match' : card.flipped ? 'card show' : card.close ? 'card close' : 'card']"
@click="flipCard(card)"
:disabled="card.match"
>
<span v-if="!card.flipped">?</span>
<div v-else :class="deck.cards[index].icon"></div>
</button>
</li>
</ul>
</section>
</main>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Update the script
as well:
<script>
import Winning from "@/components/Winning.vue";
import { mapState, mapGetters, mapActions } from "vuex";
export default {
name: "Home",
components: {
Winning
},
computed: {
...mapState([
"gameAnnounce",
"win",
"stars",
"cardsFlipped",
"numCardsFlipped",
"numMoves",
"cardsMatched",
"types"
]),
...mapGetters(["gameUpdate", "deck", "winningMessage"])
},
created() {
this.shuffle(this.deck.cards);
},
methods: {
...mapActions([
"clearGame",
"updateDeck",
"update_Win",
"update_Stars",
"clear_CardsFlipped",
"update_CardsFlipped",
"update_NumCardsFlipped",
"update_NumMoves",
"clear_CardsMatched",
"update_CardsMatched",
"update_GameAnnounce"
]),
shuffle(cards) {
this.deck.cards = [];
var currentIndex = cards.length,
temporaryValue,
randomIndex;
// While there remain elements to shuffle...
while (0 !== currentIndex) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
temporaryValue = cards[currentIndex];
cards[currentIndex] = cards[randomIndex];
cards[randomIndex] = temporaryValue;
}
this.deck.cards = cards;
},
newGame() {
this.shuffle(this.deck.cards);
for (let i = 0; i < this.deck.cards.length; i++) {
this.deck.cards[i].flipped = false;
this.deck.cards[i].close = false;
this.deck.cards[i].match = false;
}
this.clearGame();
},
flipCard(card) {
this.update_GameAnnounce({ message: "" });
if (card.flipped) {
this.update_GameAnnounce({
message: "Card already flipped."
});
return;
} else {
this.update_NumMoves({ moves: this.numMoves + 1 });
if (this.numMoves < 30) {
this.update_Stars({ num: 3 });
} else if (this.numMoves < 40) {
this.update_Stars({ num: 2 });
} else if (this.numMoves < 50) {
this.update_Stars({ num: 1 });
} else if (this.numMoves > 50) {
this.update_Stars({ num: 0 });
}
}
// only allow flips if there are < or = 2 flipped cards
if (this.numCardsFlipped < 2) {
if (this.numCardsFlipped < 1) {
this.update_GameAnnounce({
message: card.name + " flipped."
});
}
card.flipped = true;
this.update_NumCardsFlipped({ num: this.numCardsFlipped + 1 });
this.update_CardsFlipped({ cards: card });
// MATCH
if (
this.numCardsFlipped === 2 &&
this.cardsFlipped[0].name == this.cardsFlipped[1].name
) {
let matchesRemaining =
this.deck.cards.length / 2 - this.cardsMatched.length - 1;
for (let i = 0; i < this.deck.cards.length; i++) {
if (this.deck.cards[i].name == this.cardsFlipped[0].name) {
this.deck.cards[i].match = true;
}
this.update_GameAnnounce({
message:
card.name +
" flipped. Match found! " +
matchesRemaining +
" matches left!"
});
}
this.update_CardsMatched({ cards: this.cardsFlipped });
this.clear_CardsFlipped({ cards: [] });
this.update_NumCardsFlipped({ num: 0 });
//if number of cards matched = number or cards, then win the game
if (this.cardsMatched.length === this.deck.cards.length / 2) {
this.update_Win({ win: true });
this.update_GameAnnounce({
message: this.winningMessage
});
}
}
// NO MATCH
else if (
this.numCardsFlipped === 2 &&
this.cardsFlipped[0].name !== this.cardsFlipped[1].name
) {
// Wait before closing mismatched card
this.update_GameAnnounce({
message: card.name + " flipped. No match."
});
setTimeout(() => {
for (let i = 0; i < this.deck.cards.length; i++) {
if (this.deck.cards[i].flipped && !this.deck.cards[i].match) {
this.deck.cards[i].flipped = false;
this.deck.cards[i].close = true;
}
}
this.clear_CardsFlipped({ cards: [] });
this.update_NumCardsFlipped({ num: 0 });
return;
}, 900);
}
}
}
}
};
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
Update Winning to focus
When players win the game, we want to make sure the focus moves into the new content.
Lets create a directive to auto-focus an element when it comes into view.
Inside main.js:
Vue.directive('focus', {
inserted: function (el) {
el.focus()
}
})
2
3
4
5
We will use this in Winning.vue:
<template>
<div class="win">
<div>
<h2 id="congratulations" aria-labelledby="congratulations winningMsg" v-focus tabindex="-1">Congratulations!</h2>
<ul class="stars" :aria-label="stars + ' stars left'">
<li v-for="(star, index) in stars" :key="index">
<i :class="`${index} fa fa-star`"></i>
</li>
</ul>
<p id="winningMsg">{{winningMessage}}</p>
<button class="buttonGray" @click="newGame()">Play again</button>
</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
And here is what the script should look like:
<script>
import { mapState } from "vuex";
export default {
name: "Winning",
props: {
newGame: { type: Function },
winningMessage: {type: String}
},
computed: {
...mapState([
"stars"
])
}
};
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Your goal? Get a 100% Lighthouse score!
Resources
Project Checkpoint 1 - Added Cards
Project Checkpoint 2 - Added Functionality
Author
Made with ❤️ by Maria Lamardo and edited by Jen Looper