Lernziele

  • Softwareprojekt objektorientiert planen
  • Einteilung von Klassen im Rahmen eines Computerspiels
  • Funktionalität auf Klassen verteilen (in Methoden)
  • Umsetzung eines geplanten Projekts in Code
Neueste Aktualisierungen (zuletzt 02.08.2021)
  • 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:

  1. Software-Design: Hier planen wir unsere Klassen - auf dem Papier!
  2. 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:

  1. Name der Klasse
  2. Eigenschaften (Instanzvariablen, inkl. Typ)
  3. 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:

  1. Score und Leben anzeigen
  2. Game over, wenn keine Leben mehr vorhanden
  3. Gewonnen, wenn alle Punkte gegessen
  4. Bei Game over und Gewinn Spiel anhalten und Neustart erlauben