Gra memory

W tym rozdziale zajmiemy się stworzeniem prostej gry "memory".
Gra taka polega na odkrywaniu par obrazków.

Zobacz DEMO naszej gry

Podstawowe założenia gry

Tradycyjnie rozpiszmy zasadę działania naszej gry:

  1. Domyślnie plansza gry jest nieaktywna. Kliknięcie na start aktywuje planszę.
  2. Plansza składa się z x na y kafelków
  3. Kliknięcie na kafelek odsłania go.
  4. Jeżeli drugi kliknięty kafelek jest taki sam jak pierwszy, wtedy oba tiles zostają usunięte z planszy.
  5. Jeżeli drugi kliknięty kafelek jest różny od pierwszego, wtedy oba tiles są zasłaniane i zwiększany jest licznik prób.
  6. Gra kończy się z chwilą usunięcia wszystkich kafelków z planszy

Szkielet aplikacji

Szkielet aplikacji będzie bardzo prosty. Sprowadzać się będzie tylko do planszy, punktacji i przycisku startującego grę. Reszta to czysta dynamika napisana w JavaScript.


<div class="game">
    <div class="game-board">
    </div>

    <div class="game-score">
    </div>

    <button class="game-start">Rozpocznij grę</button>
</div>

Aby widzieć rezultaty nadajmy naszym elementom przykładowe style:


.game-board {
    margin:30px auto;
    position: relative;
    background: #fff;
    overflow: hidden;
    width: 550px;
    height: 444px;
    border: 2px solid #ddd;
}

.game-tile {
    position: absolute;
    cursor: pointer;
    width: 100px;
    height: 100px;
    background: #f6da17 url(images/title.png) center center no-repeat;
}

.game-tile:hover {
    background-color:#F7C11A;
}

.game-score {
    text-align:center;
}

Piszemy silnik gry

Naszą grę stworzymy jako pojedynczy obiekt. Zaczniemy od stworzenia podstawowych zmiennych określających planszę i wygląd poszczególnych klocków.


const memoryGame = {
    tileCount : 20, //liczba klocków
    tileOnRow : 5, //liczba klocków na rząd
    divBoard : null, //div z planszą gry
    divScore : null, //div z wynikiem gry
    tiles : [], //tutaj trafi wymieszana tablica klocków
    tilesChecked : [], //zaznaczone klocki
    moveCount : 0, //liczba ruchów
    tilesImg : [ //grafiki dla klocków
        'title_1.png',
        'title_2.png',
        'title_3.png',
        'title_4.png',
        'title_5.png',
        'title_6.png',
        'title_7.png',
        'title_8.png',
        'title_9.png',
        'title_10.png'
    ]
}

W tablicy tiles będziemy przechowywać "typ" poszczególnych kafelków. Typy te będą występowały parami i będą określać po prostu numer obrazka dla danego kafelka.

W zmiennej tilesImg przechowujemy grafiki dla poszczególnych klocków. Przykładowe obrazki kafelków znajdziesz poniżej:

metoda startGame()

Metodą jaką napiszemy na początku będzie startGame().

Pierwszym zadaniem jaki musi zrobić nasza startująca funkcja to czyszczenie wszystkich zmiennych (nasza gra może rozpoczynać się kilka razy) i stworzenie pomieszanej tablicy par numerów dla kafelków:


const memoryGame = {
    ...

    this.startGame = function() {
        //czyścimy planszę
        this.divBoard = document.querySelector('.game-board');
        this.divBoard.innerHTML = '';

        //czyścimy planszę z ruchami
        this.divScore = document.querySelector('.game-score');
        this.divScore.innerHTML = '';

        //czyścimy zmienne (bo gra może się zacząć ponownie)
        this.tiles = [];
        this.tilesChecked = [];
        this.moveCount = 0;

        //generujemy tablicę numerów kocków (parami)
        for (let i=0; i<tileCount; i++) {
            this.tiles.push(Math.floor(i/2));
        }

        //i ją mieszamy
        for (let i=this.tileCount-1; i>0; i--) {
            const swap = Math.floor(Math.random()*i);
            const tmp = this.tiles[i];
            this.tiles[i] = this.tiles[swap];
            this.tiles[swap] = tmp;
        }
    }
}

Po stworzeniu tablicy z numerami kafelków, możemy na jej podstawie wstawić tiles na planszę:


const memoryGame = {
    this.startGame = function() {
        //czyścimy planszę
        this.divBoard = document.querySelector('.game-board');
        this.divBoard.innerHTML = '';

        //czyścimy planszę z ruchami
        this.divScore = document.querySelector('.game-score');
        this.divScore.innerHTML = '';

        //czyścimy zmienne (bo gra może się zacząć ponownie)
        this.tiles = [];
        this.tilesChecked = [];
        this.moveCount = 0;

        //generujemy tablicę numerów kocków (parami)
        for (let i=0; i<tileCount; i++) {
            this.tiles.push(Math.floor(i/2));
        }

        //i ją mieszamy
        for (let i=this.tileCount-1; i>0; i--) {
            const swap = Math.floor(Math.random()*i);
            const tmp = this.tiles[i];
            this.tiles[i] = this.tiles[swap];
            this.tiles[swap] = tmp;
        }

        for (let i=0; i<this.tileCount; i++) {
            const tile = document.createElement('div');
            tile.classList.add("game-tile");
            this.divBoard.appendChild(tile);

            tile.dataset.cardType = this.tiles[i];
            tile.dataset.index = i;

            tile.style.left = 5 + (tile.offsetWidth+10) * (i%this.tileOnRow) + 'px'
            tile.style.top = 5 + (tile.offsetHeight+10) * (Math.floor(i/this.tileOnRow)) + 'px';

            tile.addEventListener('click', this.tileClick.bind(this));
        }
    }
}

Po kliknięciu na dany kafelek będziemy pobierać numer który został jemu przypisany. Numer taki musimy więc gdzieś przechować. Idealnie do tego celu nadaje się metoda dataset.

Nasze tiles pozycjonujemy absolutnie wykorzystując do tego prostą pętlę. Nie możemy dla nich zastosować float ponieważ po usunięciu z planszy kafelków, reszta by się przemieszczała na ich miejsce.

Po kliknięciu na dany kafelek odpalamy metodę tileClick(), którą zaraz napiszemy. Żeby mieć w tej metodzie dostęp do obiektu gry, musimy jej wskazać, że this ma nie wskazywać na kliknięty kafelek, a na nasz obiekt. Używamy do tego metody bind().

metoda tileClick()

Napiszmy teraz kod metody tileClick():


const memoryGame = {
    ...

    tileClick : function(e) {
        //jeżeli jeszcze nie pobraliśmy 1 elementu
        //lub jeżeli index tego elementu nie istnieje w pobranych...
        if (!this.tilesChecked[0] || (this.tilesChecked[0].dataset.index !== e.target.dataset.index)) {
            this.tilesChecked.push(e.target);
            e.target.style.backgroundImage = 'url(' + this.tilesImg[e.target.dataset.cardType] + ')';
        }

        if (this.tilesChecked.length === 2) {
            if (this.tilesChecked[0].dataset.cardType === this.tilesChecked[1].dataset.cardType) {
                setTimeout(this.deleteTiles.bind(this), 500);
            } else {
                setTimeout(this.resetTiles.bind(this), 500);
            }
        }
    },

    this.startGame = function() {
        ...
    }
}

Na samym początku sprawdzamy, czy dany kafelek nie jest już wcześniej kliknięty (czyli czy nie jest wrzucony do tablicy tilesChecked).
Jeżeli nie jest, a index klikniętego elementu nie występuje już w tablicy (nasza tablica będzie mogła zawierać maksymalnie 2 elementy, więc wystarczy sprawdzić jej 1 element), wtedy możemy go do niej dodać (zapobiegnie to sytuacji, gdy 2x klikniemy ten sam element).

Dodatkowo przy dodawaniu elementu do tablicy ustawiamy mu odpowiednie tło, którego numer pobieramy z "data" danego kafelka.

Następuje wybranie 2 kafelka. Jeżeli "cardType" obydwu elementów tablicy tilesChecked jest taki sam, znaczy to, że para kafelków została dopasowana. Odpalamy więc funkcję deleteTiles(). Jeżeli cardType jest różne, znaczy to że tiles są różne, więc musimy je ukryć na nowo. Służy do tego metoda resetTiles(). Obie metody musimy przypiąć za pomocą metody bind(), ponieważ domyślnie setTimeout zmienia kontekst this.
Obie metody odpalamy z opóźnieniem 500ms, tak by wybrane tiles nie ukrywały się od razu:

W postaci takiej jak powyższa nasz skrypt nie będzie działał prawidłowo. Wyobraź sobie sytuację: gracz wybiera 2 tiles, zostaje odpalona z opóźnieniem funkcja deleteTiles() lub resetTiles(). Jednak w tym samym czasie gracz może spokojnie klikać resztę kafelków i nasze działania zostają zakłócone.
Aby to naprawić dodamy do naszego skryptu dodatkową zmienną canGet, która wyłączy możliwość klikania na czas usuwania lub resetowania kafelków:


const memoryGame = {
    ...
    canGet : true, //czy można klikać na kafelki

    this.tileClick = function(element) {
        if (this.canGet) {
            //jeżeli jeszcze nie pobraliśmy 1 elementu
            //lub jeżeli index tego elementu nie istnieje w pobranych...
            if (!this.tilesChecked[0] || (this.tilesChecked[0].dataset.index !== e.target.dataset.index)) {
                this.tilesChecked.push(e.target);
                e.target.style.backgroundImage = 'url(' + this.tilesImg[e.target.dataset.cardType] + ')';
            }

            if (this.tilesChecked.length === 2) {
                this.canGet = false;

                if (this.tilesChecked[0].dataset.cardType === this.tilesChecked[1].dataset.cardType) {
                    setTimeout(this.deleteTiles.bind(this), 500);
                } else {
                    setTimeout(this.resetTiles.bind(this), 500);
                }
            }
        }
    };

    this.startGame = function() {
        ...

        //czyścimy zmienne (bo gra może się zacząć ponownie)
        this.tiles = [];
        this.tilesChecked = [];
        this.canGet = true;

        ...
    };
}

metoda deleteTiles()

Jak widać powyżej, jeżeli oba pobrane tiles mają taki sam "cartType", wtedy odpalamy funkcję deleteTiles.
Kafelki do usunięcia znajdują się teraz w tablicy tilesChecked. Wystarczy je więc usunąć za pomocą metody remove(), po czym na nowo ustawić tablicę tilesChecked. Po usunięciu kafelków włączamy na nowo możliwość pobrania nowej pary:


const memoryGame = {
    ...

    deleteTiles : function() {
        this.tilesChecked[0].remove();
        this.tilesChecked[1].remove();

        this.canGet = true;
        this.tilesChecked = [];
    },

    ...
}

metoda resetTiles()

Funkcja resetująca tiles do stanu zakrytego jest jeszcze prostsza. Oba tiles wracają po prostu do stanu początkowego, po czym tak samo jak powyżej ustawiamy na nowo tablicę tilesChecked i zmienną canGet.


const memoryGame = {
    ...

    this.resetTiles = function() {
        this.tilesChecked[0].style.backgroundImage = 'url(images/title.png)';
        this.tilesChecked[1].style.backgroundImage = 'url(images/title.png)';

        this.tilesChecked = [];
        this.canGet = true;
    },

    ...
}

Pokazywanie liczby ruchów

Ostatnimi rzeczami którymi się zajmiemy to pokazywanie graczowi liczby ruchów które wykonał oraz zakończenie gry.

Liczenie ruchów to nic trudnego. Przy każdym kliknięciu na kafelek po prostu zwiększamy zmienną moveCount, a wynik wypisujemy w elemencie divScore:


const memoryGame = {
    ...

    this.tileClick = function(element) {
        if (this.canGet) {
            //jeżeli jeszcze nie pobraliśmy 1 elementu
            //lub jeżeli index tego elementu nie istnieje w pobranych...
            if (!this.tilesChecked[0] || (this.tilesChecked[0].dataset.index !== e.target.dataset.index)) {
                this.tilesChecked.push(e.target);
                e.target.style.backgroundImage = 'url(' + this.tilesImg[e.target.dataset.cardType] + ')';
            }

            if (this.tilesChecked.length === 2) {
                this.canGet = false;

                if (this.tilesChecked[0].dataset.cardType === this.tilesChecked[1].dataset.cardType) {
                    setTimeout(this.deleteTiles.bind(this), 500);
                } else {
                    setTimeout(this.resetTiles.bind(this), 500);
                }

                this.moveCount++;
                this.divScore.innerHTML = this.moveCount;
            }
        }
    },

    ...
}

Koniec gry

Pozostało nam do wykonania sprawdzenie czy użytkownik nie odkrył wszystkich kafelków i tym samym nie zakończył gry. Wystarczy wprowadzić dodatkową zmienną, w której będziemy przechowywać liczbę odkrytych par. Jeżeli liczba ta będzie większa lub równa połowie wszystkich kafelków znaczy to, że gra została zakończona.
Oczywiście sprawdzenie takie wykonujemy tylko przy usunięciu kafelków z planszy:


const memoryGame = {
    ...
    tilePairs : 0, //liczba dopasowanych kafelkow

    this.deleteTiles = function() {
        this.tilesChecked[0].remove();
        this.tilesChecked[1].remove();

        this.canGet = true;
        this.tilesChecked = [];

        this.tilePairs++;
        if (this.tilePairs >= this.tileCount / 2) {
            alert('gameOver!');
        }
    },

    ...

}

Tak jak poprzednio, nie zapomnijmy wyzerować naszej zmiennej przy starcie gry


const memoryGame = {
    ...

    startGame : function() {
        //czyścimy planszę
        this.divBoard = document.querySelector('.game-board');
        this.divBoard.innerHTML = '';

        //czyścimy planszę z ruchami
        this.divScore = document.querySelector('.game-score');
        this.divScore.innerHTML = '';

        //czyścimy zmienne (bo gra może się zacząć ponownie)
        this.tiles = [];
        this.tilesChecked = [];
        this.moveCount = 0;
        this.canGet = true;
        this.tilePairs = 0;

        ...
    }
}

Naszą pracę kończymy podpięciem pod przycisk rozpoczęcia gry:


document.addEventListener('DOMContentLoaded', function() {
    document.querySelector('.game-start').addEventListener('click', function() {
        memoryGame.startGame();
    });
});

Cały kod

Cały kod naszej aplikacji wygląda teraz tak:


const memoryGame = {
    tileCount : 20, //liczba klocków
    tileOnRow : 5, //liczba klocków na rząd
    divBoard : null, //div z planszą gry
    divScore : null, //div z wynikiem gry
    tiles : [], //tutaj trafi wymieszana tablica klocków
    tilesChecked : [], //zaznaczone klocki
    moveCount : 0, //liczba ruchów
    tilesImg : [ //grafiki dla klocków
        'images/title_1.png',
        'images/title_2.png',
        'images/title_3.png',
        'images/title_4.png',
        'images/title_5.png',
        'images/title_6.png',
        'images/title_7.png',
        'images/title_8.png',
        'images/title_9.png',
        'images/title_10.png'
    ],
    canGet : true, //czy można klikać na kafelki
    tilePairs : 0, //liczba dopasowanych kafelkow

    tileClick : function(e) {
        if (this.canGet) {
            //jeżeli jeszcze nie pobraliśmy 1 elementu
            //lub jeżeli index tego elementu nie istnieje w pobranych...
            if (!this.tilesChecked[0] || (this.tilesChecked[0].dataset.index !== e.target.dataset.index)) {
                this.tilesChecked.push(e.target);
                e.target.style.backgroundImage = 'url(' + this.tilesImg[e.target.dataset.cardType] + ')';
            }

            if (this.tilesChecked.length === 2) {
                this.canGet = false;

                if (this.tilesChecked[0].dataset.cardType === this.tilesChecked[1].dataset.cardType) {
                    setTimeout(this.deleteTiles.bind(this), 500);
                } else {
                    setTimeout(this.resetTiles.bind(this), 500);
                }

                this.moveCount++;
                this.divScore.innerHTML = this.moveCount;
            }
        }
    },

    deleteTiles : function() {
        this.tilesChecked[0].remove();
        this.tilesChecked[1].remove();

        this.canGet = true;
        this.tilesChecked = [];

        this.tilePairs++;
        if (this.tilePairs >= this.tileCount / 2) {
            alert('gameOver!');
        }
    },

    resetTiles : function() {
        this.tilesChecked[0].style.backgroundImage = 'url(images/title.png)';
        this.tilesChecked[1].style.backgroundImage = 'url(images/title.png)';

        this.tilesChecked = [];
        this.canGet = true;
    },

    startGame : function() {
        //czyścimy planszę
        this.divBoard = document.querySelector('.game-board');
        this.divBoard.innerHTML = '';

        //czyścimy planszę z ruchami
        this.divScore = document.querySelector('.game-score');
        this.divScore.innerHTML = '';

        //czyścimy zmienne (bo gra może się zacząć ponownie)
        this.tiles = [];
        this.tilesChecked = [];
        this.moveCount = 0;
        this.canGet = true;
        this.tilePairs = 0;

        //generujemy tablicę numerów kocków (parami)
        for (let i=0; i<this.tileCount; i++) {
            this.tiles.push(Math.floor(i/2));
        }

        //i ją mieszamy
        for (let i=this.tileCount-1; i>0; i--) {
            const swap = Math.floor(Math.random()*i);
            const tmp = this.tiles[i];
            this.tiles[i] = this.tiles[swap];
            this.tiles[swap] = tmp;
        }

        for (let i=0; i<this.tileCount; i++) {
            const tile = document.createElement('div');
            tile.classList.add("game-tile");
            this.divBoard.appendChild(tile);

            tile.dataset.cardType = this.tiles[i];
            tile.dataset.index = i;

            tile.style.left = 5 + (tile.offsetWidth+10) * (i%this.tileOnRow) + 'px'
            tile.style.top = 5 + (tile.offsetHeight+10) * (Math.floor(i/this.tileOnRow)) + 'px';

            tile.addEventListener('click', this.tileClick.bind(this));
        }
    }
}

document.addEventListener('DOMContentLoaded', function() {
    document.querySelector('.game-start').addEventListener('click', function() {
        memoryGame.startGame();
    });
});

Gotową grę możesz zobaczyć tutaj

Paczkę w grą możesz pobrać tutaj