Ein Memory Spiel mit JavaScript, HTML und CSS programmieren

Browser Game CSS CSS Animation HTML JavaScript

In diesem Tutorial erstellen wir ein einfaches Memory Spiel mit JavaScript, HTML und CSS. Der Grundaufbau und die einzelnen Implementierungen werden in einer Schritt-für-Schritt-Anleitung mit Code-Beispielen entwickelt. Unser Memory-Spiel soll ohne Abhängigkeiten von Drittanbietern wie JavaScript-Bibiotheken oder CSS-Frameworks auskommen. Es soll standalone funktionieren und dabei moderne Webtechnologien benutzen wie zum Beispiel CSS-Grids und JavaScript ES6.

Das Endergebnis unseres Tutorials gibt es hier:

Code Demo

Wie funktioniert ein Memory Spiel?

Bevor wir anfangen irgendetwas zu programmieren müssen wir uns erstmal anschauen wie ein Memory Spiel überhaupt funktioniert. Dabei ist es wichtig die Spielregeln genau zu kennen, denn diese müssen wir später implementieren. Die Spielregeln definieren was die Spieler während des Spiels tun dürfen und was nicht. Sie sind die Grundlage für den Workflow des Spiels. Wir müssen zusammenfassen wann das Spiel beginnt und wann es zu Ende ist und was dazwischen passiert. Diese Grundüberlegung ist vor jeder Implementierung eines Spiels im Prinzip gleich. Egal ob es sich um ein einfaches Memory Spiel handelt oder um komplexere Spiele wie Poker oder Skat. Zuallererst benötigt man ein vollständiges und in sich geschlossenes Set der Spielregeln und des Spielworkflows.

Zum Glück ist das Memory Spiel ein einfaches Spiel. Die Komplexität der Spielregeln und Varianten ist vergleichsweise gering. Das hat auch direkte Auswirkungen auf unsere Implementierung. Denn je einfacher die Spielregeln desto einfacher ist auch die Implementierung.

Spielregeln

Zuerst wird ein Set an Spielkarten definiert. In unserem Beispiel benutzen wir 16 verschiedene Karten. Es gibt immer genau zwei Karten die ein Kartenpaar bilden. Jede Karte ist doppelt vorhanden. Das macht insgesamt 32 Karten auf dem Spieltisch. Alle Karten werden verdeckt und sorgfältig gemischt. Dann werden alle Karten gleichmäßig angeordnet. Idealerweise in einem rechteckigen Muster. Die Karten sind dabei verdeckt, sodass der Spieler nicht sehen kann welche Karte welches Bild enthält. Es wird bestimmt welcher Spieler beginnt. Wenn das geklärt ist, beginnt das Spiel. Ein Spieler dreht eine Karte um und versucht die zweite passende Karte zu finden. Wenn er ein Paar gefunden hat, werden beide Karten vom Spieltisch genommen und dem Spieler gutgeschrieben. Er erhält damit einen Punkt. Er darf danach erneut versuchen ein Kartenpaar zu finden. Wenn der Spieler hingegen kein Kartenpaar findet müssen beide Karten wieder verdeckt werden und der nächste Spieler ist an der Reihe. Das Spiel ist zu Ende wenn alle Kartenpaare auf dem Spieltisch gefunden wurden.

Wichtig ist dass die Spieler den Spielverlauf aufmerksam verfolgen und sich merken unter welchen Karten sich welche Motive verbergen. Der Spieler mit dem besseren Gedächtnis gewinnt in der Regel das Spiel.

Anforderungsliste

Aus den Spielregeln ergeben sich eine Reihe von Anforderungen für unsere Implementierung des Memory-Spiels (in unserem Fall mit JavaScript). Eine Anforderungsliste ist insofern wichtig, dass wir einen Überblick erhalten welche Teile. Im Idealfall lassen sich am Ende einzelne Anforderungen zu einzelnen Funktionen in unser Implementierung zuordnen.

Die Anforderungsliste für unser Memory-Spiel umfasst folgende Punkte:

  • Die Memory-Karten müssen gemischt werden können
  • Memory-Karten werden auf einem Spieltisch gleichmäßig verteilt
  • Es gibt immer genau 2 Karten die gleich sind - das Kartenpaar. Bei 16 Karten gibt es insgesamt 32 Karten auf dem Spieltisch
  • Memory-Karten müssen verdeckt sein, damit der Spieler das Bild nicht direkt sehen kann
  • Ein Spieler kann eine Karte anklicken und diese umdrehen
  • Ein Spieler kann ein passendes Kartenpaar einsammeln
  • Wenn ein Spieler ein Kartenpaar einsammelt, wird es vom Spieltisch entfernt und dem Spielerkonto "gutgeschrieben".
  • Spielkarten die nicht zueinaderpassen müssen wieder verdeckt werden
  • Ein Spieler kann das Spiel gewinnen indem er alle Kartenpaare einsammelt.

Bei einem Spiel wie Memory mag eine Anforderungsliste überflüssig erscheinen, weil die Spielregeln vergleichsweise einfach sind. Bei komplexer werdenden Spielen sind Anforderungslisten (und Use Cases) unersätzlich um eine saubere und übersichtliche Zuführbarkeit der einzelnen Bereiche zur Implementierung in der Programmiersprache sicherzustellen. Aus diesem Grund ist es immer empfehlenswert eine Anforderungliste anzufertigen. Das gilt nicht nur für Spiele sondern für alle Projekte.

Welche Features soll unser Memory Spiel haben?

Wir müssen definieren was das Ziel unseres Memory Spiels sein soll. Vielleicht fühlt sich der Spieler auch an seine Kindheit erinnert. EIne emotionale Komponente. Das Spiel soll visuell ansprechend sein. Das heißt wir wollen keine Mottenkiste bauen sondern eine frische und visuell attraktive Darstellung anbieten. Wir benötigen eine einfaches User Interface auf dem der Spieler sehen kann was passiert. Dieses soll selbsterklärend sein. Dafür greifen wir auf CSS zurück und bestücken unser Memory-Spiel mit einigen einfachen CSS-Animationen und Transitions.

Auf technischer Ebene wollen wir es einfach halten. Das Memory Spiel soll Crossbrowser kompatibel sein und mit aktuellen gängigen Webbrowsern laufen. Es soll ohne externe Plugins und Bibliotheken auskommen. Wir wollen keine Third-Party-Dependencies. Das Spiel soll direkt im Webbrowser lauffähig sein. Außerdem wollen wir im ersten Schritt auf Code-Transpilation ala Gulp und Webpack verzichten.

Neben den technischen Grundlagen benötigen wir noch ein Bilderset für die Memory-Karten. In unserem Beispiel benutze ich Bilder von Früchten und Obst die ich auf Pixabay zusammengesammelt habe. Die Website bietet eine ganze Reihe von Bildern an, die eine öffentliche Lizenz haben. Man kann natürlich auch eine Bilderauswahl aus der eigenen Fotosammlung nehmen oder passende Bilder kaufen.

Grundaufbau des Memory Spiels

Zuerst überlegen wir uns wie der Grundaufbau des Memory Spiels aussehen soll. Die HTML-Struktur definiert den semantischen Aufbau des Spiels. Dabei definieren wir die HTML-Tags des Spiels in einer Baumstruktur. Die visuelle Darstellung der Elemente wird mit CSS definiert. Auch das Grid und einfache Animationen lassen sich damit definieren. Unser JavaScript steuert die Spiellogik und verarbeitet Benutzereingaben wie zum Beispiel Klick-Events.

HTML - Semantische Darstellung (mit HTML-Tags)
CSS - visuelle Darstellung der semantischen Elemente (Größen, Farben, Abstände, Anordnung, usw.)
JavaScript - Interaktivität, User-Input, Spiel-Logik, Veränderung der semantischen Darstellung

Die HTML-Struktur bestimmen

Das HTML versuchen wir zu Beginn möglichst einfach abzubilden. Wir definieren einen div-Container mit einer CSS-Klasse die wir zum Stylen per CSS benutzen werden. Über die ID id="memoryGame" wird das DOM-Objekt des Div-Containers mit JavaScript verbunden. Diese werden im Laufe des Spiels aktualisiert. Der Container memory-game bekommt einen Child-Container memory-board. Dieser enthält die einzelnen Memory-Karten die wir später mit JavaScript dynamisch erzeugen und in den Container einfügen werden. Außerdem benötigen wir noch einen Div-Container memory-ui der unser User Interface enthält. In diese zeigen wir später den Spielfortschritt an. Zum Beispiel wieviele "Moves" der Spieler gemacht hat oder wieviele Kartenpaare der Spieler bisher gesammelt hat.

<div class="memory-game" id="memoryGame">
    <div class="memory-board"></div>
    <div class="memory-ui">
        <span class="memory-moves">Moves: <span id="memoryMoves">0</span></span>
        <span class="memory-matches">Matches: <span id="memoryMatches">0</span>
            <span class="memory-matches-cards" id="memoryMatchesCards"></span>
        </span>
    </div>
</div>

Spieltisch (Board) mit CSS-Grid definieren

Das Element memory-board positioniert sich und seine Grid-Elemente automatisch. Mit display: grid; sagen wir dem Browser, dass er das Element als CSS-Grid rendern soll. Die CSS-Eigenschaft grid-template-columns definiert das Verhalten der Grids. Genauergesagt beschreibt sie das Verhalten der einzelnen Spalten innerhalb des Grid-Containers und wie die einzelnen Elemente in das Grid "hineinfließen" sollen. CSS bietet die repeat()-Funktion an die wir nutzen können um die Spalteneigenschaften festzulegen ohne die Eigenschaften für jede Spalte einzeln angeben zu müssen. Andernfalls müssten wir hier 8 Spalteneigenschaften angeben.

Das Schlüsselwort auto-fit legt fest, das die Elemente immer die maximal zur Verfügung stehende Breite nutzen sollen egal wie breit der Bildschirm ist. Das ist wichtig, damit der Container ein korrektes responsives Verhalten hat. Außerdem sorgt es dafür, dass die Grid-Elemente automatisch umbrechen und in die nächste Zeile rutschen, sobald der zur Verfügung stehende Platz von 12,5% überschritten wird. Der Prozentwert 12.5% setzt sich aus der Anzahl der Karten zusammen die wir horizontal darstellen wollen. Wir wollen 8 Karten haben, das ergibt 100/8 = 12,5% in der Breite. Bei 4 Karten müssten man den Prozentsatz entsprechend anders setzen. Von diesem Prozentsatz müssen wir dann noch den Wert für den Innenabstand abziehen calc(12.5% - 15px). Da wir mit Prozent statt Pixelwerten arbeiten, sollte die Kratenelemente browserübergreifend und geräteübergreifend stets gleich sein.

Im Wortlaut liest sich das Ganze so: Setze eine Spalte mit einer Breite von 12,5% des verfügbaren Platzes, ziehe davon 15px für den Innenabstand ab und wiederhole diese Definition so lange bis die Gesamtbreite 100% erreicht. Wenn das passiert beginne mit der Anordnung in der nächsten Zeile.

grid-gap definiert die Innenabstand der einzelnen Zellen im Grid. Hier nutzen wir einen Pixelwert, damit der Abstand nach oben/unten und links/rechts immer gleich ist. Wir könnten hier auch einen Prozentwert angeben, allerdings haben einige Browser scheinbar damit ein Problem denn die Höhe des Grid-Containers wird dann nicht mehr richtig berechnet. Das kann dann dazu führen, dass die nachfolgenden Elemente, die nach dem Grid-Container erscheinen, falsch dargestellet werden. Mehr zum Thema CSS-Grids findest du übrigens hier: https://css-tricks.com/snippets/css/complete-guide-grid/

Codebeispiel mit CSS-Grid:


.memory-board {
    width: 100%;
    margin: 0;
    padding: 0;
    position: relative;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(calc(12.5% - 15px), 1fr));
    grid-gap: 15px;
}

Benutzeroberfläche (User Interface)

Das User Interface definieren wir ebenfalls mit CSS. Allerdings nutzen wir hierbei Flexbox display: flex; Da wir nur zwei Elemente darstellen möchten die sich links und rechts voneinander positionieren sollen, reicht Flexbox mehr als aus.


.memory-ui {
    height: 50px;
    margin: 3em 0;
    padding: 10px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-size: 20px;
    background-color: rgba(255,255,255,0.65);
}
.memory-ui .memory-moves {
    display: flex;
}
.memory-ui .memory-matches {
    display: flex;
    align-items: center;
}

Die Memorykarten anzeigen

Was jetzt noch fehlt sind die einzelnen Karten auf dem Board. Auch hier können wir uns wieder zuerst überlegen wie das Markup einer einzelnen Spielkarte und das CSS dazu aussehen soll. Erst im zweiten Schritt ergänzen wir dann Interaktivität und dynamisieren das Ganze.

Wir definieren die Karte per Div-Container und setzen eine Klasse die wir im CSS nutzen können. Wir benötigen für jede Karte einen Zustand in dem die Spielkarte verdeckt und aufgedeckt ist. Wenn ein Spieler auf eine Spielkarte klickt, muss sich diese umdrehen und das Bild dahinter anzeigen. Damit das funktioneren kann definieren wir zwei Zustände in Form zweier Div-Container memory-card-item-front und memory-card-item-back.



<div class="memory-card-item" data-card="1">
    <div class="memory-card-item-inner">
        <div class="memory-card-item-front"></div>
        <div class="memory-card-item-back">
            <img src="img/01.jpg" />
        </div>
    </div>
</div>

Aber hey! Wollen wir tatsächlich alle 32 Karten "per Hand" auf dem Spielfeld einbauen? Wie können wir die Spielkarten dann zufällig sortieren? Wie können wir eine Spielkarte vom Board später entfernen, wenn ein Spieler ein Kartenpaar einsammelt? Und was passiert, wenn wir am Markup später einmal etwas anpassen wollen? Wir wollen das Markup einer Kachel nur einmal definieren und nicht 32x kopieren. Wir wollen möglichst "DRY" bleiben (don't repeat yourself). Damit das gelingt müssen wir die Memory-Karten dynamisch auf das Memory-Board positionieren. Das geht nur mit JavaScript.

Der Memory Spielbereich mit den einzelnen Karten und dem Interface darunter sieht am Ende so aus:

Der Memory Spielbereich mit den einzelnen Karten

Spiellogik mit JavaScript implementieren

Jetzt kommt der eigentlich interessante Teil - die Spiellogik. Hier definieren wir die Interaktivität und das Event-Handling, Das Matching der Memory-Karten und wann das Spiel zu Ende ist. Im Prinzip bilden wir hier den Spielverlauf technisch ab.

JavaScript ES6 Klasse erstellen

Zuerst erstellen wir eine JavaScript-Klasse und definieren welche Variablen wir benötigen um den Spielfortschritt zu tracken. Das ganze kann dann so aussehen:


class MemoryGame {

    constructor(opts) {
        this.node = opts.selector;

        // duplicate the array entries once the game starts
        this.cards = opts.cards.concat(opts.cards);

        // how many moves the player has made?
        this.cardMoves = 0;

        // how many cards have been collected so far?
        this.cardsCollected = 0;

        // how many cards match?
        this.cardsMatch = 0;

        this.board = this.node.querySelector('.memory-board');
        this.memoryMoves = this.node.querySelector('#memoryMoves');
        this.memoryMatches = this.node.querySelector('#memoryMatches');

        this.startGame();
    }

    startGame() {
        // game logic goes here
    }
}

An das Array opts.cards wird das gleiche Array nochmal drangehangen. Damit haben wir aus 16 Karten genau 32 gemacht. this.cards = opts.cards.concat(opts.cards);

Für die Karten benutzen wir die JavaScript-Klasse memory-cards.js Hier können wir festlegen wieviele Elemente es geben soll amount: 16; und wie die Daten-Struktur der einzelnen Karten aussehen soll. Wir benutzen hierfür id und img für den Pfad zum Bild. Die Klasse implementiert die Funktion getCards() mit der die einzelnen Bilder im Array-Objekt cards = [] zurückgegeben werden. Damit haben wir alle Karten in einem Array gesammelt und können dieses später in der Memory-Klasse benutzen.


// file memory-cards.js
export default {

    // the directory or path of the images
    dir: '/img/fruits/',

    // the number of available images
    amount: 16,

    getCards() {
        let cards = [];
        for (let i=1; i<=this.amount; i++) {
            cards.push({
                id: i,
                img: `${this.dir}${i<10?'0':''}${i}.jpg`
            });
        }
        return cards;
    }
}

Wenn wir beide Klassen definiert haben können wir eine Instanz der MemoryGame-Klasse bilden. Diese bekommt das DOM-Objekt und die Spielkarten als Parameter übergeben.


import MemoryCards from './js/memory-cards.js';
let memoryGame = new MemoryGame({
    selector: document.getElementById('memoryGame'),
    cards: MemoryCards.getCards()
});

Karten-Elemente dynamisch rendern

Wir nehmen dafür das HTML-Snippet aus Abschnit 3.4) und setzen es in die Funktion renderCard(card). Das generierte HTML wird dann per innerHTML in das Memory-Board eingefügt - fertig. Damit ist es auf der Website sichtbar.



class MemoryGame {
    
    // ...
  
    /**
     * render the cards and put them to the board element
     */
    render() {
        this.board.innerHTML = "";
        this.cards.forEach((card, i) => {
            this.board.innerHTML += this.renderCard(card, i);
        });
    }

    /**
     * Define the html for a card item
     * @param card - the card object
     * @returns {string} - html of the card item
     */
    renderCard(card) {
        return `
            <div class="memory-card-item" data-card="${card.id}">
                <div class="memory-card-item-inner">
                    <div class="memory-card-item-front"></div>
                    <div class="memory-card-item-back">
                        <img src="../${card.img}" />
                    </div>
                </div>
            </div>
        `;
    } 
    
    // ...
    
}

Klick-Handler definieren

Wir sammeln alle gerenderten Kartenelemente aus dem DOM ein und registrieren für jedes Element einen ClickHandler. Mit der Funktion this.cardClicked(e); wird das Klick-Event verarbeitet. Dieser KlickHandler implementiert die eigentliche Logik des Spiels.


const cardElements = this.node.querySelectorAll('.memory-card-item');
cardElements.forEach(cardElement => {
    cardElement.addEventListener('click', (e) => {
        e.preventDefault()
        // prevent double click
        if (!e.detail || e.detail === 1) {
            this.cardClicked(e);
        }
    });
});

Wie prüft man ob zwei Karten zusammenpassen?

Jedes Mal wenn der Spieler eine Spielkarte anklickt wird das Array this.clickedCards mit dem Objekt der angeklickten Spielkarte befüllt. Wenn er ein zweites Element anklickt, wird es ebenfalls in this.clickedCards hinzugefügt. Immer wenn genau zwei Elemente vorhanden sind, wird mit matchCards(a, b) geprüft, ob diese zusammenpassen. Das ist denkbar einfach. Wir vergleichen im Prinzip lediglich ob beide Elemente innerhalb von this.clickedCards gleich sind. Falls ja, hat der Spieler ein Pärchen gefunden. Falls nicht wird this.clickedCards zurückgesetzt und alle Elemente wieder verdeckt.


/**
 * Check if two cards are the same
 * @param a - card a
 * @param b - card b
 * @returns {boolean} - match or not
 */
matchCards(a, b) {
    return a === b;
}

Für die visuelle Darstellung der Kartenelemente benutzen wir eine CSS-Klasse. Wenn ein Spieler auf eine Karte klickt, wird dem Element die CSS-Klasse .visible hinzugefügt. Im CSS definieren wir den Container dann um 180° zu drehen.

clickedCard.classList.toggle('visible');

.memory-card-item.visible .memory-card-item-inner {
    transform: rotateY(180deg);
}

Ein Modal-Fenster bei Spielende anzeigen

Wenn ein Spieler alle Spielkarten gefunden hat, ist das Spiel beendet. Ein Modalfenster zeigt dem Spieler eine Zusammenfassung an. Zum Beispiel wie viele Moves der Spieler gebraucht hat oder wie viele Moves pro Match gebraucht wurden. Um zu prüfen ob das Spiel zu Ende ist, können wir ein Array-Vergleich machen. Wir vergleichen die Länge des Arrays aller Karten this.cards.length mit der Anzahl der eingesammelten Karten this.cardsCollected. Wenn die Werte genau gleich sind, dann müssen alle Karten vom Spieltisch gesammelt worden sein und das Spiel ist beendet.


class MemoryGame {
    constructor() {
        ...
        this.modal = this.node.querySelector('.modal');
        ...
    }
    checkGameEnd() {
        if (this.cards.length === this.cardsCollected) {
            this.openModal();
        }
    }
    openModal() {
        this.modal.classList.add('modal-show');
    }
}

Für das Modal fügen wir einen Div-Container in das Markup ein. Wir nutzen dafür den Code den wir in dem Tutorial zum Thema /var/www/web17/html/tommykrueger.com/app/views/posts/16.php on line 406
">
Warning: Attempt to read property "title" on bool in /var/www/web17/html/tommykrueger.com/app/views/posts/16.php on line 406
erstellt haben und setzen den Inhalt in den Container mit der Klasse modal-body ein. Das Modal lässt sich bequem programmatisch öffnen indem wir dem Modal-Container die Klasse modal-show per JavaScript zuweisen.



<div class="modal modal-fadeIn" id="modal-finish">
    <div class="modal-stage">
        <div class="modal-body">
            <h1>All Matches found!</h1>
            <p><strong>Well Done!</strong> You have found all matches.</p>
            <button class="playBtn">Play Again</button>
        </div>
    </div>
</div>

Das Spiel neustarten

Damit der Spieler nicht am Spielende in einer Sackgasse ist, soll er die Möglichkeit bekommen das Memory-Spiel neuzustarten. Dafür integrieren wir einen Start-Button in das Modal-Fenster mit einem Klick-Handler:

<button class="playBtn">Play Again</button>

Das Klick-Event für den Button ergänzen wir in der MemoryGame-Klasse im JavaScript:


class MemoryGame {
    constructor() {
        this.playBtn = this.node.querySelector('.playBtn');
        this.playBtn.addEventListener('click', (e) => {
            this.closeModal();
            this.startGame();
        });
    }

    ...

    closeModal() {
        this.modal.classList.remove('modal-show');
    }
}

Weitere Ideen und Features

Unser Spiel erfüllt die Grundlagen eines Memory Spiels. So weit so gut. Aber wie schön wäre es das Spiel mit weiteren Features zu verbessern? Hier eine Liste mit möglichen Features:

Timer und Countdowns: Einen Countdown integrieren der zum Beispiel 3 Minuten bis 0:00 zählt. Wenn die Zeit abgelaufen ist, ist das Spiel vorbei.
Anzahl der Moves begrenzen: Ein Spieler hat eine begrenzte Zahl an Moves zur Verfügung um das Memory zu lösen. In diesem Fall könnte der Spieler das Memory-Spiel sogar verlieren.
Multiplayer-Funktion: Bisher kann das Memory nur eine einzelne Person spielen. Spannend wäre es wenn mehrere Spieler gegeneinander antreten könnten. Dazu bräuchte man eine Spiel-Lobbyin der sich Spieler zu einem Spiel verabreden können, bzw. eine eigene Spielinstanz starten könnten.
Computer-Gegner: Ein Spiel gegen den Computer-Gegner? Warum nicht! Dazu müsste man eine einfache Spieler-KI implementieren bzw. einen Agenten der Memory spielen kann und eine Spielstrategie hat.
Bestenliste: Eine Bestenliste kann Spielern helfen sich mit anderen Spielern zu vergleichen und ihren Erfolg zu messen.
Mehrsprachigkeit: Das Spiel nicht nur auf Deutsch sondern auch auf English anbieten. Hierzu bräuchte man ggf. eine Sprachauswahl.
Chat-Funktion: Eine Multiplayer-Funktion könnte auch eine Chat-Funktion vertragen oder? Visuelle Darstellung verbessern: Bessere Grafiken und Animationen können die Spielqualität erhöhen.
Sound und Töne: Die Integration von Sound kann das Spiel sogar noch krasser machen.


Hast du weitere Ideen?
Schreibe gerne eine E-Mail mit deinen Vorschlägen an memorygame@tommykrueger.com

Quellen

# Bildnachweis: Obst und Früchte-Bilder allesamt von pixabay.com (Alle Bilder standen zum Zeitpunkt des Abrufs unter freier Lizenz)
# Bildnachweis: "Wood Background" Bild von unsplash.com (Photo by Lukas Blazek on Unsplash)



Mehr lesen



Artikel teilen