Ich diesem Kapitel wollen wir das Spiel Asteroids programmieren, ein Klassiker von der Firma Atari aus dem Jahr 1979 und, laut Wikipedia, "einer der größten Erfolge aller Zeiten in der Geschichte der Computerspiele". Sehen Sie sich diese Online-Version an.
Wir benötigen unsere Kenntnisse über Ober- und Unterklassen und Verebung aus dem vorigen Kapitel, sowie ein paar neue Konzepte, wie Zustände und Listen.
P3.1 Klassen und Zustände
Mit diesem geballten Vorwissen kommen wir jetzt zu Asteroids. Wir benötigen drei Klassen: Ship (das Raumschiff), Asteroid und Bullet (für die Schüsse).
Alle drei Klassen realisieren Objekte, die durch den Raum fliegen, mit einer Position und einer Geschwindigkeit. Beides lässt sich jeweils durch einen Vektor (Klasse PVector) wunderbar ausdrücken.
Das wir eine wichtige Gemeinsamkeit gefunden haben, halten wir das gleich in einer (abstrakten) Klasse fest. Das Klassendiagramm sieht wie folgt aus:
Die Methode update() ist diejenige Methode, die immer in draw() aufgerufen wird, um die Position eines Objekts anzupassen. Die Methode render() hingegen zeichnet das Objekt, deshalb muss jede Unterklasse von FlyingThing diese Methode implementieren, sie ist in FlyingThing selbst als abstract gekennzeichnet (render ist englisch für machen/übergeben - bedeutet in der Computergrafik "grafisch konkret umsetzen"). Im Klassendiagramm wird eine abstrakte Methode durch die kursive Schrift ausgedrückt.
Klasse FlyingThing
Alle Objekte in dem Spiel sollen, sobald Sie den Bildschirmrand erreichen, auf der anderen Seite des Bildschirms wieder auftauchen. Diese Eigenschaft programmieren wir in die update()-Methode von FlyingThing.
// Abstrakte Klasse, // d.h. aus dieser Klasse selbst // kann man keine Objekte erzeugen abstract class FlyingThing { PVector pos; PVector speed; FlyingThing(PVector pos, PVector speed) { // Vektoren kopieren mit get() this.pos = pos.get(); this.speed = speed.get(); } // Berechne neue Position void update() { pos.add(speed); if (pos.x > width) { pos.x = 0; } if (pos.y > height) { pos.y = 0; } if (pos.x < 0) { pos.x = width; } if (pos.y < 0) { pos.y = height; } } // Zeichne Objekt // (muss von Unterklasse implementiert werden) abstract void render(); }
Klasse Ship und Steuerung mit Schub
Die Klasse Ship hat außerdem noch eine Ausrichtung (Winkel). Außerdem kann das Raumschiff per Cursortaste Schub bekommen (engl. thrust).
class Ship extends FlyingThing { float angle = 0; // ergänze Ausrichtung des Schiffs Ship(PVector pos, PVector speed) { super(pos, speed); } // Überschreibe Zeichenmethode: // Dreieck mit Ausrichtung void render() { pushMatrix(); translate(pos.x, pos.y); rotate(angle); stroke(255); triangle(-10, 10, 0, -20, 10, 10); popMatrix(); } // Schub geben in Richtung des Schiffs (angle) // der Parameter "amount" gibt die Größe des Schubs an void thrustForward(float amount) { PVector thrust = new PVector(0, -amount); // pointing up thrust.rotate(angle); speed.add(thrust); } }
Im Code sehen Sie, wie der Schub umgesetzt wird. Schematisch passiert folgendes:
Der Vektor speed zeigt in die aktuelle Flugrichtung (Bild a). Dann dreht der Benutzer das Schiff (Bild b) und gibt Schub (Methode thrustForward). In der methode wird ein Vektor thrust erstellt. Dann wird der Vektor gedreht, so dass er wirklich aus der Raumschiffspitze schaut (roter Pfeil in Bild c). Erst dann wird der Schubvektor auf den Geschwindigkeitsvektor addiert - der resultierende Vektor ist die neue Flugrichtug (grüner Pfeil in Bild d).
Klassen Asteroid und Bullet
Ein Asteroid ist ein einfaches Wesen, es fügt der Oberklasse lediglich eine Größe (engl. size) hinzu. Wir schreiben uns einen komfortablen Konstruktor, der Position und Speed-Vektor zufällig erzeugt und dem Konstruktor der Oberklasse (FlyingThing) übergibt.
class Asteroid extends FlyingThing { float size; Asteroid() { // Übergebe Konstruktor der Oberklasse zufällige // Position und Geschwindigkeit super(new PVector(random(0,width), random(0,height)), new PVector(random(-1,1), random(-1,1))); size = random(10,30); } void render() { stroke(255); ellipse(pos.x, pos.y, size, size); } }
Genauso wie die Kugeln (engl. bullet), die das Raumschiff abfeuern kann.
class Bullet extends FlyingThing { Bullet(PVector pos, PVector speed) { super(pos, speed); } void render() { strokeWeight(3); stroke(255,255,0); point(pos.x, pos.y); strokeWeight(1); } }
Konstanten mit final
Im Hauptprogramm benutzen wir Zustände, um den aktuellen Modus des Spiels festzuhalten. Wir definieren eine Integeger-Variable für den Zustand:
int state; // aktueller Zustand des Spiels
Im Zustand kodieren wir, ob das Spiel läuft (= 0) oder pausiert (= 1). Mit der Taste 'p' schaltet man zwischen pause/play um. Später werden wir auch Gewinnen und Verlieren über Zustände modellieren.
Das Unschöne an solchen Zuständen ist, dass man im weiteren Verlauf des Codes nicht direkt sieht, was genau "Zustand 0" oder "Zustand 1" bedeuten.
Daher werden oft Konstanten verwendet, um diese Zahlen zu repräsentieren. Sie definieren einfach zwei weitere Integer-Variablen, die gar nicht variabel sind, sondern immer fix bleiben:
final int PLAY = 0; final int PAUSE = 1;
Das Schlüsselwort
final
bedeutet, dass die Variablen
im Code nicht verändert werden dürfen, sonst bekommen Sie
eine Fehlermeldung. Deshalb nennen wir diese Variablen auch
Konstanten. Sie kennen Konstanten bereits, wie z.B. in
rectMode(CENTER); // CENTER ist eine Konstante! if (keyCode == LEFT) { // genauso wie LEFT ... }
Diese Platzhalter von Processing, sind in Wirklichkeit Zahlen! Sie sehen hier eine allgemeine Konvention in Java: Konstanten-Namen werden IN GROSSBUCHSTABEN geschrieben.
Jetzt also unser Hauptprogramm mit Zuständen und Konstanten. Beachten Sie auch, dass alle Objekte, also das Raumschiff und Asteroiden in einer Liste gehalten werden. Wir verwenden hier wieder boolesche Variablen zur gleichzeitigen Abfrage mehrerer Tasten (keyLeft, keyRight...).
// Definition von Konstanten und Variablen: final int PLAY = 0; final int PAUSE = 1; int state = PLAY; ArrayList<FlyingThing> things = new ArrayList<FlyingThing>(); Ship ship; boolean keyLeft, keyRight, keyUp, keyDown; // Jetzt die Funktionen: void setup() { size(400, 400); initGame(); } void initGame() { ship = new Ship(new PVector(width/2, height/2), new PVector()); things.add(ship); // generate asteroids for (int i = 0; i < numAsteroids; i++) { things.add(new Asteroid()); } state = PLAY; } void draw() { background(0); noFill(); stroke(255); switch (state) { case PLAY: for (FlyingThing thing: things) { thing.update(); thing.render(); } checkKeys(); break; case PAUSE: for (FlyingThing thing: things) { thing.render(); } break; } void checkKeys() { if (keyLeft) { ship.angle -= radians(2); } if (keyRight) { ship.angle += radians(2); } if (keyUp) { ship.thrustForward(.1); } if (keyDown) { ship.thrustForward(-.1); } } void keyPressed() { switch (keyCode) { case UP: keyUp = true; break; case DOWN: keyDown = true; break; case LEFT: keyLeft = true; break; case RIGHT: keyRight = true; break; } if (state == PLAY || state == PAUSE) { if (key == 'p') { if (state == PLAY) { state = PAUSE; } else { state = PLAY; } } } } void keyReleased() { switch (keyCode) { case UP: keyUp = false; break; case DOWN: keyDown = false; break; case LEFT: keyLeft = false; break; case RIGHT: keyRight = false; break; } }
Jetzt haben Sie die wichtigsten Objekte auf dem Spielfeld, aber das Raumschiff wird noch nicht von den Asteroiden bedroht...
P3.2 Game Over: Kollision mit Asteroiden
In dem Spiel geht es darum, nicht mit einem Asteroid zu kollidieren. Das heißt, man muss ständig testen, ob einer der Asteroiden mit dem Schiff kollidiert. Präziser gesagt: in jedem draw()-Aufruf testen wir für alle Asteroiden, ob sie sich mit dem Schiff überschneiden. Der Einfachheit halber nehmen wir an, das Schiff sei ein Kreis.
Dann tritt genau dann eine Überschneidung auf, wenn die Distanz zwischen Schiff und Asteroiden kleiner als die Summe aus (Radius Schiff) und (Radius Asteroid) ist.
Wir schreiben in der Klasse Ship eine Methode, die testet, ob wir mit einem Asteroiden, der als Parameter übergeben wird, kollidieren (der Radius des Schiffs ist hier 20 Pixel):
boolean checkCollision(Asteroid ast) { if (pos.dist(ast.pos) < (ast.size/2 + 20)) { return true; } else { return false; } }
Im Hauptprogramm nutzen wir diese Methode, wenn wir alle Kollisionen testen. Bei einer einzigen vorliegenden Kollision, gehen wir in den neuen Zustand LOSE über (= 2).
final int LOSE = 2; // Zustand: verloren!Beachten Sie, dass wir mit instanceof testen müssen, ob wir einen Asteroiden haben. Schließlich sind in der List things auch das Raumschiff und später auch die Kugeln (bullets).
void checkCollision() { boolean coll = false; for (FlyingThing thing: things) { if (thing instanceof Asteroid) { if (ship.checkCollision((Asteroid)thing)) { coll = true; } } } if (coll) { state = LOSE; } }
Wir ergänzen unseren Switch in draw(), um den neuen Zustand darzustellen. Es erscheint "GAME OVER". Hier sehen Sie auch den Aufruf von checkCollision() in Zustand 0 (play).
switch (state) { case PLAY: for (FlyingThing thing: things) { thing.update(); thing.render(); } checkKeys(); checkCollision(); break; case PAUSE: for (FlyingThing thing: things) { thing.render(); } break; case LOSE: textAlign(CENTER); textSize(40); text("GAME OVER!\n(press enter)", width/2, height/2); break; }
Wir wollen bei Tastendruck ENTER wieder ein Spiel starten. Das müssen wir in keyPressed() behandeln. Wir rufen initGame() auf, wo auch der Zustand wieder auf 0 gesetzt wird.
void keyPressed() { ... if (keyCode == ENTER) { if (state == LOSE) { initGame(); } } ... }
P3.3 Die Gewinnbedingung
Bis jetzt können wir nur verlieren, indem wir mit einem Asteroiden kollidieren. Jetzt schießen wir zurück!
Wir wollen bei Druck der Leertaste eine Kugel abfeuern und zwar in die Richtung, in die das Raumschiff zeigt. Wir lassen am besten das Schiff selbst die Kugel (Objekt) erzeugen. Das Objekt muss aber im Hauptprogramm der Liste "things" hinzugefügt werden, damit die Kugeln auch geupdatet und gezeichnet werden.
// In Klasse "Ship": Bullet fire() { PVector sp = new PVector(0, -4); // Geschwindigkeit 4 sp.rotate(angle); Bullet b = new Bullet(pos.get(), sp); return b; }
Im Hauptprogramm rufen wir diese Methode beim Tastendruck der Leertaste auf, also in keyPressed(). Natürlich führen wir die Aktion nur im Zustand 0 (play) durch.
// Hauptprogramm: void keyPressed() { ... if (state == 0 && key == ' ') { Bullet b = ship.fire(); things.add(b); } }
super beim Methodenaufruf
Unsere Kugeln fliegen jetzt unbegrenzt durch den Bildschirm. Wir wollen aber, dass die Kugeln verschwinden, sobald sie den Bildschirmrand erreichen.
Dazu führen wir eine neue Instanzvariable "alive" bei FlyingThing
ein. Wenn diese Variable
false
ist, wird das Objekt
beim zeichnen ignoriert. Am Anfang ist sie natürlich erstmal
true
.
abstract class FlyingThing { PVector pos; PVector speed; boolean alive = true; ... }
Jetzt müssen wir bei einer Kugel (Bullet) prüfen, ob sie den Bildschirmrand
erreicht. Das machen wir am besten in udpate(). Das Problem ist: wenn wir
update() überschreiben, wird das schöne update() der Oberklasse FlyingThing
nicht mehr aufgerufen. Dort haben wir ja den ganzen Code, um die Objekte zu bewegen.
Am liebsten würden wir den update()-Code der Oberklasse und einigen
Zusatz-Code ausführen. Dies geht mit dem Schlüsselwort
super
.
Damit können Sie die Methode der Oberklasse aufrufen. In
Bullet
schreiben
wir also:
void update() { if (pos.x <= 0 || pos.x >= width || pos.y <= 0 || pos.y >= height) { alive = false; } super.update(); }
Sie sehen, dass
super
so ähnlich wie
this
funktioniert.
Zu beachten ist, dass es auch hier zwei Varianten gibt:
- super() als Funktion (mit Klammern), um den Konstruktor der Oberklasse aufzurufen
- super als Variable, die das eigene Objekt als Instanz der Oberklasse enthält
Damit unser Trick mit
alive
funktioniert, müssen wir noch einiges anpassen.
Es dürfen nur things geupdatet und gezeichnet werden, die "am Leben" sind:
// Hauptprogramm, in draw() for (FlyingThing thing: things) { if (thing.alive) { thing.update(); thing.render(); } }
Kollision Kugel-Asteroid
Wir können schießen, aber die Asteroiden merken nichts davon.
Wir erweitern unsere Funktion
checkCollision()
, so
dass auch getestet wird, ob eine Kugel mit einem Asteroiden kollidiert:
void checkCollision() { boolean coll = false; for (FlyingThing thing: things) { // nur lebende Objekte sind interessant if (thing.alive) { if (thing instanceof Asteroid) { if (ship.checkCollision((Asteroid)thing)) { coll = true; } } if (thing instanceof Bullet) { // prüfe alle Asteroiden for (FlyingThing thing2: things) { if (thing2.alive && thing2 instanceof Asteroid) { Bullet b = (Bullet)thing; Asteroid a = (Asteroid)thing2; // bei Kollision den Asteroiden auf tot schalten if (b.pos.dist(a.pos) < a.size/2) { a.alive = false; } } } } } } if (coll) { gameOver = true; } }
Sie sehen, dass Sie für jede Kugel nochmal durch alle Objekte laufen müssen. Das kostet viel Zeit und ist immer wieder ein Grund, warum Spiele so viel Rechenpower benötigen: die Kollisionsprüfung zwischen Objekten.
Jetzt können wir auch endlich das Spiel gewinnen, indem wir alle Asteroiden abschießen. Dazu führen wir einen neuen Zustand WIN (= 4) ein:
final int WIN = 4;
In jedem draw() testen wir, ob der Gewinnzustand
erreicht ist, und schreiben dazu eine
Funktion
checkWin()
void checkWin() { boolean win = true; // sobald ich einen lebenden Asteroiden finde, // bin ich noch nicht fertig for (FlyingThing thing: things) { if (thing instanceof Asteroid && thing.alive) { win = false; } } if (win) { state = WIN; // neuer Zustand: Gewonnen! } }
Aufräumen
Sie erzeugen beim Spielen viele Objekte (Kugeln). Das könnte zu Problemen führen (Speicherplatz). Daher sollten wir hin und wieder die Objekte entsorgen, die nicht mehr "alive" sind.
WICHTIG: In einer For-Schleife, in der wir eine Liste durchlaufen dürfen wir nicht den Befehl remove() verwenden, um Objekte aus dieser Liste zu entfernen.
Unsere Strategie ist daher, eine neue Liste zu erzeugen, die alle noch lebenden Objekte enthält. Der Variablen things wird dann diese neue Liste zugewiesen.
void collectGarbage() { println("+++ collecting garbage +++"); ArrayList<FlyingThing> newThings = new ArrayList<FlyingThing>(); for (FlyingThing thing: things) { if (thing.alive) { newThings.add(thing); } } things = newThings; println("+++ done +++"); }
Wir nennen diesen Vorgang mal "garbage collection", obwohl bei Programmiersprachen etwas anderes (aber durchaus ähnliches) damit gemeint ist.
Wir rufen diese Funktion z.B. immer dann auf, wenn geschossen wird und wir feststellen, dass wir schon viele "things" haben:
if (things.size() > 30) { collectGarbage(); }
Wir haben die Zahl mal auf 30 gesetzt, damit Sie ab und zu die "garbage collection" Meldung auf der Konsole sehen.
P3.4 Speichern und Laden: Serialisierung von Objekten
Sie möchten das Spiel nicht nur pausieren, sondern auch den Zustand speichern. Später möchten Sie den Spielzustand vielleicht auch übers Internet an einen Freund schicken, um gemeinsam zu spielen. Insofern ist es gut zu wissen, wie das geht.
Am liebsten würden Sie sagen: speichere alle Objekte in der Liste "things". Das Problem ist: man kann Objekte nicht ohne weiteres speichern. Sie müssen selbst dafür sorgen, ein Objekt in Text zu übersetzen. Der Text wird dann gespeichert. Das Übersetzen eines Objekts in Text nennt man auch Serialisierung. Das Wort meint, dass man das Objekt "in eine Reihenfolge" bringt.
Damit jede Klasse selbst dafür verantwortlich ist, wie die
eigenen Objekte als Text aussehen, definieren wir eine Methode
getSerialisation()
in FlyingThing, wo wir die dort
wichtigen Infos abspeichern (Position und Speed-Vektor):
String getSerialization() { return pos.x + " " + pos.y + " " + speed.x + " " + speed.y; }
Wie sieht jetzt
getSerialisation()
für Ship aus?
String getSerialization() { return "ship " + super.getSerialization() + " " + angle; }
Sie sehen hier nochmal ein Beispiel dafür, dass eine Klasse eine Methode der Oberklasse aufruft. Ship schreibt den Hinweis "ship" und fügt dann die Infos von FlyingThing (Position + Speed) hinzu. Zum Schluss hängt Ship noch den Winkel (angle) an.
Bei Asteroid gibt es noch die
size
, die mit in die
Serialisierung muss:
String getSerialization() { return "asteroid " + super.getSerialization() + " " + size; }
Bei Bullet hingegen muss nur der Hinweis auf Bullet gegeben werden:
String getSerialization() { return "bullet " + super.getSerialization(); }
Jetzt können wir die Klasse zum Speichern schreiben:
class GameWriter { void writeGame() { // Schritt 1: lebende Dinge einsammeln ArrayList<FlyingThing> livingThings = new ArrayList<FlyingThing>(); for (FlyingThing thing: things) { if (thing.alive) { livingThings.add(thing); } } // Schritt 2: Array mit einem Objekt pro Index String[] lines = new String[livingThings.size()]; int i = 0; for (FlyingThing thing: livingThings) { lines[i] = thing.getSerialization(); i++; } saveStrings("state-of-the-game.txt", lines); } }
Die Klasse zum Lesen versuchen Sie bitte, selbst zu schreiben...
P3.5 Der Feind: Künstliche Intelligenz mit Zuständen
Unser nächster Schritt wird es sein, ein gegnerisches Raumschiff zu programmieren. Was soll unser Gegener können?
- Schüsse abgeben, in Richtung Raumschiff
- Unser Raumschiff verfolgen
- evtl. auch vor unserem Raumschiff fliehen (z.B. wenn selbst angeschossen)
Wir wollen unserem Gegner also ein etwas interessanteres, "menschenähnliches" Verhalten geben. Wir programmieren also eine sehr einfache "Künstliche Intelligenz". Damit wir uns in unserem mittlerweile recht komplexen Code von Asteroids nicht verzetteln, schreiben wir ein neues Programm, in dem wir unser Feindobjekt entwickeln. Die Position des Mauszeigers soll unser Raumschiff sein.
In unserer Klasse
Enemy
unterscheiden wir zwischen zwei Zuständen:
follow (state 0) und escape (state 1). Im Zustand follow, soll
mein Feind der Maus folgen (wir nehmen die Maus als Platzhalter
für unser Raumschiff) und im Zustand escape soll der Feind sich
von der Maus wegbewegen.
class Enemy extends FlyingThing { int state = 0; // 0: follow, 1: escape Enemy() { super(new PVector(random(0, width), random(0, height)), new PVector(random(-1, 1), random(-1, 1))); } void update() { super.update(); switch (state) { case 0: followMouse(); break; case 1: escapeFromMouse(); break; } } void followMouse() { PVector newDir = getVectorTo(mouseX, mouseY); speed = newDir; } void escapeFromMouse() { PVector newDir = getVectorTo(mouseX, mouseY); newDir.mult(-1); speed = newDir; } PVector getVectorTo(float x, float y) { PVector newDir = new PVector(x, y); newDir.sub(pos); newDir.normalize(); newDir.mult(1); return newDir; } void render() { noStroke(); switch (state) { case 0: fill(#FF8B93); break; case 1: fill(#9BEFFF); break; } ellipse(pos.x, pos.y, 30, 30); }
Das Hauptprogramm:
Enemy enemy = new Enemy(); void setup() { size(400, 400); } void draw() { background(0); if (play) { enemy.update(); } enemy.render(); } void mousePressed() { enemy.state = 1- enemy.state; }
Wir haben ein Problem mit Zustand "escape": unser Feind bleibt immer am Rand hängen. Ein Lösung ist, ein Sichtfeld einzuführen, da unser Feind ja nicht unendlich weit sehen kann. Nur wenn die Maus innerhalb des Sichtfelds ist, greift eine der beiden Verhaltensweisen.
class Enemy extends FlyingThing { int state = 0; // 0: follow, 1: escape float senseRadius = 100; ... void update() { super.update(); switch (state) { case 0: if (pos.dist(new PVector(mouseX, mouseY)) < senseRadius) { followMouse(); } break; case 1: if (pos.dist(new PVector(mouseX, mouseY)) < senseRadius) { escapeFromMouse(); } break; } } ... void render() { noStroke(); switch (state) { case 0: fill(#FF8B93); break; case 1: fill(#9BEFFF); break; } ellipse(pos.x, pos.y, 30, 30); // Zeigt das Sichtfeld stroke(255, 255, 0); noFill(); ellipse(pos.x, pos.y, senseRadius*2, senseRadius*2); }