Tuesday, 7 April 2020

Web Tutorial: Easter Memory Game (Part 2/3)

This game is all about pairing up. Remember in the reset() method we had 6 templates? Well, for each template there will be 3 pairs. That makes (3 x 2 = 6) cards per template, and since we have 6 templates, that makes (6 x 6 = 36) cards!

After having derived the cards array, create a For loop to iterate through totalTemplates. Also, since we're talking about templates, add the template property to the card object, with a default value of undefined.

And just in case you didn't set the isOpened property to true in the previous part of this tutorial, you should totally do it now.
reset()
{
    this.stopTimer();

    var cards = [];
    var totalTemplates = 6;
    var totalCards = totalTemplates * totalTemplates;

    for(var i = 0; i < totalCards; i++)
    {
        var card =
        {
            id: i,
            template: undefined,
            isOpened: true,
        }

        cards.push(card);
    }

    this.setState({"seconds": 100, "cards": cards});

    this.startTimer();
}

for (var i = 0; i < totalTemplates; i++)
{

}

this.setState({"seconds": 100, "cards": cards});


Within the loop, declare assigned and set it to 0. Then create a While loop that runs as long as assigned is less than totalTemplates.
for (var i = 0; i < totalTemplates; i++)
{
    var assigned = 0;

    while (assigned < totalTemplates)
    {

    }
}


Then declare unassigned. It's an array, returned by running the cards array through the filter() method to only get the elements whose template property is undefined. Here's some info about the filter() method.
for (var i = 0; i < totalTemplates; i++)
{
    var assigned = 0;

    while (assigned < totalTemplates)
    {
        var unassigned = cards.filter((x) => {return x.template == undefined;});
    }
}


Next, declare randomIndex and use a random number function to get any number from 0 to the length of unassigned, minus 1.
for (var i = 0; i < totalTemplates; i++)
{
    var assigned = 0;

    while (assigned < totalTemplates)
    {
        var unassigned = cards.filter((x) => {return x.template == undefined;});
        var randomIndex = Math.floor((Math.random() * (unassigned.length)));
    }
}


And once that's done, we use the id property of the element of unassigned pointed to by randomIndex, to point to the actual element in cards, and set the template property to the current template number! Then increment assigned.

The idea here is to go through every template, numbered 0 to 5, progressively setting the template property of "unassigned" cards until the current template fills up 6 cards. Once each of the 6 templates is assigned to 6 cards, the script exits the For loop. This will not be an infinite loop because the length of unassigned grows shorter by one every time the contents of the While loop is run.
for (var i = 0; i < totalTemplates; i++)
{
    var assigned = 0;

    while (assigned < totalTemplates)
    {
        var unassigned = cards.filter((x) => {return x.template == undefined;});
        var randomIndex = Math.floor((Math.random() * (unassigned.length)));

        cards[unassigned[randomIndex].id].template = i;
        assigned++;
    }
}


Now in the render() method, remember we created cardDisplay? Here, declare template and set it to "template", concatenating the string with the template property. Then redefine style so that it adds template to the class as well as "opened" if isOpen is true. That's what the space was for!
var cardDisplay = this.state.cards.map(
    (item, key) =>
    {
        var template = "template" + item.template;
        var style = "card " + (item.isOpened ? "opened " + template: "closed");

        return (
            <div className="cardContainer" key={key}>
                <div className={style}>                               
                </div>
            </div>
        );
    }
);


And after this, the CSS. Set background properties for opened. And then set the background-image property for CSS classes template0 to template5. We'll use the images shown in the first part of this tutorial.
.opened:before
{
    display: block;
    content: "";
    height: 100%;   
    width: 100%;
    background-size: cover;
    background-position: center center;
    background-repeat: no-repeat;
}

.template0:before
{
    background-image: url(easter0.jpg);
}

.template1:before
{
    background-image: url(easter1.jpg);
}   

.template2:before
{
    background-image: url(easter2.jpg);
}   

.template3:before
{
    background-image: url(easter3.jpg);
}   

.template4:before
{
    background-image: url(easter4.jpg);
}

.template5:before
{
    background-image: url(easter5.jpg);
}


There you go. Each individual template should have 3 pairs, randomly placed all throughout the grid. Remember we set isOpened to true? That's so you can see what's going on.


Clicking the cards!

In the reset() method, set isOpened to false again, and add the flip() method to card. We pass in the event, e, as an argument. In the method, we call the flipCard() method (which we'll write soon), and pass in the id of the card. Remember each card has an id? Well, it's about to be useful.
for(var i = 0; i < totalCards; i++)
{
    var card =
    {
        id: i,
        template: undefined,
        isOpened: false,
        flip: (e) =>
        {
            this.flipCard(e.currentTarget.id);
        }
    }

    cards.push(card);
}


In the render() method, ensure that the onClick attribute is set with the flip() method.
return (
    <div className="cardContainer" key={key}>
        <div className={style} id={item.id} onClick={item.flip}>                               
        </div>
    </div>
);


Now, we're going to define the flipCard() method. It accepts a parameter - an integer which is the id of the card clicked.
constructor(props)
{
    super(props);
    this.state =
    {
        "seconds": 100,
        "cards": []
    };
}

flipCard(cardIndex)
{

}

stopTimer()
{
    clearInterval(this.interval);
    this.interval = undefined;
}


flipCard() does nothing if seconds is 0, because then the game would be over.
flipCard(cardIndex)
{
    if (this.state.seconds > 0)
    {       

    }
}


Then declare cards, and assign the value of the cards array in state, to it. Think of cards in this case as a temporary storage variable. Then define an If block, using the element in the cards array pointed to by cardIndex, and checking if it's opened.
flipCard(cardIndex)
{
    if (this.state.seconds > 0)
    {       
        var cards = this.state.cards;

        if (cards[cardIndex].isOpened)
        {

        }
        else
        {

        }
    }
}


Don't do anything for now if the card is opened. But if it isn't, set the isOpened property to true, then replace the cards array in the state, with this altered cards array. We need to do this because arrays in state aren't mutable the normal way.
flipCard(cardIndex)
{
    if (this.state.seconds > 0)
    {       
        var cards = this.state.cards;

        if (cards[cardIndex].isOpened)
        {

        }
        else
        {
            cards[cardIndex].isOpened = true;
            this.setState({"cards": cards});                                                               
        }
    }
}


Now try clicking on each card. Those that are closed, will be opened to reveal their picture. And those already opened, won't react!


Matching Logic

Time for the next part - determining if the cards are matched. Add the isMatched property to card in the reset() method. The default value is false.
for(var i = 0; i < totalCards; i++)
{
    var card =
    {
        id: i,
        template: undefined,
        isMatched: false,
        isOpened: false,
        flip: (e) =>
        {
            this.flipCard(e.currentTarget.id);
        }
    }

    cards.push(card);
}


Then in the flipCard() method, after setting the isOpened property to true and setting the state, run the matchCards() method, passing in cards as an argument.
flipCard(cardIndex)
{
    if (this.state.seconds > 0)
    {       
        var cards = this.state.cards;

        if (cards[cardIndex].isOpened)
        {

        }
        else
        {
            cards[cardIndex].isOpened = true;
            this.setState({"cards": cards});

            this.matchCards(cards);                                   
        }
    }
}


Here, define the matchCards() method. It will accept the array arr as a parameter.
constructor(props)
{
    super(props);
    this.state =
    {
        "seconds": 100,
        "cards": []
    };
}

matchCards(arr)
{
   
}

flipCard(cardIndex)
{
    if (this.state.seconds > 0)
    {       
        var cards = this.state.cards;

        if (cards[cardIndex].isOpened)
        {

        }
        else
        {
            cards[cardIndex].isOpened = true;
            this.setState({"cards": cards});

            this.matchCards(cards);                                   
        }
    }
}


Declare cards and assign arr as its value. Then define the variable matching. It is an array that will be produced when we run cards through the filter() method, only returning cards that are opened and not matched.

Then, in an If block, check if the there are two elements in the matching array. This would mean that there are two cards that are opened and not matched, and it is these two cards that need to be compared.
matchCards(arr)
{
    var cards = arr;
    var matching = cards.filter((x) => {return x.isOpened && !x.isMatched;});

    if (matching.length == 2)
    {

    }   
}


Compare the template properties of these cards in a nested If block.
matchCards(arr)
{
    var cards = arr;
    var matching = cards.filter((x) => {return x.isOpened && !x.isMatched;});

    if (matching.length == 2)
    {
        if (matching[0].template == matching[1].template)
        {
                                   
        }
        else
        {
                                
        }
    }   
}


If they match, set the isMatched property of these cards to true. Ensure that you use the id property of the matching elements to reference the index of the cards array when you do, because it's the cards in cards that you need to change, not those in matching. Then update cards in state with the newly updated array.
matchCards(arr)
{
    var cards = arr;
    var matching = cards.filter((x) => {return x.isOpened && !x.isMatched;});

    if (matching.length == 2)
    {
        if (matching[0].template == matching[1].template)
        {
            cards[matching[0].id].isMatched = true;
            cards[matching[1].id].isMatched = true;

            this.setState({"cards": cards});                                   
        }
        else
        {
                            
        }
    }   
}


Add an extra condition meanwhile. using the filter() function on cards again, check if any cards are not matched and return the resulting array to the variable notMatched. If there are no cards that aren't matched, stop the timer because the game is over.
matchCards(arr)
{
    var cards = arr;
    var matching = cards.filter((x) => {return x.isOpened && !x.isMatched;});

    if (matching.length == 2)
    {
        if (matching[0].template == matching[1].template)
        {
            cards[matching[0].id].isMatched = true;
            cards[matching[1].id].isMatched = true;

            var notMatched = cards.filter((x) => {return !x.isMatched;});
            if (notMatched.length == 0) this.stopTimer();

            this.setState({"cards": cards});                                   
        }
        else
        {
                            
        }
    }   
}


Naturally, if the two cards do not match, close them by setting isOpened to false, and update state.
matchCards(arr)
{
    var cards = arr;
    var matching = cards.filter((x) => {return x.isOpened && !x.isMatched;});

    if (matching.length == 2)
    {
        if (matching[0].template == matching[1].template)
        {
            cards[matching[0].id].isMatched = true;
            cards[matching[1].id].isMatched = true;

            var notMatched = cards.filter((x) => {return !x.isMatched;});
            if (notMatched.length == 0) this.stopTimer();

            this.setState({"cards": cards});                                   
        }
        else
        {
            cards[matching[0].id].isOpened = false;
            cards[matching[1].id].isOpened = false;
            this.setState({"cards": cards});                                
        }
    }   
}


Next, in the render() method, the cardDisplay component should add an extra CSS class if isOpened and isMatched are true. The CSS class is matched, and that's what we're going to create next.
var cardDisplay = this.state.cards.map(
    (item, key) =>
    {
        var template = "template" + item.template;
        var style = "card " + (item.isOpened ? "opened " + template + (item.isMatched ? " matched" : "") : "closed");

        return (
            <div className="cardContainer" key={key}>
                <div className={style} id={item.id} onClick={item.flip}>                               
                </div>
            </div>
        );
    }
);


This one is simple. We'll just set brightness lower if the cards are matched, to visually differentiate them.
.template5:before
{
    background-image: url(easter5.jpg);
}

.matched:before
{
    filter: brightness(30%);
}   


Oh wow, did you see that? The matched cards are darker.


You may notice that it is devilishly hard to get a matched pair because when you open a second card and it doesn't match, both cards close straight away. This behavior is correct, but it needs to be improved via animation.

Next

Making the interface friendlier via animation, and some cleaning up.

No comments:

Post a Comment