JavaScript/Tutorials/Spiele/Tic-Tac-Toe

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Text-Info

Lesedauer
45min
Schwierigkeitsgrad
mittel
Vorausgesetztes Wissen
Kenntnisse in
● HTML
● JavaScript


In diesem Tutorial lernen Sie, wie Sie das Spiel Tic-Tac-Toe als Browsergame selbst programmieren können.

Spielverlauf[Bearbeiten]

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[Bearbeiten]

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.

Alternativ bietet sich eine Checkbox an. Diese kann leer sein und dann angeklickt werden, sodass man die Interaktivität schon im HTML hat. Zusätzlich zum angeklickten Zustand müsste die Zugehörigkeit zum Spieler durch eine Klasse oder ein aria-Attribut definiert werden. Checkboxen und Buttons sind interaktive Elemente, die nicht nur mit der Maus, sondern auch mit der Tastatur ansteuerbar sind und deshalb auch für Nutzer mit Screenreadern verwendbar sind.

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.

ausgewähltes Feld
<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:

semantisches HTML-Markup für Tic-Tac-Toe ansehen …
<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[Bearbeiten]

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:

Gestaltung durch CSS ansehen …
.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[Bearbeiten]

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[Bearbeiten]

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.

Zeitversetztes Ausführen mit Browser-Event
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[Bearbeiten]

Per addEventListener erhält nun das gameboard eine Klickfunktionalität:


geklicktes Feld markieren ansehen …
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[Bearbeiten]

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:

Spielerverwaltung ansehen …
  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;
  }
Will man auf ein Element des Arrays 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 ist players[current] in Abhängigkeit vom Wert in current entweder „x“ oder „o“.

Wir haben doch einen Textabsatz für Meldungen und Anleitungen eingerichtet. Das sollten wir jetzt nutzen:

Hinweis an die Spieler ausgeben ansehen …
  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[Bearbeiten]

Wie prüft man nun darauf, ob das Spiel zu Ende ist? Dazu gibt es zwei Möglichkeiten:

  1. Alle Felder sind verwendet worden.
  2. Ein Spieler hat drei Felder waagrecht, senkrecht oder diagonal und damit gewonnen.

Alle Felder verwendet?[Bearbeiten]

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.

Prüfung auf unverwendete Zellen ansehen …
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
  }
}
Die Variable 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.

Hinweis

Eine Variable wie 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.
wichtige Variablen im äußersten Scope
  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?[Bearbeiten]

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.

drei senkrechte, waagrechte oder diagonale Felder? ansehen …
 // 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[Bearbeiten]

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.
drei senkrechte Felder – Was passiert dann?
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[Bearbeiten]

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:

Gewinnfelder anzeigen
function highlightCells (cells) {
  for (var i = 0; i < 3; i++) {
    cells[i].classList.add('highlighted');
  }
}

Was fehlt noch?[Bearbeiten]

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[Bearbeiten]