JavaScript/Tutorials/Spiele/Tic-Tac-Toe
In diesem Tutorial lernen Sie, wie Sie das Spiel Tic-Tac-Toe als Browsergame selbst programmieren können.
Inhaltsverzeichnis
Spielverlauf
Auf einem quadratischen, 3×3 Felder großen Spielfeld setzen die beiden Spieler abwechselnd ihr Zeichen (ein Spieler Kreuze, der andere Kreise) in ein freies Feld.
- Der Spieler, der als Erster drei Zeichen in eine Zeile, Spalte oder Diagonale setzen kann, gewinnt.
- Wenn allerdings beide Spieler optimal spielen, kann keiner gewinnen, und es kommt zu einem Unentschieden. Das heißt, alle neun Felder sind gefüllt, ohne dass ein Spieler die erforderlichen Zeichen in einer Reihe, Spalte oder Diagonalen setzen konnte.
HTML-Markup
Die Darstellung des Spielfeldes ist eine Matrix mit 3x3 Feldern. Die Spieler müssen abwechselnd auf die Felder klicken oder auf einem Touchdisplay „tappen“, um ihre Spielzüge durchzuführen und das Feld zu „markieren“. Diese Funktionalität kann mit JavaScript nachgebaut werden, besser ist es jedoch, sich das Standardverhalten „normaler“ HTML-Elemente zunutze zu machen.
Information: Standardverhalten nutzen
Ursprünglich wurde für das Spiel eine 3x3-Tabelle verwendet, bei der man in die Tabellenzellen klicken musste.
Besser ist es, ein dafür geeignetes Element mit dem passenden Standardverhalten zu verwenden:
Ein solches Feld könnte man mit einem select-Menü erreichen. Allerdings darf ein Spieler ja nur „sein “ Symbol in das von ihm gewählte Feld eintragen. Des Weiteren kann ein select-Menü nicht direkt durch einmaliges Anklicken gesteuert werden.
Jedes Feld kann drei Zustände haben:
- leer
- von Spieler 1 mit "X" gefüllt
- von Spieler 2 mit "O" gefüllt
Deshalb haben wir uns für einen button entschieden. Als Textinhalt enthält er die Position des Feldes. Wenn ein Feld ausgewählt ist, wird dem zugehörenden button ein aria-label x
für Spieler1 bzw. o
für Spieler2 gegeben. Da das Feld nach einem einmal gewählten Zug nicht mehr verändert werden darf, wird den bereits ausgewählten Feldern ein disabled-Attribut gegeben.
<button aria-label="x" disabled>A1</button>
Eine passende Anleitung, welcher Spieler gerade am Zug sei, wird im Text-Absatz mit der id hint
ausgegeben. Das Spiel selbst erhält über das aria-describedby-Attribut einen Hinweis auf diese Anleitung:
<div class="tic-tac-toe" aria-describedby="hint">
<p id="hint">Zum Spielen bitte abwechselnd in die Spielfelder klicken/tappen!</p>
<div id="gameboard">
<button aria-label="x" disabled>A1</button>
<button>A2</button>
<button>A3</button>
<button>B1</button>
<button aria-label="o" disabled>B2</button>
<button>B3</button>
<button>C1</button>
<button>C2</button>
<button>C3</button>
</div>
</div>
Das sieht ja aber gar nicht so aus wie ein Spielfeld!
Deshalb müssen die HTML-Elemente nun mit CSS gestylt werden:
CSS für die Darstellung
Mittels geeigneter Darstellungsregeln in CSS sorgt der Browser dann für eine passende Darstellung.
Damit später nicht alle divs und button-Elemente in einem Dokument wie ein Tic-Tac-Toe-Spielfeld aussehen, wird die Klasse tic-tac-toe
als Selektor in CSS genutzt, um die Darstellung auf unser Spielfeld zu beschränken:
.tic-tac-toe #gameboard {
width: 60vmin;
height: 60vmin;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
}
.tic-tac-toe button {
width:20vmin;
height: 20vmin;
background: white;
border: 1px solid black;
margin: 0;
font: 0/0 transparent;
}
.tic-tac-toe [aria-label="x"] {
background-image: url('data:image/svg+xml,%3Csvg%20...');
}
.tic-tac-toe [aria-label="o"]{
background-image: url('data:image/svg+xml,%3Csvg%20...');
}
Innerhalb des Containerelements <div class="tic-tac-toe">
gibt es nun ein zweites div mit der id gameboard
. Dies erhält nun mit display:grid
ein dreispaltiges Grid-Layout für die 3x3 Matrix.
Alle button-Elemente innerhalb der Klasse .tic-tac-toe
erhalten nun einen weißen Hintergrund und eine gleiche Höhe und Breite, sodass sie quadratisch werden. Die Einheit vmin ist eine relative Größe, die sich am vorhandenen Seitenverhältnis orientiert. Der Textinhalt wird durch Image-Replacement ausgeblendet.
Wird ein Feld ausgewählt, erhält es ja ein aria-label-Attribut. Auch dies kann über den Attributwert-Selektor ausgewählt werden und erhält mit der background-image-Eigenschaft eine SVG-Grafik. Diese ist nicht extern referenziert, sondern als base64-Grafik im CSS enthalten und spart so einen HTTP-Request.
Die Spielmechanik
Damit unser Spiel spielbar wird, braucht es nun die Programmlogik, die den Spielverlauf steuert. Im Grunde soll die Aktion des jeweiligen Spielers in einem Betätigen des Buttons mit einem Click oder Tap bestehen. Diese wird mit JavaScript erkannt und verarbeitet.
Einbindung ins Dokument
Wie im Event-Handling-Tutorial näher beschrieben, wird das JavaScript im head
des Dokuments notiert, da er keinen Dokumentinhalt, sondern ergänzende Informationen über das Dokument darstellt. Alternativ könnte der Code auch in eine separate Textdatei mit der Dateiendung „.js“ ausgelagert werden, welche dann im head
als externe Resource referenziert würde.
Der Browser führt das Script aus, noch bevor irgendein Dokumentinhalt vorhanden ist, denn dieser steht ja erst im body
des Dokuments, unser Script aber davor im head
. Daher beginnt unser Script mit der Anweisung, dass der Hauptteil unseres JavaScripts erst dann vom Browser auszuführen ist, wenn der Dokumentinhalt verfügbar ist. Dazu machen wir uns ein bestimmtes Ereignis im Browser zunutze: DomContentLoaded
.
document.addEventListener(
'DOMContentLoaded',
function () {
// das hier bitte erst im Ereignis-Fall ausführen:
var a = 'Hallo Welt!';
}
);
Ein schöner Nebeneffekt dieser Schreibweise ist, dass alle in dieser Funktion deklarierten lokalen Variablen nicht im globalen Geltungsbereich (Scope) stehen, sondern für mögliche andere JavaScripte unerreichbar sind.
Markieren des gewählten Feldes
Per addEventListener erhält nun das gameboard eine Klickfunktionalität:
document.addEventListener('DOMContentLoaded', function () {
document.querySelector('#gameboard').addEventListener('click', markField);
function markField (e) {
var field = e.target;
field.setAttribute('aria-label','x');
field.setAttribute('disabled','disabled');
}
}
);
Wenn in das Spielfeld mit der id gameboard
geklickt wird, wird die Funktion markField
aufgerufen.
In ihr wird mit e.target das geklickte Feld identifiziert und für dieses mit setAttribute zuerst das aria-label- und dann das disabled-Attribut gesetzt.
Umschalten zwischen den Spielern
Welcher Spieler gerade am Zug ist, muss in einer Variablen gespeichert werden, auf die in den Eventhandlerfunktionen zugegriffen werden kann.
Die Variable players
ist ein Array, welches als Array-Literal notiert wurde.
Zusätzlich gibt es die Variable current
, die den aktuellen Spieler anzeigen soll.
Wenn man zwischen genau zwei Werten hin- und herschalten muss, kann man sich folgende Mathematik zunutze machen:
x = 1 - x
Wenn x den Wert null hat, dann wird x zu eins. Und umgekehrt. Das wenden wir so an:
document.querySelector('#gameboard').addEventListener('click', markField);
var current = 0,
players = ['x', 'o'];
function markField (e) {
var field = e.target;
field.setAttribute('aria-label', players[current]);
field.setAttribute('disabled','disabled');
current = 1 - current;
}
Wir haben doch einen Textabsatz für Meldungen und Anleitungen eingerichtet. Das sollten wir jetzt nutzen:
document.querySelector('#gameboard').addEventListener('click', markField);
var current = 0,
players = [ 'x', 'o'];
function markField (e) {
var field = e.target;
field.setAttribute('aria-label', players[current]);
field.setAttribute('disabled','disabled');
current = 1 - current;
document.querySelector('#hint').innerText = 'Spieler ' + players[current] + ' ist am Zug.';
}
Prüfung auf Spielende
Wie prüft man nun darauf, ob das Spiel zu Ende ist? Dazu gibt es zwei Möglichkeiten:
- Alle Felder sind verwendet worden.
- Ein Spieler hat drei Felder waagrecht, senkrecht oder diagonal und damit gewonnen.
Alle Felder verwendet?
Nehmen wir die erste Prüfung. Um alle Felder innerhalb des Spielfelds zu prüfen, werden mit querySelectorAll('#gameboard button')
alle Buttons innerhalb des Spielfelds selektiert.
Da jedes von einem Spieler gewählte Feld mit dem disabled-Attribut versehen ist, können wir jetzt prüfen, ob es ein Feld gibt, das noch aktiv ist. Das bedeutet, dass seine disabled
-Eigenschaft ein Leerstring sein muss, was wir mit hasAttribute('disabled')
überprüfen können.
function checkIfCompleted () {
var fields = document.querySelectorAll('#gameboard button'), // fields ist die Liste unserer Felder
full = true; // wir gehen davon aus, dass alle Zellen benutzt wurden
// alle Felder markiert?
for (var i = 0; i < fields.length; i++) {
if (!fields[i].hasAttribute('disabled')) {
full = false;
}
}
// wenn full, dann Spiel vorbei, wenn nicht full, dann noch nicht
if (full) {
// Spiel zu Ende weil alle Felder belegt
}
}
fields
bekommt von querySelectorAll
eine Liste zurück. Diese Liste kann man ähnlich wie ein Array mit einer for
-Schleife der Reihe nach abarbeiten.Wenn die Bedingung nicht zutrifft, was über den NOT-Operator
(!...)
abgeprüft wird, wird full
auf false
gesetzt.Wenn ein Spiel vorbei ist, dann sollte eine weitere Auswahl von noch freien Feldern nicht mehr möglich sein. Daher ist es ratsam eine Variable anzulegen, die diesen Zustand speichert: Nennen wir sie finished
und legen wir sie in den äußersten Scope zu players
, field
und caption
.
finished
(oder wie im vorherigen Code-Beispiel full
), die einen Bool'schen Wahrheitswert wie true
oder false
speichert, nennt man auch „Flag“ (englisch für Flagge), da sie wie eine Flagge entweder gehisst ist, oder eben nicht. document.querySelector('#gameboard').addEventListener('click', markField);
var current = 0,
players = [ 'x', 'o'],
finished; // Flag für Spiel-ist-zuende;
function markField (e) {
}
function checkIfCompleted () {
}
In der Eventhandler-Funktion markField(e)
kann nun anhand des Flags finished
entschieden werden, ob überhaupt noch auf einen Klick hin reagiert werden soll, oder nicht.
Das Ausrufezeichen sorgt dafür, dass der Inhalt der Variable finished
ins Gegenteil verkehrt verstanden wird. Ist das Flag negativ, so wird in der Verzweigung notierter Code ausgeführt (if (true)
wird ausgeführt). Steht dagegen in unserem Flag der Wert true
, so fällt die Prüfung für die Verzweigung negativ aus und der Code darin wird nicht ausgeführt.
Die Verzweigung liest man am besten „wenn (noch) nicht zu Ende“.
Wer hat gewonnen?
Um einen Gewinner zu ermitteln müssen wir prüfen, ob die drei senkrechten Kombinationen, die drei waagrechten, oder die beiden diagonalen dasselbe aria-label aufweisen. Das aria-label
(entweder x
oder o
) enthält dann die Identität des gewinnenden Spielers.
0 | 1 | 2 |
3 | 4 | 5 |
6 | 7 | 8 |
Die senkrechten Felder sind immer ein Vielfaches von 3: Entweder 0, 3, 6 oder 1, 4, 7, oder 2, 5, 8. Das lässt sich in einer Schleife so abfragen fields[0 + i]
, fields[3 + i]
, fields[6 + i]
, wobei die Zählvariablen i
von null bis einschließlich 2 hochzählt.
Die waagrechten Felder sind immer drei aufeinanderfolgende: Entweder 0, 1, 2, oder 3, 4, 5, oder 6, 7, 8. Das geht auch in einer Schleife: fields[i*3]
, fields[i*3 + 1]
, fields[i*3 + 2]
, wobei die Zählvariablen i
wieder von null bis einschließlich 2 hochzählt.
Für die diagonalen Möglichkeiten sind dann nur noch die Kombinationen 0, 4, 8 und 2, 4, 6 zu prüfen.
// Gewinner ermitteln
for (i = 0; i < 3; i++) {
// 3 senkrecht
if (fields[0 + i].getAttribute('aria-label') != ""
&& fields[0 + i].getAttribute('aria-label') == fields[3 + i].getAttribute('aria-label')
&& fields[3 + i].getAttribute('aria-label') == fields[6 + i].getAttribute('aria-label')
) {
// we have a winner!
winner = fields[0 + i].getAttribute('aria-label');
}
// 3 waagrecht
if (fields[i*3].getAttribute('aria-label') != ""
&& fields[i*3].getAttribute('aria-label') == fields[i*3 + 1].getAttribute('aria-label')
&& fields[i*3 + 1].getAttribute('aria-label') == fields[i*3 + 2].getAttribute('aria-label')
) {
// we have a winner!
winner = fields[i*3].getAttribute('aria-label');
}
}
// diagonal links oben nach rechts unten
if (fields[0].getAttribute('aria-label') != ""
&& fields[0].getAttribute('aria-label') == fields[4].getAttribute('aria-label')
&& fields[4].getAttribute('aria-label') == fields[8].getAttribute('aria-label')
) {
winner = fields[0].getAttribute('aria-label');
}
// diagonal rechts oben nach links unten
if (fields[2].getAttribute('aria-label') != ""
&& fields[2].getAttribute('aria-label') == fields[4].getAttribute('aria-label')
&& fields[4].getAttribute('aria-label') == fields[6].getAttribute('aria-label')
) {
winner = fields[2].getAttribute('aria-label');
}
Mit dem logischen UND-Operator (&&
) werden die drei notwendigen Bedingungen verknüpft, die wir an die drei zusammengehörigen Felder stellen. Konkret wird verglichen, ob ein Feld eine Klasse hat (getAttribute('aria-label')
also nicht leer ist), und ob es den selben Attributwert wie die anderen beiden Felder hat (also die aria-label
-Eigenschaft aller drei identisch ist). Werden drei solche erkannt, wird in der Variablen winner
der Wert x oder o gespeichert, welcher für den Spieler steht, der nun gewonnen hat.
Das Gegenstück zum UND-Operator ist der ODER-Operator (||
). Mit ihm wird ermittelt, ob das Spiel zu Ende ist. Die Verzweigung if (full || winner)
liest man am besten als „wenn voll (ausgefüllt) oder Gewinner (ermittelt)“.
Spielende
Das Spiel ist beendet, wenn keine weiteren Züge möglich sind oder jemand drei Felder in einer Linie erreicht hat.
Dann sollen …
- keine weiteren Eingaben gestattet sein, indem alle verbleibenden Buttons ausgegraut werden
- eine entsprechende Meldung in unsere Tabellenbeschriftung erfolgen
- die drei Gewinner markiert werden.
function checkIfCompleted () {
var fields = document.querySelectorAll('#gameboard button'), // fields ist die Liste unserer Felder
full = true; // wir gehen davon aus, dass alle Zellen benutzt wurden
// alle Felder markiert?
...
// Gewinner ermitteln
// 3 senkrecht
if (fields[0 + i].getAttribute('aria-label') != ''
&& fields[0 + i].getAttribute('aria-label') == fields[3 + i].getAttribute('aria-label')
&& fields[3 + i].getAttribute('aria-label') == fields[6 + i].getAttribute('aria-label')
) {
// we have a winner!
winner = fields[0 + i].getAttribute('aria-label');
highlightCells([fields[i], fields[3 + i], fields[6 + i]]);
}
// Game over?
if (full || winner) {
finished = true;
if (winner == 'x' || winner == 'o') {
hint.innerText = 'Das Spiel ist zu Ende, weil Spieler ' + winner + ' gewonnen hat!';
hint.className = 'success';
} else {
// Spiel zu Ende weil alle Felder belegt
hint.innerText = 'Das Spiel endet unentschieden, weil alle Felder belegt sind.';
hint.className = 'warning';
}
Wenn die oben erklärte Gewinnabfrage erfüllt ist, wird das entsprechende aria-label
ausgelesen und der Variable winner
zugewiesen. Die drei Felder werden der Funktion highlightCells()
übergeben.
Die folgende Verzweigung schreibt eine passende Meldung in den Textabsatz, basierend darauf, ob es einen Gewinner gibt, oder nicht. Dazu wird ein Trick angewandt: Der String-Wert in winner
ist entweder „x“ oder „o“. Sollte es keinen Gewinner geben, hat die Variable keinen Wert, was dem Wert null
entspricht (nicht mit der Zahl null verwechseln!). Bei der Prüfung werden die String-Werte wie ein true
gewertet, das Nichtvorhandensein eines Wertes als false
. Daher kann man ganz kurz if (winner)
notieren, ohne einen exakten Wertevergleich mit logischer ODER-Verknüpfung anwenden zu müssen. Dass das möglich ist, liegt an der automatischen Typkonvertierung in JavaScript. Bei Bedarf werden Datentypen (hier entweder String oder null
) passend umgeformt.
Lösung anzeigen
Wenn das Spiel zu Ende ist, und wenn es einen Gewinner gibt, dann möchte dieser sicherlich gerne seine drei gewinnenden Felder als solche hervorgehoben haben.
Wir definieren eine Funktion namens highlightCells
, die als Argument eine Liste (genauer ein Array) an Feldern entgegen nimmt, um diese zu verändern. Diese werden in einer Schleife durchlaufen und erhalten mit classList.add die Klasse highlighted
:
function highlightCells (cells) {
for (var i = 0; i < 3; i++) {
cells[i].classList.add('highlighted');
}
}
Was fehlt noch?
Da es sich hier um ein Anfänger-Tutorial handelt, werden gewisse Dinge einfach „unter den Tisch gekehrt“, über die sich fortgeschrittene Script-Autoren aber Gedanken machen sollten. Hier nur ein paar solcher Ideen:
- Vergrößerung des Spielfelds
Tic-Tac-Toe könnte auch mit einen 4x4 oder 5x5-Feld gespielt werden.
- "Vier gewinnt" hat einen ähnlich Spielverlauf, nur muss man hier 4 in eine diagonale, senkrechte oder waagerechte Reihe bringen. Dafür müssten einige Erweiterungen vorgenommen werden:
- checkIfCompleted() muss auf 4 in einer Reihe überprüfen
- Der Wurf eines Chips in eine Spalte erfolgt durch eine Animation, bei der er bis zum letzten leeren Feld nach unten fällt.
Weblinks
- codepen Tic-Tac-Toe von Gunnar Bittersmann
players
zugreifen, so notiert man nach dem Variablennamen eckige Klammern, in die man die Nummer des gewünschten Elements notiert, beginnend mit null als erstem Element. In diesem Beispiel istplayers[current]
in Abhängigkeit vom Wert incurrent
entweder „x“ oder „o“.