/*
Forum: https://forum.arduino.cc/t/hilfe-bei-meinem-dino-spiel/1195564
Wokwi: https://wokwi.com/projects/382927977210321921
Sehr weitgehend umgeschrieben auf eine einfache "State Machine"
mit
- Leveln, bei denen die Position des Hindernisses jeweils per Zufall neu gesetzt wird
und die Geschwindigkeit des ">" Zeichens zunimmt (nur bis Level 9)
- automatischer "Rückkehr" auf den "Boden" nach vorgegebener Sprunglänge
- automatische "Rückkehr" des ">" an den linken Rand und die untere Zeile bei
Überschreiten des rechten Randes
- einer "Pause" -Taste auf der '5', die durch eine beliebigen Taste aufgehoben wird
*/
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Keypad.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // I2C-Display-Adresse und Zeilenzahl
const byte ROWS = 4; //three rows
const byte COLS = 3; //four columns
char keys[ROWS][COLS] = {
{'1', '2', '3'},
{'4', '5', '6'},
{'7', '8', '9'},
{'*', '0', '#'}
};
byte rowPins[ROWS] = {8, 7, 6, 5}; //connect to the row pinouts of the keypad
byte colPins[COLS] = {4, 3, 2}; //connect to the column pinouts of the keypad
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
// Struktur zum Zusammenfassen der dino und hindernis-Positionen
// unter einem Namen, damit man es sich einfacher merken kann,
struct position {
byte x;
byte y ;
};
// Deklaration der Variablen für dino und hindernis, incl. Default-Werte
position dino {1, 1};
position hindernis {10, 1};
// Da die Abfrage des keyPads und die Bewegung des "Dinos" nicht mit gleicher
// Geschwindigkeit verlaufen, müssen wir uns die keyPad-Daten in "richtung" merken
// um sie in der weniger häufig aufgerufenen Funktion zum Bewegen verwenden zu
// können
char richtung;
// Die Schritte, die Dino in der oberen Zeile vollzogen hat
byte schritteOben = 0;
// Die maximale Schrittweite ist erlaubterSprung+1, nach der wird
// der Dino wieder in die untere Zeile gezogen
// Durch " if (schritteOben > erlaubterSprung)" ergibt sich, dass
// ein Schritt mehr als erlaubterSprung zur Auflösung true führt
// Das könnte (sollte) man in einem nächsten Entwicklungsschritt bereinigen!
constexpr byte erlaubterSprung = 2;
// Der Zähler für das Level, mit dessen Hilfe im Verlauf auch
// die Schrittdauer verkürzt wird, damit der Dino sich schneller
// bewegt
int level = 0;
// Ein enum ist ein Aufzählungstyp, wo der Compiler den einzelnen
// Elementen beim Compilieren einen fortlaufenden Zahlenwert zuweist,
// um den wir uns deshalb nicht selbst kümmern müssen.
// Fügen wir hier neue Elemente ein, löschen sie oder ordnen wir sie um
// ist völlig egal, da wir im Code immer(!) den Elementenamen verwenden.
// Ob der intern eine 1, 7 oder 43 ist, ist uns egal.
enum zustandsAuswahl {ANSAGE, DINOLAEUFT,PAUSE, GAMEOVER};
// Jetzt deklarieren wir eine Variable vom o.a. enum-Typen (den wir hier mal
// zustandsAuswahl genannt haben). Der Variablen zustand weisen wir schon gleich
// das Element ANSAGE zu.
zustandsAuswahl zustand = ANSAGE;
// Um den Dino nicht mit "voller" Controllergeschwindigkeit über das LCD
// zu hetzen, bedienen wir uns der bekannten "millis()"-Funktion, wofür wir
// ein paar globale Variable vorbereiten:
// in zuletztBewegt merken wir uns jeweils die aktuelle Controllerzeit in [ms]
// bei jeder Bewegung
unsigned long zuletztBewegt = 0;
// und warten für den nächsten Schritt darauf, dass zeitProSchritt (hier 0,55 s)
// verstrichen sind (Achtung: Hier nur Vorbereitung, die eigentliche Funktion, die
// das umsetzt folgt später! siehe millis()-Funktion im Folgenden)
unsigned long zeitProSchritt {550}; // Jede Sekunde ein Schritt
// Im Setup() bereiten wir die Serielle Schnittstelle und das LCD vor.
// Außerdem schreiben wir schon mal die Startmeldung auf das LCD.
// Da wir die Variable zustand = ANSAGE in der Deklaration schon gesetzt
// haben, brauchen wir hier nichts weiter zu tun.
//
// Auch die pins des keypads brauchen wir nicht zu behandeln, das machen die
// Funktion der keyPad Library selbst!
void setup() { //LCD Display initialisieren und "Startmenü" anzeigen
Serial.begin(115200);
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("Press 0 to start");
lcd.setCursor(0, 1);
lcd.print("FAKE DINO RUNNER");
Serial.println("Startschirm");
}
// Die loop() kommt ziemlich aufgeräumt daher ;-)
// Hier wird nur die Funktion zustandsMaschine() aufgerufen.
// Die erledigt alles andere ... wie schön ... ;-)
void loop() {
zustandsMaschine();
}
// Hier kommt das "Arbeitstier", die zustandsMaschine
//
// Gleich am Anfang sammeln wir eventuelle Tastatureingaben ein, ohne uns
// darum zu kümmern, was damit passieren soll
//
// danach folgt ein switch/case Konstrukt. Das ist die eigentlich "State Machine"
// auf Deutsch eigentlich "Zustandsautomat"
//
// Wie der Name sagt, unterscheidet diese Maschine verschiedene Zustände der Software.
// Es wird immer nur der aktuelle case-Zweig bis zum jeweiligen break; durchlaufen.
// Damit können wir in der gleichen(!) Schleife (loop()) unterschiedlich auf Dinge reagieren.
//
// In unserem Fall gibt es die vier Zustände ANSAGE, DINOLAEUFT, PAUSE, GAMEOVER.
//
// ANSAGE: Hier starten wir, da wir ja zustand = ANSAGE gesetzt hatten.
// Hier passiert ... nichts... solange die Taste '0' nicht betätigt wird.
// Dann allerdings wird genau einmal die Funktion startGame() ausgeführt
// und dann zustand auf DINOLAEUFT gesetzt. Durch Letzteres laufen wir beim
// nächsten loop()-Durchlauf nicht wieder in den case ANSAGE sondern in DINOLAEUFT
// Was ANSAGE anzeigt, schreibt schon der Zustand vorher aufs LCD, ANSAGE ist
// nur ein gutmütiger Helfer oder williger Gehilfe(!), dem es egal ist,
// ob man gerade anfängt oder schon ein paar GameOver-Frustrationen hinter sich hat.
//
// DINOLAEUFT: Durch startGame() wurde das LCD für das Spiel vorbereitet und der erste Schritt
// des Dino kann erfolgen. Dazu wird hier regelmäßig (und zwar extrem häufig!) bewegeDino(key)
// aufgerufen. Die Funktion bewegeDino() wird aber erträglich verlangsamt, indem es nur dann
// wirklich aktiv wird, wenn zeitProSchritt verstrichen ist (Genaueres steht bei der Funktion unten).
// Eine eventuelle Tasteneingabe wird immer weitergeleitet (key), damit die Funktion sie
// bei Bedarf, also vor der Bewegung, auswerten kann.
// Falls man eine '5' eingegeben hat, wechselt der zustand auf "PAUSE"
//
// PAUSE: Macht genau, was ihr Name verspricht: Nix, außer auf eine Störung durch eine erneute
// beliebige Tasteneingabe zu warten... Dann allerdings geht's sofort mit DINOLAEUFT weiter.
//
// GAMEOVER: Zeigt nur die traurige Game-Over-Meldung und die Restart-Möglichkeit an und geht
// ohne weitere Umstände zum Zustand ANSAGE. Und der ist ja ein williger Gehilfe...
void zustandsMaschine() {
char key = keypad.getKey();
switch (zustand) {
case ANSAGE:
if (key == '0') {
startGame();
zustand = DINOLAEUFT;
}
break;
case DINOLAEUFT:
bewegeDino(key);
if (key == '5'){
zustand = PAUSE;
}
break;
case PAUSE:
if (key != NO_KEY) {
zustand = DINOLAEUFT;
}
break;
case GAMEOVER:
gameOver();
zustand = ANSAGE;
break;
}
}
// bewegeDino hat es schon in sich, hier "spielt die Musik"
//
// Die Funktion lässt sich von der Zustandsmaschine die jeweils aktuelle Tasteneingabe übergeben
// Sollte aKey nicht für "nix gedrückt seit dem letzten Aufruf" stehen, also ungleich NO_KEY sein,
// merken wir uns den Wert in der globalen Variablen richtung. Diese muss global sein, damit sie
// auch beim nächsten Aufruf von bewegeDino() noch ihren alten Wert behält. Lokale Variable verfallen
// bekanntermaßen beim Verlassen der Funktion ihre Inhalte, globale (wie auch static/statische) nicht.
//
// Jetzt kommt die berühmt-berüchtigte millis()-Funktion ... Es ranken sich diverse Legenden darum,
// wie sie funktioniert, wo und wann nicht usw. ...
//
// Man nehme ... eine if-Anweisung ... deren Inhalt nur ausgeführt wird, wenn
//
// die aktuelle Controller-Zeit [ms] abzüglich des Zeitpunktes der letzten Ausführung einer Aktion
// millis() - zuletztBewegt
//
// größer oder gleich der gewünschten Zeitspanne zeitProSchritt ist.
//
// Warum man das so und zwar genauso(!) schreiben sollte, ergibt sich aus dem mathematischen
// Wunder der Binärzahlenrechnung, die auch bei Überläufen der millis() Funktion funktioniert.
//
// Oder an einem vereinfachten Überlaufbeispiel mit Uhrzeiten ausgedrückt:
//
// Endtermin Starttermin Ergebnis
// Wieviele Stunden liegen zwischen 15 Uhr und 13 Uhr 15 - 13 = 2 Stunden
// Wieviele Stunden liegen zwischen 01 Uhr und 22 Uhr 01 - 22 = -21 Stunden?????
//
// Da Zeit (nach menschlicher Kenntnis) nur in eine Richtung verläuft, korrigiert man die -21 h
// durch Addition von 24 h -> 24h + (-21h) = 3 h, schon stimmt's.
// So (zumindest im Ergebnis) sorgt die o.a. Schreibweise immer für richtige Ergebnisse, auch
// wenn millis() zwischendurch im Betrieb(!) wieder mit 0 ms beginnt. Passiert so ca. alle 50 Tage
// bei einem vorzeichenlosen 32-Bit Zähler.
// Näheres siehe:
// https://www.norwegiancreations.com/2018/10/arduino-tutorial-avoiding-the-overflow-issue-when-using-millis-and-micros/
//
//
// Nun weiter: Wenn wir tatsächlich, z.B. aller lächerliche 500 ms mal in die bedingten Funktionen
// hineindürfen, dann merken wir uns diese Zeit in zuletztBewegt. Schliesslich sollen es die nächsten
// Schleife nicht einfacher haben, als wir ...
//
// Danach löschen wir mal den Dino an seinem "alten Platz".
//
// Wir prüfen den Inhalt von "richtung" darauf, ob in der Zwischenzeit, während wir auf das Verstreichen
// der zeitProSchritt warten mussten, die Tasten '2' oder '8' eingegeben wurden.
//
// Im Falle der '2' lupfen wir Dino auf die ober Zeile und löschen richtung, denn das soll ja nur einmal
// pro Tastendruck geschehen.
// Im Falle der '8' bewegen wir Dino wieder auf die untere Ebene und löschen den schritteOben-Zähler,
// er ist ja jetzt wieder unten. Ausserdem löschen wir auch hier die Tasteneingabe.
//
// Sollte Dino bereits einen mehr als die erlaubten Schritte gemacht haben, zieht die Schwerkraft
// (oder ersatzweise wir in diesem Fall) ihn wieder nach unten. Auch hier löschen wir den Schrittzähler.
//
// Jetzt macht Dino tatsächlich einen Schritt nach vorne: dino.x++;
//
// Nun, dann prüfen wir mal, ob da nicht gerade schon das Hindernis ist ... die boolsche
// Funktion kollision() gibt true zurück, wenn beide jetzt das gleiche Feld x auf der gleichen
// Ebene y belegen. In diesem Fall "Gute Nacht" oder neudeutsch GAMEOVER.
//
// Falls nicht, prüfen wir zunächst, ob dino.x jetzt größer/gleich 16 ist. Falls ja,
// schieben wir ihn auf das Feld links unten und lassen uns das Hindernis an eine neue
// Stelle setzen (setzeHindernis(true)). Das (true) veranlasst diese Funktion dazu, das
// "alte" Hindernis zunächst zu löschen, bevor das neue gesetzt wird.
//
// Ist das geschafft (oder noch nicht erforderlich), erscheint Dino an seiner neuen Position.
// Sollte er dabei in "höheren Gefilden" schweben, zählen wir seine schritteOben gleich
// hier mal mit..
void bewegeDino(char aKey) { // automatische Bewegung des ">" (Dinos) nach rechts Richtung Hinderniss "#"
if (aKey != NO_KEY) {
richtung = aKey;
}
if (millis() - zuletztBewegt >= zeitProSchritt) {
zuletztBewegt = millis();
lcd.setCursor(dino.x, dino.y);
lcd.print(" ");
if (richtung == '2') {
dino.y = 0;
richtung = NO_KEY;
}
if (richtung == '8') {
dino.y = 1;
richtung = NO_KEY;
schritteOben = 0;
}
if (schritteOben > erlaubterSprung) {
dino.y = 1;
schritteOben = 0;
}
dino.x++;
if (kollision()) {
zustand = GAMEOVER;
Serial.println("Game Over");
} else {
if (dino.x >= 16) {
dino.x = 1;
dino.y = 1;
setzeHindernis(true);
}
lcd.setCursor(dino.x, dino.y);
lcd.print(">");
if (dino.y == 0) {
schritteOben++;
}
}
}
}
// Eigentlich selbsterklärend:
// Gibt true zurück, wen die verglichenen Koordinaten paarweise identisch
// sind, mithin also hindernis und dino den gleichen Platz einnehmen,
// was bei massiven Körpern zu bekannten Problemem führt und daher
// zu vermeiden ist ... ;-)
boolean kollision() {
return (dino.x == hindernis.x && dino.y == hindernis.y);
}
// Dies Funktion hat es relativ einfach und gemütlich,
// da sie im Vergleich zu anderen in der loop() selten
// arbeiten muss.
//
// Falls gewünscht (loeschen == true), lässt sie das Hindernis
// an seiner zuletzt bekannten Stelle verschwinden
//
// Danach hilft ihr die Pseudozufallsfunktion random() dabei
// einen neuen schönen Platz für das Hindernis zu erwürfeln
// Der Wert soll minimal 5 und maximal 15 sein, bei random(a,b)
// ist a inklusiv, b aber exklusiv (wird also nicht gewürfelt)
// daher steht dort eine 16.
//
// Nun wird das Hindernis am neuen Platz auf dem LCD ausgegeben
// und die Variable Level erhöht. Das Spiel startet immer mit
// dem Level 1, indem "level" zuvor geschickterweise auf Null gesetzt wird.
// Solange der Wert von level kleiner als 10 ist (also 1...9), reduziert sich
// im folgenden Schritt die zeitProSchritt von 550 - 50 = 500 ms bei Level 1
// schrittweise bis 550 - 9*50 = 550 -450 = 100 ms = 1/10 s
// Für den Interessierten geben wir das Level noch auf der seriellen Schnittstelle aus.
// Das erreichte Level könnte allerdings auch - wenn man es noch einbaut -
// als kleiner Trost bei GameOver ausgegeben werden.
void setzeHindernis(boolean loeschen) {
if (loeschen) {
lcd.setCursor(hindernis.x, hindernis.y);
lcd.print(" ");
}
hindernis.x = random(5, 16);
lcd.setCursor(hindernis.x, hindernis.y);
lcd.print("#");
level++;
if (level < 10) {
zeitProSchritt = 550 - level * 50;
}
Serial.print("Level : ");
Serial.println(level);
}
// startGame hat es noch ruhiger als setzteHindernis.
// Es bereitet das LCD, Hindernis und einige Spielstart-relevante Variable auf einen
// frischen Start vor.
void startGame() { //">" und "#", Dino und Hindernis werden auf das Display geprintet
Serial.println("Dino läuft ..");
lcd.clear();
schritteOben = 0;
level = 0;
zeitProSchritt = 550;
dino.x = 1;
dino.y = 1;
lcd.setCursor(dino.x, dino.y);
lcd.print(">");
setzeHindernis(false);
}
// gameOver() hat die traurige Aufgabe, das Ende einer steilen Dino-Karriere anzuzeigen.
// A dirty job but someone's got to do it ... Wie der des Englischen fähige Franzose sagt.
// Die weitere Arbeit muss dann wieder die ANSAGE erledigen, aber selbst das überlässt man
// hier der zustandsMaschine() ...
void gameOver() { // GAME-OVER screen
lcd.clear();
lcd.setCursor(0, 0);
lcd.print(" GAME OVER");
lcd.setCursor(0, 1);
lcd.print("Restart with 0");
}
// Das hier wird im Code nicht benötigt, ausser man möchte irgendwelchen Mauscheleien
// des Codes auf den Grund gehen, der einfach Kollisionen behauptet, obwohl man doch
// ganz sicher noch rechtzeitig die '2' gedrückt hatte... die '2' ... ja ... und der
// Dino war oben, ganz sicher, ich schwör' ...
//
// Also, falls die Daten mal geprüft werden müssen,
// an passender Stelle printPos() aufrufen ...
void printPos() {
Serial.print("dino.x ");
Serial.println(dino.x);
Serial.print("dino.y ");
Serial.println(dino.y);
Serial.print("hindernis.x ");
Serial.println(hindernis.x);
Serial.print("hindernis.y ");
Serial.println(hindernis.y);
}