Gra memory

Podstawowe założenia gry

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

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

HTML i CSS

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.

Pokaż HTML

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

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

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

body {
    font-family:sans-serif;
    display: flex;
    align-items: center;
    justify-content: center;
    background-image: linear-gradient(-20deg, #e9defa 0%, #fbfcdb 100%);
    min-height: 100vh;
    margin: 0;
}

.game-board {
    margin:30px auto;
    position: relative;
    overflow: hidden;
    width: 800px;
    height: 600px;
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    grid-template-rows: repeat(4, 1fr);
    border-radius: 5px;
    gap: 10px;
    padding: 1rem;
}

.game-tile {
    cursor: pointer;
    background: #f6da17;
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    border-radius: 5px;
    transition: 0.4s background-color, 0.4s box-shadow;
}

.game-tile:hover {
    background-color:#F7C11A;
    box-shadow: 0 1px 4px rgba(0,0,0,0.2);
}

.game-score {
    position: absolute;
    left: 50%;
    top: 0px;
    width: 80px;
    height: 80px;
    border-radius: 50%;
    transform: translate(-50%, -50%);
    padding: 30px;
    background: tomato;
    color: #fff;
    text-align: center;
    display: flex;
    justify-content: center;
    align-items: flex-end;
    font-size: 2rem;
    font-weight: bold;
    line-height: 1rem;
    text-shadow: 0 1px 2px rgba(0,0,0,0.4);
}

/* -------------------------------------------------- */
button.button,
input[type=button].button,
input[type=submit].button {
    display: block;
    max-width: 20em;
    text-align: center;
    padding: 1.3em 3em;
    font-weight: bold;
    font-size: 1.1rem;
    color: #FFF;
    text-decoration: none;
    margin: 2em auto;
    background: tomato;
    border-radius: 3em;
    line-height: 1.1em;
    text-shadow: none;
    transition: 0.5s background-color, 0.5s box-shadow;
    box-shadow: 0 2px 5px rgba(255, 99, 71, 0.4);
    border:0;
    cursor: pointer;
}

Planszę naszej gry ustawiamy za pomocą grida. Dzięki temu łatwiej będzie nam reagować na mniejsze ekrany.

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
        "element1.png",
        "element2.png",
        "element3.png",
        "element4.png",
        "element5.png",
        "element6.png",
        "element7.png",
        "element8.png",
        "element9.png",
        "element10.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 (paczuszkę pobierzesz tutaj):

Jakiś czas temu wpadła mi w oko też inna kolekcja, która znajduje się pod adresem: https://www.iconarchive.com/show/childrens-book-animals-icons-by-iconarchive.html

metoda startGame()

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

Pierwszym zadaniem, jakie 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 = {
    ...

    startGame() {
        //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 = 0;

        //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 = {
    startGame() {
        //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.innerText = 0;

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

        //generujemy tablicę numerów klockó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.addEventListener("click", e => this.tileClick(e));
        }
    }
}

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ę właściwość dataset.

metoda tileClick()

Po kliknięciu na dany kafelek odpalamy metodę tileClick():


const memoryGame = {
    ...

    tileClick(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);
            }
        }
    },

    startGame() {
        ...
    }
}

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 dataset 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 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

    tileClick(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(), 500);
                } else {
                    setTimeout(() => this.resetTiles(), 500);
                }
            }
        }
    },

    startGame() {
        ...

        //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.

Nasza plansza ustawia elementy za pomocą grida. Gdy usuniemy jakiś kafelek, wszystkie przesuną się o jedno miejsce. Tego nie chcemy, dlatego na miejsce usuniętego kafalka musimy wstawić jakiś element.


const memoryGame = {
    ...

    deleteTiles() {
        this.tilesChecked.forEach(el => {
            const emptyDiv = document.createElement("div");
            el.after(emptyDiv);
            el.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 = {
    ...

    resetTiles() {
        this.tilesChecked.forEach(el => el.style.backgroundImage = "");
        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 = {
    ...

    tileClick(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(), 500);
                } else {
                    setTimeout(() => this.resetTiles(), 500);
                }

                this.moveCount++;
                this.divScore.innerText = 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 kafelków

    deleteTiles() {
        this.tilesChecked.forEach(el => {
            const emptyDiv = document.createElement("div");
            el.after(emptyDiv);
            el.remove();
        });

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

        this.tilePairs++;

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

    ...

}

Przy czym alert() wydaje się najbardziej biednym sposobem pokazywania zakończenia gry. Już lepiej pokazać wcześniej przygotowany element np. korzystając z template, lub też przenieść użytkownika na inną stronę:


...

if (this.tilePairs >= this.tileCount / 2) {
    window.location.href = "https://www.youtube.com/watch?v=zYt0WbDjJ4E";
}

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


const memoryGame = {
    ...

    startGame() {
        //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", () => {
    document.querySelector(".game-start").addEventListener("click", e => {
        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/element1.png",
        "images/element2.png",
        "images/element3.png",
        "images/element4.png",
        "images/element5.png",
        "images/element6.png",
        "images/element7.png",
        "images/element8.png",
        "images/element9.png",
        "images/element10.png"
    ],
    canGet : true, //czy można klikać na kafelki
    tilePairs : 0, //liczba dopasowanych kafelków

    tileClick(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(), 500);
                } else {
                    setTimeout(() => this.resetTiles(), 500);
                }

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

    deleteTiles() {
        this.tilesChecked.forEach(el => {
            const emptyDiv = document.createElement("div");
            el.after(emptyDiv);
            el.remove();
        });

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

        this.tilePairs++;

        if (this.tilePairs >= this.tileCount / 2) {
            alert("Udało ci się odgadnąć wszystkie obrazki");
        }
    },

    resetTiles() {
        this.tilesChecked.forEach(el => el.style.backgroundImage = "");
        this.tilesChecked = [];
        this.canGet = true;
    },

    startGame() {
        //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 = 0;

        //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 klockó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.addEventListener("click", e => this.tileClick(e));
        }
    }
}

document.addEventListener("DOMContentLoaded", () => {
    const startBtn = document.querySelector(".game-start");
    startBtn.addEventListener("click", e => memoryGame.startGame());
});

Demo

Gotowa gra

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.