Für
Janne, Julia, Daniel und Katrin

C++ für Kids

Grundlagen für Spieleprogrammierer

Hans-Georg Schumann

Impressum

Bibliografische Information der Deutschen Nationalbibliothek

Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über <http://dnb.d-nb.de> abrufbar.

ISBN 978-3-95845-714-0

1. Auflage 2018

www.mitp.de

E-Mail: mitp-verlag@sigloch.de

Telefon: +49 7953 / 7189 - 079

Telefax: +49 7953 / 7189 - 082

© 2018 mitp Verlags GmbH & Co. KG

Dieses Werk, einschließlich aller seiner Teile, ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheberrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und die Einspeicherung und Verarbeitung in elektronischen Systemen.

Die Wiedergabe von Gebrauchsnamen, Handelsnamen, Warenbezeichnungen usw. in diesem Werk berechtigt auch ohne besondere Kennzeichnung nicht zu der Annahme, dass solche Namen im Sinne der Warenzeichen- und Markenschutz-Gesetzgebung als frei zu betrachten wären und daher von jedermann benutzt werden dürften.

Lektorat: Katja Völpel

Sprachkorrektorat: Petra Heubach-Erdmann

Covergestaltung: Sandrina Dralle, Christian Kalkert

Coverbild: © yayasya @ fotolia.com

electronic publication: III-satz, Husby, www.drei-satz.de

Dieses Ebook verwendet das ePub-Format und ist optimiert für die Nutzung mit dem iBooks-reader auf dem iPad von Apple. Bei der Verwendung anderer Reader kann es zu Darstellungsproblemen kommen.

Der Verlag räumt Ihnen mit dem Kauf des ebooks das Recht ein, die Inhalte im Rahmen des geltenden Urheberrechts zu nutzen. Dieses Werk, einschließlich aller seiner Teile, ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheherrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und Einspeicherung und Verarbeitung in elektronischen Systemen.

Der Verlag schützt seine ebooks vor Missbrauch des Urheberrechts durch ein digitales Rechtemanagement. Bei Kauf im Webshop des Verlages werden die ebooks mit einem nicht sichtbaren digitalen Wasserzeichen individuell pro Nutzer signiert.

Bei Kauf in anderen ebook-Webshops erfolgt die Signatur durch die Shopbetreiber. Angaben zu diesem DRM finden Sie auf den Seiten der jeweiligen Anbieter.

Anhang B

Die Suche nach Fehlern hat so manchen Programmentwickler schon an den Rand des Wahnsinns getrieben. Zumal die schlimmsten meist so gut verborgen sind, dass man zuerst daran zweifelt, sie jemals zu finden. Gerade deshalb ist es gut und wichtig zu wissen, dass Visual Studio bemüht ist, dir dein Programmierleben mit C++ so bequem wie möglich zu machen, und darüber hinaus auch eine Reihe von Werkzeugen zur Fehlersuche bereitstellt. Aber du selbst kannst auch schon eine Menge tun, wenn du beim Erstellen deiner Programme ein paar Regeln beachtest.

Kleine Checkliste

Dem Fehler auf der Spur

Weil allzu viele Fehler so hinterlistig sind, sich nicht gleich zu zeigen, müssen sie erst einmal aufgespürt werden. In Visual Studio gibt es deshalb einige Hilfsmittel, die dir bei der Suche nach Fehlern helfen können.

Alle zusammen gehören zum sogenannten Debugger​. Das bedeutet zu Deutsch so viel wie »Entwanzer«. Damit ist hier kein Desinfektionsmittel gemeint, sondern ein Zusatzprogramm, das dir dabei hilft, Fehler zu finden.

Der Name kommt daher, dass unter Programmierern meist nicht von Fehlern, sondern von Bugs​ die Rede ist. Und um so einen Bug (englisch für »Käfer, Wanze, Ungeziefer«) auszumerzen, dafür gibt’s den Debugger.

Der kann ein Programm zum Beispiel in einzelnen Schritten ablaufen lassen oder dafür sorgen, dass es nur bis zu einer bestimmten Stelle ausgeführt wird. Mehr darüber erfährst du über das HIlfe-System​ von Visual Studio.

Ein Programm in Einzelschritten laufen lassen

​​Um genau zu beobachten, was ein Programm macht, kann es nützlich sein, dass jede Anweisung einzeln ausgeführt wird. Dann lässt sich der Programmlauf Schritt für Schritt mit einem Tastendruck steuern:

  • Starte das Programm nicht wie üblich, sondern drücke die Tastenkombination F10. Dann wird jede Anweisung einzeln ausgeführt.

So kannst du beobachten, was das Programm an welcher Stelle macht. (F10 entspricht dem Menüpunkt Debug/Prozedurschritt.)

Weil F10 Funktionen wie eine einzelne Anweisung betrachtet, wird eine Funktion (auch Prozedur​ genannt) in einem Schritt ausgeführt.

​​Deshalb gibt es noch die Möglichkeit, die Taste F11 zu nutzen. Damit werden auch die Anweisungen in einer Methode einzeln ausgeführt. (F11 entspricht dem Menüpunkt Debug/Einzelschritt.)

  • ​Falls du die schrittweise Programmausführung wieder abbrechen möchtest, bringst du sie im (geänderten) Debug-Menü über Debuggen beenden wieder zum Stillstand. (Das gilt auch, wenn das Programm in einem Laufzeitfehler steckt.)

Haltepunkte setzen

​​Es kann auch nötig sein, ein Programm an genau einer bestimmten Stelle anzuhalten, um zu sehen, was bis dahin passiert ist. Dazu gibt es sogenannte Haltepunkte:

  • Setze den Textcursor in die Zeile, in der das Programm anhalten soll.

  • Drücke die Taste F9. (Oder klicke mit der Maus auf den linken Editorrand.)

Die betreffende Zeile ist nun markiert. Nach einem Start des Programms läuft es bis zu diesem Punkt und bleibt dann stehen.

  • Mit erneutem Klick auf Debug/Debuggen starten bzw. mit F5 läuft das Programm weiter – bis zum nächsten Haltepunkt (falls es einen gibt) oder bis zum Ende.

  • Ausschalten kannst du Haltepunkte, indem du den Textcursor darauf setzt und wieder die Taste F9 drückst (wie zum Einschalten).

Anhang A

Visual Studio installieren

​​Die Installation von Visual Studio ist recht einfach. Du installierst ein komplettes umfangreiches Paket, in dem die Programmiersprache C++ bereits enthalten ist. Dazu sind nur ein paar Schaltflächen anzuklicken, um die Installation zu steuern. Im Zweifelsfall kannst du dir aber auch von jemandem helfen lassen.

Zuerst aber musst du dir das benötigte Paket aus dem Internet holen. Es heißt Visual Studio Community​ und ist kostenlos (im Gegensatz zu den anderen Versionen). Was nicht heißt, dass es nicht auch für Profis geeignet ist.

Die folgende Installation wurde im Sommer 2017 durchgeführt. Zu späteren Zeiten kann es durchaus sein, dass Microsoft, der Hersteller von Visual Studio, einiges geändert hat. Außerdem sind die Installationsschritte vielleicht andere (das kann u.a. davon abhängen, was auf deinem Computer bereits von Microsoft vorhanden ist). Doch auch dann dürfte es kein Problem sein, Visual Studio erfolgreich zu installieren.

https://www.visualstudio.com/de/downloads/

Und kurze Zeit später landet das (noch ziemlich kleine) Startprogramm auf deiner Festplatte. Wahrscheinlich im Download-Ordner (je nachdem, was du für deinen Browser als Download-Ziel eingestellt hast).

Und das Setup-Programm legt gleich mit einer kurzen Begrüßung los:

Nun heißt es einen Moment warten. Im nächsten Fenster kannst du dir aussuchen, welche Komponenten installiert werden sollen.

Du hast hier auch die Möglichkeit, das Verzeichnis zu ändern, in das Visual Studio installiert wird. Ich schlage vor, den Speicherort so zu kürzen:

C:\Program Files (x86)\Microsoft Visual Studio\Community

bzw.

C:\Programme\Microsoft Visual Studio\Community

Und nun wird installiert, dabei kannst du an den Balken den Fortschritt der Installation ablesen.

Zum Schluss siehst du im Fenster, dass die Installation geklappt hat:

Es erscheint ein Dialogfeld, in dem dir angeboten wird, dich bei einem Microsoft-Konto anzumelden oder ein neues einzurichten.

Als Nächstes kannst du dir noch aussuchen, wie deine Arbeitsumgebung aussehen soll:

Und einige Zeit später landest du in der Entwicklungsumgebung, in der du dann deine Projekte in Visual C++ erstellen kannst. Wie das geht, erfährst du ab Kapitel 1.

Um das Angebot, dich bei Microsoft mit einem vorhandenen oder neuen Konto anzumelden, kommst du nicht herum. Den Zeitpunkt kannst du selbst bestimmen. Allerdings muss die kostenlose (!) Registrierung nach 30 Tagen erledigt sein (Menüeintrag Hilfe/Produkt registrieren).

SFML herunterladen und vorbereiten

​Für das SFML-Paket ist keine Extra-Installation nötig, aber herunterladen und entpacken musst du es trotzdem.

https://www.sfml-dev.org/download.php

Nun bekommst du eine Menge Versionen angeboten. Visual Studio 2017 war zum Zeitpunkt, als dieses Buch fertiggestellt wurde, noch nicht im Angebot, aber ich hatte keine Probleme mit der Version für 2015.

Ich benutze im Buch nur die 32-bit-Version. Zum einen, weil diese auch unter einem 64-bit-Windows funktioniert, zum anderen, weil SFML 32-bit lieber mag.

Heruntergeladen wird eine zip-Datei, die anschließend entpackt werden muss.

Wenn du den SFML-Ordner öffnest, müssten darin einige Unterordner mit Namen wie bin und include zu finden sein.

Wenn da nur ein einzelner Ordner ist, der auch wieder etwas mit SFML im Namen trägt, dann musst du aus dem alles in den aktuellen Ordner verschieben.

Alle Unterordner müssen über Projekte\SFML erreichbar sein, sonst findet Visual Studio nachher die SFML-Dateien für die Buch-Projekte nicht.

Einsatz der Buch-Dateien

Wenn du die Beispiel-Projekte für C++ wie auch die Lösungen nutzen willst, lassen sie sich von dieser Seite herunterladen:

http://www.mitp.de/712

Du kannst sie dann alle in einem Ordner auf deiner Festplatte unterbringen. Ich benutze im Buch einen Ordner namens D:\Projekte.

Wichtig: Die für die SFML-Projekte benötigten dll-Dateien befinden sich nicht in den Ordnern der Download-Projekte – aus Copyright-Gründen. Du musst sie also selbst aus dem Ordner SFML\bin rüberkopieren.

Kapitel 16: Gizmo oder was?

Nun ist es an der Zeit, unser kleines Jump-&-Run-Spiel um Gegenstände zu erweitern, die der Player einsammeln und einsetzen muss. Dabei könnten ihm einige Hindernisse die Arbeit etwas schwerer machen. Konkret soll es darum gehen, dass der Player farbige Scheiben in einer bestimmten Reihenfolge einsammelt, damit am Ende so etwas wie ein Regenbogen herauskommt.

Außerdem beschäftigen wir uns noch mit dem Springen nicht nur über Hindernisse – und mit dem möglichen Tod des Players.

In diesem Kapitel lernst du

Die Gizmo-Klasse

Beginnen wir mit der Klasse, die für Gegenstände, Hindernisse und andere Dinge zuständig sein soll. Ich dachte zuerst an Thing, habe mich aber dann für Gizmo​ entschieden (was das englische Wort für »Ding«, »Dingsbums«, »Zeugs« oder Ähnliches ist, also all dem Kram, den wir hier zusammenfassen wollen).

Beginnen wir wieder mit der Deklaration all der Elemente, die ich gern in eine Klasse Gizmo hineinpacken würde ( Rainbow1, Gizmo.h):

class Gizmo {
private:
  // Elementvariablen
  Texture texThing;
  float x, y, z;
  int error = 0;
 
public:
  static int Collected;
  Sprite sprite;
  bool isObstacle = false;
  bool isMoveable = false;
  bool isPickable = false;
  bool isUsable = false;
  bool isVisible = false;
  // Elementfunktionen
  Gizmo();
  Gizmo(const string texName);
  void loadTexture(const string texName);
  void initSprite(float xPos, float yPos, float scale);
  void initSprite
  (float xPos, float yPos, float zLev, float scale);
  void setPosition(float xx, float yy, float zz = 0);
  void move(float xx, float yy, int num);
  Vector2f getPosition();
  float getX();
  float getY();
  float getZ();
};

Wenn du genau hinschaust, wirst du hier eine ganze Menge Elementvariablen finden. Ein Pärchen wie texThing und sprite hast du sicher ohnehin erwartet, denn wenn der Gegenstand bzw. das Ding universell sein soll, dann brauchen wir eine Textur. Die kann das Bild eines Steins, einer Kiste, einer Blume, eines Schlüssels, also alles Mögliche sein. Weil diese Dinge verschiedene Eigenschaften haben, brauchen wir dazu auch einige öffentliche Elemente, auf die ich gleich komme.

Mit den beiden Variablen x und y wird die Position des Objekts erfasst, in z steht die Nummer der Szene, in der der Gegenstand liegt. Statt Szene kann man auch Level sagen, gemeint ist damit: Der Player wechselt am Rand des Spielfeldes in eine neue Szene mit eventuell neuem Hintergrund, auf jeden Fall aber mit neuen (anderen) Objekten.

​​Kommen wir nun zu den public-Elementen: Es gibt eine statische Variable, die mitzählt, wie viele Instanzen der Klasse (also wie viele Gegenstände) eingesammelt oder verbraucht wurden:

static int Collected;

Dann folgen mehrere Schalt-Variablen, die ich kurz in einer Tabelle erläutern möchte:​​

 

Bedeutung bei true

isObstacle

Ding ist ein Hindernis, muss also umgangen oder übersprungen werden

isMoveable

Ding lässt sich verschieben (rollen, werfen)

isPickable

Ding lässt sich aufheben

isUsable

Ding kann verwendet werden (z.B. Schlüssel)

isVisible

Ding ist sichtbar

Am Anfang ist so ein Objekt also quasi ein Nichts. Es muss erst »eingeschaltet« werden, ob es z.B. ein Hindernis ist oder ein Gegenstand, den man aufsammeln kann. Ist ein Ding kein Hindernis, kann der Player einfach quasi hindurchgehen; ist das Ding nutzbar, kann er es mit einem bestimmten Tastendruck aufheben (was erst einmal nichts weiter heißt, als dass es von dort verschwindet).

Man hätte auch eine kleine simple Basis-Klasse vereinbaren und davon spezielle Klassen z.B. für Hindernisse und greifbare Gegenstände ableiten können. Aber mir gefiel die Idee einer universelleren Klasse, zumal die es ermöglicht, dass Gegenstände ihre Funktion im Spiel ändern.

Schauen wir uns die Definition der Elementfunktionen an ( Rainbow1, Gizmo.cpp). Das heißt: Zuerst kommt die statische Elementvariable, die wir auf 0 setzen:

int Gizmo::Collected = 0;

Der Konstruktor übernimmt den Namen einer Bild-Datei:

Gizmo::Gizmo(string texName) {
  if (!texThing.loadFromFile(texName)) error = -1;
  if (error != -1) sprite.setTexture(texThing);
}

Das Gleiche gibt es noch mal als normale Elementfunktion:

void Gizmo::loadTexture(string texName) {
  if (!texThing.loadFromFile(texName)) error = -1;
  if (error != -1) sprite.setTexture(texThing);
}

Damit wird die Vereinbarung eines Arrays von Gegenständen einfacher, wenn man keine Zeiger benutzen will (wie du später sehen wirst).

​Die Methode zum Initialisieren des Sprites kennst du ja aus anderen Klassen, die du bis jetzt erstellt hast:

void Gizmo::initSprite
(float xPos, float yPos, float scale) {
  // Größe und Position festlegen
  sprite.setScale(scale, scale);
  sprite.setPosition(xPos, yPos);
  // Mittelpunkt finden
  IntRect picture = sprite.getTextureRect();
  sprite.setOrigin(picture.width/2, picture.height/2);
  // Position und Szene merken
  x = xPos; y = yPos; z = 0;
}
void Gizmo::initSprite
(float xPos, float yPos, float zLev, float scale) {
  initSprite(xPos, yPos, scale); z = zLev;
}

Hier gibt es zwei Versionen, falls man einen bestimmten Level als Startwert übergeben will. Wie du siehst, lässt sich die eine Methode in der anderen aufrufen, was Tipparbeit spart.

Nun kommen Funktionen für das Setzen der Position (inklusive Szene bzw. Level) und das Bewegen des Gegenstandes – falls er beweglich ist:

void Gizmo::setPosition(float xx, float yy, float zz) {
  x = xx, y = yy; z = zz;
  sprite.setPosition(x, y);
}
void Gizmo::move(float xx, float yy, int num) {
  if (isMoveable) {
    sprite.move(xx, yy);
    sleep(milliseconds(num));
  }
}

Schließlich folgen noch einige Funktionen, die die Position als Vektor oder die x- und y-Koordinaten des Objekts bzw. die Nummer der Szene einzeln zurückgeben:

Vector2f Gizmo::getPosition() {
  return (sprite.getPosition());
}
float Gizmo::getX() { return x; }
float Gizmo::getY() { return y; }
float Gizmo::getZ() { return z; }

Bunte Scheiben

Das war schon alles? Und damit wollen wir dann einige farbige Scheiben auf dem Spielfeld verteilen und in der richtigen Reihenfolge einsammeln und anordnen lassen? Du liegst richtig, wenn du denkst, dass die Mammutarbeit im Hauptprogramm stattfindet. Da brauchen wir zuerst einmal je eine Konstante und eine Variable:

const int diskMax = 6;  // maximale Scheibenzahl
int current = 0;        // aktuelle Scheibe

Dazu kommt ein Array mit Schalt-Elementen, in dem steht, welche Scheiben eingesammelt wurden:

bool collect[diskMax];

Für den Hintergrund beschränke ich mich hier auf einen Boden und einen Himmel:

string BackName[1] = { "Ground00.jpg" };
Background background
(1, BackName, Color(180, 220, 200));
background.initArea(xMax, yMax/2+80, yMax);

Wobei ich ein Array mit einem Element vereinbare und dort gleich den Dateinamen als Startwert setze.

Als Nächstes kommt das Scheiben-Array. Weil jedes Element nur eine Textur benötigt, genügt eine Variable für den Dateinamen:

string diskName;
Gizmo disk[diskMax];

Recht umfangreich wird nun der Teil, in dem die sechs Scheiben ihre Textur bekommen, wobei ich halbe Ringe mit den sechs Grundfarben verwende. (Ich hatte vorher mit ganzen Ringen und mit vollen Kreisen experimentiert.)

Du kannst dir die Zutaten für den Regenbogen selber zusammenstellen oder du holst dir meine Farb-Scheiben von der Seite www.mitp.de/712. Wichtig ist auch hier der transparente Hintergrund.

Bei mir heißen die Dateien Element00.png bis Element05.png. In einer Schleife laden wir alle Bild-Elemente herunter und ordnen sie den Objekten zu:

for (int Nr = 0; Nr < diskMax; Nr++) {
  diskName = "Element0" + to_string(Nr) + ".png";
  disk[Nr].loadTexture(diskName);

Dafür benutze ich hier nicht den Konstruktor, mit dem klappt es nämlich nicht, weil ich das disk-Array direkt erzeugt habe. Und da lässt sich nur der Standard-Konstruktor ohne Parameter benutzen.

Auch in die for-Schleife gehört dann das zufällige Verteilen der Objekte auf dem unteren Spielfeldbereich:

float xx = rand() % (xMax-250) + 150;
disk[Nr].initSprite(xx, yMax - 100, 0.6f);

Anschließend wird jede Scheibe als »greifbar« gekennzeichnet:

disk[Nr].isPickable = true;

Außerdem werden alle Elemente des bool-Feldes auf false gesetzt, was »noch nicht eingesammelt« bedeutet:

collect[Nr] = false;

Damit endet die for-Schleife für die Erzeugung und Vorbereitung der Scheiben.

Nun soll bei jedem Szenenwechsel eine zufällige Scheibe auftauchen und die gerade vorhandene wieder verschwinden:

if (player.controlRange(xMax)) {
  if (!collect[current])
    disk[current].isVisible = false;
  current = rand() % (diskMax);
  disk[current].isVisible = true;
}

Wenn die aktuelle (current) Scheibe noch nicht eingesammelt wurde, wird sie unsichtbar. Dann wird eine neue Zufallszahl für current erzeugt und damit eine neue Scheibe sichtbar gemacht. (Natürlich kann das auch zufällig dieselbe wie vorher sein.)

Dieser Teil gehört in die äußere while-Schleife, am besten direkt unter player.adaptState().

Nun müssen wir noch ganz unten im Hauptprogramm dafür sorgen, dass die Scheiben auch dargestellt werden:

window.draw(player.sprite);
if (!collect[current] && disk[current].isVisible)
  window.draw(disk[current].sprite);

Dabei ist eine bestimmte Reihenfolge wichtig: Erst wird der Player angezeigt, dann die jeweilige Scheibe, damit der Player hinter der Scheibe herläuft.

Vorläufig gilt »nur schauen, nicht anfassen«, aber das werden wir schon bald ändern.

Auf dem Weg zum Regenbogen

Zuerst definieren wir eine Taste als »Greifwerkzeug«. Ich habe mich dabei für die Strg-Tasten entschieden. Der folgende Text gehört direkt unter die Abfrage der anderen Tasten ( Rainbow2):

if (Keyboard::isKeyPressed(Keyboard::LControl)
|| Keyboard::isKeyPressed(Keyboard::RControl)) {
  Vector2f xy = disk[current].getPosition();
  if (player.spotCollision(xy) 
  && current == Gizmo::Collected)
    collect[current] = true;
}

Wenn die linke oder rechte Strg-Taste gedrückt wurde, ermitteln wir zunächst die Position der aktuellen Scheibe. Dann wird überprüft, ob Player und Scheibe nahe genug beisammen sind. Das genügt aber nicht: Der Player darf nur eine bestimmte Scheibe aufsammeln, nämlich die, die gerade dran ist. Welche das ist, ermitteln wir über die statische Variable Gizmo::Collected.

​Um einen Regenbogen zu erzeugen, müssen die Scheiben in dieser Reihenfolge aufgenommen werden:

Collected

Dateiname

Farbe

0

Element00.png

Rot

1

Element01.png

Gelb

2

Element02.png

Grün

3

Element03.png

Zyan

4

Element04.png

Blau

5

Element05.png

Magenta

Die entsprechende Scheibe darf jetzt nicht mehr in irgendeiner Szene auf dem Boden des Spielfeldes auftauchen. Wir machen es uns hier einfach und setzen die Scheibe auch gleich woanders ein, um daraus einen Regenbogen zu machen.

Gilt eine Scheibe als gesammelt und ist es die richtige, dann »hängen« wir sie doch direkt am Himmel auf:

if (collect[current] && current == Gizmo::Collected) {
  float scale = 2.8f - (float) 2*current/6;
  disk[current].sprite.setScale(1.2f*scale, scale);
  disk[current].sprite.setPosition(xMax/2, yMax/3 
  + 22*current);
  Gizmo::Collected++;
}

Zuerst muss das Objekt kräftig vergrößert werden. Bei mir passt die obige Skalierung sehr gut, bei dir musst du experimentieren, wenn du andere Bilder benutzt. Das, was vorher eine Scheibe war, wird jetzt zum Bogen und in die Himmelsmitte gesetzt.

Weil current eine ganze Zahl ist, würde das Ergebnis von 2*current/6 auf eine Ganzzahl gerundet werden. Wir aber brauchen eine Dezimalzahl, deshalb wandeln wir das Ergebnis durch Casting​ mit (float) entsprechend um.

Die aktuelle Scheiben-Nummer (current) hilft dabei, die Bögen nach und nach kleiner zu machen und leicht zu verschieben, damit nachher alles schön ineinanderpasst. (Auch da ist es wahrscheinlich, dass du deine eigenen Werte finden musst.) Zuletzt wird der Zähler für die Scheiben-Sammlung (Collected) um 1 erhöht.

Sobald alle Elemente für einen Regenbogen am Himmel »hängen«, ist das Spiel zu Ende. Das machen wir mit einer entsprechenden Meldung deutlich:

if (Gizmo::Collected == diskMax) {
  game.initMessage(20, 10, 36, Color::Blue);
  game.setMessage("Game over");
}

​​​Zuletzt schauen wir noch mal nach ganz unten ans Ende der main-Funktion, da, wo die vielen draw-Anweisungen stehen, die dafür sorgen, dass man überhaupt etwas im Game-Fenster zu sehen bekommt. Weil es mehr als nur ein bisschen blöd aussieht, wenn der Player hinter dem Regenbogen herläuft statt davor, müssen wir die Anzeige der Scheiben bzw. Bögen auf zwei Bereiche verteilen.

Hängen die Bögen am Himmel, sollen sie hinter dem Player liegen, sodass der davor hergeht. Damit gehört die entsprechende Schleife vor die draw-Anweisung für den Player:

for (int Nr = 0; Nr < diskMax; Nr++) {
  if (disk[Nr].isVisible)
    window.draw(disk[Nr].sprite);
}
window.draw(player.sprite);

Das jeweilige Element, dem der Player beim Wandern begegnet, soll vor ihm liegen, sodass er dahinter vorbeigehen kann:

if (!collect[current] && disk[current].isVisible)
  window.draw(disk[current].sprite);

Hindernislauf

​Nun bist du mit dem Player viel gewandert und hast einiges aufgesammelt. Aber was ist mit dem Springen? Das war in unserem Spiel nicht nötig. Aber es interessiert dich schon, wie der Player sich beim Hindernislauf macht? Dann kommen wir erst einmal zurück zu unserer letzten Jumper-Version, in der die Gizmo-Klasse noch nicht vorkam. Die nehme ich mir als Basis und erweitere das Programm um ein paar Gegenstände, die dem Player beim Wandern immer wieder mal begegnen. Einige davon sind Hindernisse, aber man kann auch etwas mitnehmen.

Wir brauchen also in der Jumperklasse die beiden Gizmo-Dateien, die wir vorhin für das Regenbogen-Spiel erstellt haben. Widmen wir uns gleich dem Hauptprogramm (in das unsere neue Klasse natürlich mit #include "gizmo.h" eingebunden sein muss).

Zuerst erzeugen wir ein »Ding« und bereiten es darauf vor, dass es im Spiel als Hindernis fungieren kann ( Jumper4).

Gizmo thing("Thing00.png");
thing.initSprite(xMax/2, yMax/2+100, 0.7f);
thing.isObstacle = true;

Dazu müssen wir natürlich eine Textur haben, die dazu passt. Ich stelle hier kurz die Sammlung vor, die du von der mitp-Seite herunterladen kannst, die Namen durchnummeriert von Thing00.png bis Thing04.png.

Dabei habe ich vor, nur die ersten drei als Hindernisse zu benutzen. Die anderen beiden Elemente sollen sich mitnehmen lassen. Zunächst aber reicht mir eine einzige Textur, z.B. die für eine Mauer (oder ein Mäuerchen).

Auch hier soll die Position des Hindernis-Elements sich von Szene zu Szene ändern:

if (player.controlRange(xMax)) {
  background.change();
  float xPos = rand() % (xMax-500) + 250;
  thing.setPosition(xPos, yMax/2+100);
}

Damit danach der Player auch am Hindernis aufgehalten wird, müssen wir eine Kollisionskontrolle durchführen, dazu vereinbaren wir am Anfang der main-Funktion eine Schalt-Variable:

bool isBlocked = false;

​Ich habe mich hier dafür entschieden, den Test mit der Funktion areaCollision() durchzuführen, also zu überprüfen, ob sich die Flächen der Sprites von Player und Hindernis überschneiden:

FloatRect xy = thing.sprite.getGlobalBounds();
if (player.areaCollision(xy) && thing.isObstacle)
  isBlocked = true;
else
  isBlocked = false;

Kommt es zu einer Berührung und ist das »Ding« ein Hindernis, dann wird isBlocked auf true gesetzt, sonst auf false.

Was soll nun passieren, wenn der Player blockiert wird? Er muss aufgehalten werden, darf nicht mehr weiterlaufen. Man könnte das ganz einfach lösen, indem man speed auf 0 setzt. Der Haken ist, dass der Player im »Kollisionsgeflecht« festhängt, er muss sozusagen etwas zurückprallen, damit er sich außerhalb der Zone befindet, in der sich die Flächen der Sprites überschneiden.

​Die Player-Klasse braucht also eine neue Methode, die ich bounce() nennen will:

// player.h
void bounce();
 
// player.cpp
void Player::bounce() {
  Vector2f xy = sprite.getPosition();
  sprite.setPosition(xy.x-5*speed, xy.y);
}

Hier wird der Player ein Stückchen gegen seine Laufrichtung (also zurück) geschubst.

Nun müssen wir die mögliche Blockade für den Lauf-Modus einbauen:

if (player.isWalking) {
  if (!isBlocked) player.move(2);
  else player.bounce();
}

Gibt es kein Hindernis, dann kann der Player einfach drauflos gehen, ansonsten wird er blockiert. Er muss dann über das Hindernis springen oder in die andere Richtung weiterlaufen.

Allerlei Dinge

​Nun wollen wir ein paar Gegenstände mehr in Spiel bringen. Dabei ist hier keine Vereinbarung eines Gizmo-Arrays nötig, nein, wir bleiben bei dem einen Objekt und verpassen ihm nur verschiedene Texturen, wie wir es ja beim Player längst tun. Somit taucht der eigentlich gleiche Gegenstand in verschiedenem Outfit auf, außerdem hat er nicht immer die gleichen Eigenschaften.

Beginnen wir mit den Textur-Dateien. Dazu vereinbare ich eine weitere Konstante:

const int thingMax = 5;

Um das eine Objekt zu erzeugen, das dann in jeder Szene anders »auftreten« soll, sind einige Vorbereitungen nötig, dazu gehört das Laden einer Textur.

Ich habe in der Gizmo-Klasse nur eine Textur als Element vereinbart, du kannst aber auch wie in den Klassen für den Player und den Hintergrund einen Zeiger einsetzen, um in einer Schleife alle Texturen zu laden und für einen einzelnen Gegenstand zur Verfügung zu haben. Mir aber reicht hier eine Textur.

Weil bei jedem Szenenwechsel eine neue Textur geladen wird, wiederholen sich eine ganze Reihe von Anweisungen. Deshalb habe ich hier entschieden, eine externe Funktion zu vereinbaren, die ich oberhalb der main-Funktion als Prototyp aufführe, um sie später unterhalb zu definieren:

void setThing(int xx, int yy, int num, Gizmo &object);

Der letzte Parameter ist eine Referenz auf den jeweiligen Gegenstand, weil der ja verändert werden soll.

Nun sieht die Vorbereitung des »Dings« nur noch so aus:

Gizmo thing;
setThing (xMax, yMax/2, thingMax, thing);

Dafür ist die Funktion setThing() ziemlich umfangreich ( Jumper5):

void setThing(int xx, int yy, int num, Gizmo &object) {
  string thingName;
  // Zufällige Textur
  int Nr = rand() % num;
  thingName = "Thing0" + to_string(Nr) + ".png";;
  object.loadTexture(thingName);
  // Typ-Eigenschaften
  object.isVisible = true;
  if (Nr < num - 2) {
    object.isObstacle = true;
    object.isPickable = false;
  }
  else {
    object.isObstacle = false;
    object.isPickable = true;
  }
  // Zufällige Position
  float xPos = rand() % (xx - 500) + 250;
  object.initSprite(xPos, yy + 110, 0.7f);
}

Wie du siehst, wird zuerst eine zufällige Datei ausgesucht und geladen, damit wird das Sprite dann texturiert. Nun wird der Typ festgelegt: Bei mir sind die ersten drei Texturen für Hindernisse, die letzten beiden für »Mitnehmsel«. Schließlich wird das Objekt dann noch irgendwo auf dem Spielfeld positioniert.

So lässt sich das Programm bereits ausprobieren, in jeder neuen Szene wird dir wahrscheinlich ein neues »Ding« begegnen (nicht immer), zum Teil musst du drüberspringen, zum Teil kannst du dahinter vorbeilaufen.

Um ein solches Teil aufzunehmen, brauchen wir eine weitere Taste, die wir schon im Regenbogen-Projekt benutzt hatten (genau genommen zwei, die linke und die rechte Strg-Taste). Die Abfrage übernehmen wir hier in unser Jumper-Projekt und passen sie an ( Jumper5):

if (Keyboard::isKeyPressed(Keyboard::LControl)
|| Keyboard::isKeyPressed(Keyboard::RControl)) {
  Vector2f xy = thing.getPosition();
  if (player.spotCollision(xy) && thing.isPickable)
    thing.isVisible = false;
}

Natürlich wird der betreffende Gegenstand dadurch noch nicht unsichtbar, deshalb müssen wir noch dafür sorgen, dass er anschließend nicht mehr angezeigt wird:

if (thing.isVisible) window.draw(thing.sprite);

Das steht ganz unten in der main-Funktion, womit das aufgenommene Ding dann in dieser Szene wirklich verschwunden ist. Weil nach jedem Szenenwechsel das Objekt mit neuer Textur wieder sichtbar gemacht wird, erscheint es auch später wieder.

Sprung-Technik

Was macht man dann mit den gesammelten Sachen? Das wird hier nicht geklärt, aber mich interessiert im Moment auch mehr die Sprungtechnik des Players. Bis jetzt ist es nämlich so, dass man einfach nur die Leertaste festhalten muss und die Figur fliegt geradezu über alle Hindernisse hinweg. Und sie landet erst wieder auf dem Boden, wenn man die Sprung-Taste loslässt. Schauen wir mal, ob und wie sich das ändern lässt.

Was ist mit der Gravitation​, auch Anziehungskraft genannt, also die, die der Boden eigentlich auf den Player ausüben sollte? Denn während er zum Sprung ansetzt, hält ihn die Gravitation doch eigentlich zunächst noch unten, dann ist die Gegenkraft so stark, dass er sich vom Boden löst und nach oben steigt. Aber nur für kurze Zeit, denn der Schwung des Absprungs und die stärkere Gravitation sind nun Gegenspieler, sie wirken in entgegengesetzte Richtungen.

Mit jumpSpeed meine ich die Strecke, um die der Player nach oben geschoben werden soll. Dafür genügt mir der Wert -1, wie ich ihn auch in der jump-Methode verwende, die in der Player-Klasse bereits definiert ist.

Noch mal zur Klarstellung: Eine Bewegung nach oben heißt, dass der y-Wert für move (x,y) negativ sein muss. Und mit 0 bezeichne ich die Normalhöhe, also die Ebene, in der der Player festen Boden unter den Füßen hat.

Stärker als die Sprungkraft ist auf Dauer die Gravitation. Dafür muss ich später einen Wert suchen. Die echte Gravitationskonstante kann ich hier nicht gebrauchen. Ich kann nicht einfach einen festen Wert nehmen, weil sich der Player sonst gleichförmig nach unten bewegen würde.

​Auf gravity kommen wir später zurück, erst schauen wir uns die neue (zusätzliche) jump-Funktion für die Player-Klasse an ( Jumper6):

//player.h
void jump(float gravity);
 
//player.cpp
void Player::jump(float gravity) {
  float y = gravity - 1;
  height += y;
  if (height < 0)
    sprite.move(0, y);
  else {
    height = 0;
    isJumping = false;
  }
}

Zuerst wird der y-Wert aus gravity und Sprungkraft (-1) gebildet. Dann wird die Höhe des Players über dem Boden ermittelt:

float y = gravity - 1;
height += y;

Ist sie kleiner als 0 (muss sie sein, weil »oben« hier negativ bedeutet), dann wird der Player bewegt:

if (height < 0)
  sprite.move(0, y);

Ist height größer als 0, wird die Variable auf 0 und isJumping auf false gesetzt:

else {
  height = 0;
  isJumping = false;
}

Das Springen funktioniert aus dem Stand gut, damit das auch beim Laufen klappt, müssen wir hier die sleep-Anweisung in der move-Methode deaktivieren, was zu dieser kleinen Änderung führt:

void Player::move(int ms) {
  sprite.move(speed, 0);
  if (!isJumping) sleep(milliseconds(ms));
  isWalking = false;
  speed = 0;
}

Das bringt auch in der normalen jump-Methode keine Probleme, aber andersherum würde die sleep-Methode die arme Gravitation »ins Schwitzen« bringen (probiere es aus).

Nun wollen wir die neue Sprung-Technik gleich im Hauptprogramm ausprobieren. Der Wert, den ich als gravity-Parameter übergeben möchte, muss sich ständig vergrößern, nur dann wird der Player in seiner Bewegung nach oben abgebremst und kommt auch wieder herunter.

Doch woher bekommen wir einen Wert, der mit der Zeit größer wird? Es ist die Zeitspanne, die wir mit game.getTime() auffangen. Beim Drücken der Leertaste wird der Timer neu gestartet. Solange der Sprung anhält, wird die abgelaufene Zeit immer länger, und damit hätten wir etwas für unseren graviity-Parameter.

Das funktioniert aber nur, wenn die Zeit mit jedem Tastendruck nur einmal zurückgesetzt wird. Wir brauchen eine neue Schalt-Variable, die ich inTime nennen möchte. Die vereinbaren wir direkt unter der isBlocked-Variablen:

bool inTime = false;

Nun passen wir die Abfrage der Leertaste neu an:

if (Keyboard::isKeyPressed(Keyboard::Space)) {
  player.isJumping = true;
  player.adaptState();
  if (!inTime) {
    game.getTime(false);
    inTime = true;
  }
}

Wenn inTime »ausgeschaltet« ist (also der Player noch nicht nach oben geschickt wurde), wird der Timer auf 0 gesetzt:

if (!inTime) {
  game.getTime(false);
  inTime = true;
}

Außerdem wird inTime »angeschaltet«. Wird die Leertaste festgehalten, kann der Timer nicht erneut auf 0 zurückgesetzt werden, er läuft also weiter.

Was konkret bedeutet: Drückt man die Leertaste und hält sie fest, springt der Player hoch und landet nach einiger Zeit wieder auf dem Boden. Doch das klappt nur einmal, irgendwie müssen wir inTime auch wieder »ausschalten«, der Player soll wieder springen können, wenn ich die Leertaste loslasse und dann erneut drücke.

Tatsächlich bietet das SFML-Paket ein Event an, das ermittelt, ob eine Taste losgelassen wurde:

if (event.type == Event::KeyReleased) {
  if (event.key.code == Keyboard::Space)
    inTime = false;
}

​Dabei können wir nicht auf die Keyboard-Klasse zurückgreifen, sondern KeyReleased​ findet sich in der Event-Gruppe. Beim Loslassen einer Taste wird ermittelt, welche es ist. Ist es die Leertaste (Space), dann wird inTime deaktiviert. Anschließend kann der Timer wieder zurückgesetzt werden (wenn man die Leertaste erneut drückt).

​Es gibt übrigens auch ein Keypressed-Event, das man alternativ zu den Keyboard-Ereignissen nutzen könnte, wenn man will. Meist ist der direkte Einsatz von Keyboard aber praktischer.

Eine weitere Verwendung von inTime ist nicht nötig, aber die Anweisungen für den Sprung-Modus müssen angepasst werden:

float stay = (float) game.getTime(true)/500;
if (player.isJumping) player.jump(stay);

Wir übernehmen die aktuell verstrichene Zeit (in Millisekunden) und teilen dies durch eine ziemlich große Zahl, damit ein nicht zu großer Wert herauskommt.

Der Wert für gravity muss anfangs deutlich unter 1 liegen (denn der jump-Wert ist -1) und dann soll er allmählich über 1 anwachsen. Die übergebenen Millisekunden sind also erst einmal eine viel zu große Zahl, deshalb müssen wir dafür sorgen, dass daraus ein Wert zwischen 0 und 1 entsteht.

Ob auch bei dir die 500 passt, musst du ausprobieren (mit größeren Zahlen springt der Player höher).

​Damit stay keine ganze Zahl zugewiesen bekommt, müssen wir auch hier mit (float) eine Dezimalzahl daraus machen. Danach bekommt die jump-Methode ihre »Gravitationskraft«, der Player springt und wird wieder auf die Erde zurückgeholt.

Leben und Tod

​Auch ein Player ist sterblich – normalerweise. Irgendwann geht jedes Spiel zu Ende, aber nicht unbedingt mit dem Tod des Players. Doch damit haben wir uns noch gar nicht auseinandergesetzt: Was ist, wenn man in ein Spiel so etwas wie eine tödliche Falle einbauen will? Oder einen Gegenstand, der den Player bei Kontakt aus dem Leben befördert?

​Auch dazu könnte man die Gizmo-Klasse benutzen, die ich dann um eine Eigenschaft erweitern würde (in gizmo.h):

bool isLethal = false;

Was zunächst heißt: Das »Ding« ist (noch) ungefährlich. Auch im Hauptprogramm sollte es eine Schalt-Variable geben, die natürlich auf »lebendig« eingestellt ist:

bool inLife = true;

Zuerst müssen wir nun dafür sorgen, dass das Laufen und Springen nur dann stattfindet, wenn der Player am Leben ist. Dazu bekommen die entsprechenden if-Zweige jeweils eine Zusatzbedingung ( Jumper7):

if (player.isJumping && inLife)
  player.jump(stay);
 
if (player.isWalking && inLife) {
  if (!isBlocked) player.move(2);
  else player.bounce();
}

Ausprobieren könntest du das, indem du inLife am Anfang der main-Funktion auf false setzt, dann dürfte sich der Player nicht von der Stelle rühren. Dass er dennoch herumhampelt, macht eigentlich nichts, das könnte man aber auch dadurch eindämmen, dass man den Aufruf von adaptState() an eine passende Bedingung knüpft:

if (inLife) player.adaptState();

Was soll nun passieren, wenn es den Player tödlich erwischt? Hier eine einfache Lösung:

if (!inLife) {
  player.sprite.setRotation(90);
  player.sprite.move(0, -0.1f);
  game.initMessage(xMax/2-120, 10, 48, Color::Black);
  game.setMessage("Game over");
}

Der Player wird »umgelegt« und schwebt in den Himmel, während die Meldung »Game over« erscheint. Du findest sicher einen originelleren Tod für den Player, ich überlasse das ganz deiner Fantasie.

Bevor der Player sterben kann, muss es etwas geben, das für ihn tödlich ist. Ich habe mir den mittleren Gegenstand meiner Gizmo-Sammlung herausgesucht, den man als seltsam aussehenden Stein mit magisch-tödlichen Kräften ansehen könnte. Zuerst muss die setThing-Funktion erweitert werden. Dabei geht es nur um den Teil, in dem bestimmte Eigenschaften der Objekte »geschaltet« werden ( Jumper7):​

switch (Nr) {
  case 0: case 1:
    object.isObstacle = true;
    object.isPickable = false;
    object.isLethal = false;
    break;
  case 2:
    object.isObstacle = false;
    object.isPickable = false;
    object.isLethal = true;
    break;
  case 3: case 4:
    object.isObstacle = false;
    object.isPickable = true;
    object.isLethal = false;
}

Ich habe hier mal wieder eine switch-Struktur eingesetzt. Und wie du siehst, kann man auch mehrere case-Elemente hintereinander einsetzen, wenn dieselben Anweisungsblöcke damit verbunden werden sollen.

Nun sind zwei Gegenstände Hindernisse, zwei kann man einsammeln – und eines ist von nun an tödlich. Damit das auch seine Wirkung zeigt, muss das bei der Kollisionskontrolle berücksichtigt werden:

if (player.areaCollision(xy)) {
  if (thing.isObstacle) isBlocked = true;
  if (thing.isLethal) inLife = false;
}
else isBlocked = false;

Zusammenfassung und Schluss

Mit dem möglichen Tod des Players sind auch wir leider am Ende. Und zwar nicht nur dieses Kapitels, sondern aller Kapitel. Es gibt weiterhin vieles, was du in Sachen C++ und Spieleprogrammierung noch nicht weißt (und damit bist du nicht allein). Aber du hast mindestens drei Spiele selbst erstellt und bist wahrscheinlich inzwischen dabei, diese zu verbessern oder neue zu programmieren. Weiter so!

Es gab auch hier einige Neuigkeiten, aber nur ein neues Element aus dem SFML-Wortschatz, das Ereignis KeyReleased. Es lohnt sich auf jeden Fall, sich das, was SFML noch zu bieten hat, anzuschauen, z.B. unter dieser Internet-Adresse:

https://www.sfml-dev.org/learn.php

Hier findest du auch kleine Tutorials sowie den kompletten Wortschatz (API Documentation). Und wenn du (auf Englisch) im Forum mitreden willst, dann schau dich mal hier um:

https://en.sfml-dev.org/forums/index.php

Es gibt auch ein paar deutsche Seiten, die sich mit dem SFML befassen, da findet man aber deutlich weniger Informationen.

Es muss ja auch nicht beim SFML-Paket bleiben. Es gibt einige Game Engines, die sich mit C++ steuern lassen. Dazu gehört u.a. auch Panda3D​, zu finden auf dieser Internet-Seite:

http://www.panda3d.org/

Speziell mit der Programmierung in C++ befasst sich ein Manual, das du hier findest:

http://www.panda3d.org/manual/index.php?title=Main_Page&language=cxx

Damit machst du den Schritt von 2D zu 3D, den ich hier nicht mehr mitgehen kann.

Keine Fragen ...

... aber eine Aufgabe

  1. Baue in das Rainbow-Projekt ein paar Hindernisse ein.