JavaScript/Tutorials/Spiele/Zahlenspiele

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

In diesem Kapitel lernen Sie die Grundzüge von Lernspielen kennen. Der Partner, hier der Computer, stellt eine Aufgabe, die dann zu bearbeiten ist. Die Eingabe des Benutzers wird nach dem EVA-Prinzip ausgelesen, verarbeitet und dann eine Rückmeldung ausgegeben.

Dabei stehen nicht die (zugegebenermaßen) einfachen Spiele im Vordergrund, sondern …

Zahlen-Raten

Spielidee

  1. Der Computer „denkt“ sich eine Zufallszahl aus.
  2. Der Spieler versucht sie in möglichst wenigen Schritten zu erraten.

HTML-Markup

Anders als im Einstieg im JavaScript machen wir hier von allen Möglichkeiten von HTML5 Gebrauch:

HTML-Markup ansehen …
<p>Wir haben eine Zahl zwischen 1-10 ausgesucht. Kannst Du sie erraten?</p> 
  
<form> 
    <label for="guessField">Gib Deine Zahl ein:</label> 
    <input type="number" id="guessField"> 
    <button type="button" id="submitguess">Prüfe!</button> 
</form>
Das Formular besteht aus einem Eingabefeld von type="number". So können Fehleingaben von nichtnumerischen Zeichen von vorneherein ausgeschlossen werden. Dem Eingabefeld ist über seine id ein label zugeordnet, das eine erklärende Beschriftung gibt. Ein Button kann dann die Überprüfung des eingegebenen Werts auslösen.

JavaScript

Einbindung

Wie im Event-Handling-Tutorial näher beschrieben, wird das JavaScript im head des HTML-Dokuments notiert und erst nach dem Laden der Seite ausgeführt:

Einbindung des JavaScripts
<script>
'use strict';
document.addEventListener('DOMContentLoaded', function () {

  // Hier kommt unser Script hin!

});
</script>

Zufallszahlen erzeugen

In JavaScript können mit Math.random() Zufallszahlen erzeugt werden. Da diese aber nur zwischen 0 und 1 liegen, benötigen wir eine kleine Helferfunktion:

allgemeine Helferfunktion zur Berechnung ganzzahliger Zufallszahlen ansehen …
document.querySelector('#submit').addEventListener('click', ausgabeZufallszahl);
	
let randomNumberMin = 1,
    randomNumberMax = 10;
		
function rand (min, max) {
	return Math.floor(Math.random() * (max - min + 1)) + min;
}
	
function ausgabeZufallszahl () {
	document.querySelector('output').innerText = rand(randomNumberMin,randomNumberMax); 
}
Empfehlung: Verwenden Sie „Sprechende Variablennamen“. Die Mindest und Maximalwerte könnte man beispielsweise mit min und max kennzeichnen, mit randomNumberMin ist es aber auch später in komplexeren Skripten klar, was gemeint ist.

Verarbeitung von Benutzereingaben

Nachdem die Zufallszahl gebildet wurde, warten wir nun auf eine Benutzereingabe, die dann „verarbeitet“ – nämlich mit der Zufallszahl verglichen – wird:

Zahlenraten ansehen …
	document.querySelector('#submitguess').addEventListener('click', checkInput);	

	let input = document.getElementById('guessField'),
	    output =  document.querySelector('output'),
	    randomNumberMin = 1,
	    randomNumberMax = 10,
	    randomNumber = rand(randomNumberMin,randomNumberMax),
	    guess = 1; 

	function rand (min, max) {
		return Math.floor(Math.random() * (max - min + 1)) + min;
	}
	
	function checkInput() { 	
	console.log('Zufallszahl: ' + randomNumber + '   input: ' + input.value + '  Versuche(guess): ' + guess);
		if (input.value == randomNumber) {     
       		output.innerText = 'Glückwunsch! Sie haben es in  ' + guess + ' Versuch(en) erraten!'; 
   		} 
   		else if(input.value > randomNumber) {
	       guess++; 
       	   output.innerText = 'Schade! Versuchen Sie eine kleinere Zahl.'; 
   		} 
	   	else { 
    	   	guess++; 
       		output.innerText = 'Schade! Versuchen Sie eine größere Zahl.'; 
   		} 
		input.value = '';
	}

An den submit-Button wird mit addEventListener ein EventListener angehängt, der beim Auslösen des Klickereignisses die Funktion checkInput() aufruft.
Diese Funktion besteht aus einer bedingten Anweisung if(). Innerhalb der Klammern werden zwei Werte (randomNumber und input.value) miteinander verglichen. Dafür benötigen wir den Vergleichsoperator ==.
Falls die Bedingung zutrifft gibt es eine Erfolgsmeldung; falls nicht wird eine Fehlermeldung ausgegeben und der Zähler guess um eins erhöht.
Anschließend wird das Eingabefeld geleert und auf eine neue Eingabe gewartet.


Ist ihnen diese Zeile aufgefallen?

console.log('Zufallszahl:' + randomNumber + ' input: ' + input.value + 'Versuche(guess): ' + guess);
Empfehlung:
  • Öffnen Sie das Beispiel mit Vorschau und Rechtsklick in einem neuen Tab.
  • Mit einem Klick auf F12 können Sie die Konsole öffnen und die aktuellen Werte der drei verwendeten Variablen überprüfen.

Mathe-Quiz

Spielidee

  1. Der Benutzer wählt eine Rechenart, bzw. den Operator aus.
  2. Der Computer „denkt“ sich eine Rechnung mit 2 Zufallszahlen aus.
  3. Der Spieler versucht die Rechnung zu lösen und gibt das Ergebnis ein.
  4. Die eingegebene Zahl wird mit dem Ergebnis verglichen.
  5. Es wird eine Erfolgs- oder Fehlermeldung ausgegeben.
  6. Auf dem Rechner des Nutzers wird eine Statistik gespeichert.

HTML-Markup

Auswahlmenü

Der Nutzer soll zwischen mehreren Operatoren und einem Zufallsmodus, bei dem alle Rechenarten zufällig ausgewählt werden, auswählen können. Auch wenn dies wie eine Navigation aussieht, ist dies semantisch eine Eingabe, die dann vom Skript ausgewertet wird.

Empfehlung: Verwenden Sie also keine Verweise, sondern ein select-Menü oder Radio-Buttons.
Vorteil dieser Vorgehensweise ist, dass Sie das Standardverhalten der Radio-Buttons ausnutzen können.
Radio-Buttons sind bereits interaktiv: Wenn ein Button ausgewählt wird, wird der zuvor ausgewählte Button deaktiviert.
Toggle-Menü mit Radio-Buttons ansehen …
<form class="toggle-buttons" id="rechenarten">
   <input type="radio" name="op" data-operator="+" id="add" checked><label for="add">+</label>
   <input type="radio" name="op" data-operator="-" id="subtract"> <label for="subtract">-</label>
   <input type="radio" name="op" data-operator="÷" id="divide"> <label for="divide">/</label>
   <input type="radio" name="op" data-operator="×" id="multiply"> <label for="multiply">x</label>
   <input type="radio" name="op" data-operator="any" id="any"> <label for="any">?</label>
</form>
Das Toggle-Menü besteht aus Radio-Buttons, die über das name-Attribut miteinander verknüpft sind. Danach folgen label mit einer Beschriftung.
Das Menü wird nun mit CSS gestylt:
.toggle-buttons input[type="radio"] {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}
.toggle-buttons label {
  display: inline-block;
  border: 1px solid #333;
  border-radius: 0.5em;
  width: 2rem;
  height: 2rem;
  margin: 0.5rem;
  font: 0/0;
  color: transparent;
}

.toggle-buttons label[for=add] {
  background-image: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20...");
}

.toggle-buttons input:checked + label {
  background-color: #ebf5d7;
}

Die Radio-Buttons selbst werden mit Image-Replacement versteckt und dafür die label-Elemente als Buttons gestylt: Da es schwierig wäre, die vier Symbole der Zufallsauswahl passend zu formatieren, wird die Beschriftung ausgeblendet und durch SVG-Hintergrundgrafiken ersetzt. Um einen HTTP-Request zu sparen, wird das SVG-Markup direkt in die url()-Funktion notiert.
Der letzte Regelsatz färbt das Label des angeklickten Radio-Buttons grün. Dafür wird für das input-Element die Pseudoklasse :checked und dann der Nachbarkombinator + verwendet.

Aufgabenfeld

Aufgabenfeld ansehen …
<form id="task">
	<span id="var1">a</span>
	<span id="operator">+</span>
	<span id="var2">b</span>
	=
	<input id="input" type="number" step="1" size="3">
	<button type="submit"></button>
</form>
Die beiden Zufallszahlen und der Operator werden innerhalb von inhaltsleeren span-Elementen notiert. Danach findet sich ein input-Eingabefeld. Der type="number" sorgt dafür, dass nur Zahlen eingegeben werden können, sodass eine Überprüfung der Eingabe mit JavaScript nicht mehr nötig ist. Ein Button ermöglicht das Absenden des Formulars.
Das Eingabefeld enthält normalerweise eine Scrollleiste am rechten Rand - sie wird nun mit CSS entfernt:
#task input[type="number"]{
  width: 2em;
  -moz-appearance: textfield;
  appearance: textfield;
  margin: 0;
  font-size: 1em;
}

input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
      -webkit-appearance: none;
      margin: 0;
}
Die appearance-Eigenschaft ermöglicht es, Formularelementen ihr betriebssystemspezifisches Aussehen zu nehmen. Dabei gibt es je nach Browser aber verschiedene Umsetzungen.

JavaScript

Das JavaScript dieses Spiels ist nun etwas komplexer. Umso wichtiger wird es, die einzelnen Schritte in Funktionen zu gliedern und jeden Schritt so zu kommentieren, dass wir das Skript auch nach ein paar Monaten wieder nachvollziehen und evtl. verbessern / ausbauen können.

Aufgabenerstellung

Für die Erstellung der Aufgaben benötigen Sie nun den gewählten Operator und zwei Zufallszahlen. Dabei muss aber darauf geachtet werden, dass bei einer Subtraktion keine negativen und bei einer Division keine Dezimalzahlen herauskommen.

Im Internet gibt es ältere Skripte, die die Aufgabenrechnung nun aus den drei Variablen zu einem String zusammenbauen und dann mit eval() auswerten. Allerdings ist eval() im Strengen Modus verboten.

Alternativ könnte man mit einer Verzweigung jeweils eine Anweisung mit der passenden Rechnung erstellen:

Aufgabenerstellung - mit if-Anweisung
       if (rechenArt == '2') {
          operator = "-";
          b = rand(1, randomNumberMax);
          a = rand(b, randomNumberMax);
          result = a - b;
       }
In diesem Skript wurden die einzelnen Rechenarten durch ein Select-Menü ausgewählt. In jeder If-Verzweigung wird nun eine Variable operator zugewiesen und die beiden Zufallszahlen ermittelt. (Damit bei einer Subtraktion eine positive ganze Zahl als Differenz herauskommt, muss der Minuend größer als der Subtrahend sein. Dies wird erreicht, indem zuerst der Subtrahend b und dann erst eine Zufallszahl mit dem Wert von b als randomNumberMin ermittelt wird.)

In unserem Beispiel verwenden wir jedoch ein im SELF-Forum vorgeschlagenes Objekt, das die Operatoren als Eigenschaften des opMap-Objekts ...[1]

Aufgabenerstellung
	function createTask () {
		let a,
		b;

		let opMap = {
			"+": function() {
				a = rand( 1, randomNumberMax * 3);
				b = rand( 1, randomNumberMax * 3);
				result = a+b;
				return "+";
			},
			"-": function() {
				// So kann sichergestellt werden, dass das Ergebnis immer positiv ist
				b = rand( 1, randomNumberMax * 3);
				a = rand( b, randomNumberMax * 3);
				result = a-b;
				return "-";
			},
			"×": function() {
				a = rand( 1, randomNumberMax);
				b = rand( 1, randomNumberMax);
				result = a*b;
				return "×";
			},
			"÷": function() {
				// So kann sichergestellt werden, dass das Ergebnis immer eine ganze Zahl ist
				b = rand( 1, randomNumberMax);
				a = b * rand( 0, randomNumberMax);
				result = a/b;
				return "÷";
			},
			"any": function() {
				let p, props = [];

				for (p in opMap) {
					if (p != "any") {
						props.push(p);
					}
				}

				p = rand(0, props.length);

				return opMap[props[p]]();
			}
		};
In diesem Objekt sind für jeden Operator sowohl die Symbole als auch die Rechenoperation gespeichert.
Für den Zufallsoperator werden die Operatoren dann mit der Zufallsfunktion ausgesucht. Die Zufallszahlen je nach Operator so ermitteln, dass als Ergebnis nur positive ganze Zahlen herauskommen.

Verarbeitung der Benutzereingabe

Um das Spiel zu starten, wird nun zuerst die Funktion createGame() aufgerufen, die alles Nötige einrichtet:

createGame()
	let input = document.getElementById('input'),
	    output =  document.querySelector('output'),
	    randomNumberMax = 10,
	    result = 0,
	    stat = {
	      correct: 0,
	      incorrect: 0
	    };

	function createGame () {
		document.querySelector('#rechenarten').addEventListener('change', createTask);
		
		// submit-Button einrichten
		document.querySelector('#task').addEventListener('submit', evaluateResponse);

		createTask();
	}

Die Funktion createGame() hängt nun sowohl an das Toggle-Menü als auch an den submit-Button EventListener, die bei Benutzereingaben im Toggle-Menü das Spiel starten. Danach wird mit createTask() eine Rechenaufgabe gestellt.
Nach einer Benutzereingabe, die mit einem Klick auf den submit-Button bestätigt werden muss, wird die Funktion evaluateResponse() aufgerufen.

Auswertung der Benutzereingabe - evaluateResponse()
	function evaluateResponse (evt) {
		// Ergebnis mit Eingabe vergleichen
		evt.preventDefault();

		if (result == input.value){
			output.textContent = 'Super!';
			output.className = 'exact';
			stat.correct++;
		} else {
			output.textContent = 'Leider falsch!';
			output.className = 'inexact';
			stat.incorrect++;
		}
		
		setTimeout(createTask, 1000);
	}

Nach einer Benutzereingabe, die mit einem Klick auf den Submit-Button bestätigt werden muss, wird die Funktion evaluateResponse() aufgerufen.
evt.preventDefault() verhindert das Standardverhalten des Submit eines Formulars, bei dem die Seite neu geladen würde.
Anschließend werden …

  • das erwartete Ergebnis result und die Eingabe input.value verglichen
  • eine Fehlermeldung in das output-Element ausgegeben
  • diese Fehlermeldung mit output.className mit einer Klasse versehen, die dann mit CSS formatiert werden kann.
  • und die Eigenschaften correct oder incorrect des Stat-Objekts um eins erhöht.

Damit die Erfolgs-/Fehlermeldung sichtbar bleibt, wird erst nach einer Verzögerung von 1sec eine neue Aufgabe gestartet. Hierfür wird setTimeout() verwendet.

Ergebnissicherung

Schon in den Variablen zum Spielstart und in der vorherigen Funktion evaluateResponse() begegnete uns der Term stat.correct.

createGame()
	let stat = {
          correct: 0,
          incorrect: 0
		};
stat ist ein Objekt mit den zwei Eigenschaften correct und incorrect. Mit ihnen kann die Zahl der richtig und der falsch gelösten Aufgaben ermittelt und in einem Feedback ausgegeben werden.

Es wäre gut, wenn man diese Auswertung auch über einen längeren Zeitraum speichern könnte. Hier gibt es zwei Möglichkeiten:

  • Daten könnten vom Benutzer, dem Client, zum Server übertragen und dort gespeichert werden. Um diese Daten dem Benutzer zuordnen zu können, müsste man Namen mit einem Login speichern und hätte neben den technischen Aspekten die Pflicht, den Datenschutz zu berücksichtigen.
  • Eine clientseitige Speicherung mit Web Storage verzichtet auf die Erfassung und Speicherung des Namens. Die Daten bleiben beim Nutzer und können von ihm, wenn gewünscht, wieder gelöscht werden.
Helferfunktionen für LocalStorage
	// Statistiken aus LocalStorage holen
    function getStorage () {
	  let eintraegeArray = localStorage.getItem('item');
	  if (eintraegeArray) {
          output.innerText = eintraegeArray;
		  stat = JSON.parse(eintraegeArray);
	  }
	  else {
	    output.textContent = 'Herzlich willkommen in unserem Mathe-Quiz! Gib das Ergebnis der Rechnung ein und drücke die Enter-Taste!';
	  }
	  return stat;
	}
	
	// Statistiken zurücksetzen
    function resetStatistics () {
		stat.correct = 0;
		stat.incorrect = 0;
		getStatistics();
		document.querySelector('#ergebnis').textContent = 'Statisiken wurden zurückgesetzt!';
	}	

	// Statisiken ausgeben
    function getStatistics () {
		document.querySelector('#richtig').textContent = stat.correct;
		document.querySelector('#falsch').textContent =  stat.incorrect;
		let percentage = stat.correct/( stat.correct+stat.incorrect)*100;
		document.querySelector('#prozent').textContent =  percentage.toFixed(2);
	}
Die Funktion getStorage() überprüft den Web Storage, ob es einen Eintrag eintraegeArray gibt. Falls ja, wird er mit JSON.parse() in das Objekt stat umgewandelt.
Die Funktion resetStatistics () setzt die Werte für stat wieder auf 0.
Die Funktion getStatistics () ermittelt aus den Werten für stat die Gesamtsumme und den Anteil zwischen richtigen und flaschen Lösungen und gibt diesen aus.

Um die Speicherung der Statisitik zu starten, wird die Funktion createGame() erweitert:

Speicherung der Statistik mit LocalStorage
	function createGame () {

		document.querySelector('#rechenarten').addEventListener('change', createTask);
		
		// Beim Verlassen der Seite wird vorher die Statistik gespeichert
		window.addEventListener('beforeunload', function(event) {
      		localStorage.setItem('item', JSON.stringify(stat));
		});

		
		// gespeicherte Statistik laden. Wenn keine vorhanden, Info-Template laden
		getStorage();
		
		// Button um Statistiken zurückzusetzen
		document.querySelector('#reset-stat').addEventListener('click', resetStatistics);
       
		// submit-Button einrichten
		document.querySelector('#task').addEventListener('submit', evaluateResponse);

		createTask();
	}
Beim Start der Funktion createGame() wird ein EventListener eingerichtet, der beim Feuern des beforeunload-Events vor dem Verlassen der Seite die aktuelle Statistik speichert. Dafür wird das stat-Objekt mit JSON.stringify() in eine Zeichenkette umgewandelt.
Anschließend wird mit der oben beschriebenen Funktion getStorage() überprüft, ob es bereits eine Statistik gibt.

Ausgabe der Statistik

Für die Ausgabe der Statistik benötigen wir kein JavaScript, sondern machen uns das Dialog-Element zunutze:

Ausgabe der Statistik mit Dialog-Box
<button id="open-dialog">Ergebnisse und Statistiken</button>
<dialog role="dialog">
	<button id="close-dialog">X</button>	
	<h2>Ergebnis</h2>
    <p>Richtig: <span class="exact" id="richtig"></span></p>
    <p>Falsch: <span class="inexact" id="falsch"></span></p>
    <p>Prozent: <span id="prozent"></span>%</p>
    
    <button id="reset-stat">Statistiken zurücksetzen</button>
    <p id="ergebnis"></p>
</dialog>

Das fertige Spiel

Beispiel ansehen …


Kritik

Das Spiel bietet eine Fülle an Kritikpunkten, bzw. positiver formuliert: Verbesserungsmöglichkeiten:

  • Die Zufallszahlen sind zufällig; können sich also auch wiederholen. Besser wäre es einen Array aus Zufallszahlen oder Aufgaben zu bilden und mit einer Wechsellogik die Methode „Ziehen ohne Zurücklegen“ aus dem Urnenmodell anzuwenden.
  • Die hartkodierten Faktoren, mit denen bei der Aufgabenerstellung die maxValue multipliziert werden, um größere Zahlen bei den einfacheren Additionen und Subtraktionen zu erreichen, sollten in Variablen umgewandelt und je nach Erfolgslevel angepasst werden. So kann man mit leichten Aufgaben starten und mit fortschreitendem Erfolg schwierigere Aufgaben lösen müssen.
  • Apropos Erfolgslevel – dies sollte entweder der Nutzer selbst einstellen können oder mit fortschreitender Punktezahl automatisch angepasst werden können.
  • In einem solchen Level könnten dann auch Dezimalzahlen und evtl. Brüche abgefragt werden. Andererseits benötigt man für Brüche wohl 2; für gemischte Zahlen 3 Eingabefelder. Hier wäre eine Zuordnungsaufgabe übersichtlicher.


Weblinks

  1. SELF-Forum: Alternative zu eval() für arithmetische Berechnungen vom 26.06.2017