Lernziele
- Softwareprojekt objektorientiert planen
- Einteilung von Klassen im Rahmen eines Computerspiels
- Funktionalität auf Klassen verteilen (in Methoden)
- Umsetzung eines geplanten Projekts in Code
- 02.08.2021: Neue Kapitelnummerierung
Dieses Kapitel enthält optionales Zusatzmaterial zur Vorlesung. In dem Kapitel wird das Computerspiel "Pacman" systematisch mit Hilfe von Klassen entwickelt.
P1.1 Planung
Wir gehen nach einem vereinfachten Plan vor. Die Methodik nennt man auch Softwaretechnik oder Software Engineering. Grob gesagt besteht unser Plan aus zwei Schritten:
- Software-Design: Hier planen wir unsere Klassen - auf dem Papier!
- Implementation: Erst dann schreiben wir Code
Software-Design
Vielleicht hatten Sie bei den bisherigen Übungen schon das Gefühl, dass man das gleiche Problem auf viele verschiedene Weisen lösen kann. Allerdings können ungünstige Lösungswege das Erweitern und Wiederverwenden des Codes sehr erschweren. Deshalb spricht man von "Design": Man trifft genauso wie in der Architektur aus einer Vielzahl von möglichen Lösungswegen bestimmte Entscheidungen, die sich in der Zukunft günstig auswirken. Sei es, dass der Code in der Zukunft besser verstanden, schneller repariert, effizienter erweitert oder in anderen Projekten wiederverwendet werden kann.
Wir entscheiden uns, mit Klassen zu arbeiten. Teile eines Pacman-Programms könnte man sicher in anderen Spielen gut wiederverwenden. Wie sehen die Klassen aus? Man bestimmt drei Aspekte:
- Name der Klasse
- Eigenschaften (Instanzvariablen, inkl. Typ)
- Methoden (inkl. Rückgabetyp und Parameterliste)
Für unser Pacman-Programm könnten wir uns erstmal drei Klassen überlegen:
Wir geben für Pacman den Durchmesser an (engl. diameter), weil er sich auf einem Raster bewegen soll. Der Durchmesser von Pacman entspricht der Größe einer Rasterzelle.
Implementation
Bevor Sie mit dem Code-Schreiben beginnen, brauchen Sie einen Plan, in welcher Reihenfolge Sie den Code schreiben. Eine Möglichkeit, das zu tun, ist, das Programm schrittweise aufzubauen. Ein solcher Schritt heißt Meilenstein und es ist wichtig, dass Sie sich bewusst sind, welche Funktionalität jeder Meilenstein haben sollte.
Das Pacman-Spiel könnte man in drei Meilensteine zerlegen:
-
Meilenstein 1
- Pacman auf schwarzem Hintergrund
- bewegt sich auf einem Raster
- kann mit Cursortasten gesteuert werden
- ist animiert (Mund auf+zu)
-
Meilenstein 2
- Punkte sind zufällig verteilt
- können von Pacman gegessen werden
- der Score wird entsprechend angepasst
- wenn alle Punkte weg sind, ist das Spiel gewonnen
-
Meilenstein 3
- Geister bewegen sich zufällig übers Feld und können Pacman fressen, entsprechend wird ein "Leben" abgezogen
- wenn alle Leben weg sind, ist das Spiel verloren
P1.2 Meilenstein 1: Pacman
In unseren ersten Meilenstein haben wir einen Animationszyklus eingebaut, der jede halb Sekunde den Mund bewegt.
class Pacman { int x; int y; int durchmesser; int state = 0; float animTimerSec = 0; float ANIM_CYCLE = .5; Pacman(int d) { durchmesser = d; x = d/2; y = d/2; } void draw() { noStroke(); fill(#FFE624); animTimerSec += 1.0/frameRate; if (animTimerSec >= ANIM_CYCLE) { animTimerSec = 0; state++; state = state % 2; } if (state == 0) { ellipse(x, y, durchmesser, durchmesser); } else if (state == 1) { arc(x, y, durchmesser, durchmesser, radians(30), radians(330)); } fill(0); ellipse(x+durchmesser/6, y-durchmesser/3, 8, 8); } void moveLeft() { x -= durchmesser; x = constrain(x, durchmesser/2, width-durchmesser/2); } void moveRight() { x += durchmesser; x = constrain(x, durchmesser/2, width-durchmesser/2); } void moveUp() { y -= durchmesser; y = constrain(y, durchmesser/2, height-durchmesser/2); } void moveDown() { y += durchmesser; y = constrain(y, durchmesser/2, height-durchmesser/2); } }
Das Hauptprogramm könnte wie folgt aussehen. Ich habe die Verwendung unseres Pacman-Objekts hervorgehoben:
int cellSize = 40; Pacman pacman = new Pacman(cellSize); void setup() { size(10*cellSize, 10*cellSize); // 10x10-Raster } void draw() { background(0); pacman.draw(); } // Tastatursteuerung void keyPressed() { if (keyCode == LEFT) { pacman.moveLeft(); } if (keyCode == RIGHT) { pacman.moveRight(); } if (keyCode == UP) { pacman.moveUp(); } if (keyCode == DOWN) { pacman.moveDown(); } }
Sie haben jetzt einen steuerbaren Pacman mit animiertem Mund. Meilenstein 1 ist geschafft.
P1.3 Meilenstein 2: Punkte
So ein Punkt ist ein relativ einfaches Wesen: Er wird gezeichnet, solange
er noch nicht gefressen wurde.
Das halten wir in einer
booleschen Eigenschaft
alive
fest.
class Punkt { int x; int y; int wert = 5; boolean alive = true; Punkt(int px, int py) { x = px; y = py; } void draw() { // zeichne nur, wenn noch nicht gefressen if (alive) { noStroke(); fill(#FF52F4); ellipse(x, y, 10, 10); } } }
Im Hauptprogramm erzeugen wir 40 Punkte und speichern sie in einem Array. Dann verteilen wir die Punkte zufällig über das Spielfeld und malen sie. Wir führen auch einen Score ein, der erhöht wird, wenn man einen Punkt frisst.
int score = 0; int cellSize = 40; Pacman pacman = new Pacman(cellSize); Punkt[] punkte = new Punkt[30]; void setup() { size(10*cellSize, 10*cellSize); for (int i = 0; i < punkte.length; i++) { int x = (int)random(0, 10) * cellSize + cellSize/2; int y = (int)random(0, 10) * cellSize + cellSize/2; punkte[i] = new Punkt(x, y); } } void draw() { background(0); pacman.draw(); for (int i = 0; i < punkte.length; i++) { punkte[i].draw(); } }
Jetzt müssen wir noch die Klasse
Pacman
anpassen:
Pacman muss die Möglichkeit haben, einen Punkt zu fressen. Wenn
Pacman auf dem gleichen Feld ist wie der Punkt, frisst er ihn.
Dazu geben wir Pacman eine Methode tryToEat():
// Gibt true zurück, wenn Punkt erfolgreich gegessen boolean tryToEat(Punkt p) { // Punkt ist bereits gegessen if (!p.alive) { return false; } // Wenn auf gleichem Feld: Punkt essen if (dist(p.x, p.y, x, y) < = durchmesser/2) { p.alive = false; // Lecker, lecker return true; // Erfolgreich gegessen! } return false; // Nix gegessen }
Im Hauptprogramm bauen wir den tryToEat-Check in die draw()-Schleife ein. Beachten Sie, dass man hier in jedem draw()-Aufruf, also 60x pro Sekunde alle Punkte abprüft.
Bei erfolgreichem Aufessen wird der Score um den Wert des Punkts erhöht.
void draw() { background(0); pacman.draw(); for (int i = 0; i < punkte.length; i++) { boolean eaten = pacman.tryToEat(punkte[i]); if (eaten) { score += punkte[i].wert; } punkte[i].draw(); } }
Zusammen mit dem Code von oben, können Sie jetzt Pacman übers Feld steuern und Punkte fressen. Meilenstein 2 ist fertig!
P1.4 Meilenstein 3: Geister
Ein Geist funktioniert so ähnlich wie Pacman, nur dass er keine Animation hat und nicht vom Benutzer bewegt wird. Wir kopieren einfach den Code von Pacman und passen das Zeichnen etwas an. Der Körper besteht aus einem Kreis und einem Rechteck. Wir bewegen den Geist mit der Methode move(), die zufällig eine der vier eigentlichen Bewegungsmethoden aussucht (moveLeft, moveRight, moveUp, moveDown).
class Geist { int x; int y; int durchmesser; Geist(int gx, int gy, int d) { x = gx; y = gy; durchmesser = d; } void draw() { noStroke(); fill(#79D3FF); // Körper ellipse(x, y, durchmesser, durchmesser); rectMode(CORNER); rect(x-durchmesser/2, y, durchmesser, durchmesser/2); // Augen rectMode(CENTER); fill(0); rect(x-durchmesser/4, y, durchmesser/4, durchmesser/8); rect(x+durchmesser/4, y, durchmesser/4, durchmesser/8); } // Im Gegensatz zu Pacman wollen wir einen // zufälligen Bewegungsschritt ausführen void move() { int richtung = (int)random(0, 4); if (richtung == 0) { moveLeft(); } if (richtung == 1) { moveRight(); } if (richtung == 2) { moveUp(); } if (richtung == 3) { moveDown(); } } void moveLeft() { x -= durchmesser; x = constrain(x, durchmesser/2, width-durchmesser/2); } void moveRight() { x += durchmesser; x = constrain(x, durchmesser/2, width-durchmesser/2); } void moveUp() { y -= durchmesser; y = constrain(y, durchmesser/2, height-durchmesser/2); } void moveDown() { y += durchmesser; y = constrain(y, durchmesser/2, height-durchmesser/2); } }
Im Hauptprogramm verwalten wir Geister - wir nehmen erstmal drei - und, da Geister den Pacman fressen können, führen wir die "Leben" des Pacman ein:
// neue globale Variablen Geist[] geister = new Geist[3]; int leben = 3;
In setup() erzeugen wir die Geister mit zufälligen Positionen. Dabei lassen wir ein paar Positionen oben links (wo Pacman immer startet) bewusst aus, um Pacman nicht gleich zu Beginn zu fressen.
void setup() { size(10*cellSize, 10*cellSize); for (int i = 0; i < punkte.length; i++) { int x = (int)random(0, 10) * cellSize + cellSize/2; int y = (int)random(0, 10) * cellSize + cellSize/2; punkte[i] = new Punkt(x, y); } for (int i = 0; i < geister.length; i++) { int x = (int)random(3, 10) * cellSize + cellSize/2; int y = (int)random(3, 10) * cellSize + cellSize/2; geister[i] = new Geist(x, y, cellSize); } }
Damit die Geister Pacman fressen können, bauen wir in der Klasse Geist wieder einen Test ein, denn wir vom Hauptprogramm aus nutzen können, um in jedem draw()-Aufruf zu checken, ob einer der drei Geister erfolgreich Pacman gefressen hat.
// In Geist: // Liefert true zurück, wenn Pacman gegessen wurde boolean tryToEat(Pacman pacman) { return dist(x, y, pacman.x, pacman.y) < durchmesser/2; }
In draw() rufen wir jedesmal eine Funktion auf, die die Geister behandelt. Diese Funktion könnte zum Beispiel manageGeister() heißen:
void manageGeister() { // Alle 50 Zyklen die Geister bewegen boolean move = false; if (timer < = 0) { move = true; timer = 50; } timer--; for (int i = 0; i < geister.length; i++) { if (move) { geister[i].move(); } geister[i].draw(); boolean eaten = geister[i].tryToEat(pacman); if (eaten) { leben--; // Pacman wieder oben links erscheinen lassen int x = cellSize/2; int y = cellSize/2; pacman.x = x; pacman.y = y; } } }
In dieser Version werden die Geister alle 50 Zyklen bewegt. Versuchen Sie, die Version unabhängig von der frameRate zu machen, ähnlich wie die Animation bei Pacman.
Sie sind noch nicht ganz fertig, aber den Rest schaffen Sie sicher allein:
- Score und Leben anzeigen
- Game over, wenn keine Leben mehr vorhanden
- Gewonnen, wenn alle Punkte gegessen
- Bei Game over und Gewinn Spiel anhalten und Neustart erlauben