Grundlagen eines Platform - Games

Seit ich vor fast einem Jahr begonnen habe J-Rio zu implementieren, spiele ich mit dem Gedanken ein Kapitel über die Grundlagen von Platform Games für dieses Tutorial zu schreiben, hatte aber bis jetzt keine Zeit dies zu tun. Das ein solches Kapitel aber durchaus seine Berechtigung hat zeigen die vielen Mails, die ich seit J-Rio auf den Online Seiten zu finden ist von euch bekommen habe und die alle Probleme betrafen, welche bei der Implementierung von Platform - Spielen auftreten. Da ich den kompletten Sourcecode von J-rio nicht öffentlich zur Verfügung stellen werde, können all jene, die mich nach dem Code gefragt haben mit diesem Kapitel auch ein wenig hinter die Kulissen von J-rio blicken, denn das Beispiel - Applet, das wir im Folgenden entwickeln werden ist im Grunde eine sehr abgespeckte Version von J-rio. Ich wünsche euch also wie immer viel Spaß mit diesem Kapitel, hoffe, dass ich euch ein wenig weiterhelfen kann und möchte noch anmerken, dass es sehr empfehlenswert wäre sich die Kapitel über den Leveleditor für arraybasierte Spiele und Bildschirmscrollen nochmals anzusehen, da ich die dort vorgestellten Techniken verwenden, aber nicht mehr im Detail darauf eingehen werde. Außerdem lohnt es sich sicherlich den Sourcecode für das Beispielapplet jetzt schon runterzuladen und während des Kapitels auch durchzusehen, denn ich kann nicht den kompletten Code, sondern nur Ausschnitte vorstellen und besprechen. Das Kapitel gliedert sich dabei in drei Abschnitte. Zuerst werden wir uns dem von mir gewählten Klassendesign des Spiels zuwenden, anschließend werden wir das Player - Objekt und dort vor allem jene Teile der Klasse, die für die Bewegung des Spielers nötig sind genauer unter die Lupe nehmen und zum Schluss werden wir noch einen Blick auf die Struktur und Funktionen der Klasse Level werfen.

Das Klassendesign

Umso größer und komplexer ein Spiel wird desto wichtiger ist ein flexibles und erweiterbares Klassendesign. Ich glaube, dass mir dies im Falle von J-rio ziemlich gut gelungen ist, denn es ist eine einfache Prozedur z. B. eine neue Art Levelelement in das Spiel einzufügen und neue Levels für das Spiel zu schreiben (siehe auch J-rio LevelEditor). Ein einfaches Leveldesign ist sehr wichtig, denn nicht zu letzt lebt das Spiel ja von den zu spielenden Levels. Wir wollen also erreichen neue Level Elemente einfach hinzufügen zu können und wir wollen die Level des Spiels frei und schnell gestallten können.
Wenn ihr euch den Sourcecode runtergeladen und das *.zip - File entpackt habt, dann findet ihr folgende Klassen:

Die Klasse Main

Zunächst implementiert die Main - Klasse, wie bei allen meinen Spielen und Beispielen, das Applet selbst, also die init(), start(), stop(), destroy() und paint(Graphics g) - Methoden und enthält die Hauptschleife, in der das Spiel läuft (implementiert also das Interface Runnable), sowie die Ereignisskontrolle für die Tastatureingaben des Spielers. Zusätzlich hält sie zwei Attribute: das Player - Objekt und jeweils eine konkrete Instanz eines Levels, in unserem Fall ausschließlich eine Instanz der LevelOne - Klasse. In der run() - Schleife werden sowohl Level als auch Player gezeichnet, gescrollt (wenn nötig), bewegt (Player) und nach Kollisionen des Spielers mit Level - Elementen getestet, wobei diese Methoden, wie ihr im Sourcecode sehen könnt, lediglich in der run() - Methode aufgerufen werden, aber in anderen Klassen (Level und Player) implementiert sind.

Die Klasse Player

Die Klasse Player zeigt sich für Attribute und Eigenschaften des Spielers verantwortlich. Sie hält im wesentlichen Informationen über die Position(en) des Spielers im Spiel, speichert die Bilder des Spielers, die für die Animation nötig sind und zeigt sich für die Bewegungen des Spieler verantwortlich (Details darüber kommen später).

Die Klasse LevelElement

Ein Spiel wie J-rio beinnhaltet viele verschiedenen Arten von beweglichen und auch unbeweglichen Level - Elementen. Dazu gehören z.B. die Ereignissteine (die mit dem Fragezeichen), welche z. B. zwei verschiedene Zustände haben, nämlich "noch nicht angestoßen" und "schon angestoßen", kompliziertere Elemente wie die sich bewegenden Platformen, sowie ganz einfache Elemente wie die Grundelemente. Für dieses Kapitel beschränken wir uns auf das einfachste LevelElement in J-rio, die Klasse Ground. Allerdings haben alle Levelelemente viele Eigenschaften gemeinsam, diese Eigenschaften werden in der Klasse LevelElement implementiert. Dazu gehören die Speicherung allgemeiner Attribute wie Position, eine eindeutige Integer - ID des Elementes, eine "inSight" - Variable, die für das Scrollen und die Anzeige des LevelElements von Bedeutung ist (siehe auch Kapitel über Scrolling), sowie das GIF, als das das LevelElement gezeichnet wird. Alle J-rio Level Elemente erweitern diese Klasse, alle Elemente von J-rio sind LevelElement - Instanzen (außer die Gegner und J-rio selbst). Die interne Datenrepräsentation des Levels wird in der Klasse Level nur über Instanzen der Kinderklassen der Klasse LevelElement realisiert, die verschiedenen Elemente werden durch ihre verschiedenen ID's identifiziert!

Die Klasse Ground

Die Klasse Ground ist eben eine solche konkrete Implementierung der Klasse LevelElement. Allerdings hat ein Ground - Element keine zusätzlichen Attribute, so dass hier lediglich der Konstruktor der Parent - Klasse aufgerufen wird.

Die Klasse Level

Die abstrakte Klasse Level ist die vielleicht wichtigste Klasse des Spiels. Die Klasse Level kann die aus 25 Strings bestehende Level - Definition (gespeichert in der Klasse LevelOne) in ein zweidimensionales (bzw. in Kopie auch eindimensional) Array aus LevelElementen übersetzen. Sie verfügt auch über die nötigen Methoden zur Kollisionskontrolle und das Scollen des Levels nötig sind. Wie genau die Methoden arbeiten und wie die interne Repräsentation genau aussieht werden wir im Folgenden noch genauer betrachten.

Die Klasse LevelOne

Diese Klasse erweitert die Klasse Level und beinhaltet nun die konkrete Definition des Levels in Form von 25 Strings. Auch das Array mit den Hintergrund - Farben wird hier initialisiert um es in jedem Level anders gestallten zu können.

Die Klasse C_Jump

Wie schon im Kapitel über den Leveleditor empfiehlt es sich auch in einem Platform - Game alle konstanten Größen (Spielfeldgröße, Größe der Levelelemente, des Spielers...) in eine Konstantenklasse auszulagern, wie auch hier geschehen.

Zusammenspiel der Klassen und Erweiterbarkeit

Wie schon erwähnt hält die Klasse Main eine Instanz des Player - Objektes, sowie eine Instanz des Levels und ist für das managen des Spielablaufs verantwortlich. Während die Bewegungen, Scollen, Zeichnen und Animieren des Spielers von der Klasse Player übernommen werden, zeigt sich die Klasse Level für die Kollisionskontrolle von Spieler und LevelElementen, das Scollen der Levelelemente und das Zeichnen des Levels verantwortlich und natürlich hält sie eine interne Datenrepräsentation des Levels. Ein Level wird durch eine konkrete Child - Klasse der Klasse Level, in unserem Fall LevelOne implementiert. Bei der Konstruktion einer Instanz der Klasse LevelOne werden die String - Definitionen der Klasse LevelOne in der Klasse Level in ein zweidimensionales Array von LevelElement - Objekten übersetzt. Alle Levelelemente werden von der Klasse LevelElement abgeleitet, in unserem Beispiel ist dies nur die Klasse Ground.
Ich hoffe, dass euch das Klassendesign unseres Platform - Games nun soweit geläufig ist, wenn nicht, dann solltet ihr euch vielleicht nochmal die "Leveleditor" und "Scrolling" - Kapitel sowie den Sourcecode (im Bezug auf das Klassendesign unseres Platform - Games) zu Gemüte führen. Wir wollen uns nun detailierter den Aufgaben der Klassen Player und Level widmen.

Eine nähere Betrachtung der Klasse Player

Der Steuerung und Animation des Spielers kommt natürlich eine entscheidende Rolle in unserem Spiel zu. Da allerdings das Ganze nicht so einfach ist, denn unser Spieler muss ja auf Platformen stehen bleiben, herunterfallen, wenn er eine Platform verlässt, bzw. auf eine höhere Platform springen können, möchte ich hier kurz beschreiben, wie ich diese Probleme gelöst habe.
Zunächst einmal ist es vielleicht wichtig zu erkennen, dass die Bewegungen des Player - Objektes nicht direkt vom Spieler sondern über einen Umweg gesteuert werden. In der Klasse Player werden durch die Tastatureingaben des Spielers bzw. auch durch die Kollisionskontrolle des Levels lediglich boolsche Variablen auf true oder false gesetzt, die von der run() - Methode der Klasse Main aufgerufene Methode der Klasse Player playerMove() realisiert nun die Bewegung gemäß der aktuellen Werte der boolschen Variablen (flags).

Die vier verschiedenen Zustände und ihre Kontrolle

Dabei verfügt unser Spieler über vier verschiedene Bewegungszustände, die teilweise unabhängig voneinander sind:

Die Flags walking_left und walking_right beeinflussen einander gegenseitig, da sie nicht gemeinsam zum selben Zeitpunkt ausgeführt werden können. Ebenso können die Zustände falling und jumping nicht gemeinsam auftreten. Allerdings sind falling / jumping und walking_left / walking_right vollkommen unabhängig voneinander und können somit gemeinsam auftreten, denn der Spieler soll ja in der Lage sein, z. B. nach rechts zu springen oder nach links zu fallen. Gesetzt werden die Flags von zwei unabhängigen Seiten: den Tastatureingaben des Spielers, sowie der Kollisionskontrolle des Levels, die wir uns im Folgenden noch genauer ansehen werden. Zuvor möchte ich euch aber noch kurz den Sourcecode der set - Methoden für die einzelnen Flags, sowie die Methode playerMove(), die für Bewegung und Animation des Spielers zuständig ist vorstellen.

Die set - Methoden für die Links- und Rechtsbewegungs - Flags sind wirklich denkbar einfach, es braucht hier wohl keine weiteren Erläuterungen.

Im Falle der set - Methode für den Flag jumping wird die Sache schon schwieriger. Denn wir müssen hier einige Dinge zusätzlich zum einfachen Setzen der Werte beachten. Zum einen kann ein Sprung nur dann angestoßen werden, wenn der Spieler nicht gerade schon fällt, falling also false ist und wenn der Spieler nicht schon springt, und damit ein weiterer Flag namens jump_lock ebenfalls false ist. Eine weitere Sache betrifft die Sprungbewegung selbst. Um zu steuern, wie weit der Spieler springen kann gibt es einen Counter jump_counter, der zählt, wie weit der Spieler schon gesprungen ist. Dieser muss bei einem ganz neuen Sprung (in diesem Fall ist jumping = false, jump_lock = false und der zu setzende Wert (value) ist true) natürlich wieder auf 0 gesetzt werden (Details zu diesem und anderen Countern findet ihr weiter unten). Hier kommt also der Sourcecode:

Der letzte der vier Zustände ist der Zustand falling. Hier ist hauptsächlich zu beachten, dass wenn der Spieler auf einer neuen Platform landet, falling also auf false gesetzt wird, dass in diesem Fall ein Sprung beendet ist (jump_lock und jumping also auf false gesetzt werden müssen). Eine weitere Sache ist, dass es vorkommen kann, dass der Spieler nicht direkt auf der Oberfläche der Platform zum stehen kommt, sondern gewissermaßen in ihr. Daher muss man nach Stoppen eines Falls die Spielerfigur auf die Oberfläche des Levelelementes bewegen. Ansonsten ist noch zu beachten, dass der Spieler nicht gleichzeitig fallen und springen kann, dies wird aber an anderer Stelle (in der playerMove() - Methode) nochmals sicher gestellt.

Bewegung und Animation des Spielers

Nachdem wir jetzt wissen, wie die einzelnen Bewegungszustände des Spielers manipuliert werden werden wir einen kurzen Blick auf die konkrete Umsetzung der Flag - Werte in Bewegung und Animation werfen. Dies geschieht in der Methode playerMove(), deren Sourcecode ich nun etwas genauer unter die Lupe nehmen werde, zuvor muss ich allerdings noch einige kleine Variablen erklären:

Ich hoffe, dass ich euch nun die wichtigsten Eigenschaften und Methoden der Klasse Player näher bringen konnte, Scrollen und Zeichnen des Spielers sollte inzwischen kein Problem mehr darstellen. Allerdings müsst ihr euch, denke ich, trotzdem nochmal den Sourcecode genauer ansehen, da es sich doch um eine etwas komplexere Klasse handelt. Also auf zu einer noch komplexeren Klasse, der Klasse Level

Struktur und Funktionen der Klasse Level

In dem folgenden Abschnitt wollen wir uns detailierter mit der Klasse Level und den dazugehörigen Klassen LevelOne und LevelElement beschäftigen. Zunächst werden wir uns mit der "Designsprache" eines Levels in unserem Spiel und der Übersetzung dieser Definition in die interne Datenstruktur des Levels beschäfitigen. Im wesentlichen handelt es sich hier um Ideen, die ich schon im Kapitel über den Leveleditor vorgestellt habe. Anschließend wollen wir uns die Kollisionskontrolle zwischen Spieler und Levelelementen ansehen. Auf die Methoden zum Scrolling und Zeichnen des Levels werde ich nicht eingehen.

Designsprache und interne Datenrepräsentation eines Levels

Ein wichtiges Ziel des Klassendesigns war es, das Design von neuen Leveln möglichst einfach zu gestallten. Aus diesem Grund werden neue Level zunächst in einer eigenen kleinen "Designsprache" geschrieben und anschließend mittels eines Parsers in die eigentliche interne Datenstruktur des Levels übersetzt. Ein Level besteht in der "Designsprache" dabei aus 25 Strings und damit 25 untereinanderliegender Zeilen. Diese Strings können eine beliebige Länge haben, müssen jedoch gleich lang sein. Die verschiedenen Levelelemente werden in diesen Strings durch festgelegte Buchstaben beschrieben. Der Parser in der Klasse Level (Methode initializeLevel()) muss nun in der Lage sein diese Stringrepräsentation in eine sinnvolle interne Repräsentation zu übersetzen wobei diese interne Repräsentation im Wesentlichen aus einem zweidimensionalen Array besteht, das Instanzen der Klasse LevelElement bzw. ihrer Kinder sowie einen null - Pointer an Stellen, an denen sich kein Levelelement befindet, hält. Wer noch Probleme mit dieser Idee hat, sollte sich jetzt nochmal das Kapitel über den Leveleditor für arraybasierte Spiele ansehen, da ist das Ganze nochmal genauer erklärt. Wir wollen uns jetzt kurz den Sourcecode ansehen und anschließend noch die Kollisionskontrolle zwischen Player und Levelelementen besprechen.

Die Definition eines Levels findet sich in der von der Klasse Level abgeleiteten Klasse LevelOne. Diese Klasse LevelOne dient dazu, alle levelspezifischen Dinge zu definieren (Hintergrundfarben, das Level selbst...), während die von der Klasse Level vererbten Methoden und Eigenschaften das Level funktionsfähig machen. Werfen wir zunächst einen Blick auf die Leveldefinitionssprache:

/**
Legende:
":": Repräsentiert eine Stelle im Level wo kein Levelelement erzeugt werden soll
"g": Repräsentiert eine Grundelement
*/

// Stringdefinitionen des Levels
// Zeilen 1 - 10 fehlen, da sie keine Definitionen enthalten, also nur aus ":" bestehen
public static final String row11 = "::::::::::::::::::::::::::::::::::::::::g::";
public static final String row12 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row13 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row14 = "::::::::::::::::::::::::::::::::::::g::::::";
public static final String row15 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row16 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row17 = "::::::::::::gggg::::::::::::::::g::::::::::";
public static final String row18 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row19 = ":::::::::::::::::::::::gggg::::::::::::::::";
public static final String row20 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row21 = "::::::gggg:::::::::::::::::::::::::::::::::";
public static final String row22 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row23 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row24 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row25 = "ggggggggggggggggggggggggggggggggggggggggggg";

Es ist also wie man sieht sehr einfach ein neues Level zu definieren. Wie wird nun diese einfache Repräsentation in die interne Datenstruktur übersetzt? Diese Methode findet sich in der Klasse Level und zwar dort in der Methode initializeLevel(String [] definitions). Sie parst die Definitionsstrings und erzeugt zunächst ein zweidimensionales Array, in dem die Levelelemente an den gleichen Positionen wie in den Strings gespeichert werden. Dieses 2D - Array wird hauptsächlich für die Kollisionskontrolle verwendet. Anschließend werden die Refferenzen aller Levelelemente im 2D - Array auch noch in ein 1D - Array kopiert. Dies geschieht ausschließlich aus Effizienzgründen beim Zeichen und Scrollen des Levels.

// Leveldefinitionsstrings parsen und in erzeugten Levelelemente in 2D - Array bzw.
// auch in Kopie 1D - Array speichern
public void initializeLevel(String [] definitions)
{
}

Die Kollisionskontrolle zwischen Player und LevelElementen

Kommen wir nun zum letzten Punkt in diesem Kapitel und gleichzeitig auch dem umfassensten Punkt. Denn alle bisher besprochenen Dinge sind bei der Kollisionskontrolle relevant, vor allem die Bewegungskontrolle des Spielers und die interne Repräsentation des Levels.
Die Idee zur Kollisionskontrolle ist nun folgende: Da das Level intern als zweidimensionales Array repräsentiert wird müssen wir "nur" die Position des Spielers im Array bestimmen (also die x - und y - Koordinaten des Spielers in Arrayzeile und -spalte umrechnen) und sehen, ob sich über, unter, links und rechts des Spielers ein Levelelement befindet. Ist dies der Fall, so müssen wir die Bewegung des Spielers in diese Richtung stoppen, also die Bewegungsflags des Spielers entsprechend verändern. Dazu habe ich mehrere Methoden implementiert. Die Methode testForPlayerCollisions() ist für die Hauptkontrolle der Kollisionskontrolle zuständig, testet also für alle möglichen Fälle (oben, unten, ...) ob eine Kollision stattgefunden hat und entscheidet gegebenenfalls, was in dieser Situation zu tun ist. Denn je nach Element, mit dem unsere Spielfigur da kollidiert ist, muss sich ja etwas tun (Punkte hinzuzählen, Bewegung stoppen...). Des weiteren existieren vier Methoden (testCollisionUp, -Down, ...), die für eine gegebene Position des Spielers einen Lookup in der zweidimensionalen Matrix des Levels machen und gegebenenfalls das Levelelement für die gewünschte Position oder null zurückgeben. Hier kommt schon mal der Sourcecode für die Methode testForPlayerCollisions(Player player) sowie für die Methode testCollisionDown (repräsentativ für die Methoden, die die LevelElemente an der gegebenen Spielerposition zurückgeben).

// Methode testet nach Kollisionen des Spielers mit LevelElementen
public void testForPlayerCollisions(Player player)
{
}

// Methode testet nach Kollisionen unten und liefert gegebenenfalls das Levelelemet zurück
public LevelElement testCollisionDown(int game_pos, int player_y_down)
{
}

Geschafft!

So, damit möchte ich dieses Kapitel beschließen und hoffe, dass ich euch ein wenig weiterhelfen konnte. Mir ist klar, dass dieses Kapitel nicht einfach war und ist (weder für mich zu schreiben noch für euch um es zu verstehen), aber es handelt sich hier halt auch um ein etwas schwierigeres und komplexeres Thema. Außerdem würde ich mich über Feedback zu diesem Kapitel sehr freuen, da ich nicht so gut einschätzen kann, wo noch Schwachpunkte liegen, die man vielleicht noch besser erklären könnte. Schreibt mir doch einfach mal eure Meinung dazu! Ansonsten gibts wie immer den Sourcecode zum runterladen und das Beispielapplet zum ansehen (könnt aber auch genausogut J-Rio spielen, da dieses Spiel zumindest in Grundzügen so implementiert ist, wie ich es gerade vorgestellt habe) und ich wünsche euch viel Spaß bei der Entwicklung eurer eigenen Platformspiele!

Sourcecode runterzuladen
Applet ansehen