Programmieren lernen mit Spaß: Tic-Tac-Toe mit JavaScript, HTML und CSS

Lerne, wie du mit HTML, CSS und JavaScript ein einfaches Tic-Tac-Toe-Spiel erstellst. Dieses Tutorial führt dich Schritt für Schritt durch die Spiellogik, das Event-Handling mit JavaScript und den Aufbau des HTML-Templates.

Aufbau der HTML-Struktur

Um die Grundlage unseres Tic Tac Toe-Spiels zu schaffen, beginnen wir mit einerm einfachen HTML-Template , welches nur zwei Elemente enthält: das Spielbrett und eine Anzeige für den Spielerzug. Der Kürze halber haben wir alle nicht wesentlichen Teile weggelassen:

<div id="board"></div>
<p id="player-turn">Player X's turn</p>
<script src="index.js"></script>

Styling mit CSS

Wie bereits erwähnt, werden im <div id="board"> per JavaScript neun div-Elemente für die einzelnen Spielfelder eingefügt. Doch wie entsteht daraus ein 3×3-Spielbrett? Hier kommt das CSS-Grid-Layout ins Spiel – mit display: grid und grid-template-columns lassen sich die Zellen wie in einer Tabelle anordnen.

#board {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-gap: 0;
    width: 300px;
    height: 300px;
    border-top: 1px solid black;
    border-right: 1px solid black;
    padding: 0;
}

#board > div {
    background-color: white;
    border-left: 1px solid black;
    border-bottom: 1px solid black;
    font-size: 40px;
    text-align: center;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    width: calc(300px / 3);
    height: calc(300px / 3);
    box-sizing: border-box;
}

Das Grid-Layout display: grid zusammen mit grid-template-columns: repeat(3, 1fr) erstellt ein strukturiertes Layout mit 3 Spalten. Da wir insgesamt 9 div-Elemente verwenden, ergibt sich daraus ein 3x3 Spielbrett. Der Wert 1fr steht für "fraction of the available space" - Bruchteil des verfügbaren Platzes - allerdings setzen wir später für die einzelnen div-s die Breite explizit auf calc(300px / 3) (das kann man noch bestimmt eleganter lösen).

Die Zellen haben an der linken und unteren Seite eine Rahmenlinie (border), während das Spielfeld selbst an der oberen und rechten Seite eine besitzt. So entsteht ein vollständiges Gitter. Dank box-sizing: border-box wird die Rahmenbreite in die Gesamtgröße der Elemente einberechnet. Dadurch entfällt die Notwendigkeit, das Spielfeld manuell anzupassen (z. B. es auf 302 px statt 300 px zu setzen - jeweils 2 Mal 1px für den Rahmen).

Initialisierung des Spielbretts mit JavaScript

JavaScript wird verwendet, um das Spielbrett zu generieren und Interaktiv zu machen.

const board = document.getElementById('board');
const boardCells = [];

for (let i = 0; i < 9; i++) {
    const cell = document.createElement('div');
    cell.textContent = '';
    cell.addEventListener('click', handleCellClick);
    cell.setAttribute('data-index', i);
    board.appendChild(cell);
    boardCells.push(cell);
}

Es werden dynamisch neun <div>-Elemente erzeugt, die die Spielfelder darstellen. Diese werden im Array boardCells gespeichert, um später leicht darauf zugreifen zu können. Jede Zelle erhält einen Click-Event-Listener, der die Funktion handleCellClick aufruft und so die Spielzüge verwaltet.

let blockClick = false;

function handleCellClick(event) {
    if (blockClick || event.target.textContent !== '') return;

    let index = event.target.getAttribute('data-index');
    boardCells[index].textContent = currentPlayer;

    checkWinner();
    switchTurn();
}

Wenn auf eine Zelle geklickt wird, wird sie mit dem Symbol des aktuellen Spielers ('X' oder 'O') markiert. Über den Aufruf der Funktionen checkWinner und switchTurn überprüfen wir ob das Spiel endet und wechseln den aktuellen Spieler.

let currentPlayer = 'X';
const playerTurn = document.getElementById('player-turn');

function switchTurn() {
    currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
    playerTurn.textContent = `Zug des Spielers ${currentPlayer}`;
}

Der aktuelle Spieler wird in der globalen Variable currentPlayer gespeichert. Die Funktion switchTurn wechselt den Spieler abwechselnd zwischen 'X' und 'O' und aktualisiert den Spielstatus im playerTurn-Element.

Wer hat gewonnen?

function checkWinner() {
    const winningCombinations = [
        [0, 1, 2], // horizontal
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6], // vertikal
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8], // diagonal
        [2, 4, 6]
    ];

    for (let combination of winningCombinations) {
        if (
            boardCells[combination[0]].textContent &&
            boardCells[combination[0]].textContent === boardCells[combination[1]].textContent &&
            boardCells[combination[0]].textContent === boardCells[combination[2]].textContent
        ) {
            alert(`Spieler ${boardCells[combination[0]].textContent} gewinnt!`);
            resetGame();
            return;
        }
    }

    if (countEmptyCells() === 0) {
        alert('Unentschieden!');
        resetGame();
    }
}

Das Spiel überprüft vordefinierte Gewinnkombinationen, die im Array winningCombinations gespeichert sind. Jede Kombination repräsentiert ein Set von drei Zellindizes, die eine gewinnende Reihe, Spalte oder Diagonale bilden. Wenn alle drei Zellen in einer Kombination dasselbe Symbol ('X' oder 'O') enthalten, wird der entsprechende Spieler zum Gewinner erklärt. Dies wird durch die Verwendung einer for-Schleife über das Array winningCombinations implementiert. Im if-Statement überprüfen wir zuerst, ob die Zelle nicht leer ist boardCells[combination[0]].textContent && ... und vergleichen dann die erste Zelle mit der zweiten und der dritten Zelle. Da eine Gewinnkombination nur gebildet werden kann, wenn alle drei Zellen dasselbe Symbol haben, müssen wir nur überprüfen, dass die ersten beiden Zellen mit der dritten übereinstimmen.

Wenn keine Gewinnkombination gefunden wird und alle Zellen gefüllt sind, gibt das Spiel ein Unentschieden bekannt. Um zu überprüfen, ob noch Zellen auf dem Board verbleiben, verwenden wir die Funktion countEmptyCells:

function countEmptyCells() {
    return boardCells.filter(cell => cell.textContent === '').length;
}

Die Methode filter erstellt ein neues Array mit allen Elementen, die die Bedingung der Callback-Funktion erfüllen (hier: cell => cell.textContent === '').
Sind keine leeren Zellen mehr vorhanden, beträgt die Länge (length) des Ergebnis-Arrays 0.

Das war's – unser Tic-Tac-Toe-Spiel ist fertig! Zum Abschluss findest du hier noch die vollständigen Quellcodes für index.html und index.js, falls du sie dir im Ganzen ansehen möchtest:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>JBerries - Tic Tac Toe</title>
    <style>
        #board {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            grid-gap: 0;
            width: 300px;
            height: 300px;
            border-top: 1px solid black; /* Füge oberen Rand zum Container hinzu */
            border-right: 1px solid black; /* Füge rechten Rand zum Container hinzu */
            padding: 0;
        }

        #board > div {
            background-color: white;
            border-left: 1px solid black; /* Nur linker Rand für Zellen */
            border-bottom: 1px solid black; /* Nur unterer Rand für Zellen */
            font-size: 40px;
            text-align: center;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            width: calc(300px / 3);
            height: calc(300px / 3);
            box-sizing: border-box;
        }

        #player-turn {
            font-size: 24px;
            margin-bottom: 20px;
        }
    </style>
</head>

<body>
    <div id="board"></div>
    <p id="player-turn">Zug des Spielers X</p>

    <script src="index.js"></script>
</body>
</html>
JavaScript

const board = document.getElementById('board');
const playerTurn = document.getElementById('player-turn');
let currentPlayer = 'X';

// Initialisiere das Board mit leeren Zellen
// [0][1][2]
// [3][4][5]
// [6][7][8]
const boardCells = [];
for (let i = 0; i < 9; i++) {
    const cell = document.createElement('div');
    cell.textContent = '';
    cell.addEventListener('click', handleCellClick);
    cell.setAttribute('data-index', i);
    board.appendChild(cell);
    boardCells.push(cell);
}

let blockClick = false;
function resetGame() {
    blockClick = true;
    setTimeout(() => {
        for (let i = 0; i < 9; i++) {
            boardCells[i].textContent = '';
        }
        blockClick = false;
    }, 1000)
}

function handleCellClick(event) {
    if (blockClick) return;
    if (event.target.textContent !== '') return;

    let index = event.target.getAttribute('data-index');

    if (currentPlayer === 'X') {
        boardCells[index].textContent = 'X';
        checkWinner();
        switchTurn();
    } else {
        boardCells[index].textContent = 'O';
        checkWinner();
        switchTurn();
    }
}

function switchTurn() {
    currentPlayer = (currentPlayer === 'X') ? 'O' : 'X';
    playerTurn.textContent = `Zug des Spielers ${currentPlayer}`;
}

function checkWinner() {
    const winningCombinations = [
        [0, 1, 2], // horizontal
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6], // vertikal
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8], // diagonal
        [2, 4, 6]
    ];

    for (let combination of winningCombinations) {
        if (
            boardCells[combination[0]].textContent &&
            boardCells[combination[0]].textContent ===
            boardCells[combination[1]].textContent &&
            boardCells[combination[2]].textContent ===
            boardCells[combination[0]].textContent
        ) {
            alert(`Spieler ${boardCells[combination[0]].textContent} gewinnt!`);
            resetGame();
            return;
        }
    }

    if (countEmptyCells() === 0) {
        alert('Es ist ein Unentschieden!');
        resetGame();
    }
}

function countEmptyCells() {
    return boardCells.filter(cell => cell.textContent === '').length;
}